From fb13d586973841c6257185aaa74209ac358c6bd3 Mon Sep 17 00:00:00 2001 From: William Stein Date: Fri, 9 Oct 2026 22:31:43 +0000 Subject: [PATCH 1/3] Immer documents without immutable.js (sideEffects: false) A browser bundle that uses Session with the immer documents carried all of immutable.js: patch-graph.ts used immutable Maps and a List comparison, and db-util.ts imported immutable for one Map.isMap check and two helpers of the immutable documents. - patch-graph.ts: native Maps for patches and children (Map.set returns the map, so the assignments stay as they were); keys/values via the Map iterators; the file-load dedup compares patches element by element as List(a).equals(List(b)) did (Object.is; nested values by identity). - db-util.ts: recognizes an immutable Map by the marker Map.isMap checks, without importing immutable; mergeSet and nonnullCols move to db-document-immutable.ts, their only user. - package.json: "sideEffects": false, so bundlers drop the modules a program does not use (db-document-immutable.ts and immutable.js for immer users). Session + createImmerDbCodec + decodePatchId, minified by Bun for the browser: 167 kB before, 103 kB after. Tests: 157 pass (1 suite skipped, as before); tsc and oxlint clean. --- package.json | 1 + src/db-document-immutable.ts | 21 +++++++++++++++++++-- src/db-util.ts | 30 ++++++++++-------------------- src/patch-graph.ts | 30 +++++++++++++++++------------- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 80e78ec..4730dd4 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Lightweight patch-based directed acyclic graph realtime sync core", "license": "MIT", "type": "module", + "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/sagemathinc/patchflow.git" diff --git a/src/db-document-immutable.ts b/src/db-document-immutable.ts index 23e0e3f..3feb7a5 100644 --- a/src/db-document-immutable.ts +++ b/src/db-document-immutable.ts @@ -18,14 +18,31 @@ import { isArray, isObject, mapMergePatch, - mergeSet, - nonnullCols, toKey, toStr, type JsMap, } from "./db-util"; import type { DocCodec, Document } from "./types"; +// (immutable.js helpers, here so that db-util.ts does not import immutable) +export function mergeSet( + obj: ImMap, + change: ImMap, +): ImMap { + change.forEach((v, k) => { + if (v === null || v == null) { + obj = obj.delete(k); + } else { + obj = obj.set(k, v); + } + }); + return obj; +} + +export function nonnullCols(f: ImMap): ImMap { + return ImMap(f.filter((v) => v !== null)); +} + const DEFAULT_JSONL_SIZE_FACTOR = 1000; type RecordValue = ImMap | undefined; diff --git a/src/db-util.ts b/src/db-util.ts index 78932dc..7d4e47d 100644 --- a/src/db-util.ts +++ b/src/db-util.ts @@ -1,12 +1,20 @@ -import { Map as ImMap } from "immutable"; import jsonstable from "fast-json-stable-stringify"; import deepEqual from "fast-deep-equal/es6"; export { deepEqual }; export type JsMap = Record; +// An immutable.js Map (or OrderedMap), recognized as Map.isMap does, without +// importing immutable: the immer documents never need it. +const IS_IMMUTABLE_MAP = "@@__IMMUTABLE_MAP__@@"; +function isImmutableMap(value: unknown): value is { toJS(): unknown } { + return ( + !!value && typeof value === "object" && !!(value as Record)[IS_IMMUTABLE_MAP] + ); +} + export function toKey(value: unknown): string { - if (ImMap.isMap(value)) { + if (isImmutableMap(value)) { value = value.toJS(); } // must be stable, especially if value contains objects, which is technically allowed @@ -34,24 +42,6 @@ export function mapMergePatch(obj1: JsMap, obj2: JsMap): JsMap { return change; } -export function mergeSet( - obj: ImMap, - change: ImMap, -): ImMap { - change.forEach((v, k) => { - if (v === null || v == null) { - obj = obj.delete(k); - } else { - obj = obj.set(k, v); - } - }); - return obj; -} - -export function nonnullCols(f: ImMap): ImMap { - return ImMap(f.filter((v) => v !== null)); -} - export function isArray(x: unknown): x is unknown[] { return Array.isArray(x); } diff --git a/src/patch-graph.ts b/src/patch-graph.ts index 8aabe32..124e534 100644 --- a/src/patch-graph.ts +++ b/src/patch-graph.ts @@ -1,4 +1,3 @@ -import { List, Map } from "immutable"; import { LRUCache } from "lru-cache"; import { comparePatchId, decodePatchId } from "./patch-id"; import type { @@ -11,7 +10,7 @@ import type { } from "./types"; import { hashString, sameHashFormat } from "./value-hash"; -type PatchMap = Map; +type PatchMap = Map; // (native maps: patch-graph does not need immutable.js) const DEFAULT_DEDUP_TOLERANCE = 3000; const DEFAULT_VALUE_CACHE_MAX_ENTRIES = 100; @@ -49,6 +48,14 @@ function docSize(value: { doc: Document }): number { } // Keep `doc` as the most recent entry of a small insertion-ordered map. +// Element by element, as immutable.js's List(a).equals(List(b)) compared them +// (values with Object.is; nested arrays and objects by identity). +function sameElements(a: unknown[], b: unknown[]): boolean { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false; + return true; +} + function remember(map: globalThis.Map, key: string, doc: Document): void { map.delete(key); map.set(key, doc); @@ -102,8 +109,8 @@ function patchCmp(a: Patch, b: Patch): number { } export class PatchGraph { - private patches: PatchMap = Map(); - private children: Map> = Map>(); + private patches: PatchMap = new Map(); + private children: Map> = new Map>(); private codec: DocCodec; public fileTimeDedupTolerance = DEFAULT_DEDUP_TOLERANCE; private mergeStrategy: MergeStrategy; @@ -506,7 +513,7 @@ export class PatchGraph { } private computeHeads(): string[] { - const allTimes = new Set(this.patches.keySeq().toArray()); + const allTimes = new Set(this.patches.keys()); const parents = new Set(); this.patches.forEach((patch) => { for (const p of patch.parents ?? []) { @@ -598,10 +605,9 @@ export class PatchGraph { versions(opts: { start?: string; end?: string } = {}): string[] { const { start, end } = opts; if (this.versionsCache == null) { - this.versionsCache = this.patches - .toArray() - .map(([, patch]) => patch.time) - .sort(comparePatchId); + this.versionsCache = Array.from(this.patches.values(), (patch) => patch.time).sort( + comparePatchId, + ); } return this.versionsCache.filter((t) => { if (start != null && comparePatchId(t, start) < 0) return false; @@ -1383,7 +1389,7 @@ export class PatchGraph { patch.patch && decodePatchId(patch.time).timeMs - decodePatchId(last.time).timeMs <= this.fileTimeDedupTolerance && - List(patch.patch as unknown[]).equals(List(last.patch as unknown[])) + sameElements(patch.patch as unknown[], last.patch as unknown[]) ); } @@ -1407,9 +1413,7 @@ export class PatchGraph { history(opts: { start?: string; end?: string; includeSnapshots?: boolean } = {}): Patch[] { const { start, end, includeSnapshots = true } = opts; - return this.patches - .toArray() - .map(([, patch]) => patch) + return Array.from(this.patches.values()) .filter((p) => { if (start != null && comparePatchId(p.time, start) < 0) return false; if (end != null && comparePatchId(p.time, end) > 0) return false; From dbca2ce95df081de401743e1be13e94da2db2f50 Mon Sep 17 00:00:00 2001 From: William Stein Date: Fri, 9 Oct 2026 23:09:00 +0000 Subject: [PATCH 2/3] File-load dedup: exactly List.equals's element equality; helpers not exported Review (lite4-review): sameElements used Object.is, but List(a).equals(List(b)) compared elements with immutable's is(): SameValueZero (+0 equals -0), valueOf() (equal Dates, a Date and its time, boxed values) and value objects (equals/hashCode). src/same-elements.ts now ports is() and List()'s view of array-likes (strings included), internal to the package; a test checks it against immutable's List.equals on those cases. mergeSet and nonnullCols are module-private again: moving them into db-document-immutable.ts had made them root exports. --- src/db-document-immutable.ts | 4 +-- src/patch-graph.ts | 11 ++----- src/same-elements.test.ts | 41 +++++++++++++++++++++++++ src/same-elements.ts | 58 ++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 src/same-elements.test.ts create mode 100644 src/same-elements.ts diff --git a/src/db-document-immutable.ts b/src/db-document-immutable.ts index 3feb7a5..2456260 100644 --- a/src/db-document-immutable.ts +++ b/src/db-document-immutable.ts @@ -25,7 +25,7 @@ import { import type { DocCodec, Document } from "./types"; // (immutable.js helpers, here so that db-util.ts does not import immutable) -export function mergeSet( +function mergeSet( obj: ImMap, change: ImMap, ): ImMap { @@ -39,7 +39,7 @@ export function mergeSet( return obj; } -export function nonnullCols(f: ImMap): ImMap { +function nonnullCols(f: ImMap): ImMap { return ImMap(f.filter((v) => v !== null)); } diff --git a/src/patch-graph.ts b/src/patch-graph.ts index 124e534..3f6b571 100644 --- a/src/patch-graph.ts +++ b/src/patch-graph.ts @@ -9,6 +9,7 @@ import type { PatchGraphValueOptions, } from "./types"; import { hashString, sameHashFormat } from "./value-hash"; +import { sameElements } from "./same-elements"; type PatchMap = Map; // (native maps: patch-graph does not need immutable.js) @@ -48,14 +49,6 @@ function docSize(value: { doc: Document }): number { } // Keep `doc` as the most recent entry of a small insertion-ordered map. -// Element by element, as immutable.js's List(a).equals(List(b)) compared them -// (values with Object.is; nested arrays and objects by identity). -function sameElements(a: unknown[], b: unknown[]): boolean { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false; - return true; -} - function remember(map: globalThis.Map, key: string, doc: Document): void { map.delete(key); map.set(key, doc); @@ -1389,7 +1382,7 @@ export class PatchGraph { patch.patch && decodePatchId(patch.time).timeMs - decodePatchId(last.time).timeMs <= this.fileTimeDedupTolerance && - sameElements(patch.patch as unknown[], last.patch as unknown[]) + sameElements(patch.patch, last.patch) ); } diff --git a/src/same-elements.test.ts b/src/same-elements.test.ts new file mode 100644 index 0000000..a210708 --- /dev/null +++ b/src/same-elements.test.ts @@ -0,0 +1,41 @@ +// sameElements must decide exactly as immutable.js's List(a).equals(List(b)), +// which PatchGraph's file-load dedup used before it stopped importing immutable. +import { List, Map as ImMap } from "immutable"; +import { sameElements } from "./same-elements"; + +const listEquals = (a: unknown, b: unknown) => List(a as unknown[]).equals(List(b as unknown[])); + +describe("sameElements agrees with List(a).equals(List(b))", () => { + const shared = [1, 2]; + const cases: [string, unknown[], unknown[]][] = [ + ["equal numbers and strings", [1, "a", [0, "x"]], [1, "a", [0, "x"]]], + ["the same nested array", [shared, 3], [shared, 3]], + ["+0 and -0", [0], [-0]], + ["NaN and NaN", [NaN], [NaN]], + ["equal Dates", [new Date(5)], [new Date(5)]], + ["a Date and its time", [new Date(5)], [5]], + ["different Dates", [new Date(5)], [new Date(6)]], + ["equal immutable Maps (value objects)", [ImMap({ a: 1 })], [ImMap({ a: 1 })]], + ["different immutable Maps", [ImMap({ a: 1 })], [ImMap({ a: 2 })]], + ["boxed and primitive strings", [Object("s")], ["s"]], + ["null and undefined", [null], [undefined]], + ["0 and false", [0], [false]], + ["different lengths", [1, 2], [1]], + ["empty arrays", [], []], + ]; + for (const [name, a, b] of cases) { + it(name, () => { + expect(sameElements(a, b)).toBe(listEquals(a, b)); + }); + } + it("nested arrays with equal contents compare by identity, as List did", () => { + const a = [[1, 2]], + b = [[1, 2]]; + expect(listEquals(a, b)).toBe(false); + expect(sameElements(a, b)).toBe(false); + }); + it("strings compare as List(string) did", () => { + expect(sameElements("abc", "abc")).toBe(listEquals("abc", "abc")); + expect(sameElements("abc", "abd")).toBe(listEquals("abc", "abd")); + }); +}); diff --git a/src/same-elements.ts b/src/same-elements.ts new file mode 100644 index 0000000..50880e7 --- /dev/null +++ b/src/same-elements.ts @@ -0,0 +1,58 @@ +// Element-by-element equality of two patches' contents, exactly as +// immutable.js's List(a).equals(List(b)) decided it, without importing +// immutable (so that the immer documents do not bundle it). Internal: not +// exported from the package root. + +// immutable.js's `is`: SameValueZero, then valueOf() (Dates, boxed values), +// then value objects (anything with equals() and hashCode(), such as +// immutable collections). +export function immutableIs(a: unknown, b: unknown): boolean { + if (a === b || (a !== a && b !== b)) return true; + if (!a || !b) return false; + let x = a as { + valueOf?: () => unknown; + equals?: (o: unknown) => boolean; + hashCode?: () => number; + }; + let y = b as typeof x; + if (typeof x.valueOf === "function" && typeof y.valueOf === "function") { + const va = x.valueOf(); + const vb = y.valueOf(); + if (va === vb || (va !== va && vb !== vb)) return true; + if (!va || !vb) return false; + x = va as typeof x; + y = vb as typeof y; + } + return ( + !!x && + !!y && + typeof x.equals === "function" && + typeof x.hashCode === "function" && + typeof y.equals === "function" && + typeof y.hashCode === "function" && + !!x.equals(y) + ); +} + +// What List() makes of a value: an array's (or array-like's, strings +// included) elements; null for anything else (where List() would throw). +function elements(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value; + if (typeof value === "string") return value.split(""); + if ( + value && + typeof value === "object" && + typeof (value as { length?: unknown }).length === "number" + ) { + return Array.prototype.slice.call(value); + } + return null; +} + +export function sameElements(a: unknown, b: unknown): boolean { + const x = elements(a); + const y = elements(b); + if (x == null || y == null || x.length !== y.length) return false; + for (let i = 0; i < x.length; i++) if (!immutableIs(x[i], y[i])) return false; + return true; +} From a34c1f7fd8644d21be96c6ab330e88e542feb1cb Mon Sep 17 00:00:00 2001 From: William Stein Date: Fri, 9 Oct 2026 23:16:05 +0000 Subject: [PATCH 3/3] File-load dedup: List()'s conversion of iterables and collections too Second review (lite4-review): List(value) also takes iterables and immutable collections (native Sets and Maps, immutable Lists, Sets, Maps, Seqs, records, generators), and List.equals compared their elements; the port only took arrays, strings and array-likes, so a codec whose patches are, e.g., immutable Lists stopped deduping identical file loads (applied twice). elements() now follows List() exactly (immutable 4.3): lists and indexed collections as they are; keyed collections and records by their entries; other collections by toIndexedSeq(); immutable's isArrayLike rule; any iterable (Symbol.iterator or @@iterator). Where List() throws (a value of none of these kinds) the patches are not equal. Tests: every container kind against immutable's List.equals, and a PatchGraph regression with a codec whose patches are immutable Lists: two identical file loads close in time apply once (the previous port applied them twice). --- src/same-elements.test.ts | 136 ++++++++++++++++++++++++++++++-------- src/same-elements.ts | 90 +++++++++++++++++-------- 2 files changed, 173 insertions(+), 53 deletions(-) diff --git a/src/same-elements.test.ts b/src/same-elements.test.ts index a210708..f39240d 100644 --- a/src/same-elements.test.ts +++ b/src/same-elements.test.ts @@ -1,41 +1,125 @@ // sameElements must decide exactly as immutable.js's List(a).equals(List(b)), // which PatchGraph's file-load dedup used before it stopped importing immutable. -import { List, Map as ImMap } from "immutable"; +import { List, Map as ImMap, Set as ImSet, Record, Seq } from "immutable"; import { sameElements } from "./same-elements"; +import { PatchGraph } from "./patch-graph"; +import { legacyPatchId } from "./patch-id"; +import type { DocCodec, Document } from "./types"; const listEquals = (a: unknown, b: unknown) => List(a as unknown[]).equals(List(b as unknown[])); describe("sameElements agrees with List(a).equals(List(b))", () => { const shared = [1, 2]; - const cases: [string, unknown[], unknown[]][] = [ - ["equal numbers and strings", [1, "a", [0, "x"]], [1, "a", [0, "x"]]], - ["the same nested array", [shared, 3], [shared, 3]], - ["+0 and -0", [0], [-0]], - ["NaN and NaN", [NaN], [NaN]], - ["equal Dates", [new Date(5)], [new Date(5)]], - ["a Date and its time", [new Date(5)], [5]], - ["different Dates", [new Date(5)], [new Date(6)]], - ["equal immutable Maps (value objects)", [ImMap({ a: 1 })], [ImMap({ a: 1 })]], - ["different immutable Maps", [ImMap({ a: 1 })], [ImMap({ a: 2 })]], - ["boxed and primitive strings", [Object("s")], ["s"]], - ["null and undefined", [null], [undefined]], - ["0 and false", [0], [false]], - ["different lengths", [1, 2], [1]], - ["empty arrays", [], []], + const Point = Record({ x: 0, y: 0 }); + function* gen(...xs: unknown[]) { + yield* xs; + } + // [name, () => [a, b]]: fresh values each call (generators are consumed) + const cases: [string, () => [unknown, unknown]][] = [ + // elements + [ + "equal numbers and strings", + () => [ + [1, "a", [0, "x"]], + [1, "a", [0, "x"]], + ], + ], + [ + "the same nested array", + () => [ + [shared, 3], + [shared, 3], + ], + ], + ["nested arrays with equal contents (by identity)", () => [[[1, 2]], [[1, 2]]]], + ["+0 and -0", () => [[0], [-0]]], + ["NaN and NaN", () => [[NaN], [NaN]]], + ["equal Dates", () => [[new Date(5)], [new Date(5)]]], + ["a Date and its time", () => [[new Date(5)], [5]]], + ["different Dates", () => [[new Date(5)], [new Date(6)]]], + ["equal immutable Maps (value objects)", () => [[ImMap({ a: 1 })], [ImMap({ a: 1 })]]], + ["different immutable Maps", () => [[ImMap({ a: 1 })], [ImMap({ a: 2 })]]], + ["boxed and primitive strings", () => [[Object("s")], ["s"]]], + ["null and undefined", () => [[null], [undefined]]], + ["0 and false", () => [[0], [false]]], + ["different lengths", () => [[1, 2], [1]]], + ["empty arrays", () => [[], []]], + // outer containers: what List() makes of them + ["strings", () => ["abc", "abc"]], + ["different strings", () => ["abc", "abd"]], + ["null and undefined patches", () => [null, undefined]], + ["array-like objects", () => [{ length: 2, 0: "a", 1: "b" }, ["a", "b"]]], + ["{length: 0}", () => [{ length: 0 }, []]], + ["native Sets", () => [new Set([1, 2]), new Set([1, 2])]], + ["a native Set and an array", () => [new Set([1, 2]), [1, 2]]], + ["native Maps (entries compare by identity)", () => [new Map([[1, 2]]), new Map([[1, 2]])]], + ["immutable Lists", () => [List([1, 2]), List([1, 2])]], + ["different immutable Lists", () => [List([1, 2]), List([2, 1])]], + ["immutable Sets", () => [ImSet([1, 2]), ImSet([1, 2])]], + ["immutable Maps (entries)", () => [ImMap({ a: 1 }), ImMap({ a: 1 })]], + ["indexed Seqs", () => [Seq([1, 2]), Seq([1, 2])]], + ["records", () => [Point({ x: 1 }), Point({ x: 1 })]], + ["generators", () => [gen(1, 2), gen(1, 2)]], + ["different generators", () => [gen(1, 2), gen(1, 3)]], ]; - for (const [name, a, b] of cases) { + for (const [name, make] of cases) { it(name, () => { - expect(sameElements(a, b)).toBe(listEquals(a, b)); + expect(sameElements(...make())).toBe(listEquals(...make())); }); } - it("nested arrays with equal contents compare by identity, as List did", () => { - const a = [[1, 2]], - b = [[1, 2]]; - expect(listEquals(a, b)).toBe(false); - expect(sameElements(a, b)).toBe(false); + it("a value List() cannot take is not equal (where List() throws)", () => { + expect(() => listEquals({ a: 1 }, { a: 1 })).toThrow(TypeError); + expect(sameElements({ a: 1 }, { a: 1 })).toBe(false); }); - it("strings compare as List(string) did", () => { - expect(sameElements("abc", "abc")).toBe(listEquals("abc", "abc")); - expect(sameElements("abc", "abd")).toBe(listEquals("abc", "abd")); +}); + +// A custom codec whose patches are immutable Lists of numbers to add: two +// identical file loads close in time must apply once (as before, when +// PatchGraph compared them with List.equals). +class Counter implements Document { + constructor(readonly n: number) {} + applyPatch(patch: unknown): Counter { + return new Counter(this.n + List(patch as number[]).reduce((s: number, x: number) => s + x, 0)); + } + applyPatchBatch(patches: unknown[]): Counter { + return patches.reduce((d: Counter, p) => d.applyPatch(p), this); + } + makePatch(other: Document): unknown { + return List([(other as Counter).n - this.n]); + } + isEqual(other?: Document): boolean { + return other instanceof Counter && other.n === this.n; + } + toString(): string { + return String(this.n); + } + set(value: unknown): Counter { + return new Counter(Number(value)); + } + get(): unknown { + return this.n; + } + count(): number { + return 1; + } +} +const counterCodec: DocCodec = { + fromString: (text) => new Counter(Number(text || 0)), + toString: (doc) => doc.toString(), + applyPatch: (doc, patch) => doc.applyPatch(patch), + applyPatchBatch: (doc, patches) => doc.applyPatchBatch(patches), + makePatch: (a, b) => a.makePatch(b), +}; + +describe("file-load dedup with a codec whose patches are immutable Lists", () => { + it("two identical file loads close in time apply once", () => { + const graph = new PatchGraph({ codec: counterCodec }); + const t1 = legacyPatchId(1); + const t2 = legacyPatchId(2); + graph.add([ + { time: t1, patch: List([1]), parents: [], file: true }, + { time: t2, patch: List([1]), parents: [], file: true }, + ]); + expect(graph.value().toString()).toBe("1"); }); }); diff --git a/src/same-elements.ts b/src/same-elements.ts index 50880e7..ab81fa6 100644 --- a/src/same-elements.ts +++ b/src/same-elements.ts @@ -1,7 +1,9 @@ // Element-by-element equality of two patches' contents, exactly as -// immutable.js's List(a).equals(List(b)) decided it, without importing -// immutable (so that the immer documents do not bundle it). Internal: not -// exported from the package root. +// immutable.js's List(a).equals(List(b)) decided it (immutable 4.3), without +// importing immutable (so that the immer documents do not bundle it). +// Internal: not exported from the package root. + +type Any = Record; // eslint-disable-line @typescript-eslint/no-explicit-any // immutable.js's `is`: SameValueZero, then valueOf() (Dates, boxed values), // then value objects (anything with equals() and hashCode(), such as @@ -9,23 +11,15 @@ export function immutableIs(a: unknown, b: unknown): boolean { if (a === b || (a !== a && b !== b)) return true; if (!a || !b) return false; - let x = a as { - valueOf?: () => unknown; - equals?: (o: unknown) => boolean; - hashCode?: () => number; - }; - let y = b as typeof x; + let x = a as Any; + let y = b as Any; if (typeof x.valueOf === "function" && typeof y.valueOf === "function") { - const va = x.valueOf(); - const vb = y.valueOf(); - if (va === vb || (va !== va && vb !== vb)) return true; - if (!va || !vb) return false; - x = va as typeof x; - y = vb as typeof y; + x = x.valueOf(); + y = y.valueOf(); + if (x === y || (x !== x && y !== y)) return true; + if (!x || !y) return false; } return ( - !!x && - !!y && typeof x.equals === "function" && typeof x.hashCode === "function" && typeof y.equals === "function" && @@ -34,17 +28,59 @@ export function immutableIs(a: unknown, b: unknown): boolean { ); } -// What List() makes of a value: an array's (or array-like's, strings -// included) elements; null for anything else (where List() would throw). +// immutable.js's markers (what isList, isIndexed, ... check) +const has = (value: unknown, marker: string) => !!(value && (value as Any)[marker]); +const LIST = "@@__IMMUTABLE_LIST__@@"; +const INDEXED = "@@__IMMUTABLE_INDEXED__@@"; +const COLLECTION = "@@__IMMUTABLE_ITERABLE__@@"; +const KEYED = "@@__IMMUTABLE_KEYED__@@"; +const RECORD = "@@__IMMUTABLE_RECORD__@@"; + +// immutable.js's isArrayLike +function isArrayLike(value: unknown): boolean { + if (Array.isArray(value) || typeof value === "string") return true; + const v = value as Any; + return ( + !!v && + typeof v === "object" && + Number.isInteger(v.length) && + v.length >= 0 && + (v.length === 0 + ? Object.keys(v).length === 1 + : Object.prototype.hasOwnProperty.call(v, v.length - 1)) + ); +} + +function iteratorFn(value: unknown): (() => Iterator) | undefined { + const v = value as Any; + const f = v && ((typeof Symbol === "function" && v[Symbol.iterator]) || v["@@iterator"]); + return typeof f === "function" ? f : undefined; +} + +// The elements of List(value), in order: List() itself for immutable lists +// and indexed collections; a keyed collection's (or record's) entries; +// another collection's values; an array-like's indexed values (a string's +// UTF-16 units); an iterable's values. null where List() throws (a value of +// none of these kinds): such patches are not equal. function elements(value: unknown): unknown[] | null { - if (Array.isArray(value)) return value; - if (typeof value === "string") return value.split(""); - if ( - value && - typeof value === "object" && - typeof (value as { length?: unknown }).length === "number" - ) { - return Array.prototype.slice.call(value); + if (value === undefined || value === null) return []; + const v = value as Any; + if (has(v, LIST) || has(v, INDEXED)) return v.toArray(); + if (has(v, COLLECTION)) + return has(v, KEYED) ? v.entrySeq().toArray() : v.toIndexedSeq().toArray(); + if (has(v, RECORD)) return v.toSeq().entrySeq().toArray(); + if (isArrayLike(v)) { + const out: unknown[] = []; + for (let i = 0; i < v.length; i++) out.push(v[i]); + return out; + } + const f = iteratorFn(v); + if (f) { + const it = f.call(v); + const out: unknown[] = []; + if (!it || typeof it.next !== "function") return out; + for (let step = it.next(); !step.done; step = it.next()) out.push(step.value); + return out; } return null; }