Skip to content

fix(web): refuse an async memo that resolves to an element (#4242) - #4255

Merged
ryansolid merged 1 commit into
nextfrom
claude/async-memo-element-4242
Oct 24, 2026
Merged

ryansolid merged 1 commit into
nextfrom
claude/async-memo-element-4242

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Before: on the server, an async memo whose value was rendered output broke hydration. Rendered output here means JSX, a component's result, or a <Loading>/<Errored> boundary. A boundary's output failed in seroval (SerovalUnsupportedTypeError), so its hydration record never resolved and the client's memo waited forever. When streamed, the boundary showed the server content and then reverted to its fallback for good. A plain template object crossed as data instead: the markup was duplicated, and when streamed the client hit UNRECOGNIZED_INSERT_VALUE. This is what remained of #4242 after #4246.

After: the memo rejects where its value lands, with the new diagnostic ASYNC_MEMO_ELEMENT. Its boundary contains the error like any other render error: the nearest <Errored> renders its fallback, and the client hydrates that. The dev message says what to do instead: return data from the async memo and render the JSX from it.

How:

  • refuseRenderedLanding in packages/solid/src/server/signals.ts runs where a serialized async value lands: promise values, and the first value of async iterables (all three paths).
  • settleServerAsync now turns a throwing landing into the memo's error. Before, it was an unobserved rejection that left the deferred pending.
  • Detection only inspects own-Object.prototype objects, arrays of them, and functions marked $lhSkip. Class instances, Dates, Maps, strings and other data are never refused, and neither is content under <NoHydration> or serialize: false.
  • One known edge: a data object that is exactly { t: "<…" } (a single key holding markup) matches a settled template object. Closing that would need a brand on the hot ssr() path, so this PR doesn't add one.
  • abandonSubtree in renderToStream skips registry keys that are undefined. A boundary created inside an async memo's continuation registers without an owner id, and that turned one fragment's failure into a whole-request failure.
  • The diagnostic is registered like DYNAMIC_ASYNC_COMPONENT: a dev/prod ternary, an observe-channel finding, the RFC-08 table and SKILL.md. diagnostics.json is regenerated. The @solidjs/signals change is type-only.

All the new code is server-only; the client bundle is unchanged.

Not covered: later yields of an async iterable (only the first value is checked), and the separate undefined ids of boundaries created inside a continuation.

Research write-up: options measured, including why sending undefined or handing off to the client doesn't work when streamed.

How did you test this change?

  • New harness and specs test/{harness,server,hydration}/async-memo-element-4242* cover two shapes: the issue's nested boundary and plain JSX. Each runs with and without <Errored>, settles before the shell or streamed, and is replayed both loaded and streamed.
    • Server: the refusal is the only error, <Errored> catches it when present, there is no seroval error and no markup sent as data, and the request never fails as a whole.
    • Hydration with <Errored>: the fallback hydrates and there's no halt.
    • Without <Errored>: each case matches a twin whose memo throws a plain Error.
    • Data controls (object, single-key { t: "plain text" }, array, a markup string, a Date) serialize and are adopted without recomputing.
  • On next without the fix, all 12 element tests fail on the server side and 12 of 15 hydration tests fail (the data controls pass).
  • stream-batch-serialize-failure-4242.spec.tsx (fix(web): one value that fails to serialize no longer strands the pre-shell hydration stubs (#4242) #4246) now expects the outer _fr to reject with the refusal and the inner one to complete.
  • Suites:
    • web server: 1811 passed. The 2 failures are the same as on next: the env-only server-functions-adapter-request "rebuild failure", and lone-spread-hydration-key-4240 against a stale local compiler binary.
    • web client: the only failures are the known env-only dist-nested-manifests and uncommitted-input-overwritten files.
    • hydrate: 535 passed.
    • solid: 893 passed.
    • Typecheck and prettier are clean.

Generated by Claude Code

On the server, an async memo (or async iterable source) whose value is
rendered output was handed to the hydration serializer: a boundary's
output (`$lhSkip` accessor) failed in seroval, leaving its record
pending so the hydrating client waited on the memo forever (streamed,
the boundary reverted to its fallback), and an SSR template object
crossed as data (`{t:"<p…>"}`), duplicating the markup and, streamed,
hitting UNRECOGNIZED_INSERT_VALUE on the client.

The memo now rejects where its value lands, before the serializer sees
it, following dynamic()'s DYNAMIC_ASYNC_COMPONENT refusal: new
diagnostic ASYNC_MEMO_ELEMENT (finding on the observe channel with the
memo's owner path; full guidance in dev, the code in prod). The error
takes the normal boundary road: an <Errored> renders its fallback and
the client hydrates it; with none the <Loading> fragment rejects and the
client renders the subtree, exactly as for any other server render
error there.

- Detection (server only, only where the memo serializes): a function
  carrying `$lhSkip`, a plain object in the SSRTemplateObject shape (a
  lone `t` holding markup, or `t`/`h`/`p` arrays), or a fragment array
  holding either. Class instances, Dates, objects with other keys or
  with a text `t`, and strings are never refused; <NoHydration> content
  is untouched.
- Covers a thenable's landing and an async iterable's first value
  (direct, hybrid, and a promise resolving to an iterable).
- settleServerAsync: a landing onSuccess refuses settles as the node's
  error instead of an unobserved rejection that left the deferred
  pending.
- renderToStream abandonSubtree: skip undefined fragment keys (a
  boundary created in an async memo's continuation), which otherwise
  turned the refusal into a whole-request failure.

Tests: the issue's nested-boundary shape and a plain JSX element, with
and without <Errored>, pre-shell and streamed, server and hydration
halves (uncontained cases compared against a plain-Error twin), plus
async memos resolving to ordinary data (object, array, string, Date)
that still serialize and are adopted without recompute. The #4246
batch test now expects the outer boundary to reject with the refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@changeset-bot

changeset-bot Bot commented Oct 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4a732da

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

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

@github-actions

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B −79 B 7.44 KB ✅
signals: + createStore 14.97 KB 0 B 0 B −930 B 15.04 KB ✅
signals: + isPending/latest 10.30 KB 0 B 0 B −83 B 10.30 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.06 KB 0 B 0 B −85 B 8.05 KB ⚠️ over by 5.9999999999990905 B, 105 B minified headroom
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B −84 B 9.75 KB ⚠️ over by 35 B, 104 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.43 KB 0 B 0 B −93 B 18.38 KB ⚠️ over by 45 B, 113 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.68 KB 0 B 0 B −949 B 30.66 KB ⚠️ over by 21 B, 969 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.38 KB 0 B 0 B −78 B 13.33 KB ⚠️ over by 51 B, 98 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.09 KB 0 B 0 B −79 B 15.08 KB ⚠️ over by 12 B, 99 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.68 KB 0 B 0 B −75 B 29.64 KB ⚠️ over by 40 B, 95 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B −83 B 9.93 KB ⚠️ over by 32 B, 103 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.24 KB 0 B 0 B −923 B 26.23 KB ⚠️ over by 6 B, 943 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.23 KB 0 B 0 B −946 B 32.23 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.33 KB 0 B 0 B −83 B 33.27 KB ⚠️ over by 56 B, 103 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.58 KB 0 B 0 B −93 B 37.64 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.04 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.33 KB 0 B 0 B −87 B 35.30 KB ⚠️ over by 29 B, 107 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.08 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.49 KB 0 B 0 B −91 B 41.47 KB ⚠️ over by 21 B, 111 B minified headroom eager (counted): web.js 22.12 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 9.08 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.81 KB 0 B 0 B −89 B 40.78 KB ⚠️ over by 29 B, 109 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.09 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.00 KB 0 B 0 B −99 B 47.01 KB ✅ eager (counted): client.js 27.16 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.32 KB, trace.js 9.09 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: + until/refresh (truth readers, no optimism): over brotli cap by 5.9999999999990905 B; minified 21,861 B vs 21,946 B recorded with the cap (−85 B) — 105 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: render + one signal (the simple-app floor): over brotli cap by 35 B; minified 26,945 B vs 27,029 B recorded with the cap (−84 B) — 104 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 45 B; minified 53,505 B vs 53,598 B recorded with the cap (−93 B) — 113 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 21 B; minified 94,888 B vs 95,837 B recorded with the cap (−949 B) — 969 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 51 B; minified 37,174 B vs 37,252 B recorded with the cap (−78 B) — 98 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 12 B; minified 42,130 B vs 42,209 B recorded with the cap (−79 B) — 99 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 40 B; minified 88,452 B vs 88,527 B recorded with the cap (−75 B) — 95 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 32 B; minified 27,477 B vs 27,560 B recorded with the cap (−83 B) — 103 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 6 B; minified 80,404 B vs 81,327 B recorded with the cap (−923 B) — 943 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 56 B; minified 101,443 B vs 101,526 B recorded with the cap (−83 B) — 103 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 29 B; minified 107,789 B vs 107,876 B recorded with the cap (−87 B) — 107 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 21 B; minified 123,029 B vs 123,120 B recorded with the cap (−91 B) — 111 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 29 B; minified 125,275 B vs 125,364 B recorded with the cap (−89 B) — 109 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 40074372338

Coverage remained the same at 77.764%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1358
Covered Lines: 1114
Line Coverage: 82.03%
Relevant Branches: 1066
Covered Branches: 771
Branch Coverage: 72.33%
Branches in Coverage %: Yes
Coverage Strength: 27.6 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 24, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 7.22%

2 warnings

⚠️ 3 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 195 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 34 ms 31.8 ms +7.22%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/async-memo-element-4242 (4a732da) with next (253561e)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid merged commit 2a0da2f into next Oct 24, 2026
8 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.

3 participants