From 71303050db4bceecdcee318ea69678620c0db6a7 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 22 Jun 2026 12:43:59 +0800 Subject: [PATCH 1/2] fix(core): avoid double mirror startup traversal --- packages/core/src/core/mirror.ts | 126 ++++++++++++++++++++++++----- packages/core/tests/mirror.test.ts | 60 ++++++++++++++ 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 8f6ddbc..af4602e 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -78,6 +78,10 @@ interface MirrorStateObject { [k: string]: MirrorState; } +type RootSnapshotOptions = { + registerContainers?: boolean; +}; + /** * Values allowed for root-level keys of `initialState`. LoroDoc only stores * container types at the root, so primitives (bare `number`/`boolean`) are @@ -410,10 +414,10 @@ export class Mirror { // Initialize in-memory state without writing to LoroDoc: // 1) Start from schema defaults (if any) - // 2) Overlay current LoroDoc snapshot (normalized) - // 3) Fill any missing top-level keys hinted by initialState with a normalized empty shape + // 2) Fill any missing top-level keys hinted by initialState with a normalized empty shape // (arrays -> [], strings -> '', objects -> {}), but do NOT override existing values // from the doc/defaults. This keeps doc pristine while providing a predictable state shape. + // 3) After container registration, initializeContainers overlays the current LoroDoc snapshot. const baseState: Record = {}; const defaults = ( this.schema ? getDefaultValue(this.schema) : undefined @@ -422,12 +426,6 @@ export class Mirror { Object.assign(baseState, defaults); } - // Overlay the current doc snapshot so real data takes precedence over defaults - const docSnapshot = this.buildRootStateSnapshot(); - if (docSnapshot && typeof docSnapshot === "object") { - Object.assign(baseState, docSnapshot); - } - // Merge initialState with awareness of schema: // - Respect Ignore fields by keeping their values in memory only // - For container fields, fill missing base keys with normalized empties ([], "", {}) @@ -579,14 +577,18 @@ export class Mirror { ); // Record canonical root path for this root container id this.rootPathById.set(container.id, [key]); - this.registerContainerHandle(container, fieldSchema); + this.registerContainerHandle(container, fieldSchema, { + scanNested: false, + }); } } } } // Build initial state snapshot from the current document - const currentDocState = this.buildRootStateSnapshot(); + const currentDocState = this.buildRootStateSnapshot(undefined, { + registerContainers: true, + }); const newState = produce>((draft) => { Object.assign( draft as unknown as Record, @@ -601,8 +603,10 @@ export class Mirror { private registerContainerHandle( container: Container, schemaType: ContainerSchemaType | undefined, + options: { scanNested?: boolean } = {}, ) { const containerId = container.id; + const shouldScanNested = options.scanNested !== false; // If already registered, optionally upgrade schema const existing = this.containerRegistry.get(containerId); @@ -612,7 +616,9 @@ export class Mirror { // Schema was missing on initial registration (e.g. from // ensureRootContainersFromInitialState), so nested // containers were never scanned. Do it now. - this.registerNestedContainers(container); + if (shouldScanNested) { + this.registerNestedContainers(container); + } } return; } @@ -620,7 +626,71 @@ export class Mirror { this.registerContainerWithRegistry(containerId, schemaType); // Register nested containers - this.registerNestedContainers(container); + if (shouldScanNested) { + this.registerNestedContainers(container); + } + } + + private registerSnapshotChildContainer( + parent: Container, + child: Container, + childKey?: string | number, + ) { + const parentSchema = this.getContainerSchema(parent.id); + const parentLocalInfer = this.inferOptionsByContainerId.get(parent.id); + let nestedSchema: ContainerSchemaType | undefined; + + if (parent.kind() === "Map") { + if ( + parentSchema && + isLoroMapSchema(parentSchema) && + typeof childKey === "string" + ) { + const candidate = getMapFieldSchema(parentSchema, childKey); + if (candidate?.type === "any") { + this.inferOptionsByContainerId.set( + child.id, + this.getInferOptionsForChild(parent.id, candidate), + ); + } + if (candidate && isContainerSchema(candidate)) { + nestedSchema = candidate; + } + } + } else if ( + parent.kind() === "List" || + parent.kind() === "MovableList" + ) { + if ( + parentSchema && + (isLoroListSchema(parentSchema) || + isLoroMovableListSchema(parentSchema)) + ) { + const itemSchema = parentSchema.itemSchema; + if (itemSchema?.type === "any") { + this.inferOptionsByContainerId.set( + child.id, + this.getInferOptionsForChild(parent.id, itemSchema), + ); + } + if (isContainerSchema(itemSchema)) { + nestedSchema = itemSchema; + } + } + } + + if ( + !parentSchema && + !nestedSchema && + parentLocalInfer && + !this.inferOptionsByContainerId.has(child.id) + ) { + this.inferOptionsByContainerId.set(child.id, parentLocalInfer); + } + + this.registerContainerHandle(child, nestedSchema, { + scanNested: false, + }); } /** @@ -2664,7 +2734,10 @@ export class Mirror { } } - private containerToMirrorState(c: Container): MirrorState { + private containerToMirrorState( + c: Container, + options: RootSnapshotOptions = {}, + ): MirrorState { const kind = c.kind(); const schema = this.getContainerSchema(c.id); if (kind === "Map") { @@ -2674,7 +2747,10 @@ export class Mirror { for (const k of m.keys()) { const v = m.get(k); if (isContainer(v)) { - obj[k] = this.containerToMirrorState(v); + if (options.registerContainers) { + this.registerSnapshotChildContainer(c, v, k); + } + obj[k] = this.containerToMirrorState(v, options); } else { // Decode primitive values using field schema const fieldSchema = getChildSchema(schema, k); @@ -2691,7 +2767,10 @@ export class Mirror { for (let i = 0; i < len; i++) { const v = l.get(i); if (isContainer(v)) { - arr.push(this.containerToMirrorState(v)); + if (options.registerContainers) { + this.registerSnapshotChildContainer(c, v, i); + } + arr.push(this.containerToMirrorState(v, options)); } else { // Decode primitive items using item schema arr.push(applyDecode(itemSchema, v) as MirrorState); @@ -2703,6 +2782,9 @@ export class Mirror { return (c as LoroText).toJSON(); } else if (kind === "Tree") { const t = c as LoroTree; + if (options.registerContainers) { + this.registerNestedContainers(c); + } // Normalize via toJSON first const normalized = normalizeTreeJsonForMirror(t.toJSON()); // Optionally inject $cid per node.data using an id->cid map from live nodes @@ -2778,6 +2860,7 @@ export class Mirror { */ private buildRootStateSnapshot( prevState?: Record, + options: RootSnapshotOptions = {}, ): Record { if (!this.schema || this.schema.type !== "schema") { // Fallback to previous normalization if no schema @@ -2806,22 +2889,25 @@ export class Mirror { ); // Always include maps to expose $cid for stable identity if (containerType === "Map") { - root[key] = this.containerToMirrorState(container); + root[key] = this.containerToMirrorState(container, options); } else if ( containerType === "List" || containerType === "MovableList" ) { // Always include lists, even if empty, to match Mirror's state shape - root[key] = this.containerToMirrorState(container); + root[key] = this.containerToMirrorState(container, options); } else if (containerType === "Text") { // Always include text, even if empty, to match Mirror's state shape - root[key] = this.containerToMirrorState(container); + root[key] = this.containerToMirrorState(container, options); } else if (containerType === "Tree") { - const arr = this.containerToMirrorState(container) as unknown[]; + const arr = this.containerToMirrorState( + container, + options, + ) as unknown[]; if (!Array.isArray(arr) || arr.length === 0) continue; root[key] = arr; } else { - root[key] = this.containerToMirrorState(container); + root[key] = this.containerToMirrorState(container, options); } } return root; diff --git a/packages/core/tests/mirror.test.ts b/packages/core/tests/mirror.test.ts index fac2c87..d8d6e2c 100644 --- a/packages/core/tests/mirror.test.ts +++ b/packages/core/tests/mirror.test.ts @@ -1006,6 +1006,66 @@ describe("Mirror - State Consistency", () => { }); }); + it("builds the root doc snapshot once during construction", () => { + const todosSchema = schema({ + todos: schema.LoroList( + schema.LoroMap({ + id: schema.String(), + text: schema.String(), + }), + ), + }); + + const todos = doc.getList("todos"); + const todo = todos.insertContainer(0, new LoroMap()); + todo.set("id", "1"); + todo.set("text", "already in doc"); + doc.commit(); + + type SnapshotCapableMirror = { + buildRootStateSnapshot: ( + prevState?: Record, + ) => Record; + registerNestedContainers: (container: unknown) => void; + }; + const proto = Mirror.prototype as unknown as SnapshotCapableMirror; + const originalBuildRootStateSnapshot = proto.buildRootStateSnapshot; + const originalRegisterNestedContainers = proto.registerNestedContainers; + let snapshotCalls = 0; + let nestedScanCalls = 0; + + proto.buildRootStateSnapshot = function ( + this: SnapshotCapableMirror, + prevState?: Record, + ) { + snapshotCalls += 1; + return originalBuildRootStateSnapshot.call(this, prevState); + }; + proto.registerNestedContainers = function ( + this: SnapshotCapableMirror, + container: unknown, + ) { + nestedScanCalls += 1; + return originalRegisterNestedContainers.call(this, container); + }; + + try { + const mirror = new Mirror({ + doc, + schema: todosSchema, + initialState: { todos: [] }, + }); + expect(mirror.getState().todos).toHaveLength(1); + mirror.dispose(); + } finally { + proto.buildRootStateSnapshot = originalBuildRootStateSnapshot; + proto.registerNestedContainers = originalRegisterNestedContainers; + } + + expect(snapshotCalls).toBe(1); + expect(nestedScanCalls).toBe(0); + }); + it("should not write into LoroDoc with initState", async () => { const someState = { list: [{}], From a99dfa7cbbf60448844d42a4d0a2ef9513193a52 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 22 Jun 2026 16:55:07 +0800 Subject: [PATCH 2/2] test(core): cover startup snapshot registration Model: GPT-5 --- packages/core/tests/mirror.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/mirror.test.ts b/packages/core/tests/mirror.test.ts index d8d6e2c..13aae79 100644 --- a/packages/core/tests/mirror.test.ts +++ b/packages/core/tests/mirror.test.ts @@ -1025,6 +1025,7 @@ describe("Mirror - State Consistency", () => { type SnapshotCapableMirror = { buildRootStateSnapshot: ( prevState?: Record, + options?: { registerContainers?: boolean }, ) => Record; registerNestedContainers: (container: unknown) => void; }; @@ -1032,14 +1033,23 @@ describe("Mirror - State Consistency", () => { const originalBuildRootStateSnapshot = proto.buildRootStateSnapshot; const originalRegisterNestedContainers = proto.registerNestedContainers; let snapshotCalls = 0; + let snapshotRegistrationCalls = 0; let nestedScanCalls = 0; proto.buildRootStateSnapshot = function ( this: SnapshotCapableMirror, prevState?: Record, + options?: { registerContainers?: boolean }, ) { snapshotCalls += 1; - return originalBuildRootStateSnapshot.call(this, prevState); + if (options?.registerContainers) { + snapshotRegistrationCalls += 1; + } + return originalBuildRootStateSnapshot.call( + this, + prevState, + options, + ); }; proto.registerNestedContainers = function ( this: SnapshotCapableMirror, @@ -1056,6 +1066,7 @@ describe("Mirror - State Consistency", () => { initialState: { todos: [] }, }); expect(mirror.getState().todos).toHaveLength(1); + expect(mirror.getContainerIds()).toContain(todo.id); mirror.dispose(); } finally { proto.buildRootStateSnapshot = originalBuildRootStateSnapshot; @@ -1063,6 +1074,7 @@ describe("Mirror - State Consistency", () => { } expect(snapshotCalls).toBe(1); + expect(snapshotRegistrationCalls).toBe(1); expect(nestedScanCalls).toBe(0); });