Skip to content

fix(core): keep SSR-shared text intact when a keyed diff reorders or replaces siblings - #9368

Merged
Varixo merged 2 commits into
QwikDev:mainfrom
omerman:fix/keyed-shared-text-split
Nov 3, 2026
Merged

Varixo merged 2 commits into
QwikDev:mainfrom
omerman:fix/keyed-shared-text-split

Conversation

@omerman

@omerman omerman commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What is it? Bug fix

Description

After resume, the text of adjacent keyed fragments shares one SSR Text node and is split lazily by vnode_ensureTextInflated, which assumes that text vnodes that are neighbours in the current vnode tree came from the same SSR Text node. A keyed diff can break that assumption in two ways:

  1. cleanupSideBuffer → removeUnmatchedSiblings removes the element that separated two SSR text runs; vnode_insertElementBefore → vnode_findInsertBefore then inflates text and the split walks across both runs, so text is duplicated or overwritten.
  2. reorderMatchedSiblings moves a keyed Fragment; vnode_insertVirtualBefore unlinks it first and its text is split afterwards, so its last text vnode looks like the end of the run, takes over the shared node, and the other fragment's text is lost.

This PR:

  • vnode_ensureTextInflated: only treats text vnodes that still point at the same SSR Text node as one run, both when walking the previous siblings and when deciding which vnode keeps the shared node. Runs from different SSR Text nodes that became adjacent are left alone.
  • vnode_insertVirtualBefore: inflates a moved virtual's text while it is still linked to its siblings, before vnode_unlinkFromOldParent (the same idea as vnode_inflateProjectionTrailingText, for keyed fragments).

Reproduction

const Cmp = component$(() => {
  const done = useSignal(false);
  return (
    <div>
      <button onClick$={() => (done.value = true)} />
      <section>
        {done.value ? <b key="c0">X0</b> : <i key="c0" />}
        <Fragment key="t1">t1</Fragment>
        <Fragment key="t2">t2</Fragment>
        {done.value ? <b key="c3">X3</b> : <i key="c3" />}
        <Fragment key="t4">t4</Fragment>
      </section>
    </div>
  );
});

With ssrRenderToDom, after the click: expected X0t1t2X3t4 (what domRender gives), actual on main X0t1t2t1t2X3t4. With a third swapped element instead of the trailing t4, t1 is lost (X0t2X3X4).

Real-world impact: a chord-sheet page renders <Skeleton key={i}/> placeholders between lyric fragments until a client-only preference loads; after the swap, lyrics blocks repeat and text goes missing. The same JSX works in v1 (1.20.3), which wrapped each keyed Fragment's text in its own comment-delimited node.

Tests

  • 3 new cases in render-regression.spec.tsx, run with both ssrRenderToDom and domRender: element placeholders with trailing text, without trailing text, and component placeholders replaced by keyed fragments. All 3 SSR cases fail on main ('X0t1t2t1t2X3t4', 'X0t2X3X4', 'X0t1t2t4X3t4').
  • vitest run packages/qwik/src: 133 files, 2982 passed.

Checklist

  • My code follows the developer guidelines of this project
  • I performed a self-review of my own code
  • I added a changeset with pnpm change
  • I added new tests to cover the fix / functionality

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an issue where keyed lists could display missing or duplicated text after a page resumed. Text in keyed fragments is now preserved when list items are replaced or updated, including when adjacent fragments share rendered text.
  • Tests
    • Added regression coverage for keyed lists that replace placeholders with elements or components, checking that fragment text and replacement content remain visible.

@omerman
omerman requested a review from a team as a code owner November 3, 2026 11:52
@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1853fee

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@qwik.dev/core Patch
@qwik.dev/devtools Patch
eslint-plugin-qwik Patch
@qwik.dev/mcp Patch
@qwik.dev/react Patch
@qwik.dev/router Patch
@qwik.dev/utils Patch
create-qwik Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Walkthrough

Keyed-list text inflation now groups adjacent text VNodes by shared SSR Text-node identity. The code walks a parented virtual child’s direct children before unlinking it. Regression tests cover keyed placeholder replacements around adjacent fragments, and a patch changeset records the fix.

Changes: Keyed-list text handling

Layer / File(s) Summary
Shared text inflation and child insertion
packages/qwik/src/core/client/vnode-utils.ts
Text VNodes are grouped by shared SSR Text-node identity. Before unlinking a parented virtual child, the code walks its direct children.
Keyed replacement regression coverage
packages/qwik/src/core/tests/render-regression.spec.tsx, .changeset/keyed-shared-text-split.md
Three regression tests cover keyed placeholder replacements around adjacent keyed fragments. A patch changeset records the fix.

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix

Possibly related issues

  • QwikDev/qwik issue 9369 describes SSR-resume text corruption involving keyed diffs, adjacent keyed fragments, and shared SSR Text nodes.

Possibly related PRs

  • QwikDev/qwik#9313: This PR changes sibling lookup behavior used to determine text-run boundaries during inflation.

Suggested reviewers: varixo

Merge Risk: Minimal · up to 1853f

The keyed SSR text fix includes regression coverage and has no verified merge-blocking risk remaining.

Security architecture risk: Minimal · up to 1853f

The fix preserves existing rendering behavior by separating text according to its original DOM-node identity before moving keyed fragments. It introduces no new public API or markup-parsing path, and the review found no material security risk introduced or worsened by the change.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • observed — Inflation can affect text outside the moved virtual fragment because DOM-sibling traversal crosses virtual wrappers. The new identity checks restrict that effect to VNodes sharing the same SSR Text object, rather than unrelated text runs made adjacent by reconciliation.

Trust Boundaries and Controls

  • observed — Application text continues through DOM Text creation and text-update operations. The changed helpers do not send those values into markup parsing; the renderer’s dangerouslySetInnerHTML handling is a separate path.
Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title names the main change: keyed-diff handling of SSR-shared text when siblings are reordered or replaced. The changeset summarizes a keyed-list fix that prevents duplicated or lost SSR-shared t…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names the main change: keyed-diff handling of SSR-shared text when siblings are reordered or replaced. The changeset summarizes a keyed-list fix that prevents duplicated or lost SSR-shared text in keyed fragments. The vnode-utils.ts changes limit text inflation to vnodes that still reference the same SSR Text node, and inflate moved virtual node text before unlinking. The regression tests cover keyed lists with adjacent fragments. The title therefore matches the changeset.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 1 functions across 2 files.

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a17eb04b-dc1f-4b5b-b561-3dfcb1e29928
Commits

Reviewing files that changed from the base of the PR and between 1ad577e and 1853fee.

Files selected for processing: 1
  • packages/qwik/src/core/client/vnode-utils.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@qwik.dev/core

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@9368

@qwik.dev/router

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@9368

@qwik.dev/utils

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/utils@9368

eslint-plugin-qwik

npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@9368

create-qwik

npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@9368

@qwik.dev/optimizer

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/optimizer@9368

@qwik.dev/devtools

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/devtools@9368

@qwik.dev/mcp

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/mcp@9368

commit: 1853fee

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix comments: Fix CodeRabbit comments on this PR

omerman added a commit to omerman/qwik that referenced this pull request Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

⚡ Docs preview for acd4b11

@Varixo Varixo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey, instead we can use exisitng walkDirectChildren:

// Stop both sibling walks at the boundary of the shared SSR text.
while (isText(cursor) && cursor.node === sharedTextNode) {
  const next = nextSibling(cursor);
  const isLast = !isText(next) || next.node !== sharedTextNode;
  // Existing text-splitting logic.
  cursor = next;
}

// Make the callback optional.
walkDirectChildren(journal, parent, callback?) {
  // Existing traversal and text inflation.
  callback?.(child);
}

// Before unlinking:
walkDirectChildren(journal, newChild);
unlink(newChild);

…replaces siblings

After resume, the text of adjacent keyed fragments shares one SSR Text node and is split lazily by
vnode_ensureTextInflated. A keyed diff could corrupt it:
- removing the element that separated two SSR text runs made the split walk across both runs
  (duplicated or overwritten text);
- moving a keyed fragment unlinked it before its text was split, so its last text vnode took over
  the shared node and the other fragment's text was lost.

vnode_ensureTextInflated now only treats text vnodes that still point at the same SSR Text node as
one run, and vnode_insertVirtualBefore splits a moved virtual's text before unlinking it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omerman
omerman force-pushed the fix/keyed-shared-text-split branch from acd4b11 to 1ad577e Compare November 3, 2026 20:32
Both sibling walks in vnode_ensureTextInflated now stop at the first vnode that
no longer points at the shared SSR Text node, which also removes the quadratic
lookahead. The vnode_walkDirectChildren callback is optional for callers that
only need the text inflated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Varixo Varixo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Varixo
Varixo enabled auto-merge November 3, 2026 21:03
@Varixo
Varixo merged commit 11765ab into QwikDev:main Nov 3, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[🐞] v2: keyed list re-render after resume duplicates or drops text of keyed fragments (SSR-shared Text node)

2 participants