Migrate update step changes to diff (#14663)
This PR should fix optimistic rendering issues on step updates: - compute a diff for trigger and steps on mutations - build an util that applies that diff (built a more robust version from https://github.com/AsyncBanana/micropatch) - apply diff in cache
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { isNumber, isObject, isString } from '@sniptt/guards';
|
||||
import { type Difference } from 'microdiff';
|
||||
|
||||
type ObjectType = Record<string, unknown>;
|
||||
type ArrayType = unknown[];
|
||||
type MutableData = ObjectType | ArrayType;
|
||||
|
||||
type ArrayDeletionFn = () => void;
|
||||
|
||||
// Symbol used to mark array elements for deletion
|
||||
const REMOVE_SYMBOL = Symbol('micropatch-delete');
|
||||
|
||||
const FORBIDDEN_OBJECT_KEYS = ['__proto__', 'constructor', 'prototype'];
|
||||
|
||||
export const applyDiff = <T>(obj: T, diffs: Difference[]): T => {
|
||||
if (!isDefined(obj)) {
|
||||
throw new Error('Cannot apply diff to null or undefined object');
|
||||
}
|
||||
|
||||
if (!Array.isArray(diffs)) {
|
||||
throw new Error('Diffs must be an array');
|
||||
}
|
||||
|
||||
// Create a deep copy to avoid modifying read-only/frozen objects
|
||||
const mutableObj = deepClone(obj) as T;
|
||||
const arrayDeletionQueue: ArrayDeletionFn[] = [];
|
||||
|
||||
for (const diff of diffs) {
|
||||
if (!diff || !diff.path || diff.path.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
applyDiffToPath(mutableObj as MutableData, diff, arrayDeletionQueue);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to apply diff at path ${diff.path.join('.')}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
arrayDeletionQueue.forEach((deletionFn) => deletionFn());
|
||||
|
||||
return mutableObj;
|
||||
};
|
||||
|
||||
const applyDiffToPath = (
|
||||
obj: MutableData,
|
||||
diff: Difference,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
) => {
|
||||
const { path, type } = diff;
|
||||
const value = 'value' in diff ? diff.value : undefined;
|
||||
const pathLength = path.length;
|
||||
const lastPathElement = path[pathLength - 1];
|
||||
|
||||
const parentContainer = navigateToParent(obj, path);
|
||||
|
||||
switch (type) {
|
||||
case 'CREATE':
|
||||
case 'CHANGE':
|
||||
setValueAtPath(parentContainer, lastPathElement, value);
|
||||
break;
|
||||
|
||||
case 'REMOVE':
|
||||
handleRemoval(obj, path, parentContainer, lastPathElement, arrayDeletionQueue);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported diff type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToParent = (obj: MutableData, path: (string | number)[]): MutableData => {
|
||||
let current = obj;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const pathElement = path[i];
|
||||
|
||||
if (current === null || current === undefined) {
|
||||
throw new Error(`Cannot traverse path: found null/undefined at element ${i}`);
|
||||
}
|
||||
|
||||
if (isNumber(pathElement) && !Array.isArray(current)) {
|
||||
throw new Error(`Expected array at path element ${i}, got ${typeof current}`);
|
||||
}
|
||||
|
||||
if (isString(pathElement) && Array.isArray(current)) {
|
||||
throw new Error(`Expected object at path element ${i}, got array`);
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
current = current[pathElement as number] as MutableData;
|
||||
} else {
|
||||
current = (current as ObjectType)[pathElement as string] as MutableData;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const setValueAtPath = (
|
||||
container: MutableData,
|
||||
pathElement: string | number,
|
||||
value: unknown
|
||||
): void => {
|
||||
if (Array.isArray(container)) {
|
||||
if (!isNumber(pathElement)) {
|
||||
throw new Error(`Expected numeric index for array, got ${typeof pathElement}`);
|
||||
}
|
||||
|
||||
try {
|
||||
container[pathElement] = value;
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`);
|
||||
}
|
||||
} else if (isObject(container)) {
|
||||
if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) {
|
||||
throw new Error(`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`);
|
||||
}
|
||||
|
||||
try {
|
||||
container[pathElement] = value;
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected object or array, got ${typeof container}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoval = (
|
||||
rootObj: MutableData,
|
||||
fullPath: (string | number)[],
|
||||
parentContainer: MutableData,
|
||||
lastPathElement: string | number,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
): void => {
|
||||
if (Array.isArray(parentContainer)) {
|
||||
handleArrayRemoval(rootObj, fullPath, parentContainer, lastPathElement, arrayDeletionQueue);
|
||||
} else {
|
||||
handleObjectRemoval(parentContainer as ObjectType, lastPathElement);
|
||||
}
|
||||
};
|
||||
|
||||
// Handles removal from arrays by marking for deletion and queuing cleanup
|
||||
const handleArrayRemoval = (
|
||||
rootObj: MutableData,
|
||||
fullPath: (string | number)[],
|
||||
parentArray: ArrayType,
|
||||
index: string | number,
|
||||
arrayDeletionQueue: ArrayDeletionFn[]
|
||||
): void => {
|
||||
if (typeof index !== 'number') {
|
||||
throw new Error(`Expected numeric index for array removal, got ${typeof index}`);
|
||||
}
|
||||
|
||||
parentArray[index] = REMOVE_SYMBOL;
|
||||
|
||||
arrayDeletionQueue.push(() => {
|
||||
if (fullPath.length === 1) {
|
||||
if (Array.isArray(rootObj)) {
|
||||
filterRemoveSymbols(rootObj);
|
||||
}
|
||||
} else {
|
||||
filterRemoveSymbols(parentArray);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleObjectRemoval = (
|
||||
parentObject: ObjectType,
|
||||
key: string | number
|
||||
): void => {
|
||||
if (FORBIDDEN_OBJECT_KEYS.includes(key as string)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete parentObject[key];
|
||||
};
|
||||
|
||||
const filterRemoveSymbols = (array: ArrayType): void => {
|
||||
const indicesToRemove: number[] = [];
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (array[i] === REMOVE_SYMBOL) {
|
||||
indicesToRemove.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
|
||||
array.splice(indicesToRemove[i], 1);
|
||||
}
|
||||
};
|
||||
|
||||
const deepClone = (obj: MutableData): MutableData => {
|
||||
if (obj === null || !isObject(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof structuredClone !== 'undefined') {
|
||||
try {
|
||||
return structuredClone(obj) as MutableData;
|
||||
} catch {
|
||||
return deepCloneJson(obj);
|
||||
}
|
||||
}
|
||||
|
||||
return deepCloneJson(obj);
|
||||
};
|
||||
|
||||
const deepCloneJson = (obj: MutableData): MutableData => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj)) as MutableData;
|
||||
} catch {
|
||||
throw new Error('Failed to clone object');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user