Skip to content

[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

Closed

Description

@frenzzy

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

  1. Create the four files below and run npm install, then npm run dev.
  2. Open the page in Chromium three times: /, /?outside and /?text. The script fails the memo, calls the fallback's reset() while the retry is still in flight, then fails the retry and writes counters into the page.
  3. To compare with rc.13, set solid-js, @solidjs/web and @solidjs/signals to 2.0.0-rc.13 (keep the overrides entry: solid-js@2.0.0-rc.13 depends on @solidjs/signals@^2.0.0-rc.13, which otherwise resolves to rc.14) and @solidjs/vite-plugin to 3.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 next at 84dada7); Vite 8.3.1; Node 26.10.0

Additional context

Activity

  1. ryansolid commented on Oct 12, 2026

    @ryansolid
    Member

    Fixed by #3992, merged into next as 49a765c. A boundary's fallback is now created once per show and disposed only once the content replacing it is on screen, so a <Show> reading the failing memo no longer renders it twice or re-creates it on a failed retry (the second-reader loop included). <Loading> fallbacks get the same treatment.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions