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
50 changes: 49 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2705,7 +2705,26 @@ export class RouterCore<
startViewTransitionParams = fn
}

document.startViewTransition(startViewTransitionParams)
const viewTransition = document.startViewTransition(
startViewTransitionParams,
)
const reportedErrors = new Set<unknown>()
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()
}
Expand Down Expand Up @@ -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) {
Expand Down
184 changes: 184 additions & 0 deletions packages/router-core/tests/view-transition.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>
reject: (reason: unknown) => void
}

function createDeferred(): Deferred {
let reject!: (reason: unknown) => void
const promise = new Promise<void>((_, 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<void>
finished: Promise<void>
}) {
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
value: (update: () => Promise<void>) => {
const updateCallbackDone = Promise.resolve().then(update)
return {
ready,
finished,
updateCallbackDone,
skipTransition() {},
} as ViewTransition
},
})
}

function installCallbackPropagatingViewTransition() {
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
value: (update: () => Promise<void>) => {
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)
})
})