| \n\nqwikLoader?\n\n\n | \n\n\n | \n\n[QwikLoaderOptions](#qwikloaderoptions)\n\n\n | \n\n_(Optional)_ Specifies how the Qwik Loader is included in the document. This enables interactivity and lazy loading.\n\n`module`: Use a ``
+ );
+ }
+ }
+
+ private markSuspenseFallbackEmitted(node: ISsrNode) {
+ const boundary = this.suspenseBoundaries.find((candidate) => candidate.node === node);
+ if (boundary) {
+ boundary.fallbackEmitted = true;
+ }
+ }
+
+ private async waitForSuspenseProgressOrDeadline(deadline: number | null) {
+ const waitMs = deadline == null ? 0 : Math.max(0, deadline - performance.now());
+ if (this.$renderPromise$) {
+ if (waitMs > 0) {
+ await Promise.race([
+ this.$renderPromise$,
+ new Promise((resolve) => {
+ setTimeout(resolve, waitMs);
+ }),
+ ]);
+ } else {
+ await this.$renderPromise$;
+ }
+ return;
+ }
+ if (waitMs > 0) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, waitMs);
+ });
+ }
+ }
}
const isQwikStyleElement = (tag: string, attrs: Props | null) => {
diff --git a/packages/qwik/src/server/ssr-node.spec.ts b/packages/qwik/src/server/ssr-node.spec.ts
index cb4d9f8bcf4..b8caa4c9ffc 100644
--- a/packages/qwik/src/server/ssr-node.spec.ts
+++ b/packages/qwik/src/server/ssr-node.spec.ts
@@ -1,24 +1,25 @@
-import { _EMPTY_ARRAY, _EMPTY_OBJ } from '@qwik.dev/core';
import { describe, expect, it } from 'vitest';
import { SsrNode } from './ssr-node';
-import { OPEN_FRAGMENT, type VNodeData } from './vnode-data';
-import { VNodeDataFlag } from './types';
+import {
+ _vnode_getProp as vnode_getProp,
+ _vnode_setProp as vnode_setProp,
+} from '@qwik.dev/core/internal';
describe('ssr-node', () => {
- it('should create empty array as attrs if attributesIndex is -1', () => {
- const vNodeData: VNodeData = [VNodeDataFlag.VIRTUAL_NODE];
- vNodeData.push(OPEN_FRAGMENT);
- const ssrNode = new SsrNode(null, '1', -1, [], vNodeData, null);
- ssrNode.setProp('a', 1);
- expect(vNodeData[(ssrNode as any).attributesIndex]).toEqual({ a: 1 });
+ it('should store attrs in standalone object (via props)', () => {
+ const attrs = {};
+ const ssrNode = new SsrNode(null, '1', attrs, [], null);
+ vnode_setProp(ssrNode, 'a', 1);
+ expect(attrs).toEqual({ a: 1 });
+ expect(ssrNode.props).toBe(attrs);
});
- it('should create new empty array as attrs if attrs are equal to EMPTY_ARRAY', () => {
- const vNodeData: VNodeData = [VNodeDataFlag.VIRTUAL_NODE];
- const attrs = _EMPTY_OBJ;
- vNodeData.push(attrs, OPEN_FRAGMENT);
- const ssrNode = new SsrNode(null, '1', 1, [], vNodeData, null);
- ssrNode.setProp('a', 1);
- expect(vNodeData[(ssrNode as any).attributesIndex]).toEqual({ a: 1 });
+ it('should store all props in attrs (colon-prefixed filtered at serialization)', () => {
+ const attrs = {};
+ const ssrNode = new SsrNode(null, '1', attrs, [], null);
+ vnode_setProp(ssrNode, ':localProp', 'value');
+ vnode_setProp(ssrNode, 'serializable', 'data');
+ expect(attrs).toEqual({ ':localProp': 'value', serializable: 'data' });
+ expect(vnode_getProp(ssrNode, ':localProp', null)).toBe('value');
});
});
diff --git a/packages/qwik/src/server/ssr-node.ts b/packages/qwik/src/server/ssr-node.ts
index 158efe46106..6cfd3e02e9c 100644
--- a/packages/qwik/src/server/ssr-node.ts
+++ b/packages/qwik/src/server/ssr-node.ts
@@ -1,9 +1,10 @@
import type { JSXNode } from '@qwik.dev/core';
import {
_isJSXNode as isJSXNode,
- _EMPTY_ARRAY,
- _EMPTY_OBJ,
_EFFECT_BACK_REF,
+ _VirtualVNode as VirtualVNode,
+ _vnode_getProp as vnode_getProp,
+ _vnode_setProp as vnode_setProp,
} from '@qwik.dev/core/internal';
import { isDev } from '@qwik.dev/core/build';
import {
@@ -12,148 +13,231 @@ import {
mapArray_get,
mapArray_set,
mapArray_has,
- ELEMENT_SEQ,
QSlot,
QDefaultSlot,
- NON_SERIALIZABLE_MARKER_PREFIX,
QBackRefs,
- SsrNodeFlags,
ChoreBits,
+ VNodeFlags,
} from './qwik-copy';
import type { ISsrNode, ISsrComponentFrame, JSXChildren, Props } from './qwik-types';
import type { CleanupQueue } from './ssr-container';
import type { VNodeData } from './vnode-data';
+/**
+ * Local prop keys for deferred data stored on SsrNodes during tree building. These use the
+ * NON_SERIALIZABLE_MARKER_PREFIX (':') so they won't be serialized.
+ */
+export const SSR_VAR_ATTRS = ':varAttrs';
+export const SSR_CONST_ATTRS = ':constAttrs';
+export const SSR_STYLE_SCOPED_ID = ':styleScopedId';
+export const SSR_INNER_HTML = ':innerHTML';
+export const SSR_HAS_MOVED_CAPTURES = ':hasMovedCaptures';
+export const SSR_TEXT = ':text';
+export const SSR_JSX = ':jsx';
+export const SSR_SCOPED_STYLE = ':scopedStyle';
+export const SSR_COMPONENT_FRAME = ':componentFrame';
+/** Serialized attribute HTML stored on SsrNode for streaming walker emission. */
+export const SSR_ATTR_HTML = ':attrHtml';
+/** Suspense fallback SsrNode stored on the boundary node. */
+export const SSR_SUSPENSE_FALLBACK = ':suspenseFallback';
+/** Suspense placeholder ID for OoO streaming. */
+export const SSR_SUSPENSE_PLACEHOLDER_ID = ':suspensePlaceholderId';
+/** Content SsrNode holding Suspense children built by sub-cursor. */
+export const SSR_SUSPENSE_CONTENT = ':suspenseContent';
+/** Whether Suspense children are ready (sub-cursor completed). */
+export const SSR_SUSPENSE_READY = ':suspenseReady';
+
+/**
+ * Lightweight content node for text, raw HTML, and comments stored in an SsrNode's orderedChildren.
+ * These don't need the full SsrNode infrastructure — just a kind tag and content string.
+ */
+export interface SsrContentChild {
+ kind: SsrNodeKind.Text | SsrNodeKind.RawHtml | SsrNodeKind.Comment;
+ content: string;
+ /** Original (unescaped) text length. Only set for Text kind. Used by vNodeData builder. */
+ textLength?: number;
+}
+
+/** A child entry in orderedChildren — either a full SsrNode or a lightweight content node. */
+export type SsrChild = ISsrNode | SsrContentChild;
+
+/** Type guard for SsrContentChild (has 'kind' and 'content' but not 'id'). */
+export function isSsrContentChild(child: SsrChild): child is SsrContentChild {
+ return 'kind' in child && !('id' in child);
+}
+
+/**
+ * The type of SsrNode for emission purposes.
+ *
+ * @internal
+ */
+export const enum SsrNodeKind {
+ /** HTML element (div, span, etc.) — has tagName */
+ Element = 0,
+ /** Text node */
+ Text = 1,
+ /** Virtual boundary (Fragment, InlineComponent, WrappedSignal, Awaited) */
+ Virtual = 2,
+ /** Qwik component — needs component execution */
+ Component = 3,
+ /** Slot projection */
+ Projection = 4,
+ /** Raw HTML */
+ RawHtml = 5,
+ /** Comment */
+ Comment = 6,
+ /** Suspense boundary */
+ Suspense = 7,
+}
+
/**
* Server has no DOM, so we need to create a fake node to represent the DOM for serialization
* purposes.
*
- * Once deserialized the client, they will be turned to ElementVNodes.
+ * Once deserialized on the client, they will be turned to ElementVNodes.
+ *
+ * Extends VirtualVNode to share cursor infrastructure (dirty bits, dirtyChildren,
+ * parent/slotParent, sibling linked list).
*/
-export class SsrNode implements ISsrNode {
+export class SsrNode extends VirtualVNode implements ISsrNode {
__brand__ = 'SsrNode' as const;
+ /** ID which the deserialize will use to retrieve the node. */
+ public id: string;
+
/**
- * ID which the deserialize will use to retrieve the node.
- *
- * @param id - Unique id for the node.
+ * VNode serialization data for this node's subtree. Set externally by
+ * vNodeData_createSsrNodeReference (during tree building) or by the streamer (future).
*/
- public id: string;
- public flags: SsrNodeFlags;
- public dirty = ChoreBits.NONE;
+ public vnodeData: VNodeData | null = null;
- public children: ISsrNode[] | null = null;
- private attrs: Props;
+ /** Source file location for dev mode diagnostics. */
+ public currentFile: string | null;
- /** Local props which don't serialize; */
- private localProps: Props | null = null;
+ /** Component host node (for SSR component tracking). */
+ public parentComponent: ISsrNode | null;
- get [_EFFECT_BACK_REF]() {
- return this.getProp(QBackRefs);
- }
+ /** HTML tag name for element nodes, null for virtual nodes. */
+ public tagName: string | null = null;
+
+ /** Node kind for emission dispatch. */
+ public nodeKind: SsrNodeKind = SsrNodeKind.Virtual;
+
+ public cleanupQueue: CleanupQueue;
+
+ /**
+ * Legacy children array for backward compatibility during migration. TODO: Remove once all
+ * consumers switch to VNode linked list traversal.
+ */
+ public children: ISsrNode[] | null = null;
+
+ /**
+ * Ordered children for streaming walker emission. Contains ALL children (elements, text, virtual
+ * nodes, raw HTML, comments) in document order. Only populated in treeOnly mode.
+ */
+ public orderedChildren: SsrChild[] | null = null;
constructor(
- public parentComponent: ISsrNode | null,
+ parentComponent: ISsrNode | null,
id: string,
- private attributesIndex: number,
- private cleanupQueue: CleanupQueue,
- public vnodeData: VNodeData,
- public currentFile: string | null
+ attrs: Props,
+ cleanupQueue: CleanupQueue,
+ currentFile: string | null
) {
+ super(
+ null, // key
+ VNodeFlags.Virtual,
+ null, // parent
+ null, // previousSibling
+ null, // nextSibling
+ attrs, // props — serializable attributes (shared reference with vNodeData)
+ null, // firstChild
+ null // lastChild
+ );
+
this.id = id;
- this.flags = SsrNodeFlags.Updatable;
- this.attrs =
- this.attributesIndex >= 0 ? (this.vnodeData[this.attributesIndex] as Props) : _EMPTY_OBJ;
+ this.parentComponent = parentComponent;
+ this.cleanupQueue = cleanupQueue;
+ this.currentFile = currentFile;
+ this.dirty = ChoreBits.NONE;
+
+ if (this.parentComponent) {
+ ssrNode_addChild(this.parentComponent, this);
+ }
- this.parentComponent?.addChild(this);
+ // Override VNode's [_EFFECT_BACK_REF] field with getter/setter that delegates to
+ // serializable props (QBackRefs), so back refs are included in vnodeData serialization.
+ Object.defineProperty(this, _EFFECT_BACK_REF, {
+ get: () => vnode_getProp(this, QBackRefs, null),
+ set: (value: any) => {
+ if (value !== undefined) {
+ vnode_setProp(this, QBackRefs, value);
+ }
+ },
+ configurable: true,
+ });
if (isDev && id.indexOf('undefined') != -1) {
throw new Error(`Invalid SSR node id: ${id}`);
}
}
- setProp(name: string, value: any): void {
- if (this.attrs === _EMPTY_OBJ) {
- this.setEmptyArrayAsVNodeDataAttributes();
- }
- if (name.startsWith(NON_SERIALIZABLE_MARKER_PREFIX)) {
- (this.localProps ||= {})[name] = value;
+ override toString(): string {
+ if (isDev) {
+ let stringifiedAttrs = '';
+ for (const key in this.props) {
+ const value = this.props![key];
+ stringifiedAttrs += `${key}=`;
+ stringifiedAttrs +=
+ typeof value === 'string' || typeof value === 'number' ? JSON.stringify(value) : '*';
+ stringifiedAttrs += ', ';
+ }
+ return ``;
} else {
- this.attrs[name] = value;
- }
- if (name == ELEMENT_SEQ && value) {
- // Sequential Arrays contain Tasks. And Tasks contain cleanup functions.
- // We need to collect these cleanup functions and run them when the rendering is done.
- this.cleanupQueue.push(value);
+ return ``;
}
}
+}
- private setEmptyArrayAsVNodeDataAttributes() {
- if (this.attributesIndex >= 0) {
- this.vnodeData[this.attributesIndex] = {};
- this.attrs = this.vnodeData[this.attributesIndex] as Props;
- } else {
- // we need to insert a new empty array at index 1
- // this can be inefficient, but it is only done once per node and probably not often
- const newAttributesIndex = this.vnodeData.length > 1 ? 1 : 0;
- this.vnodeData.splice(newAttributesIndex, 0, {});
- this.attributesIndex = newAttributesIndex;
- this.attrs = this.vnodeData[this.attributesIndex] as Props;
- }
- }
+// ============================================================================
+// SsrNode free functions
+// ============================================================================
- getProp(name: string): any {
- if (name.startsWith(NON_SERIALIZABLE_MARKER_PREFIX)) {
- return this.localProps ? (this.localProps[name] ?? null) : null;
- } else {
- return this.attrs[name] ?? null;
- }
- }
+/** Updatable = opening tag not yet streamed */
+export const ssrNode_isUpdatable = (node: ISsrNode): boolean => {
+ return !(node.flags & VNodeFlags.OpenTagEmitted);
+};
- removeProp(name: string): void {
- if (name.startsWith(NON_SERIALIZABLE_MARKER_PREFIX)) {
- if (this.localProps) {
- delete this.localProps[name];
- }
- } else {
- delete this.attrs[name];
- }
- }
+/** Returns the serializable props object. Used by the streamer to build vNodeData. */
+export const ssrNode_getSerializableAttrs = (node: ISsrNode): Props => {
+ return node.props!;
+};
- addChild(child: ISsrNode): void {
- if (!this.children) {
- this.children = [];
- }
- this.children.push(child);
+export const ssrNode_addChild = (node: ISsrNode, child: ISsrNode): void => {
+ if (!node.children) {
+ node.children = [];
}
+ node.children.push(child);
+};
- setTreeNonUpdatable(): void {
- if (this.flags & SsrNodeFlags.Updatable) {
- this.flags &= ~SsrNodeFlags.Updatable;
- if (this.children) {
- for (let i = 0; i < this.children.length; i++) {
- const child = this.children[i];
- (child as SsrNode).setTreeNonUpdatable();
- }
- }
- }
+/** Add an ordered child for streaming walker emission (treeOnly mode). */
+export const ssrNode_addOrderedChild = (node: SsrNode, child: SsrChild): void => {
+ if (!node.orderedChildren) {
+ node.orderedChildren = [];
}
+ node.orderedChildren.push(child);
+};
- toString(): string {
- if (isDev) {
- let stringifiedAttrs = '';
- for (const key in this.attrs) {
- const value = this.attrs[key];
- stringifiedAttrs += `${key}=`;
- stringifiedAttrs += `${typeof value === 'string' || typeof value === 'number' ? JSON.stringify(value) : '*'}`;
- stringifiedAttrs += ', ';
+export const ssrNode_setTreeNonUpdatable = (node: ISsrNode): void => {
+ if (!(node.flags & VNodeFlags.OpenTagEmitted)) {
+ node.flags |= VNodeFlags.OpenTagEmitted;
+ if (node.children) {
+ for (let i = 0; i < node.children.length; i++) {
+ ssrNode_setTreeNonUpdatable(node.children[i]);
}
- return ``;
- } else {
- return ``;
}
}
-}
+};
/** A ref to a DOM element */
export class DomRef {
@@ -229,8 +313,9 @@ export class SsrComponentFrame implements ISsrComponentFrame {
consumeChildrenForSlot(projectionNode: ISsrNode, slotName: string): JSXChildren | null {
const children = mapApp_remove(this.slots, slotName, 0);
- this.componentNode.setProp(slotName, projectionNode.id);
- projectionNode.setProp(QSlotParent, this.componentNode.id);
+ // Store SsrNode references (resolved to IDs at serialization time in writeFragmentAttrs)
+ vnode_setProp(this.componentNode, slotName, projectionNode);
+ vnode_setProp(projectionNode, QSlotParent, this.componentNode);
return children;
}
}
diff --git a/packages/qwik/src/server/ssr-streaming-walker.ts b/packages/qwik/src/server/ssr-streaming-walker.ts
new file mode 100644
index 00000000000..0720397ad60
--- /dev/null
+++ b/packages/qwik/src/server/ssr-streaming-walker.ts
@@ -0,0 +1,787 @@
+/**
+ * @file Streaming walker that emits HTML from the SsrNode tree.
+ *
+ * The walker traverses the orderedChildren of each SsrNode in document order, emitting HTML for
+ * elements (open tag + attrs + children + close tag), text (pre-escaped), raw HTML, and comments.
+ * Virtual nodes (fragments, components, projections) produce no HTML — only their children are
+ * emitted.
+ *
+ * Element attributes are serialized from stored props (SSR_VAR_ATTRS / SSR_CONST_ATTRS) — no
+ * pre-computed HTML strings. The walker calls serializeAttribute() for each attr pair.
+ *
+ * Suspense boundaries are handled specially: the walker emits fallback content wrapped in a
+ * placeholder div. The actual content is deferred for OoO (out-of-order) streaming.
+ *
+ * Two emission modes:
+ *
+ * - SsrStreamingWalker: recursive, emits entire tree at once (for toHTML and OoO chunks)
+ * - IncrementalEmitter: stack-based, can pause at dirty nodes and resume (for render() loop)
+ */
+
+import {
+ SsrNodeKind,
+ SSR_VAR_ATTRS,
+ SSR_CONST_ATTRS,
+ SSR_STYLE_SCOPED_ID,
+ SSR_SUSPENSE_PLACEHOLDER_ID,
+ SSR_SUSPENSE_CONTENT,
+ SSR_SUSPENSE_READY,
+ isSsrContentChild,
+ ssrNode_getSerializableAttrs,
+ type SsrChild,
+ type SsrContentChild,
+ type SsrNode,
+} from './ssr-node';
+import { _vnode_getProp as vnode_getProp } from '@qwik.dev/core/internal';
+import type { ISsrNode, StreamWriter, ValueOrPromise } from './qwik-types';
+import {
+ type VNodeData,
+ vNodeData_incrementElementCount,
+ vNodeData_addTextSize,
+ vNodeData_openFragment,
+ vNodeData_closeFragment,
+ WRITE_ELEMENT_ATTRS,
+ encodeAsAlphanumeric,
+} from './vnode-data';
+import { VNodeDataFlag } from './types';
+import { isSelfClosingTag } from './tag-nesting';
+import {
+ LT,
+ GT,
+ CLOSE_TAG,
+ VNodeFlags,
+ SPACE,
+ ATTR_EQUALS_QUOTE,
+ QUOTE,
+ Q_PROPS_SEPARATOR,
+ EMPTY_ATTR,
+ ChoreBits,
+ QStyle,
+ QScopedStyle,
+ serializeAttribute,
+ escapeHTML,
+ maybeThen,
+} from './qwik-copy';
+
+/** Suspense boundary info passed from the container to the streaming walker. */
+export interface SuspenseBoundaryInfo {
+ node: ISsrNode;
+ placeholderId: string;
+ createdAt: number;
+ fallbackEmitted: boolean;
+}
+
+export interface SsrStreamingWalkerOptions {
+ writer: StreamWriter;
+ /**
+ * Node before whose close tag a callback should be invoked. Used for emitting container data
+ * inside |