Skip to content

Commit

Permalink
applyPatchesを自前実装に置き換える (VOICEVOX#2052)
Browse files Browse the repository at this point in the history
* Revert "applyPatchesをimmerのものを利用するように変更 (VOICEVOX#362)"

This reverts commit c4f96ff.

* npm install

* implement applyPatches

* drop rfc6902

* doc

* ignore type error

* fix typo

* fix error by structuredClone

* refactor

* support Map and Set

* add note comment

* support Map/Set on clone

* assert equals on clone

* support original classes

* shrink changes

* use internal applyPatches for testing

* simplify tests

* remove tests for function

* add tests for patches

* add tests for Map

* add tests for error

* format

* add test for remove from array

* add test for "-" path

* add tests for error

* Revert "use internal applyPatches for testing"

This reverts commit 7bedef3.

* rename immerPatch into immerPatchUtility

* use splice on remove from array

* fix add operation into array

* refactor tests

* add test for insert element into array

* drop support for userClass

* drop support for un-cloneable value

* 微調整

---------

Co-authored-by: Hiroshiba Kazuyuki <[email protected]>
  • Loading branch information
White-Green and Hiroshiba authored Jul 7, 2024
1 parent 5ef5d65 commit 1f39897
Show file tree
Hide file tree
Showing 3 changed files with 673 additions and 14 deletions.
18 changes: 4 additions & 14 deletions src/store/command.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { toRaw } from "vue";
import { enablePatches, enableMapSet, Immer } from "immer";
// immerの内部関数であるgetPlugin("Patches").applyPatches_はexportされていないので
// ビルド前のsrcからソースコードを読み込んで使う必要がある
import { enablePatches as enablePatchesImpl } from "immer/src/plugins/patches";
import { enableMapSet as enableMapSetImpl } from "immer/src/plugins/mapset";
import { getPlugin } from "immer/src/utils/plugins";

import { Command, CommandStoreState, CommandStoreTypes, State } from "./type";
import { applyPatches } from "@/store/immerPatchUtility";
import {
createPartialStore,
Mutation,
Expand All @@ -15,14 +11,8 @@ import {
} from "@/store/vuex";
import { EditorType } from "@/type/preload";

// ビルド後のモジュールとビルド前のモジュールは別のスコープで変数を持っているので
// enable * も両方叩く必要がある。
enablePatches();
enableMapSet();
enablePatchesImpl();
enableMapSetImpl();
// immerのPatchをmutableに適応する内部関数
const applyPatchesImpl = getPlugin("Patches").applyPatches_;

const immer = new Immer();
immer.setAutoFreeze(false);
Expand Down Expand Up @@ -60,7 +50,7 @@ export const createCommandMutation =
): Mutation<S, M, K> =>
(state: S, payload: M[K]): void => {
const command = recordPatches(payloadRecipe)(state, payload);
applyPatchesImpl(state, command.redoPatches);
applyPatches(state, command.redoPatches);
state.undoCommands[editor].push(command);
state.redoCommands[editor].splice(0);
};
Expand Down Expand Up @@ -112,7 +102,7 @@ export const commandStore = createPartialStore<CommandStoreTypes>({
const command = state.undoCommands[editor].pop();
if (command != null) {
state.redoCommands[editor].push(command);
applyPatchesImpl(state, command.undoPatches);
applyPatches(state, command.undoPatches);
}
},
action({ commit, dispatch }, { editor }: { editor: EditorType }) {
Expand All @@ -130,7 +120,7 @@ export const commandStore = createPartialStore<CommandStoreTypes>({
const command = state.redoCommands[editor].pop();
if (command != null) {
state.undoCommands[editor].push(command);
applyPatchesImpl(state, command.redoPatches);
applyPatches(state, command.redoPatches);
}
},
action({ commit, dispatch }, { editor }: { editor: EditorType }) {
Expand Down
144 changes: 144 additions & 0 deletions src/store/immerPatchUtility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Patch } from "immer";
import { ExhaustiveError } from "@/type/utility";

/**
* produceWithPatchesにより生成された複数のパッチをオブジェクトに適用する。
*
* @param {T} target パッチを適用する対象オブジェクト
* @param {Patch[]} patches 適用するパッチの配列
* @template T 対象オブジェクトの型(任意)
*/
export function applyPatches<T>(target: T, patches: Patch[]) {
for (const patch of patches) {
applyPatch(target, patch);
}
}

function isObject(value: unknown): value is object {
return typeof value === "object" && value != null;
}

// 値を再帰的に複製する。
// ユーザー定義クラスのインスタンスや関数など、複製しない値で例外を発生させる。
function clone<T>(value: T): T {
// function等、単純にcloneできない値の取り扱いを禁止する
if (!isObject(value)) return structuredClone(value);

if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype)
throw new Error("unsupported type");
return value.map((v) => clone(v)) as T;
}

if (value instanceof Map) {
if (Object.getPrototypeOf(value) !== Map.prototype)
throw new Error("unsupported type");
const result = new Map();
for (const [k, v] of value.entries()) {
result.set(clone(k), clone(v));
}
return result as T;
}

if (value instanceof Set) {
if (Object.getPrototypeOf(value) !== Set.prototype)
throw new Error("unsupported type");
const result = new Set();
for (const v of value.values()) {
result.add(clone(v));
}
return result as T;
}

// object以外の自前classは現状利用していないため不具合予防として一旦禁止
// 適切な動作テストの後制限を緩めることを妨げるものではない
if (Object.getPrototypeOf(value) !== Object.prototype)
throw new Error("unsupported type");

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = Object.create(Object.getPrototypeOf(value));
for (const [k, v] of Object.entries(value)) {
result[k] = clone(v);
}
return result as T;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function get(value: unknown, key: string | number): any {
if (value instanceof Map) {
return value.get(key);
}
// @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う
return value[key];
}

function add(value: unknown, key: string | number, v: unknown): void {
if (value instanceof Map) {
value.set(key, v);
} else if (value instanceof Set) {
value.add(v);
} else if (Array.isArray(value)) {
if (typeof key === "number") {
value.splice(key, 0, v);
} else if (key === "-") {
value.push(v);
} else {
throw new Error("unsupported key");
}
} else {
// @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う
value[key] = v;
}
}

function replace(value: unknown, key: string | number, v: unknown): void {
if (value instanceof Map) {
value.set(key, v);
} else if (value instanceof Set) {
value.add(v);
} else {
// @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う
value[key] = v;
}
}

function remove(value: unknown, key: string | number, v: unknown): void {
if (value instanceof Map) {
value.delete(key);
} else if (value instanceof Set) {
value.delete(v);
} else if (Array.isArray(value) && typeof key === "number") {
value.splice(key, 1);
} else {
// @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う
delete value[key];
}
}

/**
* produceWithPatchesにより生成された単一のパッチをオブジェクトに適用する。
*
* @param {T} target パッチを適用する対象オブジェクト
* @param {Patch} patch 適用するパッチ
* @template T 対象オブジェクトの型(任意)
*/
function applyPatch<T>(target: T, patch: Patch) {
const { path, value, op } = patch;
for (const p of patch.path.slice(0, path.length - 1)) {
target = get(target, p);
}
const v = clone(value);
switch (op) {
case "add":
add(target, path[path.length - 1], v);
break;
case "replace":
replace(target, path[path.length - 1], v);
break;
case "remove":
remove(target, path[path.length - 1], v);
break;
default:
throw new ExhaustiveError(op);
}
}
Loading

0 comments on commit 1f39897

Please sign in to comment.