Skip to content

test: failing test for hydration race when a subscriber exists before RouterProvider mounts - #15308

Open
marlass wants to merge 2 commits into
remix-run:mainfrom
marlass:hydration-race-external-subscriber
Open

test: failing test for hydration race when a subscriber exists before RouterProvider mounts#15308
marlass wants to merge 2 commits into
remix-run:mainfrom
marlass:hydration-race-external-subscriber

Conversation

@marlass

@marlass marlass commented Jul 13, 2026

Copy link
Copy Markdown

Same scenario as the existing race-condition regression test, plus a single router.subscribe(() => {}) call before RouterProvider mounts, which is what Sentry's wrapCreateBrowserRouterV7 instrumentation does at router creation. Because bufferedInitialStateUpdate is only armed when updateState runs with zero subscribers, RouterProvider's layout-effect subscription gets no replay and the app renders the HydrateFallback forever.

The bug from #14356 is back in 7.16.0+ and 8.x, with one extra condition: a subscriber attached to the router before RouterProvider mounts. The most common real-world source is Sentry — wrapCreateBrowserRouterV7 calls router.subscribe(...) at router creation. Combined with anything suspending above RouterProvider (code-split shell, i18n bundle), fast loaders / cached route.lazy chunks resolve before RouterProvider's subscribe() layout effect runs, the initialization update is lost, and the app renders the HydrateFallback forever. Fast responses fail, slow ones work, so it presents as an intermittent hang that is worst on warm caches.

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

// Something suspends above RouterProvider and resolves at ~100ms
// our app use case: loading translations
const suspensePromise = sleep(100)

const router = createBrowserRouter([
  {
    path: '*',
    // Hangs when this lands ~120-300ms after mount; 800ms works
    loader: () => sleep(200).then(() => 'LOADER_DATA'),
    Component: () => <p>Data: {useLoaderData()}</p>,
    HydrateFallback: () => <p>Hydrating... (stuck here forever)</p>,
  },
])

// THE TRIGGER: what Sentry's wrapCreateBrowserRouter does at creation.
// Comment this line out and the app renders fine.
router.subscribe(() => {})

function App() {
  use(suspensePromise)
  return <RouterProvider router={router} />
}

createRoot(document.getElementById('root')).render(
  <Suspense fallback={<p>Loading...</p>}>
    <App />
  </Suspense>
)

marlass added 2 commits July 13, 2026 10:04
… RouterProvider mounts

Same scenario as the existing race-condition regression test, plus a
single router.subscribe(() => {}) call before RouterProvider mounts,
which is what Sentry's wrapCreateBrowserRouterV7 instrumentation does at
router creation. Because bufferedInitialStateUpdate is only armed when
updateState runs with zero subscribers, RouterProvider's layout-effect
subscription gets no replay and the app renders the HydrateFallback
forever.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants