Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-route-component-reload-reentry.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/react-router/src/lazyRouteComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function lazyRouteComponent<
let loadPromise: Promise<any> | undefined
let comp: T[TKey] | T['default']
let error: any
let reloadRequested = false

const load = () => {
if (!loadPromise) {
Expand All @@ -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.
Expand All @@ -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(() => {})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { lazyRouteComponent } from '../src'

let reload: ReturnType<typeof vi.fn>
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()
})