fix(ssr): a memo joining a pending slot adopts the slot's answer - #3816
Conversation
A retry pass that re-creates an async memo while its slot is in flight hands it the shared deferred, but only the earlier memo's promise settles that deferred. The joined memo kept its NotReadyError on the resolved promise, so any memo reading it retried every microtask and the stream never ended. Fixes solidjs#3815.
🦋 Changeset detectedLatest commit: ff3b466 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
Merging this PR will improve performance by 10.05%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | memo + sync render effect only (reference) |
29.4 ms | 26.8 ms | +10.05% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing brenelz:fix/hole-retry-shared-slot-3815 (ff3b466) with next (79df376)
ryansolid
left a comment
There was a problem hiding this comment.
Thanks — the fix is right and the diagnosis matches what I see. Verified locally:
- On
next(a52615c), both<Errored>/<Loading>variants hit the 10,000-run reader guard in ~90 ms (a microtask spin); with this PR all three tests pass. I also compiled the issue's script and ran it under node: onnextit already ends in ~105 ms withPageset up once, so #3770 did remove the original trigger, as you said.adoptSlotis a faithful merge of the old copies; the async-iterable path (#3804) and theloadingValuefirst-value lock are unchanged. The new joined-thenable subscription is correctly ordered:recordSlotruns before the deferred settles, andsettleis registered before any reader in the pass subscribes, so the node adopts before readers retry. The client-hole case adopts the taggedNotReadyError, which is correct.- The #3734/#3750/retry/shared-async/slot-hydration specs all pass with the PR rebased onto current
next, and the solid, web client, server and hydrate suites are green with a freshly built compiler. (Your 47 local failures were the stale prebuilt binary.)One blocker: CI fails on
@solidjs/web#test-typesin the new spec:test/server/hole-retry-shared-slot-3815.spec.tsx(54,43): error TS2322: Type '() => JSX.Element' is not assignable to type 'Element'.
{(() => <Page />) as any}on line 54 clears it (checked locally, tests still pass).Not blocking:
- The CodSpeed
dbmon shallow full tickregression (−6.59%) is noise. That benchmark is in@solidjs/signals, which this PR doesn't touch, and #3814 shows the same drop against the same base.- Optional: a
comp.disposedearly return in the joinedsettleso superseded nodes aren't written. It's inert today, so your call.- The double write you flagged as out of scope (the joined node's own flight overwriting a settled slot with a different answer) is real and predates this PR. We'll track it separately as "first settle wins" for the slot.
— Claude via Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Summary
Fixes #3815.
When a retry pass re-creates an async memo while the slot it occupies is still loading, the new memo joins that slot and gets the slot's shared deferred as its
NotReadyErrorsource. Only the earlier memo's promise settles the deferred, and that settle writes to the earlier memo, not the new one. The new memo keeps throwingNotReadyErroron a promise that has already resolved, so any memo that reads it retries every microtask. Timers never run again, the stream never ends, and the process sits at 100% CPU.The issue's exact reproduction (a function hole that creates a component returning a pending
lazy()view) hangs on rc.13 but already ends onnext, because #3770 made the hole scope resume the suspended accessor instead of setting the component up again. The flaw inprocessResultis still onnextand can be reached another way: an<Errored>retry that re-creates a pending<Loading>(the #3750 shape), where the component makes a fresh promise on each setup and has a memo that reads it.A memo that joins a pending thenable slot now subscribes to the shared deferred and takes the slot's value or error when it settles, the same way the re-created async-iterable path already did. A served
loadingValuestill keeps the first-value lock. The three copies of the adopt code are now oneadoptSlothelper.How did you test this change?
packages/web/test/server/hole-retry-shared-slot-3815.spec.tsxcovers the<Errored>/<Loading>shape with a sync and an async reader. Both tests fail onnextwithout the fix (the reader memo hits its 10,000-run guard) and pass with it. A third test pins the issue's original hole-plus-lazy()shape.@solidjs/compiler(generate: "ssr", hydratable: true) and ran it against the published rc.13 packages with the same change applied tosolid-js/dist/server.js. It ends after 116 ms instead of spinning.cd packages/solid && npx vitest run: 819 tests pass.cd packages/web && npx vitest run --config vite.config.server.mjsand--config vite.config.hydrate.mjs: the only change against unpatchednextis the two new tests going from failing to passing. The other server and hydration failures in my environment are identical with and without the change and come from the local native compiler build. WithJSX_COMPILER=babel, the new spec and the [2.0 rc.13]<Loading>discovery never converges when a hole re-creates a component that reads its own async source during setup (routerquery()/liveQuery()included) #3734, retry-robustness and shared-async-sources specs all pass.One thing is unchanged and out of scope here: if the joined memo's own promise settles later, its success handler still overwrites the slot and its value with its own answer, which can differ from the value already serialized to the client.
🤖 Generated with Claude Code