diff --git a/.changeset/lazy-route-component-reload-reentry.md b/.changeset/lazy-route-component-reload-reentry.md new file mode 100644 index 0000000000..5866dbeab8 --- /dev/null +++ b/.changeset/lazy-route-component-reload-reentry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +`lazyRouteComponent` no longer flashes the error component while it is reloading after a stale-chunk failure. `window.location.reload()` is asynchronous, so renders can still happen before the document goes away; those renders re-read the `sessionStorage` guard, found it already set, and fell through to `throw error`. The reload request is now remembered in the closure and later renders keep suspending. The `sessionStorage` guard is unchanged, so a chunk that is missing for any reason other than a new deployment still surfaces its error on the next page load instead of reloading in a loop. diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index a7b58c4499..5fdec8ce8c 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -25,6 +25,7 @@ export function lazyRouteComponent< let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any + let reloadRequested = false const load = () => { if (!loadPromise) { @@ -51,6 +52,14 @@ export function lazyRouteComponent< } const lazyComp = function Lazy(props: any) { if (error) { + // `location.reload()` is asynchronous, so renders can still happen while + // the document is on its way out. Keep suspending on those instead of + // re-reading the guard below, which is already set and would otherwise + // fall through to `throw error` and flash the error component. + if (reloadRequested) { + throw new Promise(() => {}) + } + // A missing module can mean that a newer deployment replaced the URL. // Reload only for the error that is still current at render time, so a // successful retry cannot leave a stale reload request armed. @@ -62,6 +71,7 @@ export function lazyRouteComponent< const storageKey = `tanstack_router_reload:${error.message}` if (!sessionStorage.getItem(storageKey)) { sessionStorage.setItem(storageKey, '1') + reloadRequested = true window.location.reload() // Suspend forever while the document reloads. throw new Promise(() => {}) diff --git a/packages/react-router/tests/issue-8377-lazy-chunk-reload-reentry.test.tsx b/packages/react-router/tests/issue-8377-lazy-chunk-reload-reentry.test.tsx new file mode 100644 index 0000000000..a228906b4a --- /dev/null +++ b/packages/react-router/tests/issue-8377-lazy-chunk-reload-reentry.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { lazyRouteComponent } from '../src' + +let reload: ReturnType +let originalLocation: Location + +beforeEach(() => { + sessionStorage.clear() + reload = vi.fn() + originalLocation = window.location + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...originalLocation, reload }, + }) +}) + +afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }) + vi.restoreAllMocks() +}) + +const chunkError = () => + new TypeError( + 'Failed to fetch dynamically imported module: /assets/posts-BgDSEldj.js', + ) + +// https://github.com/TanStack/router/issues/8377 +test('#8377: renders after the reload is requested keep suspending instead of throwing', async () => { + const Lazy = lazyRouteComponent(() => Promise.reject(chunkError())) as any + + await Lazy.preload() + + // First render arms the reload and suspends. + let firstThrown: unknown + try { + Lazy({}) + } catch (thrown) { + firstThrown = thrown + } + expect(firstThrown).toBeInstanceOf(Promise) + expect(reload).toHaveBeenCalledTimes(1) + + // `location.reload()` is async, so React can render again before the + // document goes away. That render must not fall through to `throw error`. + let secondThrown: unknown + try { + Lazy({}) + } catch (thrown) { + secondThrown = thrown + } + expect(secondThrown).toBeInstanceOf(Promise) + expect(secondThrown).not.toBeInstanceOf(TypeError) + + // Still only the one reload — the sessionStorage guard is untouched. + expect(reload).toHaveBeenCalledTimes(1) +}) + +// The guard exists to stop a reload loop when the chunk is missing for some +// reason other than a new deployment. A fresh document must still surface the +// error rather than suspending forever. +test('#8377: a later page load still throws once the guard is set', async () => { + const error = chunkError() + sessionStorage.setItem(`tanstack_router_reload:${error.message}`, '1') + + const Lazy = lazyRouteComponent(() => Promise.reject(error)) as any + + await Lazy.preload() + + expect(() => Lazy({})).toThrow(error) + expect(reload).not.toHaveBeenCalled() +})