@@ -117,7 +117,34 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
117117
118118 const [ isDebugModalOpen , setIsDebugModalOpen ] = useState < boolean > ( false )
119119
120- const [ expandedKeys , setExpandedKeys ] = useState < TFormName [ ] > ( expandedPaths || [ ] )
120+ // Create a React state variable called `expandedKeys` to store the current expanded form paths.
121+ // `_setExpandedKeys` is the internal setter returned by `useState`, but we’ll wrap it below.
122+ const [ expandedKeys , _setExpandedKeys ] = useState < TFormName [ ] > ( expandedPaths || [ ] )
123+ // Create a mutable ref that always holds the latest value of `expandedKeys`.
124+ // Unlike React state, updating a ref does *not* trigger a re-render —
125+ // this lets us access the most recent expansion list even inside stale closures or async callbacks.
126+ const expandedKeysRef = useRef < TFormName [ ] > ( expandedPaths || [ ] )
127+ // Define our own wrapper function `setExpandedKeys`
128+ // so we can update *both* the state and the ref at the same time.
129+ // This ensures they always stay in sync.
130+ const setExpandedKeys = (
131+ next : TFormName [ ] | ( ( prev : TFormName [ ] ) => TFormName [ ] ) , // can pass a new array OR an updater function
132+ ) => {
133+ // Call the internal React setter `_setExpandedKeys`.
134+ // It can accept a callback that receives the previous value (`prev`).
135+ _setExpandedKeys ( prev => {
136+ // Determine the new value:
137+ // - If `next` is a function, call it with the previous array to get the new one.
138+ // - If it’s already an array, use it directly.
139+ const value = typeof next === 'function' ? ( next as ( p : TFormName [ ] ) => TFormName [ ] ) ( prev ) : next
140+ // Immediately update the ref so it always mirrors the latest value.
141+ // This is crucial because state updates are asynchronous,
142+ // but ref updates are synchronous and happen right away.
143+ expandedKeysRef . current = value
144+ // 7️⃣ Return the new value to React so it updates the state as usual.
145+ return value
146+ } )
147+ }
121148 const [ persistedKeys , setPersistedKeys ] = useState < TFormName [ ] > ( persistedPaths || [ ] )
122149 const [ resolvedHiddenPaths , setResolvedHiddenPaths ] = useState < TFormName [ ] > ( [ ] )
123150
@@ -493,7 +520,7 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
493520 * Builds the payload and triggers the debounced sync-to-YAML call.
494521 */
495522 const onValuesChangeCallback = useCallback (
496- ( values ?: any ) => {
523+ ( values ?: any , changedValues ?: any ) => {
497524 // Get the most recent form values (or use the provided ones)
498525 const vRaw = values ?? form . getFieldsValue ( true )
499526 const v = scrubLiteralWildcardKeys ( vRaw )
@@ -535,38 +562,111 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
535562 group ( 'values change' )
536563 dbg ( 'values snapshot keys' , Object . keys ( v || { } ) )
537564
565+ // 🔹 Helper function to extract a "root" or "head" portion of a path array.
566+ // Example: if a full path is ['spec', 'addressGroups', 0, 'name']
567+ // then headPath(p) → ['spec', 'addressGroups', 0]
568+ // This helps us identify which top-level object/array the change happened in.
569+ const headPath = ( p : ( string | number ) [ ] ) => {
570+ // You can control how deep you consider something to be the "root" of a change.
571+ // slice(0, 3) means we take the first three segments.
572+ // So a path like ['spec','containers',0,'env',0,'name'] becomes ['spec','containers',0].
573+ // If you want broader or narrower scoping, adjust this number.
574+ return p . slice ( 0 , 3 )
575+ }
576+
577+ // 🔹 Create a Set to hold the unique "root paths" of everything that changed in this render.
578+ // We’ll use these roots later to decide which arrays are safe to purge expansions for.
579+ const changedRoots = new Set < string > ( )
580+
581+ // 🔹 If Ant Design’s `onValuesChange` gave us a `changedValues` object (it does),
582+ // and it’s a normal object, collect all the individual field paths inside it.
583+ if ( changedValues && typeof changedValues === 'object' ) {
584+ // `getAllPathsFromObj` returns arrays of keys/indexes for every nested field.
585+ // Example:
586+ // changedValues = { spec: { addressGroups: [ { name: "new" } ] } }
587+ // → getAllPathsFromObj(changedValues)
588+ // returns [ ['spec'], ['spec','addressGroups'], ['spec','addressGroups',0], ['spec','addressGroups',0,'name'] ]
589+ const changedPaths = getAllPathsFromObj ( changedValues )
590+
591+ // 🔹 For each changed path, derive its "root" using headPath(),
592+ // then store it as a JSON string in our Set.
593+ // Using JSON.stringify lets us easily compare path arrays later.
594+ for ( const p of changedPaths ) {
595+ changedRoots . add ( JSON . stringify ( headPath ( p ) ) )
596+ }
597+ }
598+
538599 const newLengths = collectArrayLengths ( v )
539600 const prevLengths = prevArrayLengthsRef . current
540601
541- // If you delete arr el and then add it again. There is no purge
542- // Adding purge:
602+ // We previously had no purge when you delete an array element and add it again.
603+ // This block adds a *safe* purge for array SHRIΝKs (when items are actually removed).
604+
543605 // --- handle SHRINK: indices removed ---
606+ // IMPORTANT: Only treat an array as "shrunk" if it appears in *both* snapshots.
607+ // If it's missing from `newLengths`, we don't know its real length (could be a transient omission),
608+ // so we must NOT assume length 0 and accidentally purge unrelated expansions.
544609 for ( const [ k , prevLen ] of prevLengths . entries ( ) ) {
545- const newLen = newLengths . get ( k ) ?? 0
610+ if ( ! newLengths . has ( k ) ) {
611+ // Array is absent in the new snapshot → consider length "unknown", skip purging.
612+ // (Prevents false positives where another part of the form caused a temporary omission.)
613+ // eslint-disable-next-line no-continue
614+ continue
615+ }
616+
617+ // Safe: the array exists in both snapshots; compare lengths.
618+ const newLen = newLengths . get ( k ) !
546619 if ( newLen < prevLen ) {
620+ // We detected a real shrink: some trailing indices were removed.
547621 const arrayPath = JSON . parse ( k ) as ( string | number ) [ ]
622+
623+ // OPTIONAL SCOPE: If we captured change roots via `changedValues`,
624+ // only purge when this array is inside one of those roots.
625+ // This prevents edits under A from purging expansions under B.
626+ if ( changedRoots . size ) {
627+ const shouldPurge = [ ...changedRoots ] . some ( rootJson =>
628+ isPrefix ( arrayPath , JSON . parse ( rootJson ) as ( string | number ) [ ] ) ,
629+ )
630+ if ( ! shouldPurge ) {
631+ console . debug ( '[shrink] skipped unrelated array:' , arrayPath )
632+ // eslint-disable-next-line no-continue
633+ continue
634+ }
635+ }
636+
637+ // Purge UI state for each removed index (from newLen up to prevLen - 1).
548638 for ( let i = newLen ; i < prevLen ; i ++ ) {
549- // purge UI state + tombstones under removed index
550- const removedPrefix = [ ...arrayPath , i ]
639+ const removedPrefix = [ ...arrayPath , i ] // e.g., ['spec','addressGroups', 2]
551640
552- // drop expansions/persisted under this subtree
553- setExpandedKeys ( prev =>
554- prev . filter ( p => {
641+ // Drop EXPANSION state anywhere under the removed element's subtree.
642+ // (Prevents "phantom" expanded panels for items that no longer exist.)
643+ setExpandedKeys ( prev => {
644+ const before = prev . length
645+ const next = prev . filter ( p => {
555646 const full = Array . isArray ( p ) ? p : [ p ]
556647 return ! isPrefix ( full , removedPrefix )
557- } ) ,
558- )
559- setPersistedKeys ( prev =>
560- prev . filter ( p => {
648+ } )
649+ console . debug ( '[shrink] expanded pruned:' , before - next . length , 'under' , removedPrefix )
650+ return next
651+ } )
652+
653+ // Drop PERSISTED markers under the same subtree.
654+ // (Prevents keeping persistence flags for fields that were deleted.)
655+ setPersistedKeys ( prev => {
656+ const before = prev . length
657+ const next = prev . filter ( p => {
561658 const full = Array . isArray ( p ) ? p : [ p ]
562659 return ! isPrefix ( full , removedPrefix )
563- } ) ,
564- )
660+ } )
661+ console . debug ( '[shrink] persisted pruned:' , before - next . length , 'under' , removedPrefix )
662+ return next
663+ } )
565664
566- // clear any blocks (tombstones) beneath removed index
567- for ( const k of [ ...blockedPathsRef . current ] ) {
568- const path = JSON . parse ( k ) as ( string | number ) [ ]
569- if ( isPrefix ( path , removedPrefix ) ) blockedPathsRef . current . delete ( k )
665+ // Clear any "tombstone" blocks for paths under the removed element,
666+ // so that if a new element is added later, it won't be blocked from materializing.
667+ for ( const bk of [ ...blockedPathsRef . current ] ) {
668+ const path = JSON . parse ( bk ) as ( string | number ) [ ]
669+ if ( isPrefix ( path , removedPrefix ) ) blockedPathsRef . current . delete ( bk )
570670 }
571671 }
572672 }
@@ -710,6 +810,22 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
710810 if ( dataPathSet . has ( k ) ) blockedPathsRef . current . delete ( k )
711811 } )
712812
813+ // 🧩 When YAML → values sync finishes, the backend sends a brand-new `data` object,
814+ // and we immediately call `form.setFieldsValue(data)` to replace all form fields.
815+ //
816+ // ⚠️ That replacement often causes parts of the form UI (especially dynamic arrays
817+ // and nested objects) to unmount and remount — because React sees new keys,
818+ // new field paths, or different schema nodes.
819+ //
820+ // 👇 This line restores the user’s previous expansion state (`expandedKeysRef.current`)
821+ // right after we inject the new values, so any sections the user had expanded
822+ // stay visible instead of collapsing back to their default closed state.
823+ //
824+ // TL;DR — Without this line, every time YAML updates the form, all expanded panels
825+ // would snap shut; this re-applies the last known expansion model immediately
826+ // to preserve a stable, intuitive editing experience.
827+ setExpandedKeys ( expandedKeysRef . current )
828+
713829 // --- Bring schema in sync: prune missing additional props, then materialize new ones ---
714830 setProperties ( prevProps => {
715831 // Remove additionalProperties entries that are now absent or blocked
@@ -742,6 +858,27 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
742858 return merged
743859 } )
744860 }
861+
862+ // 🧠 Why we need this:
863+ // Updating `setProperties(...)` can cause the form schema to re-render.
864+ // That re-render might temporarily unmount and recreate form sections
865+ // (especially when `additionalProperties` or dynamic arrays change).
866+ //
867+ // ⚠️ React applies the `setProperties` state update asynchronously.
868+ // If we immediately call `setExpandedKeys` *inside* this callback,
869+ // the UI may not yet reflect the new schema — our expansion restore
870+ // could run too early and be lost during the render that follows.
871+ //
872+ // ✅ Wrapping it in `queueMicrotask(...)` schedules the restore to run
873+ // right after React finishes applying the `setProperties` update,
874+ // but before the browser paints. That guarantees the expansion state
875+ // is re-applied *after* the new schema has stabilized.
876+ //
877+ // TL;DR — Wait one micro-tick after schema update, then re-apply expansions
878+ // so newly materialized fields appear in the correct expanded state
879+ // and nothing collapses due to the schema refresh.
880+ queueMicrotask ( ( ) => setExpandedKeys ( expandedKeysRef . current ) )
881+
745882 return materialized
746883 } )
747884 } )
@@ -1027,7 +1164,11 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
10271164 < >
10281165 < Styled . Container $designNewLayout = { designNewLayout } $designNewLayoutHeight = { designNewLayoutHeight } >
10291166 < Styled . OverflowContainer ref = { overflowRef } >
1030- < Form form = { form } initialValues = { initialValues } onValuesChange = { onValuesChangeCallback } >
1167+ < Form
1168+ form = { form }
1169+ initialValues = { initialValues }
1170+ onValuesChange = { ( _changedValues , all ) => onValuesChangeCallback ( all , _changedValues ) }
1171+ >
10311172 < DesignNewLayoutProvider value = { designNewLayout } >
10321173 < OnValuesChangeCallbackProvider value = { onValuesChangeCallback } >
10331174 < IsTouchedPersistedProvider value = { { } } >
0 commit comments