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') +})