Skip to content

Commit 1bf2eab

Browse files
committed
make e2e pass
1 parent 046b1ae commit 1bf2eab

6 files changed

Lines changed: 315 additions & 20 deletions

File tree

packages/qwik/src/core/reactive-primitives/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ export const scheduleEffects = (
9292
if (effects) {
9393
const scheduleEffect = (effectSubscription: EffectSubscription) => {
9494
const consumer = effectSubscription.consumer;
95+
if (!consumer || (consumer as any).nodeType !== undefined) {
96+
// Orphaned subscription — consumer was an SsrNode that was never emitted during SSR.
97+
// Deserialized as Document (VNode with empty ID). Skip — the subscription is stale.
98+
// VNodes don't have nodeType; DOM nodes (Document) do.
99+
return;
100+
}
95101
const property = effectSubscription.property;
96102
isDev && assertDefined(container, 'Container must be defined.');
97103
if (isTask(consumer)) {

packages/qwik/src/core/shared/cursor/ssr-chore-execution.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,10 @@ export function executeSsrNodeDiff(
152152
const ssr = container as SSRContainer;
153153
const styleScopedId = addComponentStylePrefix(ssrNode.getProp?.(QScopedStyle));
154154

155-
// Clear content children from previous ssrDiff, preserving hook-injected children
156-
// (e.g., style elements from useStylesScoped$). Hooks run during executeSsrComponent
157-
// and add children before NODE_DIFF runs. They use sequential scope caching so won't
158-
// re-register on re-render. The :hookChildCount boundary is set by executeSsrComponent.
159-
const orderedChildren = (ssrNode as any).orderedChildren;
160-
const hookChildCount = ssrNode.getProp?.(':hookChildCount') ?? 0;
161-
if (orderedChildren && orderedChildren.length > hookChildCount) {
162-
orderedChildren.length = hookChildCount;
163-
}
155+
// Reconciliation of existing children is handled inside ssrDiff's diff() function.
156+
// It detects existing children when :hookChildCount is set (by executeSsrComponent)
157+
// and reconciles them: reusing matching nodes, creating new ones, and cleaning up
158+
// unmatched orphans via clearAllEffects.
164159

165160
// For component nodes (have OnRenderProp), the component context was already
166161
// pushed by executeSsrComponent. Use it and pop when done.

packages/qwik/src/core/shared/serdes/inflate.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { isServer } from '@qwik.dev/core/build';
21
import {
32
vnode_getFirstChild,
43
vnode_getProp,
@@ -32,7 +31,6 @@ import { qError, QError } from '../error/error';
3231
import { JSXNodeImpl } from '../jsx/jsx-node';
3332
import { Fragment, Props } from '../jsx/jsx-runtime';
3433
import { PropsProxy } from '../jsx/props-proxy';
35-
import { isServerPlatform } from '../platform/platform';
3634
import type { QRLInternal } from '../qrl/qrl-class';
3735
import type { DeserializeContainer, HostElement } from '../types';
3836
import { _OWNER, _PROPS_HANDLER, _UNINITIALIZED } from '../utils/constants';
@@ -365,10 +363,11 @@ export function inflateWrappedSignalValue(signal: WrappedSignalImpl<unknown>) {
365363
}
366364

367365
function restoreEffectBackRefForConsumer(effect: EffectSubscription): void {
368-
const isServerSide = import.meta.env.TEST ? isServerPlatform() : isServer;
369366
const consumerBackRef = effect.consumer as BackRef;
370-
if (isServerSide && !consumerBackRef) {
371-
// on browser, we don't serialize for example VNodes, so then on server side we don't have consumer
367+
if (!consumerBackRef || (consumerBackRef as any).nodeType !== undefined) {
368+
// Consumer may be null/Document if the SsrNode was orphaned (never emitted) during SSR
369+
// re-renders. Deserialized as Document (VNode with empty ID). Skip — the subscription is stale.
370+
// Also handles browser case where VNodes aren't serialized.
372371
return;
373372
}
374373
consumerBackRef[_EFFECT_BACK_REF] ||= new Map();

packages/qwik/src/core/ssr/ssr-diff.ts

Lines changed: 216 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ import type { SerializationContext } from '../shared/serdes/index';
7171
import type { ISsrComponentFrame, ISsrNode, SSRContainer } from './ssr-types';
7272
import { applyInlineComponent } from './ssr-render-component';
7373
import { 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

100121
function 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. */
163219
function 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. */
349559
function ssrElement(ctx: SsrDiffContext, jsx: JSXNodeInternal, tagName: string) {
350560
const ssr = ctx.$container$;

0 commit comments

Comments
 (0)