Skip to content

Commit 0235bb6

Browse files
committed
Client instrumentation: onRouterTransitionStart
Adds a new API for observing the start of an App Router navigation: ```ts // <PROJECT_ROOT>/instrumentation-client.ts export function onRouterTransitionStart( url: string, navigationType: 'push' | 'replace' | 'traverse' ) { // ... } ``` `navigationType` is one of "push", "replace", or "traverse". This is inspired by the Navigation API: https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/navigationType `onRouterTransitionStart` is intended for sending logs to a performance monitoring tool. It is _not_ intended as a general purpose event for implementing application behavior. It fires at the start of every client-side navigation, including those initiated by a popstate (back/forward) event. There is no corresponding API for observing the end of a navigation. We intend to build support for this in the future, but it's a non-trivial problem space due of the streaming nature of React transitions. Refer to the React Transition Tracing proposal for more details: https://github.com/reactjs/rfcs/blob/transition-tracing/text/0235-transition-tracing.md `onRouterTransitionStart` is only called during App Router navigations. To instrument Pages Router navigations, use `router.events`.
1 parent 4643bcd commit 0235bb6

File tree

13 files changed

+186
-58
lines changed

13 files changed

+186
-58
lines changed

Diff for: packages/next/src/client/app-index.tsx

+51-37
Original file line numberDiff line numberDiff line change
@@ -159,41 +159,11 @@ const initialServerResponse = createFromReadableStream<InitialRSCPayload>(
159159
{ callServer, findSourceMapURL }
160160
)
161161

162-
// React overrides `.then` and doesn't return a new promise chain,
163-
// so we wrap the action queue in a promise to ensure that its value
164-
// is defined when the promise resolves.
165-
// https://github.com/facebook/react/blob/163365a07872337e04826c4f501565d43dbd2fd4/packages/react-client/src/ReactFlightClient.js#L189-L190
166-
const pendingActionQueue: Promise<AppRouterActionQueue> = new Promise(
167-
(resolve, reject) => {
168-
initialServerResponse.then(
169-
(initialRSCPayload) => {
170-
// setAppBuildId should be called only once, during JS initialization
171-
// and before any components have hydrated.
172-
setAppBuildId(initialRSCPayload.b)
173-
174-
const initialTimestamp = Date.now()
175-
176-
resolve(
177-
createMutableActionQueue(
178-
createInitialRouterState({
179-
navigatedAt: initialTimestamp,
180-
initialFlightData: initialRSCPayload.f,
181-
initialCanonicalUrlParts: initialRSCPayload.c,
182-
initialParallelRoutes: new Map(),
183-
location: window.location,
184-
couldBeIntercepted: initialRSCPayload.i,
185-
postponed: initialRSCPayload.s,
186-
prerendered: initialRSCPayload.S,
187-
})
188-
)
189-
)
190-
},
191-
(err: Error) => reject(err)
192-
)
193-
}
194-
)
195-
196-
function ServerRoot(): React.ReactNode {
162+
function ServerRoot({
163+
pendingActionQueue,
164+
}: {
165+
pendingActionQueue: Promise<AppRouterActionQueue>
166+
}): React.ReactNode {
197167
const initialRSCPayload = use(initialServerResponse)
198168
const actionQueue = use<AppRouterActionQueue>(pendingActionQueue)
199169

@@ -241,12 +211,56 @@ const reactRootOptions: ReactDOMClient.RootOptions = {
241211
onUncaughtError,
242212
}
243213

244-
export function hydrate() {
214+
export type ClientInstrumentationHooks = {
215+
onRouterTransitionStart?: (
216+
url: string,
217+
navigationType: 'push' | 'replace' | 'traverse'
218+
) => void
219+
}
220+
221+
export function hydrate(
222+
instrumentationHooks: ClientInstrumentationHooks | null
223+
) {
224+
// React overrides `.then` and doesn't return a new promise chain,
225+
// so we wrap the action queue in a promise to ensure that its value
226+
// is defined when the promise resolves.
227+
// https://github.com/facebook/react/blob/163365a07872337e04826c4f501565d43dbd2fd4/packages/react-client/src/ReactFlightClient.js#L189-L190
228+
const pendingActionQueue: Promise<AppRouterActionQueue> = new Promise(
229+
(resolve, reject) => {
230+
initialServerResponse.then(
231+
(initialRSCPayload) => {
232+
// setAppBuildId should be called only once, during JS initialization
233+
// and before any components have hydrated.
234+
setAppBuildId(initialRSCPayload.b)
235+
236+
const initialTimestamp = Date.now()
237+
238+
resolve(
239+
createMutableActionQueue(
240+
createInitialRouterState({
241+
navigatedAt: initialTimestamp,
242+
initialFlightData: initialRSCPayload.f,
243+
initialCanonicalUrlParts: initialRSCPayload.c,
244+
initialParallelRoutes: new Map(),
245+
location: window.location,
246+
couldBeIntercepted: initialRSCPayload.i,
247+
postponed: initialRSCPayload.s,
248+
prerendered: initialRSCPayload.S,
249+
}),
250+
instrumentationHooks
251+
)
252+
)
253+
},
254+
(err: Error) => reject(err)
255+
)
256+
}
257+
)
258+
245259
const reactEl = (
246260
<StrictModeIfEnabled>
247261
<HeadManagerContext.Provider value={{ appDir: true }}>
248262
<Root>
249-
<ServerRoot />
263+
<ServerRoot pendingActionQueue={pendingActionQueue} />
250264
</Root>
251265
</HeadManagerContext.Provider>
252266
</StrictModeIfEnabled>

Diff for: packages/next/src/client/app-next-dev.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// TODO-APP: hydration warning
22

33
import './app-webpack'
4-
import '../lib/require-instrumentation-client'
4+
55
import { appBootstrap } from './app-bootstrap'
66
import { initializeDevBuildIndicatorForAppRouter } from './dev/dev-build-indicator/initialize-for-app-router'
77

8+
const instrumentationHooks = require('../lib/require-instrumentation-client')
9+
810
appBootstrap(() => {
911
const { hydrate } = require('./app-index')
10-
hydrate()
12+
hydrate(instrumentationHooks)
1113
initializeDevBuildIndicatorForAppRouter()
1214
})

Diff for: packages/next/src/client/app-next-turbopack.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
// TODO-APP: hydration warning
22

3-
import '../lib/require-instrumentation-client'
43
import { appBootstrap } from './app-bootstrap'
54

65
window.next.version += '-turbo'
76
;(self as any).__webpack_hash__ = ''
87

8+
const instrumentationHooks = require('../lib/require-instrumentation-client')
9+
910
appBootstrap(() => {
1011
const { hydrate } = require('./app-index')
11-
hydrate()
12+
hydrate(instrumentationHooks)
1213

1314
if (process.env.NODE_ENV !== 'production') {
1415
const { initializeDevBuildIndicatorForAppRouter } =

Diff for: packages/next/src/client/app-next.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// This import must go first because it needs to patch webpack chunk loading
22
// before React patches chunk loading.
33
import './app-webpack'
4-
import '../lib/require-instrumentation-client'
54
import { appBootstrap } from './app-bootstrap'
65

6+
const instrumentationHooks = require('../lib/require-instrumentation-client')
7+
78
appBootstrap(() => {
89
const { hydrate } = require('./app-index')
910
// Include app-router and layout-router in the main chunk
1011
require('next/dist/client/components/app-router')
1112
require('next/dist/client/components/layout-router')
12-
hydrate()
13+
hydrate(instrumentationHooks)
1314
})

Diff for: packages/next/src/client/components/app-router-instance.ts

+42-1
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,20 @@ import type {
2525
PrefetchOptions,
2626
} from '../../shared/lib/app-router-context.shared-runtime'
2727
import { setLinkForCurrentNavigation, type LinkInstance } from './links'
28+
import type { FlightRouterState } from '../../server/app-render/types'
29+
import type { ClientInstrumentationHooks } from '../app-index'
2830

2931
export type DispatchStatePromise = React.Dispatch<ReducerState>
3032

3133
export type AppRouterActionQueue = {
3234
state: AppRouterState
3335
dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
3436
action: (state: AppRouterState, action: ReducerActions) => ReducerState
37+
38+
onRouterTransitionStart:
39+
| ((url: string, type: 'push' | 'replace' | 'traverse') => void)
40+
| null
41+
3542
pending: ActionQueueNode | null
3643
needsRefresh?: boolean
3744
last: ActionQueueNode | null
@@ -193,7 +200,8 @@ function dispatchAction(
193200
let globalActionQueue: AppRouterActionQueue | null = null
194201

195202
export function createMutableActionQueue(
196-
initialState: AppRouterState
203+
initialState: AppRouterState,
204+
instrumentationHooks: ClientInstrumentationHooks | null
197205
): AppRouterActionQueue {
198206
const actionQueue: AppRouterActionQueue = {
199207
state: initialState,
@@ -205,6 +213,12 @@ export function createMutableActionQueue(
205213
},
206214
pending: null,
207215
last: null,
216+
onRouterTransitionStart:
217+
instrumentationHooks !== null &&
218+
typeof instrumentationHooks.onRouterTransitionStart === 'function'
219+
? // This profiling hook will be called at the start of every navigation.
220+
instrumentationHooks.onRouterTransitionStart
221+
: null,
208222
}
209223

210224
if (typeof window !== 'undefined') {
@@ -236,6 +250,13 @@ function getAppRouterActionQueue(): AppRouterActionQueue {
236250
return globalActionQueue
237251
}
238252

253+
function getProfilingHookForOnNavigationStart() {
254+
if (globalActionQueue !== null) {
255+
return globalActionQueue.onRouterTransitionStart
256+
}
257+
return null
258+
}
259+
239260
export function dispatchNavigateAction(
240261
href: string,
241262
navigateType: NavigateAction['navigateType'],
@@ -251,6 +272,11 @@ export function dispatchNavigateAction(
251272

252273
setLinkForCurrentNavigation(linkInstanceRef)
253274

275+
const onRouterTransitionStart = getProfilingHookForOnNavigationStart()
276+
if (onRouterTransitionStart !== null) {
277+
onRouterTransitionStart(href, navigateType)
278+
}
279+
254280
dispatchAppRouterAction({
255281
type: ACTION_NAVIGATE,
256282
url,
@@ -262,6 +288,21 @@ export function dispatchNavigateAction(
262288
})
263289
}
264290

291+
export function dispatchTraverseAction(
292+
href: string,
293+
tree: FlightRouterState | undefined
294+
) {
295+
const onRouterTransitionStart = getProfilingHookForOnNavigationStart()
296+
if (onRouterTransitionStart !== null) {
297+
onRouterTransitionStart(href, 'traverse')
298+
}
299+
dispatchAppRouterAction({
300+
type: ACTION_RESTORE,
301+
url: new URL(href),
302+
tree,
303+
})
304+
}
305+
265306
/**
266307
* The app router that is exposed through `useRouter`. These are public API
267308
* methods. Internal Next.js code should call the lower level methods directly

Diff for: packages/next/src/client/components/app-router.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { getSelectedParams } from './router-reducer/compute-changed-path'
4040
import type { FlightRouterState } from '../../server/app-render/types'
4141
import { useNavFailureHandler } from './nav-failure-handler'
4242
import {
43+
dispatchTraverseAction,
4344
publicAppRouterInstance,
4445
type AppRouterActionQueue,
4546
} from './app-router-instance'
@@ -418,11 +419,10 @@ function Router({
418419
// TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously
419420
// Without startTransition works if the cache is there for this path
420421
startTransition(() => {
421-
dispatchAppRouterAction({
422-
type: ACTION_RESTORE,
423-
url: new URL(window.location.href),
424-
tree: event.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
425-
})
422+
dispatchTraverseAction(
423+
window.location.href,
424+
event.state.__PRIVATE_NEXTJS_INTERNALS_TREE
425+
)
426426
})
427427
}
428428

Diff for: packages/next/src/lib/require-instrumentation-client.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
if (process.env.NODE_ENV === 'development') {
88
const measureName = 'Client Instrumentation Hook'
99
const startTime = performance.now()
10-
require('private-next-instrumentation-client')
10+
module.exports = require('private-next-instrumentation-client')
1111
const endTime = performance.now()
1212

1313
const duration = endTime - startTime
@@ -27,5 +27,5 @@ if (process.env.NODE_ENV === 'development') {
2727
)
2828
}
2929
} else {
30-
require('private-next-instrumentation-client')
30+
module.exports = require('private-next-instrumentation-client')
3131
}

Diff for: packages/next/src/server/app-render/app-render.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,7 @@ function App<T>({
10921092
prerendered: response.S,
10931093
})
10941094

1095-
const actionQueue = createMutableActionQueue(initialState)
1095+
const actionQueue = createMutableActionQueue(initialState, null)
10961096

10971097
const { HeadManagerContext } =
10981098
require('../../shared/lib/head-manager-context.shared-runtime') as typeof import('../../shared/lib/head-manager-context.shared-runtime')
@@ -1159,7 +1159,7 @@ function ErrorApp<T>({
11591159
prerendered: response.S,
11601160
})
11611161

1162-
const actionQueue = createMutableActionQueue(initialState)
1162+
const actionQueue = createMutableActionQueue(initialState, null)
11631163

11641164
return (
11651165
<ServerInsertedMetadataProvider>
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
import React from 'react'
1+
import Link from 'next/link'
22

33
export default function RootLayout({ children }) {
44
return (
55
<html>
6-
<body>{children}</body>
6+
<body>
7+
<ul>
8+
<li>
9+
<Link href="/">Home</Link>
10+
</li>
11+
<li>
12+
<Link href="/some-page">Some Page</Link>
13+
</li>
14+
</ul>
15+
{children}
16+
</body>
717
</html>
818
)
919
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import React from 'react'
2-
31
export default function Page() {
4-
return <h1>App</h1>
2+
return <h1 id="home">Home</h1>
53
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <h1 id="some-page">Some page</h1>
3+
}

Diff for: test/e2e/instrumentation-client-hook/app-router/instrumentation-client.ts

+5
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ const start = performance.now()
44
while (performance.now() - start < 20) {
55
// Intentionally block for 20ms to test instrumentation timing
66
}
7+
8+
export function onRouterTransitionStart(href: string, navigateType: string) {
9+
const pathname = new URL(href, window.location.href).pathname
10+
console.log(`[Router Transition Start] [${navigateType}] ${pathname}`)
11+
}

0 commit comments

Comments
 (0)