diff --git a/.changeset/tired-deer-kiss.md b/.changeset/tired-deer-kiss.md new file mode 100644 index 00000000000..31cd51d7afd --- /dev/null +++ b/.changeset/tired-deer-kiss.md @@ -0,0 +1,21 @@ +--- +'@qwik.dev/core': minor +--- + +FEAT: `` allows out-of-order rendering of nested content. If the content is not ready to be rendered, a placeholder is rendered instead. When the content is ready, it streams to the client and replaces the placeholder. + +Example: + +```tsx +import { component$, Suspense } from '@qwik.dev/core'; + +export const MyComponent = component$(() => { + return ( +
+ Loading...
}> + +
+ + ); +}); +``` diff --git a/packages/docs/src/routes/api/qwik-server/api.json b/packages/docs/src/routes/api/qwik-server/api.json index 0154d10dc0b..79e6faf0a87 100644 --- a/packages/docs/src/routes/api/qwik-server/api.json +++ b/packages/docs/src/routes/api/qwik-server/api.json @@ -180,7 +180,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RenderOptions extends SerializeDocumentOptions \n```\n**Extends:** [SerializeDocumentOptions](#serializedocumentoptions)\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbase?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Specifies the root of the JS files of the client build. Setting a base, will cause the render of the `q:base` attribute in the `q:container` element.\n\n\n
\n\ncontainerAttributes?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ncontainerTagName?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ When set, the app is serialized into a fragment. And the returned html is not a complete document. Defaults to `html`\n\n\n
\n\nlocale?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Language to use when rendering the document.\n\n\n
\n\nprefetchStrategy?\n\n\n\n\n\n\n\n[PrefetchStrategy](#prefetchstrategy) \\| null\n\n\n\n\n_(Optional)_\n\n\n
\n\npreloader?\n\n\n\n\n\n\n\n[PreloaderOptions](#preloaderoptions) \\| false\n\n\n\n\n_(Optional)_ Specifies how preloading is handled. This ensures that code is instantly available when needed.\n\n\n
\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 or the container element. + */ + containerDataNode?: ISsrNode | null; + /** Callback invoked before the containerDataNode's close tag. May be async. */ + onBeforeContainerClose?: () => ValueOrPromise; + /** Suspense boundaries to handle during emission. */ + suspenseBoundaries?: SuspenseBoundaryInfo[]; + /** Grace period before falling back to Suspense fallback content. */ + suspenseFallbackDelay?: number; + /** Called when a Suspense fallback is actually emitted. */ + onSuspenseFallback?: (node: ISsrNode) => void; + /** Waits for suspense progress or deadline expiry before retrying. */ + waitForSuspense?: (deadline: number) => ValueOrPromise; +} + +/** + * Recursive streaming walker. Emits entire tree at once. Used by toHTML path and OoO chunk + * emission. Cannot pause at dirty nodes. + */ +export class SsrStreamingWalker { + private writer: StreamWriter; + private containerDataNode: ISsrNode | null; + private onBeforeContainerClose: (() => ValueOrPromise) | null; + /** Set of Suspense boundary nodes for fast lookup. */ + private suspenseBoundaries: Map | null; + private suspenseBoundariesByPlaceholderId: Map | null; + private suspenseFallbackDelay: number; + private onSuspenseFallback: ((node: ISsrNode) => void) | null; + private waitForSuspense: ((deadline: number) => ValueOrPromise) | null; + /** Tracks emitted bytes for qwikLoader inline heuristic. */ + public size: number = 0; + + constructor(options: SsrStreamingWalkerOptions) { + this.writer = options.writer; + this.containerDataNode = options.containerDataNode ?? null; + this.onBeforeContainerClose = options.onBeforeContainerClose ?? null; + + this.suspenseFallbackDelay = Math.max(0, options.suspenseFallbackDelay ?? 0); + this.onSuspenseFallback = options.onSuspenseFallback ?? null; + this.waitForSuspense = options.waitForSuspense ?? null; + if (options.suspenseBoundaries && options.suspenseBoundaries.length > 0) { + this.suspenseBoundaries = new Map(options.suspenseBoundaries.map((b) => [b.node, b])); + this.suspenseBoundariesByPlaceholderId = new Map( + options.suspenseBoundaries.map((b) => [b.placeholderId, b]) + ); + } else { + this.suspenseBoundaries = null; + this.suspenseBoundariesByPlaceholderId = null; + } + } + + /** Emit all HTML for the given root node (the container element). */ + emitTree(root: ISsrNode): ValueOrPromise { + return this.emitNode(root); + } + + write(text: string): void { + this.size += text.length; + this.writer.write(text); + } + + private emitNode(node: ISsrNode | SsrChild): ValueOrPromise { + if (isSsrContentChild(node)) { + this.emitContentChild(node); + return; + } + + const ssrNode = node as ISsrNode; + // Mark node as emitted so backpatching knows this node's attrs have been streamed + ssrNode.flags |= VNodeFlags.OpenTagEmitted; + switch ((ssrNode as any).nodeKind) { + case SsrNodeKind.Element: + return this.emitElement(ssrNode); + case SsrNodeKind.Suspense: + return this.emitSuspenseBoundary(ssrNode); + case SsrNodeKind.Virtual: + case SsrNodeKind.Component: + case SsrNodeKind.Projection: + // Virtual nodes produce no HTML — just emit their children + return this.emitChildren(ssrNode); + default: + // Unknown node kind — emit children as fallback + return this.emitChildren(ssrNode); + } + } + + private emitElement(node: ISsrNode): ValueOrPromise { + const tagName = (node as any).tagName as string; + const varAttrs = vnode_getProp(node, SSR_VAR_ATTRS, null) as Record | null; + const constAttrs = vnode_getProp(node, SSR_CONST_ATTRS, null) as Record | null; + const styleScopedId = vnode_getProp(node, SSR_STYLE_SCOPED_ID, null) as string | null; + + // Opening tag + this.write(LT); + this.write(tagName); + + // Var attrs + emitAttrs(this, varAttrs, styleScopedId); + + // q: separator + key + this.write(' ' + Q_PROPS_SEPARATOR); + const key = (node as any).key; + if (key !== null && key !== undefined) { + this.write(`="${key}"`); + } else if (import.meta.env.TEST) { + this.write(EMPTY_ATTR); + } + + // Const attrs + emitAttrs(this, constAttrs, styleScopedId); + + this.write(GT); + + // Children + return maybeThen(this.emitChildren(node), () => { + // Before close tag callback (for container data emission) + if (node === this.containerDataNode && this.onBeforeContainerClose) { + return maybeThen(this.onBeforeContainerClose(), () => { + emitCloseTag(this, tagName); + }); + } + emitCloseTag(this, tagName); + }); + } + + /** + * Emit a Suspense boundary. If the boundary has deferred children (is in the suspenseBoundaries + * list), emit the fallback wrapped in a placeholder div. Otherwise emit the content node's + * children inline (sub-cursor completed synchronously). + */ + private emitSuspenseBoundary(node: ISsrNode): ValueOrPromise { + const contentNode = vnode_getProp(node, SSR_SUSPENSE_CONTENT, null) as ISsrNode | null; + const boundary = this.getSuspenseBoundary(node); + if (!boundary || vnode_getProp(node, SSR_SUSPENSE_READY, null) === true) { + if (contentNode) { + return this.emitChildren(contentNode); + } + return this.emitChildren(node); + } + + if (boundary.createdAt === 0) { + boundary.createdAt = performance.now(); + } + const deadline = boundary.createdAt + this.suspenseFallbackDelay; + if (this.suspenseFallbackDelay > 0 && performance.now() < deadline && this.waitForSuspense) { + return maybeThen(this.waitForSuspense(deadline), () => this.emitSuspenseBoundary(node)); + } + + if (!boundary.fallbackEmitted) { + boundary.fallbackEmitted = true; + this.onSuspenseFallback?.(node); + } + + if (boundary.fallbackEmitted) { + // Emit fallback wrapped in a placeholder div for OoO replacement + const placeholderId = + boundary.placeholderId || vnode_getProp(node, SSR_SUSPENSE_PLACEHOLDER_ID, null); + this.write(`
`); + // Emit fallback content (the boundary's orderedChildren contain the fallback) + return maybeThen(this.emitChildren(node), () => { + this.write('
'); + }); + } + return this.emitChildren(node); + } + + private getSuspenseBoundary(node: ISsrNode): SuspenseBoundaryInfo | null { + const direct = this.suspenseBoundaries?.get(node) ?? null; + if (direct) { + return direct; + } + const placeholderId = vnode_getProp(node, SSR_SUSPENSE_PLACEHOLDER_ID, null) as string | null; + if (!placeholderId) { + return null; + } + return this.suspenseBoundariesByPlaceholderId?.get(placeholderId) ?? null; + } + + private emitContentChild(child: SsrContentChild): void { + emitContentChild(this, child); + } + + emitChildren(node: ISsrNode): ValueOrPromise { + const children = (node as any).orderedChildren as SsrChild[] | null; + if (!children || children.length === 0) { + return; + } + let result: ValueOrPromise = undefined; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (result) { + result = maybeThen(result, () => this.emitNode(child)); + } else { + result = this.emitNode(child); + } + } + return result!; + } +} + +// ─── Shared helpers used by both SsrStreamingWalker and IncrementalEmitter ─── + +/** Serialize and emit attrs from a processed attrs map. */ +function emitAttrs( + target: { write(text: string): void }, + attrs: Record | null, + styleScopedId: string | null +): void { + if (!attrs) { + return; + } + for (const key in attrs) { + const serializedValue = serializeAttribute(key, attrs[key], styleScopedId); + if (serializedValue != null && serializedValue !== false) { + target.write(SPACE); + target.write(key); + if (serializedValue !== true) { + target.write(ATTR_EQUALS_QUOTE); + target.write(escapeHTML(String(serializedValue))); + target.write(QUOTE); + } + } + } +} + +function emitCloseTag(target: { write(text: string): void }, tagName: string): void { + if (!isSelfClosingTag(tagName)) { + target.write(CLOSE_TAG); + target.write(tagName); + target.write(GT); + } +} + +function emitContentChild(target: { write(text: string): void }, child: SsrContentChild): void { + switch (child.kind) { + case SsrNodeKind.Text: + target.write(child.content); + break; + case SsrNodeKind.RawHtml: + target.write(child.content); + break; + case SsrNodeKind.Comment: + target.write(''); + break; + } +} + +// ─── Incremental Emitter ───────────────────────────────────────────────────── + +/** Result of an incremental emission step. */ +export const enum EmitResult { + /** All nodes emitted — streaming is complete. */ + COMPLETE = 0, + /** Hit a dirty node — need more cursor processing before resuming. */ + BLOCKED_DIRTY = 1, + /** Reached container data point — caller must run async callback before resuming. */ + NEEDS_CALLBACK = 2, + /** Paused at a Suspense boundary waiting for readiness or fallback deadline. */ + BLOCKED_SUSPENSE = 3, +} + +/** Phase of emission for a stack frame. */ +const enum EmitPhase { + OPEN = 0, + CHILDREN = 1, + CLOSE = 2, +} + +/** Stack frame for incremental tree emission. */ +interface EmitFrame { + node: ISsrNode; + /** Cached orderedChildren (populated on CHILDREN phase entry). */ + children: SsrChild[] | null; + /** Index of next child to process. */ + childIdx: number; + /** Current phase. */ + phase: EmitPhase; + /** Cached tag name for element nodes. */ + tagName: string | null; + /** Whether this is a deferred Suspense boundary (emit fallback in placeholder div). */ + isDeferred: boolean; +} + +/** + * VNodeData build state for an ancestor element. Pushed when entering an element, popped when + * leaving. Virtual nodes write to the top state (their nearest ancestor element's vNodeData). + */ +interface VNodeDataBuildState { + /** VNodeData being built for this element. */ + vd: VNodeData; + /** + * Child path for virtual node ID computation. Each entry tracks the child index within a nesting + * level. Mirrors the stack in vNodeData_createSsrNodeReference. + */ + path: number[]; + /** Depth-first element index of this element (for ID computation). */ + depthFirstIdx: number; +} + +function getFirstBlockedChild(node: ISsrNode): ISsrNode | null { + const dirtyChildren = (node as any).dirtyChildren as ISsrNode[] | null; + if (!dirtyChildren || dirtyChildren.length === 0) { + return null; + } + + for (let i = 0; i < dirtyChildren.length; i++) { + let candidate: ISsrNode | null = dirtyChildren[i]; + if (!((candidate as any).dirty & ChoreBits.DIRTY_MASK)) { + continue; + } + + while (candidate.parent !== node && candidate.slotParent !== node) { + candidate = (candidate.parent || candidate.slotParent) as ISsrNode | null; + if (!candidate) { + break; + } + } + + if (candidate && (candidate.parent === node || candidate.slotParent === node)) { + return candidate; + } + } + + return null; +} + +/** + * Incremental tree emitter. Uses an explicit stack so it can pause at dirty nodes and resume later. + * Used by SSRContainer.render() in the interleaving loop. + * + * When vNodeDatas is provided, the emitter builds vNodeData from the SsrNode tree in document + * order, assigning IDs and vnodeData references to each SsrNode during emission. This decouples + * vNodeData from tree-building order, enabling deferred component execution. + */ +export class IncrementalEmitter { + private stack: EmitFrame[] = []; + /** Whether emission is complete. */ + done = false; + /** Tracks emitted bytes for qwikLoader inline heuristic. */ + size = 0; + + /** + * Depth-first element index counter. Starts at -1 to match container convention (element IDs are + * `String(depthFirstElementIdx + 1)`). Public so the container can continue counting for + * direct-mode elements after emission. + */ + depthFirstElementCount = -1; + + /** Stack of vNodeData being built for ancestor elements. */ + private vdStack: VNodeDataBuildState[] = []; + /** Deadline for the next suspense fallback decision, if emission is waiting on one. */ + nextSuspenseDeadline: number | null = null; + private suspenseBoundariesByPlaceholderId: Map; + + constructor( + private writer: StreamWriter, + /** + * Node before whose close tag a callback should be invoked. Updated after tree building when + * the body SsrNode becomes available. + */ + public containerDataNode: ISsrNode | null, + /** Suspense boundary state keyed by boundary node. */ + private suspenseBoundaries: Map, + /** Grace period before falling back to Suspense fallback content. */ + private suspenseFallbackDelay: number, + /** Called when a Suspense fallback is actually emitted. */ + private onSuspenseFallback: (node: ISsrNode) => void, + /** + * VNodeData array to populate in document order. The emitter pushes each element's vNodeData as + * it opens, ensuring correct document-order indexing regardless of tree-building order. + */ + private vNodeDatas: VNodeData[] | null = null + ) { + this.suspenseBoundariesByPlaceholderId = new Map(); + this.syncSuspenseBoundaries(suspenseBoundaries.values()); + } + + write(text: string): void { + this.size += text.length; + this.writer.write(text); + } + + /** Start emission from the given root node. */ + init(root: ISsrNode): void { + this.stack = [ + { + node: root, + children: null, + childIdx: 0, + phase: EmitPhase.OPEN, + tagName: (root as any).tagName ?? null, + isDeferred: false, + }, + ]; + this.done = false; + } + + syncSuspenseBoundaries(boundaries: Iterable): void { + this.suspenseBoundaries.clear(); + this.suspenseBoundariesByPlaceholderId.clear(); + const boundariesArray = Array.from(boundaries); + for (let i = 0; i < boundariesArray.length; i++) { + const boundary = boundariesArray[i]; + this.suspenseBoundaries.set(boundary.node, boundary); + this.suspenseBoundariesByPlaceholderId.set(boundary.placeholderId, boundary); + } + } + + /** + * A node is "ready" when its own chores are done. Only CHILDREN may still be dirty (meaning some + * descendants need processing). This lets us emit the open tag as soon as the node itself is + * ready, then descend into children. + */ + private isReady(node: ISsrNode): boolean { + return ((node as any).dirty & ~ChoreBits.CHILDREN) === 0; + } + + /** Track a virtual node open in the parent element's vNodeData. Assigns ID and vnodeData ref. */ + private trackVirtualOpen(ssrNode: SsrNode): void { + if (this.vdStack.length > 0) { + const parent = this.vdStack[this.vdStack.length - 1]; + vNodeData_openFragment(parent.vd, ssrNode_getSerializableAttrs(ssrNode)); + // Mark parent as having references (for ID-based node lookup on client) + parent.vd[0] |= VNodeDataFlag.REFERENCE; + parent.path[parent.path.length - 1]++; + parent.path.push(-1); + + // Compute virtual node ID from parent element's index + path + let refId = String(parent.depthFirstIdx + 1); + for (let j = 0; j < parent.path.length; j++) { + if (parent.path[j] >= 0) { + refId += encodeAsAlphanumeric(parent.path[j]); + } + } + ssrNode.id = refId; + ssrNode.vnodeData = parent.vd; + } + } + + /** Track a virtual node close in the parent element's vNodeData. */ + private trackVirtualClose(): void { + if (this.vdStack.length > 0) { + const parent = this.vdStack[this.vdStack.length - 1]; + vNodeData_closeFragment(parent.vd); + parent.path.pop(); + } + } + + /** Check if an element SsrNode is a qwik style (invisible to vNodeData child counting). */ + private isQwikStyleElement(ssrNode: SsrNode): boolean { + if ((ssrNode as any).tagName !== 'style') { + return false; + } + const varAttrs = vnode_getProp(ssrNode, SSR_VAR_ATTRS, null) as Record | null; + const constAttrs = vnode_getProp(ssrNode, SSR_CONST_ATTRS, null) as Record | null; + return ( + (varAttrs != null && (QStyle in varAttrs || QScopedStyle in varAttrs)) || + (constAttrs != null && (QStyle in constAttrs || QScopedStyle in constAttrs)) + ); + } + + /** + * Emit as many ready nodes as possible. Returns: + * + * - COMPLETE: all nodes emitted + * - BLOCKED_DIRTY: paused at a dirty node (need more cursor processing) + * - NEEDS_CALLBACK: reached container data point (caller must invoke async callback) + */ + emitReady(): EmitResult { + const stack = this.stack; + this.nextSuspenseDeadline = null; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const node = frame.node; + + switch (frame.phase) { + case EmitPhase.OPEN: { + // Check if node is ready to emit + if (!this.isReady(node)) { + return EmitResult.BLOCKED_DIRTY; + } + // Mark as emitted for backpatch correctness + node.flags |= VNodeFlags.OpenTagEmitted; + + const kind = (node as any).nodeKind as SsrNodeKind; + if (kind === SsrNodeKind.Element) { + if (this.vNodeDatas) { + const ssrNode = node as unknown as SsrNode; + + // Qwik style elements are invisible to vNodeData (not counted as children) + const isQwikStyle = this.isQwikStyleElement(ssrNode); + + // Match tree-building convention: post-increment counter, use +1 for ID + const depthFirstIdx = this.depthFirstElementCount++; + + // Reuse existing tree-built vNodeData array (preserves object identity for + // virtual children's .vnodeData refs) or create new. Clear and rebuild. + // Always set REFERENCE — all elements need to be locatable by ID on client. + const existingVd = ssrNode.vnodeData; + let vd: VNodeData; + if (existingVd) { + vd = existingVd; + vd.length = 1; + vd[0] = VNodeDataFlag.ELEMENT_NODE | VNodeDataFlag.REFERENCE; + } else { + vd = [VNodeDataFlag.ELEMENT_NODE | VNodeDataFlag.REFERENCE] as VNodeData; + ssrNode.vnodeData = vd; + } + vd.push(ssrNode_getSerializableAttrs(ssrNode), WRITE_ELEMENT_ATTRS); + + // Increment parent element's element count in its vNodeData + // (but not for qwik style elements — they're invisible to client) + if (!isQwikStyle && this.vdStack.length > 0) { + const parent = this.vdStack[this.vdStack.length - 1]; + vNodeData_incrementElementCount(parent.vd); + parent.path[parent.path.length - 1]++; + } + + // Push to vNodeDatas in document (open) order + this.vNodeDatas.push(vd); + this.vdStack.push({ vd, path: [-1], depthFirstIdx }); + + // Assign element ID + ssrNode.id = String(depthFirstIdx + 1); + } + this.emitOpenTag(node, frame.tagName!); + frame.phase = EmitPhase.CHILDREN; + } else if (kind === SsrNodeKind.Suspense) { + if (this.vNodeDatas) { + this.trackVirtualOpen(node as unknown as SsrNode); + } + const suspenseResult = this.handleSuspenseOpen(frame, node); + if (suspenseResult !== undefined) { + return suspenseResult; + } + // Phase set by handleSuspenseOpen + } else { + // Virtual/Component/Projection — no HTML output, track in parent vNodeData + if (this.vNodeDatas) { + this.trackVirtualOpen(node as unknown as SsrNode); + } + frame.phase = EmitPhase.CHILDREN; + } + break; + } + + case EmitPhase.CHILDREN: { + if (!frame.children) { + frame.children = (node as any).orderedChildren as SsrChild[] | null; + } + const children = frame.children; + const blockedChild = + ((node as any).dirty & ChoreBits.CHILDREN) !== 0 ? getFirstBlockedChild(node) : null; + if (blockedChild) { + if (!children || frame.childIdx >= children.length) { + return EmitResult.BLOCKED_DIRTY; + } + if (blockedChild && children[frame.childIdx] === blockedChild) { + return EmitResult.BLOCKED_DIRTY; + } + } + if (children && frame.childIdx < children.length) { + const child = children[frame.childIdx++]; + if (isSsrContentChild(child)) { + emitContentChild(this, child); + // Track text in parent element's vNodeData + if (this.vNodeDatas && child.kind === SsrNodeKind.Text && this.vdStack.length > 0) { + const parent = this.vdStack[this.vdStack.length - 1]; + vNodeData_addTextSize(parent.vd, child.textLength ?? child.content.length); + parent.path[parent.path.length - 1]++; + } + } else { + const childNode = child as ISsrNode; + stack.push({ + node: childNode, + children: null, + childIdx: 0, + phase: EmitPhase.OPEN, + tagName: (childNode as any).tagName ?? null, + isDeferred: false, + }); + } + } else if ( + (node as any)._pendingContent > 0 || + (((node as any).dirty & ChoreBits.CHILDREN) !== 0 && + getFirstBlockedChild(node) !== null) + ) { + return EmitResult.BLOCKED_DIRTY; + } else { + frame.phase = EmitPhase.CLOSE; + } + break; + } + + case EmitPhase.CLOSE: { + // Check for container data callback before close tag + if (node === this.containerDataNode) { + // Signal caller to run the async container data emission + this.containerDataNode = null; // Only fire once + return EmitResult.NEEDS_CALLBACK; + } + + if (frame.tagName) { + // Element close: pop vNodeData build state + if (this.vNodeDatas) { + this.vdStack.pop(); + } + emitCloseTag(this, frame.tagName); + } else { + // Non-element close: close virtual fragment in parent vNodeData + if (this.vNodeDatas) { + this.trackVirtualClose(); + } + } + + // For deferred Suspense: close the placeholder div + if (frame.isDeferred) { + this.write(''); + } + + stack.pop(); + break; + } + } + } + + this.done = true; + return EmitResult.COMPLETE; + } + + private emitOpenTag(node: ISsrNode, tagName: string): void { + const varAttrs = vnode_getProp(node, SSR_VAR_ATTRS, null) as Record | null; + const constAttrs = vnode_getProp(node, SSR_CONST_ATTRS, null) as Record | null; + const styleScopedId = vnode_getProp(node, SSR_STYLE_SCOPED_ID, null) as string | null; + + this.write(LT); + this.write(tagName); + emitAttrs(this, varAttrs, styleScopedId); + + // q: separator + key + this.write(' ' + Q_PROPS_SEPARATOR); + const key = (node as any).key; + if (key !== null && key !== undefined) { + this.write(`="${key}"`); + } else if (import.meta.env.TEST) { + this.write(EMPTY_ATTR); + } + + emitAttrs(this, constAttrs, styleScopedId); + this.write(GT); + } + + /** + * Handle Suspense boundary open. Deferred boundaries emit fallback in placeholder div. Ready + * boundaries emit content node's children inline. + */ + private handleSuspenseOpen(frame: EmitFrame, node: ISsrNode): EmitResult | void { + const boundary = this.getSuspenseBoundary(node); + const contentNode = vnode_getProp(node, SSR_SUSPENSE_CONTENT, null) as ISsrNode | null; + if (!boundary || vnode_getProp(node, SSR_SUSPENSE_READY, null) === true) { + if (contentNode) { + frame.children = (contentNode as any).orderedChildren as SsrChild[] | null; + frame.childIdx = 0; + } + frame.phase = EmitPhase.CHILDREN; + return; + } + + if (boundary.createdAt === 0) { + boundary.createdAt = performance.now(); + } + const deadline = boundary.createdAt + this.suspenseFallbackDelay; + if (this.suspenseFallbackDelay > 0 && performance.now() < deadline) { + this.nextSuspenseDeadline = deadline; + return EmitResult.BLOCKED_SUSPENSE; + } + + if (!boundary.fallbackEmitted) { + boundary.fallbackEmitted = true; + this.onSuspenseFallback(node); + } + + if (boundary.fallbackEmitted) { + // Deferred: emit
and walk fallback children, then close with
+ const placeholderId = + boundary.placeholderId || vnode_getProp(node, SSR_SUSPENSE_PLACEHOLDER_ID, null); + this.write(`
`); + frame.isDeferred = true; + frame.phase = EmitPhase.CHILDREN; + // orderedChildren contain the fallback + return; + } + frame.phase = EmitPhase.CHILDREN; + } + + private getSuspenseBoundary(node: ISsrNode): SuspenseBoundaryInfo | null { + const direct = this.suspenseBoundaries.get(node) ?? null; + if (direct) { + return direct; + } + const placeholderId = vnode_getProp(node, SSR_SUSPENSE_PLACEHOLDER_ID, null) as string | null; + if (!placeholderId) { + return null; + } + return this.suspenseBoundariesByPlaceholderId.get(placeholderId) ?? null; + } +} diff --git a/packages/qwik/src/server/types.ts b/packages/qwik/src/server/types.ts index 72c9b927af6..2cb34474757 100644 --- a/packages/qwik/src/server/types.ts +++ b/packages/qwik/src/server/types.ts @@ -203,6 +203,8 @@ export interface RenderOptions extends SerializeDocumentOptions { containerAttributes?: Record; /** Metadata that can be retrieved during SSR with `useServerData()`. */ serverData?: Record; + /** Streaming behavior for SSR output. */ + streaming?: StreamingOptions; } /** @public */ @@ -231,6 +233,9 @@ export type InOrderStreaming = InOrderAuto | InOrderDisabled | InOrderDirect; /** @public */ export interface StreamingOptions { inOrder?: InOrderStreaming; + suspenseFallbackDelay?: number; + /** Time budget in ms between I/O yields during SSR. Default: 10. Set to 0 to disable. */ + yieldBudget?: number; } /** @public */ diff --git a/packages/qwik/src/server/vnode-data.ts b/packages/qwik/src/server/vnode-data.ts index 6148fb500c1..9c4483c9bb3 100644 --- a/packages/qwik/src/server/vnode-data.ts +++ b/packages/qwik/src/server/vnode-data.ts @@ -2,7 +2,6 @@ import type { ISsrNode, Props } from './qwik-types'; import { SsrNode } from './ssr-node'; import type { CleanupQueue } from './ssr-container'; import { VNodeDataFlag } from './types'; -import { _EMPTY_ARRAY } from '@qwik.dev/core/internal'; /** * Array of numbers which describes virtual nodes in the tree. @@ -124,14 +123,11 @@ export function vNodeData_createSsrNodeReference( } } } - return new SsrNode( - currentComponentNode, - refId, - attributesIndex, - cleanupQueue, - vNodeData, - currentFile - ); + // Extract the attrs object from vNodeData (shared reference — writes to attrs update vNodeData too) + const attrs = attributesIndex >= 0 ? (vNodeData[attributesIndex] as Props) : {}; + const node = new SsrNode(currentComponentNode, refId, attrs, cleanupQueue, currentFile); + node.vnodeData = vNodeData; + return node; } /** diff --git a/packages/qwik/src/testing/index.ts b/packages/qwik/src/testing/index.ts index fb4f09ec6ae..34fac99a9ca 100644 --- a/packages/qwik/src/testing/index.ts +++ b/packages/qwik/src/testing/index.ts @@ -12,6 +12,8 @@ export { walkJSX, vnode_fromJSX } from './vdom-diff.unit-util'; export { trigger, ElementFixture } from './element-fixture'; export { waitForDrain } from './util'; +export type { StreamingOptions } from '../server/types'; + // TODO get api-extractor to export this too interface CustomMatchers { toMatchVDOM(expectedJSX: JSXOutput, isCsr?: boolean): R; diff --git a/packages/qwik/src/testing/qwik.testing.api.md b/packages/qwik/src/testing/qwik.testing.api.md index 14b370bfcba..6ad6857e6e1 100644 --- a/packages/qwik/src/testing/qwik.testing.api.md +++ b/packages/qwik/src/testing/qwik.testing.api.md @@ -79,6 +79,7 @@ export function ssrRenderToDom(jsx: JSXOutput, opts?: { debug?: boolean; raw?: boolean; qwikLoader?: boolean; + streaming?: StreamingOptions; onBeforeResume?: (document: Document) => void; }): Promise<{ container: _DomContainer; @@ -87,6 +88,17 @@ export function ssrRenderToDom(jsx: JSXOutput, opts?: { getStyles: () => Record; }>; +// @public (undocumented) +export interface StreamingOptions { + // Warning: (ae-forgotten-export) The symbol "InOrderStreaming" needs to be exported by the entry point index.d.ts + // + // (undocumented) + inOrder?: InOrderStreaming; + // (undocumented) + suspenseFallbackDelay?: number; + yieldBudget?: number; +} + // @public export function trigger(root: Element, queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventName: string, eventPayload?: any, options?: { waitForIdle?: boolean; diff --git a/packages/qwik/src/testing/rendering.unit-util.tsx b/packages/qwik/src/testing/rendering.unit-util.tsx index 1ea325f781b..f38698f05ec 100644 --- a/packages/qwik/src/testing/rendering.unit-util.tsx +++ b/packages/qwik/src/testing/rendering.unit-util.tsx @@ -42,6 +42,7 @@ import { useContextProvider } from '../core/use/use-context'; import { DEBUG_TYPE, ELEMENT_BACKPATCH_DATA, VirtualType } from '../server/qwik-copy'; import type { HostElement } from '../server/qwik-types'; import { Q_FUNCS_PREFIX, renderToString } from '../server/ssr-render'; +import type { StreamingOptions } from '../server/types'; import { createDocument } from './document'; import './vdom-diff.unit-util'; import type { VNode } from '../core/shared/vnode/vnode'; @@ -112,6 +113,8 @@ export async function ssrRenderToDom( raw?: boolean; /** Include QwikLoader */ qwikLoader?: boolean; + /** SSR streaming behavior overrides. */ + streaming?: StreamingOptions; /** Inject nodes into the document before test runs (for testing purposes) */ onBeforeResume?: (document: Document) => void; } = {} @@ -129,6 +132,7 @@ export async function ssrRenderToDom( ]; const result = await renderToString(jsxToRender, { qwikLoader: opts.qwikLoader ? 'inline' : 'never', + streaming: opts.streaming, }); html = result.html; } finally { diff --git a/scripts/submodule-server.ts b/scripts/submodule-server.ts index f574bdc4961..6ad99f3c238 100644 --- a/scripts/submodule-server.ts +++ b/scripts/submodule-server.ts @@ -67,6 +67,7 @@ export async function submoduleServer(config: BuildConfig, nameCache?: object) { args.path.includes('util') || args.path.includes('shared') || args.path.includes('ssr') || + args.path.includes('src/core/client/types') || // we allow building preloader into server builds args.path.includes('preloader') ) { diff --git a/scripts/validate-benchmarks.ts b/scripts/validate-benchmarks.ts index cea78d4b8f0..41a60116b12 100644 --- a/scripts/validate-benchmarks.ts +++ b/scripts/validate-benchmarks.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; const BENCH_ENTRY = 'packages/qwik/src/core/bench/core.bench.ts'; @@ -14,7 +15,7 @@ const LOW_SAMPLE_MIN_TOLERANCE_PCT = 10; const VERY_LOW_SAMPLE_MIN_TOLERANCE_PCT = 15; const execFileAsync = promisify(execFile); -type BenchmarkMetrics = { +export type BenchmarkMetrics = { mean: number; median: number; p75: number; @@ -26,7 +27,7 @@ type BenchmarkMetrics = { factor?: number; }; -type StoredResults = { +export type StoredResults = { version: number; generatedAt: string; benchmarks: Record; @@ -137,7 +138,7 @@ async function runBenchmarks(): Promise<{ return { measuredBenchmarks: benchmarks, measuredSizes }; } -function buildStoredResults( +export function buildStoredResults( scenarioIds: string[], measuredBenchmarks: Record, measuredSizes: Record @@ -155,7 +156,7 @@ function buildStoredResults( ? benchmark : { ...benchmark, - factor: benchmark.median / baseline.median, + factor: benchmark.mean / baseline.mean, }; } @@ -182,7 +183,7 @@ async function readStoredResults(storedPath: string): Promise { } } -function validateResults( +export function validateResults( scenarioIds: string[], stored: StoredResults, measuredBenchmarks: Record, @@ -208,7 +209,7 @@ function validateResults( addSampleCountWarning(warnings, BASELINE_BENCHMARK_ID, measuredBaseline.sampleCount); lines.push( - `${BASELINE_BENCHMARK_ID}: median=${formatNumber( + `${BASELINE_BENCHMARK_ID}: mean=${formatNumber(measuredBaseline.mean)}ms median=${formatNumber( measuredBaseline.median )}ms samples=${measuredBaseline.sampleCount} BASELINE` ); @@ -228,7 +229,7 @@ function validateResults( const medianTolerancePct = tolerancePct(storedBenchmark); const factorTolerancePct = tolerancePct(storedBenchmark) + tolerancePct(storedBaseline); const storedFactor = storedBenchmark.factor; - const measuredFactor = measuredBenchmark.median / measuredBaseline.median; + const measuredFactor = measuredBenchmark.mean / measuredBaseline.mean; const medianMax = maxAllowedValue(storedBenchmark.median, medianTolerancePct); const factorMax = storedFactor == null ? null : maxAllowedValue(storedFactor, factorTolerancePct); @@ -243,9 +244,10 @@ function validateResults( lines.push( [ `${benchmarkId}:`, + `mean=${formatNumber(measuredBenchmark.mean)}ms`, `median=${formatNumber(measuredBenchmark.median)}ms`, - `stored=${formatNumber(storedBenchmark.median)}ms`, - `max=${formatNumber(medianMax)}ms`, + `storedMedian=${formatNumber(storedBenchmark.median)}ms`, + `medianMax=${formatNumber(medianMax)}ms`, `factor=${formatNumber(measuredFactor)}x`, storedFactor == null ? '' : `storedFactor=${formatNumber(storedFactor)}x`, factorMax == null ? '' : `factorMax=${formatNumber(factorMax)}x`, @@ -415,7 +417,12 @@ function formatPercent(value: number) { return `${value.toFixed(2)}%`; } -main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -}); +const isDirectExecution = + process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + +if (isDirectExecution) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +}