Skip to content

Commit

Permalink
Merge pull request #1 from legalscape/feature/optimized-array-patches-2
Browse files Browse the repository at this point in the history
feature: optimized array patch
  • Loading branch information
shqld authored Sep 25, 2024
2 parents 0932dba + 12e00f2 commit 40f9975
Show file tree
Hide file tree
Showing 8 changed files with 1,956 additions and 504 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@legalscape/mutative",
"version": "1.0.8",
"version": "1.0.8-ls.55ad523",
"description": "A JavaScript library for efficient immutable updates",
"main": "dist/index.js",
"module": "dist/mutative.esm.js",
Expand Down
83 changes: 83 additions & 0 deletions src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,73 @@ import { generatePatches } from './patch';

const draftsCache = new WeakSet<object>();

function proxiedSplice(
this: Array<unknown>,
...args: Parameters<(typeof Array)['prototype']['splice']>
) {
const [start, deleteCount, ...items] = args;

const draft = getProxyDraft(this)!;

draft.arrayChanges ??= [];

const addCount = items.length;

for (let i = addCount; i < deleteCount; i++) {
if (this.length > start + i) {
draft.arrayChanges.push(['removed', start]);
}
}

for (let i = deleteCount; i < addCount; i++) {
draft.arrayChanges.push(['added', start + i]);
}

return Array.prototype.splice.call(this, ...args);
}

function proxiedPush(this: ProxyDraft, ...items: any[]) {
const draft = getProxyDraft(this)!;

draft.arrayChanges ??= [];

for (let i = 0; i < items.length; i++) {
draft.arrayChanges.push(['added', (this as any).length + i]);
}

return Array.prototype.push.call(this, ...items);
}

function proxiedPop(this: ProxyDraft) {
const draft = getProxyDraft(this)!;

draft.arrayChanges ??= [];
draft.arrayChanges.push(['removed', (this as any).length - 1]);

return Array.prototype.pop.call(this);
}

function proxiedUnshift(this: ProxyDraft, ...items: any[]) {
const draft = getProxyDraft(this)!;

draft.arrayChanges ??= [];

for (let i = 0; i < items.length; i++) {
draft.arrayChanges.push(['added', i]);
}

return Array.prototype.unshift.call(this, ...items);
}

function proxiedShift(this: ProxyDraft) {
const draft = getProxyDraft(this)!;

draft.arrayChanges ??= [];
draft.arrayChanges.push(['removed', 0]);

return Array.prototype.shift.call(this);
}

const proxyHandler: ProxyHandler<ProxyDraft> = {
get(target: ProxyDraft, key: string | number | symbol, receiver: any) {
const copy = target.copy?.[key];
Expand Down Expand Up @@ -86,6 +153,21 @@ const proxyHandler: ProxyHandler<ProxyDraft> = {
}
}

if (target.options.enablePatches) {
switch (source[key]) {
case Array.prototype.push:
return proxiedPush;
case Array.prototype.pop:
return proxiedPop;
case Array.prototype.unshift:
return proxiedUnshift;
case Array.prototype.shift:
return proxiedShift;
case Array.prototype.splice:
return proxiedSplice;
}
}

if (!has(source, key)) {
const desc = getDescriptor(source, key);
return desc
Expand Down Expand Up @@ -125,6 +207,7 @@ const proxyHandler: ProxyHandler<ProxyDraft> = {
return value;
},
set(target: ProxyDraft, key: string | number | symbol, value: any) {
// TODO: もしvalueが既存のcopyに含まれているなら、Moveが使えそう
if (target.type === DraftType.Set || target.type === DraftType.Map) {
throw new Error(
`Map/Set draft does not support any property assignment.`
Expand Down
1 change: 1 addition & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ProxyDraft<T = any> {
setMap?: Map<any, ProxyDraft>;
assignedMap?: Map<any, boolean>;
callbacks?: ((patches?: Patches, inversePatches?: Patches) => void)[];
arrayChanges?: Array<['removed' | 'added', number]>;
}

interface IPatch {
Expand Down
168 changes: 121 additions & 47 deletions src/patch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { DraftType, Operation, Patches, ProxyDraft } from './interface';
import { cloneIfNeeded, escapePath, get, has, isEqual } from './utils';
import {
cloneIfNeeded,
escapePath,
get,
getProxyDraft,
getValue,
has,
isEqual,
} from './utils';

const REMOVED = Symbol('REMOVED');
const ADDED = Symbol('ADDED');

function generateArrayPatches(
proxyState: ProxyDraft<Array<any>>,
Expand All @@ -8,64 +19,127 @@ function generateArrayPatches(
inversePatches: Patches,
pathAsArray: boolean
) {
let { original, assignedMap, options } = proxyState;
let { original, arrayChanges } = proxyState;

// console.log('generateArrayPatches', proxyState.key);

let copy = proxyState.copy!;
if (copy.length < original.length) {
[original, copy] = [copy, original];
[patches, inversePatches] = [inversePatches, patches];

// console.log('original', original);
// console.log('copy', copy.map(getValue));

if (arrayChanges) {
const changedOriginal = original.slice();
const changedCopy = copy.slice();

arrayChanges.sort(([, a], [, b]) => a - b);
// console.log('arrayChanges', arrayChanges);

let removedOffset = 0;
let addedOffset = 0;

for (const [op, index] of arrayChanges) {
switch (op) {
case 'removed':
changedCopy.splice(index + addedOffset, 0, REMOVED);
removedOffset += 1;
break;
case 'added':
changedOriginal.splice(index + removedOffset, 0, ADDED);
addedOffset += 1;
break;
}
}

original = changedOriginal;
copy = changedCopy;
}

// console.log('original', original);
// console.log('copy', copy.map(getValue));

let removedOffset = 0;
let addedOffset = 0;
for (let index = 0; index < original.length; index += 1) {
if (assignedMap!.get(index.toString()) && copy[index] !== original[index]) {
const _path = basePath.concat([index]);
const path = escapePath(_path, pathAsArray);
patches.push({
op: Operation.Replace,
path,
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(copy[index]),
});
inversePatches.push({
op: Operation.Replace,
path,
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(original[index]),
});
if (getValue(copy[index]) !== original[index]) {
// console.log('index', index);
// console.log('removedOffset', removedOffset);
if (copy[index] === REMOVED && original[index] === ADDED) {
removedOffset += 1;
addedOffset += 1;
} else if (copy[index] === REMOVED) {
patches.push({
op: Operation.Remove,
path: escapePath(
basePath.concat([index - removedOffset]),
pathAsArray
),
});
inversePatches.push({
op: Operation.Add,
path: escapePath(basePath.concat([index - addedOffset]), pathAsArray),
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(original[index]),
});
removedOffset += 1;
} else if (original[index] === ADDED) {
patches.push({
op: Operation.Add,
path: escapePath(
basePath.concat([index - removedOffset]),
pathAsArray
),
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(copy[index]),
});
inversePatches.push({
op: Operation.Remove,
path: escapePath(basePath.concat([index - addedOffset]), pathAsArray),
});
addedOffset += 1;
} else {
const item = getProxyDraft(copy[index]);

if (item && !item.operated && isEqual(original[index], item.original)) {
// eslint-disable-next-line no-continue
continue;
}

patches.push({
op: Operation.Replace,
path: escapePath(
basePath.concat([index - removedOffset]),
pathAsArray
),
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(copy[index]),
});
inversePatches.push({
op: Operation.Replace,
path: escapePath(basePath.concat([index - addedOffset]), pathAsArray),
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(original[index]),
});
}
}
}

// !case: support for insertion into an index that exceeds the length of the array
for (let index = original.length; index < copy.length; index += 1) {
const _path = basePath.concat([index]);
const path = escapePath(_path, pathAsArray);
patches.push({
op: Operation.Add,
path,
path: escapePath(basePath.concat([index]), pathAsArray),
// If it is a draft, it needs to be deep cloned, and it may also be non-draft.
value: cloneIfNeeded(copy[index]),
});
inversePatches.push({
op: Operation.Remove,
path: escapePath(basePath.concat([original.length]), pathAsArray),
});
}
if (original.length < copy.length) {
// https://www.rfc-editor.org/rfc/rfc6902#appendix-A.4
// For performance, here we only generate an operation that replaces the length of the array,
// which is inconsistent with JSON Patch specification
const { arrayLengthAssignment = true } = options.enablePatches;
if (arrayLengthAssignment) {
const _path = basePath.concat(['length']);
const path = escapePath(_path, pathAsArray);
inversePatches.push({
op: Operation.Replace,
path,
value: original.length,
});
} else {
for (let index = copy.length; original.length < index; index -= 1) {
const _path = basePath.concat([index - 1]);
const path = escapePath(_path, pathAsArray);
inversePatches.push({
op: Operation.Remove,
path,
});
}
}
}

// console.log('patches', [...patches]);
// console.log('inversePatches', [...inversePatches]);
}

function generatePatchesFromAssigned(
Expand Down
Loading

0 comments on commit 40f9975

Please sign in to comment.