Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 106 additions & 20 deletions packages/core/src/core/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -410,10 +414,10 @@ export class Mirror<S extends SchemaType> {

// 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<string, unknown> = {};
const defaults = (
this.schema ? getDefaultValue(this.schema) : undefined
Expand All @@ -422,12 +426,6 @@ export class Mirror<S extends SchemaType> {
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 ([], "", {})
Expand Down Expand Up @@ -579,14 +577,18 @@ export class Mirror<S extends SchemaType> {
);
// 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<InferType<S>>((draft) => {
Object.assign(
draft as unknown as Record<string, unknown>,
Expand All @@ -601,8 +603,10 @@ export class Mirror<S extends SchemaType> {
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);
Expand All @@ -612,15 +616,81 @@ export class Mirror<S extends SchemaType> {
// 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;
}

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,
});
}

/**
Expand Down Expand Up @@ -2664,7 +2734,10 @@ export class Mirror<S extends SchemaType> {
}
}

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") {
Expand All @@ -2674,7 +2747,10 @@ export class Mirror<S extends SchemaType> {
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);
Expand All @@ -2691,7 +2767,10 @@ export class Mirror<S extends SchemaType> {
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);
Expand All @@ -2703,6 +2782,9 @@ export class Mirror<S extends SchemaType> {
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
Expand Down Expand Up @@ -2778,6 +2860,7 @@ export class Mirror<S extends SchemaType> {
*/
private buildRootStateSnapshot(
prevState?: Record<string, unknown>,
options: RootSnapshotOptions = {},
): Record<string, unknown> {
if (!this.schema || this.schema.type !== "schema") {
// Fallback to previous normalization if no schema
Expand Down Expand Up @@ -2806,22 +2889,25 @@ export class Mirror<S extends SchemaType> {
);
// 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;
Expand Down
72 changes: 72 additions & 0 deletions packages/core/tests/mirror.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,78 @@ 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<string, unknown>,
options?: { registerContainers?: boolean },
) => Record<string, unknown>;
registerNestedContainers: (container: unknown) => void;
};
const proto = Mirror.prototype as unknown as SnapshotCapableMirror;
const originalBuildRootStateSnapshot = proto.buildRootStateSnapshot;
const originalRegisterNestedContainers = proto.registerNestedContainers;
let snapshotCalls = 0;
let snapshotRegistrationCalls = 0;
let nestedScanCalls = 0;

proto.buildRootStateSnapshot = function (
this: SnapshotCapableMirror,
prevState?: Record<string, unknown>,
options?: { registerContainers?: boolean },
) {
snapshotCalls += 1;
if (options?.registerContainers) {
snapshotRegistrationCalls += 1;
}
return originalBuildRootStateSnapshot.call(
this,
prevState,
options,
);
};
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);
expect(mirror.getContainerIds()).toContain(todo.id);
mirror.dispose();
} finally {
proto.buildRootStateSnapshot = originalBuildRootStateSnapshot;
proto.registerNestedContainers = originalRegisterNestedContainers;
}

expect(snapshotCalls).toBe(1);
expect(snapshotRegistrationCalls).toBe(1);
expect(nestedScanCalls).toBe(0);
});

it("should not write into LoroDoc with initState", async () => {
const someState = {
list: [{}],
Expand Down
Loading