From 1526b2dcad6b337f01c6622f0926a1b54bb2b930 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 19 Mar 2026 11:36:12 +0100 Subject: [PATCH 01/18] refactor(core): extract shared cursor runtime and SSR interfaces --- packages/qwik/src/core/client/vnode-diff.ts | 128 +--------- packages/qwik/src/core/client/vnode-utils.ts | 32 ++- .../core/reactive-primitives/subscriber.ts | 14 +- .../src/core/reactive-primitives/types.ts | 5 +- .../src/core/reactive-primitives/utils.ts | 17 +- .../src/core/shared/component-execution.ts | 42 +++- .../src/core/shared/cursor/chore-execution.ts | 226 +++++------------- .../shared/cursor/chore-execution.unit.ts | 2 + .../src/core/shared/cursor/chore-helpers.ts | 202 ++++++++++++++++ .../src/core/shared/cursor/chore-runtime.ts | 97 ++++++++ .../src/core/shared/cursor/cursor-props.ts | 11 +- .../core/shared/cursor/cursor-walker.spec.ts | 2 + .../src/core/shared/cursor/cursor-walker.ts | 158 ++++++++++-- .../qwik/src/core/shared/cursor/cursor.ts | 3 + packages/qwik/src/core/shared/diff-context.ts | 158 ++++++++++++ .../qwik/src/core/shared/serdes/inflate.ts | 9 +- .../shared/serdes/serialization-context.ts | 2 +- .../qwik/src/core/shared/serdes/serialize.ts | 2 +- packages/qwik/src/core/shared/types.ts | 9 +- .../shared/vnode/enums/chore-bits.enum.ts | 4 +- .../qwik/src/core/shared/vnode/vnode-dirty.ts | 22 +- 21 files changed, 779 insertions(+), 366 deletions(-) create mode 100644 packages/qwik/src/core/shared/cursor/chore-helpers.ts create mode 100644 packages/qwik/src/core/shared/cursor/chore-runtime.ts create mode 100644 packages/qwik/src/core/shared/diff-context.ts diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index fc086bff398..9f76c4f7e40 100644 --- a/packages/qwik/src/core/client/vnode-diff.ts +++ b/packages/qwik/src/core/client/vnode-diff.ts @@ -63,6 +63,13 @@ import { trackSignalAndAssignHost } from '../use/use-core'; import { TaskFlags, isTask } from '../use/use-task'; import { cleanupDestroyable } from '../use/utils/destroyable'; import { runEventHandlerQRL } from '../client/run-qrl'; +import { + type BaseDiffContext, + advance as baseAdvance, + stackPush as baseStackPush, + stackPopBase, + peekNextSibling, +} from '../shared/diff-context'; import { VNodeFlags, type ClientContainer } from './types'; import { mapApp_findIndx, mapArray_set } from './util-mapArray'; import { getNewElementNamespaceData } from './vnode-namespace'; @@ -98,60 +105,18 @@ import { } from './vnode-utils'; import { isObjectEmpty } from '../shared/utils/objects'; -export interface DiffContext { +export interface DiffContext extends BaseDiffContext { $container$: ClientContainer; $journal$: VNodeJournal; - $cursor$: Cursor; - $scopedStyleIdPrefix$: string | null; - /** - * Stack is used to keep track of the state of the traversal. - * - * We push current state into the stack before descending into the child, and we pop the state - * when we are done with the child. - */ - $stack$: any[]; - $asyncQueue$: Array | Promise>; $asyncAttributePromises$: Promise[]; - //////////////////////////////// - //// Traverse state variables - //////////////////////////////// + // Narrow parent type for client (elements have DOM nodes) $vParent$: ElementVNode | VirtualVNode; - /// Current node we compare against. (Think of it as a cursor.) - /// (Node can be null, if we are at the end of the list.) - $vCurrent$: VNode | null; - /// When we insert new node we start it here so that we can descend into it. - /// NOTE: it can't be stored in `vCurrent` because `vNewNode` is in journal - /// and is not connected to the tree. - $vNewNode$: VNode | null; - $vSiblings$: Map | null; - /// The array even indices will contains keys and odd indices the non keyed siblings. - $vSiblingsArray$: Array | null; - /// Side buffer to store nodes that are moved out of order during key scanning. - /// This contains nodes that were found before the target key and need to be moved later. - $vSideBuffer$: Map | null; - /// Current set of JSX children. - $jsxChildren$: JSXChildren[] | null; - // Current JSX child. - $jsxValue$: JSXChildren | null; - $jsxIdx$: number; - $jsxCount$: number; - // When we descend into children, we need to skip advance() because we just descended. - $shouldAdvance$: boolean; - $isCreationMode$: boolean; $subscriptionData$: { $const$: SubscriptionData; $var$: SubscriptionData; }; } -/** - * Helper to get the next sibling of a VNode. Extracted to module scope to help V8 inline it - * reliably. - */ -function peekNextSibling(vCurrent: VNode | null): VNode | null { - return vCurrent ? (vCurrent.nextSibling as VNode | null) : null; -} - const _hasOwnProperty = Object.prototype.hasOwnProperty; /** Helper to set an attribute on a vnode. Extracted to module scope to avoid closure allocation. */ @@ -386,29 +351,7 @@ function resolveSignalAndDescend( } function advance(diffContext: DiffContext) { - if (!diffContext.$shouldAdvance$) { - diffContext.$shouldAdvance$ = true; - return; - } - diffContext.$jsxIdx$++; - if (diffContext.$jsxIdx$ < diffContext.$jsxCount$) { - diffContext.$jsxValue$ = diffContext.$jsxChildren$![diffContext.$jsxIdx$]; - } else if ( - diffContext.$stack$.length > 0 && - diffContext.$stack$[diffContext.$stack$.length - 1] === false - ) { - // this was special `descendVNode === false` so pop and try again - return ascend(diffContext); - } - if (diffContext.$vNewNode$ !== null) { - // We have a new Node. - // This means that the `vCurrent` was deemed not useful and we inserted in front of it. - // This means that the next node we should look at is the `vCurrent` so just clear the - // vNewNode and try again. - diffContext.$vNewNode$ = null; - } else { - diffContext.$vCurrent$ = peekNextSibling(diffContext.$vCurrent$); - } + baseAdvance(diffContext, ascend); } /** @@ -469,59 +412,12 @@ function descend( } function ascend(diffContext: DiffContext) { - const descendVNode = diffContext.$stack$.pop(); // boolean: descendVNode - if (descendVNode) { - diffContext.$isCreationMode$ = diffContext.$stack$.pop(); - diffContext.$vSideBuffer$ = diffContext.$stack$.pop(); - diffContext.$vSiblings$ = diffContext.$stack$.pop(); - diffContext.$vSiblingsArray$ = diffContext.$stack$.pop(); - diffContext.$vNewNode$ = diffContext.$stack$.pop(); - diffContext.$vCurrent$ = diffContext.$stack$.pop(); - diffContext.$vParent$ = diffContext.$stack$.pop(); - } - diffContext.$jsxValue$ = diffContext.$stack$.pop(); - diffContext.$jsxCount$ = diffContext.$stack$.pop(); - diffContext.$jsxIdx$ = diffContext.$stack$.pop(); - diffContext.$jsxChildren$ = diffContext.$stack$.pop(); + stackPopBase(diffContext); advance(diffContext); } function stackPush(diffContext: DiffContext, children: JSXChildren, descendVNode: boolean) { - diffContext.$stack$.push( - diffContext.$jsxChildren$, - diffContext.$jsxIdx$, - diffContext.$jsxCount$, - diffContext.$jsxValue$ - ); - if (descendVNode) { - diffContext.$stack$.push( - diffContext.$vParent$, - diffContext.$vCurrent$, - diffContext.$vNewNode$, - diffContext.$vSiblingsArray$, - diffContext.$vSiblings$, - diffContext.$vSideBuffer$, - diffContext.$isCreationMode$ - ); - } - diffContext.$stack$.push(descendVNode); - if (Array.isArray(children)) { - diffContext.$jsxIdx$ = 0; - diffContext.$jsxCount$ = children.length; - diffContext.$jsxChildren$ = children; - diffContext.$jsxValue$ = diffContext.$jsxCount$ > 0 ? children[0] : null; - } else if (children === undefined) { - // no children - diffContext.$jsxIdx$ = 0; - diffContext.$jsxValue$ = null; - diffContext.$jsxChildren$ = null!; - diffContext.$jsxCount$ = 0; - } else { - diffContext.$jsxIdx$ = 0; - diffContext.$jsxValue$ = children; - diffContext.$jsxChildren$ = null!; - diffContext.$jsxCount$ = 1; - } + baseStackPush(diffContext, children, descendVNode); } function getInsertBefore(diffContext: DiffContext) { diff --git a/packages/qwik/src/core/client/vnode-utils.ts b/packages/qwik/src/core/client/vnode-utils.ts index b4c2e1283a9..8b345dbb31d 100644 --- a/packages/qwik/src/core/client/vnode-utils.ts +++ b/packages/qwik/src/core/client/vnode-utils.ts @@ -394,12 +394,17 @@ export const vnode_getNodeTypeName = (vNode: VNode): string => { return ''; }; +/** @internal */ export const vnode_getProp = ( vNode: VNode, key: string, getObject: ((id: string) => unknown) | null ): T | null => { if (vnode_isElementVNode(vNode) || vnode_isVirtualVNode(vNode)) { + // SsrNode stores non-serializable (':' prefixed) props in a separate localProps object + if (key.charCodeAt(0) === 58 /* ':' */ && 'localProps' in vNode) { + return (((vNode as any).localProps as Record | null)?.[key] ?? null) as T | null; + } const value = vNode.props?.[key] ?? null; if (typeof value === 'string' && getObject) { const result = getObject(value) as T | null; @@ -411,13 +416,38 @@ export const vnode_getProp = ( return null; }; +/** @internal */ export const vnode_setProp = (vNode: VNode, key: string, value: unknown) => { - if (value == null && vNode.props) { + // SsrNode stores non-serializable (':' prefixed) props in a separate localProps object + if (key.charCodeAt(0) === 58 /* ':' */ && 'localProps' in vNode) { + ((vNode as any).localProps ||= {})[key] = value; + return; + } + if ('localProps' in vNode) { + // SsrNode: always store (never implicit delete — use vnode_removeProp for that) + (vNode.props ||= {})[key] = value; + } else if (value == null && vNode.props) { delete vNode.props[key]; } else { vNode.props ||= {}; vNode.props[key] = value; } + // SsrNode: ELEMENT_SEQ values contain Tasks with cleanup functions + if (key === ELEMENT_SEQ && value && 'cleanupQueue' in vNode) { + (vNode as any).cleanupQueue.push(value); + } +}; + +/** @internal */ +export const vnode_removeProp = (vNode: VNode, key: string) => { + if (key.charCodeAt(0) === 58 /* ':' */ && 'localProps' in vNode) { + const localProps = (vNode as any).localProps as Record | null; + if (localProps) { + delete localProps[key]; + } + } else if (vNode.props) { + delete vNode.props[key]; + } }; export const vnode_setAttr = ( diff --git a/packages/qwik/src/core/reactive-primitives/subscriber.ts b/packages/qwik/src/core/reactive-primitives/subscriber.ts index d98372812eb..f6cec332421 100644 --- a/packages/qwik/src/core/reactive-primitives/subscriber.ts +++ b/packages/qwik/src/core/reactive-primitives/subscriber.ts @@ -1,6 +1,3 @@ -import { isServer } from '@qwik.dev/core/build'; -import { QBackRefs } from '../shared/utils/markers'; -import type { ISsrNode } from '../ssr/ssr-types'; import { Consumer, EffectProperty, EffectSubscription } from './types'; import { _EFFECT_BACK_REF, type BackRef } from './backref'; import type { SubscriptionData } from './subscription-data'; @@ -11,11 +8,9 @@ export function getSubscriber( data?: SubscriptionData ): EffectSubscription { if (!(effect as BackRef)[_EFFECT_BACK_REF]) { - if (isServer && isSsrNode(effect)) { - effect.setProp(QBackRefs, new Map()); - } else { - (effect as BackRef)[_EFFECT_BACK_REF] = new Map(); - } + // For SsrNode, _EFFECT_BACK_REF is a defineProperty getter/setter + // that delegates to serializable attrs (QBackRefs), so this works for both. + (effect as BackRef)[_EFFECT_BACK_REF] = new Map(); } const subMap = (effect as any)[_EFFECT_BACK_REF]; let sub: EffectSubscription = subMap.get(prop); @@ -32,3 +27,6 @@ export function getSubscriber( export function isSsrNode(value: any): value is ISsrNode { return '__brand__' in value && value.__brand__ === 'SsrNode'; } + +// Re-export ISsrNode for callers that still need the type +import type { ISsrNode } from '../ssr/ssr-types'; diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index 7f4b7f3614d..bcae3c95dcc 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -7,7 +7,6 @@ import type { ComputedFn } from '../use/use-computed'; import type { AsyncFn } from '../use/use-async'; import type { Container, SerializationStrategy } from '../shared/types'; import type { VNode } from '../shared/vnode/vnode'; -import type { ISsrNode } from '../ssr/ssr-types'; import type { PropsProxy } from '../shared/jsx/props-proxy'; /** @@ -135,10 +134,10 @@ export type AllSignalFlags = * There are three types of effects: * * - `Task`: `useTask`, `useVisibleTask`, `useResource` - * - `VNode` and `ISsrNode`: Either a component or `` + * - `VNode` (including `SsrNode` which extends `VNode`): Either a component or `` * - `Signal2`: A derived signal which contains a computation function. */ -export type Consumer = Task | VNode | SignalImpl | ISsrNode; +export type Consumer = Task | VNode | SignalImpl; /** * An effect consumer plus type of effect, back references to producers and additional data diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index 003ae048ae5..a0d11f5d8bd 100644 --- a/packages/qwik/src/core/reactive-primitives/utils.ts +++ b/packages/qwik/src/core/reactive-primitives/utils.ts @@ -6,7 +6,7 @@ import type { Container, SerializationStrategy } from '../shared/types'; import { OnRenderProp } from '../shared/utils/markers'; import { SerializerSymbol } from '../shared/serdes/verify'; import { isObject } from '../shared/utils/types'; -import type { ISsrNode, SSRContainer } from '../ssr/ssr-types'; +import type { SSRContainer } from '../ssr/ssr-types'; import { TaskFlags, isTask } from '../use/use-task'; import { ComputedSignalImpl } from './impl/computed-signal-impl'; import { SignalImpl } from './impl/signal-impl'; @@ -25,6 +25,7 @@ import { markVNodeDirty } from '../shared/vnode/vnode-dirty'; import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum'; import { setNodeDiffPayload, setNodePropData } from '../shared/cursor/chore-execution'; import type { VNode } from '../shared/vnode/vnode'; +import { vnode_getProp, vnode_setProp } from '../client/vnode-utils'; import { NODE_PROPS_DATA_KEY } from '../shared/cursor/cursor-props'; import { isDev, isServer } from '@qwik.dev/core/build'; @@ -92,6 +93,12 @@ export const scheduleEffects = ( if (effects) { const scheduleEffect = (effectSubscription: EffectSubscription) => { const consumer = effectSubscription.consumer; + if (!consumer || (consumer as any).nodeType !== undefined) { + // Orphaned subscription — consumer was an SsrNode that was never emitted during SSR. + // Deserialized as Document (VNode with empty ID). Skip — the subscription is stale. + // VNodes don't have nodeType; DOM nodes (Document) do. + return; + } const property = effectSubscription.property; isDev && assertDefined(container, 'Container must be defined.'); if (isTask(consumer)) { @@ -118,11 +125,13 @@ export const scheduleEffects = ( if (isBrowser) { setNodePropData(consumer as VNode, property, payload); } else { - const node = consumer as ISsrNode; - let data = node.getProp(NODE_PROPS_DATA_KEY) as Map | null; + let data = vnode_getProp(consumer, NODE_PROPS_DATA_KEY, null) as Map< + string, + NodeProp + > | null; if (!data) { data = new Map(); - node.setProp(NODE_PROPS_DATA_KEY, data); + vnode_setProp(consumer, NODE_PROPS_DATA_KEY, data); } data.set(property, payload); } diff --git a/packages/qwik/src/core/shared/component-execution.ts b/packages/qwik/src/core/shared/component-execution.ts index 23ead5d723a..04abb1cda94 100644 --- a/packages/qwik/src/core/shared/component-execution.ts +++ b/packages/qwik/src/core/shared/component-execution.ts @@ -18,6 +18,7 @@ import { EMPTY_OBJ } from './utils/flyweight'; import { logWarn } from './utils/log'; import { ELEMENT_PROPS, + ELEMENT_SEQ, ELEMENT_SEQ_IDX, OnRenderProp, USE_ON_LOCAL, @@ -32,6 +33,7 @@ import { isServerPlatform } from './platform/platform'; import type { ISsrNode } from '../ssr/ssr-types'; import { ChoreBits } from './vnode/enums/chore-bits.enum'; import type { SignalImpl } from '../reactive-primitives/impl/signal-impl'; +import { runTask, Task, TaskFlags } from '../use/use-task'; /** * Use `executeComponent` to execute a component. @@ -114,12 +116,42 @@ export const executeComponent = ( return maybeThen(componentFn(props), (jsx) => maybeThen(iCtx.$waitOn$, () => jsx)); }, (jsx) => { - // In SSR, check if the component was marked dirty (COMPONENT bit) during execution. - // This happens when something completes and updates reactive primitives - // while we're waiting on $waitOn$. If so, we need to re-execute the component - // to get fresh JSX with updated values. + // In SSR, handle cascading task dependencies before returning JSX. + // When task A sets store.x and task B tracks store.x, the subscription fires + // synchronously during task execution, setting ChoreBits.TASKS on the host. + // Run those dirty tasks now so the component's JSX sees final values. + // This mirrors the cursor walker's TASKS → COMPONENT loop for inline execution. + if (isSsr && !isInlineComponent && vnode_isVNode(renderHost)) { + const runDirtyTasks = (): ValueOrPromise => { + const vnode = renderHost as unknown as ISsrNode; + if (!(vnode.dirty & ChoreBits.TASKS)) { + return; + } + vnode.dirty &= ~ChoreBits.TASKS; + const elementSeq = container.getHostProp(renderHost, ELEMENT_SEQ); + if (!elementSeq) { + return; + } + let p: ValueOrPromise | undefined; + for (const item of elementSeq) { + if (item instanceof Task && item.$flags$ & TaskFlags.DIRTY) { + const r = runTask(item, container, renderHost); + if (isPromise(r)) { + p = p ? maybeThen(p, () => r as Promise) : (r as Promise); + } + } + } + return maybeThen(p!, () => runDirtyTasks()); + }; + const result = runDirtyTasks(); + if (isPromise(result)) { + return maybeThen(result, () => jsx); + } + } + + // Also check COMPONENT re-dirty (signal changes during $waitOn$) if (isSsr && !isInlineComponent) { - const ssrNode = renderHost as ISsrNode; + const ssrNode = renderHost as unknown as ISsrNode; if (ssrNode.dirty & ChoreBits.COMPONENT) { ssrNode.dirty &= ~ChoreBits.COMPONENT; if (retryCount < MAX_RETRY_ON_PROMISE_COUNT) { diff --git a/packages/qwik/src/core/shared/cursor/chore-execution.ts b/packages/qwik/src/core/shared/cursor/chore-execution.ts index f4fb5e571bd..53bd2398f09 100644 --- a/packages/qwik/src/core/shared/cursor/chore-execution.ts +++ b/packages/qwik/src/core/shared/cursor/chore-execution.ts @@ -1,36 +1,35 @@ -import { type VNodeJournal } from '../../client/vnode-utils'; +import { VNodeFlags, type ClientContainer } from '../../client/types'; import { vnode_diff } from '../../client/vnode-diff'; +import { type VNodeJournal } from '../../client/vnode-utils'; +import type { WrappedSignalImpl } from '../../reactive-primitives/impl/wrapped-signal-impl'; +import { SignalFlags } from '../../reactive-primitives/types'; +import { isSignal, scheduleEffects } from '../../reactive-primitives/utils'; +import type { ISsrNode } from '../../ssr/ssr-types'; +import { invoke, newInvokeContext } from '../../use/use-core'; import { Task, TaskFlags, runTask, type TaskFn } from '../../use/use-task'; -import { executeComponent } from '../component-execution'; -import type { OnRenderFn } from '../component.public'; -import type { Props } from '../jsx/jsx-runtime'; -import type { QRLInternal } from '../qrl/qrl-class'; -import { ChoreBits } from '../vnode/enums/chore-bits.enum'; -import { ELEMENT_SEQ, ELEMENT_PROPS, OnRenderProp, QScopedStyle } from '../utils/markers'; -import { addComponentStylePrefix } from '../utils/scoped-styles'; -import { isPromise, maybeThen, retryOnPromise, safeCall } from '../utils/promises'; +import { cleanupDestroyable } from '../../use/utils/destroyable'; +import type { Container } from '../types'; +import { ELEMENT_SEQ } from '../utils/markers'; +import { maybeThen, retryOnPromise } from '../utils/promises'; import type { ValueOrPromise } from '../utils/types'; -import type { Container, HostElement } from '../types'; -import type { VNode } from '../vnode/vnode'; -import { VNodeFlags, type ClientContainer } from '../../client/types'; -import type { NodeProp } from '../../reactive-primitives/subscription-data'; -import { isSignal, scheduleEffects } from '../../reactive-primitives/utils'; -import type { Signal } from '../../reactive-primitives/signal.public'; import type { ElementVNode } from '../vnode/element-vnode'; +import { ChoreBits } from '../vnode/enums/chore-bits.enum'; import { createSetAttributeOperation } from '../vnode/types/dom-vnode-operation'; -import type { JSXOutput } from '../jsx/types/jsx-node'; +import type { VNode } from '../vnode/vnode'; import { - HOST_SIGNAL, - NODE_DIFF_DATA_KEY, - NODE_PROPS_DATA_KEY, - type CursorData, -} from './cursor-props'; -import { invoke, newInvokeContext } from '../../use/use-core'; -import type { WrappedSignalImpl } from '../../reactive-primitives/impl/wrapped-signal-impl'; -import { SignalFlags } from '../../reactive-primitives/types'; -import { cleanupDestroyable } from '../../use/utils/destroyable'; -import type { ISsrNode } from '../../ssr/ssr-types'; + clearNodePropData, + executeTaskSequence, + forEachPendingNodeProp, + getComponentChoreData, + getElementSequenceFromContainer, + getNodeDiffPayload, + readComponentScopedStylePrefix, + runComponentChore, + setNodeDiffPayload, + setNodePropData, +} from './chore-helpers'; import type { Cursor } from './cursor'; +import { HOST_SIGNAL, type CursorData } from './cursor-props'; /** * Executes tasks for a vNode if the TASKS dirty bit is set. Tasks are stored in the ELEMENT_SEQ @@ -55,59 +54,20 @@ export function executeTasks( cursorData: CursorData ): ValueOrPromise { vNode.dirty &= ~ChoreBits.TASKS; - - const elementSeq = container.getHostProp(vNode, ELEMENT_SEQ); - - if (!elementSeq || elementSeq.length === 0) { - // No tasks to execute, clear the bit - return; - } - - // Execute all tasks in sequence - let taskPromise: Promise | undefined; - - for (const item of elementSeq) { - if (item instanceof Task) { - const task = item as Task; - - // Skip if task is not dirty - if (!(task.$flags$ & TaskFlags.DIRTY)) { - continue; - } - - if (task.$flags$ & TaskFlags.VISIBLE_TASK) { - // VisibleTasks: store for execution after flush (don't execute now) - (cursorData.afterFlushTasks ||= []).push(task); - } else { - // Regular tasks: chain promises only between each other - const isRenderBlocking = !!(task.$flags$ & TaskFlags.RENDER_BLOCKING); - const result = runTask(task, container, vNode); - if (isPromise(result)) { - if (isRenderBlocking) { - taskPromise = taskPromise - ? taskPromise.then(() => result as Promise) - : (result as Promise); - } else { - // TODO: set extrapromises on vNode instead of cursorData if server - (cursorData.extraPromises ||= []).push(result as Promise); - } - } - } - } - } - - return taskPromise; -} - -function getNodeDiffPayload(vNode: VNode): JSXOutput | null { - const props = vNode.props as Props; - return props?.[NODE_DIFF_DATA_KEY] as JSXOutput | null; + return executeTaskSequence(vNode, container, cursorData, { + getElementSeq: getElementSequenceFromContainer, + isRenderBlocking(task) { + return !!(task.$flags$ & TaskFlags.RENDER_BLOCKING); + }, + treatVisibleTaskAsAfterFlush: true, + collectNonBlockingPromise(data, promise) { + (data.extraPromises ||= []).push(promise); + }, + runTask, + }); } -export function setNodeDiffPayload(vNode: VNode, payload: JSXOutput | Signal): void { - const props = (vNode.props ||= {}) as Props; - props[NODE_DIFF_DATA_KEY] = payload; -} +export { setNodeDiffPayload, setNodePropData }; export function executeNodeDiff( vNode: VNode, @@ -143,81 +103,22 @@ export function executeComponentChore( cursor: Cursor ): ValueOrPromise { vNode.dirty &= ~ChoreBits.COMPONENT; - const host = vNode as HostElement; - const componentQRL = container.getHostProp> | null>( - host, - OnRenderProp - ); - - if (!componentQRL) { + const component = getComponentChoreData(vNode, container); + if (!component) { return; } - - const props = container.getHostProp(host, ELEMENT_PROPS) || null; - - const result = safeCall( - () => executeComponent(container, host, host, componentQRL, props), - (jsx) => { - const styleScopedId = container.getHostProp(host, QScopedStyle); - return retryOnPromise(() => - vnode_diff( - container as ClientContainer, - journal, - jsx, - host as VNode, - cursor, - addComponentStylePrefix(styleScopedId) - ) - ); - }, - (err: any) => { - container.handleError(err, host); - } + return runComponentChore(container, component, (jsx) => + retryOnPromise(() => + vnode_diff( + container as ClientContainer, + journal, + jsx, + component.host as VNode, + cursor, + readComponentScopedStylePrefix(container, component) + ) + ) ); - - if (isPromise(result)) { - return result as Promise; - } - - return; -} - -/** - * Gets node prop data from a vNode. - * - * @param vNode - The vNode to get node prop data from - * @returns Array of NodeProp, or null if none - */ -function getNodePropData(vNode: VNode): Map | null { - const props = (vNode.props ||= {}) as Props; - return (props[NODE_PROPS_DATA_KEY] as Map | null) ?? null; -} - -/** - * Sets node prop data for a vNode. - * - * @param vNode - The vNode to set node prop data for - * @param property - The property to set node prop data for - * @param nodeProp - The node prop data to set - */ -export function setNodePropData(vNode: VNode, property: string, nodeProp: NodeProp): void { - const props = (vNode.props ||= {}) as Props; - let data = props[NODE_PROPS_DATA_KEY] as Map | null; - if (!data) { - data = new Map(); - props[NODE_PROPS_DATA_KEY] = data; - } - data.set(property, nodeProp); -} - -/** - * Clears node prop data from a vNode. - * - * @param vNode - The vNode to clear node prop data from - */ -function clearNodePropData(vNode: VNode): void { - const props = (vNode.props ||= {}) as Props; - delete props[NODE_PROPS_DATA_KEY]; } function setNodeProp( @@ -260,24 +161,12 @@ export function executeNodeProps(vNode: VNode, journal: VNodeJournal): void { return; } - const allPropData = getNodePropData(vNode); - if (!allPropData || allPropData.size === 0) { - return; - } - const domVNode = vNode as ElementVNode; - - // Process all pending node prop updates - for (const [property, nodeProp] of allPropData.entries()) { - let value: Signal | string = nodeProp.value; - if (isSignal(value)) { - // TODO: Handle async signals (promises) - need to track pending async prop data - value = value.value as any; - } - - // Pass raw value and scopedStyleIdPrefix - serialization happens in flush - const isConst = nodeProp.isConst; - setNodeProp(domVNode, journal, property, value, isConst, nodeProp.scopedStyleIdPrefix); + const hasPropData = forEachPendingNodeProp(vNode, (property, value, nodeProp) => { + setNodeProp(domVNode, journal, property, value, nodeProp.isConst, nodeProp.scopedStyleIdPrefix); + }); + if (!hasPropData) { + return; } // Clear pending prop data after processing @@ -327,7 +216,10 @@ export function executeCompute( container: Container ): ValueOrPromise { vNode.dirty &= ~ChoreBits.COMPUTE; - const target = container.getHostProp | null>(vNode, HOST_SIGNAL); + const target = container.getHostProp | null>( + vNode as VNode, + HOST_SIGNAL + ); if (!target) { return; } diff --git a/packages/qwik/src/core/shared/cursor/chore-execution.unit.ts b/packages/qwik/src/core/shared/cursor/chore-execution.unit.ts index cdd014dbdde..3cc7901e277 100644 --- a/packages/qwik/src/core/shared/cursor/chore-execution.unit.ts +++ b/packages/qwik/src/core/shared/cursor/chore-execution.unit.ts @@ -134,6 +134,8 @@ function createMockCursorData(container: Container): CursorData { position: null, priority: 0, promise: null, + ssrBuildState: null, + onDone: null, }; } diff --git a/packages/qwik/src/core/shared/cursor/chore-helpers.ts b/packages/qwik/src/core/shared/cursor/chore-helpers.ts new file mode 100644 index 00000000000..c32388820d2 --- /dev/null +++ b/packages/qwik/src/core/shared/cursor/chore-helpers.ts @@ -0,0 +1,202 @@ +import type { JSXOutput } from '../jsx/types/jsx-node'; +import type { Props } from '../jsx/jsx-runtime'; +import type { QRLInternal } from '../qrl/qrl-class'; +import type { Signal } from '../../reactive-primitives/signal.public'; +import type { NodeProp } from '../../reactive-primitives/subscription-data'; +import { isSignal } from '../../reactive-primitives/utils'; +import { executeComponent } from '../component-execution'; +import type { OnRenderFn } from '../component.public'; +import { ELEMENT_PROPS, ELEMENT_SEQ, OnRenderProp, QScopedStyle } from '../utils/markers'; +import { safeCall, isPromise } from '../utils/promises'; +import { addComponentStylePrefix } from '../utils/scoped-styles'; +import type { ValueOrPromise } from '../utils/types'; +import type { Container, HostElement } from '../types'; +import type { VNode } from '../vnode/vnode'; +import { Task, TaskFlags, type TaskFn } from '../../use/use-task'; +import type { CursorData } from './cursor-props'; +import { vnode_getProp, vnode_removeProp, vnode_setProp } from '../../client/vnode-utils'; + +export const NODE_PROPS_DATA_KEY = ':nodeProps'; +export const NODE_DIFF_DATA_KEY = ':nodeDiff'; +export const HOST_SIGNAL = ':signal'; + +export function getNodeDiffPayload(vNode: VNode): JSXOutput | Signal | null { + return vnode_getProp(vNode, NODE_DIFF_DATA_KEY, null) as JSXOutput | Signal | null; +} + +export function setNodeDiffPayload(vNode: VNode, payload: JSXOutput | Signal): void { + vnode_setProp(vNode, NODE_DIFF_DATA_KEY, payload); +} + +export function takeNodeDiffPayload(vNode: VNode): JSXOutput | Signal | null { + const payload = vnode_getProp(vNode, NODE_DIFF_DATA_KEY, null) as + | JSXOutput + | Signal + | null; + if (payload == null) { + return null; + } + vnode_removeProp(vNode, NODE_DIFF_DATA_KEY); + return payload ?? null; +} + +function getNodePropData(vNode: VNode): Map | null { + return vnode_getProp(vNode, NODE_PROPS_DATA_KEY, null) as Map | null; +} + +export function setNodePropData(vNode: VNode, property: string, nodeProp: NodeProp): void { + let data = vnode_getProp(vNode, NODE_PROPS_DATA_KEY, null) as Map | null; + if (!data) { + data = new Map(); + vnode_setProp(vNode, NODE_PROPS_DATA_KEY, data); + } + data.set(property, nodeProp); +} + +export function clearNodePropData(vNode: VNode): void { + vnode_removeProp(vNode, NODE_PROPS_DATA_KEY); +} + +export function forEachPendingNodeProp( + vNode: VNode, + fn: (property: string, value: any, nodeProp: NodeProp) => void +): boolean { + const allPropData = getNodePropData(vNode); + if (!allPropData || allPropData.size === 0) { + return false; + } + + for (const [property, nodeProp] of allPropData.entries()) { + let value: Signal | string = nodeProp.value; + if (isSignal(value)) { + value = value.value as any; + } + fn(property, value, nodeProp); + } + + return true; +} + +export interface TaskRunnerPolicy { + getElementSeq(vNode: VNode, container: Container): unknown[] | null; + isRenderBlocking(task: Task): boolean; + treatVisibleTaskAsAfterFlush: boolean; + collectNonBlockingPromise(cursorData: CursorData, promise: Promise): void; + runTask( + task: Task, + container: Container, + host: HostElement + ): ValueOrPromise; +} + +export function executeTaskSequence( + vNode: VNode, + container: Container, + cursorData: CursorData, + policy: TaskRunnerPolicy +): ValueOrPromise { + const elementSeq = policy.getElementSeq(vNode, container); + + if (!elementSeq || elementSeq.length === 0) { + return; + } + + let taskPromise: Promise | undefined; + + for (const item of elementSeq) { + if (!(item instanceof Task)) { + continue; + } + + const task = item as Task; + if (!(task.$flags$ & TaskFlags.DIRTY)) { + continue; + } + + if (policy.treatVisibleTaskAsAfterFlush && task.$flags$ & TaskFlags.VISIBLE_TASK) { + (cursorData.afterFlushTasks ||= []).push(task); + continue; + } + + const result = policy.runTask(task, container, vNode as HostElement); + if (!isPromise(result)) { + continue; + } + + if (policy.isRenderBlocking(task)) { + taskPromise = taskPromise + ? taskPromise.then(() => result as Promise) + : (result as Promise); + } else { + policy.collectNonBlockingPromise(cursorData, result as Promise); + } + } + + return taskPromise; +} + +export interface ComponentChoreData { + host: HostElement; + componentQRL: QRLInternal>; + props: Props | null; +} + +export function getComponentChoreData( + vNode: VNode, + container: Container +): ComponentChoreData | null { + const host = vNode as HostElement; + const componentQRL = container.getHostProp> | null>( + host, + OnRenderProp + ); + + if (!componentQRL) { + return null; + } + + return { + host, + componentQRL, + props: container.getHostProp(host, ELEMENT_PROPS) || null, + }; +} + +export function getScopedStylePrefix(styleScopedId: string | null): string | null { + return addComponentStylePrefix(styleScopedId); +} + +export function readComponentScopedStylePrefix( + container: Container, + component: ComponentChoreData +): string | null { + return getScopedStylePrefix(container.getHostProp(component.host, QScopedStyle)); +} + +export function runComponentChore( + container: Container, + component: ComponentChoreData, + onJsx: (jsx: JSXOutput, component: ComponentChoreData) => ValueOrPromise +): ValueOrPromise { + return safeCall( + () => + executeComponent( + container, + component.host, + component.host, + component.componentQRL, + component.props + ), + (jsx) => onJsx(jsx, component), + (err: any) => { + container.handleError(err, component.host); + } + ); +} + +export function getElementSequenceFromContainer( + vNode: VNode, + container: Container +): unknown[] | null { + return container.getHostProp(vNode as HostElement, ELEMENT_SEQ); +} diff --git a/packages/qwik/src/core/shared/cursor/chore-runtime.ts b/packages/qwik/src/core/shared/cursor/chore-runtime.ts new file mode 100644 index 00000000000..98bcefb7b5f --- /dev/null +++ b/packages/qwik/src/core/shared/cursor/chore-runtime.ts @@ -0,0 +1,97 @@ +import type { VNodeJournal } from '../../client/vnode-utils'; +import type { ValueOrPromise } from '../utils/types'; +import type { Container } from '../types'; +import type { VNode } from '../vnode/vnode'; +import { + executeCleanup, + executeComponentChore, + executeCompute, + executeNodeDiff, + executeNodeProps, + executeTasks, +} from './chore-execution'; +import { + executeSsrComponent, + executeSsrNodeDiff, + executeSsrNodeProps, + executeSsrTasks, + executeSsrUnclaimedProjections, +} from './ssr-chore-execution'; +import { executeFlushPhase } from './cursor-flush'; +import type { Cursor } from './cursor'; +import type { CursorData } from './cursor-props'; + +export interface CursorChoreRuntime { + needsJournal: boolean; + hasCleanNodeLeave: boolean; + tasks(vNode: VNode, container: Container, cursorData: CursorData): ValueOrPromise; + component( + vNode: VNode, + container: Container, + cursorData: CursorData, + cursor: Cursor, + journal: VNodeJournal | null + ): ValueOrPromise; + nodeDiff( + vNode: VNode, + container: Container, + cursorData: CursorData, + cursor: Cursor, + journal: VNodeJournal | null + ): ValueOrPromise; + nodeProps( + vNode: VNode, + container: Container, + cursorData: CursorData, + journal: VNodeJournal | null + ): void; + cleanup(vNode: VNode, container: Container): void; + compute(vNode: VNode, container: Container): ValueOrPromise; + onCleanNodeLeave( + vNode: VNode, + container: Container, + cursorData: CursorData, + cursor: Cursor + ): ValueOrPromise; + onCursorFinish(cursor: Cursor, container: Container, cursorData: CursorData): void; +} + +export const domCursorChoreRuntime: CursorChoreRuntime = { + needsJournal: true, + hasCleanNodeLeave: false, + tasks: executeTasks, + component(vNode, container, _cursorData, cursor, journal) { + return executeComponentChore(vNode, container, journal!, cursor); + }, + nodeDiff(vNode, container, _cursorData, cursor, journal) { + return executeNodeDiff(vNode, container, journal!, cursor); + }, + nodeProps(vNode, _container, _cursorData, journal) { + executeNodeProps(vNode, journal!); + }, + cleanup: executeCleanup, + compute: executeCompute, + onCleanNodeLeave() {}, + onCursorFinish(cursor, container) { + executeFlushPhase(cursor, container); + }, +}; + +export const ssrCursorChoreRuntime: CursorChoreRuntime = { + needsJournal: false, + hasCleanNodeLeave: true, + tasks: executeSsrTasks, + component: executeSsrComponent, + nodeDiff: executeSsrNodeDiff, + nodeProps(vNode, container) { + executeSsrNodeProps(vNode, container); + }, + cleanup: executeCleanup, + compute: executeCompute, + onCleanNodeLeave: executeSsrUnclaimedProjections, + onCursorFinish() {}, +}; + +export function getCursorChoreRuntime(isServer: boolean): CursorChoreRuntime { + return isServer ? ssrCursorChoreRuntime : domCursorChoreRuntime; +} diff --git a/packages/qwik/src/core/shared/cursor/cursor-props.ts b/packages/qwik/src/core/shared/cursor/cursor-props.ts index 69d803adda6..f1aec809c40 100644 --- a/packages/qwik/src/core/shared/cursor/cursor-props.ts +++ b/packages/qwik/src/core/shared/cursor/cursor-props.ts @@ -4,14 +4,10 @@ import { removeCursorFromQueue } from './cursor-queue'; import type { Container } from '../types'; import type { VNodeJournal } from '../../client/vnode-utils'; import type { Task } from '../../use/use-task'; +export { HOST_SIGNAL, NODE_DIFF_DATA_KEY, NODE_PROPS_DATA_KEY } from './chore-helpers'; export const cursorDatas = new WeakMap(); -/** Key used to store pending node prop updates in vNode props. */ -export const NODE_PROPS_DATA_KEY = ':nodeProps'; -export const NODE_DIFF_DATA_KEY = ':nodeDiff'; -export const HOST_SIGNAL = ':signal'; - export interface CursorData { afterFlushTasks: Task[] | null; extraPromises: Promise[] | null; @@ -20,6 +16,10 @@ export interface CursorData { position: VNode | null; priority: number; promise: Promise | null; + /** Per-cursor SSR build state (frame state). Only used on server. */ + ssrBuildState: unknown | null; + /** Callback invoked when the cursor completes (finishWalk). Used for sub-cursor tracking. */ + onDone: (() => void) | null; } /** @@ -86,6 +86,7 @@ function mergeCursors(container: Container, newCursorData: CursorData, oldCursor * * @param vNode - The vNode * @returns The cursor data, or null if none or not a cursor + * @internal */ export function getCursorData(vNode: VNode): CursorData | null { return cursorDatas.get(vNode) ?? null; diff --git a/packages/qwik/src/core/shared/cursor/cursor-walker.spec.ts b/packages/qwik/src/core/shared/cursor/cursor-walker.spec.ts index c4c532e1555..c6e065a7546 100644 --- a/packages/qwik/src/core/shared/cursor/cursor-walker.spec.ts +++ b/packages/qwik/src/core/shared/cursor/cursor-walker.spec.ts @@ -295,6 +295,8 @@ describe('tryDescendDirtyChildren', () => { extraPromises: null, afterFlushTasks: null, priority: 0, + ssrBuildState: null, + onDone: null, }; setCursorData(cursor, cursorData); return cursorData; diff --git a/packages/qwik/src/core/shared/cursor/cursor-walker.ts b/packages/qwik/src/core/shared/cursor/cursor-walker.ts index 5f8f645effb..c273e259e77 100644 --- a/packages/qwik/src/core/shared/cursor/cursor-walker.ts +++ b/packages/qwik/src/core/shared/cursor/cursor-walker.ts @@ -7,14 +7,6 @@ import { isServerPlatform } from '../platform/platform'; import type { VNode } from '../vnode/vnode'; -import { - executeCleanup, - executeComponentChore, - executeCompute, - executeNodeDiff, - executeNodeProps, - executeTasks, -} from './chore-execution'; import { type Cursor } from './cursor'; import { setCursorPosition, getCursorData, type CursorData } from './cursor-props'; import { ChoreBits } from '../vnode/enums/chore-bits.enum'; @@ -24,7 +16,6 @@ import { removeCursorFromQueue, resumeCursor, } from './cursor-queue'; -import { executeFlushPhase } from './cursor-flush'; import { createMicroTask, createMacroTask } from '../platform/next-tick'; import { isPromise } from '../utils/promises'; import type { ValueOrPromise } from '../utils/types'; @@ -32,6 +23,7 @@ import { assertDefined, assertFalse } from '../error/assert'; import type { Container } from '../types'; import { VNodeFlags } from '../../client/types'; import { isBrowser, isDev, isServer } from '@qwik.dev/core/build'; +import { getCursorChoreRuntime } from './chore-runtime'; const DEBUG = false; @@ -64,6 +56,7 @@ export interface WalkOptions { * Processes the cursor queue, walking each cursor in turn. * * @param options - Walk options (time budget, etc.) + * @internal */ export function processCursorQueue( options: WalkOptions = { @@ -101,6 +94,7 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { const { timeBudget } = options; const isRunningOnServer = import.meta.env.TEST ? isServerPlatform() : isServer; const startTime = performance.now(); + const choreRuntime = getCursorChoreRuntime(isRunningOnServer); const cursorData = getCursorData(cursor)!; @@ -113,13 +107,18 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { const container = cursorData.container; isDev && assertDefined(container, 'Cursor container not found'); + // Swap SSR build state if this cursor has one + if (isRunningOnServer && cursorData.ssrBuildState) { + (container as any).ssrBuildState = cursorData.ssrBuildState; + } + // Check if cursor is already complete if (!cursor.dirty) { - finishWalk(container, cursor, cursorData, isRunningOnServer); + finishWalk(container, cursor, cursorData, choreRuntime); return; } - const journal = (cursorData.journal ||= []); + const journal = choreRuntime.needsJournal ? (cursorData.journal ||= []) : null; // Get starting position (resume from last position or start at root) let currentVNode: VNode | null = null; @@ -136,6 +135,38 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { // Skip if the vNode is not dirty if (!(currentVNode.dirty & ChoreBits.DIRTY_MASK)) { + // Before moving on, emit unclaimed projections for this node AND all ancestors + // that getNextVNode will skip over during its recursive parent walk. + // getNextVNode clears CHILDREN bits on ancestors without the walker ever visiting them, + // so we must process unclaimed projections for the entire ancestor chain here. + if (choreRuntime.hasCleanNodeLeave) { + const unclaimedResult = emitUnclaimedProjectionsForChain( + currentVNode, + cursor, + container, + cursorData, + choreRuntime + ); + if (unclaimedResult && isPromise(unclaimedResult)) { + cursorData.promise = unclaimedResult; + pauseCursor(cursor, container); + const host = currentVNode; + unclaimedResult + .catch((error) => { + container.handleError(error, host); + }) + .finally(() => { + cursorData.promise = null; + resumeCursor(cursor, container); + triggerCursors(); + }); + return; + } + // If unclaimed projections made us dirty again, re-process this node + if (currentVNode.dirty & ChoreBits.DIRTY_MASK) { + continue; + } + } // Move to next node setCursorPosition(container, cursorData, getNextVNode(currentVNode, cursor)); continue; @@ -145,7 +176,7 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { if (currentVNode.flags & VNodeFlags.Deleted) { // if deleted, run cleanup if needed if (currentVNode.dirty & ChoreBits.CLEANUP) { - executeCleanup(currentVNode, container); + choreRuntime.cleanup(currentVNode, container); } else if (currentVNode.dirty & ChoreBits.CHILDREN) { const next = tryDescendDirtyChildren(container, cursorData, currentVNode, cursor); if (next !== null) { @@ -161,23 +192,24 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { let result: ValueOrPromise | undefined; try { - // Execute chores in order + // Execute chores in order, with SSR-specific dispatch on server if (currentVNode.dirty & ChoreBits.TASKS) { - result = executeTasks(currentVNode, container, cursorData); - } else if (currentVNode.dirty & ChoreBits.NODE_DIFF) { - result = executeNodeDiff(currentVNode, container, journal, cursor); + result = choreRuntime.tasks(currentVNode, container, cursorData); } else if (currentVNode.dirty & ChoreBits.COMPONENT) { - result = executeComponentChore(currentVNode, container, journal, cursor); + result = choreRuntime.component(currentVNode, container, cursorData, cursor, journal); + } else if (currentVNode.dirty & ChoreBits.NODE_DIFF) { + result = choreRuntime.nodeDiff(currentVNode, container, cursorData, cursor, journal); } else if (currentVNode.dirty & ChoreBits.NODE_PROPS) { - executeNodeProps(currentVNode, journal); + choreRuntime.nodeProps(currentVNode, container, cursorData, journal); } else if (currentVNode.dirty & ChoreBits.COMPUTE) { - result = executeCompute(currentVNode, container); + result = choreRuntime.compute(currentVNode, container); } else if (currentVNode.dirty & ChoreBits.CHILDREN) { const next = tryDescendDirtyChildren(container, cursorData, currentVNode, cursor); if (next !== null) { currentVNode = next; continue; } + result = choreRuntime.onCleanNodeLeave(currentVNode, container, cursorData, cursor); } } catch (error) { container.handleError(error, currentVNode); @@ -218,21 +250,22 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void { !!(cursor.dirty & ChoreBits.DIRTY_MASK && !cursorData.position), 'Cursor is still dirty and position is not set after walking' ); - finishWalk(container, cursor, cursorData, isRunningOnServer); + finishWalk(container, cursor, cursorData, choreRuntime); } function finishWalk( container: Container, cursor: Cursor, cursorData: CursorData, - isServer: boolean + choreRuntime: ReturnType ): void { if (!(cursor.dirty & ChoreBits.DIRTY_MASK)) { removeCursorFromQueue(cursor, container); DEBUG && console.warn('walkCursor: cursor done', cursor.toString()); - if (!isServer) { - executeFlushPhase(cursor, container); - } + choreRuntime.onCursorFinish(cursor, container, cursorData); + + // Notify completion (used by Suspense sub-cursors) + cursorData.onDone?.(); if (cursorData.extraPromises) { Promise.all(cursorData.extraPromises).then(() => { @@ -353,3 +386,80 @@ export function getNextVNode(vNode: VNode, cursor: Cursor): VNode | null { parent!.nextDirtyChildIndex = 0; return getNextVNode(parent!, cursor); } + +/** + * Walk up the ancestor chain from `vNode` to `cursor` root, emitting unclaimed projections for each + * ancestor that has no remaining dirty children. This is needed because `getNextVNode` recursively + * clears CHILDREN bits on ancestors without the walker ever visiting them — so we process unclaimed + * projections for each ancestor that getNextVNode would skip over. + * + * Only processes an ancestor if its dirtyChildren are all clean (matching the condition under which + * getNextVNode would clear its CHILDREN bit). Stops at ancestors that still have dirty children — + * those will be visited later by the walker. + * + * If any emission is async, returns the promise immediately. The cursor will re-enter the "not + * dirty" branch on resume and call this function again to process remaining ancestors. + */ +function emitUnclaimedProjectionsForChain( + vNode: VNode, + cursor: Cursor, + container: Container, + cursorData: CursorData, + choreRuntime: ReturnType +): ValueOrPromise { + let node: VNode | null = vNode; + + while (node) { + const result = choreRuntime.onCleanNodeLeave(node, container, cursorData, cursor); + if (result && isPromise(result)) { + // Return immediately — don't continue ancestor walk. + // The emission may create new dirty children that change the ancestor state. + // When the cursor resumes, the walker will re-enter the "not dirty" branch + // and call this function again, which will continue the ancestor walk. + return result; + } + // If unclaimed projections made the node dirty again, stop — walker will re-process + if (node.dirty & ChoreBits.DIRTY_MASK) { + return; + } + + if (node === cursor) { + break; + } + + // Move to parent. But only continue if the parent's dirtyChildren are all clean + // (i.e., getNextVNode would clear its CHILDREN bit). If the parent still has other + // dirty children, stop — those children need to run first, and the walker will visit + // the parent later. + const parent: VNode | null = node.slotParent || node.parent; + if (!parent) { + break; + } + + // Check if parent still has other dirty children + if (parent.dirty & ChoreBits.CHILDREN) { + const dirtyChildren = parent.dirtyChildren; + if (dirtyChildren) { + let hasOtherDirty = false; + for (let i = 0; i < dirtyChildren.length; i++) { + if (dirtyChildren[i] !== node && dirtyChildren[i].dirty & ChoreBits.DIRTY_MASK) { + // Parent still has other dirty children — stop. Walker will handle this parent later. + hasOtherDirty = true; + break; + } + } + if (hasOtherDirty) { + return; + } + } + // All dirty children are clean — clear the CHILDREN bit so we don't + // stop at this node in the dirty check above. This mirrors what + // getNextVNode does when it finds no more dirty children. + parent.dirty &= ~ChoreBits.CHILDREN; + parent.dirtyChildren = null; + parent.nextDirtyChildIndex = 0; + } + + node = parent; + } +} diff --git a/packages/qwik/src/core/shared/cursor/cursor.ts b/packages/qwik/src/core/shared/cursor/cursor.ts index 108740ac780..77544061b66 100644 --- a/packages/qwik/src/core/shared/cursor/cursor.ts +++ b/packages/qwik/src/core/shared/cursor/cursor.ts @@ -20,6 +20,7 @@ export type Cursor = VNode; * @param root - The vNode that will become the cursor root (dirty root) * @param priority - Priority level (lower = higher priority, 0 is default) * @returns The vNode itself, now acting as a cursor + * @internal */ export function addCursor(container: Container, root: VNode, priority: number): Cursor { const cursorData: CursorData = { @@ -30,6 +31,8 @@ export function addCursor(container: Container, root: VNode, priority: number): position: root, priority: priority, promise: null, + ssrBuildState: null, + onDone: null, }; setCursorData(root, cursorData); diff --git a/packages/qwik/src/core/shared/diff-context.ts b/packages/qwik/src/core/shared/diff-context.ts new file mode 100644 index 00000000000..ad9c5b0d54f --- /dev/null +++ b/packages/qwik/src/core/shared/diff-context.ts @@ -0,0 +1,158 @@ +/** + * @file Shared diff context and stack mechanics for JSX diffing. + * + * Both client `vnode_diff` and SSR `ssrDiff` use the same DiffContext-based state machine for + * depth-first JSX traversal. This file defines the shared `BaseDiffContext` interface and the + * mechanical stack operations (`descend`, `ascend`, `stackPush`, `advance`) that both platforms + * use. + */ + +import type { Cursor } from './cursor/cursor'; +import type { JSXChildren } from './jsx/types/jsx-qwik-attributes'; +import type { ValueOrPromise } from './utils/types'; +import type { VNode } from './vnode/vnode'; + +/** + * Shared traversal state for JSX diffing — used by both client and SSR. + * + * Platform-specific contexts extend this with their own container, journal, and platform-specific + * state (e.g., client adds `$journal$` and `$subscriptionData$`, SSR adds `$componentStack$`). + */ +export interface BaseDiffContext { + $cursor$: Cursor; + $scopedStyleIdPrefix$: string | null; + /** + * Stack is used to keep track of the state of the traversal. + * + * We push current state into the stack before descending into the child, and we pop the state + * when we are done with the child. + */ + $stack$: any[]; + $asyncQueue$: Array | Promise>; + //////////////////////////////// + //// Traverse state variables + //////////////////////////////// + $vParent$: VNode; + /// Current node we compare against. (Think of it as a cursor.) + /// (Node can be null, if we are at the end of the list.) + $vCurrent$: VNode | null; + /// When we insert new node we start it here so that we can descend into it. + /// NOTE: it can't be stored in `vCurrent` because `vNewNode` is in journal + /// and is not connected to the tree. + $vNewNode$: VNode | null; + $vSiblings$: Map | null; + /// The array even indices will contain keys and odd indices the non keyed siblings. + $vSiblingsArray$: Array | null; + /// Side buffer to store nodes that are moved out of order during key scanning. + /// This contains nodes that were found before the target key and need to be moved later. + $vSideBuffer$: Map | null; + /// Current set of JSX children. + $jsxChildren$: JSXChildren[] | null; + // Current JSX child. + $jsxValue$: JSXChildren | null; + $jsxIdx$: number; + $jsxCount$: number; + // When we descend into children, we need to skip advance() because we just descended. + $shouldAdvance$: boolean; + $isCreationMode$: boolean; +} + +/** Helper to get the next sibling of a VNode. */ +export function peekNextSibling(vCurrent: VNode | null): VNode | null { + return vCurrent ? (vCurrent.nextSibling as VNode | null) : null; +} + +/** + * Advance to the next JSX child. After processing a JSX value, call this to move the cursor + * forward. + * + * If `$shouldAdvance$` is false (just descended), skip the advance and set it to true for next + * time. If the current JSX array is exhausted and we're in a non-VNode-descend frame, auto-ascend. + */ +export function advance(ctx: T, ascendFn: (ctx: T) => void): void { + if (!ctx.$shouldAdvance$) { + ctx.$shouldAdvance$ = true; + return; + } + ctx.$jsxIdx$++; + if (ctx.$jsxIdx$ < ctx.$jsxCount$) { + ctx.$jsxValue$ = ctx.$jsxChildren$![ctx.$jsxIdx$]; + } else if (ctx.$stack$.length > 0 && ctx.$stack$[ctx.$stack$.length - 1] === false) { + // this was special `descendVNode === false` so pop and try again + return ascendFn(ctx); + } + if (ctx.$vNewNode$ !== null) { + // We have a new Node. + // This means that the `vCurrent` was deemed not useful and we inserted in front of it. + // This means that the next node we should look at is the `vCurrent` so just clear the + // vNewNode and try again. + ctx.$vNewNode$ = null; + } else { + ctx.$vCurrent$ = peekNextSibling(ctx.$vCurrent$); + } +} + +/** + * Push JSX children state onto the stack. If `descendVNode` is true, also pushes VNode traversal + * state (parent, current, newNode, siblings, sideBuffer, creationMode). + */ +export function stackPush( + ctx: BaseDiffContext, + children: JSXChildren, + descendVNode: boolean +): void { + ctx.$stack$.push(ctx.$jsxChildren$, ctx.$jsxIdx$, ctx.$jsxCount$, ctx.$jsxValue$); + if (descendVNode) { + ctx.$stack$.push( + ctx.$vParent$, + ctx.$vCurrent$, + ctx.$vNewNode$, + ctx.$vSiblingsArray$, + ctx.$vSiblings$, + ctx.$vSideBuffer$, + ctx.$isCreationMode$ + ); + } + ctx.$stack$.push(descendVNode); + if (Array.isArray(children)) { + ctx.$jsxIdx$ = 0; + ctx.$jsxCount$ = children.length; + ctx.$jsxChildren$ = children; + ctx.$jsxValue$ = ctx.$jsxCount$ > 0 ? children[0] : null; + } else if (children === undefined) { + ctx.$jsxIdx$ = 0; + ctx.$jsxValue$ = null; + ctx.$jsxChildren$ = null!; + ctx.$jsxCount$ = 0; + } else { + ctx.$jsxIdx$ = 0; + ctx.$jsxValue$ = children; + ctx.$jsxChildren$ = null!; + ctx.$jsxCount$ = 1; + } +} + +/** + * Pop state from the stack (ascend from a child frame). Platform-specific ascend functions call + * this to restore the JSX and VNode traversal state, then call advance to move to the next + * sibling. + * + * Returns the `descendVNode` flag so the caller knows whether to restore VNode state. + */ +export function stackPopBase(ctx: BaseDiffContext): boolean { + const descendVNode = ctx.$stack$.pop() as boolean; + if (descendVNode) { + ctx.$isCreationMode$ = ctx.$stack$.pop(); + ctx.$vSideBuffer$ = ctx.$stack$.pop(); + ctx.$vSiblings$ = ctx.$stack$.pop(); + ctx.$vSiblingsArray$ = ctx.$stack$.pop(); + ctx.$vNewNode$ = ctx.$stack$.pop(); + ctx.$vCurrent$ = ctx.$stack$.pop(); + ctx.$vParent$ = ctx.$stack$.pop(); + } + ctx.$jsxValue$ = ctx.$stack$.pop(); + ctx.$jsxCount$ = ctx.$stack$.pop(); + ctx.$jsxIdx$ = ctx.$stack$.pop(); + ctx.$jsxChildren$ = ctx.$stack$.pop(); + return descendVNode; +} diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 5f5d7cc8f9b..6e0bea01d95 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -1,4 +1,3 @@ -import { isServer } from '@qwik.dev/core/build'; import { vnode_getFirstChild, vnode_getProp, @@ -32,7 +31,6 @@ import { qError, QError } from '../error/error'; import { JSXNodeImpl } from '../jsx/jsx-node'; import { Fragment, Props } from '../jsx/jsx-runtime'; import { PropsProxy } from '../jsx/props-proxy'; -import { isServerPlatform } from '../platform/platform'; import type { QRLInternal } from '../qrl/qrl-class'; import type { DeserializeContainer, HostElement } from '../types'; import { _OWNER, _PROPS_HANDLER, _UNINITIALIZED } from '../utils/constants'; @@ -384,10 +382,11 @@ export function inflateWrappedSignalValue(signal: WrappedSignalImpl) { } function restoreEffectBackRefForConsumer(effect: EffectSubscription): void { - const isServerSide = import.meta.env.TEST ? isServerPlatform() : isServer; const consumerBackRef = effect.consumer as BackRef; - if (isServerSide && !consumerBackRef) { - // on browser, we don't serialize for example VNodes, so then on server side we don't have consumer + if (!consumerBackRef || (consumerBackRef as any).nodeType !== undefined) { + // Consumer may be null/Document if the SsrNode was orphaned (never emitted) during SSR + // re-renders. Deserialized as Document (VNode with empty ID). Skip — the subscription is stale. + // Also handles browser case where VNodes aren't serialized. return; } consumerBackRef[_EFFECT_BACK_REF] ||= new Map(); diff --git a/packages/qwik/src/core/shared/serdes/serialization-context.ts b/packages/qwik/src/core/shared/serdes/serialization-context.ts index 8d69192a659..b09ac402873 100644 --- a/packages/qwik/src/core/shared/serdes/serialization-context.ts +++ b/packages/qwik/src/core/shared/serdes/serialization-context.ts @@ -11,7 +11,7 @@ import { Serializer } from './serialize'; type SsrNode = { id: string; children: ISsrNode[] | null; - vnodeData: VNodeData; + vnodeData: VNodeData | null; [_EFFECT_BACK_REF]: Map | null; }; type DomRef = { diff --git a/packages/qwik/src/core/shared/serdes/serialize.ts b/packages/qwik/src/core/shared/serdes/serialize.ts index 28a14890368..fb5e59ded46 100644 --- a/packages/qwik/src/core/shared/serdes/serialize.ts +++ b/packages/qwik/src/core/shared/serdes/serialize.ts @@ -386,7 +386,7 @@ export class Serializer { this.output(TypeIds.Object, out.length ? out : 0); } } else if (this.$serializationContext$.$isDomRef$(value)) { - value.$ssrNode$.vnodeData[0] |= VNodeDataFlag.SERIALIZE; + value.$ssrNode$.vnodeData![0] |= VNodeDataFlag.SERIALIZE; this.output(TypeIds.RefVNode, value.$ssrNode$.id); } else if (value instanceof SignalImpl) { if (value instanceof SerializerSignalImpl) { diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index 61b14ffbf84..e84d729c43e 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -1,4 +1,4 @@ -import type { ISsrNode, StreamWriter, SymbolToChunkResolver } from '../ssr/ssr-types'; +import type { StreamWriter, SymbolToChunkResolver } from '../ssr/ssr-types'; import type { ContextId } from '../use/use-context'; import type { EventHandler } from './jsx/types/jsx-qwik-attributes'; import type { SerializationContext } from './serdes/index'; @@ -57,7 +57,8 @@ export interface Container { ): SerializationContext; } -export type HostElement = VNode | ISsrNode; +// SsrNode extends VirtualVNode extends VNode, so HostElement is just VNode. +export type HostElement = VNode; export interface QElement extends Element { _qDispatch?: Record; @@ -148,7 +149,3 @@ export type SerializationStrategy = // TODO: implement this in the future // 'auto' | 'never' | 'always'; - -export const enum SsrNodeFlags { - Updatable = 1, -} diff --git a/packages/qwik/src/core/shared/vnode/enums/chore-bits.enum.ts b/packages/qwik/src/core/shared/vnode/enums/chore-bits.enum.ts index cece7a2c17c..97b67401027 100644 --- a/packages/qwik/src/core/shared/vnode/enums/chore-bits.enum.ts +++ b/packages/qwik/src/core/shared/vnode/enums/chore-bits.enum.ts @@ -1,8 +1,8 @@ export const enum ChoreBits { NONE = 0, TASKS = 1 << 0, - NODE_DIFF = 1 << 1, - COMPONENT = 1 << 2, + COMPONENT = 1 << 1, + NODE_DIFF = 1 << 2, NODE_PROPS = 1 << 3, COMPUTE = 1 << 4, CHILDREN = 1 << 5, diff --git a/packages/qwik/src/core/shared/vnode/vnode-dirty.ts b/packages/qwik/src/core/shared/vnode/vnode-dirty.ts index f38ebae7773..8216c652553 100644 --- a/packages/qwik/src/core/shared/vnode/vnode-dirty.ts +++ b/packages/qwik/src/core/shared/vnode/vnode-dirty.ts @@ -1,13 +1,8 @@ -import { isServer } from '@qwik.dev/core/build'; import type { VNodeJournal } from '../../client/vnode-utils'; -import type { ISsrNode, SSRContainer } from '../../ssr/ssr-types'; import { addCursor, findCursor, isCursor } from '../cursor/cursor'; import { getCursorData, type CursorData } from '../cursor/cursor-props'; -import { _executeSsrChores } from '../cursor/ssr-chore-execution'; -import { isServerPlatform } from '../platform/platform'; import type { Container } from '../types'; import { throwErrorAndStop } from '../utils/log'; -import { isPromise } from '../utils/promises'; import { ChoreBits } from './enums/chore-bits.enum'; import type { VNodeOperation } from './types/dom-vnode-operation'; import type { VNode } from './vnode'; @@ -93,12 +88,12 @@ function findAndPropagateToBlockingCursor(vNode: VNode): boolean { return false; } -function isSsrNodeGuard(_vNode: VNode | ISsrNode): _vNode is ISsrNode { - return import.meta.env.TEST ? isServerPlatform() : isServer; -} /** * Marks a vNode as dirty and propagates dirty bits up the tree. * + * SsrNode extends VNode, so this function handles both client and SSR nodes. Both use the same + * propagation logic: dirty bits are propagated up to a cursor root, or a new cursor is created. + * * @param container - The container * @param vNode - The vNode to mark dirty * @param bits - The dirty bits to set @@ -107,21 +102,12 @@ function isSsrNodeGuard(_vNode: VNode | ISsrNode): _vNode is ISsrNode { */ export function markVNodeDirty( container: Container, - vNode: VNode | ISsrNode, + vNode: VNode, bits: ChoreBits, cursorRoot: VNode | null = null ): void { const prevDirty = vNode.dirty; vNode.dirty |= bits; - if (isSsrNodeGuard(vNode)) { - const result = _executeSsrChores(container as SSRContainer, vNode as ISsrNode); - if (isPromise(result)) { - container.$renderPromise$ = container.$renderPromise$ - ? container.$renderPromise$.then(() => result) - : result; - } - return; - } const isRealDirty = bits & ChoreBits.DIRTY_MASK; // If already dirty, no need to propagate again if ((isRealDirty ? prevDirty & ChoreBits.DIRTY_MASK : prevDirty) || vNode === cursorRoot) { From 458a364be67c2d1df6bf8a4a49560797d6d50a11 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 19 Mar 2026 11:36:42 +0100 Subject: [PATCH 02/18] feat(ssr): render through cursors with interleaved streaming --- packages/docs/src/routes/api/qwik/api.json | 28 + packages/docs/src/routes/api/qwik/index.mdx | 21 + packages/qwik/src/core/client/types.ts | 6 +- packages/qwik/src/core/index.ts | 2 + packages/qwik/src/core/internal.ts | 13 +- packages/qwik/src/core/qwik.core.api.md | 62 +- .../core/shared/cursor/ssr-chore-execution.ts | 290 +++-- .../qwik/src/core/shared/jsx/utils.public.ts | 14 + packages/qwik/src/core/ssr/ssr-diff.ts | 1081 +++++++++++++++++ .../qwik/src/core/ssr/ssr-render-component.ts | 36 +- packages/qwik/src/core/ssr/ssr-render-jsx.ts | 368 ------ packages/qwik/src/core/ssr/ssr-types.ts | 43 +- .../qwik/src/core/tests/container.spec.tsx | 2 +- .../qwik/src/core/tests/projection.spec.tsx | 69 ++ .../qwik/src/core/tests/render-api.spec.tsx | 115 ++ .../qwik/src/core/tests/suspense.spec.tsx | 139 +++ .../qwik/src/core/tests/use-resource.spec.tsx | 183 +++ .../qwik/src/core/tests/use-task.spec.tsx | 8 +- .../src/plugins/fixture-output-bundles.json | 2 +- packages/qwik/src/server/qwik-copy.ts | 2 +- .../qwik/src/server/ssr-container.spec.ts | 3 + packages/qwik/src/server/ssr-container.ts | 974 ++++++++++++--- packages/qwik/src/server/ssr-node.spec.ts | 33 +- packages/qwik/src/server/ssr-node.ts | 285 +++-- .../qwik/src/server/ssr-streaming-walker.ts | 684 +++++++++++ packages/qwik/src/server/vnode-data.ts | 14 +- scripts/submodule-server.ts | 1 + 27 files changed, 3687 insertions(+), 791 deletions(-) create mode 100644 packages/qwik/src/core/ssr/ssr-diff.ts delete mode 100644 packages/qwik/src/core/ssr/ssr-render-jsx.ts create mode 100644 packages/qwik/src/core/tests/suspense.spec.tsx create mode 100644 packages/qwik/src/server/ssr-streaming-walker.ts diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index a93961dfa11..471a185f7d5 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -2227,6 +2227,34 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts", "mdFile": "core.ssrstreamwriter.md" }, + { + "name": "Suspense", + "id": "suspense", + "hierarchy": [ + { + "name": "Suspense", + "id": "suspense" + } + ], + "kind": "Variable", + "content": "```typescript\nSuspense: FunctionComponent\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts", + "mdFile": "core.suspense.md" + }, + { + "name": "SuspenseProps", + "id": "suspenseprops", + "hierarchy": [ + { + "name": "SuspenseProps", + "id": "suspenseprops" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type SuspenseProps = {\n fallback?: JSXOutput;\n children?: JSXOutput;\n};\n```\n**References:** [JSXOutput](#jsxoutput)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts", + "mdFile": "core.suspenseprops.md" + }, { "name": "SVGAttributes", "id": "svgattributes", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 0a44824e6f8..b838828773e 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -4867,6 +4867,27 @@ Description [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts) +

Suspense

+ +```typescript +Suspense: FunctionComponent; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts) + +

SuspenseProps

+ +```typescript +export type SuspenseProps = { + fallback?: JSXOutput; + children?: JSXOutput; +}; +``` + +**References:** [JSXOutput](#jsxoutput) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/jsx/utils.public.ts) +

SVGAttributes

The TS types don't include the SVG attributes so we have to define them ourselves diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index f463f17f147..5ed18e97f34 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -97,11 +97,13 @@ export const enum VNodeFlags { NS_math /* ****************** */ = 0b010_000000000, // http://www.w3.org/1998/Math/MathML /// Marks if the vnode has a target element for external projection rendering. HasTargetElement /* ********* */ = 0b100_000000000, + /// SSR: marks if the opening tag has been streamed (node is no longer updatable). + OpenTagEmitted /* *********** */ = 0b1000_000000000, } export const enum VNodeFlagsIndex { - mask /* ************** */ = 0b111_111111111, - shift /* ************* */ = 12, + mask /* ************** */ = 0b1111_111111111, + shift /* ************* */ = 13, } export const enum VNodeProps { diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index f77ec1a5451..48f243e0427 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -63,6 +63,7 @@ export { SSRRaw, SSRStream, SSRComment, + Suspense, SkipRender, } from './shared/jsx/utils.public'; export type { @@ -70,6 +71,7 @@ export type { SSRHintProps, SSRStreamChildren, SSRStreamWriter, + SuspenseProps, } from './shared/jsx/utils.public'; export { Slot } from './shared/jsx/slot.public'; export { diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index aed071f342c..5248c0afb9b 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -23,17 +23,22 @@ export { vnode_ensureElementInflated as _vnode_ensureElementInflated, vnode_getAttrKeys as _vnode_getAttrKeys, vnode_getFirstChild as _vnode_getFirstChild, + vnode_getProp as _vnode_getProp, vnode_isMaterialized as _vnode_isMaterialized, vnode_isTextVNode as _vnode_isTextVNode, vnode_isVirtualVNode as _vnode_isVirtualVNode, + vnode_removeProp as _vnode_removeProp, + vnode_setProp as _vnode_setProp, vnode_toString as _vnode_toString, } from './client/vnode-utils'; -export { _executeSsrChores } from './shared/cursor/ssr-chore-execution'; +export { addCursor as _addCursor } from './shared/cursor/cursor'; +export { getCursorData as _getCursorData } from './shared/cursor/cursor-props'; +export { processCursorQueue as _processCursorQueue } from './shared/cursor/cursor-walker'; export type { Container as _Container } from './shared/types'; export type { ElementVNode as _ElementVNode } from './shared/vnode/element-vnode'; export type { TextVNode as _TextVNode } from './shared/vnode/text-vnode'; -export type { VirtualVNode as _VirtualVNode } from './shared/vnode/virtual-vnode'; -export type { VNode as _VNode } from './shared/vnode/vnode'; +export { VirtualVNode as _VirtualVNode } from './shared/vnode/virtual-vnode'; +export { VNode as _VNode } from './shared/vnode/vnode'; export { _EFFECT_BACK_REF } from './reactive-primitives/backref'; export { _hasStoreEffects, isStore as _isStore } from './reactive-primitives/impl/store'; @@ -60,7 +65,7 @@ export { _SharedContainer } from './shared/shared-container'; export { _CONST_PROPS, _IMMUTABLE, _UNINITIALIZED, _VAR_PROPS } from './shared/utils/constants'; export { EMPTY_ARRAY as _EMPTY_ARRAY, EMPTY_OBJ as _EMPTY_OBJ } from './shared/utils/flyweight'; export { _restProps } from './shared/utils/prop'; -export { _walkJSX } from './ssr/ssr-render-jsx'; +export { ssrDiff as _ssrDiff } from './ssr/ssr-diff'; export { _resolveContextWithoutSequentialScope } from './use/use-context'; export { _getContextContainer, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index af732b49904..7aaa60f5cf2 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -12,6 +12,11 @@ import { isServer } from '@qwik.dev/core/build'; // @public export const $: (expression: T) => QRL; +// Warning: (ae-forgotten-export) The symbol "Cursor" needs to be exported by the entry point index.d.ts +// +// @internal +export function _addCursor(container: _Container, root: _VNode, priority: number): Cursor; + // @internal export function _addProjection(container: _Container, parentVNode: _VirtualVNode, componentQRL: QRL, props: Record, slotName: string): _VirtualVNode; @@ -377,12 +382,6 @@ export type EventHandler = { // @internal (undocumented) export const eventQrl: (qrl: QRL) => QRL; -// Warning: (ae-forgotten-export) The symbol "SSRContainer" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "ISsrNode" needs to be exported by the entry point index.d.ts -// -// @internal (undocumented) -export function _executeSsrChores(container: SSRContainer, ssrNode: ISsrNode): ValueOrPromise; - // Warning: (ae-forgotten-export) The symbol "WrappedSignalImpl" needs to be exported by the entry point index.d.ts // // @internal (undocumented) @@ -416,7 +415,12 @@ export const _getContextContainer: () => _Container | undefined; export const _getContextEvent: () => unknown; // @internal (undocumented) -export const _getContextHostElement: () => HostElement | undefined; +export const _getContextHostElement: () => _VNode | undefined; + +// Warning: (ae-forgotten-export) The symbol "CursorData" needs to be exported by the entry point index.d.ts +// +// @internal +export function _getCursorData(vNode: _VNode): CursorData | null; // Warning: (ae-incompatible-release-tags) The symbol "getDomContainer" is marked as @public, but its signature references "ClientContainer" which is marked as @internal // @@ -486,6 +490,8 @@ export const isSignal: (value: any) => value is Signal; // // @internal (undocumented) export interface ISsrComponentFrame { + // Warning: (ae-forgotten-export) The symbol "ISsrNode" needs to be exported by the entry point index.d.ts + // // (undocumented) componentNode: ISsrNode; // (undocumented) @@ -685,6 +691,11 @@ export const PrefetchServiceWorker: (opts: { // @internal export function _preprocessState(data: unknown[], container: DeserializeContainer): void; +// Warning: (ae-forgotten-export) The symbol "WalkOptions" needs to be exported by the entry point index.d.ts +// +// @internal +export function _processCursorQueue(options?: WalkOptions): void; + // @public export type PropFunction = QRL; @@ -1133,6 +1144,11 @@ export const SSRComment: FunctionComponent<{ data: string; }>; +// Warning: (ae-forgotten-export) The symbol "SSRContainer" needs to be exported by the entry point index.d.ts +// +// @internal +export function _ssrDiff(container: SSRContainer, jsx: JSXOutput, parentVNode: _VNode, cursor: Cursor, scopedStyleIdPrefix: string | null, parentComponentFrame?: ISsrComponentFrame | null): ValueOrPromise; + // @public (undocumented) export type SSRHintProps = { dynamic?: boolean; @@ -1185,6 +1201,15 @@ export class _SubscriptionData { data: NodePropData; } +// @public (undocumented) +export const Suspense: FunctionComponent; + +// @public (undocumented) +export type SuspenseProps = { + fallback?: JSXOutput; + children?: JSXOutput; +}; + // Warning: (ae-forgotten-export) The symbol "AriaAttributes" needs to be exported by the entry point index.d.ts // // @public @@ -1977,6 +2002,9 @@ export const _vnode_getAttrKeys: (container: _Container, vnode: _ElementVNode | // @internal (undocumented) export const _vnode_getFirstChild: (vnode: _VNode) => _VNode | null; +// @internal (undocumented) +export const _vnode_getProp: (vNode: _VNode, key: string, getObject: ((id: string) => unknown) | null) => T | null; + // @internal (undocumented) export const _vnode_isMaterialized: (vNode: _VNode) => boolean; @@ -1986,6 +2014,12 @@ export const _vnode_isTextVNode: (vNode: _VNode) => vNode is _TextVNode; // @internal (undocumented) export const _vnode_isVirtualVNode: (vNode: _VNode) => vNode is _VirtualVNode; +// @internal (undocumented) +export const _vnode_removeProp: (vNode: _VNode, key: string) => void; + +// @internal (undocumented) +export const _vnode_setProp: (vNode: _VNode, key: string, value: unknown) => void; + // @internal (undocumented) export function _vnode_toString(this: _VNode | null, depth?: number, offset?: string, materialize?: boolean, siblings?: boolean, colorize?: boolean, container?: _Container | null): string; @@ -2022,11 +2056,13 @@ export const enum _VNodeFlags { // (undocumented) NS_svg = 512, // (undocumented) - Resolved = 16,// http://www.w3.org/1999/xhtml + OpenTagEmitted = 4096,// http://www.w3.org/1999/xhtml + // (undocumented) + Resolved = 16,// http://www.w3.org/2000/svg // (undocumented) - Text = 4,// http://www.w3.org/2000/svg + Text = 4,// http://www.w3.org/1998/Math/MathML // (undocumented) - TYPE_MASK = 7,// http://www.w3.org/1998/Math/MathML + TYPE_MASK = 7, // (undocumented) Virtual = 2 } @@ -2034,12 +2070,6 @@ export const enum _VNodeFlags { // @internal (undocumented) export const _waitUntilRendered: (container: _Container) => Promise; -// @internal (undocumented) -export function _walkJSX(ssr: SSRContainer, value: JSXOutput, options: { - currentStyleScoped: string | null; - parentComponentFrame: ISsrComponentFrame | null; -}): Promise; - // @public export function withLocale(locale: string, fn: () => T): T; diff --git a/packages/qwik/src/core/shared/cursor/ssr-chore-execution.ts b/packages/qwik/src/core/shared/cursor/ssr-chore-execution.ts index 4532f943a1d..f1b45f56759 100644 --- a/packages/qwik/src/core/shared/cursor/ssr-chore-execution.ts +++ b/packages/qwik/src/core/shared/cursor/ssr-chore-execution.ts @@ -1,109 +1,231 @@ -import type { ISsrNode, SSRContainer } from '../../ssr/ssr-types'; -import { runTask, Task, TaskFlags, type TaskFn } from '../../use/use-task'; -import { SsrNodeFlags, type Container } from '../types'; -import { ELEMENT_SEQ } from '../utils/markers'; +import { vnode_getProp, vnode_setProp } from '../../client/vnode-utils'; +import { ssrDiff } from '../../ssr/ssr-diff'; +import type { ISsrComponentFrame, ISsrNode, SSRContainer } from '../../ssr/ssr-types'; +import { runTask } from '../../use/use-task'; +import type { Container } from '../types'; +import { ELEMENT_SEQ, OnRenderProp, QScopedStyle } from '../utils/markers'; +import { isPromise } from '../utils/promises'; +import { serializeAttribute } from '../utils/styles'; import type { ValueOrPromise } from '../utils/types'; import { ChoreBits } from '../vnode/enums/chore-bits.enum'; -import { logWarn } from '../utils/log'; -import { serializeAttribute } from '../utils/styles'; -import { NODE_PROPS_DATA_KEY } from './cursor-props'; -import type { NodeProp } from '../../reactive-primitives/subscription-data'; -import { isSignal, type Signal } from '../../reactive-primitives/signal.public'; -import { executeCompute } from './chore-execution'; -import { isPromise } from '../utils/promises'; +import type { VNode } from '../vnode/vnode'; +import { + executeTaskSequence, + forEachPendingNodeProp, + getComponentChoreData, + getScopedStylePrefix, + runComponentChore, + setNodeDiffPayload, + takeNodeDiffPayload, +} from './chore-helpers'; +import type { Cursor } from './cursor'; +import type { CursorData } from './cursor-props'; + +// ============================================================================ +// Cursor-walker SSR chore implementations +// These are called by walkCursor when isRunningOnServer is true. +// They mirror the client chore functions but operate on SsrNodes. +// ============================================================================ -/** @internal */ -export function _executeSsrChores( - container: SSRContainer, - ssrNode: ISsrNode +/** + * Execute tasks for an SSR node. Mirrors client `executeTasks`. + * + * Tasks are stored in the ELEMENT_SEQ property and executed in order. Render-blocking tasks return + * promises that pause the cursor. Non-blocking tasks go into extraPromises on cursorData. + */ +export function executeSsrTasks( + vNode: VNode, + container: Container, + cursorData: CursorData ): ValueOrPromise { - if (!(ssrNode.flags & SsrNodeFlags.Updatable)) { - if (ssrNode.dirty & ChoreBits.NODE_PROPS) { - executeNodePropChore(container, ssrNode); - } - if (ssrNode.dirty & ChoreBits.COMPUTE) { - executeCompute(ssrNode, container); - } - if (ssrNode.dirty & ChoreBits.DIRTY_MASK) { - // We are running on the server. - // On server we can't schedule task for a different host! - // Server is SSR, and therefore scheduling for anything but the current host - // implies that things need to be re-run and that is not supported because of streaming. - const warningMessage = `A chore was scheduled on a host element that has already been streamed to the client. - This can lead to inconsistencies between Server-Side Rendering (SSR) and Client-Side Rendering (CSR). - - Problematic chore: - - Host: ${ssrNode.toString()} - - Nearest element location: ${ssrNode.currentFile} - - This is often caused by modifying a signal in an already rendered component during SSR.`; - logWarn(warningMessage); - } - ssrNode.dirty &= ~ChoreBits.DIRTY_MASK; + vNode.dirty &= ~ChoreBits.TASKS; + return executeTaskSequence(vNode, container, cursorData, { + getElementSeq(node) { + return vnode_getProp(node, ELEMENT_SEQ, null) as unknown[] | null; + }, + isRenderBlocking() { + return true; + }, + treatVisibleTaskAsAfterFlush: false, + collectNonBlockingPromise(data, promise) { + (data.extraPromises ||= []).push(promise); + }, + runTask, + }); +} + +/** + * Execute component rendering for an SSR node. Mirrors client `executeComponentChore`. + * + * Executes the component QRL and stores the resulting JSX as `:nodeDiff` on the vNode, then marks + * NODE_DIFF dirty so the cursor walker processes it in the next chore cycle. This two-phase + * approach (COMPONENT → NODE_DIFF) matches the client-side pattern and enables the cursor walker to + * handle cascading task dependencies naturally via its TASKS → COMPONENT → NODE_DIFF ordering. + */ +export function executeSsrComponent( + vNode: VNode, + container: Container, + _cursorData: CursorData, + _cursor: Cursor +): ValueOrPromise { + vNode.dirty &= ~ChoreBits.COMPONENT; + const component = getComponentChoreData(vNode, container); + if (!component) { + // No QRL means this was just dirtied during initial inline execution — nothing to do. return; } - let promise: ValueOrPromise | null = null; - if (ssrNode.dirty & ChoreBits.TASKS) { - const result = executeTasksChore(container, ssrNode); - if (isPromise(result)) { - promise = result; - } - } + const ssrNode = vNode as unknown as ISsrNode; + const ssr = container as SSRContainer; - // In SSR, we don't handle the COMPONENT bit here. - // During initial render, if a task completes and marks the component dirty, - // we want to leave the COMPONENT bit set so that executeComponent can detect - // it after $waitOn$ completes and re-execute the component function. - // executeComponent will clear the bit after re-executing. + // Push component context BEFORE executing so hooks (useStylesScoped, useContext, etc.) + // can find the component frame via getComponentFrame(0). + const storedFrame = vnode_getProp(vNode, ':componentFrame', null) as any; + ssr.enterComponentContext(ssrNode, storedFrame || undefined); + const result = runComponentChore(container, component, (jsx) => { + // Record hook-injected child count (e.g., style elements from useStylesScoped$) + // so executeSsrNodeDiff can preserve them when clearing content for re-diff. + // Only set once: on first render, orderedChildren only has hook-injected children. + // On re-render, orderedChildren also has content from previous ssrDiff, so we + // must not overwrite the original boundary. + if (vnode_getProp(vNode, ':hookChildCount', null) == null) { + const orderedChildren = (ssrNode as any).orderedChildren; + vnode_setProp(vNode, ':hookChildCount', orderedChildren?.length ?? 0); + } - // Clear all dirty bits EXCEPT COMPONENT - ssrNode.dirty &= ~(ChoreBits.DIRTY_MASK & ~ChoreBits.COMPONENT); + setNodeDiffPayload(vNode, jsx); + vNode.dirty |= ChoreBits.NODE_DIFF; + }); - if (promise) { - return promise; + if (isPromise(result)) { + return (result as Promise).catch((error) => { + ssr.leaveComponentContext(); + throw error; + }); } } -function executeTasksChore(container: Container, ssrNode: ISsrNode): ValueOrPromise | null { - ssrNode.dirty &= ~ChoreBits.TASKS; - const elementSeq = ssrNode.getProp(ELEMENT_SEQ); - if (!elementSeq || elementSeq.length === 0) { - // No tasks to execute, clear the bit - return null; - } - let promise: ValueOrPromise | null = null; - for (const item of elementSeq) { - if (item instanceof Task) { - const task = item as Task; - - // Skip if task is not dirty - if (!(task.$flags$ & TaskFlags.DIRTY)) { - continue; - } +/** + * Execute node diff for an SSR node. Mirrors client `executeNodeDiff`. + * + * Processes stored JSX (from render() or signal-driven re-diffs) into child SsrNodes via ssrDiff. + * The cursor walker drives traversal through the resulting tree. + */ +export function executeSsrNodeDiff( + vNode: VNode, + container: Container, + _cursorData: CursorData, + cursor: Cursor +): ValueOrPromise { + vNode.dirty &= ~ChoreBits.NODE_DIFF; + + const jsx = takeNodeDiffPayload(vNode); + if (jsx) { + const ssr = container as SSRContainer; + const styleScopedId = getScopedStylePrefix(vnode_getProp(vNode, QScopedStyle, null)); - const result = runTask(task, container, ssrNode); - promise = promise ? promise.then(() => result as Promise) : (result as Promise); + // Reconciliation of existing children is handled inside ssrDiff's diff() function. + // It detects existing children when :hookChildCount is set (by executeSsrComponent) + // and reconciles them: reusing matching nodes, creating new ones, and cleaning up + // unmatched orphans via clearAllEffects. + + // For component nodes (have OnRenderProp), the component context was already + // pushed by executeSsrComponent. Use it and pop when done. + const isComponent = !!vnode_getProp(vNode, OnRenderProp, null); + if (isComponent) { + const componentFrame = ssr.getComponentFrame(0); + const result = ssrDiff(ssr, jsx as string, vNode, cursor, styleScopedId, componentFrame); + if (isPromise(result)) { + return (result as Promise).then(() => { + ssr.leaveComponentContext(); + }); + } + ssr.leaveComponentContext(); + return; } + + return ssrDiff(ssr, jsx as string, vNode, cursor, styleScopedId, null); } - return promise; } -export function executeNodePropChore(container: SSRContainer, ssrNode: ISsrNode): void { - ssrNode.dirty &= ~ChoreBits.NODE_PROPS; +/** + * Execute node prop updates for an SSR node. Mirrors client `executeNodeProps`. + * + * For already-streamed nodes, adds backpatch entries. For not-yet-streamed nodes, the attrs are + * already on the SsrNode and will be emitted normally. + */ +export function executeSsrNodeProps(vNode: VNode, container: Container): void { + vNode.dirty &= ~ChoreBits.NODE_PROPS; - const allPropData = ssrNode.getProp(NODE_PROPS_DATA_KEY) as Map | null; - if (!allPropData || allPropData.size === 0) { + const ssrNode = vNode as unknown as ISsrNode; + const hasPropData = forEachPendingNodeProp(vNode, (property, value, nodeProp) => { + const serializedValue = serializeAttribute(property, value, nodeProp.scopedStyleIdPrefix); + (container as SSRContainer).addBackpatchEntry(ssrNode, property, serializedValue); + }); + if (!hasPropData) { return; } +} - for (const [property, nodeProp] of allPropData.entries()) { - let value: Signal | string = nodeProp.value; - if (isSignal(value)) { - // TODO: Handle async signals (promises) - need to track pending async prop data - value = value.value as any; +/** + * Emit unclaimed projections for a component VNode after all its children have been processed. + * + * Called by the cursor walker when a node is about to be left (no more dirty bits, or after + * CHILDREN processing). This must happen AFTER children processing because deferred child + * components may consume slots during their execution (via Slot resolution). If we emitted + * unclaimed projections immediately in leaveComponentContext, we'd create duplicate SsrNodes with + * wrong parentComponent. + * + * Sets up minimal build state: the component's ssrNode as the current element frame's ssrNode + * (matching the state during original closeComponent), currentComponentNode pointing to the + * component node for proper parentComponent assignment on new SsrNodes. + */ +export function executeSsrUnclaimedProjections( + vNode: VNode, + container: Container, + _cursorData: CursorData, + cursor: Cursor +): ValueOrPromise { + const ssrNode = vNode as unknown as ISsrNode; + const componentFrame = vnode_getProp(vNode, ':componentFrame', null) as ISsrComponentFrame | null; + if (!componentFrame) { + return; + } + if (componentFrame.slots.length === 0) { + return; + } + const ssr = container as SSRContainer; + // Set up build state to match the state during original closeComponent: + // currentComponentNode = this component, ssrNode = this component's node + // tagNesting = the component's parent element nesting (so q:template is allowed) + const buildState = (ssr as any).ssrBuildState; + const savedComponentNode = buildState.currentComponentNode; + const savedSsrNode = buildState.currentElementFrame?.ssrNode ?? null; + const savedTagNesting = buildState.currentElementFrame?.tagNesting; + buildState.currentComponentNode = ssrNode; + if (buildState.currentElementFrame) { + buildState.currentElementFrame.ssrNode = ssrNode; + // Use the stored parent tag nesting from tree-building time, or ANYTHING as fallback + const storedTagNesting = vnode_getProp(vNode, ':parentTagNesting', null); + if (storedTagNesting != null) { + buildState.currentElementFrame.tagNesting = storedTagNesting; } - const serializedValue = serializeAttribute(property, value, nodeProp.scopedStyleIdPrefix); - container.addBackpatchEntry(ssrNode.id, property, serializedValue); } + + const result = ssr.emitUnclaimedProjectionForComponent(componentFrame); + + const cleanup = () => { + buildState.currentComponentNode = savedComponentNode; + if (buildState.currentElementFrame) { + buildState.currentElementFrame.ssrNode = savedSsrNode; + if (savedTagNesting != null) { + buildState.currentElementFrame.tagNesting = savedTagNesting; + } + } + }; + + if (isPromise(result)) { + return (result as Promise).then(cleanup); + } + cleanup(); } diff --git a/packages/qwik/src/core/shared/jsx/utils.public.ts b/packages/qwik/src/core/shared/jsx/utils.public.ts index 19ad81f1751..24d47d91a0c 100644 --- a/packages/qwik/src/core/shared/jsx/utils.public.ts +++ b/packages/qwik/src/core/shared/jsx/utils.public.ts @@ -42,3 +42,17 @@ export type SSRHintProps = { }; export const InternalSSRStream: FunctionComponent = () => null; + +/** @public */ +export type SuspenseProps = { + /** Fallback content shown while children are loading. */ + fallback?: JSXOutput; + children?: JSXOutput; +}; + +/** @public */ +export const Suspense: FunctionComponent = (props) => { + // On the client, Suspense is a no-op — just render children. + // The SSR handling is in ssrDiff (ssr-diff.ts). + return props.children; +}; diff --git a/packages/qwik/src/core/ssr/ssr-diff.ts b/packages/qwik/src/core/ssr/ssr-diff.ts new file mode 100644 index 00000000000..37a63c03491 --- /dev/null +++ b/packages/qwik/src/core/ssr/ssr-diff.ts @@ -0,0 +1,1081 @@ +/** + * @file SSR diff — processes JSX into SsrNode children using a DiffContext state machine. + * + * This is the SSR equivalent of client `vnode_diff`. It follows the same DiffContext-based + * traversal pattern: a while-loop dispatching on JSX type, with descend/ascend/advance stack + * mechanics shared via BaseDiffContext. + * + * Key differences from client: + * + * - Component$ is executed INLINE — sync components use ssrDescend/ssrAscend, async components break + * the diff loop and resume via drainAsyncQueue. + * - Inline components are executed immediately and their JSX pushed to asyncQueue. + * - SSR-specific JSX types (SSRComment, SSRRaw, SSRStream, SSRStreamBlock, Suspense). + * - On first render, always in creation mode (no existing children to reconcile). + * - On re-render (signal/task re-dirty), reconciles against existing SsrNode children. + * + * Container open/close methods (openElement/closeElement, openFragment/closeFragment) manage the + * element frame stack and VNodeData. Close callbacks are stored on a `$closeStack$` that is + * synchronized with the DiffContext's descend/ascend operations. + */ + +import { isDev } from '@qwik.dev/core/build'; +import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; +import { AsyncSignalImpl } from '../reactive-primitives/impl/async-signal-impl'; +import { EffectProperty } from '../reactive-primitives/types'; +import { isSignal } from '../reactive-primitives/utils'; +import { isQwikComponent, SERIALIZABLE_STATE, type OnRenderFn } from '../shared/component.public'; +import type { Cursor } from '../shared/cursor/cursor'; +import { + type BaseDiffContext, + advance as baseAdvance, + stackPush as baseStackPush, + stackPopBase, +} from '../shared/diff-context'; +import { Fragment } from '../shared/jsx/jsx-runtime'; +import { isJSXNode } from '../shared/jsx/jsx-node'; +import { directGetPropsProxyProp } from '../shared/jsx/props-proxy'; +import { Slot } from '../shared/jsx/slot.public'; +import { JSXNodeFlags, type JSXNodeInternal, type JSXOutput } from '../shared/jsx/types/jsx-node'; +import type { JSXChildren } from '../shared/jsx/types/jsx-qwik-attributes'; +import { + SSRComment, + SSRRaw, + SSRStream, + SSRStreamBlock, + Suspense, + type SSRStreamChildren, +} from '../shared/jsx/utils.public'; +import type { QRLInternal } from '../shared/qrl/qrl-class'; +import { DEBUG_TYPE, VirtualType } from '../shared/types'; +import { EMPTY_OBJ } from '../shared/utils/flyweight'; +import { getFileLocationFromJsx } from '../shared/utils/jsx-filename'; +import { + ELEMENT_KEY, + ELEMENT_PROPS, + OnRenderProp, + QDefaultSlot, + QSlot, + QSlotParent, + qwikInspectorAttr, +} from '../shared/utils/markers'; +import { isPromise, maybeThen, retryOnPromise } from '../shared/utils/promises'; +import { qInspector } from '../shared/utils/qdev'; +import { isFunction, type ValueOrPromise } from '../shared/utils/types'; +import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum'; +import type { VNode } from '../shared/vnode/vnode'; +import type { VirtualVNode } from '../shared/vnode/virtual-vnode'; +import { markVNodeDirty } from '../shared/vnode/vnode-dirty'; +import { trackSignalAndAssignHost } from '../use/use-core'; +import type { SerializationContext } from '../shared/serdes/index'; +import type { ISsrComponentFrame, ISsrNode, SSRContainer } from './ssr-types'; +import { applyInlineComponent } from './ssr-render-component'; +import { isAsyncGenerator } from '../shared/utils/async-generator'; +import type { SsrChild, SsrContentChild } from '../../server/ssr-node'; +import { VNodeFlags } from '../client/types'; +import { clearAllEffects } from '../reactive-primitives/cleanup'; +import type { Container } from '../shared/types'; +import { escapeHTML } from '../shared/utils/character-escaping'; +import { vnode_getProp, vnode_setProp } from '../client/vnode-utils'; + +// Inline equivalents to avoid circular dependency with ssr-node.ts (SsrNode extends VirtualVNode) +const enum SsrNodeKindLocal { + Text = 1, +} +function isSsrContentChild(child: SsrChild): child is SsrContentChild { + return 'kind' in child && !('id' in child); +} + +// ============================================================================ +// SsrDiffContext +// ============================================================================ + +export interface SsrDiffContext extends BaseDiffContext { + $container$: SSRContainer; + /** Current scoped style prefix for class injection */ + $currentStyleScoped$: string | null; + /** Current component frame for slot distribution */ + $parentComponentFrame$: ISsrComponentFrame | null; + /** + * Close callback stack — synchronized with DiffContext stack. Each ssrDescend pushes a callback + * (or null), each ssrAscend pops and executes it. This ensures container open/close operations + * (element frames, fragment boundaries) are properly balanced with the DiffContext's + * descend/ascend. + */ + $closeStack$: Array<(() => void) | null>; + /** + * When set, the diff loop exits early. Used for async operations (async components, async close + * callbacks) that need to break out of the synchronous loop. The asyncQueue draining handles + * resumption after the async operation completes. + */ + $asyncBreak$: boolean; + + // Reconciliation state (SSR re-render) + /** Previous orderedChildren for reconciliation (null = creation mode) */ + $oldChildren$: SsrChild[] | null; + /** Current index into $oldChildren$ */ + $oldChildIdx$: number; + /** New orderedChildren being built during reconciliation */ + $newChildren$: SsrChild[] | null; +} + +function createSsrDiffContext( + container: SSRContainer, + cursor: Cursor, + scopedStyleIdPrefix: string | null, + parentComponentFrame: ISsrComponentFrame | null +): SsrDiffContext { + return { + $container$: container, + $cursor$: cursor, + $scopedStyleIdPrefix$: scopedStyleIdPrefix, + $stack$: [], + $asyncQueue$: [], + $vParent$: null!, + $vCurrent$: null, + $vNewNode$: null, + $vSiblings$: null, + $vSiblingsArray$: null, + $vSideBuffer$: null, + $jsxChildren$: null!, + $jsxValue$: null, + $jsxIdx$: 0, + $jsxCount$: 0, + $shouldAdvance$: true, + $isCreationMode$: true, + $currentStyleScoped$: scopedStyleIdPrefix, + $parentComponentFrame$: parentComponentFrame, + $closeStack$: [], + $asyncBreak$: false, + $oldChildren$: null, + $oldChildIdx$: 0, + $newChildren$: null, + }; +} + +// ============================================================================ +// Stack mechanics with close callback synchronization +// ============================================================================ + +/** Descend into children, optionally registering a close callback for ascend. */ +function ssrDescend( + ctx: SsrDiffContext, + children: JSXChildren, + descendVNode: boolean, + closeCallback: (() => void) | null = null +) { + ctx.$closeStack$.push(closeCallback); + // Save reconciliation state on stack before baseStackPush saves JSX/VNode state + if (descendVNode) { + ctx.$stack$.push(ctx.$oldChildren$, ctx.$oldChildIdx$, ctx.$newChildren$); + } + baseStackPush(ctx, children, descendVNode); + if (descendVNode) { + const isReusing = ctx.$vCurrent$ !== null && ctx.$vNewNode$ === null; + const parent = (ctx.$vNewNode$ || ctx.$vCurrent$)!; + const parentVirtual = parent as unknown as VirtualVNode; + // Set VNode parent for dirty propagation — ensures markVNodeDirty can walk up to cursor root + if (!parent.parent) { + parent.parent = ctx.$vParent$; + } + ctx.$vSideBuffer$ = null; + ctx.$vSiblings$ = null; + ctx.$vSiblingsArray$ = null; + ctx.$vParent$ = parent; + ctx.$vCurrent$ = (parentVirtual.firstChild as VNode | null) ?? null; + ctx.$vNewNode$ = null; + + // Set up reconciliation state for children + if (isReusing) { + // Descending into a REUSED node — reconcile its children + const existingChildren = (parent as unknown as ISsrNode as any).orderedChildren as + | SsrChild[] + | null; + if (existingChildren && existingChildren.length > 0) { + ctx.$isCreationMode$ = false; + ctx.$oldChildren$ = existingChildren; + ctx.$oldChildIdx$ = 0; + // Swap out orderedChildren so container methods add to the new array + const newChildren: SsrChild[] = []; + (parent as unknown as ISsrNode as any).orderedChildren = newChildren; + ctx.$newChildren$ = newChildren; + } else { + ctx.$isCreationMode$ = true; + ctx.$oldChildren$ = null; + ctx.$oldChildIdx$ = 0; + ctx.$newChildren$ = null; + } + } else { + // Descending into a NEW node — pure creation mode + ctx.$isCreationMode$ = true; + ctx.$oldChildren$ = null; + ctx.$oldChildIdx$ = 0; + ctx.$newChildren$ = null; + } + } + ctx.$shouldAdvance$ = false; +} + +/** Ascend from children, executing the close callback pushed during ssrDescend. */ +function ssrAscend(ctx: SsrDiffContext) { + const cb = ctx.$closeStack$.pop(); + const descendVNode = stackPopBase(ctx); + if (descendVNode) { + // Restore reconciliation state from stack + ctx.$newChildren$ = ctx.$stack$.pop(); + ctx.$oldChildIdx$ = ctx.$stack$.pop(); + ctx.$oldChildren$ = ctx.$stack$.pop(); + } + if (cb) { + cb(); + } + if (!ctx.$asyncBreak$) { + ssrAdvance(ctx); + } +} + +function ssrAdvance(ctx: SsrDiffContext) { + if (ctx.$asyncBreak$) { + return; + } + if (ctx.$oldChildren$) { + // Reconciliation mode: use array-based navigation instead of VNode linked list. + // This mirrors baseAdvance but increments $oldChildIdx$ instead of peekNextSibling. + if (!ctx.$shouldAdvance$) { + ctx.$shouldAdvance$ = true; + return; + } + ctx.$jsxIdx$++; + if (ctx.$jsxIdx$ < ctx.$jsxCount$) { + ctx.$jsxValue$ = ctx.$jsxChildren$![ctx.$jsxIdx$]; + } else if (ctx.$stack$.length > 0 && ctx.$stack$[ctx.$stack$.length - 1] === false) { + // Non-VNode descend frame — auto-ascend + return ssrAscend(ctx); + } + if (ctx.$vNewNode$ !== null) { + // New node was inserted — clear it, keep old child cursor in place + ctx.$vNewNode$ = null; + } else { + // Move to next old child + ctx.$oldChildIdx$++; + } + } else { + baseAdvance(ctx, ssrAscend); + } +} + +// ============================================================================ +// Reconciliation helpers +// ============================================================================ + +/** Get current old child at cursor position */ +function getOldChild(ctx: SsrDiffContext): SsrChild | null { + return ctx.$oldChildren$ && ctx.$oldChildIdx$ < ctx.$oldChildren$.length + ? ctx.$oldChildren$[ctx.$oldChildIdx$] + : null; +} + +/** Check if an SsrChild is a text content child */ +function matchesText(child: SsrChild): boolean { + return isSsrContentChild(child) && (child as any).kind === SsrNodeKindLocal.Text; +} + +/** Throw if an SsrNode has already been emitted to the HTML stream */ +function assertNotEmitted(node: ISsrNode, action: string): void { + if ((node as any).flags & VNodeFlags.OpenTagEmitted) { + throw new Error(`Cannot ${action} already-emitted SsrNode during SSR re-render: ${node.id}`); + } +} + +/** + * Clean up remaining unmatched old children after JSX is exhausted. Mirrors client's + * expectNoMore(). + */ +function ssrExpectNoMore(ctx: SsrDiffContext) { + if (!ctx.$oldChildren$) { + return; + } + const container = ctx.$container$ as unknown as Container; + for (let i = ctx.$oldChildIdx$; i < ctx.$oldChildren$.length; i++) { + const child = ctx.$oldChildren$[i]; + if (!isSsrContentChild(child)) { + const node = child as ISsrNode; + assertNotEmitted(node, 'remove'); + clearAllEffects(container, node as unknown as VNode); + cleanupSsrTree(container, node); + } + } +} + +/** + * Recursively clean up signal subscriptions on an SsrNode tree. Walks orderedChildren depth-first, + * calling clearAllEffects on each SsrNode. + */ +function cleanupSsrTree(container: Container, node: ISsrNode): void { + const children = (node as any).orderedChildren as SsrChild[] | null; + if (children) { + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!isSsrContentChild(child)) { + clearAllEffects(container, child as unknown as VNode); + cleanupSsrTree(container, child as ISsrNode); + } + } + } +} + +/** + * Record a child (reused or new) in the reconciliation's newChildren list. In creation mode, also + * adds to orderedChildren via the container. + */ +function recordChild(ctx: SsrDiffContext, child: SsrChild): void { + if (ctx.$newChildren$) { + ctx.$newChildren$.push(child); + } +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +/** + * Process JSX and create/reconcile child SsrNodes under a parent VNode. + * + * @param container - The SSR container + * @param jsx - JSX to process + * @param parentVNode - Parent VNode (cursor tree node for dirty propagation) + * @param cursor - The cursor driving this diff + * @param scopedStyleIdPrefix - Current scoped style prefix + * @param parentComponentFrame - Current component frame for slot distribution + * @internal + */ +export function ssrDiff( + container: SSRContainer, + jsx: JSXOutput, + parentVNode: VNode, + cursor: Cursor, + scopedStyleIdPrefix: string | null, + parentComponentFrame: ISsrComponentFrame | null = null +): ValueOrPromise { + // Capture the active build state — during async yields, other cursors may swap + // ssrBuildState. We restore it after each async resolution. + const savedBuildState = (container as any).ssrBuildState; + // Store active cursor for container methods that need to call ssrDiff (e.g., unclaimed projections) + (container as any)._activeCursor = cursor; + + const ctx = createSsrDiffContext(container, cursor, scopedStyleIdPrefix, parentComponentFrame); + diff(ctx, jsx as JSXChildren, parentVNode); + return drainAsyncQueue(ctx, savedBuildState); +} + +// ============================================================================ +// Core diff loop +// ============================================================================ + +function diff(ctx: SsrDiffContext, jsxNode: JSXChildren, vStartNode: VNode) { + ctx.$vParent$ = vStartNode; + ctx.$vNewNode$ = null; + ctx.$vCurrent$ = ((vStartNode as unknown as VirtualVNode).firstChild as VNode | null) ?? null; + + // Check for existing children → reconciliation mode. + // Only enter reconciliation for component re-renders (hookChildCount explicitly set), + // NOT for additive streaming writes where children accumulate from multiple ssrDiff calls. + const ssrParent = vStartNode as unknown as ISsrNode; + const existing = (ssrParent as any).orderedChildren as SsrChild[] | null; + const hookCountProp = vnode_getProp(vStartNode, ':hookChildCount', null); + if (hookCountProp != null && existing && existing.length > (hookCountProp as number)) { + const hookCount = hookCountProp as number; + ctx.$isCreationMode$ = false; + // Save old children in a separate array for iteration + ctx.$oldChildren$ = existing; + ctx.$oldChildIdx$ = hookCount; + // Replace parent's orderedChildren with a new array (keeping hook children). + // Container methods (openElement, textNode, etc.) will add new nodes to this array. + // Reused nodes are added via recordChild. + const newChildren = existing.slice(0, hookCount); + (ssrParent as any).orderedChildren = newChildren; + ctx.$newChildren$ = newChildren; + } + + // Root-level push: no close callback needed + ctx.$closeStack$.push(null); + baseStackPush(ctx, jsxNode, true); + + runDiffLoop(ctx); + + // Clean up reconciliation state (orderedChildren already replaced above) + ctx.$newChildren$ = null; + ctx.$oldChildren$ = null; +} + +/** The inner while loop of diff — extracted so it can be resumed after async breaks. */ +function runDiffLoop(ctx: SsrDiffContext) { + const ssr = ctx.$container$; + + while (ctx.$stack$.length) { + while (ctx.$jsxIdx$ < ctx.$jsxCount$) { + if (ctx.$asyncBreak$) { + return; + } + const value = ctx.$jsxValue$; + + if (typeof value === 'string') { + ssrText(ctx, ssr, value); + } else if (typeof value === 'number') { + ssrText(ctx, ssr, String(value)); + } else if (value == null || typeof value === 'boolean') { + ssrText(ctx, ssr, ''); + } else if (typeof value === 'object') { + if (isJSXNode(value)) { + const jsx = value as JSXNodeInternal; + const type = jsx.type; + + if (typeof type === 'string') { + ssrElement(ctx, jsx, type); + } else if (typeof type === 'function') { + if (type === Fragment) { + ssrFragment(ctx, jsx); + } else if (type === Slot) { + ssrSlot(ctx, jsx); + } else if (type === SSRComment) { + ssr.commentNode(directGetPropsProxyProp(jsx, 'data') || ''); + } else if (type === SSRRaw) { + ssr.htmlNode(directGetPropsProxyProp(jsx, 'data')); + } else if (type === SSRStream) { + ssrStream(ctx, jsx); + } else if (type === SSRStreamBlock) { + ssrStreamBlock(ctx, jsx); + } else if (type === Suspense) { + ssrSuspense(ctx, jsx); + } else if (isQwikComponent(type)) { + ssrComponent(ctx, jsx, type); + } else { + ssrInlineComponent(ctx, jsx, type); + } + } + } else if (Array.isArray(value)) { + ssrDescend(ctx, value, false); + } else if (isSignal(value)) { + ssrSignal(ctx, value); + } else if (isPromise(value)) { + ssrPromise(ctx, value); + } else if (isAsyncGenerator(value)) { + ssrAsyncGenerator(ctx, value); + } + } + ssrAdvance(ctx); + } + if (ctx.$asyncBreak$) { + return; + } + // Clean up remaining unmatched old children before ascending + ssrExpectNoMore(ctx); + // Finalize orderedChildren for the node we're leaving + if (ctx.$newChildren$ && ctx.$vParent$) { + const parentNode = ctx.$vParent$ as unknown as ISsrNode; + (parentNode as any).orderedChildren = ctx.$newChildren$; + } + ssrAscend(ctx); + } +} + +// ============================================================================ +// Async queue draining +// ============================================================================ + +function drainAsyncQueue(ctx: SsrDiffContext, savedBuildState: any): ValueOrPromise { + const ssr = ctx.$container$; + + // Handle async break: process the async item, then resume the diff loop + if (ctx.$asyncBreak$) { + ctx.$asyncBreak$ = false; + if (ctx.$asyncQueue$.length > 0) { + const asyncItem = ctx.$asyncQueue$.shift()!; + ctx.$asyncQueue$.shift(); // skip vNode marker + + if (isPromise(asyncItem)) { + return maybeThen(asyncItem as Promise, (resolved) => { + (ssr as any).ssrBuildState = savedBuildState; + // Ensure advance actually moves forward (asyncBreak may have skipped ssrDescend) + ctx.$shouldAdvance$ = true; + ssrAdvance(ctx); // advance past the item that triggered the break + runDiffLoop(ctx); + return drainAsyncQueue(ctx, savedBuildState); + }); + } + // Sync async-break item — resume immediately + ctx.$shouldAdvance$ = true; + ssrAdvance(ctx); + runDiffLoop(ctx); + } + } + + // Normal async queue processing + while (ctx.$asyncQueue$.length > 0) { + const jsxOrPromise = ctx.$asyncQueue$.shift()!; + const vNode = ctx.$asyncQueue$.shift() as VNode; + + if (isPromise(jsxOrPromise)) { + return maybeThen(jsxOrPromise as Promise, (resolvedJsx) => { + (ssr as any).ssrBuildState = savedBuildState; + if (resolvedJsx != null) { + diff(ctx, resolvedJsx as JSXChildren, vNode); + } + return drainAsyncQueue(ctx, savedBuildState); + }); + } else { + diff(ctx, jsxOrPromise as JSXChildren, vNode); + } + } +} + +// ============================================================================ +// JSX type handlers +// ============================================================================ + +/** Text node: reconcile against existing text or create new. */ +function ssrText(ctx: SsrDiffContext, ssr: SSRContainer, text: string) { + if (ctx.$oldChildren$) { + const old = getOldChild(ctx); + if (old && matchesText(old)) { + // Reuse existing text — update content + const escaped = escapeHTML(text); + (old as SsrContentChild).content = escaped; + (old as SsrContentChild).textLength = text.length; + recordChild(ctx, old); + // Don't set $vNewNode$ — text isn't a VNode. Advance will increment oldChildIdx. + return; + } + // No text match — create new via container method (adds to new orderedChildren). + // Set $vNewNode$ sentinel to prevent advance from incrementing oldChildIdx + // (the old child at current position wasn't consumed). + ssr.textNode(text); + ctx.$vNewNode$ = ctx.$vParent$; // sentinel: any non-null VNode + return; + } + ssr.textNode(text); +} + +/** HTML element: open, descend into children, close on ascend. */ +function ssrElement(ctx: SsrDiffContext, jsx: JSXNodeInternal, tagName: string) { + const ssr = ctx.$container$; + + appendClassIfScopedStyleExists(jsx, ctx.$currentStyleScoped$); + + let currentFile: string | null = null; + if (isDev && jsx.dev && tagName !== 'head') { + currentFile = getFileLocationFromJsx(jsx.dev); + if (qInspector) { + appendQwikInspectorAttribute(jsx, currentFile); + } + } + + const innerHTML = ssr.openElement( + tagName, + jsx.key, + jsx.varProps, + jsx.constProps, + ctx.$currentStyleScoped$, + currentFile, + !!(jsx.flags & JSXNodeFlags.HasCapturedProps) + ); + + if (innerHTML) { + ssr.htmlNode(innerHTML); + } + + // Handle special elements + let extraChildren: JSXOutput | null = null; + if (tagName === 'head') { + ssr.emitQwikLoaderAtTopIfNeeded(); + ssr.emitPreloaderPre(); + // Additional head nodes are merged with children (must be inside before close) + const headNodes = ssr.additionalHeadNodes; + if (headNodes.length > 0) { + extraChildren = headNodes as unknown as JSXOutput; + } + } else if (tagName === 'body') { + const bodyNodes = ssr.additionalBodyNodes; + if (bodyNodes.length > 0) { + extraChildren = bodyNodes as unknown as JSXOutput; + } + } else if (!ssr.isHtml && !(ssr as any)._didAddQwikLoader) { + ssr.emitQwikLoaderAtTopIfNeeded(); + ssr.emitPreloaderPre(); + (ssr as any)._didAddQwikLoader = true; + } + + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + + const children = jsx.children as JSXOutput; + // Combine children with extra nodes (head styles, body scripts) + const combinedChildren = + extraChildren != null + ? children != null + ? [children, extraChildren] + : extraChildren + : children; + if (combinedChildren != null && !innerHTML) { + ssrDescend(ctx, combinedChildren as JSXChildren, true, () => ssr.closeElement()); + } else { + ssr.closeElement(); + } +} + +/** Fragment: open virtual node, descend, close on ascend. */ +function ssrFragment(ctx: SsrDiffContext, jsx: JSXNodeInternal) { + const ssr = ctx.$container$; + const attrs: Record = jsx.key != null ? { [ELEMENT_KEY]: jsx.key } : {}; + if (isDev) { + attrs[DEBUG_TYPE] = VirtualType.Fragment; + } + ssr.openFragment(attrs); + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + + const children = jsx.children as JSXOutput; + if (children != null) { + ssrDescend(ctx, children as JSXChildren, true, () => ssr.closeFragment()); + } else { + ssr.closeFragment(); + } +} + +/** + * Component$ (INLINE execution): open component, distribute slots, execute component QRL, process + * returned JSX as children, close component on ascend. + * + * For sync components, children are processed via ssrDescend/ssrAscend. For async components, the + * diff loop breaks and resumes via drainAsyncQueue. + */ +function ssrComponent(ctx: SsrDiffContext, jsx: JSXNodeInternal, component: Function) { + const ssr = ctx.$container$; + const [componentQRL] = (component as any)[SERIALIZABLE_STATE] as [QRLInternal>]; + + const componentAttrs: Record = {}; + if (isDev) { + componentAttrs[DEBUG_TYPE] = VirtualType.Component; + } + + // Create component SsrNode directly (without openComponent which would + // push build state that can't be popped if we defer). + ssr.openFragment(componentAttrs); + const host = ssr.getOrCreateLastNode(); + + // Capture children BEFORE deleting from props (the props proxy delegates + // children to jsx.children, so `delete srcProps.children` nullifies it). + const children = jsx.children; + + // Store QRL and props on the host node + const srcProps = jsx.props; + if (srcProps && srcProps.children) { + delete srcProps.children; + } + const hostVNode = host as unknown as VNode; + vnode_setProp(hostVNode, OnRenderProp, componentQRL); + vnode_setProp(hostVNode, ELEMENT_PROPS, srcProps); + if (jsx.key !== null) { + vnode_setProp(hostVNode, ELEMENT_KEY, jsx.key); + } + + // Create component frame and distribute slots (no walker context changes) + const componentFrame = ssr.createAndDistributeComponentFrame( + host, + children, + ctx.$currentStyleScoped$, + ctx.$parentComponentFrame$ + ); + + // Store component frame on node for cursor walker to retrieve + vnode_setProp(hostVNode, ':componentFrame', componentFrame); + // Store parent element frame info for enterComponentContext to use when creating + // a synthetic element frame (needed for HTML nesting validation and style routing). + const parentFrame = (ssr as any).ssrBuildState.currentElementFrame; + vnode_setProp(hostVNode, ':parentTagNesting', parentFrame?.tagNesting ?? 0); + vnode_setProp(hostVNode, ':parentElementName', parentFrame?.elementName ?? null); + + // Register in cursor tree so cursor walker can visit and execute the component + hostVNode.parent = ctx.$vParent$; + ctx.$vNewNode$ = hostVNode; + + // markVNodeDirty propagates CHILDREN up to the cursor root, so the walker finds this node + markVNodeDirty(ssr, hostVNode, ChoreBits.COMPONENT, ctx.$cursor$); + + ssr.closeFragment(); +} + +/** + * Inline component: open fragment, execute component function, process output. For sync output, + * descend into children with close-fragment on ascend. For async output, push to asyncQueue and + * close fragment immediately. + */ +function ssrInlineComponent(ctx: SsrDiffContext, jsx: JSXNodeInternal, inlineFn: Function) { + const ssr = ctx.$container$; + + const inlineAttrs: Record = { [ELEMENT_KEY]: jsx.key }; + if (isDev) { + inlineAttrs[DEBUG_TYPE] = VirtualType.InlineComponent; + } + ssr.openFragment(inlineAttrs); + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + + // Use ctx.$parentComponentFrame$ (not ssr.getParentComponentFrame()) because in cursor-driven + // mode each cursor has its own componentStack that doesn't include all ancestors. + // Inside a projection, ssrSlot sets $parentComponentFrame$ to the projection's parent + // component frame, which correctly crosses the projection boundary for subscription tracking. + const parentFrame = ctx.$parentComponentFrame$; + const jsxOutput = applyInlineComponent( + ssr, + parentFrame && parentFrame.componentNode, + inlineFn as OnRenderFn, + jsx + ); + + const fragmentVNode = node as unknown as VNode; + // Ensure VNode parent chain is connected for dirty propagation (async path doesn't call + // ssrDescend which normally sets this). + if (!fragmentVNode.parent) { + fragmentVNode.parent = ctx.$vParent$; + } + + if (isPromise(jsxOutput)) { + // Async inline component: break the diff loop, keep parent elements open. + // We must NOT close the fragment yet — the resolved JSX needs to be rendered + // inside the current element frame context. Close fragment after resolution. + ctx.$asyncBreak$ = true; + const savedBuildState = (ssr as any).ssrBuildState; + const asyncLifecycle = (jsxOutput as Promise).then(async (resolvedJsx) => { + (ssr as any).ssrBuildState = savedBuildState; + if (resolvedJsx != null) { + await ssrDiff( + ssr, + resolvedJsx, + fragmentVNode, + ctx.$cursor$, + ctx.$currentStyleScoped$, + ctx.$parentComponentFrame$ + ); + } + ssr.closeFragment(); + }); + ctx.$asyncQueue$.unshift(asyncLifecycle as any, fragmentVNode); + } else if (jsxOutput != null) { + // Sync inline component: descend into children, close fragment on ascend + ssrDescend(ctx, jsxOutput as JSXChildren, true, () => ssr.closeFragment()); + } else { + ssr.closeFragment(); + } +} + +/** Signal: wrap in virtual node, track, descend into value. */ +function ssrSignal(ctx: SsrDiffContext, signal: any) { + const ssr = ctx.$container$; + + maybeAddPollingAsyncSignalToEagerResume(ssr.serializationCtx, signal); + + const attrs: Record = isDev + ? { [DEBUG_TYPE]: VirtualType.WrappedSignal } + : EMPTY_OBJ; + ssr.openFragment(attrs); + const signalNode = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = signalNode as unknown as VNode; + + const unwrappedSignal = signal instanceof WrappedSignalImpl ? signal.$unwrapIfSignal$() : signal; + + const trackFn = () => + trackSignalAndAssignHost( + unwrappedSignal, + signalNode as unknown as VNode, + EffectProperty.VNODE, + ssr + ); + + // Track the signal value. If tracking throws a Promise (async signal needing computation), + // use retryOnPromise to await and retry. The resolved value is rendered directly (no Awaited). + // If tracking returns a Promise (signal's literal value is a Promise), descend into it + // so the inner diff loop creates the Awaited wrapper via ssrPromise. + let trackedValue: any; + let threwPromise = false; + try { + trackedValue = trackFn(); + } catch (e) { + if (isPromise(e)) { + threwPromise = true; + // Async signal computation — retryOnPromise awaits thrown promise, retries + trackedValue = retryOnPromise(trackFn); + } else { + throw e; + } + } + + if (threwPromise && isPromise(trackedValue)) { + // Async signal (threw Promise during tracking): render resolved value directly + // inside signal fragment — no Awaited wrapper. + ctx.$asyncBreak$ = true; + const savedBuildState = (ssr as any).ssrBuildState; + const asyncLifecycle = (trackedValue as Promise).then(async (resolvedValue) => { + (ssr as any).ssrBuildState = savedBuildState; + if (resolvedValue != null) { + await ssrDiff( + ssr, + resolvedValue as JSXOutput, + signalNode as unknown as VNode, + ctx.$cursor$, + ctx.$currentStyleScoped$, + ctx.$parentComponentFrame$ + ); + } + ssr.closeFragment(); + }); + ctx.$asyncQueue$.unshift(asyncLifecycle as any, signalNode as unknown as VNode); + } else { + // Tracked value is synchronous (possibly a literal Promise) — descend into it. + // If it's a Promise, the inner diff loop creates an Awaited wrapper via ssrPromise. + ssrDescend(ctx, trackedValue as JSXChildren, true, () => ssr.closeFragment()); + } +} + +/** Promise: wrap in virtual node, flush stream, push to async queue. */ +function ssrPromise(ctx: SsrDiffContext, promise: Promise) { + const ssr = ctx.$container$; + + const attrs: Record = isDev ? { [DEBUG_TYPE]: VirtualType.Awaited } : EMPTY_OBJ; + ssr.openFragment(attrs); + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + // Ensure VNode parent chain is connected for dirty propagation (async path doesn't call + // ssrDescend which normally sets this). + const nodeVNode = node as unknown as VNode; + if (!nodeVNode.parent) { + nodeVNode.parent = ctx.$vParent$; + } + + ssr.streamHandler.flush(); + + // Async break: keep fragment open, await promise, process resolved JSX inside, then close + ctx.$asyncBreak$ = true; + const savedBuildState = (ssr as any).ssrBuildState; + const asyncLifecycle = promise.then(async (resolvedJsx: any) => { + (ssr as any).ssrBuildState = savedBuildState; + if (resolvedJsx != null) { + await ssrDiff( + ssr, + resolvedJsx, + node as unknown as VNode, + ctx.$cursor$, + ctx.$currentStyleScoped$, + ctx.$parentComponentFrame$ + ); + } + ssr.closeFragment(); + }); + ctx.$asyncQueue$.unshift(asyncLifecycle as any, node as unknown as VNode); +} + +/** Slot: consume projected content from component frame. */ +function ssrSlot(ctx: SsrDiffContext, jsx: JSXNodeInternal) { + const ssr = ctx.$container$; + const componentFrame = ctx.$parentComponentFrame$; + + if (componentFrame) { + const compId = componentFrame.componentNode.id || ''; + const projectionAttrs: Record = isDev + ? { [DEBUG_TYPE]: VirtualType.Projection } + : {}; + projectionAttrs[QSlotParent] = compId; + ssr.openProjection(projectionAttrs); + + const host = componentFrame.componentNode; + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + const slotName = getSlotName(host, jsx, ssr); + projectionAttrs[QSlot] = slotName; + + const slotDefaultChildren: JSXChildren | null = jsx.children || null; + const slotChildren = + componentFrame.consumeChildrenForSlot(node, slotName) || slotDefaultChildren; + if (slotDefaultChildren && slotChildren !== slotDefaultChildren) { + ssr.addUnclaimedProjection(componentFrame, QDefaultSlot, slotDefaultChildren); + } + + // Save style/component frame, switch to projection's scope + const savedStyleScoped = ctx.$currentStyleScoped$; + const savedComponentFrame = ctx.$parentComponentFrame$; + ctx.$currentStyleScoped$ = componentFrame.projectionScopedStyle; + ctx.$parentComponentFrame$ = componentFrame.projectionComponentFrame; + + if (slotChildren != null) { + ssrDescend(ctx, slotChildren as JSXChildren, true, () => { + ssr.closeProjection(); + ctx.$currentStyleScoped$ = savedStyleScoped; + ctx.$parentComponentFrame$ = savedComponentFrame; + }); + } else { + ssr.closeProjection(); + ctx.$currentStyleScoped$ = savedStyleScoped; + ctx.$parentComponentFrame$ = savedComponentFrame; + } + } else { + // No component frame — emit empty projection marker + let projectionAttrs = EMPTY_OBJ; + if (isDev) { + projectionAttrs = { [DEBUG_TYPE]: VirtualType.Projection }; + } + ssr.openFragment(projectionAttrs); + ssr.closeFragment(); + } +} + +/** Suspense: create boundary, process fallback inline, create sub-cursor for children. */ +function ssrSuspense(ctx: SsrDiffContext, jsx: JSXNodeInternal) { + const ssr = ctx.$container$; + const fallback = directGetPropsProxyProp(jsx, 'fallback') as JSXOutput; + + const suspenseAttrs: Record = {}; + if (isDev) { + suspenseAttrs[DEBUG_TYPE] = VirtualType.InlineComponent; + } + ssr.openSuspenseBoundary(suspenseAttrs); + const node = ssr.getOrCreateLastNode(); + ctx.$vNewNode$ = node as unknown as VNode; + + // Create sub-cursor for Suspense children before processing fallback + const children = jsx.children as JSXOutput; + if (children != null) { + (ssr as any).createSuspenseSubCursor(children); + } + + if (fallback != null) { + ssrDescend(ctx, fallback as JSXChildren, true, () => ssr.closeSuspenseBoundary()); + } else { + ssr.closeSuspenseBoundary(); + } +} + +/** SSRStream: flush and process generator or async value. */ +function ssrStream(ctx: SsrDiffContext, jsx: JSXNodeInternal) { + const ssr = ctx.$container$; + ssr.streamHandler.flush(); + + const generator = jsx.children as SSRStreamChildren; + const savedBuildState = (ssr as any).ssrBuildState; + const parentVNode = ctx.$vParent$; + const cursor = ctx.$cursor$; + const styleScoped = ctx.$currentStyleScoped$; + const parentFrame = ctx.$parentComponentFrame$; + let value: AsyncGenerator | Promise; + + if (isFunction(generator)) { + value = generator({ + async write(chunk) { + (ssr as any).ssrBuildState = savedBuildState; + await ssrDiff(ssr, chunk, parentVNode, cursor, styleScoped, parentFrame); + ssr.streamHandler.flush(); + }, + }); + } else { + value = generator; + } + + if (isAsyncGenerator(value)) { + // Async generator: iterate and process each yielded chunk + const lifecycle = (async () => { + for await (const chunk of value as AsyncGenerator) { + (ssr as any).ssrBuildState = savedBuildState; + await ssrDiff(ssr, chunk as JSXOutput, parentVNode, cursor, styleScoped, parentFrame); + } + })(); + ctx.$asyncBreak$ = true; + ctx.$asyncQueue$.unshift(lifecycle as any, parentVNode); + } else if (isPromise(value)) { + // Async function with write callback: block until complete + ctx.$asyncBreak$ = true; + ctx.$asyncQueue$.unshift(value as any, parentVNode); + } +} + +/** SSRStreamBlock: wrap children in stream block markers. */ +function ssrStreamBlock(ctx: SsrDiffContext, jsx: JSXNodeInternal) { + const ssr = ctx.$container$; + ssr.streamHandler.streamBlockStart(); + + const children = jsx.children as JSXOutput; + if (children != null) { + ssrDescend(ctx, children as JSXChildren, false, () => ssr.streamHandler.streamBlockEnd()); + } else { + ssr.streamHandler.streamBlockEnd(); + } +} + +/** Async generator: process chunks as they arrive. */ +function ssrAsyncGenerator(ctx: SsrDiffContext, generator: AsyncGenerator) { + const ssr = ctx.$container$; + const savedBuildState = (ssr as any).ssrBuildState; + const parentVNode = ctx.$vParent$; + const cursor = ctx.$cursor$; + const styleScoped = ctx.$currentStyleScoped$; + const parentFrame = ctx.$parentComponentFrame$; + + const processGenerator = async () => { + for await (const chunk of generator) { + (ssr as any).ssrBuildState = savedBuildState; + await ssrDiff(ssr, chunk as JSXOutput, parentVNode, cursor, styleScoped, parentFrame); + ssr.streamHandler.flush(); + } + }; + + ctx.$asyncBreak$ = true; + ctx.$asyncQueue$.unshift(processGenerator() as unknown as JSXChildren, parentVNode); +} + +// ============================================================================ +// Utility helpers +// ============================================================================ + +function getSlotName(host: ISsrNode, jsx: JSXNodeInternal, ssr: SSRContainer): string { + const constProps = jsx.constProps; + if (constProps && typeof constProps == 'object' && 'name' in constProps) { + const constValue = constProps.name; + if (constValue instanceof WrappedSignalImpl) { + return trackSignalAndAssignHost( + constValue, + host as unknown as VNode, + EffectProperty.COMPONENT, + ssr + ); + } + } + return directGetPropsProxyProp(jsx, 'name') || QDefaultSlot; +} + +function appendQwikInspectorAttribute(jsx: JSXNodeInternal, value: string | null) { + if (value && (!jsx.constProps || !(qwikInspectorAttr in jsx.constProps))) { + (jsx.constProps ||= {})[qwikInspectorAttr] = value; + } +} + +function appendClassIfScopedStyleExists(jsx: JSXNodeInternal, styleScoped: string | null) { + const classExists = directGetPropsProxyProp(jsx, 'class') != null; + if (!classExists && styleScoped) { + if (!jsx.constProps) { + jsx.constProps = {}; + } + jsx.constProps['class'] = ''; + } +} + +function maybeAddPollingAsyncSignalToEagerResume( + serializationCtx: SerializationContext, + signal: unknown +) { + const unwrappedSignal = signal instanceof WrappedSignalImpl ? signal.$unwrapIfSignal$() : signal; + if (unwrappedSignal instanceof AsyncSignalImpl) { + const interval = unwrappedSignal.$interval$; + if (interval > 0) { + serializationCtx.$addRoot$(unwrappedSignal); + serializationCtx.$eagerResume$.add(unwrappedSignal); + } + } +} diff --git a/packages/qwik/src/core/ssr/ssr-render-component.ts b/packages/qwik/src/core/ssr/ssr-render-component.ts index 54a4783950b..6f030b293c8 100644 --- a/packages/qwik/src/core/ssr/ssr-render-component.ts +++ b/packages/qwik/src/core/ssr/ssr-render-component.ts @@ -1,11 +1,8 @@ import type { JSXNode } from '@qwik.dev/core'; -import { SERIALIZABLE_STATE, type Component, type OnRenderFn } from '../shared/component.public'; -import type { QRLInternal } from '../shared/qrl/qrl-class'; -import { ELEMENT_KEY, ELEMENT_PROPS, OnRenderProp } from '../shared/utils/markers'; -import { type ISsrNode, type SSRContainer } from './ssr-types'; +import type { OnRenderFn } from '../shared/component.public'; +import type { HostElement } from '../shared/types'; +import type { ISsrNode, SSRContainer } from './ssr-types'; import { executeComponent } from '../shared/component-execution'; -import type { ValueOrPromise } from '../shared/utils/types'; -import type { JSXOutput } from '../shared/jsx/types/jsx-node'; export const applyInlineComponent = ( ssr: SSRContainer, @@ -14,24 +11,11 @@ export const applyInlineComponent = ( jsx: JSXNode ) => { const host = ssr.getOrCreateLastNode(); - return executeComponent(ssr, host, componentHost, inlineComponentFunction, jsx.props); -}; - -export const applyQwikComponentBody = ( - ssr: SSRContainer, - jsx: JSXNode, - component: Component -): ValueOrPromise => { - const host = ssr.getOrCreateLastNode(); - const [componentQrl] = (component as any)[SERIALIZABLE_STATE] as [QRLInternal>]; - const srcProps = jsx.props; - if (srcProps && srcProps.children) { - delete srcProps.children; - } - host.setProp(OnRenderProp, componentQrl); - host.setProp(ELEMENT_PROPS, srcProps); - if (jsx.key !== null) { - host.setProp(ELEMENT_KEY, jsx.key); - } - return executeComponent(ssr, host, host, componentQrl, srcProps); + return executeComponent( + ssr, + host as unknown as HostElement, + componentHost as unknown as HostElement, + inlineComponentFunction, + jsx.props + ); }; diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts deleted file mode 100644 index 6ca1765daf2..00000000000 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { isDev } from '@qwik.dev/core/build'; -import { _run } from '../client/run-qrl'; -import { AsyncSignalImpl } from '../reactive-primitives/impl/async-signal-impl'; -import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; -import { EffectProperty } from '../reactive-primitives/types'; -import { isSignal } from '../reactive-primitives/utils'; -import { isQwikComponent } from '../shared/component.public'; -import { Fragment } from '../shared/jsx/jsx-runtime'; -import { directGetPropsProxyProp } from '../shared/jsx/props-proxy'; -import { Slot } from '../shared/jsx/slot.public'; -import { JSXNodeFlags, type JSXNodeInternal, type JSXOutput } from '../shared/jsx/types/jsx-node'; -import type { JSXChildren } from '../shared/jsx/types/jsx-qwik-attributes'; -import { - SSRComment, - SSRRaw, - SSRStream, - SSRStreamBlock, - type SSRStreamChildren, -} from '../shared/jsx/utils.public'; -import { type SerializationContext } from '../shared/serdes/index'; -import { DEBUG_TYPE, VirtualType } from '../shared/types'; -import { isAsyncGenerator } from '../shared/utils/async-generator'; -import { EMPTY_OBJ } from '../shared/utils/flyweight'; -import { getFileLocationFromJsx } from '../shared/utils/jsx-filename'; -import { - ELEMENT_KEY, - QDefaultSlot, - QScopedStyle, - QSlot, - QSlotParent, - qwikInspectorAttr, -} from '../shared/utils/markers'; -import { isPromise, retryOnPromise } from '../shared/utils/promises'; -import { qInspector } from '../shared/utils/qdev'; -import { addComponentStylePrefix } from '../shared/utils/scoped-styles'; -import { isFunction, type ValueOrPromise } from '../shared/utils/types'; -import { trackSignalAndAssignHost } from '../use/use-core'; -import { applyInlineComponent, applyQwikComponentBody } from './ssr-render-component'; -import type { ISsrComponentFrame, ISsrNode, SSRContainer } from './ssr-types'; - -class MaybeAsyncSignal {} - -type StackFn = () => ValueOrPromise; -type StackValue = ValueOrPromise< - | JSXOutput - | StackFn - | Promise - | typeof Promise - | AsyncGenerator - | typeof MaybeAsyncSignal ->; - -function setParentOptions( - mutable: { currentStyleScoped: string | null; parentComponentFrame: ISsrComponentFrame | null }, - styleScoped: string | null, - parentComponentFrame: ISsrComponentFrame | null -): StackFn { - return () => { - mutable.currentStyleScoped = styleScoped; - mutable.parentComponentFrame = parentComponentFrame; - }; -} - -/** @internal */ -export async function _walkJSX( - ssr: SSRContainer, - value: JSXOutput, - options: { - currentStyleScoped: string | null; - parentComponentFrame: ISsrComponentFrame | null; - } -): Promise { - const stack: StackValue[] = [value]; - const enqueue = (value: StackValue) => stack.push(value); - const drain = async (): Promise => { - while (stack.length) { - const value = stack.pop(); - // Reference equality first (no prototype walk), then typeof - if (value === MaybeAsyncSignal) { - const trackFn = stack.pop() as () => StackValue; - await retryOnPromise(() => stack.push(trackFn())); - continue; - } - if (typeof value === 'function') { - if (value === Promise) { - stack.push(await (stack.pop() as Promise)); - } else { - await (value as StackFn).apply(ssr); - } - continue; - } - processJSXNode(ssr, enqueue, value as JSXOutput, options); - } - }; - await drain(); -} - -function processJSXNode( - ssr: SSRContainer, - enqueue: (value: StackValue) => void, - value: JSXOutput, - options: { currentStyleScoped: string | null; parentComponentFrame: ISsrComponentFrame | null } -) { - // console.log('processJSXNode', value); - if (value == null) { - ssr.textNode(''); - } else if (typeof value === 'boolean') { - ssr.textNode(''); - } else if (typeof value === 'number') { - ssr.textNode(String(value)); - } else if (typeof value === 'string') { - ssr.textNode(value); - } else if (typeof value === 'object') { - if (Array.isArray(value)) { - for (let i = value.length - 1; i >= 0; i--) { - enqueue(value[i]); - } - } else if (isSignal(value)) { - maybeAddPollingAsyncSignalToEagerResume(ssr.serializationCtx, value); - ssr.openFragment(isDev ? { [DEBUG_TYPE]: VirtualType.WrappedSignal } : EMPTY_OBJ); - const signalNode = ssr.getOrCreateLastNode(); - const unwrappedSignal = value instanceof WrappedSignalImpl ? value.$unwrapIfSignal$() : value; - enqueue(ssr.closeFragment); - enqueue(() => - trackSignalAndAssignHost(unwrappedSignal, signalNode, EffectProperty.VNODE, ssr) - ); - enqueue(MaybeAsyncSignal); - } else if (isPromise(value)) { - ssr.openFragment(isDev ? { [DEBUG_TYPE]: VirtualType.Awaited } : EMPTY_OBJ); - enqueue(ssr.closeFragment); - enqueue(value); - enqueue(Promise); - enqueue(() => ssr.streamHandler.flush()); - } else if (isAsyncGenerator(value)) { - enqueue(async () => { - for await (const chunk of value) { - await _walkJSX(ssr, chunk as JSXOutput, { - currentStyleScoped: options.currentStyleScoped, - parentComponentFrame: options.parentComponentFrame, - }); - ssr.streamHandler.flush(); - } - }); - } else { - const jsx = value as JSXNodeInternal; - const type = jsx.type; - // Below, JSXChildren allows functions and regexes, but we assume the dev only uses those as appropriate. - if (typeof type === 'string') { - appendClassIfScopedStyleExists(jsx, options.currentStyleScoped); - let qwikInspectorAttrValue: string | null = null; - if (isDev && jsx.dev && jsx.type !== 'head') { - qwikInspectorAttrValue = getFileLocationFromJsx(jsx.dev); - if (qInspector) { - appendQwikInspectorAttribute(jsx, qwikInspectorAttrValue); - } - } - - const innerHTML = ssr.openElement( - type, - jsx.key, - jsx.varProps, - jsx.constProps, - options.currentStyleScoped, - qwikInspectorAttrValue, - !!(jsx.flags & JSXNodeFlags.HasCapturedProps) - ); - if (innerHTML) { - ssr.htmlNode(innerHTML); - } - - enqueue(ssr.closeElement); - - if (type === 'head') { - ssr.emitQwikLoaderAtTopIfNeeded(); - ssr.emitPreloaderPre(); - enqueue(ssr.additionalHeadNodes); - } else if (type === 'body') { - enqueue(ssr.additionalBodyNodes); - } else if (!ssr.isHtml && !(ssr as any)._didAddQwikLoader) { - ssr.emitQwikLoaderAtTopIfNeeded(); - ssr.emitPreloaderPre(); - (ssr as any)._didAddQwikLoader = true; - } - - const children = jsx.children as JSXOutput; - children != null && enqueue(children); - } else if (isFunction(type)) { - if (type === Fragment) { - const attrs: Record = - jsx.key != null ? { [ELEMENT_KEY]: jsx.key } : {}; - if (isDev) { - attrs[DEBUG_TYPE] = VirtualType.Fragment; // Add debug info. - } - ssr.openFragment(attrs); - enqueue(ssr.closeFragment); - // In theory we could get functions or regexes, but we assume all is well - const children = jsx.children as JSXOutput; - children != null && enqueue(children); - } else if (type === Slot) { - const componentFrame = options.parentComponentFrame; - if (componentFrame) { - const compId = componentFrame.componentNode.id || ''; - const projectionAttrs: Record = isDev - ? { [DEBUG_TYPE]: VirtualType.Projection } - : {}; - projectionAttrs[QSlotParent] = compId; - ssr.openProjection(projectionAttrs); - const host = componentFrame.componentNode; - const node = ssr.getOrCreateLastNode(); - const slotName = getSlotName(host, jsx, ssr); - projectionAttrs[QSlot] = slotName; - - enqueue( - setParentOptions(options, options.currentStyleScoped, options.parentComponentFrame) - ); - enqueue(ssr.closeProjection); - const slotDefaultChildren: JSXChildren | null = jsx.children || null; - const slotChildren = - componentFrame.consumeChildrenForSlot(node, slotName) || slotDefaultChildren; - if (slotDefaultChildren && slotChildren !== slotDefaultChildren) { - ssr.addUnclaimedProjection(componentFrame, QDefaultSlot, slotDefaultChildren); - } - enqueue(slotChildren as JSXOutput); - enqueue( - setParentOptions( - options, - componentFrame.projectionScopedStyle, - componentFrame.projectionComponentFrame - ) - ); - } else { - // Even thought we are not projecting we still need to leave a marker for the slot. - let projectionAttrs = EMPTY_OBJ; - if (isDev) { - projectionAttrs = { [DEBUG_TYPE]: VirtualType.Projection }; - } - ssr.openFragment(projectionAttrs); - ssr.closeFragment(); - } - } else if (type === SSRComment) { - ssr.commentNode(directGetPropsProxyProp(jsx, 'data') || ''); - } else if (type === SSRStream) { - ssr.streamHandler.flush(); - const generator = jsx.children as SSRStreamChildren; - let value: AsyncGenerator | Promise; - if (isFunction(generator)) { - value = generator({ - async write(chunk) { - await _walkJSX(ssr, chunk, { - currentStyleScoped: options.currentStyleScoped, - parentComponentFrame: options.parentComponentFrame, - }); - ssr.streamHandler.flush(); - }, - }); - } else { - value = generator; - } - - enqueue(value as StackValue); - isPromise(value) && enqueue(Promise); - } else if (type === SSRRaw) { - ssr.htmlNode(directGetPropsProxyProp(jsx, 'data')); - } else if (type === SSRStreamBlock) { - ssr.streamHandler.streamBlockStart(); - enqueue(() => ssr.streamHandler.streamBlockEnd()); - enqueue(jsx.children as JSXOutput); - } else if (isQwikComponent(type)) { - // prod: use new instance of an object for props, we always modify props for a component - const componentAttrs: Record = {}; - if (isDev) { - componentAttrs[DEBUG_TYPE] = VirtualType.Component; - } - ssr.openComponent(componentAttrs); - const host = ssr.getOrCreateLastNode(); - const componentFrame = ssr.getParentComponentFrame()!; - componentFrame!.distributeChildrenIntoSlots( - jsx.children, - options.currentStyleScoped, - options.parentComponentFrame - ); - - const jsxOutput = applyQwikComponentBody(ssr, jsx, type); - enqueue( - setParentOptions(options, options.currentStyleScoped, options.parentComponentFrame) - ); - enqueue(ssr.closeComponent); - if (isPromise(jsxOutput)) { - // Defer reading QScopedStyle until after the promise resolves - enqueue(async () => { - const resolvedOutput = await jsxOutput; - const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle)); - - enqueue(resolvedOutput); - enqueue(setParentOptions(options, compStyleComponentId, componentFrame)); - }); - } else { - enqueue(jsxOutput); - const compStyleComponentId = addComponentStylePrefix(host.getProp(QScopedStyle)); - enqueue(setParentOptions(options, compStyleComponentId, componentFrame)); - } - } else { - const inlineComponentProps: Record = { [ELEMENT_KEY]: jsx.key }; - if (isDev) { - inlineComponentProps[DEBUG_TYPE] = VirtualType.InlineComponent; - } - ssr.openFragment(inlineComponentProps); - enqueue(ssr.closeFragment); - const component = ssr.getParentComponentFrame(); - const jsxOutput = applyInlineComponent( - ssr, - component && component.componentNode, - type, - jsx - ); - enqueue(jsxOutput); - isPromise(jsxOutput) && enqueue(Promise); - } - } - } - } -} - -function maybeAddPollingAsyncSignalToEagerResume( - serializationCtx: SerializationContext, - signal: unknown -) { - // Unwrap if it's a WrappedSignalImpl - const unwrappedSignal = signal instanceof WrappedSignalImpl ? signal.$unwrapIfSignal$() : signal; - - if (unwrappedSignal instanceof AsyncSignalImpl) { - const interval = unwrappedSignal.$interval$; - // Don't check for $effects$ here - effects are added later during tracking. - // The AsyncSignal's polling mechanism will check for effects before scheduling. - if (interval > 0) { - serializationCtx.$addRoot$(unwrappedSignal); - serializationCtx.$eagerResume$.add(unwrappedSignal); - } - } -} - -function getSlotName(host: ISsrNode, jsx: JSXNodeInternal, ssr: SSRContainer): string { - const constProps = jsx.constProps; - if (constProps && typeof constProps == 'object' && 'name' in constProps) { - const constValue = constProps.name; - if (constValue instanceof WrappedSignalImpl) { - return trackSignalAndAssignHost(constValue, host, EffectProperty.COMPONENT, ssr); - } - } - return directGetPropsProxyProp(jsx, 'name') || QDefaultSlot; -} - -function appendQwikInspectorAttribute(jsx: JSXNodeInternal, qwikInspectorAttrValue: string | null) { - if (qwikInspectorAttrValue && (!jsx.constProps || !(qwikInspectorAttr in jsx.constProps))) { - (jsx.constProps ||= {})[qwikInspectorAttr] = qwikInspectorAttrValue; - } -} - -// append class attribute if styleScopedId exists and there is no class attribute -function appendClassIfScopedStyleExists(jsx: JSXNodeInternal, styleScoped: string | null) { - const classAttributeExists = directGetPropsProxyProp(jsx, 'class') != null; - if (!classAttributeExists && styleScoped) { - if (!jsx.constProps) { - jsx.constProps = {}; - } - jsx.constProps['class'] = ''; - } -} diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts index 879688bebb3..3ad379f161c 100644 --- a/packages/qwik/src/core/ssr/ssr-types.ts +++ b/packages/qwik/src/core/ssr/ssr-types.ts @@ -1,6 +1,5 @@ /** @file Public types for the SSR */ -import type { ChoreBits } from '../../server/qwik-copy'; import type { Container, JSXChildren, @@ -13,26 +12,24 @@ import type { VNodeData } from '../../server/vnode-data'; import type { Props } from '../shared/jsx/jsx-runtime'; import type { JSXNodeInternal } from '../shared/jsx/types/jsx-node'; import type { QRL } from '../shared/qrl/qrl.public'; -import type { SsrNodeFlags } from '../shared/types'; import type { ResourceReturnInternal } from '../use/use-resource'; +import type { VirtualVNode } from '../shared/vnode/virtual-vnode'; /** @internal */ export interface StreamWriter { write(chunk: string): void; } -export interface ISsrNode { +export interface ISsrNode extends VirtualVNode { id: string; - flags: SsrNodeFlags; - dirty: ChoreBits; + // TODO perhaps make props a class so we can filter `:` props during serialization and we don't need localProps + localProps: Props | null; + cleanupQueue: any[]; parentComponent: ISsrNode | null; - vnodeData: VNodeData; + vnodeData: VNodeData | null; currentFile: string | null; - setProp(name: string, value: any): void; - getProp(name: string): any; - removeProp(name: string): void; - addChild(child: ISsrNode): void; - setTreeNonUpdatable(): void; + children: ISsrNode[] | null; + orderedChildren: any[] | null; } /** @internal */ @@ -65,6 +62,8 @@ export interface SSRContainer extends Container { readonly resolvedManifest: ResolvedManifest; additionalHeadNodes: Array; additionalBodyNodes: Array; + /** Per-cursor SSR build state (frame state), swapped by cursor walker. */ + ssrBuildState: unknown; write(text: string): void; @@ -88,10 +87,23 @@ export interface SSRContainer extends Container { openProjection(attrs: Props): void; closeProjection(): void; - openComponent(attrs: Props): void; getComponentFrame(projectionDepth: number): ISsrComponentFrame | null; getParentComponentFrame(): ISsrComponentFrame | null; - closeComponent(): Promise; + enterComponentContext(componentNode: ISsrNode, existingFrame?: ISsrComponentFrame): void; + leaveComponentContext(): void; + /** + * Creates a component frame for host and distributes children into slots. Does NOT modify walker + * context (no stack pushes). Used by ssrComponent during tree-building to set up deferred + * component state without stack imbalance. + */ + createAndDistributeComponentFrame( + host: ISsrNode, + children: JSXChildren, + parentScopedStyle: string | null, + parentComponentFrame: ISsrComponentFrame | null + ): ISsrComponentFrame; + + emitUnclaimedProjectionForComponent(frame: ISsrComponentFrame): ValueOrPromise; textNode(text: string): void; htmlNode(rawHtml: string): void; @@ -106,10 +118,13 @@ export interface SSRContainer extends Container { emitQwikLoaderAtTopIfNeeded(): void; + openSuspenseBoundary(attrs: Props): void; + closeSuspenseBoundary(): void; + emitPatchDataIfNeeded(): void; addBackpatchEntry( - ssrNodeId: string, + ssrNodeOrId: string | ISsrNode, attrName: string, serializedValue: string | boolean | null ): void; diff --git a/packages/qwik/src/core/tests/container.spec.tsx b/packages/qwik/src/core/tests/container.spec.tsx index 964aef02866..f722e13b6b7 100644 --- a/packages/qwik/src/core/tests/container.spec.tsx +++ b/packages/qwik/src/core/tests/container.spec.tsx @@ -507,7 +507,7 @@ describe('serializer v2', () => { describe('DocumentSerializer, //////', () => { it('should serialize and deserialize', async () => { - const obj = new SsrNode(null, '', -1, [], [] as any, null); + const obj = new SsrNode(null, '', {}, [], null); const container = await withContainer((ssr) => ssr.addRoot(obj)); expect(container.$getObjectById$(0)).toEqual(container.element.ownerDocument); }); diff --git a/packages/qwik/src/core/tests/projection.spec.tsx b/packages/qwik/src/core/tests/projection.spec.tsx index 63e93a428c2..e9614f4002d 100644 --- a/packages/qwik/src/core/tests/projection.spec.tsx +++ b/packages/qwik/src/core/tests/projection.spec.tsx @@ -1975,6 +1975,75 @@ describe.each([ ); }); + it('issue1945 - should render named conditional slot from q:template with useStore and scoped styles', async () => { + const ComponentB = component$(() => { + return ( +
+ +
+ ); + }); + + const ComponentA = component$(() => { + const store = useStore({ show: false }); + return ( +
+ + + + + {store.show ? : null} +
+ ); + }); + + const { document } = await render( + +

+ Outside B +

+ +

Inside B

+
+
+

Inside slot 3

+
+
+

Inside slot 4

+
+
, + { debug: DEBUG } + ); + + // Initially slot "four" is hidden + const s1 = document.querySelector('#s1'); + const s3 = document.querySelector('#s3'); + const s4 = document.querySelector('#s4'); + expect(s1).toBeTruthy(); + expect(s3).toBeTruthy(); + expect(s4).toBeTruthy(); + + // // Debug before click + // if (render === ssrRenderToDom) { + // console.log('BEFORE CLICK:', document.body.innerHTML); + // console.log('q:template:', document.querySelector('q\\:template')?.outerHTML || 'NONE'); + // } + + // Click to show slot "four" + await trigger(document.body, '#toggle', 'click'); + + // // Debug after click + // if (render === ssrRenderToDom) { + // console.log('AFTER CLICK:', document.body.innerHTML); + // } + + const s5 = document.querySelector('#s5'); + expect(s5).toBeTruthy(); + expect(s5!.textContent).toContain('Inside slot 4'); + }); + it('should correctly inflate text nodes from q:template', async () => { const Cmp = component$((props: { show: boolean }) => { return {props.show && }; diff --git a/packages/qwik/src/core/tests/render-api.spec.tsx b/packages/qwik/src/core/tests/render-api.spec.tsx index fa266c865ec..e5abfb6cb44 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -1,6 +1,7 @@ import { Fragment as Component, Fragment as Signal, + Resource, component$, componentQrl, getDomContainer, @@ -10,6 +11,7 @@ import { setPlatform, useLexicalScope, useOn, + useResource$, useServerData, useSignal, useTask$, @@ -878,6 +880,119 @@ describe('render api', () => { // This can change when the size of the output changes expect(stream.write).toHaveBeenCalledTimes(4); }); + it('should interleave cursor walking with direct streaming', async () => { + let resolveGate!: () => void; + const gate = new Promise((resolve) => { + resolveGate = resolve; + }); + const writes: string[] = []; + const stream: StreamWriter = { + write(chunk) { + writes.push(chunk); + }, + }; + const streaming: StreamingOptions = { + inOrder: { + strategy: 'direct', + }, + }; + + const BlockedChild = component$(() => { + const resource = useResource$(async () => { + await gate; + return 'blocked child'; + }); + return {value}} />; + }); + + let renderSettled = false; + const renderPromise = renderToStreamAndSetPlatform( + <> + before + + after + , + { + containerTagName: 'div', + stream, + streaming, + } + ); + renderPromise.then(() => { + renderSettled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + const partialHtml = writes.join(''); + expect(renderSettled).toBe(false); + expect(writes.length).toBeGreaterThan(0); + expect(partialHtml).toContain(' { + it('should not duplicate content with async resource in HTML container', async () => { + const Inner = component$(() => { + const resourceSuccess = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'Success'; + }); + const resourceFailure = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + throw new Error('failed'); + }); + return ( + <> + } + onRejected={(reason) => } + /> + } + onRejected={(reason) => } + /> + + ); + }); + + const Root = component$(() => { + return ; + }); + + const result = await renderToStringAndSetPlatform( + <> + + Test + + + + + , + {} + ); + + const html = result.html; + const passCount = (html.match(/PASS:/g) || []).length; + const errorCount = (html.match(/ERROR:/g) || []).length; + expect(passCount).toBeLessThanOrEqual(1); + expect(errorCount).toBeLessThanOrEqual(1); }); }); }); diff --git a/packages/qwik/src/core/tests/suspense.spec.tsx b/packages/qwik/src/core/tests/suspense.spec.tsx new file mode 100644 index 00000000000..2b963c8006d --- /dev/null +++ b/packages/qwik/src/core/tests/suspense.spec.tsx @@ -0,0 +1,139 @@ +import { Suspense } from '@qwik.dev/core'; +import { ssrRenderToDom } from '@qwik.dev/core/testing'; +import { describe, expect, it } from 'vitest'; +import { component$ } from '../shared/component.public'; + +const debug = false; //true; +Error.stackTraceLimit = 100; + +describe('SSR Suspense', () => { + describe('basic Suspense', () => { + it('should render sync children inline (no fallback needed)', async () => { + const { document } = await ssrRenderToDom( +
+ Loading...}> +

Sync content

+
+
, + { debug } + ); + + const html = document.body.innerHTML; + // Sync content should be in the output + expect(html).toContain('Sync content'); + }); + + it('should render async children via OoO streaming', async () => { + const AsyncChild = component$(() => { + return

Async content

; + }); + + const { document } = await ssrRenderToDom( +
+ Loading...}> + + +
, + { debug } + ); + + const html = document.body.innerHTML; + // The async content should appear somewhere in the output + expect(html).toContain('Async content'); + }); + + it('should emit fallback with placeholder div for deferred content', async () => { + const SlowChild = component$(() => { + return

Slow content

; + }); + + const { document } = await ssrRenderToDom( +
+ Loading...}> + + +
, + { debug } + ); + + const html = document.body.innerHTML; + // Should have the placeholder div with fallback + expect(html).toContain('id="qph-0"'); + expect(html).toContain('Loading...'); + // Should have the OoO template with actual content + expect(html).toContain('id="qooo-qph-0"'); + expect(html).toContain('Slow content'); + // Should have the swap script + expect(html).toContain('replaceWith'); + }); + + it('should handle multiple Suspense boundaries', async () => { + const Child1 = component$(() =>

Content 1

); + const Child2 = component$(() =>

Content 2

); + + const { document } = await ssrRenderToDom( +
+ Loading 1...}> + + + Loading 2...}> + + +
, + { debug } + ); + + const html = document.body.innerHTML; + // Both placeholders + expect(html).toContain('id="qph-0"'); + expect(html).toContain('id="qph-1"'); + // Both OoO templates + expect(html).toContain('id="qooo-qph-0"'); + expect(html).toContain('id="qooo-qph-1"'); + // Both actual contents + expect(html).toContain('Content 1'); + expect(html).toContain('Content 2'); + }); + + it('should render non-Suspense content before fallback', async () => { + const SlowChild = component$(() =>

Slow

); + + const { document } = await ssrRenderToDom( +
+
Header
+ Loading...}> + + +
Footer
+
, + { debug } + ); + + const html = document.body.innerHTML; + // Header and footer should be in the main HTML before any OoO templates + const headerPos = html.indexOf('Header'); + const footerPos = html.indexOf('Footer'); + const templatePos = html.indexOf('qooo-'); + expect(headerPos).toBeLessThan(templatePos); + expect(footerPos).toBeLessThan(templatePos); + }); + + it('should handle Suspense with no fallback', async () => { + const Child = component$(() =>

Content

); + + const { document } = await ssrRenderToDom( +
+ + + +
, + { debug } + ); + + const html = document.body.innerHTML; + // Should still work — empty placeholder, content via OoO + expect(html).toContain('id="qph-0"'); + expect(html).toContain('Content'); + }); + }); +}); diff --git a/packages/qwik/src/core/tests/use-resource.spec.tsx b/packages/qwik/src/core/tests/use-resource.spec.tsx index 4927343a135..2ed6a9a09fe 100644 --- a/packages/qwik/src/core/tests/use-resource.spec.tsx +++ b/packages/qwik/src/core/tests/use-resource.spec.tsx @@ -533,4 +533,187 @@ describe.each([ ); }); + + it('should handle client-side click after async resource SSR (resource-serialization pattern)', async () => { + const TestCmp = component$(() => { + const state = useStore({ count0: 0, count1: 0 }); + const resourceSuccess = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'Success'; + }); + const resourceFailure = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + throw new Error('failed'); + }); + const resourceTimeout = useResource$( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return 'Success'; + }, + { timeout: 10 } + ); + + return ( + <> + ( + + )} + onRejected={(reason) => ( + + )} + /> + ( + + )} + onRejected={(reason) => ( + + )} + /> + ( + + )} + onRejected={(reason) => ( + + )} + /> + + ); + }); + + const { container } = await render(, { debug }); + // Log full HTML for debugging + // console.log('SSR HTML:', (container.element as any).innerHTML || container.element.outerHTML); + const r1 = container.element.querySelector('.r1') as HTMLElement; + const r2 = container.element.querySelector('.r2') as HTMLElement; + const r3 = container.element.querySelector('.r3') as HTMLElement; + + expect(r1).toBeTruthy(); + expect(r2).toBeTruthy(); + expect(r3).toBeTruthy(); + expect(r1.textContent).toContain('PASS: Success 0'); + expect(r2.textContent).toContain('ERROR:'); + expect(r3.textContent).toContain('ERROR:'); + + // Click button1 to trigger client-side re-render + await trigger(container.element, '.r1', 'click'); + await waitForDrain(container); + expect(r1.textContent).toContain('PASS: Success 1'); + }); + + it('should not duplicate content with async resource (simple)', async () => { + const TestCmp = component$(() => { + const resourceSuccess = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'Success'; + }); + const resourceFailure = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + throw new Error('failed'); + }); + + return ( + <> + } + onRejected={(reason) => } + /> + } + onRejected={(reason) => } + /> + + ); + }); + + const { document } = await render(, { debug }); + const html = document.querySelector('body')?.innerHTML || (document as any).innerHTML || ''; + const passCount = (html.match(/PASS:/g) || []).length; + const errorCount = (html.match(/ERROR:/g) || []).length; + expect(passCount).toBeLessThanOrEqual(1); + expect(errorCount).toBeLessThanOrEqual(1); + }); + + it('should not duplicate content with async resource (wrapped in parent component)', async () => { + const Inner = component$(() => { + const resource = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'InnerValue'; + }); + return ( + INNER: {data}} + /> + ); + }); + + const Outer = component$(() => { + return ; + }); + + const { document } = await render(, { debug }); + const html = document.querySelector('body')?.innerHTML || (document as any).innerHTML || ''; + const innerCount = (html.match(/INNER:/g) || []).length; + expect(innerCount).toBeLessThanOrEqual(1); + }); + + it('should not duplicate content with async resource (nested components)', async () => { + const ChildWithResource = component$(() => { + const resource = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'ChildValue'; + }); + return ( + {data}} + /> + ); + }); + + const ParentCmp = component$(() => { + const parentResource = useResource$(async () => { + await new Promise((r) => setTimeout(r, 10)); + return 'ParentSuccess'; + }); + + return ( + <> + + } + onRejected={(reason) => } + /> + + ); + }); + + const { document } = await render(, { debug }); + const html = document.querySelector('body')?.innerHTML || (document as any).innerHTML || ''; + const passCount = (html.match(/PASS:/g) || []).length; + const childCount = (html.match(/ChildValue/g) || []).length; + expect(passCount).toBeLessThanOrEqual(1); + expect(childCount).toBeLessThanOrEqual(1); + }); }); diff --git a/packages/qwik/src/core/tests/use-task.spec.tsx b/packages/qwik/src/core/tests/use-task.spec.tsx index 660de13449a..e586ffc3e4d 100644 --- a/packages/qwik/src/core/tests/use-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-task.spec.tsx @@ -391,11 +391,9 @@ describe.each([ }); const { vNode, document } = await render(, { debug }); - if (render === ssrRenderToDom) { - expect((globalThis as any).log).toEqual(['quadruple', 'double', 'quadruple', 'Counter']); - } else { - expect((globalThis as any).log).toEqual(['quadruple', 'double', 'Counter', 'quadruple']); - } + // With cursor-driven SSR, task execution order matches client: component renders + // before dependent task re-fires (cursor batching vs immediate execution). + expect((globalThis as any).log).toEqual(['quadruple', 'double', 'Counter', 'quadruple']); expect(vNode).toMatchVDOM(