executor-fn
Version:
A lightweight function wrapper with optional state, history, undo/redo, and reset support. Use like a normal function or unlock powerful time-travel features.
681 lines (572 loc) • 23.5 kB
JavaScript
// executor.js
import { useSyncExternalStore } from "react";
// Deep clone utility to avoid reference issues in history
function deepClone(value) {
if (value === null || typeof value !== "object") return value;
return Array.isArray(value)
? value.map(deepClone)
: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepClone(v)]));
}
// Main Executor function
export default function Executor(callback, options = {}) {
if (typeof callback !== "function") {
throw new Error("Executor: callback must be a function");
}
// Default options
const {
storeHistory = false,
initialArgs = [],
callNow = false,
metadataFn, // custom metadata for each history entry
maxHistory = Infinity,// limit number of stored entries
noDuplicate = false, // 🆕 prevent duplicates in history
equalityFn, // skip adding follow-up duplicates
onError, // handle errors gracefully
historyStep = 1, // 🆕 only record every Nth snapshot
groupBy // 🆕 group history entries (e.g. "move", "attack")
} = options;
const history = storeHistory ? [] : null;
const redoStack = storeHistory ? [] : null;
const subscribers = new Set();
let historyPaused = false;
let stepCounter = 0; // for historyStep
let initialValue;
let eCounter = 0;
try {
if (callNow) {
initialValue = callback(...initialArgs);
if (storeHistory) {
history.push({
value: deepClone(initialValue),
meta: metadataFn?.(initialValue),
group: groupBy?.(initialValue),
_index: eCounter++, // new insertion index
_time: Date.now() // new timestamp
});
}
}
} catch (err) {
if (onError) onError(err);
else throw err;
}
const notifySubscribers = () => subscribers.forEach(cb => cb());
let entryCounter = 0; // monotonic index
const pushToHistory = (result) => {
if (!storeHistory || historyPaused) return;
// 🔍 noDuplicate checks
if (noDuplicate && equalityFn) {
if (history.some(h => equalityFn(h.value, result))) return;
} else if (noDuplicate) {
if (history.some(h => JSON.stringify(h.value) === JSON.stringify(result))) return;
}
// 🔍 throttle
stepCounter++;
if (stepCounter % historyStep !== 0) return;
// 🔍 skip consecutive duplicates
if (equalityFn && history.length > 0) {
const last = history[history.length - 1].value;
if (equalityFn(result, last)) return;
}
// ✅ push with metadata + timestamp
history.push({
value: deepClone(result),
meta: metadataFn?.(result),
group: groupBy?.(result),
_index: entryCounter++, // new insertion index
_time: Date.now() // new timestamp
});
if (history.length > maxHistory) history.shift();
redoStack.length = 0;
};
// The main executor function
const fn = async (...args) => {
try {
let result = callback(...args);
if (result instanceof Promise) result = await result;
fn.value = result;
pushToHistory(result);
notifySubscribers();
return result;
} catch (err) {
if (onError) onError(err);
else throw err;
}
};
fn.value = initialValue;
fn.initialValue = initialValue;
fn.history = history;
fn.redoStack = redoStack;
fn.log = () => console.log(fn.value);
// Reset to initial value
fn.reset = () => {
fn.value = fn.initialValue;
if (storeHistory) {
history.length = 0;
history.push({
value: deepClone(fn.initialValue),
meta: metadataFn?.(fn.initialValue),
group: groupBy?.(fn.initialValue)
});
redoStack.length = 0;
}
notifySubscribers();
return fn.value;
};
// Undo last change
fn.undo = (steps = 1) => {
if (storeHistory && history.length > 1) {
for (let i = 0; i < steps && history.length > 1; i++) redoStack.push(history.pop());
fn.value = history[history.length - 1].value;
notifySubscribers();
}
return fn.value;
};
// Redo last undone change
fn.redo = (steps = 1) => {
if (storeHistory && redoStack.length > 0) {
for (let i = 0; i < steps && redoStack.length > 0; i++) {
const next = redoStack.pop();
history.push(next);
fn.value = next.value;
}
notifySubscribers();
}
return fn.value;
};
// Remove specific history entry
fn.removeAt = (index) => {
if (storeHistory && index >= 0 && index < history.length) {
history.splice(index, 1);
fn.value = history[history.length - 1]?.value ?? fn.initialValue;
notifySubscribers();
}
return fn.value;
};
// Jump to specific history entry
fn.jumpTo = (index) => {
if (!storeHistory) throw new Error("Executor: jumpTo requires storeHistory = true");
if (index < 0 || index >= history.length) return fn.value;
fn.value = history[index].value;
notifySubscribers();
return fn.value;
};
// Replace specific history entry
fn.replaceAt = (index, newValue) => {
if (!storeHistory) throw new Error("Executor: replaceAt requires storeHistory = true");
if (index < 0 || index >= history.length) return fn.value;
history[index] = {
value: deepClone(newValue),
meta: metadataFn?.(newValue),
group: groupBy?.(newValue)
};
if (index === history.length - 1) fn.value = newValue;
notifySubscribers();
return fn.value;
};
// Insert new history entry at specific position
fn.insertAt = (index, newValue) => {
if (storeHistory && index >= 0 && index <= history.length) {
history.splice(index, 0, {
value: deepClone(newValue),
meta: metadataFn?.(newValue),
group: groupBy?.(newValue)
});
fn.value = newValue;
notifySubscribers();
}
return fn.value;
};
// Clear entire history and reset to current value
fn.clearHistory = () => {
if (storeHistory) {
history.length = 0;
history.push({
value: deepClone(fn.value),
meta: metadataFn?.(fn.value),
group: groupBy?.(fn.value)
});
redoStack.length = 0;
notifySubscribers();
}
return fn.value;
};
// Copy one or more history entries and replace current history
fn.copy = (histories) => {
if (!storeHistory) return fn.value;
// Normalize to array of histories
const allHistories = Array.isArray(histories[0]) ? histories : [histories];
// Flatten + deep clone with duplicate checks
const copied = [];
allHistories.forEach(h => {
if (Array.isArray(h)) {
h.forEach(entry => {
const val = entry.value;
// noDuplicate + equalityFn
if (noDuplicate && equalityFn) {
if (copied.some(e => equalityFn(e.value, val))) return;
} else if (noDuplicate) {
if (copied.some(e => JSON.stringify(e.value) === JSON.stringify(val))) return;
}
copied.push({
value: deepClone(val),
meta: entry.meta,
group: entry.group
});
});
}
});
// Full replace
history.length = 0;
history.push(...copied);
// Trim to maxHistory
if (history.length > maxHistory) {
history.splice(0, history.length - maxHistory);
}
// Reset redoStack
redoStack.length = 0;
// Update fn.value
fn.value = history[history.length - 1]?.value ?? fn.initialValue;
notifySubscribers();
return fn.value;
};
// Merge one or more history entries into current history
fn.merge = (histories, { position = "end" } = {}) => {
if (!storeHistory) return fn.value;
// Normalize to array of histories
const allHistories = Array.isArray(histories[0]) ? histories : [histories];
// Flatten and deep clone with duplicate checks
const merged = [];
allHistories.forEach(h => {
if (Array.isArray(h)) {
h.forEach(entry => {
const val = entry.value;
// noDuplicate + equalityFn
if (noDuplicate && equalityFn) {
if (history.some(e => equalityFn(e.value, val))) return;
if (merged.some(e => equalityFn(e.value, val))) return;
} else if (noDuplicate) {
if (history.some(e => JSON.stringify(e.value) === JSON.stringify(val))) return;
if (merged.some(e => JSON.stringify(e.value) === JSON.stringify(val))) return;
}
merged.push({
value: deepClone(val),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
});
});
}
});
// Insert by position
if (position === "start") {
history.unshift(...merged);
} else if (position === "end") {
history.push(...merged);
} else if (typeof position === "number") {
history.splice(position, 0, ...merged);
}
// Trim to maxHistory
if (history.length > maxHistory) {
history.splice(0, history.length - maxHistory);
}
// Reset redoStack
redoStack.length = 0;
// Update fn.value
fn.value = history[history.length - 1]?.value ?? fn.initialValue;
notifySubscribers();
return fn.value;
};
// Sort history entries accending, descending, or reset to default
fn.sort = (orderOrFn = "default") => {
if (!storeHistory) return fn.value;
let sorted = [...history];
if (orderOrFn === "default") {
// restore insertion order
sorted.sort((a, b) => (a._index ?? 0) - (b._index ?? 0));
}
else if (orderOrFn === "asc") {
sorted.sort((a, b) => {
if (typeof a.value === "number" && typeof b.value === "number") {
return a.value - b.value;
}
return String(a.value).localeCompare(String(b.value));
});
}
else if (orderOrFn === "desc") {
sorted.sort((a, b) => {
if (typeof a.value === "number" && typeof b.value === "number") {
return b.value - a.value;
}
return String(b.value).localeCompare(String(a.value));
});
}
else if (orderOrFn === "groupAsc") {
// 🔥 sort by group label (alphabetical ascending)
sorted.sort((a, b) => String(a.group ?? "").localeCompare(String(b.group ?? "")));
}
else if (orderOrFn === "groupDesc") {
// 🔥 sort by group label (alphabetical descending)
sorted.sort((a, b) => String(b.group ?? "").localeCompare(String(a.group ?? "")));
}
else if (typeof orderOrFn === "function") {
// 🆕 full entry comparator (not just value)
sorted.sort((a, b) => orderOrFn(a, b));
}
// 🔒 Deduplicate after sort
const deduped = [];
sorted.forEach(entry => {
const val = entry.value;
if (noDuplicate && equalityFn) {
if (deduped.some(e => equalityFn(e.value, val))) return;
} else if (noDuplicate) {
if (deduped.some(e => JSON.stringify(e.value) === JSON.stringify(val))) return;
}
deduped.push(entry);
});
// overwrite history with deduped
history.length = 0;
history.push(...deduped);
fn.value = history[history.length - 1]?.value ?? fn.initialValue;
notifySubscribers();
return fn.value;
};
// Split history into multiple Executors by index ranges
fn.split = (...ranges) => {
const result = {};
ranges.forEach((range, i) => {
if (!Array.isArray(range) || range.length === 0) return;
let indices = [];
if (range.length === 2 && typeof range[0] === "number" && typeof range[1] === "number") {
// Treat [start, end] as a range
const [start, end] = range;
const step = start <= end ? 1 : -1;
for (let idx = start; step > 0 ? idx <= end : idx >= end; idx += step) {
indices.push(idx);
}
} else {
// Treat as explicit indices [0, 2, 5]
indices = range;
}
// Collect history entries by indices
const subset = indices
.map(idx => history[idx])
.filter(Boolean)
.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index
}));
// Create a new Executor seeded with this subset
const mini = Executor(() => fn.initialValue, {
storeHistory: true,
callNow: false
});
mini.history = subset;
mini.value = subset[subset.length - 1]?.value ?? fn.initialValue;
result[`ex${i + 1}`] = mini;
});
return result; // { ex1, ex2, ... }
};
// Serialize/Deserialize history for persistence
fn.serializeHistory = () => JSON.stringify(history);
fn.deserializeHistory = (data) => {
if (storeHistory && Array.isArray(data)) {
history.length = 0;
history.push(...data.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
})));
fn.value = history[history.length - 1].value;
redoStack.length = 0;
notifySubscribers();
}
};
// ✅ Export full state safely
fn.exportHistory = () => {
try {
return JSON.stringify({
value: fn.value,
initialValue: fn.initialValue,
history: history.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
})),
redoStack: redoStack.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
}))
});
} catch (e) {
if (onError) onError(e);
else throw e;
}
};
// ✅ Import full state safely
fn.importHistory = (json) => {
try {
const data = JSON.parse(json);
if (Array.isArray(data.history)) {
history.length = 0;
history.push(...data.history.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
})));
}
redoStack.length = 0;
if (Array.isArray(data.redoStack)) {
redoStack.push(...data.redoStack.map(entry => ({
value: deepClone(entry.value),
meta: entry.meta,
group: entry.group,
_index: entry._index,
_time: entry._time
})));
}
fn.value = deepClone(data.value ?? fn.initialValue);
notifySubscribers();
} catch (e) {
if (onError) onError(e);
else throw e;
}
};
// 🆕 Export history to a downloadable JSON file
fn.exportHistoryToFile = (filename = "executor-history.json") => {
const json = fn.exportHistory(); // uses the safe version we wrote
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 🆕 Import history from a user-selected JSON file
fn.importHistoryFromFile = () => {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async (event) => {
const file = event.target.files[0];
if (!file) return reject("No file selected");
try {
const text = await file.text();
fn.importHistory(text); // reuse safe import
resolve(fn.value);
} catch (e) {
if (onError) onError(e);
reject(e);
}
};
input.click();
});
};
// Batch multiple calls into one history entry
fn.batch = (callback) => {
if (!storeHistory) return callback();
historyPaused = true;
const result = callback();
historyPaused = false;
pushToHistory(fn.value);
notifySubscribers();
return result;
};
// Pause/Resume history tracking
fn.pauseHistory = () => { historyPaused = true; };
fn.resumeHistory = () => { historyPaused = false; };
// Subscription management
fn._subscribe = (cb) => subscribers.add(cb);
fn._unsubscribe = (cb) => subscribers.delete(cb);
// Extend filterHistory with common query helpers
fn.filterHistory = (predicateOrOptions) => {
if (!storeHistory) return [];
// Case 1: user passes a function → behave like before
if (typeof predicateOrOptions === "function") {
return history.filter(entry => predicateOrOptions(entry));
}
// Case 2: user passes an options object
const {
group,
meta,
after, // timestamp (ms) or Date
before, // timestamp (ms) or Date
range, // [start, end] timestamps
} = predicateOrOptions || {};
return history.filter(entry => {
// group check
if (group && entry.group !== group) return false;
// meta check (shallow compare)
if (meta && JSON.stringify(entry.meta) !== JSON.stringify(meta)) return false;
// timestamp checks
if (after && entry._time <= (after instanceof Date ? after.getTime() : after)) return false;
if (before && entry._time >= (before instanceof Date ? before.getTime() : before)) return false;
if (range && (
entry._time < (range[0] instanceof Date ? range[0].getTime() : range[0]) ||
entry._time > (range[1] instanceof Date ? range[1].getTime() : range[1])
)) return false;
return true;
});
};
return fn;
}
// 🆕 Combine multiple executors into one group
Executor.combine = (...executors) => {
const group = {};
group.undo = () => executors.map(fn => fn.undo());
group.redo = () => executors.map(fn => fn.redo());
group.reset = () => executors.map(fn => fn.reset());
group.clearHistory = () => executors.map(fn => fn.clearHistory());
group.export = () => executors.map(fn => fn.exportHistory());
group.importAll = (dataArr) => {
if (!Array.isArray(dataArr)) {
throw new Error("ExecutorGroup.importAll expects an array of JSON strings");
}
executors.forEach((fn, i) => {
if (dataArr[i]) fn.importHistory(dataArr[i]);
});
};
return group;
};
// React Hook for auto re-rendering
export function useExecutor(executor) {
if (!executor || typeof executor !== "function") {
throw new Error("useExecutor: must receive a valid Executor instance");
}
useSyncExternalStore(
(subscribe) => {
executor._subscribe(subscribe);
return () => executor._unsubscribe(subscribe);
},
() => executor.value
);
return executor; // 👈 full power (value + methods)
}
// Later we can add performance optimizations for large histories
// Later we can add a way to inspect current subscribers for debugging
// Later we can add a way to filter or transform history entries on the fly
// Later we can add a way to visualize history for better UX
// Later we can add a way to log history changes for auditing
// Later we can add a way to snapshot the entire state of multiple executors
// Later we can add a way to persist history in localStorage or IndexedDB
// Later we can add a way to sync history across multiple tabs or windows
// Later we can add a way to handle circular references in history entries
// Later we can add a way to profile performance of executor calls and history management
// Later we can add a way to handle large data structures efficiently
// Later we can add a way to visualize the call stack leading to each history entry
// Later we can add a way to customize the initial state and behavior of the executor