Repository navigation
fix(router): do not reload for a chunk cancelled while the page unloads #8830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ? <div>Error</div> : this.props.children | ||
| } | ||
| } | ||
|
|
||
| function renderLazy(Page: ReturnType<typeof lazyRouteComponent>) { | ||
| return render( | ||
| <ErrorBoundary> | ||
| <React.Suspense fallback={<div>Loading</div>}> | ||
| <Page /> | ||
| </React.Suspense> | ||
| </ErrorBoundary>, | ||
| ) | ||
| } | ||
|
|
||
| test('does not reload when the import is cancelled because the page is unloading', async () => { | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}) | ||
| const first = createControlledPromise<never>() | ||
| const importer = vi | ||
| .fn() | ||
| .mockReturnValueOnce(first) | ||
| .mockResolvedValueOnce({ default: () => <div>Loaded</div> }) | ||
| 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<never>() | ||
| 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') | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<never>() | ||
| const importer = vi | ||
| .fn() | ||
| .mockReturnValueOnce(first) | ||
| .mockResolvedValueOnce({ default: () => <div>Loaded</div> }) | ||
| 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. | ||
| 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') | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<any>(comp ? Vue.markRaw(comp) : comp) | ||
| const errorState = Vue.ref<any>(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<any>(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()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| .then((result) => { | ||
| // Use markRaw to prevent Vue from making the component reactive | ||
| component.value = result ? Vue.markRaw(result) : result | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: TanStack/router
Length of output: 30584
Subscribe to stay events when unload cancellation is classified.
waitForPageToStay()adds its listeners only when the lazy component enters the cancelled-by-unload render path. Ifpageshowfires after the import failure is classified but before that path runs, no listener observes it. The component can then remain suspended until a laterpointerdownorkeydown.Start the stay listener when the failure is classified, or record the stay event at that point and reuse it during rendering. Add a regression for
pageshowin this interval.