From 936a107f5f5c3f5040e72c5ce7172b5dbbb4e42d Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Wed, 28 Oct 2026 12:40:23 +0000 Subject: [PATCH] fix(router): do not reload for a chunk cancelled while the page unloads Firefox and Safari cancel a pending dynamic import when the page starts to unload, with the same message as a missing module. lazyRouteComponent took that as a stale deploy and reloaded, replacing the navigation the user had just started. Watch for beforeunload while an import is pending, wait instead of reloading when it fired, and retry the import if the page stays. Fixes #8827 --- .changeset/lazy-chunk-unload-no-reload.md | 8 ++ .../react-router/src/lazyRouteComponent.tsx | 23 +++- .../tests/lazy-route-chunk-unload.test.tsx | 108 ++++++++++++++++++ packages/router-core/src/index.ts | 2 + packages/router-core/src/utils.ts | 42 +++++++ .../solid-router/src/lazyRouteComponent.tsx | 23 +++- .../tests/lazy-route-chunk-unload.test.tsx | 49 ++++++++ .../vue-router/src/lazyRouteComponent.tsx | 15 ++- .../tests/lazy-route-chunk-unload.test.tsx | 49 ++++++++ 9 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 .changeset/lazy-chunk-unload-no-reload.md create mode 100644 packages/react-router/tests/lazy-route-chunk-unload.test.tsx create mode 100644 packages/solid-router/tests/lazy-route-chunk-unload.test.tsx create mode 100644 packages/vue-router/tests/lazy-route-chunk-unload.test.tsx diff --git a/.changeset/lazy-chunk-unload-no-reload.md b/.changeset/lazy-chunk-unload-no-reload.md new file mode 100644 index 00000000000..8eadefd9fda --- /dev/null +++ b/.changeset/lazy-chunk-unload-no-reload.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Do not reload the page when Firefox or Safari cancel a pending route chunk because the user is leaving the page. The cancellation has the same message as a missing chunk, and the stale-deploy reload replaced the user's navigation. If the page stays after all, the chunk import is retried. diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index a7b58c44991..12787711800 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -1,5 +1,9 @@ import * as React from 'react' -import { isModuleNotFoundError } from '@tanstack/router-core' +import { + isModuleNotFoundError, + waitForPageToStay, + watchPageUnload, +} from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { reactUse } from './utils' import type { AsyncRouteComponent } from './route' @@ -25,12 +29,17 @@ export function lazyRouteComponent< let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any + let cancelledByUnload = false + let staying: Promise | undefined const load = () => { if (!loadPromise) { error = undefined + cancelledByUnload = false + const stopWatchingUnload = watchPageUnload() loadPromise = importer() .then((res) => { + stopWatchingUnload() // Resolved clients have no preload work; SSR can reuse the import. if (!(isServer ?? typeof window === 'undefined')) { loadPromise = undefined @@ -40,6 +49,7 @@ export function lazyRouteComponent< }) .catch((err) => { loadPromise = undefined + cancelledByUnload = stopWatchingUnload() && isModuleNotFoundError(err) // We don't want an error thrown from preload in this case, because // there's nothing we want to do about module not found during preload. // Record the error, the rest is handled during the render path. @@ -51,6 +61,17 @@ export function lazyRouteComponent< } const lazyComp = function Lazy(props: any) { if (error) { + // The browser cancelled the import because the page is unloading, so a + // reload would replace the navigation the user started. If the page + // stays after all, retry the import instead. + if (cancelledByUnload) { + staying ??= waitForPageToStay().then(() => { + staying = undefined + error = undefined + cancelledByUnload = false + }) + throw staying + } // 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. diff --git a/packages/react-router/tests/lazy-route-chunk-unload.test.tsx b/packages/react-router/tests/lazy-route-chunk-unload.test.tsx new file mode 100644 index 00000000000..3b8b3ef9f59 --- /dev/null +++ b/packages/react-router/tests/lazy-route-chunk-unload.test.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { lazyRouteComponent } from '../src' + +// https://github.com/TanStack/router/issues/8827 +const message = + 'error loading dynamically imported module: http://localhost/assets/page.js' +const reloadKey = `tanstack_router_reload:${message}` + +afterEach(() => { + cleanup() + sessionStorage.clear() + vi.restoreAllMocks() +}) + +// jsdom cannot reload, so React retries the render and reaches the error that +// the reload guard rethrows once its key is set. +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: unknown } +> { + state: { error: unknown } = { error: undefined } + static getDerivedStateFromError(error: unknown) { + return { error } + } + render() { + return this.state.error ?
Error
: this.props.children + } +} + +function renderLazy(Page: ReturnType) { + return render( + + Loading}> + + + , + ) +} + +test('does not reload when the import is cancelled because the page is unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = createControlledPromise() + const importer = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce({ default: () =>
Loaded
}) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload!() + window.dispatchEvent(new Event('beforeunload')) + first.reject(new TypeError(message)) + await preload + + renderLazy(Page) + expect(screen.getByText('Loading')).toBeInTheDocument() + expect(sessionStorage.getItem(reloadKey)).toBeNull() + + // The page stayed after all, so the import is retried. + fireEvent.pointerDown(document.body) + expect(await screen.findByText('Loaded')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(sessionStorage.getItem(reloadKey)).toBeNull() +}) + +test('reloads once if the retried import is still missing after the page stays', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = createControlledPromise() + const importer = vi + .fn() + .mockReturnValueOnce(first) + .mockRejectedValueOnce(new TypeError(message)) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload!() + window.dispatchEvent(new Event('beforeunload')) + first.reject(new TypeError(message)) + await preload + + renderLazy(Page) + fireEvent.keyDown(document.body) + await vi.waitFor(() => expect(sessionStorage.getItem(reloadKey)).toBe('1')) + expect(importer).toHaveBeenCalledTimes(2) +}) + +test('still reloads once for a missing module when the page is not unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const Page = lazyRouteComponent(() => Promise.reject(new TypeError(message))) + + await Page.preload!() + renderLazy(Page) + + expect(sessionStorage.getItem(reloadKey)).toBe('1') +}) + +test('stops watching for unload once the import settles', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const Page = lazyRouteComponent(() => Promise.reject(new TypeError(message))) + + await Page.preload!() + // A later unload must not be attributed to the import that already failed. + window.dispatchEvent(new Event('beforeunload')) + renderLazy(Page) + + expect(sessionStorage.getItem(reloadKey)).toBe('1') +}) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 9206ef60dd8..2bd981f229c 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -322,6 +322,8 @@ export { createControlledPromise, isPromise, isModuleNotFoundError, + watchPageUnload, + waitForPageToStay, DEFAULT_PROTOCOL_ALLOWLIST, escapeHtml, getUrlScheme, diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 63aa5186953..e86e4af39f1 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -516,6 +516,48 @@ export function isModuleNotFoundError(error: any): boolean { ) } +/** + * Firefox and Safari cancel a pending dynamic import when the page starts to + * unload, and reject it with the same message as a missing module. Call this + * before an import, and the returned function after it settles: it stops + * watching and reports whether the page began unloading in between, in which + * case the failure says nothing about a stale deployment. + */ +export function watchPageUnload(): () => boolean { + if (typeof window === 'undefined') { + return () => false + } + let unloading = false + const onBeforeUnload = () => { + unloading = true + } + window.addEventListener('beforeunload', onBeforeUnload) + return () => { + window.removeEventListener('beforeunload', onBeforeUnload) + return unloading + } +} + +/** + * Resolves on the first sign that the page is still in use after + * `beforeunload`. Some browsers fire it for `mailto:` or download links without + * leaving, and a page restored from the back/forward cache comes back too. + */ +export function waitForPageToStay(): Promise { + return new Promise((resolve) => { + const events = ['pageshow', 'pointerdown', 'keydown'] as const + const stay = () => { + for (const event of events) { + window.removeEventListener(event, stay, true) + } + resolve() + } + for (const event of events) { + window.addEventListener(event, stay, true) + } + }) +} + export function isPromise( value: Promise> | T, ): value is Promise> { diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index 32d4f071102..0c766b6dced 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -1,6 +1,10 @@ import { Dynamic } from 'solid-js/web' import { lazy } from 'solid-js' -import { isModuleNotFoundError } from '@tanstack/router-core' +import { + isModuleNotFoundError, + waitForPageToStay, + watchPageUnload, +} from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import type { AsyncRouteComponent } from './route' @@ -16,12 +20,16 @@ export function lazyRouteComponent< let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any + let cancelledByUnload = false const load = () => { if (!loadPromise) { error = undefined + cancelledByUnload = false + const stopWatchingUnload = watchPageUnload() loadPromise = importer() .then((res) => { + stopWatchingUnload() // Resolved clients have no preload work; SSR can reuse the import. if (!(isServer ?? typeof window === 'undefined')) { loadPromise = undefined @@ -32,6 +40,7 @@ export function lazyRouteComponent< }) .catch((err) => { loadPromise = undefined + cancelledByUnload = stopWatchingUnload() && isModuleNotFoundError(err) error = err }) } @@ -48,6 +57,18 @@ export function lazyRouteComponent< // Both preload and lazy resolution end here without starting another load. function render(props: any) { + // The browser cancelled the import because the page is unloading, so a + // reload would replace the navigation the user started. If the page stays + // after all, retry the import instead. + if (error && cancelledByUnload) { + const Retry = lazy(async () => { + await waitForPageToStay() + await load() + return { default: render } + }) + return + } + // Now that we're out of preload and into actual render path, // throw the error if it was a module not found error during preload if (error) { diff --git a/packages/solid-router/tests/lazy-route-chunk-unload.test.tsx b/packages/solid-router/tests/lazy-route-chunk-unload.test.tsx new file mode 100644 index 00000000000..2026e5e3aa6 --- /dev/null +++ b/packages/solid-router/tests/lazy-route-chunk-unload.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { lazyRouteComponent } from '../src' + +// https://github.com/TanStack/router/issues/8827 +const message = + 'error loading dynamically imported module: http://localhost/assets/page.js' +const reloadKey = `tanstack_router_reload:${message}` + +afterEach(() => { + cleanup() + sessionStorage.clear() + vi.restoreAllMocks() +}) + +test('does not reload when the import is cancelled because the page is unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = createControlledPromise() + const importer = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce({ default: () =>
Loaded
}) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload!() + window.dispatchEvent(new Event('beforeunload')) + first.reject(new TypeError(message)) + await preload + + render(() => ) + expect(sessionStorage.getItem(reloadKey)).toBeNull() + + // The page stayed after all, so the import is retried. + fireEvent.pointerDown(document.body) + expect(await screen.findByText('Loaded')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(sessionStorage.getItem(reloadKey)).toBeNull() +}) + +test('still reloads once for a missing module when the page is not unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const Page = lazyRouteComponent(() => Promise.reject(new TypeError(message))) + + await Page.preload!() + render(() => ) + + expect(sessionStorage.getItem(reloadKey)).toBe('1') +}) diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 3137e15ba4f..ebd82dea39e 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -1,4 +1,5 @@ import * as Vue from 'vue' +import { waitForPageToStay, watchPageUnload } from '@tanstack/router-core' import { Outlet } from './Match' import { ClientOnly } from './ClientOnly' import type { AsyncRouteComponent } from './route' @@ -34,6 +35,7 @@ export function lazyRouteComponent< let comp: T[TKey] | T['default'] | null = null let error: any = null let attemptedReload = false + let cancelledByUnload = false const load = () => { // If we're on the server and SSR is disabled for this component @@ -45,8 +47,11 @@ export function lazyRouteComponent< // Use existing promise or create new one if (!loadPromise) { error = undefined + cancelledByUnload = false + const stopWatchingUnload = watchPageUnload() loadPromise = importer() .then((res) => { + stopWatchingUnload() if (typeof document !== 'undefined') { loadPromise = undefined ;(lazyComp as any).preload = undefined @@ -57,6 +62,7 @@ export function lazyRouteComponent< .catch((err) => { error = err loadPromise = undefined + cancelledByUnload = stopWatchingUnload() && isModuleNotFoundError(err) // If it's a module not found error, we'll try to handle it in the component if (isModuleNotFoundError(error)) { @@ -76,15 +82,18 @@ export function lazyRouteComponent< // Create refs to track component state // Use shallowRef for component to avoid making it reactive (Vue warning) const component = Vue.shallowRef(comp ? Vue.markRaw(comp) : comp) - const errorState = Vue.ref(error) + // The browser cancelled the import because the page is unloading, so a + // reload would replace the navigation the user started. If the page + // stays after all, retry the import instead. + const retryAfterUnload = !!error && cancelledByUnload + const errorState = Vue.ref(retryAfterUnload ? null : error) const loading = Vue.ref(!component.value && !errorState.value) // Setup effect to load the component when this component is used Vue.onMounted(() => { if (!component.value && !errorState.value) { loading.value = true - - load() + ;(retryAfterUnload ? waitForPageToStay().then(load) : load()) .then((result) => { // Use markRaw to prevent Vue from making the component reactive component.value = result ? Vue.markRaw(result) : result diff --git a/packages/vue-router/tests/lazy-route-chunk-unload.test.tsx b/packages/vue-router/tests/lazy-route-chunk-unload.test.tsx new file mode 100644 index 00000000000..4e33f2ea7fc --- /dev/null +++ b/packages/vue-router/tests/lazy-route-chunk-unload.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { lazyRouteComponent } from '../src' + +// https://github.com/TanStack/router/issues/8827 +const message = + 'error loading dynamically imported module: http://localhost/assets/page.js' +const reloadKey = `tanstack_router_reload:${message}` + +afterEach(() => { + cleanup() + sessionStorage.clear() + vi.restoreAllMocks() +}) + +test('does not reload when the import is cancelled because the page is unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = createControlledPromise() + const importer = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce({ default: () =>
Loaded
}) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload!() + window.dispatchEvent(new Event('beforeunload')) + first.reject(new TypeError(message)) + await preload + + render(Page) + expect(sessionStorage.getItem(reloadKey)).toBeNull() + + // The page stayed after all, so the import is retried. + await fireEvent.pointerDown(document.body) + expect(await screen.findByText('Loaded')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(sessionStorage.getItem(reloadKey)).toBeNull() +}) + +test('still reloads once for a missing module when the page is not unloading', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const Page = lazyRouteComponent(() => Promise.reject(new TypeError(message))) + + await Page.preload!() + render(Page) + + expect(sessionStorage.getItem(reloadKey)).toBe('1') +})