Skip to content

Commit 7a521bc

Browse files
lodystage[bot]zxch3nclaude
authored
fix(core): reject root-level primitives in initialState (#83)
* fix(core): reject root-level primitives in initialState LoroDoc only stores container types (Map/List/MovableList/Text/Tree) at the document root, but Mirror previously accepted root-level primitives (e.g. `{ version: 0 }`) in initialState and silently kept them in memory while never writing them to the doc. This caused `mirror.getState()` to drift from `doc.toJSON()`. Now reject primitives both at compile time (via a `RootInitialValue` constraint on `initialState`) and at runtime (clear error pointing users to wrap the value in a root LoroMap). `null`/`undefined` are still accepted as "absent". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(jotai,react): apply root primitive constraint to wrapper configs The jotai LoroMirrorAtomConfig and react UseLoroStoreOptions redeclare initialState with a looser type than MirrorOptions, which broke the build after the core constraint was tightened. Mirror the RootInitialValue intersection in both wrappers and re-export the type from loro-mirror so consumers can reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: ignore .claude/ local agent state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Zixuan Chen <me@zxch3n.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2cfc25 commit 7a521bc

7 files changed

Lines changed: 135 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ dist
33
# Ignore TS incremental build info everywhere in the repo
44
*.tsbuildinfo
55
.DS_Store
6+
.claude/

packages/core/src/core/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export type {
88
SetStateOptions,
99
SubscriberCallback,
1010
UpdateMetadata,
11-
InferContainerOptions
11+
InferContainerOptions,
12+
RootInitialValue
1213
} from "./mirror.js";

packages/core/src/core/mirror.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ interface MirrorStateObject {
7878
[k: string]: MirrorState;
7979
}
8080

81+
/**
82+
* Values allowed for root-level keys of `initialState`. LoroDoc only stores
83+
* container types at the root, so primitives (bare `number`/`boolean`) are
84+
* disallowed. A root-level `string` is permitted because it maps to a root
85+
* `LoroText` container.
86+
*/
87+
export type RootInitialValue =
88+
| string
89+
| ReadonlyArray<unknown>
90+
| { readonly [key: string]: unknown }
91+
| null
92+
| undefined;
93+
8194
function hasKeyProp(c: Change): c is Extract<Change, { key: string | number }> {
8295
return (c as { key?: unknown }).key !== undefined;
8396
}
@@ -117,9 +130,16 @@ export interface MirrorOptions<S extends SchemaType> {
117130
schema?: S;
118131

119132
/**
120-
* Initial state (optional)
133+
* Initial state (optional).
134+
*
135+
* LoroDoc can only hold container values at the root (Map, List,
136+
* MovableList, Text, Tree), so root entries must be container-shaped:
137+
* objects, arrays, strings, or `null`/`undefined`. Bare numbers and
138+
* booleans are rejected at the type level (and at runtime).
121139
*/
122-
initialState?: Partial<import("../schema/index.js").InferInputType<S>>;
140+
initialState?: Partial<import("../schema/index.js").InferInputType<S>> & {
141+
[key: string]: RootInitialValue;
142+
};
123143

124144
/**
125145
* Whether to validate state updates against the schema
@@ -473,7 +493,22 @@ export class Mirror<S extends SchemaType> {
473493
? schemaToContainerType(fieldSchema)
474494
: undefined) ??
475495
this.inferRootContainerTypeFromInitialValue(value);
476-
if (!containerType) continue;
496+
if (!containerType) {
497+
// null/undefined are treated as "absent" and silently skipped.
498+
if (value == null) continue;
499+
// LoroDoc cannot store primitives at the document root — only
500+
// containers (Map, List, MovableList, Text, Tree). Silently
501+
// dropping the value would cause Mirror state to drift from
502+
// doc state on the next sync, so fail loudly instead.
503+
const fieldType = fieldSchema?.type;
504+
const observed = Array.isArray(value) ? "array" : typeof value;
505+
const detail = fieldType
506+
? `schema type "${fieldType}"`
507+
: `value of type "${observed}"`;
508+
throw new Error(
509+
`initialState["${key}"] is a primitive (${detail}), but LoroDoc only supports container types (Map, List, MovableList, Text, Tree) at the root. Wrap it under a root LoroMap (e.g. a "meta" map).`,
510+
);
511+
}
477512

478513
const container = getRootContainerByType(
479514
this.doc,

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
type UpdateMetadata,
1414
type SubscriberCallback,
1515
type InferContainerOptions,
16+
type RootInitialValue,
1617
UpdateSource,
1718
} from "./core/index.js";
1819

packages/core/tests/mirror.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,83 @@ describe("Mirror - State Consistency", () => {
10271027
expect(doc.toJSON()).toStrictEqual(mirror.getState());
10281028
});
10291029

1030+
it("throws when initialState contains a primitive at the root (no schema)", () => {
1031+
const doc = new LoroDoc();
1032+
expect(
1033+
() =>
1034+
new Mirror({
1035+
doc,
1036+
initialState: { version: 0, notes: [] } as any,
1037+
}),
1038+
).toThrow(/initialState\["version"\].*root/i);
1039+
});
1040+
1041+
it("rejects root-level primitives at the type level", () => {
1042+
// Type-only assertions; the function body is never executed.
1043+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1044+
const _typeChecks = () => {
1045+
const doc = new LoroDoc();
1046+
// @ts-expect-error - bare number at root must be a TS error
1047+
new Mirror({ doc, initialState: { version: 0 } });
1048+
// @ts-expect-error - bare boolean at root must be a TS error
1049+
new Mirror({ doc, initialState: { active: true } });
1050+
// Allowed: containers (object/array/string), null, undefined
1051+
new Mirror({
1052+
doc: new LoroDoc(),
1053+
initialState: {
1054+
list: [],
1055+
text: "hi",
1056+
map: { a: 1 },
1057+
absent: null,
1058+
also: undefined,
1059+
},
1060+
});
1061+
};
1062+
expect(_typeChecks).toBeDefined();
1063+
});
1064+
1065+
it("throws when initialState contains a boolean at the root (no schema)", () => {
1066+
const doc = new LoroDoc();
1067+
expect(
1068+
() =>
1069+
new Mirror({
1070+
doc,
1071+
initialState: { isActive: true } as any,
1072+
}),
1073+
).toThrow(/initialState\["isActive"\].*root/i);
1074+
});
1075+
1076+
it("throws when initialState contains a primitive at the root (with schema)", () => {
1077+
const doc = new LoroDoc();
1078+
const badSchema = schema({
1079+
// Bypass type-level constraint to simulate runtime misuse.
1080+
version: schema.Number() as any,
1081+
notes: schema.LoroList(schema.LoroMap({})),
1082+
} as any);
1083+
expect(
1084+
() =>
1085+
new Mirror({
1086+
doc,
1087+
schema: badSchema,
1088+
initialState: { version: 0, notes: [] } as any,
1089+
}),
1090+
).toThrow(/initialState\["version"\].*root/i);
1091+
});
1092+
1093+
it("ignores null/undefined root values in initialState", () => {
1094+
const doc = new LoroDoc();
1095+
const mirror = new Mirror({
1096+
doc,
1097+
initialState: {
1098+
missing: null,
1099+
absent: undefined,
1100+
notes: [],
1101+
} as any,
1102+
});
1103+
expect(doc.toJSON()).toStrictEqual({ notes: [] });
1104+
expect(mirror.getState()).toMatchObject({ notes: [] });
1105+
});
1106+
10301107
it("getContainerIds returns all registered container IDs for complex nested structures", async () => {
10311108
const doc = new LoroDoc();
10321109

packages/jotai/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
SchemaType,
1515
InferType,
1616
InferInputType,
17+
RootInitialValue,
1718
} from "loro-mirror";
1819

1920
type MirrorUpdateSource = "LORO" | "MIRROR" | "EPHEMERAL";
@@ -33,9 +34,14 @@ export interface LoroMirrorAtomConfig<S extends SchemaType> {
3334
schema: S;
3435

3536
/**
36-
* Initial state (optional)
37+
* Initial state (optional). Root entries must be container-shaped
38+
* (objects, arrays, strings, or `null`/`undefined`); bare numbers and
39+
* booleans are rejected, since LoroDoc only stores containers at the
40+
* document root.
3741
*/
38-
initialState?: Partial<InferInputType<S>>;
42+
initialState?: Partial<InferInputType<S>> & {
43+
[key: string]: RootInitialValue;
44+
};
3945

4046
/**
4147
* Whether to validate state updates against the schema

packages/react/src/hooks.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
SchemaType,
1717
Mirror,
1818
} from "loro-mirror";
19+
import type { RootInitialValue } from "loro-mirror";
1920
import type { LoroDoc, EphemeralStore } from "loro-crdt";
2021
// (No external state helper needed; Mirror handles Immer internally)
2122

@@ -34,9 +35,14 @@ export interface UseLoroStoreOptions<S extends SchemaType> {
3435
schema: S;
3536

3637
/**
37-
* Initial state (optional)
38+
* Initial state (optional). Root entries must be container-shaped
39+
* (objects, arrays, strings, or `null`/`undefined`); bare numbers and
40+
* booleans are rejected, since LoroDoc only stores containers at the
41+
* document root.
3842
*/
39-
initialState?: Partial<InferInputType<S>>;
43+
initialState?: Partial<InferInputType<S>> & {
44+
[key: string]: RootInitialValue;
45+
};
4046

4147
/**
4248
* Whether to validate state updates against the schema

0 commit comments

Comments
 (0)