Skip to content

Client instrumentation: onRouterTransitionStart #77791

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

Merged
merged 1 commit into from
Apr 3, 2025
Merged
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
88 changes: 51 additions & 37 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,41 +159,11 @@ const initialServerResponse = createFromReadableStream<InitialRSCPayload>(
{ callServer, findSourceMapURL }
)

// React overrides `.then` and doesn't return a new promise chain,
// so we wrap the action queue in a promise to ensure that its value
// is defined when the promise resolves.
// https://github.com/facebook/react/blob/163365a07872337e04826c4f501565d43dbd2fd4/packages/react-client/src/ReactFlightClient.js#L189-L190
const pendingActionQueue: Promise<AppRouterActionQueue> = new Promise(
(resolve, reject) => {
initialServerResponse.then(
(initialRSCPayload) => {
// setAppBuildId should be called only once, during JS initialization
// and before any components have hydrated.
setAppBuildId(initialRSCPayload.b)

const initialTimestamp = Date.now()

resolve(
createMutableActionQueue(
createInitialRouterState({
navigatedAt: initialTimestamp,
initialFlightData: initialRSCPayload.f,
initialCanonicalUrlParts: initialRSCPayload.c,
initialParallelRoutes: new Map(),
location: window.location,
couldBeIntercepted: initialRSCPayload.i,
postponed: initialRSCPayload.s,
prerendered: initialRSCPayload.S,
})
)
)
},
(err: Error) => reject(err)
)
}
)

function ServerRoot(): React.ReactNode {
function ServerRoot({
pendingActionQueue,
}: {
pendingActionQueue: Promise<AppRouterActionQueue>
}): React.ReactNode {
const initialRSCPayload = use(initialServerResponse)
const actionQueue = use<AppRouterActionQueue>(pendingActionQueue)

Expand Down Expand Up @@ -241,12 +211,56 @@ const reactRootOptions: ReactDOMClient.RootOptions = {
onUncaughtError,
}

export function hydrate() {
export type ClientInstrumentationHooks = {
onRouterTransitionStart?: (
url: string,
navigationType: 'push' | 'replace' | 'traverse'
) => void
}

export function hydrate(
instrumentationHooks: ClientInstrumentationHooks | null
) {
// React overrides `.then` and doesn't return a new promise chain,
// so we wrap the action queue in a promise to ensure that its value
// is defined when the promise resolves.
// https://github.com/facebook/react/blob/163365a07872337e04826c4f501565d43dbd2fd4/packages/react-client/src/ReactFlightClient.js#L189-L190
const pendingActionQueue: Promise<AppRouterActionQueue> = new Promise(
(resolve, reject) => {
initialServerResponse.then(
(initialRSCPayload) => {
// setAppBuildId should be called only once, during JS initialization
// and before any components have hydrated.
setAppBuildId(initialRSCPayload.b)

const initialTimestamp = Date.now()

resolve(
createMutableActionQueue(
createInitialRouterState({
navigatedAt: initialTimestamp,
initialFlightData: initialRSCPayload.f,
initialCanonicalUrlParts: initialRSCPayload.c,
initialParallelRoutes: new Map(),
location: window.location,
couldBeIntercepted: initialRSCPayload.i,
postponed: initialRSCPayload.s,
prerendered: initialRSCPayload.S,
}),
instrumentationHooks
)
)
},
(err: Error) => reject(err)
)
}
)

const reactEl = (
<StrictModeIfEnabled>
<HeadManagerContext.Provider value={{ appDir: true }}>
<Root>
<ServerRoot />
<ServerRoot pendingActionQueue={pendingActionQueue} />
</Root>
</HeadManagerContext.Provider>
</StrictModeIfEnabled>
Expand Down
6 changes: 4 additions & 2 deletions packages/next/src/client/app-next-dev.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// TODO-APP: hydration warning

import './app-webpack'
import '../lib/require-instrumentation-client'

import { appBootstrap } from './app-bootstrap'
import { initializeDevBuildIndicatorForAppRouter } from './dev/dev-build-indicator/initialize-for-app-router'

const instrumentationHooks = require('../lib/require-instrumentation-client')

appBootstrap(() => {
const { hydrate } = require('./app-index')
hydrate()
hydrate(instrumentationHooks)
initializeDevBuildIndicatorForAppRouter()
})
5 changes: 3 additions & 2 deletions packages/next/src/client/app-next-turbopack.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// TODO-APP: hydration warning

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

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

const instrumentationHooks = require('../lib/require-instrumentation-client')

appBootstrap(() => {
const { hydrate } = require('./app-index')
hydrate()
hydrate(instrumentationHooks)

if (process.env.NODE_ENV !== 'production') {
const { initializeDevBuildIndicatorForAppRouter } =
Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/client/app-next.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// This import must go first because it needs to patch webpack chunk loading
// before React patches chunk loading.
import './app-webpack'
import '../lib/require-instrumentation-client'
import { appBootstrap } from './app-bootstrap'

const instrumentationHooks = require('../lib/require-instrumentation-client')

appBootstrap(() => {
const { hydrate } = require('./app-index')
// Include app-router and layout-router in the main chunk
require('next/dist/client/components/app-router')
require('next/dist/client/components/layout-router')
hydrate()
hydrate(instrumentationHooks)
})
43 changes: 42 additions & 1 deletion packages/next/src/client/components/app-router-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ import type {
PrefetchOptions,
} from '../../shared/lib/app-router-context.shared-runtime'
import { setLinkForCurrentNavigation, type LinkInstance } from './links'
import type { FlightRouterState } from '../../server/app-render/types'
import type { ClientInstrumentationHooks } from '../app-index'

export type DispatchStatePromise = React.Dispatch<ReducerState>

export type AppRouterActionQueue = {
state: AppRouterState
dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
action: (state: AppRouterState, action: ReducerActions) => ReducerState

onRouterTransitionStart:
| ((url: string, type: 'push' | 'replace' | 'traverse') => void)
| null

pending: ActionQueueNode | null
needsRefresh?: boolean
last: ActionQueueNode | null
Expand Down Expand Up @@ -193,7 +200,8 @@ function dispatchAction(
let globalActionQueue: AppRouterActionQueue | null = null

export function createMutableActionQueue(
initialState: AppRouterState
initialState: AppRouterState,
instrumentationHooks: ClientInstrumentationHooks | null
): AppRouterActionQueue {
const actionQueue: AppRouterActionQueue = {
state: initialState,
Expand All @@ -205,6 +213,12 @@ export function createMutableActionQueue(
},
pending: null,
last: null,
onRouterTransitionStart:
instrumentationHooks !== null &&
typeof instrumentationHooks.onRouterTransitionStart === 'function'
? // This profiling hook will be called at the start of every navigation.
instrumentationHooks.onRouterTransitionStart
: null,
}

if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -236,6 +250,13 @@ function getAppRouterActionQueue(): AppRouterActionQueue {
return globalActionQueue
}

function getProfilingHookForOnNavigationStart() {
if (globalActionQueue !== null) {
return globalActionQueue.onRouterTransitionStart
}
return null
}

export function dispatchNavigateAction(
href: string,
navigateType: NavigateAction['navigateType'],
Expand All @@ -251,6 +272,11 @@ export function dispatchNavigateAction(

setLinkForCurrentNavigation(linkInstanceRef)

const onRouterTransitionStart = getProfilingHookForOnNavigationStart()
if (onRouterTransitionStart !== null) {
onRouterTransitionStart(href, navigateType)
}

dispatchAppRouterAction({
type: ACTION_NAVIGATE,
url,
Expand All @@ -262,6 +288,21 @@ export function dispatchNavigateAction(
})
}

export function dispatchTraverseAction(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what's the semantic for traverse? just curious how we came up with this name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

href: string,
tree: FlightRouterState | undefined
) {
const onRouterTransitionStart = getProfilingHookForOnNavigationStart()
if (onRouterTransitionStart !== null) {
onRouterTransitionStart(href, 'traverse')
}
dispatchAppRouterAction({
type: ACTION_RESTORE,
url: new URL(href),
tree,
})
}

/**
* The app router that is exposed through `useRouter`. These are public API
* methods. Internal Next.js code should call the lower level methods directly
Expand Down
10 changes: 5 additions & 5 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getSelectedParams } from './router-reducer/compute-changed-path'
import type { FlightRouterState } from '../../server/app-render/types'
import { useNavFailureHandler } from './nav-failure-handler'
import {
dispatchTraverseAction,
publicAppRouterInstance,
type AppRouterActionQueue,
} from './app-router-instance'
Expand Down Expand Up @@ -418,11 +419,10 @@ function Router({
// TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously
// Without startTransition works if the cache is there for this path
startTransition(() => {
dispatchAppRouterAction({
type: ACTION_RESTORE,
url: new URL(window.location.href),
tree: event.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
dispatchTraverseAction(
window.location.href,
event.state.__PRIVATE_NEXTJS_INTERNALS_TREE
)
})
}

Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/lib/require-instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
if (process.env.NODE_ENV === 'development') {
const measureName = 'Client Instrumentation Hook'
const startTime = performance.now()
require('private-next-instrumentation-client')
module.exports = require('private-next-instrumentation-client')
const endTime = performance.now()

const duration = endTime - startTime
Expand All @@ -27,5 +27,5 @@ if (process.env.NODE_ENV === 'development') {
)
}
} else {
require('private-next-instrumentation-client')
module.exports = require('private-next-instrumentation-client')
}
4 changes: 2 additions & 2 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,7 @@ function App<T>({
prerendered: response.S,
})

const actionQueue = createMutableActionQueue(initialState)
const actionQueue = createMutableActionQueue(initialState, null)

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

const actionQueue = createMutableActionQueue(initialState)
const actionQueue = createMutableActionQueue(initialState, null)

return (
<ServerInsertedMetadataProvider>
Expand Down
14 changes: 12 additions & 2 deletions test/e2e/instrumentation-client-hook/app-router/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import React from 'react'
import Link from 'next/link'

export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
<body>
<ul>
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/some-page">Some Page</Link>
</li>
</ul>
{children}
</body>
</html>
)
}
4 changes: 1 addition & 3 deletions test/e2e/instrumentation-client-hook/app-router/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import React from 'react'

export default function Page() {
return <h1>App</h1>
return <h1 id="home">Home</h1>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <h1 id="some-page">Some page</h1>
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ const start = performance.now()
while (performance.now() - start < 20) {
// Intentionally block for 20ms to test instrumentation timing
}

export function onRouterTransitionStart(href: string, navigateType: string) {
const pathname = new URL(href, window.location.href).pathname
console.log(`[Router Transition Start] [${navigateType}] ${pathname}`)
}
Loading
Loading