/** * Mirror core functionality for bidirectional sync between app state and Loro CRDT */ import { produce, setAutoFreeze } from "immer"; setAutoFreeze(false); import { Container, ContainerID, ContainerType, EphemeralStore, isContainer, LoroDoc, LoroEventBatch, LoroList, LoroMap, LoroMovableList, LoroText, LoroTree, TreeID, } from "loro-crdt"; import { EphemeralPatchManager, type EphemeralEligibleChange, type EphemeralPatchDelta, type EphemeralStoreChangeEvent, type PathResolverContext, } from "./ephemeral.js"; import type { InferContainerOptions } from "../schema/types.js"; export type { InferContainerOptions } from "../schema/types.js"; import { applyEventBatchToState } from "./loroEventApply.js"; import { normalizeTreeJson } from "./tree-utils.js"; import { ContainerSchemaType, getDefaultValue, InferInputType, InferType, isContainerSchema, isListLikeSchema, isLoroListSchema, isLoroMapSchema, isLoroMovableListSchema, isLoroTreeSchema, LoroListSchema, LoroMapSchema, RootSchemaType, SchemaType, validateSchema, } from "../schema/index.js"; import { getChildContainerSchema, getChildSchema, getMapFieldSchema, } from "../schema/resolver.js"; import { deepEqual, inferContainerTypeFromValue, isObject, isValueOfContainerType, applySchemaToInferOptions, schemaToContainerType, tryInferContainerType, getRootContainerByType, defineCidProperty, stripUndefined, applyDecode, applyEncode, decodeNestedJsonValues, safeStringify, } from "./utils.js"; import { diffContainer, diffTree } from "./diff.js"; import { CID_KEY } from "../constants.js"; // Plain JSON-like value used for state snapshots type MirrorStatePrimitive = string | number | boolean | null | undefined | {}; type MirrorState = MirrorStatePrimitive | MirrorStateObject | MirrorState[]; interface MirrorStateObject { [k: string]: MirrorState; } /** * Values allowed for root-level keys of `initialState`. LoroDoc only stores * container types at the root, so primitives (bare `number`/`boolean`) are * disallowed. A root-level `string` is permitted because it maps to a root * `LoroText` container. */ export type RootInitialValue = | string | ReadonlyArray | { readonly [key: string]: unknown } | null | undefined; function hasKeyProp(c: Change): c is Extract { return (c as { key?: unknown }).key !== undefined; } /** * Source channel that caused this state update. */ export enum UpdateSource { /** * Changes coming from Loro to application state. */ LORO = "LORO", /** * Local state changes initiated through Mirror APIs. */ MIRROR = "MIRROR", /** * State recomposed due to an EphemeralStore change. */ EPHEMERAL = "EPHEMERAL", } /** * Configuration options for the Mirror */ export interface MirrorOptions { /** * The Loro document to sync with */ doc: LoroDoc; /** * The schema definition for the state */ schema?: S; /** * Initial state (optional). * * LoroDoc can only hold container values at the root (Map, List, * MovableList, Text, Tree), so root entries must be container-shaped: * objects, arrays, strings, or `null`/`undefined`. Bare numbers and * booleans are rejected at the type level (and at runtime). */ initialState?: Partial> & { [key: string]: RootInitialValue; }; /** * Whether to validate state updates against the schema * @default true */ validateUpdates?: boolean; /** * Debug mode - logs operations * @default false */ debug?: boolean; /** * When enabled, performs an internal consistency check after setState * to ensure in-memory state equals the normalized LoroDoc JSON. * This throws on divergence but does not emit the verbose debug logs. * @default false */ checkStateConsistency?: boolean; /** * Default values for new containers */ inferOptions?: InferContainerOptions; /** * Optional `EphemeralStore` (from `loro-crdt`) for syncing temporary state * without polluting LoroDoc editing history. * * **Setting this changes how {@link Mirror.setState} works:** when * present, `setState` automatically classifies each change and routes * eligible ones (primitive values on existing Map keys) to the * EphemeralStore instead of LoroDoc. Structural changes (new keys, * containers, list/text/tree ops) still go to LoroDoc as usual. * * State returned by {@link Mirror.getState} is always the composed * result: `LoroDoc state + EphemeralStore overlay`. Callers don't need * to be aware of where each value lives. * * Ephemeral values are committed to LoroDoc when the * {@link SetStateOptions.finalizeTimeout | finalizeTimeout} expires * (default 50 000 ms) or when {@link Mirror.finalizeEphemeralPatches} * is called manually (e.g. on `mouseup`). * * Network sync of the EphemeralStore is the caller's responsibility: * ```ts * eph.subscribeLocalUpdates((bytes) => channel.send(bytes)); * channel.on("ephemeral", (bytes) => eph.apply(bytes)); * ``` * * @see {@link Mirror.setState} for routing rules and examples * @see {@link Mirror.finalizeEphemeralPatches} for committing ephemeral values */ ephemeralStore?: EphemeralStore; } export type ChangeKinds = { set: { container: ContainerID | ""; key: string | number; value: unknown; kind: "set"; childContainerType?: ContainerType; }; setContainer: { container: ContainerID | ""; key: string | number; value: unknown; kind: "set-container"; childContainerType?: ContainerType; }; insert: { container: ContainerID | ""; key: string | number; value: unknown; kind: "insert"; }; insertContainer: { container: ContainerID | ""; key: string | number; value: unknown; kind: "insert-container"; childContainerType?: ContainerType; }; delete: { container: ContainerID | ""; key: string | number; value: unknown; kind: "delete"; }; move: { container: ContainerID; key: number; value: unknown; kind: "move"; fromIndex: number; toIndex: number; childContainerType?: ContainerType; }; treeCreate: { container: ContainerID; kind: "tree-create"; parent?: TreeID; index: number; value?: unknown; // initial node.data // Called immediately after the node is created in Loro so we can: // 1) write the assigned TreeID back onto the newState node (users cannot know it ahead of time), and // 2) patch any queued child `tree-create` ops to point to this node as their `parent`. // // Note: this implies an ordering requirement when applying changes — tree creates must be // applied one-by-one and `onCreate` invoked right away to ensure children have the correct parent. onCreate(id: TreeID): void; }; treeMove: { container: ContainerID; kind: "tree-move"; target: TreeID; parent?: TreeID; index: number; }; treeDelete: { container: ContainerID; kind: "tree-delete"; target: TreeID; }; }; export type Change = ChangeKinds[keyof ChangeKinds]; export type MapChangeKinds = | ChangeKinds["insert"] | ChangeKinds["insertContainer"] | ChangeKinds["delete"]; export type ListChangeKinds = | ChangeKinds["insert"] | ChangeKinds["insertContainer"] | ChangeKinds["delete"]; export type MovableListChangeKinds = | ChangeKinds["insert"] | ChangeKinds["insertContainer"] | ChangeKinds["delete"] | ChangeKinds["move"] | ChangeKinds["set"] | ChangeKinds["setContainer"]; export type TreeChangeKinds = | ChangeKinds["treeCreate"] | ChangeKinds["treeMove"] | ChangeKinds["treeDelete"]; export type TextChangeKinds = ChangeKinds["insert"] | ChangeKinds["delete"]; /** * Options for setState and sync operations */ export interface SetStateOptions { /** * Tags to attach to this state update * Tags can be used for tracking the source of changes or grouping related changes */ tags?: string[] | string; /** * Optional origin metadata forwarded to the underlying Loro commit. * Useful when callers need to tag commits with application-specific provenance. */ origin?: string; /** * Optional timestamp forwarded to the underlying Loro commit metadata. */ timestamp?: number; /** * Optional message forwarded to the underlying Loro commit metadata. */ message?: string; /** * Debounce delay (in ms) before ephemeral values are automatically * committed to LoroDoc. The timer resets on every `setState` call that * produces ephemeral changes, so it only fires after the user stops * updating. * * Has no effect when no `ephemeralStore` is configured. * * Typical values: * - `1_000` (1 s) — drag/resize interactions where you want quick persistence * - `50_000` (50 s, default) — long-lived ephemeral state (e.g. cursor position) * * To commit immediately instead of waiting, call * {@link Mirror.finalizeEphemeralPatches}. * * @default 50_000 */ finalizeTimeout?: number; } type ContainerRegistry = Map< ContainerID, { schema: ContainerSchemaType | undefined; registered: boolean; } >; /** * Additional metadata for state updates */ export interface UpdateMetadata { /** * Source channel that caused this update. */ source: UpdateSource; /** * Tags associated with this update, if any */ tags?: string[]; } /** * Callback type for subscribers */ export type SubscriberCallback = ( state: T, metadata: UpdateMetadata, ) => void; /** * Mirror class that provides bidirectional sync between application state and Loro */ export class Mirror { private doc: LoroDoc; private schema?: S; private state: InferType; /** Pure LoroDoc state without ephemeral overlay */ private baseState: InferType; private subscribers: Set>> = new Set(); private syncing = false; private options: MirrorOptions; private containerRegistry: ContainerRegistry = new Map(); private inferOptionsByContainerId: Map = new Map(); private subscriptions: (() => void)[] = []; // Canonical root path (e.g., ["profile"]) per root container id private rootPathById: Map = new Map(); // Ephemeral patch manager (handles EphemeralStore, local tracking, path resolution, finalization) private ephemeralManager?: EphemeralPatchManager; private suppressLocalEphemeralEvents = 0; /** * Creates a new Mirror instance */ constructor(options: MirrorOptions) { this.doc = options.doc; this.schema = options.schema; // Set default options this.options = { doc: options.doc, schema: options.schema, initialState: options.initialState || {}, validateUpdates: options.validateUpdates !== false, debug: options.debug || false, checkStateConsistency: options.checkStateConsistency || false, inferOptions: options.inferOptions || {}, }; // Pre-create root containers hinted by initialState (no-op in Loro for roots) // so that doc.toJSON() reflects empty shapes and matches normalized state. this.ensureRootContainersFromInitialState(); // 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 // (arrays -> [], strings -> '', objects -> {}), but do NOT override existing values // from the doc/defaults. This keeps doc pristine while providing a predictable state shape. const baseState: Record = {}; const defaults = ( this.schema ? getDefaultValue(this.schema) : undefined ) as Record | undefined; if (defaults && typeof defaults === "object") { 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 ([], "", {}) // - For primitives, use provided initial values if doc/defaults do not provide them const initForMerge = (this.options.initialState || {}) as Record< string, unknown >; if (this.schema && this.schema.type === "schema") { mergeInitialIntoBaseWithSchema( baseState, initForMerge, this.schema as RootSchemaType< Record >, ); } else { const hinted = normalizeInitialShapeShallow(initForMerge); for (const [k, v] of Object.entries(hinted)) { if (!(k in baseState)) baseState[k] = v; } } this.baseState = baseState as InferType; this.state = baseState as InferType; // Initialize ephemeral manager if store provided if (options.ephemeralStore) { this.ephemeralManager = new EphemeralPatchManager( options.ephemeralStore, ); this.subscriptions.push( this.ephemeralManager.subscribe(this.handleEphemeralEvent), ); } // Initialize Loro containers and setup subscriptions this.initializeContainers(); this.state = this.composeState(this.baseState); // Subscribe to the root doc for global updates this.subscriptions.push(this.doc.subscribe(this.handleLoroEvent)); } /** * Ensure root containers exist for keys hinted by initialState. * Creating root containers is a no-op in Loro (no operations are recorded), * but it makes them visible in doc JSON, staying consistent with Mirror state. */ private ensureRootContainersFromInitialState() { const init = (this.options?.initialState || {}) as Record< string, unknown >; const rootSchema = this.schema && this.schema.type === "schema" ? (this.schema as RootSchemaType< Record >) : undefined; for (const [key, value] of Object.entries(init)) { const fieldSchema = rootSchema?.definition[key]; const containerType = (fieldSchema ? schemaToContainerType(fieldSchema) : undefined) ?? this.inferRootContainerTypeFromInitialValue(value); if (!containerType) { // null/undefined are treated as "absent" and silently skipped. if (value == null) continue; // LoroDoc cannot store primitives at the document root — only // containers (Map, List, MovableList, Text, Tree). Silently // dropping the value would cause Mirror state to drift from // doc state on the next sync, so fail loudly instead. const fieldType = fieldSchema?.type; const observed = Array.isArray(value) ? "array" : typeof value; const detail = fieldType ? `schema type "${fieldType}"` : `value of type "${observed}"`; throw new Error( `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).`, ); } const container = getRootContainerByType( this.doc, key, containerType, ); this.rootPathById.set(container.id, [key]); this.registerContainerWithRegistry(container.id, undefined); } } private inferRootContainerTypeFromInitialValue( value: unknown, ): ContainerType | undefined { if (Array.isArray(value)) { return this.options.inferOptions?.defaultMovableList ? "MovableList" : "List"; } if (typeof value === "string") { return "Text"; } if (isObject(value)) { return "Map"; } return undefined; } /** * Initialize containers based on schema */ private initializeContainers() { if (this.schema && this.schema.type !== "schema") { throw new Error('Root schema must be of type "schema"'); } // Register root containers first so registry is ready if (this.schema) { for (const key in this.schema.definition) { if ( Object.prototype.hasOwnProperty.call( this.schema.definition, key, ) ) { const fieldSchema = this.schema.definition[key]; if ( [ "loro-map", "loro-list", "loro-text", "loro-movable-list", "loro-tree", ].includes(fieldSchema.type) ) { const containerType = schemaToContainerType(fieldSchema); if (!containerType) { continue; } const container = getRootContainerByType( this.doc, key, containerType, ); // Record canonical root path for this root container id this.rootPathById.set(container.id, [key]); this.registerContainerHandle(container, fieldSchema); } } } } // Build initial state snapshot from the current document const currentDocState = this.buildRootStateSnapshot(); const newState = produce>((draft) => { Object.assign( draft as unknown as Record, currentDocState, ); })(this.state); this.baseState = newState; this.state = this.composeState(newState); } private registerContainerHandle( container: Container, schemaType: ContainerSchemaType | undefined, ) { const containerId = container.id; // If already registered, optionally upgrade schema const existing = this.containerRegistry.get(containerId); if (existing) { if (!existing.schema && schemaType) { existing.schema = schemaType; // Schema was missing on initial registration (e.g. from // ensureRootContainersFromInitialState), so nested // containers were never scanned. Do it now. this.registerNestedContainers(container); } return; } this.registerContainerWithRegistry(containerId, schemaType); // Register nested containers this.registerNestedContainers(container); } /** * Register nested containers within a container */ private registerNestedContainers(container: Container) { if (!container.isAttached) return; const parentSchema = this.getContainerSchema(container.id); const parentLocalInfer = this.inferOptionsByContainerId.get( container.id, ); try { if (container.kind() === "Map") { const map = container as LoroMap; for (const key of map.keys()) { const value = map.get(key); if (isContainer(value)) { let nestedSchema: ContainerSchemaType | undefined; if (parentSchema && isLoroMapSchema(parentSchema)) { const candidate = getMapFieldSchema( parentSchema, key, ); if (candidate?.type === "any") { this.inferOptionsByContainerId.set( value.id, this.getInferOptionsForChild( container.id, candidate, ), ); } if (candidate && isContainerSchema(candidate)) { nestedSchema = candidate; } } if ( !parentSchema && !nestedSchema && parentLocalInfer && !this.inferOptionsByContainerId.has(value.id) ) { this.inferOptionsByContainerId.set( value.id, parentLocalInfer, ); } this.registerContainerHandle(value, nestedSchema); } } } else if ( container.kind() === "List" || container.kind() === "MovableList" ) { const list = container as LoroList | LoroMovableList; const len = list.length; for (let i = 0; i < len; i++) { const value = list.get(i); if (isContainer(value)) { let nestedSchema: ContainerSchemaType | undefined; if ( parentSchema && (isLoroListSchema(parentSchema) || isLoroMovableListSchema(parentSchema)) ) { const itemSchema = parentSchema.itemSchema; if (itemSchema?.type === "any") { this.inferOptionsByContainerId.set( value.id, this.getInferOptionsForChild( container.id, itemSchema, ), ); } if (isContainerSchema(itemSchema)) { nestedSchema = itemSchema; } } if ( !parentSchema && !nestedSchema && parentLocalInfer && !this.inferOptionsByContainerId.has(value.id) ) { this.inferOptionsByContainerId.set( value.id, parentLocalInfer, ); } this.registerContainerHandle(value, nestedSchema); } } } else if (container.kind() === "Tree") { const tree = container as LoroTree; let nodeSchema: ContainerSchemaType | undefined; if (parentSchema && isLoroTreeSchema(parentSchema)) { nodeSchema = parentSchema.nodeSchema as ContainerSchemaType; } if (nodeSchema) { const nodes = tree.getNodes(); for (const node of nodes) { // Register the node.data map and its nested containers this.registerContainerHandle(node.data, nodeSchema); } } else if (!parentSchema && parentLocalInfer) { const nodes = tree.getNodes(); for (const node of nodes) { if (!this.inferOptionsByContainerId.has(node.data.id)) { this.inferOptionsByContainerId.set( node.data.id, parentLocalInfer, ); } this.registerContainerHandle(node.data, undefined); } } } } catch (error) { if (this.options.debug) { console.warn( `Error registering nested containers for ${container.id}:`, error, ); } } } /** * Handle events from the LoroDoc */ private handleLoroEvent = (event: LoroEventBatch) => { if (this.syncing) return; this.syncing = true; try { // Pre-register any containers referenced in this batch this.registerContainersFromLoroEvent(event); const normalized = this.normalizeLoroEventBatch(event); // Incrementally update baseState using event deltas this.baseState = this.applyNormalizedLoroEventToState( this.baseState, normalized, ); // Compose state with ephemeral overlay this.state = this.composeState(this.baseState); // With canonicalized paths, applyEventBatchToState updates roots precisely. // No additional root refresh is required here. // Notify subscribers of the update this.notifySubscribers(UpdateSource.LORO); } finally { this.registerContainersFromLoroEvent(event); this.syncing = false; } }; private normalizeLoroEventBatch(event: LoroEventBatch): LoroEventBatch { return { ...event, events: event.events.map((e) => { const canon = this.rootPathById.get(e.target); if ( canon && (!Array.isArray(e.path) || e.path[0] !== canon[0]) ) { return { ...e, path: canon } as typeof e; } return e; }), } as LoroEventBatch; } private applyNormalizedLoroEventToState( currentState: InferType, event: LoroEventBatch, ): InferType { const nextState = applyEventBatchToState( currentState as unknown as Record, event, { getContainerById: (id) => this.doc.getContainerById(id), containerToMirrorState: (c) => this.containerToMirrorState(c), nodeDataWithCid: (treeId) => { const schema = this.getContainerSchema(treeId); return !!(schema && isLoroTreeSchema(schema)); }, getNodeDataCid: (treeId, nodeId) => { try { const node = this.doc .getTree(treeId) .getNodeByID(nodeId); return node ? node.data.id : undefined; } catch { return undefined; } }, getSchemaForKey: (containerId, mapKey) => { return getChildSchema( this.getContainerSchema(containerId), mapKey, ); }, }, ) as unknown as InferType; this.stampCidForEventTargets(nextState, event); return nextState; } private captureLocalDocEvent( callback: () => void, ): LoroEventBatch | undefined { let captured: LoroEventBatch | undefined; const unsubscribe = this.doc.subscribe((event) => { if (event.by === "local") { captured = event; } }); try { callback(); } finally { unsubscribe(); } return captured; } private stampCidForEventTargets( state: InferType, event: LoroEventBatch, ): void { const visited = new Set(); for (const item of event.events) { if (visited.has(item.target)) continue; visited.add(item.target); const container = this.doc.getContainerById(item.target); if (!container || container.kind() !== "Map") { continue; } const path = this.doc.getPathToContainer(container.id); if (!path || path.length === 0) continue; let target: unknown = state; for (const segment of path) { if (!target || typeof target !== "object") { target = undefined; break; } target = (target as Record)[segment]; } if (target && typeof target === "object") { defineCidProperty(target, container.id); } } } /** * Processes container additions/removals from the LoroDoc * and ensures the containers are reflected in the container registry. * * TODO: need to handle removing containers from the registry on import * right now the Diff Delta only returns the number of items removed * not the container IDs , of those that were removed. */ private registerContainersFromLoroEvent(batch: LoroEventBatch) { for (const event of batch.events) { if (event.diff.type === "list") { const diff = event.diff.diff; const schema = this.getContainerSchema(event.target); const itemSchema = schema && isListLikeSchema(schema) ? schema.itemSchema : undefined; const parentLocalInfer = this.inferOptionsByContainerId.get( event.target, ); for (const change of diff) { if (!change.insert) continue; for (const item of change.insert) { if (isContainer(item)) { const container = item; let containerSchema: | ContainerSchemaType | undefined; if (itemSchema?.type === "any") { this.inferOptionsByContainerId.set( container.id, this.getInferOptionsForChild( event.target, itemSchema, ), ); } else if (isContainerSchema(itemSchema)) { containerSchema = itemSchema; } else if ( !schema && parentLocalInfer && !this.inferOptionsByContainerId.has( container.id, ) ) { this.inferOptionsByContainerId.set( container.id, parentLocalInfer, ); } this.registerContainerHandle( container, containerSchema, ); if ( schema && itemSchema && itemSchema.type !== "any" && !containerSchema ) { console.warn( `Container schema not found for key in list ${event.target}`, ); } } } } } else if (event.diff.type === "map") { const diff = event.diff.updated; const parentSchema = this.getContainerSchema(event.target); const parentLocalInfer = this.inferOptionsByContainerId.get( event.target, ); for (const [key, change] of Object.entries(diff)) { const schema = this.getSchemaForChild(event.target, key); if (isContainer(change)) { const containerSchema = isContainerSchema(schema) ? schema : undefined; if (schema?.type === "any") { this.inferOptionsByContainerId.set( change.id, this.getInferOptionsForChild( event.target, schema, ), ); } else if ( !parentSchema && parentLocalInfer && !this.inferOptionsByContainerId.has(change.id) ) { this.inferOptionsByContainerId.set( change.id, parentLocalInfer, ); } this.registerContainerHandle(change, containerSchema); if ( parentSchema && !containerSchema && schema?.type !== "any" ) { console.warn( `Container schema not found for key ${key} in map ${event.target}`, ); } } } } else if (event.diff.type === "tree") { const tree = this.doc.getTree(event.target); const schema = this.getContainerSchema(event.target); let nodeSchema: ContainerSchemaType | undefined; if (schema && isLoroTreeSchema(schema)) { nodeSchema = schema.nodeSchema as ContainerSchemaType; } if (!nodeSchema) continue; for (const item of event.diff.diff) { if (item.action === "create") { const node = tree.getNodeByID(item.target); if (node) { this.registerContainerHandle(node.data, nodeSchema); } } } } } } // Tree node $cid injection happens during event application /** * Update Loro based on state changes */ private updateLoro(newState: InferType, options?: SetStateOptions) { if (this.syncing) return; this.syncing = true; try { // Diff against the composed state (not baseState) so that remote // ephemeral overlay values are not treated as local changes. const currentDocState = this.state; const changes = diffContainer( this.doc, currentDocState, newState, "", this.schema, this.options?.inferOptions, ); // Apply the changes to the Loro document (and stamp any pending-state metadata like $cid) this.applyChangesToLoro(changes, newState, options); } finally { this.syncing = false; } } /** * Apply a set of changes to the Loro document */ private applyChangesToLoro( changes: Change[], pendingState?: InferType, options?: SetStateOptions, ) { // Group changes by container for batch processing const changesByContainer = new Map(); for (const change of changes) { if (!changesByContainer.has(change.container)) { changesByContainer.set(change.container, []); } changesByContainer.get(change.container)!.push(change); } // Process changes by container for (const [ containerId, containerChanges, ] of changesByContainer.entries()) { if (containerId === "") { // Handle root level changes this.applyRootChanges(containerChanges, pendingState); } else { // Handle container-specific changes const container = this.doc.getContainerById(containerId); if (container) { this.applyContainerChanges( container, containerChanges, pendingState, ); } else { throw new Error( `Container not found for ID: ${containerId}. This is likely due to a stale reference or a synchronization issue.`, ); } } } // Only commit if we actually applied any changes if (changes.length > 0) { let commitOptions: Parameters[0]; if (options) { const commitMeta: { origin?: string; timestamp?: number; message?: string; } = {}; if (options.origin !== undefined) { commitMeta.origin = options.origin; } if (options.timestamp !== undefined) { commitMeta.timestamp = options.timestamp; } if (options.message !== undefined) { commitMeta.message = options.message; } if ( commitMeta.origin !== undefined || commitMeta.timestamp !== undefined || commitMeta.message !== undefined ) { commitOptions = commitMeta; } } this.doc.commit(commitOptions); } } /** * Update root-level fields */ private applyRootChanges(changes: Change[], pendingState?: InferType) { for (const change of changes) { if (!hasKeyProp(change)) continue; const { key, value } = change; const keyStr = key.toString(); const fieldSchema = ( this.schema as RootSchemaType< Record > )?.definition?.[keyStr]; const type = fieldSchema?.type || inferContainerTypeFromValue(value, this.options?.inferOptions); let container: Container | null = null; // Create or get the container based on the schema type if (type === "loro-map") { container = this.doc.getMap(keyStr); } else if (type === "loro-list") { container = this.doc.getList(keyStr); } else if (type === "loro-text") { container = this.doc.getText(keyStr); } else if (type === "loro-movable-list") { container = this.doc.getMovableList(keyStr); } else if (type === "loro-tree") { container = this.doc.getTree(keyStr); } else { throw new Error(); } this.registerContainerWithRegistry(container.id, fieldSchema); // Inject $cid for root maps into pending state immediately if (pendingState && container.kind() === "Map") { const rootObj = pendingState as Record; const child = rootObj[keyStr]; this.stampCid(child, container.id); } // Apply direct changes to the container this.updateTopLevelContainer(container, value); } } /** * Apply multiple changes to a container */ private applyContainerChanges( container: Container, changes: Change[], _pendingState?: InferType, ) { // Apply changes in bulk by container type switch (container.kind()) { case "Map": { const map = container as LoroMap; for (const change of changes) { const { key, value, kind } = change as MapChangeKinds; if (key === "") { continue; // Skip empty key } // If schema marks this key as Ignore, skip writing to Loro const fieldSchema = this.getSchemaForChild( container.id, key, ); if (fieldSchema && fieldSchema.type === "ignore") { continue; } if (kind === "insert") { map.set(key as string, value); } else if (kind === "insert-container") { const schema = this.getSchemaForChildContainer( container.id, key, ); const childInfer = fieldSchema?.type === "any" ? this.getInferOptionsForChild( container.id, fieldSchema, ) : undefined; const inserted = this.insertContainerIntoMap( map, schema, key as string, value, childInfer, ); // Stamp $cid into the pendingState value for child maps this.stampCid(value, inserted.id); } else if (kind === "delete") { map.delete(key as string); } else { throw new Error("Unsupported change kind for map"); } } break; } case "List": { const list = container as LoroList; // Process other changes (add/remove/replace) for (const change of changes) { const { key, value, kind } = change as ListChangeKinds; if (typeof key !== "number") { throw new Error(`Invalid list index: ${key}`); } const index = key; if (index < 0) { console.warn(`Invalid list index: ${index}`); continue; } if (kind === "delete") { list.delete(index, 1); } else if (kind === "insert") { list.insert(index, value); } else if (kind === "insert-container") { const fieldSchema = this.getSchemaForChild( container.id, key, ); const schema = this.getSchemaForChildContainer( container.id, key, ); this.insertContainerIntoList( list, schema, index, value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( container.id, fieldSchema, ) : undefined, ); } else { throw new Error("Unsupported change kind for list"); } } break; } case "MovableList": { const list = container as LoroMovableList; for (const change of changes) { const { key, value, kind } = change as MovableListChangeKinds; if (typeof key !== "number") { throw new Error(`Invalid list index: ${key}`); } const index = key; if (index < 0) { console.warn(`Invalid list index: ${index}`); continue; } if (kind === "delete") { list.delete(index, 1); } else if (kind === "insert") { list.insert(index, value); } else if (kind === "insert-container") { const fieldSchema = this.getSchemaForChild( container.id, key, ); const schema = this.getSchemaForChildContainer( container.id, key, ); this.insertContainerIntoList( list, schema, index, value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( container.id, fieldSchema, ) : undefined, ); } else if (kind === "move") { const c = change as ChangeKinds["move"]; const fromIndex = c.fromIndex; const toIndex = c.toIndex; list.move(fromIndex, toIndex); } else if (kind === "set") { list.set(index, value); } else if (kind === "set-container") { const fieldSchema = this.getSchemaForChild( container.id, key, ); const schema = this.getSchemaForChildContainer( container.id, key, ); const infer = fieldSchema?.type === "any" ? this.getInferOptionsForChild( container.id, fieldSchema, ) : !schema ? this.getInferOptionsForContainer( container.id, ) : undefined; const [detachedContainer, _containerType] = this.createContainerFromSchema( schema, value, infer, ); const newContainer = list.setContainer( index, detachedContainer, ); if (!schema && infer) { this.inferOptionsByContainerId.set( newContainer.id, infer, ); } this.registerContainerHandle(newContainer, schema); this.initializeContainer(newContainer, schema, value); // Stamp $cid into pending state when replacing with a map container this.stampCid(value, newContainer.id); } else { throw new Error(); } } break; } case "Text": { const text = container as LoroText; // Text containers only support direct value updates for (const change of changes) { if (!("value" in change)) continue; const v = (change as TextChangeKinds).value; if (typeof v === "string") { text.update(v); } else { // ignore } } break; } case "Tree": { const tree = container as LoroTree; // Determine node schema for initializing new nodes let nodeSchema: ContainerSchemaType | undefined; const parentSchema = this.getContainerSchema(tree.id); if (parentSchema && isLoroTreeSchema(parentSchema)) { nodeSchema = parentSchema.nodeSchema as ContainerSchemaType; } for (const change of changes) { if (change.kind === "tree-create") { const newNode = tree.createNode( change.parent, change.index, ); // Propagate the concrete TreeID back into the in-memory newState and // fix up any pending child creates that depend on this parent's ID. change.onCreate(newNode.id); if (nodeSchema) { this.registerContainerHandle( newNode.data, nodeSchema, ); this.initializeContainer( newNode.data, nodeSchema, change.value, ); // Stamp $cid into node.data in pending state if ( isLoroMapSchema(nodeSchema) && isObject(change.value) ) { defineCidProperty( change.value, newNode.data.id, ); } } } else if (change.kind === "tree-move") { tree.move(change.target, change.parent, change.index); } else if (change.kind === "tree-delete") { tree.delete(change.target); } else { // ignore non-tree changes for tree container } } break; } default: console.warn(`Unsupported container type: ${container.kind()}`); } } /** * Update a top-level container directly with a new value */ private updateTopLevelContainer(container: Container, value: unknown) { const kind = container.kind(); switch (kind) { case "Text": this.updateTextContainer(container as LoroText, value); break; case "List": this.updateListContainer(container as LoroList, value); break; case "Map": this.updateMapContainer(container as LoroMap, value); break; case "MovableList": this.updateListContainer(container as LoroMovableList, value); break; case "Tree": this.updateTreeContainer(container as LoroTree, value); break; default: throw new Error( `Unknown container kind for top-level update: ${kind}. This is likely a programming error or unsupported container type.`, ); } } /** * Update a Text container */ private updateTextContainer(text: LoroText, value: unknown) { if (typeof value !== "string") { throw new Error("Text value must be a string"); } text.update(value); } /** * Update a List container */ private updateListContainer( list: LoroList | LoroMovableList, value: unknown, ) { // Replace entire list if (Array.isArray(value)) { // Find the schema for this container path const schema = this.getContainerSchema(list.id); if ( schema && !isLoroListSchema(schema) && !isLoroMovableListSchema(schema) ) { throw new Error( `Invalid schema for list: ${schema.type}. Expected LoroListSchema`, ); } // Get the idSelector function from the schema const idSelector = schema?.idSelector; const itemSchema = schema?.itemSchema; // Clear out the list first to avoid duplicate items // Instead of clearing the entire list, which can leave it empty if there's an error, // we'll replace items one by one and only remove items that aren't in the new list if (idSelector) { // If we have an ID selector, we can use it for more intelligent updates this.updateListWithIdSelector( list, value, idSelector, itemSchema!, ); } else { this.updateListByIndex(list, value, itemSchema); } } else { throw new Error("List value must be an array"); } } /** * Update a list using ID selector for efficient updates */ private updateListWithIdSelector( list: LoroList | LoroMovableList, newItems: unknown[], idSelector: (item: unknown) => string | null, itemSchema: SchemaType, ) { // First, map current items by ID const currentItemsById = new Map< string, { item: unknown; index: number } >(); const currentLength = list.length; for (let i = 0; i < currentLength; i++) { const item = list.get(i); try { if (item) { const id = idSelector(item); if (id) { currentItemsById.set(id, { item, index: i }); } } } catch (e) { if (this.options.debug) { console.warn(`Error getting ID for current list item:`, e); } } } // Then map new items by ID const newItemsById = new Map< string, { item: unknown; index: number } >(); // Helper function to get ID from either LoroMap or plain object const getIdFromItem = (item: unknown) => { if (!item) return null; try { // First try using the idSelector directly (for LoroMap objects) const id = idSelector(item); if (id) return id; } catch (e) { // If that fails, try to extract ID from plain object if (this.options.debug) { console.warn(`Error using ID selector directly:`, e); } // If idSelector tries to call .get("id"), we can try to access .id directly const idProp = (item as { id?: unknown }).id; if (typeof idProp === "string") { return idProp; } } return null; }; newItems.forEach((item, index) => { try { const id = getIdFromItem(item); if (id) { newItemsById.set(id, { item, index }); } else { throw new Error(`Item at index ${index} has no ID`); } } catch (e) { if (this.options.debug) { console.warn( `Error getting ID for new list item at index ${index}:`, e, ); } } }); // Find items to remove (in current but not in new) const itemsToRemove: number[] = []; for (const [id, { index }] of currentItemsById.entries()) { if (!newItemsById.has(id)) { itemsToRemove.push(index); } } // Sort in reverse order to remove higher indices first (to avoid index shifting issues) itemsToRemove.sort((a, b) => b - a); // Remove items that aren't in the new list for (const index of itemsToRemove) { list.delete(index, 1); } // Now go through the new list and add or update items let currentIndex = 0; for (let i = 0; i < newItems.length; i++) { const newItem = newItems[i]; let id: string | null = null; try { id = getIdFromItem(newItem); } catch (e) { console.warn(`Error getting ID for new item at index ${i}:`, e); continue; } if (!id) continue; const currentEntry = currentItemsById.get(id); if (currentEntry) { // Item exists, update if needed const currentItem = list.get(currentIndex); if (!deepEqual(currentItem, newItem)) { // Only update if different list.delete(currentIndex, 1); this.insertItemIntoList( list, currentIndex, newItem, itemSchema, ); } } else { // New item, insert at current position this.insertItemIntoList( list, currentIndex, newItem, itemSchema, ); } currentIndex++; } // Truncate any remaining items if the new list is shorter if (currentIndex < list.length) { list.delete(currentIndex, list.length - currentIndex); } } /** * Update a list by index (for lists without an ID selector) */ private updateListByIndex( list: LoroList | LoroMovableList, newItems: unknown[], itemSchema: SchemaType | undefined, ) { // First, clear the list const oldLength = list.length; // Instead of clearing everything and re-adding, update existing items and add/remove as needed const maxLength = Math.max(oldLength, newItems.length); for (let i = 0; i < maxLength; i++) { if (i >= oldLength) { // Add new item this.insertItemIntoList(list, i, newItems[i], itemSchema); } else if (i >= newItems.length) { // Remove excess items, starting from the end list.delete(newItems.length, oldLength - newItems.length); break; } else { // Update existing item const oldItem = list.get(i); const newItem = newItems[i]; if (!deepEqual(oldItem, newItem)) { list.delete(i, 1); this.insertItemIntoList(list, i, newItem, itemSchema); } } } } /** * Helper to insert an item into a list, handling containers appropriately */ private insertItemIntoList( list: LoroList | LoroMovableList, index: number, item: unknown, itemSchema: SchemaType | undefined, ) { const baseInfer = this.getInferOptionsForContainer(list.id); const effectiveInfer = this.getInferOptionsForChild( list.id, itemSchema, ); let containerSchema: ContainerSchemaType | undefined; let containerType: ContainerType | undefined; if (itemSchema && isContainerSchema(itemSchema)) { containerSchema = itemSchema; containerType = schemaToContainerType(itemSchema); } else { containerType = tryInferContainerType(item, effectiveInfer); } if ( containerType && (!containerSchema || isValueOfContainerType(containerType, item)) ) { const childInfer = containerSchema ? undefined : effectiveInfer || baseInfer; this.insertContainerIntoList( list, containerSchema, index, item, childInfer, ); return; } // Default to simple insert list.insert(index, applyEncode(itemSchema, item)); } /** * Subscribe to state changes */ subscribe(callback: SubscriberCallback>): () => void { this.subscribers.add(callback); // Return unsubscribe function return () => { this.subscribers.delete(callback); }; } /** * Notify all subscribers of state change * @param source The source channel for the sync operation * @param tags Optional tags associated with this update */ private notifySubscribers(source: UpdateSource, tags?: string[]) { const metadata: UpdateMetadata = { source, tags, }; for (const subscriber of this.subscribers) { subscriber(this.state, metadata); } } /** Path resolver context for EphemeralPatchManager */ private get ephemeralCtx(): PathResolverContext { return { doc: this.doc, decodeField: (containerId, key, value) => { const fieldSchema = this.getSchemaForChild(containerId, key); return applyDecode(fieldSchema, value); }, }; } /** * Compose state by overlaying ephemeral patches on top of base state. * If no ephemeral manager or no patches, returns base unchanged. */ private composeState(base: InferType): InferType { if (!this.ephemeralManager) return base; return this.ephemeralManager.compose(base, this.ephemeralCtx); } private applyEphemeralDeltas( deltas: readonly EphemeralPatchDelta[], state: InferType = this.state, ): InferType { if (!this.ephemeralManager || deltas.length === 0) return state; return this.ephemeralManager.applyDelta( state, this.baseState, deltas, this.ephemeralCtx, ); } private withSuppressedLocalEphemeralEvents(callback: () => T): T { this.suppressLocalEphemeralEvents += 1; try { return callback(); } finally { this.suppressLocalEphemeralEvents -= 1; } } /** * Handle events from the EphemeralStore (both local and remote) */ private handleEphemeralEvent = (event: EphemeralStoreChangeEvent) => { if (this.syncing) return; if (event.by === "local" && this.suppressLocalEphemeralEvents > 0) { return; } this.state = this.applyEphemeralDeltas(event.deltas); this.notifySubscribers(UpdateSource.EPHEMERAL); }; /** * Immediately commit all pending ephemeral patches to LoroDoc. * * Call this when the temporary operation ends (e.g. on `mouseup` after a drag) * to flush ephemeral values into permanent LoroDoc history without waiting for * the debounced timeout. * * Only values that still match what this peer last wrote to the EphemeralStore * are committed. Values overwritten by a remote peer are skipped. * * After finalization, the EphemeralStore patches are cleaned up and the * debounce timer is cancelled. * * No-op if there are no pending local ephemeral patches. */ finalizeEphemeralPatches(): void { if (!this.ephemeralManager || !this.ephemeralManager.hasLocalPatches) return; this.syncing = true; try { const localEvent = this.captureLocalDocEvent(() => { this.ephemeralManager?.finalize(this.doc); }); if (localEvent) { this.registerContainersFromLoroEvent(localEvent); const normalized = this.normalizeLoroEventBatch(localEvent); this.baseState = this.applyNormalizedLoroEventToState( this.baseState, normalized, ); } this.state = this.composeState(this.baseState); this.notifySubscribers(UpdateSource.MIRROR); } finally { this.syncing = false; } } /** * Clean up resources */ dispose() { // Clean up ephemeral resources this.ephemeralManager?.dispose(); this.subscribers.clear(); this.subscriptions.forEach((x) => { x(); }); this.subscriptions.length = 0; } /** * Attaches a detached container to a map * * If the schema is provided, the container will be registered with the schema */ private insertContainerIntoMap( map: LoroMap, schema: ContainerSchemaType | undefined, key: string, value: unknown, childInferOptions?: InferContainerOptions, ) { const infer = this.getInferOptionsForMapChild( map.id, childInferOptions, ); let insertedContainer: Container | undefined; if (infer?.mergeableMapChildContainers) { const containerType = this.getContainerTypeForInsert( schema, value, infer, ); insertedContainer = this.insertMergeableContainerIntoMap( map, key, containerType, ); } else { const [detachedContainer] = this.createContainerFromSchema( schema, value, infer, ); insertedContainer = map.setContainer(key, detachedContainer); } if (!insertedContainer) { throw new Error("Failed to insert container into map"); } if (!schema && infer) { this.inferOptionsByContainerId.set(insertedContainer.id, infer); } this.registerContainerHandle(insertedContainer, schema); if (infer?.mergeableMapChildContainers) { this.updateTopLevelContainer(insertedContainer, value); } else { this.initializeContainer(insertedContainer, schema, value); } // Stamp $cid for child maps directly on the provided value (pending state) if (insertedContainer.kind() === "Map") { this.stampCid(value, insertedContainer.id); } return insertedContainer; } private insertMergeableContainerIntoMap( map: LoroMap, key: string, containerType: ContainerType, ): Container { const existing = map.get(key); if (isContainer(existing) && existing.kind() === containerType) { return existing; } if (existing !== undefined) { map.delete(key); } switch (containerType) { case "Map": return map.ensureMergeableMap(key); case "List": return map.ensureMergeableList(key); case "MovableList": return map.ensureMergeableMovableList(key); case "Text": return map.ensureMergeableText(key); case "Tree": return map.ensureMergeableTree(key); default: throw new Error( `Unsupported mergeable map child container type: ${containerType}`, ); } } /** * Once a container has been created, and attached to its parent * * We initialize the inner values using the schema that we previously registered. */ private initializeContainer( container: Container, schema: ContainerSchemaType | undefined, value: unknown, ) { const kind = container.kind(); if (kind === "Map") { const map = container as LoroMap; if (!isObject(value)) { return; } const mapSchema = schema?.type === "loro-map" ? schema : undefined; const baseInfer = this.getInferOptionsForContainer(map.id); for (const [key, val] of Object.entries(value)) { // Skip injected CID field if (key === CID_KEY) continue; // Skip undefined values - treat them as non-existent fields if (val === undefined) continue; if (mapSchema) { const fieldSchema = getMapFieldSchema(mapSchema, key); if (fieldSchema?.type === "any") { const infer = this.getInferOptionsForChild( map.id, fieldSchema, ); const ct = tryInferContainerType(val, infer); if (ct) { this.insertContainerIntoMap( map, undefined, key, val, infer, ); } else { map.set(key, val); } continue; } if (fieldSchema && isContainerSchema(fieldSchema)) { const ct = schemaToContainerType(fieldSchema); if (ct && isValueOfContainerType(ct, val)) { this.insertContainerIntoMap( map, fieldSchema, key, val, ); } else { // Schema says container but value doesn't match - fall back to primitive map.set(key, applyEncode(fieldSchema, val)); } continue; } // Primitive schema - encode value before storing map.set(key, applyEncode(fieldSchema, val)); continue; } const ct = tryInferContainerType(val, baseInfer); if (ct) { this.insertContainerIntoMap( map, undefined, key, val, baseInfer, ); } else { map.set(key, val); } } } else if (kind === "List" || kind === "MovableList") { const list = container as LoroList | LoroMovableList; if (!Array.isArray(value)) { return; } const itemSchema = ( schema as LoroListSchema | undefined )?.itemSchema; const baseInfer = this.getInferOptionsForContainer(list.id); const isListItemContainer = isContainerSchema(itemSchema); for (let i = 0; i < value.length; i++) { const item = value[i]; if (itemSchema?.type === "any") { const infer = applySchemaToInferOptions(itemSchema, baseInfer) || baseInfer; const ct = tryInferContainerType(item, infer); if (ct) { this.insertContainerIntoList( list, undefined, i, item, infer, ); } else { list.insert(i, item); } continue; } if (isListItemContainer) { const ct = schemaToContainerType(itemSchema); if (ct && isValueOfContainerType(ct, item)) { this.insertContainerIntoList(list, itemSchema, i, item); } else { list.insert(i, applyEncode(itemSchema, item)); } continue; } if (!itemSchema) { const ct = tryInferContainerType(item, baseInfer); if (ct) { this.insertContainerIntoList( list, undefined, i, item, baseInfer, ); } else { list.insert(i, item); } continue; } list.insert(i, applyEncode(itemSchema, item)); } } else if (kind === "Text") { const text = container as LoroText; if (typeof value === "string") { text.update(value); } } else if (kind === "Tree") { const tree = container as LoroTree; this.updateTreeContainer(tree, value); } else { throw new Error(`Unknown container kind: ${kind}`); } } /** * Resolve the container type for an insert without allocating a detached container. */ private getContainerTypeForInsert( schema: ContainerSchemaType | undefined, value: unknown, inferOptions?: InferContainerOptions, ): ContainerType { const containerType = schema ? schemaToContainerType(schema) : tryInferContainerType(value, inferOptions); if (!containerType) { throw new Error(`Unknown schema type: ${containerType}`); } return containerType; } /** * Create a new detached container based on a given schema. * * If the schema is undefined, we infer the container type from the value. */ private createContainerFromSchema( schema: ContainerSchemaType | undefined, value: unknown, inferOptions?: InferContainerOptions, ): [Container, ContainerType] { const containerType = this.getContainerTypeForInsert( schema, value, inferOptions, ); switch (containerType) { case "Map": return [new LoroMap(), "Map"]; case "List": return [new LoroList(), "List"]; case "MovableList": return [new LoroMovableList(), "MovableList"]; case "Text": return [new LoroText(), "Text"]; case "Tree": return [new LoroTree(), "Tree"]; default: throw new Error(`Unknown schema type: ${containerType}`); } } /** * Attaches a detached container to a list * * If the schema is provided, the container will be registered with the schema */ private insertContainerIntoList( list: LoroList | LoroMovableList, schema: ContainerSchemaType | undefined, index: number, value: unknown, childInferOptions?: InferContainerOptions, ) { const infer = childInferOptions || (!schema ? this.getInferOptionsForContainer(list.id) : undefined); const [detachedContainer, _containerType] = this.createContainerFromSchema(schema, value, infer); let insertedContainer: Container | undefined; if (index === undefined) { insertedContainer = list.pushContainer(detachedContainer); } else { insertedContainer = list.insertContainer(index, detachedContainer); } if (!insertedContainer) { throw new Error("Failed to insert container into list"); } if (!schema && infer) { this.inferOptionsByContainerId.set(insertedContainer.id, infer); } this.registerContainerHandle(insertedContainer, schema); this.initializeContainer(insertedContainer, schema, value); // Stamp $cid for list item maps directly on the provided value (pending state) if (insertedContainer.kind() === "Map") { this.stampCid(value, insertedContainer.id); } return insertedContainer; } /** * Update a Tree container using existing tree diff to generate precise create/move/delete * and nested node.data changes, then apply via container change appliers. */ private updateTreeContainer(tree: LoroTree, value: unknown) { if (!Array.isArray(value)) { throw new Error("Tree value must be an array of nodes"); } // Normalize current tree JSON from Loro to Mirror node shape const current: unknown[] = normalizeTreeJsonForMirror(tree.toJSON()); const next: unknown[] = value as unknown[]; // Optional schema to enable nested node.data diffs const parentSchema = this.getContainerSchema(tree.id); const treeSchema = parentSchema && isLoroTreeSchema(parentSchema) ? parentSchema : undefined; // Compute changes const changes = diffTree( this.doc, current, next, tree.id, treeSchema, this.options?.inferOptions, ); if (changes.length === 0) return; // Group changes by container; apply tree ops first, then nested containers. // The order here matters for trees: child creates may depend on a parent's freshly // assigned ID (filled via `onCreate`), so we must apply creates in order. const grouped = new Map(); for (const ch of changes) { const cid = ch.container; const arr = grouped.get(cid); if (arr) arr.push(ch); else grouped.set(cid, [ch]); } // Apply structural tree changes on the target tree first const treeGroup = grouped.get(tree.id); if (treeGroup && treeGroup.length) { this.applyContainerChanges(tree, treeGroup); grouped.delete(tree.id); } // Apply nested container changes (e.g., node.data maps) for (const [cid, group] of grouped) { if (cid === "") continue; const container = this.doc.getContainerById(cid); if (!container || group.length === 0) continue; this.applyContainerChanges(container, group); } } /** * Update a Map container */ private updateMapContainer(map: LoroMap, value: unknown) { // Replace entire map if (!isObject(value)) { throw new Error("Map value must be an object"); } // Schema for this container (optional) const schema = this.getContainerSchema(map.id); // Stamp $cid on the pending value if (schema && isObject(value)) { defineCidProperty(value, map.id); } // Get current keys const currentKeys = new Set(map.keys()); // Process each field in the new value for (const [key, val] of Object.entries(value)) { if (key === CID_KEY) continue; // Skip CID // Treat explicit undefined the same as an absent key. This avoids // writing Loro nulls when updating newly ensured mergeable maps. if (val === undefined) continue; // If we have a loro-map schema, use it; otherwise, infer if (schema && schema.type === "loro-map") { this.updateMapEntry(map, key, val, schema); } else { // Infer whether this is a container const infer = this.getInferOptionsForContainer(map.id); const ct = tryInferContainerType(val, infer); if (ct) { // No child schema; insert with inferred container type this.insertContainerIntoMap( map, undefined, key, val, infer, ); } else { map.set(key, val); } } currentKeys.delete(key); } // Delete keys that are no longer present for (const key of currentKeys) { map.delete(key); } } /** * Helper to update a single entry in a map */ private updateMapEntry( map: LoroMap, key: string, value: unknown, schema: SchemaType | null, ) { if (key === CID_KEY) return; // Ignore CID in writes // Check if this field should be a container according to schema const mapSchema = schema?.type === "loro-map" ? schema : null; if (mapSchema) { const fieldSchema = getMapFieldSchema(mapSchema, key); if (fieldSchema && fieldSchema.type === "ignore") { // Skip ignore fields: they live only in mirrored state return; } if (fieldSchema?.type === "any") { const infer = this.getInferOptionsForChild(map.id, fieldSchema); const ct = tryInferContainerType(value, infer); if (ct) { this.insertContainerIntoMap( map, undefined, key, value, infer, ); return; } } if (fieldSchema && isContainerSchema(fieldSchema)) { const ct = schemaToContainerType(fieldSchema); if (ct && isValueOfContainerType(ct, value)) { this.insertContainerIntoMap(map, fieldSchema, key, value); return; // Avoid overwriting the inserted container } } // Primitive schema (possibly with transform) — encode and set map.set(key, applyEncode(fieldSchema, value)); return; } // Default to simple set (no schema available) map.set(key, value); } /** * Get current state */ getState(): InferType { return this.state; } private applyLocalLoroChanges( changes: Change[], pendingState: InferType, options?: SetStateOptions, ): boolean { let localEvent: LoroEventBatch | undefined; this.syncing = true; try { localEvent = this.captureLocalDocEvent(() => { this.applyChangesToLoro(changes, pendingState, options); }); } finally { this.syncing = false; } if (!localEvent) { return false; } this.registerContainersFromLoroEvent(localEvent); const normalized = this.normalizeLoroEventBatch(localEvent); this.baseState = this.applyNormalizedLoroEventToState( this.baseState, normalized, ); this.state = this.applyNormalizedLoroEventToState( this.state, normalized, ); return true; } /** * @internal * Advanced ephemeral hot path. Prefer `setState` for the public API surface. */ private patchEphemeral( containerId: ContainerID, key: string, value: string | number | boolean | null, options?: Pick, ): void { if (this.syncing) return; if (!this.ephemeralManager) { throw new Error("patchEphemeral requires an ephemeralStore"); } const change: Change = { container: containerId, key, value, kind: "set", }; if (!this.ephemeralManager.isEligible(change, this.doc)) { throw new Error( "patchEphemeral only supports primitive values on existing LoroMap keys", ); } const deltas = this.withSuppressedLocalEphemeralEvents( () => this.ephemeralManager?.writeValue(containerId, key, value) ?? [], ); if (deltas.length === 0) return; this.ephemeralManager.scheduleFinalizeAfter( options?.finalizeTimeout, () => { this.finalizeEphemeralPatches(); }, ); this.state = this.applyEphemeralDeltas(deltas); const tags = options?.tags ? Array.isArray(options.tags) ? options.tags : [options.tags] : undefined; this.notifySubscribers(UpdateSource.MIRROR, tags); } /** * Update state and propagate changes. * * `updater` may be: * - An **object** — shallow-merged into the current state. * - A **function** — receives a mutable Immer draft (mutate in place) * or returns a new immutable state object. * * --- * * ### Behavior depends on `ephemeralStore` configuration * * **Without `ephemeralStore`** (default) — all changes are written to * LoroDoc synchronously. This is the standard path for persistent edits. * * **With `ephemeralStore`** — Mirror diffs the new state against the * current composed state (`LoroDoc + EphemeralStore overlay`) and * classifies each change automatically: * * | Condition | Destination | * |---|---| * | Primitive value (`string`, `number`, `boolean`, `null`) on an **existing key** of an **existing LoroMap** | → `EphemeralStore` (no LoroDoc write) | * | Everything else (new keys, new containers, list/text/tree ops) | → `LoroDoc` directly | * * This means a single `setState` call can write some changes to * EphemeralStore and others to LoroDoc in the same invocation. * * Ephemeral values are committed to LoroDoc when: * 1. The {@link SetStateOptions.finalizeTimeout | finalizeTimeout} * expires (default 50 000 ms), or * 2. You call {@link finalizeEphemeralPatches} manually (e.g. on `mouseup`). * * The debounce timer resets on each `setState` call that produces * ephemeral changes, so it only fires after the user stops updating. * * @example * ```ts * // Without ephemeralStore — writes directly to LoroDoc * mirror.setState((s) => { s.count = 1; }); * * // With ephemeralStore — x/y go to EphemeralStore, push goes to LoroDoc * mirror.setState( * (s) => { * s.items[0].x = e.clientX; // → EphemeralStore (primitive on existing key) * s.items[0].y = e.clientY; // → EphemeralStore * s.items.push({ x: 0, y: 0, name: "new" }); // → LoroDoc (structural change) * }, * { finalizeTimeout: 1_000 }, * ); * ``` */ setState( updater: (state: Readonly>) => InferInputType, options?: SetStateOptions, ): void; setState( updater: (state: InferType) => void, options?: SetStateOptions, ): void; setState( updater: Partial>, options?: SetStateOptions, ): void; setState( updater: | ((state: InferType) => InferType | InferInputType | void) | ((state: Readonly>) => InferInputType) | Partial>, options?: SetStateOptions, ): void { if (this.syncing) return; // Prevent recursive updates // Calculate new state; support mutative or return-based updater via Immer const newState = typeof updater === "function" ? produce>(this.state, (draft) => { const res = ( updater as ( state: InferType, ) => InferType | void )(draft as InferType); if (res && res !== (draft as unknown)) { return res as unknown as typeof draft; } }) : (Object.assign( {}, this.state as unknown as Record, updater as Record, ) as InferType); // Validate state if needed if (this.options.validateUpdates) { const validation = this.schema && validateSchema(this.schema, newState); if (validation && !validation.valid) { const errorMessage = `State validation failed: ${validation.errors?.join( ", ", )}`; throw new Error(errorMessage); } } // Extract tags for this update const tags = options?.tags ? Array.isArray(options.tags) ? options.tags : [options.tags] : undefined; if (this.ephemeralManager) { // Route changes through ephemeral classification const changes = diffContainer( this.doc, this.state, newState, "", this.schema, this.options?.inferOptions, ); const ephemeralChanges: EphemeralEligibleChange[] = []; const loroChanges: Change[] = []; for (const change of changes) { if (this.ephemeralManager.isEligible(change, this.doc)) { ephemeralChanges.push(change); } else { loroChanges.push(change); } } // Apply non-ephemeral changes directly to LoroDoc if (loroChanges.length > 0) { const appliedIncrementally = this.applyLocalLoroChanges( loroChanges, newState, options, ); if (!appliedIncrementally) { this.baseState = this.rebuildBaseState(); this.state = this.composeState(this.baseState); } } // Write ephemeral-eligible changes to EphemeralStore if (ephemeralChanges.length > 0) { const deltas = this.withSuppressedLocalEphemeralEvents( () => this.ephemeralManager?.writeChanges(ephemeralChanges) ?? [], ); // Schedule debounced finalization this.ephemeralManager.scheduleFinalizeAfter( options?.finalizeTimeout, () => { this.finalizeEphemeralPatches(); }, ); this.state = this.applyEphemeralDeltas(deltas); } } else { // No ephemeral store — apply everything to LoroDoc this.updateLoro(newState, options); this.baseState = stripUndefined(newState); this.state = this.composeState(this.baseState); if (this.options.checkStateConsistency) { this.checkStateConsistency(); } } // Notify subscribers this.notifySubscribers(UpdateSource.MIRROR, tags); } checkStateConsistency() { // Compare baseState (doc-only + Ignore) against a fresh doc snapshot. // Using this.state would include the ephemeral overlay and always diverge // when ephemeral patches are active. const base = this.baseState as unknown as Record; const snapshot = this.buildRootStateSnapshot(base); if (!deepEqual(base, snapshot)) { console.error( "State diverged", safeStringify(base), safeStringify(snapshot), ); throw new Error("[InternalError] State diverged"); } } private containerToMirrorState(c: Container): MirrorState { const kind = c.kind(); const schema = this.getContainerSchema(c.id); if (kind === "Map") { const m = c as LoroMap; const obj: MirrorStateObject = {}; defineCidProperty(obj, c.id); for (const k of m.keys()) { const v = m.get(k); if (isContainer(v)) { obj[k] = this.containerToMirrorState(v); } else { // Decode primitive values using field schema const fieldSchema = getChildSchema(schema, k); obj[k] = applyDecode(fieldSchema, v) as MirrorState; } } return obj; } else if (kind === "List" || kind === "MovableList") { const arr: MirrorState[] = []; const l = c as unknown as LoroList | LoroMovableList; const len = l.length; // Get item schema for decoding list items const itemSchema = getChildSchema(schema); for (let i = 0; i < len; i++) { const v = l.get(i); if (isContainer(v)) { arr.push(this.containerToMirrorState(v)); } else { // Decode primitive items using item schema arr.push(applyDecode(itemSchema, v) as MirrorState); } } return arr; } else if (kind === "Text") { // LoroText toJSON returns a string return (c as LoroText).toJSON(); } else if (kind === "Tree") { const t = c as LoroTree; // Normalize via toJSON first const normalized = normalizeTreeJsonForMirror(t.toJSON()); // Optionally inject $cid per node.data using an id->cid map from live nodes const schema = this.getContainerSchema(t.id); const withCid = schema && isLoroTreeSchema(schema); if (withCid) { const idToCid = new Map(); // Best-effort: collect from runtime nodes if API available const tMaybe = t as unknown as { getNodes?: () => unknown[] }; const nodes: unknown[] = tMaybe.getNodes?.() ?? []; for (const raw of nodes) { try { const n = raw as { id?: unknown; data?: unknown }; const id = typeof n.id === "string" ? n.id : undefined; let dataId: string | undefined; if (n.data && typeof n.data === "object") { const d = n.data as { id?: unknown }; dataId = typeof d.id === "string" ? d.id : undefined; } if (id && dataId) idToCid.set(id, dataId); } catch { // ignore } } const stamp = (arr: unknown[]) => { for (const node of arr) { const n = node as { id: unknown; data?: unknown; children?: unknown; }; const cid = typeof n.id === "string" ? idToCid.get(n.id) : undefined; if (cid) { if (!n.data || typeof n.data !== "object") { (n as { data: Record }).data = {}; } defineCidProperty(n.data, cid as ContainerID); } if (Array.isArray(n.children)) stamp(n.children as unknown[]); } }; stamp(normalized); decodeNestedJsonValues(normalized, schema); } return normalized as unknown as MirrorState; } // Fallback return c.toJSON(); } /** * Build a state snapshot from LoroDoc. * When `prevState` is provided, Ignore fields are preserved from it * (they only exist in memory and are not backed by LoroDoc). */ private rebuildBaseState(): InferType { return this.buildRootStateSnapshot( this.baseState as Record, ) as InferType; } /** * Build a fresh state snapshot from the LoroDoc. * * When `prevState` is provided, Ignore fields are preserved from it * (they only exist in memory and are not backed by LoroDoc). */ private buildRootStateSnapshot( prevState?: Record, ): Record { if (!this.schema || this.schema.type !== "schema") { // Fallback to previous normalization if no schema return toNormalizedJson(this.doc) as Record; } const root: Record = {}; const rootSchema = this.schema as RootSchemaType< Record >; for (const key in rootSchema.definition) { const fieldSchema = rootSchema.definition[key]; // Preserve Ignore fields from previous state — they are memory-only if ((fieldSchema as { type: string }).type === "ignore") { if (prevState && key in prevState) { root[key] = prevState[key]; } continue; } const containerType = schemaToContainerType(fieldSchema); if (!containerType) continue; const container = getRootContainerByType( this.doc, key, containerType, ); // Always include maps to expose $cid for stable identity if (containerType === "Map") { root[key] = this.containerToMirrorState(container); } else if ( containerType === "List" || containerType === "MovableList" ) { // Always include lists, even if empty, to match Mirror's state shape root[key] = this.containerToMirrorState(container); } else if (containerType === "Text") { // Always include text, even if empty, to match Mirror's state shape root[key] = this.containerToMirrorState(container); } else if (containerType === "Tree") { const arr = this.containerToMirrorState(container) as unknown[]; if (!Array.isArray(arr) || arr.length === 0) continue; root[key] = arr; } else { root[key] = this.containerToMirrorState(container); } } return root; } /** * Register a container schema */ private registerContainerWithRegistry( containerId: ContainerID, schemaType: ContainerSchemaType | undefined, ) { this.containerRegistry.set(containerId, { schema: schemaType, registered: true, }); } private stampCid(target: unknown, cid: ContainerID) { if (!isObject(target)) return; defineCidProperty(target, cid); } private getInferOptionsForContainer(containerId: ContainerID | "") { if (containerId !== "") { const local = this.inferOptionsByContainerId.get(containerId); if (local) return local; } return this.options?.inferOptions || {}; } private getInferOptionsForChild( parentId: ContainerID, childSchema: SchemaType | undefined, ) { const base = this.getInferOptionsForContainer(parentId); return applySchemaToInferOptions(childSchema, base) || base; } private getInferOptionsForMapChild( parentId: ContainerID, childInferOptions?: InferContainerOptions, ): InferContainerOptions { const base = childInferOptions || this.getInferOptionsForContainer(parentId); const parentSchema = this.getContainerSchema(parentId); if (!parentSchema || !isLoroMapSchema(parentSchema)) { return base; } const { mergeableMapChildContainers } = parentSchema.options; if (mergeableMapChildContainers === undefined) { return base; } return { ...base, mergeableMapChildContainers, }; } private getContainerSchema( containerId: ContainerID, ): ContainerSchemaType | undefined { return this.containerRegistry.get(containerId)?.schema; } private getSchemaForChildContainer( containerId: ContainerID, childKey: string | number, ): ContainerSchemaType | undefined { return getChildContainerSchema( this.getContainerSchema(containerId), childKey, ); } private getSchemaForChild( containerId: ContainerID, childKey: string | number, ): SchemaType | undefined { return getChildSchema(this.getContainerSchema(containerId), childKey); } /* Get all container IDs registered with the mirror */ getContainerIds(): ContainerID[] { return Array.from(this.containerRegistry.keys()); } } /** * Export the json of the doc with LoroTree containers normalized * @param doc * @returns */ // Sentinel prefix for $cid values during the `toJsonWithReplacer` pass. // `toJsonWithReplacer` re-resolves any *mergeable* container-id string it finds // in the replacer's return value back into a container (re-applying the // replacer), which corrupts nested `$cid` markers. Prefixing the id makes it an // invalid container id so it is left untouched; `restoreCidDescriptors` strips // the prefix afterwards. const CID_PLACEHOLDER_PREFIX = "mirror:cid"; export function toNormalizedJson(doc: LoroDoc) { // Resolve containers ourselves rather than relying on `toJsonWithReplacer`'s // own recursion: `getShallowValue` leaks mergeable child containers as raw // binary markers, and a container handle placed back into the replacer's // return value is serialized as a raw wasm pointer instead of being recursed // into. Explicit recursion via `get`/`toJSON` resolves both regular and // mergeable children uniformly and injects `$cid` on every Map. const withEnumerableCid = doc.toJsonWithReplacer((_k, v) => { if (isContainer(v)) { return normalizeContainerForJson(v) as unknown as typeof v; } return v; }); return restoreCidDescriptors(withEnumerableCid); } function normalizeContainerForJson(c: Container): unknown { const kind = c.kind(); if (kind === "Tree") { return normalizeTreeJsonForMirror((c as LoroTree).toJSON()); } if (kind === "Text") { return (c as LoroText).toJSON(); } if (kind === "Map") { const m = c as LoroMap; const obj: Record = {}; for (const key of m.keys()) { const child = m.get(key); obj[key] = isContainer(child) ? normalizeContainerForJson(child) : child; } obj[CID_KEY] = CID_PLACEHOLDER_PREFIX + m.id; return obj; } if (kind === "List" || kind === "MovableList") { const l = c as LoroList | LoroMovableList; const arr: unknown[] = []; for (let i = 0; i < l.length; i++) { const item = l.get(i); arr.push( isContainer(item) ? normalizeContainerForJson(item) : item, ); } return arr; } // Fallback for any other container kind (e.g. Counter): use its JSON form. return (c as unknown as { toJSON(): unknown }).toJSON(); } // After toJsonWithReplacer returns plain JSON objects with enumerable $cid, // walk the structure and restore the non-enumerable descriptor so mirrored state matches schema mode. function restoreCidDescriptors(value: unknown): unknown { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { value[i] = restoreCidDescriptors(value[i]); } return value; } if (isObject(value)) { const obj = value; for (const key of Object.keys(obj)) { obj[key] = restoreCidDescriptors(obj[key]); } if (Object.prototype.hasOwnProperty.call(obj, CID_KEY)) { const descriptor = Object.getOwnPropertyDescriptor(obj, CID_KEY); if (!descriptor || descriptor.enumerable) { const rawCid = obj[CID_KEY]; const cidValue = typeof rawCid === "string" && rawCid.startsWith(CID_PLACEHOLDER_PREFIX) ? rawCid.slice(CID_PLACEHOLDER_PREFIX.length) : rawCid; delete obj[CID_KEY]; Object.defineProperty(obj, CID_KEY, { value: cidValue }); } } return obj; } return value; } // Normalize a shallow object shape from provided initialState by converting // container-like primitives to empty shapes without carrying data: // - arrays -> [] // - strings -> '' // - plain objects -> {} // Other primitive types are passed through (number, boolean, null/undefined). function normalizeInitialShapeShallow( input: Record, ): Record { const out: Record = {}; for (const [key, value] of Object.entries(input)) { if (Array.isArray(value)) { out[key] = []; } else if (typeof value === "string") { out[key] = ""; } else if (isObject(value)) { out[key] = {}; } else { out[key] = value; } } return out; } function normalizeTreeJsonForMirror(input: unknown) { return normalizeTreeJson(input, { isTreeData: isObject, createEmptyData: () => ({}), }); } // Deep merge initialState into a base state with awareness of the provided root schema. // - Does not override values already present in base (doc/defaults take precedence) // - For Ignore fields, copies values verbatim into in-memory state only // - For container fields, fills missing keys with normalized empty shape when initialState hints at presence // - For primitive fields, uses initial values if base lacks them function mergeInitialIntoBaseWithSchema( base: Record, init: Record, rootSchema: RootSchemaType>, ) { for (const [k, initVal] of Object.entries(init)) { const fieldSchema = rootSchema.definition[k]; if (!fieldSchema) { // Unknown field at root: hint shape only if (!(k in base)) { if (Array.isArray(initVal)) base[k] = []; else if (typeof initVal === "string") base[k] = ""; else if (isObject(initVal)) base[k] = {}; } continue; } const t = fieldSchema.type as string; if (t === "ignore") { base[k] = initVal; continue; } if (t === "loro-map") { // Ensure object if (!(k in base) || !isObject(base[k])) base[k] = {}; const nestedBase = base[k] as Record; const nestedInit: Record = isObject(initVal) ? initVal : {}; const nestedSchema = fieldSchema as unknown as LoroMapSchema< Record >; // actual types are not used at runtime // Recurse mergeInitialIntoBaseWithSchema(nestedBase, nestedInit, { type: "schema", definition: nestedSchema.definition as Record< string, ContainerSchemaType >, options: {}, getContainerType() { return "Map"; }, } as unknown as RootSchemaType< Record >); continue; } if (t === "loro-list" || t === "loro-movable-list") { if (!(k in base)) base[k] = []; continue; } if (t === "loro-text") { if (!(k in base)) base[k] = ""; continue; } if (t === "string" || t === "number" || t === "boolean") { if (!(k in base)) base[k] = initVal; continue; } } }