@@ -71,6 +71,19 @@ import type { SerializationContext } from '../shared/serdes/index';
7171import type { ISsrComponentFrame , ISsrNode , SSRContainer } from './ssr-types' ;
7272import { applyInlineComponent } from './ssr-render-component' ;
7373import { isAsyncGenerator } from '../shared/utils/async-generator' ;
74+ import type { SsrChild , SsrContentChild } from '../../server/ssr-node' ;
75+ import { VNodeFlags } from '../client/types' ;
76+ import { clearAllEffects } from '../reactive-primitives/cleanup' ;
77+ import type { Container } from '../shared/types' ;
78+ import { escapeHTML } from '../shared/utils/character-escaping' ;
79+
80+ // Inline equivalents to avoid circular dependency with ssr-node.ts (SsrNode extends VirtualVNode)
81+ const enum SsrNodeKindLocal {
82+ Text = 1 ,
83+ }
84+ function isSsrContentChild ( child : SsrChild ) : child is SsrContentChild {
85+ return 'kind' in child && ! ( 'id' in child ) ;
86+ }
7487
7588// ============================================================================
7689// SsrDiffContext
@@ -95,6 +108,14 @@ export interface SsrDiffContext extends BaseDiffContext {
95108 * resumption after the async operation completes.
96109 */
97110 $asyncBreak$ : boolean ;
111+
112+ // Reconciliation state (SSR re-render)
113+ /** Previous orderedChildren for reconciliation (null = creation mode) */
114+ $oldChildren$ : SsrChild [ ] | null ;
115+ /** Current index into $oldChildren$ */
116+ $oldChildIdx$ : number ;
117+ /** New orderedChildren being built during reconciliation */
118+ $newChildren$ : SsrChild [ ] | null ;
98119}
99120
100121function createSsrDiffContext (
@@ -125,6 +146,9 @@ function createSsrDiffContext(
125146 $parentComponentFrame$ : parentComponentFrame ,
126147 $closeStack$ : [ ] ,
127148 $asyncBreak$ : false ,
149+ $oldChildren$ : null ,
150+ $oldChildIdx$ : 0 ,
151+ $newChildren$ : null ,
128152 } ;
129153}
130154
@@ -140,29 +164,67 @@ function ssrDescend(
140164 closeCallback : ( ( ) => void ) | null = null
141165) {
142166 ctx . $closeStack$ . push ( closeCallback ) ;
167+ // Save reconciliation state on stack before baseStackPush saves JSX/VNode state
168+ if ( descendVNode ) {
169+ ctx . $stack$ . push ( ctx . $oldChildren$ , ctx . $oldChildIdx$ , ctx . $newChildren$ ) ;
170+ }
143171 baseStackPush ( ctx , children , descendVNode ) ;
144172 if ( descendVNode ) {
173+ const isReusing = ctx . $vCurrent$ !== null && ctx . $vNewNode$ === null ;
145174 const parent = ( ctx . $vNewNode$ || ctx . $vCurrent$ ) ! ;
146175 const parentVirtual = parent as unknown as VirtualVNode ;
147176 // Set VNode parent for dirty propagation — ensures markVNodeDirty can walk up to cursor root
148177 if ( ! parent . parent ) {
149178 parent . parent = ctx . $vParent$ ;
150179 }
151- ctx . $isCreationMode$ = ctx . $isCreationMode$ || ! ! ctx . $vNewNode$ || ! parentVirtual . firstChild ;
152180 ctx . $vSideBuffer$ = null ;
153181 ctx . $vSiblings$ = null ;
154182 ctx . $vSiblingsArray$ = null ;
155183 ctx . $vParent$ = parent ;
156184 ctx . $vCurrent$ = ( parentVirtual . firstChild as VNode | null ) ?? null ;
157185 ctx . $vNewNode$ = null ;
186+
187+ // Set up reconciliation state for children
188+ if ( isReusing ) {
189+ // Descending into a REUSED node — reconcile its children
190+ const existingChildren = ( parent as unknown as ISsrNode as any ) . orderedChildren as
191+ | SsrChild [ ]
192+ | null ;
193+ if ( existingChildren && existingChildren . length > 0 ) {
194+ ctx . $isCreationMode$ = false ;
195+ ctx . $oldChildren$ = existingChildren ;
196+ ctx . $oldChildIdx$ = 0 ;
197+ // Swap out orderedChildren so container methods add to the new array
198+ const newChildren : SsrChild [ ] = [ ] ;
199+ ( parent as unknown as ISsrNode as any ) . orderedChildren = newChildren ;
200+ ctx . $newChildren$ = newChildren ;
201+ } else {
202+ ctx . $isCreationMode$ = true ;
203+ ctx . $oldChildren$ = null ;
204+ ctx . $oldChildIdx$ = 0 ;
205+ ctx . $newChildren$ = null ;
206+ }
207+ } else {
208+ // Descending into a NEW node — pure creation mode
209+ ctx . $isCreationMode$ = true ;
210+ ctx . $oldChildren$ = null ;
211+ ctx . $oldChildIdx$ = 0 ;
212+ ctx . $newChildren$ = null ;
213+ }
158214 }
159215 ctx . $shouldAdvance$ = false ;
160216}
161217
162218/** Ascend from children, executing the close callback pushed during ssrDescend. */
163219function ssrAscend ( ctx : SsrDiffContext ) {
164220 const cb = ctx . $closeStack$ . pop ( ) ;
165- stackPopBase ( ctx ) ;
221+ const descendVNode = stackPopBase ( ctx ) ;
222+ if ( descendVNode ) {
223+ // Restore reconciliation state from stack
224+ ctx . $newChildren$ = ctx . $stack$ . pop ( ) ;
225+ ctx . $oldChildIdx$ = ctx . $stack$ . pop ( ) ;
226+ ctx . $oldChildren$ = ctx . $stack$ . pop ( ) ;
227+ }
166228 if ( cb ) {
167229 cb ( ) ;
168230 }
@@ -175,7 +237,100 @@ function ssrAdvance(ctx: SsrDiffContext) {
175237 if ( ctx . $asyncBreak$ ) {
176238 return ;
177239 }
178- baseAdvance ( ctx , ssrAscend ) ;
240+ if ( ctx . $oldChildren$ ) {
241+ // Reconciliation mode: use array-based navigation instead of VNode linked list.
242+ // This mirrors baseAdvance but increments $oldChildIdx$ instead of peekNextSibling.
243+ if ( ! ctx . $shouldAdvance$ ) {
244+ ctx . $shouldAdvance$ = true ;
245+ return ;
246+ }
247+ ctx . $jsxIdx$ ++ ;
248+ if ( ctx . $jsxIdx$ < ctx . $jsxCount$ ) {
249+ ctx . $jsxValue$ = ctx . $jsxChildren$ ! [ ctx . $jsxIdx$ ] ;
250+ } else if ( ctx . $stack$ . length > 0 && ctx . $stack$ [ ctx . $stack$ . length - 1 ] === false ) {
251+ // Non-VNode descend frame — auto-ascend
252+ return ssrAscend ( ctx ) ;
253+ }
254+ if ( ctx . $vNewNode$ !== null ) {
255+ // New node was inserted — clear it, keep old child cursor in place
256+ ctx . $vNewNode$ = null ;
257+ } else {
258+ // Move to next old child
259+ ctx . $oldChildIdx$ ++ ;
260+ }
261+ } else {
262+ baseAdvance ( ctx , ssrAscend ) ;
263+ }
264+ }
265+
266+ // ============================================================================
267+ // Reconciliation helpers
268+ // ============================================================================
269+
270+ /** Get current old child at cursor position */
271+ function getOldChild ( ctx : SsrDiffContext ) : SsrChild | null {
272+ return ctx . $oldChildren$ && ctx . $oldChildIdx$ < ctx . $oldChildren$ . length
273+ ? ctx . $oldChildren$ [ ctx . $oldChildIdx$ ]
274+ : null ;
275+ }
276+
277+ /** Check if an SsrChild is a text content child */
278+ function matchesText ( child : SsrChild ) : boolean {
279+ return isSsrContentChild ( child ) && ( child as any ) . kind === SsrNodeKindLocal . Text ;
280+ }
281+
282+ /** Throw if an SsrNode has already been emitted to the HTML stream */
283+ function assertNotEmitted ( node : ISsrNode , action : string ) : void {
284+ if ( ( node as any ) . flags & VNodeFlags . OpenTagEmitted ) {
285+ throw new Error ( `Cannot ${ action } already-emitted SsrNode during SSR re-render: ${ node . id } ` ) ;
286+ }
287+ }
288+
289+ /**
290+ * Clean up remaining unmatched old children after JSX is exhausted. Mirrors client's
291+ * expectNoMore().
292+ */
293+ function ssrExpectNoMore ( ctx : SsrDiffContext ) {
294+ if ( ! ctx . $oldChildren$ ) {
295+ return ;
296+ }
297+ const container = ctx . $container$ as unknown as Container ;
298+ for ( let i = ctx . $oldChildIdx$ ; i < ctx . $oldChildren$ . length ; i ++ ) {
299+ const child = ctx . $oldChildren$ [ i ] ;
300+ if ( ! isSsrContentChild ( child ) ) {
301+ const node = child as ISsrNode ;
302+ assertNotEmitted ( node , 'remove' ) ;
303+ clearAllEffects ( container , node as unknown as VNode ) ;
304+ cleanupSsrTree ( container , node ) ;
305+ }
306+ }
307+ }
308+
309+ /**
310+ * Recursively clean up signal subscriptions on an SsrNode tree. Walks orderedChildren depth-first,
311+ * calling clearAllEffects on each SsrNode.
312+ */
313+ function cleanupSsrTree ( container : Container , node : ISsrNode ) : void {
314+ const children = ( node as any ) . orderedChildren as SsrChild [ ] | null ;
315+ if ( children ) {
316+ for ( let i = 0 ; i < children . length ; i ++ ) {
317+ const child = children [ i ] ;
318+ if ( ! isSsrContentChild ( child ) ) {
319+ clearAllEffects ( container , child as unknown as VNode ) ;
320+ cleanupSsrTree ( container , child as ISsrNode ) ;
321+ }
322+ }
323+ }
324+ }
325+
326+ /**
327+ * Record a child (reused or new) in the reconciliation's newChildren list. In creation mode, also
328+ * adds to orderedChildren via the container.
329+ */
330+ function recordChild ( ctx : SsrDiffContext , child : SsrChild ) : void {
331+ if ( ctx . $newChildren$ ) {
332+ ctx . $newChildren$ . push ( child ) ;
333+ }
179334}
180335
181336// ============================================================================
@@ -220,11 +375,36 @@ function diff(ctx: SsrDiffContext, jsxNode: JSXChildren, vStartNode: VNode) {
220375 ctx . $vParent$ = vStartNode ;
221376 ctx . $vNewNode$ = null ;
222377 ctx . $vCurrent$ = ( ( vStartNode as unknown as VirtualVNode ) . firstChild as VNode | null ) ?? null ;
378+
379+ // Check for existing children → reconciliation mode.
380+ // Only enter reconciliation for component re-renders (hookChildCount explicitly set),
381+ // NOT for additive streaming writes where children accumulate from multiple ssrDiff calls.
382+ const ssrParent = vStartNode as unknown as ISsrNode ;
383+ const existing = ( ssrParent as any ) . orderedChildren as SsrChild [ ] | null ;
384+ const hookCountProp = ssrParent . getProp ?.( ':hookChildCount' ) ;
385+ if ( hookCountProp != null && existing && existing . length > ( hookCountProp as number ) ) {
386+ const hookCount = hookCountProp as number ;
387+ ctx . $isCreationMode$ = false ;
388+ // Save old children in a separate array for iteration
389+ ctx . $oldChildren$ = existing ;
390+ ctx . $oldChildIdx$ = hookCount ;
391+ // Replace parent's orderedChildren with a new array (keeping hook children).
392+ // Container methods (openElement, textNode, etc.) will add new nodes to this array.
393+ // Reused nodes are added via recordChild.
394+ const newChildren = existing . slice ( 0 , hookCount ) ;
395+ ( ssrParent as any ) . orderedChildren = newChildren ;
396+ ctx . $newChildren$ = newChildren ;
397+ }
398+
223399 // Root-level push: no close callback needed
224400 ctx . $closeStack$ . push ( null ) ;
225401 baseStackPush ( ctx , jsxNode , true ) ;
226402
227403 runDiffLoop ( ctx ) ;
404+
405+ // Clean up reconciliation state (orderedChildren already replaced above)
406+ ctx . $newChildren$ = null ;
407+ ctx . $oldChildren$ = null ;
228408}
229409
230410/** The inner while loop of diff — extracted so it can be resumed after async breaks. */
@@ -239,11 +419,11 @@ function runDiffLoop(ctx: SsrDiffContext) {
239419 const value = ctx . $jsxValue$ ;
240420
241421 if ( typeof value === 'string' ) {
242- ssr . textNode ( value ) ;
422+ ssrText ( ctx , ssr , value ) ;
243423 } else if ( typeof value === 'number' ) {
244- ssr . textNode ( String ( value ) ) ;
424+ ssrText ( ctx , ssr , String ( value ) ) ;
245425 } else if ( value == null || typeof value === 'boolean' ) {
246- ssr . textNode ( '' ) ;
426+ ssrText ( ctx , ssr , '' ) ;
247427 } else if ( typeof value === 'object' ) {
248428 if ( isJSXNode ( value ) ) {
249429 const jsx = value as JSXNodeInternal ;
@@ -287,6 +467,13 @@ function runDiffLoop(ctx: SsrDiffContext) {
287467 if ( ctx . $asyncBreak$ ) {
288468 return ;
289469 }
470+ // Clean up remaining unmatched old children before ascending
471+ ssrExpectNoMore ( ctx ) ;
472+ // Finalize orderedChildren for the node we're leaving
473+ if ( ctx . $newChildren$ && ctx . $vParent$ ) {
474+ const parentNode = ctx . $vParent$ as unknown as ISsrNode ;
475+ ( parentNode as any ) . orderedChildren = ctx . $newChildren$ ;
476+ }
290477 ssrAscend ( ctx ) ;
291478 }
292479}
@@ -345,6 +532,29 @@ function drainAsyncQueue(ctx: SsrDiffContext, savedBuildState: any): ValueOrProm
345532// JSX type handlers
346533// ============================================================================
347534
535+ /** Text node: reconcile against existing text or create new. */
536+ function ssrText ( ctx : SsrDiffContext , ssr : SSRContainer , text : string ) {
537+ if ( ctx . $oldChildren$ ) {
538+ const old = getOldChild ( ctx ) ;
539+ if ( old && matchesText ( old ) ) {
540+ // Reuse existing text — update content
541+ const escaped = escapeHTML ( text ) ;
542+ ( old as SsrContentChild ) . content = escaped ;
543+ ( old as SsrContentChild ) . textLength = text . length ;
544+ recordChild ( ctx , old ) ;
545+ // Don't set $vNewNode$ — text isn't a VNode. Advance will increment oldChildIdx.
546+ return ;
547+ }
548+ // No text match — create new via container method (adds to new orderedChildren).
549+ // Set $vNewNode$ sentinel to prevent advance from incrementing oldChildIdx
550+ // (the old child at current position wasn't consumed).
551+ ssr . textNode ( text ) ;
552+ ctx . $vNewNode$ = ctx . $vParent$ ; // sentinel: any non-null VNode
553+ return ;
554+ }
555+ ssr . textNode ( text ) ;
556+ }
557+
348558/** HTML element: open, descend into children, close on ascend. */
349559function ssrElement ( ctx : SsrDiffContext , jsx : JSXNodeInternal , tagName : string ) {
350560 const ssr = ctx . $container$ ;
0 commit comments