From 2526ed8d2149ed2c19f8fc53bfafa8385e26eb0b Mon Sep 17 00:00:00 2001 From: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:56:51 +0800 Subject: [PATCH] fix(router-core): handle view transition rejections --- packages/router-core/src/router.ts | 50 ++++- .../router-core/tests/view-transition.test.ts | 184 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 packages/router-core/tests/view-transition.test.ts diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..6655463d6c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2705,7 +2705,26 @@ export class RouterCore< startViewTransitionParams = fn } - document.startViewTransition(startViewTransitionParams) + const viewTransition = document.startViewTransition( + startViewTransitionParams, + ) + const reportedErrors = new Set() + const reportErrorOnce = (error: unknown) => { + if (reportedErrors.has(error)) { + return + } + reportedErrors.add(error) + reportViewTransitionError(error) + } + const handleLifecycleRejection = (error: unknown) => { + if (!isExpectedViewTransitionRejection(error)) { + reportErrorOnce(error) + } + } + + void viewTransition.updateCallbackDone.catch(reportErrorOnce) + void viewTransition.ready.catch(handleLifecycleRejection) + void viewTransition.finished.catch(handleLifecycleRejection) } else { fn() } @@ -3026,6 +3045,35 @@ export class SearchParamError extends Error {} /** Error thrown when path parameter parsing/validation fails. */ export class PathParamError extends Error {} +function isExpectedViewTransitionRejection(error: unknown) { + const name = + typeof error === 'object' && error !== null && 'name' in error + ? error.name + : undefined + const message = + typeof error === 'object' && error !== null && 'message' in error + ? error.message + : undefined + + if ( + name === 'AbortError' || + (name === 'InvalidStateError' && + message === 'Transition was aborted because of invalid state') + ) { + return true + } + + return false +} + +function reportViewTransitionError(error: unknown) { + if (typeof globalThis.reportError === 'function') { + globalThis.reportError(error) + } else { + console.error(error) + } +} + const normalize = (str: string) => str.endsWith('/') && str.length > 1 ? str.slice(0, -1) : str function comparePaths(a: string, b: string) { diff --git a/packages/router-core/tests/view-transition.test.ts b/packages/router-core/tests/view-transition.test.ts new file mode 100644 index 0000000000..153d775252 --- /dev/null +++ b/packages/router-core/tests/view-transition.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +const originalStartViewTransition = Object.getOwnPropertyDescriptor( + document, + 'startViewTransition', +) + +type Deferred = { + promise: Promise + reject: (reason: unknown) => void +} + +function createDeferred(): Deferred { + let reject!: (reason: unknown) => void + const promise = new Promise((_, rejectPromise) => { + reject = rejectPromise + }) + return { promise, reject } +} + +function createRouter() { + const rootRoute = new BaseRootRoute({}) + return createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory(), + defaultViewTransition: true, + }) +} + +function installViewTransition({ + ready, + finished, +}: { + ready: Promise + finished: Promise +}) { + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: (update: () => Promise) => { + const updateCallbackDone = Promise.resolve().then(update) + return { + ready, + finished, + updateCallbackDone, + skipTransition() {}, + } as ViewTransition + }, + }) +} + +function installCallbackPropagatingViewTransition() { + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: (update: () => Promise) => { + const updateCallbackDone = Promise.resolve().then(update) + return { + updateCallbackDone, + ready: updateCallbackDone.then(() => undefined), + finished: updateCallbackDone.then(() => undefined), + skipTransition() {}, + } as ViewTransition + }, + }) +} + +afterEach(() => { + if (originalStartViewTransition) { + Object.defineProperty( + document, + 'startViewTransition', + originalStartViewTransition, + ) + } else { + Reflect.deleteProperty(document, 'startViewTransition') + } + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('view transition lifecycle rejections', () => { + test('ignores an invalid-state ready rejection and reports an unexpected finished rejection', async () => { + const ready = createDeferred() + const finished = createDeferred() + const unexpectedError = new Error('unexpected finished failure') + const reportError = vi.fn() + vi.stubGlobal('reportError', reportError) + installViewTransition({ + ready: ready.promise, + finished: finished.promise, + }) + + createRouter().startViewTransition(async () => {}) + + ready.reject( + new DOMException( + 'Transition was aborted because of invalid state', + 'InvalidStateError', + ), + ) + finished.reject(unexpectedError) + await Promise.allSettled([ready.promise, finished.promise]) + + expect(reportError).toHaveBeenCalledExactlyOnceWith(unexpectedError) + }) + + test('reports an unexpected ready rejection and ignores an aborted finished rejection', async () => { + const ready = createDeferred() + const finished = createDeferred() + const unexpectedError = new Error('unexpected ready failure') + const reportError = vi.fn() + vi.stubGlobal('reportError', reportError) + installViewTransition({ + ready: ready.promise, + finished: finished.promise, + }) + + createRouter().startViewTransition(async () => {}) + + ready.reject(unexpectedError) + finished.reject(new DOMException('Aborted', 'AbortError')) + await Promise.allSettled([ready.promise, finished.promise]) + + expect(reportError).toHaveBeenCalledExactlyOnceWith(unexpectedError) + }) + + test('falls back to console.error when reportError is unavailable', async () => { + const ready = createDeferred() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('reportError', undefined) + installViewTransition({ + ready: ready.promise, + finished: Promise.resolve(), + }) + + createRouter().startViewTransition(async () => {}) + + const unexpectedError = new Error('unexpected ready failure') + ready.reject(unexpectedError) + await Promise.allSettled([ready.promise]) + + expect(consoleError).toHaveBeenCalledExactlyOnceWith(unexpectedError) + }) + + test('reports a rejected update callback once without an unhandled rejection', async () => { + const unexpectedError = new Error('unexpected update callback failure') + const reportError = vi.fn() + vi.stubGlobal('reportError', reportError) + installCallbackPropagatingViewTransition() + + createRouter().startViewTransition(async () => { + throw unexpectedError + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(reportError).toHaveBeenCalledExactlyOnceWith(unexpectedError) + }) + + test.each([ + new DOMException('Aborted by the update callback', 'AbortError'), + new DOMException( + 'Transition was aborted because of invalid state', + 'InvalidStateError', + ), + ])('reports an %s thrown by the update callback', async (unexpectedError) => { + const reportError = vi.fn() + vi.stubGlobal('reportError', reportError) + installViewTransition({ + ready: Promise.resolve(), + finished: Promise.resolve(), + }) + + createRouter().startViewTransition(async () => { + throw unexpectedError + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(reportError).toHaveBeenCalledExactlyOnceWith(unexpectedError) + }) +})