Skip to content

fix(compiler): align lone spread hydration ids - #3105

Closed
kavhnr wants to merge 2 commits into
solidjs:nextfrom
kavhnr:fix/hydratable-lone-spread-ids
Closed

fix(compiler): align lone spread hydration ids#3105
kavhnr wants to merge 2 commits into
solidjs:nextfrom
kavhnr:fix/hydratable-lone-spread-ids

Conversation

@kavhnr

@kavhnr kavhnr commented Aug 29, 2026

Copy link
Copy Markdown

Summary

A hydratable reactive lone spread used different compiler paths on the server and the client.

Minimal case:

const attrs = () => ({ class: "example" });

<div {...attrs()}>
  <span />
</div>;

The DOM compiler sends the reactive spread through mergeProps. A function source makes mergeProps create a memo, and that memo uses a hydration child ID. The SSR lone-spread fast path sent attrs() directly, so the server did not create the same memo. The old server element IDs were 0 and 1, while the client expected 0 and 2.

This change sends a hydratable reactive lone spread through the normal SSR mergeProps path. The existing props thunk lets ssrElement allocate the element key before it evaluates the merge. The order is now element 0, merge memo 1, and following element 2, which matches the client.

The direct lone-spread path is still used when SSR is not hydratable. It is also still used when the spread expression is static or has an @static marker. Thus, the change does not add a merge cost to those paths.

The Babel and native compilers use the same rule. The compiler tests check both outputs. A shared web harness scenario also renders and hydrates the case with each compiler. It checks the server keys, hydration warnings, DOM node reuse, and an update after hydration.

Related work:

A patch changeset is included for @solidjs/babel-plugin and @solidjs/compiler.

How did you test this change?

All listed commands passed.

  • pnpm install --frozen-lockfile
  • pnpm --filter @solidjs/compiler exec vitest run __tests__/spread-hydration.test.js — 1 file, 10 tests
  • pnpm --filter @solidjs/babel-plugin test — 20 files, 153 tests
  • pnpm --filter @solidjs/compiler test — Rust tests in both feature modes; 29 Vitest files, 4,328 tests
  • pnpm --filter @solidjs/compiler lint — Clippy passed with warnings denied
  • pnpm --filter @solidjs/web test — 58 client files, 41 server files, and 24 hydration files; 1,217 tests passed and 2 skipped
  • JSX_COMPILER=babel pnpm --filter @solidjs/web test — the same 1,217 tests passed and 2 skipped
  • pnpm build — 24 tasks passed
  • pnpm typecheck — 15 tasks passed
  • pnpm test — 32 tasks passed
  • pnpm test:integration — 12 tasks passed, including imports and packaged types
  • pnpm exec prettier --check packages/babel-plugin/src/ssr/element.ts packages/compiler/__tests__/spread-hydration.test.js packages/web/test/harness/scenarios.tsx packages/web/test/server/spread-hydration.spec.tsx .changeset/fix-lone-spread-hydration-ids.md
  • rustfmt --edition 2024 --check packages/compiler/src/ssr/transform.rs
  • git diff --check

The full repository cargo fmt --check also reports existing format differences in packages/compiler/src/directives/transform.rs and packages/compiler/src/refresh/transform.rs. This pull request does not change those files. The changed Rust file passes its file-scoped format check.

@changeset-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 748db2f

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
test-integration Patch
solid-js Patch
@solidjs/web Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/universal Patch
@solidjs/element Patch
@solidjs/signals Patch
@solidjs/diagnostics 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

ryansolid added a commit that referenced this pull request Aug 30, 2026
…ough — no merge (#3105)

Merging one source is pure overhead, and the mergeProps memo consumed a
hydration id the SSR fast path never allocated, so every element after a
reactive lone spread went unclaimed. Both DOM generates now hand the
accessor straight to spread(), which resolves a function props source
inside its own tracking scopes (the Solid 1 shape). Server output is
untouched; the ids agree because neither side mints anything.

Supersedes the PR's server-side approach (deferring a merge until after
the element key): the client is the side doing unnecessary work.

The universal generate keeps its merge: it has no hydration ids to
drift, and its condition-memo insulation for arbitrarily expensive
custom-renderer props is a documented trade-off.

New hydration-parity harness scenario pins consecutive ids across the
spread (div _hk=0, button _hk=1) end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Thanks for the diagnosis here — the id drift and the root cause (the client-side mergeProps memo consuming a hydration id the server's fast path never allocates) were exactly right, and the reproduction scenario was very useful.

We ended up landing the inverse fix in 5230666: rather than teaching the hydratable SSR output to defer a merge until after the element key, the client stops merging entirely. A lone spread has nothing to merge — both DOM generates now pass the accessor straight to spread(), which resolves a function props source inside its own tracking scopes (the Solid 1 shape). No memo is minted on either side, so the ids agree, the server output stays untouched, and every lone spread gets cheaper rather than every hydratable one getting more expensive.

Your harness scenario lives on in the fix (with the button now claiming _hk=1 — no reserved gap). Closing as superseded; thanks again for the report and the careful analysis.

@ryansolid ryansolid closed this Aug 30, 2026
ryansolid added a commit that referenced this pull request Aug 31, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Aug 31, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 1, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 1, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 1, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 1, 2026
…tics, combined budgets

-X theirs clobbered upstream's contradicted-landing gate and retained-
edit drop() in optimistic.ts (caught by upstream's own suites); the file
is reset to next's version with only the branch's three emission-gate
changes re-applied (diff verified minimal). Native compiler binary
rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge —
hydrate parity caught it). Four size tiers ratcheted for upstream drift
stacking with branch bytes; treeshake core floor merged at 21.7k.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants