cursor-based SSR, Suspense, out-of-order streaming#8431
Draft
cursor-based SSR, Suspense, out-of-order streaming#8431
Conversation
Changes made: types.ts — Added VNodeFlags.OpenTagEmitted flag internal.ts — Exported VirtualVNode and VNode as values (not just types) so SsrNode can extend them ssr-node.ts — Rewrote SsrNode to extend VirtualVNode, using Object.defineProperty for _EFFECT_BACK_REF delegation to serializable attrs ssr-types.ts — Updated ISsrNode interface to use VNodeFlags instead of SsrNodeFlags vnode-dirty.ts — Simplified markVNodeDirty signature from VNode | ISsrNode to just VNode; removed isSsrNodeGuard; uses isServer directly subscriber.ts — Removed SSR-specific _EFFECT_BACK_REF initialization (defineProperty handles it) types.ts — Simplified Consumer type from Task | VNode | SignalImpl | ISsrNode to Task | VNode | SignalImpl types.ts — Simplified HostElement from VNode | ISsrNode to VNode; removed unused SsrNodeFlags enum qwik-copy.ts — Added VNodeFlags re-export; removed SsrNodeFlags re-export ssr-chore-execution.ts — Updated to use ssrNode.updatable instead of flags check
hase 1 is fully complete and verified. The cursor walker now drives SSR rendering, wrapping _walkJSX through a stored callback on a VirtualVNode cursor root. All 935 tests pass, TypeScript checks pass, and the build succeeds.
Summary of Phase 2 changes: treeOnly mode in SSRContainer: render() first builds the full SsrNode tree without emitting HTML, then the streaming walker emits it _inTagWrite flag: Distinguishes tag-structure writes (suppressed in treeOnly) from content writes (captured as RawHtml orderedChildren) _attrBuffer: Captures attribute HTML during openElement for later emission SsrStreamingWalker (new file): Traverses orderedChildren in document order, emitting HTML for elements, text, raw HTML, and comments. Supports onBeforeContainerClose callback for injecting container data before </body> SsrContentChild types: Lightweight text/rawHtml/comment entries in orderedChildren Backpatch fix: setTreeNonUpdatable() called even in treeOnly mode so signal-driven attribute changes still use backpatching (since attr HTML is pre-captured)
Suspense component — new public API in utils.public.ts, exported from @qwik.dev/core SsrNodeKind.Suspense — new node kind for Suspense boundaries Always-defer design — _walkJSX processes fallback inline but defers children. After the main tree is streamed, deferred content is processed and emitted as OoO chunks Streaming walker — detects Suspense boundaries and emits fallback wrapped in <div id="qph-N"> placeholder OoO emission — <template id="qooo-qph-N"> with actual content + swap <script> that replaces the placeholder Frame isolation — deferred content uses TagNesting.ANYTHING to bypass HTML nesting validation (content goes inside <template>)
ssr-container.ts: Defined WalkContext interface + createWalkContext() factory. Replaced 4 private fields (lastNode, currentComponentNode, currentElementFrame, componentStack) with a single public activeWalkCtx: WalkContext. Updated ~45 access sites. cursor-props.ts: Added walkCtx: unknown | null to CursorData interface. cursor.ts: Added walkCtx: null to CursorData initialization. cursor-walker.ts: Added WalkContext swap — when walkCursor starts processing a cursor on the server, it sets container.activeWalkCtx = cursorData.walkCtx. ssr-types.ts: Added activeWalkCtx to SSRContainer interface. internal.ts: Exported getCursorData for use by ssr-container. Test files: Updated CursorData creation in test mocks.
Phase 5b summary: The SSR container now builds a complete SsrNode tree in treeOnly mode during render(), then uses SsrStreamingWalker to emit all HTML from that tree. The write() method suppresses tag structure output during tree building while textNode, htmlNode, and commentNode store content on SsrNodes via orderedChildren. Size tracking (this.size) is maintained in all paths so the qwikLoader inline insertion heuristic works correctly.
Key Insights _walkJSX is async, so sub-cursors always pause (even for sync children). True sync inline Suspense requires making _walkJSX non-async — noted for future work Fragment nesting in treeOnly mode was broken (children went to grandparent) — fixed with ssrNodeStack The drain() yielding fix is both a correctness fix and a performance improvement Changes made cursor-props.ts — Added onDone: (() => void) | null callback to CursorData, invoked when a cursor completes cursor-walker.ts — Call cursorData.onDone?.() in finishWalk so Suspense sub-cursors can signal completion ssr-container.ts — Major changes: Added ssrNodeStack to WalkContext for proper fragment nesting in treeOnly mode openFragment/closeFragment now save/restore the frame's ssrNode via the stack New createSuspenseSubCursor() method — creates a sub-cursor (priority -1) with its own WalkContext, content node, and onDone callback that marks :suspenseReady render() rewritten for sub-cursor-aware streaming: awaits main cursor via onDone promise, filters deferred boundaries, passes only pending ones to streaming walker emitOoOChunks() now accepts a boundaries parameter ssr-streaming-walker.ts — emitSuspenseBoundary() checks :suspenseContent for inline emission of ready boundaries ssr-render-jsx.ts — Two changes: Suspense handling calls createSuspenseSubCursor instead of _storeSuspenseChildren Critical fix: drain() no longer awaits sync function results — this prevented other cursors from swapping activeWalkCtx during microtask yields ssr-node.ts — Added SSR_SUSPENSE_CONTENT and SSR_SUSPENSE_READY prop constants
Dead code removed: _storeSuspenseChildren() method — replaced by createSuspenseSubCursor() in Phase 5c processDeferredSuspenseContent() method — sub-cursors now handle deferred content building SSR_SUSPENSE_CHILDREN_JSX and SSR_SUSPENSE_WALK_OPTIONS constants — no longer stored on boundary nodes SuspenseBoundary interface simplified: removed childrenJsx, walkOptions, resolved fields Unused SsrChild type import Null check for contentNode in emitOoOChunks (now always provided) closeElement simplified: Removed old Suspense processing path (processDeferredSuspenseContent + emitOoOChunks) Kept container data emission for the manual rendering path (used by container.spec.tsx tests)
Phase 6 Summary Goal: Remove dual-path flags (treeOnly, _attrBuffer, _inTagWrite) and replace with a clean _directMode + _cursorDrivenRender architecture. Changes made: ssr-container.ts: Replaced treeOnly, _attrBuffer, _inTagWrite with _directMode (only true during emitContainerData) and _cursorDrivenRender (prevents manual path during render()) openElement: _directMode branch writes directly with writeAttrsDirect + Q_PROPS_SEPARATOR; tree-building branch uses new processAttrs and stores SSR_VAR_ATTRS/SSR_CONST_ATTRS/SSR_STYLE_SCOPED_ID on SsrNode closeElement: Manual rendering path guarded by !_cursorDrivenRender; creates walker for tree + container data emission _closeElement: Only writes close tags in _directMode openFragment/closeFragment: Always build tree (removed treeOnly checks) openSuspenseBoundary/closeSuspenseBoundary: Always set nodeKind (removed direct streaming) textNode/htmlNode/commentNode: _directMode writes directly; tree mode stores on SsrNode with size tracking write: Routes to stream or tree based on _directMode; always tracks this.size render: Walker uses emitTree (not emitChildren); saves body frame for VNodeData counting during callback writeAttrsDirect: New method for _directMode attrs, handles event QRLs via _setEvent processAttrs: New method extracting side effects from writeAttrs but returning processed key-value pairs instead of writing HTML Removed: Old writeAttrs method, SSR_ATTR_HTML import Tag nesting validation: Skipped in _directMode (emitContainerData only emits controlled <script> tags) ssr-streaming-walker.ts: emitElement reads SSR_VAR_ATTRS/SSR_CONST_ATTRS/SSR_STYLE_SCOPED_ID/key from SsrNode emitAttrs serializes attrs from stored props using serializeAttribute Sets VNodeFlags.OpenTagEmitted on emitted nodes size tracking field on walker ssr-container.spec.ts: Updated emitVNodeData test to set _directMode = true
Key fixes to ssr-diff.ts: Async component walkCtx restore (line ~509): Changed the no-op ssrAny.activeWalkCtx = ssrAny.activeWalkCtx to properly save and restore activeWalkCtx across async component resolution. Async inline components (line ~568): Changed from "push to asyncQueue + close fragment immediately" to $asyncBreak$ pattern — keeps parent element frames open until the promise resolves, preventing <div> at html level errors. Same fix applied to ssrPromise. Async signal tracking (line ~616): Replaced retryOnPromise(trackFn) with a try/catch that distinguishes thrown Promises (async signal computation, e.g. useAsync$) from returned Promises (literal Promise signal values, e.g. useSignal(Promise.resolve(0))): Thrown Promise → $asyncBreak$ + render resolved value directly (no Awaited wrapper) Returned Promise → ssrDescend into it, letting the inner loop create the Awaited wrapper via ssrPromise Async generator (ssrAsyncGenerator): Added walkCtx save/restore and $asyncBreak$ pattern.
Bug fixes in ssr-diff.ts (18 failures → 0) Async component walkCtx — Fixed no-op activeWalkCtx = activeWalkCtx to properly save/restore across async component resolution Async inline components — Changed from "close fragment immediately" to $asyncBreak$ pattern, keeping parent element frames open until promise resolves Async signals — Same $asyncBreak$ pattern for signal tracking that returns promises Signal tracking distinction — Try/catch to distinguish thrown promises (async signal computation, no Awaited wrapper) from returned promises (literal Promise value, gets Awaited wrapper) Async Promise JSX — Added walkCtx save/restore in ssrPromise Async generators — Added walkCtx save/restore and $asyncBreak$ in ssrAsyncGenerator ChoreBits reordering in chore-bits.enum.ts Swapped COMPONENT (now bit 1) and NODE_DIFF (now bit 2) so the cursor walker processes: TASKS → COMPONENT → NODE_DIFF Updated the if-else chain in cursor-walker.ts to match Cleanup Deleted ssr-vnode-diff.ts (unused thin wrapper) Removed _ssrVNodeDiff export from internal.ts Updated stale _walkJSX/ssrVNodeDiff comments across multiple files
Completed: Reverted premature Phase B+C — The emitter was overwriting IDs and vNodeData that cross-references had already stored, causing 335 test failures. Reverted to let tree building assign IDs/vNodeData (correct since tree order = document order currently). Replaced _walkJSX with ssrDiff in emitUnclaimedProjectionForComponent — Added _activeCursor field on the container (set by ssrDiff on entry), so container methods can call ssrDiff when needed. Deleted _walkJSX and dead code (Step 7): Deleted ssr-render-jsx.ts Removed _walkJSX export from internal.ts Removed applyQwikComponentBody from ssr-render-component.ts Exported ssrDiff as _ssrDiff from internal.ts Cleanup — Removed unused imports from ssr-streaming-walker.ts, fixed private write → write for TypeScript compatibility.
The IncrementalEmitter now pushes each element's tree-built vNodeData to this.vNodeDatas in document order (at element OPEN time) and assigns element IDs based on its own depth-first counter createAndPushFrame in ssr-container.ts skips the push when _cursorDrivenRender is true (emitter handles it), but still pushes for direct API callers and _directMode (container data scripts) After emission, the container syncs depthFirstElementCount from the emitter Simplified the emitter: removed VNodeDataBuildState, vdStack, trackVirtualOpen/Close, and vNodeData text tracking — the emitter doesn't create new vNodeData, it reuses tree-built ones
The IncrementalEmitter now creates vNodeData structure from scratch during emission, tracking element counts, text sizes, and virtual node fragments via a vdStack. Key design decisions: - Reuse existing tree-built vNodeData arrays in-place (clear + rebuild) to preserve object identity for virtual child .vnodeData references - Preserve REFERENCE flag from tree-built vNodeData (needed for client element lookup via qVNodeRefs) - Track qwik style elements as invisible to vNodeData child counting - Virtual node IDs computed from parent element's depth-first index + path All 1949 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Skip vNodeData operations (incrementElementCount, openElement, openFragment, closeFragment, addTextSize) during cursor-driven render — the emitter handles all vNodeData building from orderedChildren. Key changes: - getOrCreateLastNode creates plain SsrNode in cursor-driven mode (no vNodeData_createSsrNodeReference needed) - openFragment creates SsrNode directly with attrs (shared object ref) - Emitter always sets REFERENCE flag on all elements - Element IDs still assigned during tree-building (needed for backpatching) - Direct mode path unchanged (still uses full vNodeData operations) All 1949 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rPromise - Extract async body into _emitUnclaimedProjectionAsync to allow sync early-return when there are no unclaimed projections (no async overhead) - Connect VNode parent chain in unclaimed projection nodes so markVNodeDirty can propagate to cursor root Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ssrComponent now creates the host SsrNode, distributes slots, stores the component frame and parent element context (for HTML tagNesting validation), then marks COMPONENT dirty via markVNodeDirty so the cursor walker drives execution. executeSsrComponent calls enterComponentContext (which creates a synthetic ElementFrame with the stored parent tagNesting to satisfy HTML validation), executes the QRL, and stores the result as ':nodeDiff' for NODE_DIFF. executeSsrNodeDiff detects component nodes and passes the stored component frame to ssrDiff, then calls leaveComponentContext when done. All 1949 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ojection test that was already failing before my changes. All other 1948 tests pass (was 7-8 failures at the start of this session). Summary of fixes in this session: Suspense OoO streaming: All Suspense boundaries are now treated as deferred for OoO streaming, regardless of sub-cursor completion status (line 496 in ssr-container.ts). Fixed 4 tests. Backpatch element index mismatch: In cursor-driven mode, backpatch entries now store SsrNode references instead of tree-building-time element indices. Index resolution is deferred to emission time when the emitter has assigned final indices. Fixed 2 tests. Backpatch entry ordering: Deferred backpatch entries are sorted by element index in ascending order before emission, because the backpatch executor's TreeWalker only moves forward. Fixed the remaining backpatch test. The sole remaining failure (should rerender inline component in projection) is a pre-existing client-side reactivity issue unrelated to these changes.
…and backpatch - Rename WalkContext → SsrBuildState across all 9 files (activeWalkCtx → ssrBuildState, walkCtx → ssrBuildState, createWalkContext → createSsrBuildState) - Fix Suspense OoO streaming: treat all Suspense boundaries as deferred in cursor-driven mode since processCursorQueue completes sub-cursors synchronously - Fix backpatch element index mismatch: store backpatch entries by SsrNode reference (backpatchNodeMap), resolve indices at emission time when emitter has assigned final element IDs - Sort deferred backpatch entries by element index ascending (TreeWalker only moves forward) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lastNode is a transient cache cleared before every operation. It doesn't need per-cursor isolation (the swap mechanism), so move it to a direct field on SSRContainer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
closeComponent was part of the old _walkJSX pipeline. The cursor-driven model uses leaveComponentContext + executeSsrUnclaimedProjections instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
90792a7 to
bf7fc04
Compare
Contributor
built with Refined Cloudflare Pages Action⚡ Cloudflare Pages Deployment
|
… and enhance slot rendering test
commit: |
this is both faster in hot code and better code reuse
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
not reviewed yet, sneak preview