From b2cffd588960482a449edb2fed9c3843d5b35214 Mon Sep 17 00:00:00 2001 From: Jackie Ng Date: Mon, 1 Jun 2026 00:23:07 +1000 Subject: [PATCH] Remove immutability-helper, replace its usage with an in-house implementation. --- package.json | 1 - src/reducers/modal.ts | 4 +- src/reducers/toolbar.ts | 4 +- src/utils/immutable.ts | 96 +++++++++++++++++ test/reducers/toolbar.spec.ts | 22 ++++ test/utils/immutable.spec.ts | 190 ++++++++++++++++++++++++++++++++++ yarn.lock | 8 -- 7 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 src/utils/immutable.ts create mode 100644 test/utils/immutable.spec.ts diff --git a/package.json b/package.json index c42adbdb9..41c5192fb 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "colorbrewer": "^1.6.1", "dompurify": "^3.4.0", "geojson-vt": "^4.0.2", - "immutability-helper": "^3.1.1", "jspdf": "^4.0.0", "lodash.debounce": "^4.0.8", "lodash.xor": "^4.5.0", diff --git a/src/reducers/modal.ts b/src/reducers/modal.ts index 9abd43bcd..dfdd5b65b 100644 --- a/src/reducers/modal.ts +++ b/src/reducers/modal.ts @@ -1,7 +1,7 @@ import { ActionType } from '../constants/actions'; import { ViewerAction } from '../actions/defs'; import { DEFAULT_MODAL_POSITION, DEFAULT_MODAL_SIZE, IModalParameters, IModalReducerState } from '../api/common'; -import update from "immutability-helper"; +import { immutableUpdate } from "../utils/immutable"; function tryRestoreModalSizeAndPosition(modal: IModalParameters, prevModal?: Partial>) { if (prevModal?.position) { @@ -43,7 +43,7 @@ export function modalReducer(state = MODAL_INITIAL_STATE, action: ViewerAction): } } } - const newState = update(state, newData); + const newState = immutableUpdate(state, newData); return newState; } case ActionType.MODAL_SHOW_URL: diff --git a/src/reducers/toolbar.ts b/src/reducers/toolbar.ts index 200018c25..bdb369cb5 100644 --- a/src/reducers/toolbar.ts +++ b/src/reducers/toolbar.ts @@ -1,5 +1,5 @@ import { IToolbarReducerState, IDOMElementMetrics } from "../api/common"; -import update from 'immutability-helper'; +import { immutableUpdate } from "../utils/immutable"; import { ActionType } from '../constants/actions'; import { ViewerAction } from '../actions/defs'; import { isElementState } from './template'; @@ -27,7 +27,7 @@ function mergeFlyoutState(flyoutId: string, state: any, flyoutPayload: any, flyo } } } - const newState = update(state, updateSpec); + const newState = immutableUpdate(state, updateSpec); return newState; } diff --git a/src/utils/immutable.ts b/src/utils/immutable.ts new file mode 100644 index 000000000..3a99737b2 --- /dev/null +++ b/src/utils/immutable.ts @@ -0,0 +1,96 @@ +/** + * Checks if a value is a plain object (not null, array, or primitive). + * + * @hidden + * @since 0.15 + */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Checks if a spec object contains a recognized command key ($merge or $set). + * + * @hidden + * @since 0.15 + */ +function isCommandSpec(value: Record): boolean { + return "$merge" in value || "$set" in value; +} + +/** + * Applies an immutable update to state given a command spec, supporting only the + * {@code $merge} and {@code $set} commands (the only ones used by this codebase). + * + * @param state - The source state (never mutated) + * @param spec - The update specification + * @returns A new state object with the spec applied + * + * @example + * ```ts + * // $merge: shallow merge into an existing object + * const s1 = immutableUpdate({ a: 1, b: 2 }, { "$merge": { b: 3, c: 4 } }); + * // s1 → { a: 1, b: 3, c: 4 } + * + * // $set: replace entirely + * const s2 = immutableUpdate({ a: 1 }, { "$set": { x: 10 } }); + * // s2 → { x: 10 } + * ``` + * + * @hidden + * @since 0.15 + */ +export function immutableUpdate(state: any, spec: any): any { + // If spec is not a plain object, return as-is (defensive) + if (!isPlainObject(spec)) { + return state; + } + + // If spec itself is a command, apply it directly + if (isCommandSpec(spec as Record)) { + if ("$set" in (spec as Record)) { + return (spec as Record).$set; + } + if ("$merge" in (spec as Record)) { + const mergeVal = (spec as Record).$merge; + if (isPlainObject(state)) { + return { ...state, ...(mergeVal as Record) }; + } + return mergeVal; + } + return state; + } + + // Otherwise, walk each key in the spec and recurse + const keys = Object.keys(spec); + if (keys.length === 0) { + return state; + } + + let result = state; + for (const key of keys) { + const subSpec = spec[key]; + if (isPlainObject(subSpec) && isCommandSpec(subSpec as Record)) { + // Apply command to result[key] + const prevValue = result != null ? result[key] : undefined; + result = { + ...(result ?? {}), + [key]: immutableUpdate(prevValue, subSpec) + }; + } else if (isPlainObject(subSpec)) { + // Nested keys — recurse into result[key] + const prevValue = result != null ? result[key] : undefined; + result = { + ...(result ?? {}), + [key]: immutableUpdate(prevValue, subSpec) + }; + } else { + // Scalar value — set directly + result = { + ...(result ?? {}), + [key]: subSpec + }; + } + } + return result; +} diff --git a/test/reducers/toolbar.spec.ts b/test/reducers/toolbar.spec.ts index ba730e6f5..a0014502e 100644 --- a/test/reducers/toolbar.spec.ts +++ b/test/reducers/toolbar.spec.ts @@ -744,6 +744,28 @@ describe("reducers/mouse", () => { const newState = toolbarReducer(initialState.toolbar, action); expect(newState).toBe(initialState.toolbar); }); + it("closes other flyouts when opening a new one", () => { + const initialState = createInitialState(); + const stateWithFlyouts = { + ...initialState.toolbar, + flyouts: { + "flyoutA": { open: true, metrics: { posX: 0, posY: 0, width: 100, height: 50 } }, + "flyoutB": { open: false, metrics: null } + } + }; + const action: any = { + type: ActionType.FLYOUT_OPEN, + payload: { + flyoutId: "flyoutB", + metrics: { posX: 50, posY: 25, width: 80, height: 60 } + } + }; + const newState = toolbarReducer(stateWithFlyouts as any, action); + // Target flyout should be open + expect((newState.flyouts["flyoutB"] as any).open).toBe(true); + // Other flyout should be closed + expect((newState.flyouts["flyoutA"] as any).open).toBe(false); + }); }); describe(ActionType.FLYOUT_CLOSE, () => { diff --git a/test/utils/immutable.spec.ts b/test/utils/immutable.spec.ts new file mode 100644 index 000000000..de8524cb5 --- /dev/null +++ b/test/utils/immutable.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { immutableUpdate } from "../../src/utils/immutable"; + +describe("utils/immutable", () => { + describe("immutableUpdate", () => { + it("returns state unchanged for empty spec", () => { + const state = { a: 1, b: 2 }; + const result = immutableUpdate(state, {}); + expect(result).toEqual(state); + }); + + describe("$merge", () => { + it("merges new properties into an object at top level", () => { + const state = { a: 1, b: 2 }; + const result = immutableUpdate(state, { "$merge": { b: 3, c: 4 } }); + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it("does not mutate the original object", () => { + const state = { a: 1, b: 2 }; + immutableUpdate(state, { "$merge": { b: 3 } }); + expect(state).toEqual({ a: 1, b: 2 }); + }); + + it("merges nested one level deep", () => { + const state = { + flyouts: { + "flyoutA": { open: true, metrics: { posX: 0, posY: 0 } } + } + }; + const spec = { + flyouts: { + "flyoutA": { + "$merge": { open: false, metrics: null } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + flyouts: { + "flyoutA": { open: false, metrics: null } + } + }); + }); + + it("handles $merge on nested key that did not previously exist", () => { + const state: any = {}; + const spec = { + flyouts: { + "flyoutB": { + "$merge": { open: true } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + flyouts: { + "flyoutB": { open: true } + } + }); + }); + }); + + describe("$set", () => { + it("replaces the value entirely at top level", () => { + const state = { a: 1, b: 2 }; + const result = immutableUpdate(state, { "$set": { x: 10, y: 20 } }); + expect(result).toEqual({ x: 10, y: 20 }); + }); + + it("does not mutate the original object", () => { + const state = { a: 1 }; + immutableUpdate(state, { "$set": { b: 2 } }); + expect(state).toEqual({ a: 1 }); + }); + + it("replaces nested value entirely", () => { + const state = { + flyouts: { + "flyoutA": { open: true } + } + }; + const spec = { + flyouts: { + "flyoutA": { + "$set": { open: false, metrics: null, componentName: "X" } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + flyouts: { + "flyoutA": { open: false, metrics: null, componentName: "X" } + } + }); + }); + + it("creates the key if it does not exist", () => { + const state: any = {}; + const spec = { + flyouts: { + "newFlyout": { + "$set": { open: true, componentName: "Hello" } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + flyouts: { + "newFlyout": { open: true, componentName: "Hello" } + } + }); + }); + }); + + describe("combined spec (multiple keys)", () => { + it("applies multiple commands to different keys at once", () => { + const state = { + flyouts: { + "flyoutA": { open: true, metrics: { posX: 0, posY: 0 } }, + "flyoutB": { open: false, metrics: null } + } + }; + const spec = { + flyouts: { + "flyoutA": { + "$merge": { open: false } + }, + "flyoutB": { + "$merge": { open: true, metrics: { posX: 50, posY: 25 } } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + flyouts: { + "flyoutA": { open: false, metrics: { posX: 0, posY: 0 } }, + "flyoutB": { open: true, metrics: { posX: 50, posY: 25 } } + } + }); + }); + + it("does not mutate original when applying multiple commands", () => { + const state = { + flyouts: { + "flyoutA": { open: true }, + "flyoutB": { open: false } + } + }; + immutableUpdate(state, { + flyouts: { + "flyoutA": { "$merge": { open: false } }, + "flyoutB": { "$merge": { open: true } } + } + }); + expect(state).toEqual({ + flyouts: { + "flyoutA": { open: true }, + "flyoutB": { open: false } + } + }); + }); + }); + + describe("modal use-case ($merge on modal sub-key)", () => { + it("applies $merge to a nested modal property", () => { + const state = { + "Google": { + modal: { title: "Google", backdrop: true, size: [400, 500], position: [100, 100] }, + name: "Google" + } + }; + const spec = { + "Google": { + modal: { + "$merge": { size: [800, 600], position: [120, 140] } + } + } + }; + const result = immutableUpdate(state, spec); + expect(result).toEqual({ + "Google": { + modal: { title: "Google", backdrop: true, size: [800, 600], position: [120, 140] }, + name: "Google" + } + }); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index f35bb9220..39f07102c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8159,13 +8159,6 @@ __metadata: languageName: node linkType: hard -"immutability-helper@npm:^3.1.1": - version: 3.1.1 - resolution: "immutability-helper@npm:3.1.1" - checksum: 10c0/daf4f3a696b8735c5d2c9b1bac42908b66bfc18ea5484bccf6658f3e622e1486663b5ef781e1a407ee81183e16942e8b2596cc859ea94d522ba07731c2845f0e - languageName: node - linkType: hard - "import-fresh@npm:^3.2.1": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -10222,7 +10215,6 @@ __metadata: docsify-cli: "npm:4.4.4" dompurify: "npm:^3.4.0" geojson-vt: "npm:^4.0.2" - immutability-helper: "npm:^3.1.1" jest-image-snapshot: "npm:6.5.2" jsdom: "npm:^26.1.0" jsonfile: "npm:6.2.1"