Skip to content

cursor-based SSR, Suspense, out-of-order streaming#8431

Draft
wmertens wants to merge 39 commits intobuild/v2from
ssr-cursors
Draft

cursor-based SSR, Suspense, out-of-order streaming#8431
wmertens wants to merge 39 commits intobuild/v2from
ssr-cursors

Conversation

@wmertens
Copy link
Member

not reviewed yet, sneak preview

wmertens and others added 30 commits March 12, 2026 13:41
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>
@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

⚠️ No Changeset found

Latest commit: a78993c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@wmertens wmertens force-pushed the ssr-cursors branch 2 times, most recently from 90792a7 to bf7fc04 Compare March 15, 2026 08:27
@github-actions
Copy link
Contributor

github-actions bot commented Mar 15, 2026

built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
qwik-docs ✅ Ready (View Log) Visit Preview b986b06

@wmertens wmertens added the V2 label Mar 15, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@8431
npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@8431
npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@8431
npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@8431

commit: b986b06

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants