@@ -35,16 +35,118 @@ function firstMapValue<V>(map: Map<unknown, V>): V {
3535 throw new Error ( 'empty map' )
3636}
3737
38- function firstMapKey < K > ( map : Map < K , unknown > ) : K {
39- for ( const k of map . keys ( ) ) return k
40- throw new Error ( 'empty map' )
41- }
42-
4338function firstMapEntry < K , V > ( map : Map < K , V > ) : [ K , V ] {
4439 for ( const entry of map . entries ( ) ) return entry
4540 throw new Error ( 'empty map' )
4641}
4742
43+ /**
44+ * Build a stable merged order of child names across multiple variants/breakpoints.
45+ * Uses topological sort on a DAG of ordering constraints from all variants,
46+ * with average-position tie-breaking for deterministic output.
47+ *
48+ * Example: variant A has [Icon, TextA, Arrow], variant B has [Icon, TextB, Arrow]
49+ * → edges: Icon→TextA, TextA→Arrow, Icon→TextB, TextB→Arrow
50+ * → topo sort: [Icon, TextA, TextB, Arrow] (Arrow stays last)
51+ */
52+ function mergeChildNameOrder (
53+ childrenMaps : Map < unknown , Map < string , NodeTree [ ] > > ,
54+ ) : string [ ] {
55+ // Collect distinct child name sequences from each variant
56+ const sequences : string [ ] [ ] = [ ]
57+ for ( const childMap of childrenMaps . values ( ) ) {
58+ const seq : string [ ] = [ ]
59+ for ( const name of childMap . keys ( ) ) {
60+ seq . push ( name )
61+ }
62+ sequences . push ( seq )
63+ }
64+
65+ // Collect all unique names
66+ const allNames = new Set < string > ( )
67+ for ( const seq of sequences ) {
68+ for ( const name of seq ) {
69+ allNames . add ( name )
70+ }
71+ }
72+
73+ if ( allNames . size === 0 ) return [ ]
74+ if ( allNames . size === 1 ) return [ ...allNames ]
75+
76+ // Build DAG: for each variant, add edge from consecutive distinct names
77+ const edges = new Map < string , Set < string > > ( )
78+ const inDegree = new Map < string , number > ( )
79+ for ( const name of allNames ) {
80+ edges . set ( name , new Set ( ) )
81+ inDegree . set ( name , 0 )
82+ }
83+
84+ for ( const seq of sequences ) {
85+ for ( let i = 0 ; i < seq . length - 1 ; i ++ ) {
86+ const from = seq [ i ]
87+ const to = seq [ i + 1 ]
88+ const fromEdges = edges . get ( from )
89+ if ( fromEdges && ! fromEdges . has ( to ) ) {
90+ fromEdges . add ( to )
91+ inDegree . set ( to , ( inDegree . get ( to ) || 0 ) + 1 )
92+ }
93+ }
94+ }
95+
96+ // Compute average normalized position for tie-breaking
97+ const avgPosition = new Map < string , number > ( )
98+ for ( const name of allNames ) {
99+ let totalPos = 0
100+ let count = 0
101+ for ( const seq of sequences ) {
102+ const idx = seq . indexOf ( name )
103+ if ( idx >= 0 ) {
104+ // Normalize to 0..1 range
105+ totalPos += seq . length > 1 ? idx / ( seq . length - 1 ) : 0.5
106+ count ++
107+ }
108+ }
109+ avgPosition . set ( name , count > 0 ? totalPos / count : 0.5 )
110+ }
111+
112+ // Kahn's algorithm with priority-based tie-breaking
113+ const queue : string [ ] = [ ]
114+ for ( const [ name , deg ] of inDegree ) {
115+ if ( deg === 0 ) queue . push ( name )
116+ }
117+ // Sort initial queue by average position (stable)
118+ queue . sort ( ( a , b ) => ( avgPosition . get ( a ) || 0 ) - ( avgPosition . get ( b ) || 0 ) )
119+
120+ const result : string [ ] = [ ]
121+ while ( queue . length > 0 ) {
122+ const node = queue . shift ( )
123+ if ( ! node ) break
124+ result . push ( node )
125+ for ( const neighbor of edges . get ( node ) || [ ] ) {
126+ const newDeg = ( inDegree . get ( neighbor ) || 1 ) - 1
127+ inDegree . set ( neighbor , newDeg )
128+ if ( newDeg === 0 ) {
129+ queue . push ( neighbor )
130+ // Re-sort to maintain priority order
131+ queue . sort (
132+ ( a , b ) => ( avgPosition . get ( a ) || 0 ) - ( avgPosition . get ( b ) || 0 ) ,
133+ )
134+ }
135+ }
136+ }
137+
138+ // Cycle fallback: append any remaining nodes (shouldn't happen with consistent data)
139+ if ( result . length < allNames . size ) {
140+ for ( const name of allNames ) {
141+ if ( ! result . includes ( name ) ) {
142+ result . push ( name )
143+ }
144+ }
145+ }
146+
147+ return result
148+ }
149+
48150/**
49151 * Generate responsive code by merging children inside a Section.
50152 * Uses Codegen to build NodeTree for each breakpoint, then merges them.
@@ -212,35 +314,15 @@ export class ResponsiveCodegen {
212314
213315 // Merge children by name
214316 const childrenCodes : string [ ] = [ ]
215- const processedChildNames = new Set < string > ( )
216317
217318 // Convert all trees' children to maps
218319 const childrenMaps = new Map < BreakpointKey , Map < string , NodeTree [ ] > > ( )
219320 for ( const [ bp , tree ] of treesByBreakpoint ) {
220321 childrenMaps . set ( bp , this . treeChildrenToMap ( tree ) )
221322 }
222323
223- // Get all child names in order (first tree's order, then others)
224- const firstBreakpoint = firstMapKey ( treesByBreakpoint )
225- const firstChildrenMap = childrenMaps . get ( firstBreakpoint )
226- const allChildNames : string [ ] = [ ]
227-
228- if ( firstChildrenMap ) {
229- for ( const name of firstChildrenMap . keys ( ) ) {
230- allChildNames . push ( name )
231- processedChildNames . add ( name )
232- }
233- }
234-
235- // Add children that exist only in other breakpoints
236- for ( const childMap of childrenMaps . values ( ) ) {
237- for ( const name of childMap . keys ( ) ) {
238- if ( ! processedChildNames . has ( name ) ) {
239- allChildNames . push ( name )
240- processedChildNames . add ( name )
241- }
242- }
243- }
324+ // Get all child names in stable merged order across all breakpoints
325+ const allChildNames = mergeChildNameOrder ( childrenMaps )
244326
245327 for ( const childName of allChildNames ) {
246328 // Find the maximum number of children with this name across all breakpoints
@@ -991,26 +1073,8 @@ export class ResponsiveCodegen {
9911073 childrenMaps . set ( bp , this . treeChildrenToMap ( tree ) )
9921074 }
9931075
994- const processedChildNames = new Set < string > ( )
995- const allChildNames : string [ ] = [ ]
996- const firstBreakpoint = firstMapKey ( treesByBreakpoint )
997- const firstChildrenMap = childrenMaps . get ( firstBreakpoint )
998-
999- if ( firstChildrenMap ) {
1000- for ( const name of firstChildrenMap . keys ( ) ) {
1001- allChildNames . push ( name )
1002- processedChildNames . add ( name )
1003- }
1004- }
1005-
1006- for ( const childMap of childrenMaps . values ( ) ) {
1007- for ( const name of childMap . keys ( ) ) {
1008- if ( ! processedChildNames . has ( name ) ) {
1009- allChildNames . push ( name )
1010- processedChildNames . add ( name )
1011- }
1012- }
1013- }
1076+ // Get all child names in stable merged order across all breakpoints
1077+ const allChildNames = mergeChildNameOrder ( childrenMaps )
10141078
10151079 const mergedChildren : NodeTree [ ] = [ ]
10161080
@@ -1111,26 +1175,8 @@ export class ResponsiveCodegen {
11111175 childrenMaps . set ( variant , this . treeChildrenToMap ( tree ) )
11121176 }
11131177
1114- const processedChildNames = new Set < string > ( )
1115- const allChildNames : string [ ] = [ ]
1116- const firstVariant = firstMapKey ( treesByVariant )
1117- const firstChildrenMap = childrenMaps . get ( firstVariant )
1118-
1119- if ( firstChildrenMap ) {
1120- for ( const name of firstChildrenMap . keys ( ) ) {
1121- allChildNames . push ( name )
1122- processedChildNames . add ( name )
1123- }
1124- }
1125-
1126- for ( const childMap of childrenMaps . values ( ) ) {
1127- for ( const name of childMap . keys ( ) ) {
1128- if ( ! processedChildNames . has ( name ) ) {
1129- allChildNames . push ( name )
1130- processedChildNames . add ( name )
1131- }
1132- }
1133- }
1178+ // Get all child names in stable merged order across all variants
1179+ const allChildNames = mergeChildNameOrder ( childrenMaps )
11341180
11351181 for ( const childName of allChildNames ) {
11361182 let maxChildCount = 0
@@ -1338,27 +1384,8 @@ export class ResponsiveCodegen {
13381384 childrenMaps . set ( compositeKey , this . treeChildrenToMap ( tree ) )
13391385 }
13401386
1341- // Get all unique child names
1342- const processedChildNames = new Set < string > ( )
1343- const allChildNames : string [ ] = [ ]
1344- const firstComposite = firstMapKey ( treesByComposite )
1345- const firstChildrenMap = childrenMaps . get ( firstComposite )
1346-
1347- if ( firstChildrenMap ) {
1348- for ( const name of firstChildrenMap . keys ( ) ) {
1349- allChildNames . push ( name )
1350- processedChildNames . add ( name )
1351- }
1352- }
1353-
1354- for ( const childMap of childrenMaps . values ( ) ) {
1355- for ( const name of childMap . keys ( ) ) {
1356- if ( ! processedChildNames . has ( name ) ) {
1357- allChildNames . push ( name )
1358- processedChildNames . add ( name )
1359- }
1360- }
1361- }
1387+ // Get all unique child names in stable merged order across all composites
1388+ const allChildNames = mergeChildNameOrder ( childrenMaps )
13621389
13631390 // Process each child
13641391 for ( const childName of allChildNames ) {
0 commit comments