Files
next.js/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts
34759c3e1a router instrumentation: add transition start context (2/7) (#94766)
## Stack Position

(2/7) in the router instrumentation stack.

1. #94755: router instrumentation: refactor client hook dispatch (1/7)
2. **#94766: router instrumentation: add transition start context
(2/7)**
3. #94756: router instrumentation: add transition commit events (3/7)
4. #94765: router instrumentation: add commit route and prefetch context
(4/7)
5. #94757: router instrumentation: add transition abort events (5/7)
6. #94758: router instrumentation: add route mismatch events (6/7)
7. #94737: router instrumentation: add transition settlement events
(7/7)

Parent: #94755
Next: #94756

## What This PR Does

Adds an experimental third argument to the existing start hook:

```ts
export function onRouterTransitionStart(url, navigationType, event) {}
```

The event contains:

- `id` and framework-captured Unix `timestamp` in milliseconds
- `fromRoutes`: route patterns visible when navigation is dispatched
- `prefetchIntent`: `full`, `auto`, or `none`

The primary `children` route is first, followed by visible parallel
slots in deterministic order.

## Design

Start owns the context that is authoritative before navigation work
begins: the screen being left and the caller's prefetch intent.
Destination routes and the cache result actually used are reported by
commit instead. Consumers correlate them by transition ID.

Without `experimental.instrumentationClientRouterTransitionEvents`, the
existing two-argument hook behavior is unchanged.

## Not In This PR

- Destination routes or observed prefetch result
- Commit, abort, mismatch, or settled hooks

## Reviewer Focus

- Route pattern extraction, including dynamic and parallel routes
- Primary route ordering
- Link intent propagation and experimental gating

## Validation

- `pnpm build-all`
- `pnpm --filter=next build`
- App Router instrumentation client E2E: 15/15

<!-- NEXT_JS_LLM_PR -->

---------

Co-authored-by: Jude Gao <[email protected]>
2026-06-19 14:13:33 -04:00

345 lines
11 KiB
TypeScript

import { isNextDev, nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import path from 'path'
describe('Instrumentation Client Hook', () => {
describe.each([
{
name: 'With src folder',
appDir: 'app-with-src',
shouldLog: false,
},
{
name: 'App Router',
appDir: 'app-router',
shouldLog: true,
},
{
name: 'Pages Router',
appDir: 'pages-router',
shouldLog: false,
},
])('$name', ({ name, appDir, shouldLog }) => {
describe(name, () => {
const { next, isNextDev } = nextTestSetup({
files: path.join(__dirname, appDir),
})
it(`should execute instrumentation-client from ${name.toLowerCase()} before hydration`, async () => {
const browser = await next.browser('/')
const instrumentationTime = await browser.eval(
`window.__INSTRUMENTATION_CLIENT_EXECUTED_AT`
)
const hydrationTime = await browser.eval(`window.__NEXT_HYDRATED_AT`)
expect(instrumentationTime).toBeDefined()
expect(hydrationTime).toBeDefined()
expect(instrumentationTime).toBeLessThan(hydrationTime)
expect(
(await browser.log()).some((log) =>
log.message.startsWith(
'[Client Instrumentation Hook] Slow execution detected'
)
)
).toBe(isNextDev && shouldLog)
})
})
})
function filterNavigationStartLogs(logs: Array<{ message: string }>) {
const result = []
for (const log of logs) {
if (log.message.startsWith('[Router Transition Start]')) {
result.push(log.message)
}
}
return result
}
describe('onRouterTransitionStart', () => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'app-router'),
})
it('onRouterTransitionStart fires at the start of a navigation', async () => {
const browser = await next.browser('/')
const linkToSomePage = await browser.elementByCss('a[href="/some-page"]')
await linkToSomePage.click()
await browser.elementById('some-page')
const linkToHome = await browser.elementByCss('a[href="/"]')
await linkToHome.click()
await browser.elementById('home')
expect(filterNavigationStartLogs(await browser.log())).toEqual([
'[Router Transition Start] [push] /some-page',
'[Router Transition Start] [push] /',
])
})
it('onRouterTransitionStart fires at the start of a back/forward navigation', async () => {
const browser = await next.browser('/')
const linkToSomePage = await browser.elementByCss('a[href="/some-page"]')
await linkToSomePage.click()
await browser.elementById('some-page')
await browser.back()
await browser.elementById('home')
await browser.forward()
await browser.elementById('some-page')
expect(filterNavigationStartLogs(await browser.log())).toEqual([
'[Router Transition Start] [push] /some-page',
'[Router Transition Start] [traverse] /',
'[Router Transition Start] [traverse] /some-page',
])
})
it('preserves the legacy two-argument start hook without the experimental flag', async () => {
const browser = await next.browser('/')
await browser.elementByCss('a[href="/some-page"]').click()
await browser.elementById('some-page')
expect(
await browser.eval(`
window.__ROUTER_TRANSITION_EVENTS.map((event) => ({
phase: event.phase,
hasEvent: event.event != null,
}))
`)
).toEqual([{ phase: 'start', hasEvent: false }])
})
})
describe('router transition start context', () => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'app-router'),
nextConfig: {
experimental: {
instrumentationClientRouterTransitionEvents: true,
},
},
})
async function getTransitionEvents(browser) {
return browser.eval(`window.__ROUTER_TRANSITION_EVENTS`)
}
it('reports transition metadata, source routes, and prefetch intent', async () => {
const browser = await next.browser('/')
await browser.elementByCss('a[href="/some-page"]').click()
await browser.elementById('some-page')
const [start] = await getTransitionEvents(browser)
expect(start.phase).toBe('start')
expect(start.url).toBe('/some-page')
expect(start.navigateType).toBe('push')
expect(typeof start.event.id).toBe('string')
expect(start.event.timestamp).toBeGreaterThan(0)
expect(start.event.fromRoutes).toEqual(['/'])
expect(start.event.prefetchIntent).toBe('full')
})
it('reports a null prefetch intent for programmatic navigation', async () => {
const browser = await next.browser('/')
await browser.elementById('push-some-page').click()
await browser.elementById('some-page')
const [start] = await getTransitionEvents(browser)
expect(start.phase).toBe('start')
expect(start.url).toBe('/some-page')
expect(start.navigateType).toBe('push')
expect(start.event.prefetchIntent).toBe(null)
})
it('uses route patterns and puts the primary source route first', async () => {
const browser = await next.browser('/')
await browser.elementByCss('a[href="/blog/hello"]').click()
await browser.elementById('blog-post')
await browser.elementByCss('a[href="/"]').click()
await browser.elementById('home')
expect(
(await getTransitionEvents(browser)).at(-1).event.fromRoutes
).toEqual(['/blog/[slug]'])
await browser.elementByCss('a[href="/dashboard"]').click()
await browser.elementById('dashboard')
await browser.elementById('analytics')
await browser.elementByCss('a[href="/"]').click()
await browser.elementById('home')
expect(
(await getTransitionEvents(browser)).at(-1).event.fromRoutes
).toEqual(['/dashboard', '/dashboard/@analytics'])
})
it('omits route groups from fromRoutes', async () => {
const browser = await next.browser('/about')
await browser.elementByCss('a[href="/"]').click()
await browser.elementById('home')
expect(
(await getTransitionEvents(browser)).at(-1).event.fromRoutes
).toEqual(['/about'])
})
it('reports intercepted route patterns in fromRoutes', async () => {
const browser = await next.browser('/gallery')
await browser.elementByCss('a[href="/gallery/photos/1"]').click()
await browser.elementById('photo-modal')
await browser.elementByCss('a[href="/"]').click()
await browser.elementById('home')
expect(
(await getTransitionEvents(browser)).at(-1).event.fromRoutes
).toEqual(['/gallery', '/gallery/@modal/(.)photos/[id]'])
})
})
describe.each([
{
name: 'default',
packageJson: {},
},
{
name: 'with type:module',
packageJson: { type: 'module' },
},
])('instrumentationClientInject $name', ({ packageJson }) => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'inject'),
packageJson,
})
it('runs each injected module before the user instrumentation-client and before hydration, in array order', async () => {
const browser = await next.browser('/')
const order = await browser.eval(`window.__INJECT_ORDER`)
expect(order).toEqual(['side-effect', 'late-hook', 'a', 'b', 'user'])
const moduleA = await browser.eval(`window.__INJECT_A_EXECUTED_AT`)
const moduleB = await browser.eval(`window.__INJECT_B_EXECUTED_AT`)
const userTime = await browser.eval(
`window.__INSTRUMENTATION_CLIENT_EXECUTED_AT`
)
const hydrationTime = await browser.eval(`window.__NEXT_HYDRATED_AT`)
expect(moduleA).toBeDefined()
expect(moduleB).toBeDefined()
expect(userTime).toBeDefined()
expect(hydrationTime).toBeDefined()
expect(moduleA).toBeLessThanOrEqual(moduleB)
expect(moduleB).toBeLessThanOrEqual(userTime)
expect(userTime).toBeLessThan(hydrationTime)
})
it('surfaces onRouterTransitionStart from every injected module', async () => {
const browser = await next.browser('/')
await browser.eval(`window.__INSTALL_LATE_INSTRUMENTATION_HOOK()`)
const linkToSomePage = await browser.elementByCss('a[href="/some-page"]')
await linkToSomePage.click()
await browser.elementById('some-page')
const linkToHome = await browser.elementByCss('a[href="/"]')
await linkToHome.click()
await browser.elementById('home')
expect(filterNavigationStartLogs(await browser.log())).toEqual([
'[Router Transition Start] [push] /some-page late-hook',
'[Router Transition Start] [push] /some-page a',
'[Router Transition Start] [push] /some-page b',
'[Router Transition Start] [push] /some-page user',
'[Router Transition Start] [push] / late-hook',
'[Router Transition Start] [push] / a',
'[Router Transition Start] [push] / b',
'[Router Transition Start] [push] / user',
])
})
it('isolates hook errors between injected modules', async () => {
const browser = await next.browser('/')
await browser.eval(`window.__INSTALL_LATE_INSTRUMENTATION_HOOK()`)
await browser.eval(`window.__THROW_INJECT_A = true`)
await browser.elementByCss('a[href="/some-page"]').click()
await browser.elementById('some-page')
const logs = await browser.log()
expect(filterNavigationStartLogs(logs)).toEqual([
'[Router Transition Start] [push] /some-page late-hook',
'[Router Transition Start] [push] /some-page a',
'[Router Transition Start] [push] /some-page b',
'[Router Transition Start] [push] /some-page user',
])
expect(
logs.filter((log) =>
log.message.includes(
'An instrumentation-client router transition hook failed'
)
)
).toHaveLength(1)
})
})
if (isNextDev) {
describe('HMR in development mode', () => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'app-router'),
})
it('should reload instrumentation-client when modified', async () => {
const browser = await next.browser('/')
const initialTime = await browser.eval(
`window.__INSTRUMENTATION_CLIENT_EXECUTED_AT`
)
expect(initialTime).toBeDefined()
// Modify the instrumentation-client.ts file
const instrumentationPath = 'instrumentation-client.ts'
const originalContent = await next.readFile(instrumentationPath)
await next.patchFile(
instrumentationPath,
`
window.__INSTRUMENTATION_CLIENT_EXECUTED_AT = Date.now();
window.__INSTRUMENTATION_CLIENT_UPDATED = true;
`
)
await retry(async () => {
// Check if the updated instrumentation client was executed
const updatedFlag = await browser.eval(
`window.__INSTRUMENTATION_CLIENT_UPDATED`
)
expect(updatedFlag).toBe(true)
// Verify new execution time
const newTime = await browser.eval(
`window.__INSTRUMENTATION_CLIENT_EXECUTED_AT`
)
expect(newTime).toBeDefined()
expect(newTime).toBeGreaterThan(initialTime)
})
// Restore the original file
await next.patchFile(instrumentationPath, originalContent)
})
})
}
})