@@ -84,6 +84,9 @@ export abstract class FieldNodeStructure {
8484 /** Lazily initialized injector. Do not access directly, access via `injector` getter instead. */
8585 private _injector : DestroyableInjector | undefined = undefined ;
8686
87+ /** Cache whether any logic rules exist on children of this node. */
88+ private _anyChildHasLogic ?: boolean ;
89+
8790 /** Lazily initialized injector. */
8891 get injector ( ) : DestroyableInjector {
8992 this . _injector ??= Injector . create ( {
@@ -101,15 +104,43 @@ export abstract class FieldNodeStructure {
101104
102105 /** Gets the child fields of this field. */
103106 children ( ) : readonly FieldNode [ ] {
107+ this . ensureChildrenMap ( ) ;
104108 const map = this . childrenMap ( ) ;
105109 if ( map === undefined ) {
106110 return [ ] ;
107111 }
108112 return Array . from ( map . byPropertyKey . values ( ) ) . map ( ( child ) => untracked ( child . reader ) ! ) ;
109113 }
110114
115+ /**
116+ * Internal method (cast to any in tests) to check if the children map has been materialized.
117+ * Useful for validating that fields without logic are lazily instantiated.
118+ *
119+ * @internal
120+ */
121+ _areChildrenMaterialized ( ) : boolean {
122+ return untracked ( this . childrenMap ) !== undefined ;
123+ }
124+
125+ private ensureChildrenMap ( ) {
126+ // If we're already materialized, there's nothing to do.
127+ if ( this . _areChildrenMaterialized ( ) ) {
128+ return ;
129+ }
130+
131+ // We force materialization by telling the linkedSignal to re-evaluate now, but treating
132+ // its source value as having changed, or rather skipping the lazy fast-path.
133+ untracked ( ( ) => {
134+ ( this . childrenMap as WritableSignal < ChildrenData | undefined > ) . update ( ( current ) =>
135+ this . computeChildrenMap ( this . value ( ) , current , true ) ,
136+ ) ;
137+ } ) ;
138+ }
139+
111140 /** Retrieve a child `FieldNode` of this node by property key. */
112141 getChild ( key : PropertyKey ) : FieldNode | undefined {
142+ this . ensureChildrenMap ( ) ;
143+
113144 const strKey = key . toString ( ) ;
114145
115146 // Lookup the computed reader for this key in `childrenMap`. This lookup doesn't need to be
@@ -253,108 +284,126 @@ export abstract class FieldNodeStructure {
253284 computation : (
254285 value : unknown ,
255286 previous : { source : unknown ; value : ChildrenData | undefined } | undefined ,
256- ) : ChildrenData | undefined => {
257- if ( ! isObject ( value ) ) {
258- // Non-object values have no children. This short-circuit path makes `childrenMap` fast
259- // for primitive-valued fields.
260- return undefined ;
261- }
287+ ) : ChildrenData | undefined => this . computeChildrenMap ( value , previous ?. value , false ) ,
288+ } ) ;
289+ }
262290
263- // Previous `ChildrenData` (immutable). This is also where we first initialize our map if
264- // needed.
265- const prevData : ChildrenData = previous ?. value ?? {
266- byPropertyKey : new Map ( ) ,
267- } ;
291+ private computeChildrenMap (
292+ value : unknown ,
293+ prevData : ChildrenData | undefined ,
294+ forceMaterialize : boolean ,
295+ ) : ChildrenData | undefined {
296+ if ( ! isObject ( value ) ) {
297+ // Non-object values have no children. This short-circuit path makes `childrenMap` fast
298+ // for primitive-valued fields.
299+ return undefined ;
300+ }
268301
269- // The next `ChildrenData` object to be returned. Initialized lazily when we know there's
270- // been a structural change to the model.
271- let data : MutableChildrenData | undefined ;
302+ // Determine if we actually need to materialize children right now.
303+ // If not forced, and NO child has any logic rules, we can safely return `undefined`
304+ // to keep instantiation lazy. However, if `prevData` is already defined, we MUST
305+ // NOT return `undefined` or we will orphan the already instantiated children.
306+ if ( ! forceMaterialize && prevData === undefined ) {
307+ // Check if any child of this field has logic rules. This check only needs to run once per
308+ // structure since the presence of schema logic rules is static across value changes.
309+ if ( ! ( this . _anyChildHasLogic ??= this . logic . anyChildHasLogic ( ) ) ) {
310+ return undefined ;
311+ }
312+ }
272313
273- const parentIsArray = isArray ( value ) ;
314+ // Previous `ChildrenData` (immutable). This is also where we first initialize our map if
315+ // needed.
316+ prevData ??= {
317+ byPropertyKey : new Map ( ) ,
318+ } ;
274319
275- // Remove fields that have disappeared since the last time this map was computed.
276- if ( prevData !== undefined ) {
277- if ( parentIsArray ) {
278- data = maybeRemoveStaleArrayFields ( prevData , value , this . identitySymbol ) ;
279- } else {
280- data = maybeRemoveStaleObjectFields ( prevData , value ) ;
281- }
282- }
320+ // The next `ChildrenData` object to be returned. Initialized lazily when we know there's
321+ // been a structural change to the model.
322+ let materializedChildren : MutableChildrenData | undefined ;
283323
284- // Now, go through the values and add any new ones.
285- for ( const key of Object . keys ( value ) ) {
286- let trackingKey : TrackingKey | undefined = undefined ;
287- const childValue = value [ key ] as unknown ;
288-
289- // Fields explicitly set to `undefined` are treated as if they don't exist.
290- // This ensures that `{value: undefined}` and `{}` have the same behavior for their `value`
291- // field.
292- if ( childValue === undefined ) {
293- // The value might have _become_ `undefined`, so we need to delete it here.
294- if ( prevData . byPropertyKey . has ( key ) ) {
295- data ??= { ...( prevData as MutableChildrenData ) } ;
296- data . byPropertyKey . delete ( key ) ;
297- }
298- continue ;
299- }
324+ const parentIsArray = isArray ( value ) ;
300325
301- if ( parentIsArray && isObject ( childValue ) && ! isArray ( childValue ) ) {
302- // For object values in arrays, assign a synthetic identity. This will be used to
303- // preserve the field instance even as this object moves around in the parent array.
304- trackingKey = ( childValue [ this . identitySymbol ] as TrackingKey ) ??= Symbol (
305- ngDevMode ? `id:${ globalId ++ } ` : '' ,
306- ) as TrackingKey ;
307- }
326+ // Remove fields that have disappeared since the last time this map was computed.
327+ if ( prevData !== undefined ) {
328+ if ( parentIsArray ) {
329+ materializedChildren = maybeRemoveStaleArrayFields ( prevData , value , this . identitySymbol ) ;
330+ } else {
331+ materializedChildren = maybeRemoveStaleObjectFields ( prevData , value ) ;
332+ }
333+ }
308334
309- let childNode : FieldNode | undefined ;
335+ // Now, go through the values and add any new ones.
336+ for ( const key of Object . keys ( value ) ) {
337+ let trackingKey : TrackingKey | undefined = undefined ;
338+ const childValue = value [ key ] as unknown ;
339+
340+ // Fields explicitly set to `undefined` are treated as if they don't exist.
341+ // This ensures that `{value: undefined}` and `{}` have the same behavior for their `value`
342+ // field.
343+ if ( childValue === undefined ) {
344+ // The value might have _become_ `undefined`, so we need to delete it here.
345+ if ( prevData . byPropertyKey . has ( key ) ) {
346+ materializedChildren ??= { ...( prevData as MutableChildrenData ) } ;
347+ materializedChildren . byPropertyKey . delete ( key ) ;
348+ }
349+ continue ;
350+ }
310351
311- if ( trackingKey ) {
312- // If tracking is in use, then the `FieldNode` instance is always managed via its
313- // tracking key. Create the instance if needed, or look it up otherwise.
314- if ( ! prevData . byTrackingKey ?. has ( trackingKey ) ) {
315- data ??= { ...( prevData as MutableChildrenData ) } ;
316- data . byTrackingKey ??= new Map ( ) ;
352+ if ( parentIsArray && isObject ( childValue ) && ! isArray ( childValue ) ) {
353+ // For object values in arrays, assign a synthetic identity. This will be used to
354+ // preserve the field instance even as this object moves around in the parent array.
355+ trackingKey = ( childValue [ this . identitySymbol ] as TrackingKey ) ??= Symbol (
356+ ngDevMode ? `id:${ globalId ++ } ` : '' ,
357+ ) as TrackingKey ;
358+ }
317359
318- data . byTrackingKey . set (
319- trackingKey ,
320- this . createChildNode ( key , trackingKey , parentIsArray ) ,
321- ) ;
322- }
360+ let childNode : FieldNode | undefined ;
323361
324- // Note: data ?? prevData is needed because we might have freshly instantiated
325- // `byTrackingKey` only in `data` above.
326- childNode = ( data ?? prevData ) . byTrackingKey ! . get ( trackingKey ) ! ;
327- }
362+ if ( trackingKey ) {
363+ // If tracking is in use, then the `FieldNode` instance is always managed via its
364+ // tracking key. Create the instance if needed, or look it up otherwise.
365+ if ( ! prevData . byTrackingKey ?. has ( trackingKey ) ) {
366+ materializedChildren ??= { ...( prevData as MutableChildrenData ) } ;
367+ materializedChildren . byTrackingKey ??= new Map ( ) ;
328368
329- // Next, make sure the `ChildData` for this key in `byPropertyKey` is up to date. We need
330- // to consider two cases:
331- //
332- // 1. No record exists for this field (yet).
333- // 2. A record does exist, but the field identity at this key has changed (only possible
334- // when fields are tracked).
335- const child = prevData . byPropertyKey . get ( key ) ;
336- if ( child === undefined ) {
337- // No record exists yet - create one.
338- data ??= { ...( prevData as MutableChildrenData ) } ;
339-
340- data . byPropertyKey . set ( key , {
341- // TODO: creating a computed per-key is overkill when the field at a key can't change
342- // (e.g. the value is not an array). Maybe this can be optimized?
343- reader : this . createReader ( key ) ,
344- // If tracking is in use, then it already created/found the `childNode` for this key.
345- // Otherwise we create the child field here.
346- node : childNode ?? this . createChildNode ( key , trackingKey , parentIsArray ) ,
347- } ) ;
348- } else if ( childNode && childNode !== child . node ) {
349- // A record exists, but records the wrong `FieldNode`. Update it.
350- data ??= { ...( prevData as MutableChildrenData ) } ;
351- child . node = childNode ;
352- }
369+ materializedChildren . byTrackingKey . set (
370+ trackingKey ,
371+ this . createChildNode ( key , trackingKey , parentIsArray ) ,
372+ ) ;
353373 }
354374
355- return data ?? prevData ;
356- } ,
357- } ) ;
375+ // Note: materializedChildren ?? prevData is needed because we might have freshly instantiated
376+ // `byTrackingKey` only in `materializedChildren` above.
377+ childNode = ( materializedChildren ?? prevData ) . byTrackingKey ! . get ( trackingKey ) ! ;
378+ }
379+
380+ // Next, make sure the `ChildData` for this key in `byPropertyKey` is up to date. We need
381+ // to consider two cases:
382+ //
383+ // 1. No record exists for this field (yet).
384+ // 2. A record does exist, but the field identity at this key has changed (only possible
385+ // when fields are tracked).
386+ const child = prevData . byPropertyKey . get ( key ) ;
387+ if ( child === undefined ) {
388+ // No record exists yet - create one.
389+ materializedChildren ??= { ...( prevData as MutableChildrenData ) } ;
390+
391+ materializedChildren . byPropertyKey . set ( key , {
392+ // TODO: creating a computed per-key is overkill when the field at a key can't change
393+ // (e.g. the value is not an array). Maybe this can be optimized?
394+ reader : this . createReader ( key ) ,
395+ // If tracking is in use, then it already created/found the `childNode` for this key.
396+ // Otherwise we create the child field here.
397+ node : childNode ?? this . createChildNode ( key , trackingKey , parentIsArray ) ,
398+ } ) ;
399+ } else if ( childNode && childNode !== child . node ) {
400+ // A record exists, but records the wrong `FieldNode`. Update it.
401+ materializedChildren ??= { ...( prevData as MutableChildrenData ) } ;
402+ child . node = childNode ;
403+ }
404+ }
405+
406+ return materializedChildren ?? prevData ;
358407 }
359408
360409 /**
0 commit comments