Repository navigation
[2.0 rc.14 / next] Errored fallback renders twice and is re-created on a failed retry when a <Show> reads the failing memo (rc.14: endless loop with a second reader) #3991
Description
Describe the bug
When an <Errored> subtree reads a failing async memo through a <Show>, the error fallback is rendered more than once for one failure. When a reset() is followed by a second failure, the fallback is torn down and created again. On 2.0.0-rc.13 the fallback renders once and the same fallback DOM stays in place through the retry.
The re-creation is visible to users: focus on the Retry button is lost, and it lands on <body>. Our admin app keeps focus on Retry after a failed retry for keyboard and screen reader users, and that stops working on rc.14.
On rc.14 there is a worse variant. If a second reader of the same memo sits under its own <Loading>/<Errored> (?outside below), the second failure never settles: the fallback is re-created in an endless synchronous loop and the main thread hangs. That variant no longer hangs on next at 84dada7. I bisected it to ad973ed (#3977). On next the fallback is still rendered twice for the first failure and still re-created on the failed retry (5 renders with ?outside).
Reading the same memo as text (<p>{data()}</p>, the ?text variant) instead of through <Show when={data() > 0}> behaves like rc.13 on every version tested. The <Show> over the failing memo is the trigger. The isPending indicator is part of the shape our app uses, so I kept it in the repro.
Your Example Website or App
Inline below: a four-file Vite app, no other dependencies. There is no hosted copy.
Steps to Reproduce the Bug or Issue
- Create the four files below and run
npm install, thennpm run dev. - Open the page in Chromium three times:
/,/?outsideand/?text. The script fails the memo, calls the fallback'sreset()while the retry is still in flight, then fails the retry and writes counters into the page. - To compare with rc.13, set
solid-js,@solidjs/weband@solidjs/signalsto2.0.0-rc.13(keep theoverridesentry:solid-js@2.0.0-rc.13depends on@solidjs/signals@^2.0.0-rc.13, which otherwise resolves to rc.14) and@solidjs/vite-pluginto3.0.0-next.47.
package.json
{
"name": "errored-retry-repro",
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"solid-js": "2.0.0-rc.14",
"@solidjs/web": "2.0.0-rc.14",
"@solidjs/signals": "2.0.0-rc.14"
},
"devDependencies": {
"vite": "8.3.1",
"@solidjs/vite-plugin": "3.0.0-next.49"
},
"overrides": {
"@solidjs/signals": "2.0.0-rc.14"
}
}vite.config.ts
import { defineConfig } from 'vite'
import solid from '@solidjs/vite-plugin'
export default defineConfig({ plugins: [solid()] })index.html
<!doctype html>
<html>
<body>
<div id="app"></div>
<pre id="log"></pre>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>src/main.tsx
import { render } from '@solidjs/web'
import { createMemo, Errored, isPending, Loading, Show } from 'solid-js'
// `?outside` adds a second reader of the same memo under its own boundary.
// `?text` reads the memo as text instead of through `<Show>`.
const params = new URLSearchParams(location.search)
const outside = params.has('outside')
const asText = params.has('text')
const log = (line: string) => {
document.getElementById('log')!.textContent += line + '\n'
}
let calls = 0
let fallbacks = 0
let reset!: () => void
const second = Promise.withResolvers<never>()
function App() {
const data = createMemo(async (): Promise<number> => {
calls++
if (calls === 1) throw new Error('first failure')
return second.promise // the retry is still in flight when it fails again
})
return (
<>
{outside && (
<Loading>
<Errored fallback={null}>
<Show when={data()}>
<span>outside</span>
</Show>
</Errored>
</Loading>
)}
<Loading>
<Errored
fallback={(_error, r) => {
fallbacks++
reset = r
return <button type="button">Retry</button>
}}
>
<Show when={isPending(() => data())}>
<p>updating</p>
</Show>
{asText ? (
<p>{data()}</p>
) : (
<Show when={data() > 0}>
<p>content</p>
</Show>
)}
</Errored>
</Loading>
</>
)
}
render(() => <App />, document.getElementById('app')!)
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
await wait(100)
const first = document.querySelector('button')
log(`after first failure: fallback rendered ${fallbacks}x`)
reset()
await wait(100)
log(`after reset(): memo ran ${calls}x`)
second.reject(new Error('second failure'))
await wait(500)
log(
`after second failure: fallback rendered ${fallbacks}x, ` +
`same Retry button: ${document.querySelector('button') === first}`,
)Expected behavior
Each failure renders the fallback once. A reset() whose retry fails again leaves the same fallback in place, as on rc.13:
| page | rc.13 | rc.14 | next (84dada7) |
|---|---|---|---|
/ |
1 render, then 1; same button | 2 renders, then 3; new button | 2 renders, then 3; new button |
/?outside |
1 render, then 1; same button | 2 renders, then endless loop (tab hangs) | 2 renders, then 5; new button |
/?text |
1 render, then 1; same button | 1 render, then 1; same button | 1 render, then 1; same button |
"N renders, then M" means the fallback callback count after the first failure, then after the failed retry. "Same button" means document.querySelector('button') is the element that existed before reset().
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chromium (Playwright 1.64.0, headless)
- Version: solid-js / @solidjs/web / @solidjs/signals 2.0.0-rc.14 (also a local build of
nextat 84dada7); Vite 8.3.1; Node 26.10.0
Additional context
- The hang looks related to [2.0 rc.14 / next] Errored derived store re-fetches in an endless loop when a row binding also reads
isPending()#3945 (an errored derived store refetching endlessly when a row also readsisPending()), here with a memo and<Show>. fix(signals): carve async toward the F/D rules; redo #3937, #3954, #3895 #3977 fixes the hang but not the extra fallback renders. - I could not find a statement in the docs on whether the fallback may be re-created across
reset(). Two things suggest the remaining behaviour is not intended: the outcome depends on whether the memo is read through<Show>or as text, and the first failure alone renders the fallback twice.
Activity
fix(signals): a boundary's fallback is instantiated once per show (#3992
ryansolid commentedon Oct 12, 2026
Generated by Claude Code