Skip to content

Commit 99bc365

Browse files
authored
fix(core): avoid writes on initState and normalize shape (#20)
* fix(core): avoid writes on initState and normalize shape * fix(core): allow missing IDs in list diff for withCid lists
1 parent dc59fa8 commit 99bc365

File tree

5 files changed

+203
-14
lines changed

5 files changed

+203
-14
lines changed

packages/core/src/core/diff.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -680,17 +680,16 @@ export function diffListWithIdSelector<S extends ArrayLike>(
680680
const id = idSelector(item);
681681
if (id) {
682682
oldItemsById.set(id, { item, index });
683-
} else {
684-
throw new Error("Item ID cannot be null");
685683
}
686684
}
687685

686+
// Note: Items in the NEW state may legitimately be missing an ID when
687+
// using withCid; IDs are stamped during apply. Treat them as new inserts
688+
// later instead of throwing here.
688689
for (const [newIndex, item] of newState.entries()) {
689690
const id = idSelector(item);
690691
if (id) {
691692
newItemsById.set(id, { item, newIndex });
692-
} else {
693-
throw new Error("Item ID cannot be null");
694693
}
695694
}
696695

packages/core/src/core/mirror.ts

Lines changed: 171 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,49 @@ export class Mirror<S extends SchemaType> {
292292
inferOptions: options.inferOptions || {},
293293
};
294294

295-
// Initialize state with defaults and initial state
296-
this.state = {
297-
...(this.schema ? getDefaultValue(this.schema) : {}),
298-
...this.options.initialState,
299-
} as InferType<S>;
295+
// Pre-create root containers hinted by initialState (no-op in Loro for roots)
296+
// so that doc.toJSON() reflects empty shapes and matches normalized state.
297+
this.ensureRootContainersFromInitialState();
298+
299+
// Initialize in-memory state without writing to LoroDoc:
300+
// 1) Start from schema defaults (if any)
301+
// 2) Overlay current LoroDoc snapshot (normalized)
302+
// 3) Fill any missing top-level keys hinted by initialState with a normalized empty shape
303+
// (arrays -> [], strings -> '', objects -> {}), but do NOT override existing values
304+
// from the doc/defaults. This keeps doc pristine while providing a predictable state shape.
305+
const baseState: Record<string, unknown> = {};
306+
const defaults = (this.schema ? getDefaultValue(this.schema) : undefined) as
307+
| Record<string, unknown>
308+
| undefined;
309+
if (defaults && typeof defaults === "object") {
310+
Object.assign(baseState, defaults);
311+
}
312+
313+
// Overlay the current doc snapshot so real data takes precedence over defaults
314+
const docSnapshot = this.buildRootStateSnapshot();
315+
if (docSnapshot && typeof docSnapshot === "object") {
316+
Object.assign(baseState, docSnapshot as Record<string, unknown>);
317+
}
318+
319+
// Merge initialState with awareness of schema:
320+
// - Respect Ignore fields by keeping their values in memory only
321+
// - For container fields, fill missing base keys with normalized empties ([], "", {})
322+
// - For primitives, use provided initial values if doc/defaults do not provide them
323+
const initForMerge = (this.options.initialState || {}) as Record<string, unknown>;
324+
if (this.schema && (this.schema as any).type === "schema") {
325+
mergeInitialIntoBaseWithSchema(
326+
baseState,
327+
initForMerge,
328+
this.schema as RootSchemaType<Record<string, ContainerSchemaType>>,
329+
);
330+
} else {
331+
const hinted = normalizeInitialShapeShallow(initForMerge);
332+
for (const [k, v] of Object.entries(hinted)) {
333+
if (!(k in baseState)) baseState[k] = v;
334+
}
335+
}
336+
337+
this.state = baseState as InferType<S>;
300338

301339
// Initialize Loro containers and setup subscriptions
302340
this.initializeContainers();
@@ -305,6 +343,29 @@ export class Mirror<S extends SchemaType> {
305343
this.subscriptions.push(this.doc.subscribe(this.handleLoroEvent));
306344
}
307345

346+
/**
347+
* Ensure root containers exist for keys hinted by initialState.
348+
* Creating root containers is a no-op in Loro (no operations are recorded),
349+
* but it makes them visible in doc JSON, staying consistent with Mirror state.
350+
*/
351+
private ensureRootContainersFromInitialState() {
352+
const init = (this.options?.initialState || {}) as Record<string, unknown>;
353+
for (const [key, value] of Object.entries(init)) {
354+
let container: Container | null = null;
355+
if (Array.isArray(value)) {
356+
container = this.doc.getList(key);
357+
} else if (typeof value === "string") {
358+
container = this.doc.getText(key);
359+
} else if (isObject(value)) {
360+
container = this.doc.getMap(key);
361+
}
362+
if (container) {
363+
this.rootPathById.set(container.id, [key]);
364+
this.registerContainerWithRegistry(container.id, undefined);
365+
}
366+
}
367+
}
368+
308369
/**
309370
* Initialize containers based on schema
310371
*/
@@ -762,7 +823,11 @@ export class Mirror<S extends SchemaType> {
762823
if (key === "") {
763824
continue; // Skip empty key
764825
}
765-
826+
// If schema marks this key as Ignore, skip writing to Loro
827+
const fieldSchema = this.getSchemaForChild(container.id, key);
828+
if (fieldSchema && (fieldSchema as any).type === "ignore") {
829+
continue;
830+
}
766831
if (kind === "insert") {
767832
map.set(key as string, value);
768833
} else if (kind === "insert-container") {
@@ -1583,6 +1648,10 @@ export class Mirror<S extends SchemaType> {
15831648
// Check if this field should be a container according to schema
15841649
if (schema && schema.type === "loro-map" && schema.definition) {
15851650
const fieldSchema = schema.definition[key];
1651+
if (fieldSchema && (fieldSchema as any).type === "ignore") {
1652+
// Skip ignore fields: they live only in mirrored state
1653+
return;
1654+
}
15861655
if (fieldSchema && isContainerSchema(fieldSchema)) {
15871656
const ct = schemaToContainerType(fieldSchema);
15881657
if (ct && isValueOfContainerType(ct, value)) {
@@ -1896,6 +1965,30 @@ export function toNormalizedJson(doc: LoroDoc) {
18961965
});
18971966
}
18981967

1968+
// Normalize a shallow object shape from provided initialState by converting
1969+
// container-like primitives to empty shapes without carrying data:
1970+
// - arrays -> []
1971+
// - strings -> ''
1972+
// - plain objects -> {}
1973+
// Other primitive types are passed through (number, boolean, null/undefined).
1974+
function normalizeInitialShapeShallow(
1975+
input: Record<string, unknown>,
1976+
): Record<string, unknown> {
1977+
const out: Record<string, unknown> = {};
1978+
for (const [key, value] of Object.entries(input)) {
1979+
if (Array.isArray(value)) {
1980+
out[key] = [];
1981+
} else if (typeof value === "string") {
1982+
out[key] = "";
1983+
} else if (isObject(value)) {
1984+
out[key] = {};
1985+
} else {
1986+
out[key] = value;
1987+
}
1988+
}
1989+
return out;
1990+
}
1991+
18991992
// Normalize LoroTree JSON (with `meta`) to Mirror tree node shape `{ id, data, children }`.
19001993
function normalizeTreeJson(input: any[]): any[] {
19011994
if (!Array.isArray(input)) return [];
@@ -1912,3 +2005,75 @@ function normalizeTreeJson(input: any[]): any[] {
19122005
};
19132006
return input.map(mapNode);
19142007
}
2008+
2009+
// Deep merge initialState into a base state with awareness of the provided root schema.
2010+
// - Does not override values already present in base (doc/defaults take precedence)
2011+
// - For Ignore fields, copies values verbatim into in-memory state only
2012+
// - For container fields, fills missing keys with normalized empty shape when initialState hints at presence
2013+
// - For primitive fields, uses initial values if base lacks them
2014+
function mergeInitialIntoBaseWithSchema(
2015+
base: Record<string, unknown>,
2016+
init: Record<string, unknown>,
2017+
rootSchema: RootSchemaType<Record<string, ContainerSchemaType>>,
2018+
) {
2019+
for (const [k, initVal] of Object.entries(init)) {
2020+
const fieldSchema = rootSchema.definition[k];
2021+
if (!fieldSchema) {
2022+
// Unknown field at root: hint shape only
2023+
if (!(k in base)) {
2024+
if (Array.isArray(initVal)) base[k] = [];
2025+
else if (typeof initVal === "string") base[k] = "";
2026+
else if (isObject(initVal)) base[k] = {};
2027+
}
2028+
continue;
2029+
}
2030+
2031+
const t = (fieldSchema as any).type as string;
2032+
if (t === "ignore") {
2033+
base[k] = initVal;
2034+
continue;
2035+
}
2036+
if (t === "loro-map") {
2037+
// Ensure object
2038+
if (!(k in base) || !isObject(base[k])) base[k] = {};
2039+
const nestedBase = base[k] as Record<string, unknown>;
2040+
const nestedInit = isObject(initVal)
2041+
? (initVal as Record<string, unknown>)
2042+
: {};
2043+
const nestedSchema = fieldSchema as unknown as LoroMapSchema<
2044+
Record<string, any>
2045+
>; // actual types are not used at runtime
2046+
// Recurse
2047+
mergeInitialIntoBaseWithSchema(
2048+
nestedBase,
2049+
nestedInit,
2050+
({
2051+
type: "schema",
2052+
definition: nestedSchema.definition as Record<
2053+
string,
2054+
ContainerSchemaType
2055+
>,
2056+
options: {},
2057+
getContainerType() {
2058+
return "Map";
2059+
},
2060+
} as unknown) as RootSchemaType<
2061+
Record<string, ContainerSchemaType>
2062+
>,
2063+
);
2064+
continue;
2065+
}
2066+
if (t === "loro-list" || t === "loro-movable-list") {
2067+
if (!(k in base)) base[k] = [];
2068+
continue;
2069+
}
2070+
if (t === "loro-text") {
2071+
if (!(k in base)) base[k] = "";
2072+
continue;
2073+
}
2074+
if (t === "string" || t === "number" || t === "boolean") {
2075+
if (!(k in base)) base[k] = initVal;
2076+
continue;
2077+
}
2078+
}
2079+
}

packages/core/tests/core/mirror.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ describe("Mirror - State Consistency", () => {
850850
const doc = new LoroDoc();
851851
doc.getText("text").insert(0, "hello");
852852
doc.getList("list").push(0);
853-
const mirror = new Mirror({
853+
const _mirror = new Mirror({
854854
doc,
855855
schema: schema({
856856
list: schema.LoroList(schema.LoroMap({})),
@@ -866,4 +866,25 @@ describe("Mirror - State Consistency", () => {
866866
text: "hello",
867867
});
868868
});
869+
870+
it("should not write into LoroDoc with initState", async () => {
871+
const someState = {
872+
list: [{}],
873+
text: "some string",
874+
};
875+
876+
const doc = new LoroDoc();
877+
const mirror = new Mirror({
878+
doc,
879+
initialState: someState,
880+
});
881+
882+
await waitForSync();
883+
expect(doc.toJSON()).toStrictEqual({
884+
list: [],
885+
text: "",
886+
});
887+
expect(doc.frontiers().length).toBe(0);
888+
expect(doc.toJSON()).toStrictEqual(mirror.getState());
889+
});
869890
});

packages/jotai/tests/index.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ describe('loro-mirror-jotai', () => {
2323
const testAtom = loroMirrorAtom({
2424
doc,
2525
schema: testSchema,
26-
initialState: { text: 'hello' },
26+
initialState: { text: '' },
2727
});
2828

2929
const { result } = renderHook(() => useAtom(testAtom));
30-
expect(result.current[0]).toEqual({ text: 'hello' });
30+
expect(result.current[0]).toEqual({ text: '' });
3131
});
3232

3333
it('should update loro doc when atom state changes', async () => {

packages/jotai/tests/readme.jotai.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ describe("Jotai README example", () => {
2323
});
2424

2525
const doc = new LoroDoc();
26-
const atom = loroMirrorAtom({ doc, schema: todoSchema, initialState: { todos: [] } });
26+
const atom = loroMirrorAtom({
27+
doc,
28+
schema: todoSchema,
29+
initialState: { todos: [] },
30+
});
2731

2832
const { result } = renderHook(() => useAtom(atom));
2933

0 commit comments

Comments
 (0)