@@ -292,11 +292,49 @@ export class Mirror<S extends SchemaType> {
292
292
inferOptions : options . inferOptions || { } ,
293
293
} ;
294
294
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 > ;
300
338
301
339
// Initialize Loro containers and setup subscriptions
302
340
this . initializeContainers ( ) ;
@@ -305,6 +343,29 @@ export class Mirror<S extends SchemaType> {
305
343
this . subscriptions . push ( this . doc . subscribe ( this . handleLoroEvent ) ) ;
306
344
}
307
345
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
+
308
369
/**
309
370
* Initialize containers based on schema
310
371
*/
@@ -762,7 +823,11 @@ export class Mirror<S extends SchemaType> {
762
823
if ( key === "" ) {
763
824
continue ; // Skip empty key
764
825
}
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
+ }
766
831
if ( kind === "insert" ) {
767
832
map . set ( key as string , value ) ;
768
833
} else if ( kind === "insert-container" ) {
@@ -1583,6 +1648,10 @@ export class Mirror<S extends SchemaType> {
1583
1648
// Check if this field should be a container according to schema
1584
1649
if ( schema && schema . type === "loro-map" && schema . definition ) {
1585
1650
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
+ }
1586
1655
if ( fieldSchema && isContainerSchema ( fieldSchema ) ) {
1587
1656
const ct = schemaToContainerType ( fieldSchema ) ;
1588
1657
if ( ct && isValueOfContainerType ( ct , value ) ) {
@@ -1896,6 +1965,30 @@ export function toNormalizedJson(doc: LoroDoc) {
1896
1965
} ) ;
1897
1966
}
1898
1967
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
+
1899
1992
// Normalize LoroTree JSON (with `meta`) to Mirror tree node shape `{ id, data, children }`.
1900
1993
function normalizeTreeJson ( input : any [ ] ) : any [ ] {
1901
1994
if ( ! Array . isArray ( input ) ) return [ ] ;
@@ -1912,3 +2005,75 @@ function normalizeTreeJson(input: any[]): any[] {
1912
2005
} ;
1913
2006
return input . map ( mapNode ) ;
1914
2007
}
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
+ }
0 commit comments