Repository: vercel/next.js
Pull request: Adds a Start button to the Instant Navs panel to initiate inspection …

…of client navs. Allows the initial screen to be dismissed without reloading the app.

https://github.com/user-attachments/assets/56b08c57-bd78-4d38-875d-244a450b7325



Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/instant-navs/instant-navs-panel.tsx
patch: @@ -36,9 +36,9 @@ export function InstantNavsPanel() {
     }
   }, [dispatch])
 
-  // Navigation detection: watch state.page for changes while in waiting state
+  // Navigation detection: watch state.page for changes while in client-nav-waiting state
   useEffect(() => {
-    if (status !== 'waiting') return
+    if (status !== 'client-nav-waiting') return
     if (!state.page) return
 
     // Capture the first non-empty page as baseline (state.page starts as '')
@@ -58,11 +58,20 @@ export function InstantNavsPanel() {
   }, [state.page, status, dispatch])
 
   function handleReload() {
-    // Cookie is already set to '1' from when the panel opened.
-    // Just reload — the server will see the cookie and render the static shell.
+    document.cookie = 'next-instant-navigation-testing=1; path=/'
     window.location.reload()
   }
 
+  function handleStartClientNav() {
+    document.cookie = 'next-instant-navigation-testing=1; path=/'
+    fromUrlRef.current = window.location.pathname + window.location.search
+    initialPageRef.current = state.page
+    dispatch({
+      type: ACTION_INSTANT_NAVS_SET_STATUS,
+      status: 'client-nav-waiting',
+    })
+  }
+
   function handleContinueRendering() {
     document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0'
     window.location.reload()
@@ -85,7 +94,7 @@ export function InstantNavsPanel() {
           <div className="instant-nav-section-header">
             <label>Page load</label>
             <p className="instant-nav-section-description">
-              Reload to view the initial static UI for this page.
+              View the initial static UI for this page.
             </p>
           </div>
           <div className="instant-nav-section-control">
@@ -94,18 +103,7 @@ export function InstantNavsPanel() {
               onClick={handleReload}
               data-instant-nav-refresh
             >
-              <svg
-                width="14"
-                height="14"
-                viewBox="0 0 16 16"
-                fill="none"
-                xmlns="http://www.w3.org/2000/svg"
-              >
-                <path
-                  d="M2.5 8a5.5 5.5 0 0 1 9.68-3.578L11.092 5.5H14.5V2.092l-1.395 1.395A7 7 0 1 0 15 8h-1.5A5.5 5.5 0 0 1 2.5 8Z"
-                  fill="currentColor"
-                />
-              </svg>
+              <RotateClockwise />
               <span>Reload</span>
             </button>
           </div>
@@ -114,10 +112,33 @@ export function InstantNavsPanel() {
           <div className="instant-nav-section-header">
             <label>Client navigation</label>
             <p className="instant-nav-section-description">
-              Click any link in your app to view the prefetched UI for that
-              page.
+              Freeze the next navigation to view the prefetched UI.
             </p>
           </div>
+          <div className="instant-nav-section-control">
+            <button
+              className="action-button"
+              onClick={handleStartClientNav}
+              data-instant-nav-client
+            >
+              <span>Start</span>
+            </button>
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  if (panel.status === 'client-nav-waiting') {
+    return (
+      <div className="instant-nav-panel">
+        <div className="instant-nav-content">
+          <div className="instant-nav-section-header">
+            <label>Client navigation</label>
+          </div>
+          <p className="instant-nav-helper-description">
+            Click any link in your app to view the prefetched UI for that page.
+          </p>
         </div>
       </div>
     )
@@ -229,3 +250,22 @@ function ShareButton({ getShareUrl }: { getShareUrl: () => string }) {
     </button>
   )
 }
+
+function RotateClockwise() {
+  return (
+    <svg
+      width="14"
+      height="14"
+      strokeLinejoin="round"
+      viewBox="0 0 16 16"
+      fill="none"
+    >
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M2.5 8C2.5 4.96643 4.97431 2.5 8.03548 2.5C10.5716 2.5 12.7064 4.19393 13.3628 6.5H10.75H10V8H10.75H15.25C15.6642 8 16 7.66421 16 7.25V2.75V2H14.5V2.75V5.23347C13.4215 2.74164 10.9316 1 8.03548 1C4.1539 1 1 4.13001 1 8C1 11.87 4.1539 15 8.03548 15C10.3763 15 12.4513 13.8617 13.7295 12.1122L14.172 11.5066L12.9609 10.6217L12.5184 11.2273C11.5117 12.6051 9.87945 13.5 8.03548 13.5C4.97431 13.5 2.5 11.0336 2.5 8Z"
+        fill="currentColor"
+      />
+    </svg>
+  )
+}

filename: packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx
patch: @@ -16,7 +16,6 @@ import { useDevOverlayContext } from '../../dev-overlay.browser'
 import { createContext, useContext } from 'react'
 import { useRenderErrorContext } from '../dev-overlay'
 import {
-  ACTION_INSTANT_NAVS_TOGGLE,
   ACTION_DEV_INDICATOR_SET,
   ACTION_DEVTOOLS_POSITION,
   ACTION_DEVTOOLS_SCALE,
@@ -112,12 +111,6 @@ const MenuPanel = () => {
             label: 'Instant Navs',
             value: <ChevronRight />,
             onClick: () => {
-              // Set cookie to activate the navigation lock (read by
-              // navigation-testing-lock.ts via CookieStore change event)
-              document.cookie = 'next-instant-navigation-testing=1; path=/'
-              if (!state.instantNavs) {
-                dispatch({ type: ACTION_INSTANT_NAVS_TOGGLE })
-              }
               setPanel('instant-navs')
             },
             attributes: {

filename: packages/next/src/next-devtools/dev-overlay/shared.ts
patch: @@ -73,6 +73,7 @@ export interface OverlayState {
   readonly instantNavs: boolean
   readonly instantNavsPanel:
     | { readonly status: 'waiting' }
+    | { readonly status: 'client-nav-waiting' }
     | { readonly status: 'initial-load'; readonly toUrl: string }
     | {
         readonly status: 'client-nav'
@@ -234,6 +235,10 @@ interface CacheOnlyToggleAction {
 
 type InstantNavSetStatusAction =
   | { type: typeof ACTION_INSTANT_NAVS_SET_STATUS; status: 'waiting' }
+  | {
+      type: typeof ACTION_INSTANT_NAVS_SET_STATUS
+      status: 'client-nav-waiting'
+    }
   | {
       type: typeof ACTION_INSTANT_NAVS_SET_STATUS
       status: 'initial-load'
@@ -571,13 +576,15 @@ export function useErrorOverlayReducer(
             instantNavsPanel:
               action.status === 'waiting'
                 ? { status: 'waiting' }
-                : action.status === 'initial-load'
-                  ? { status: 'initial-load', toUrl: action.toUrl }
-                  : {
-                      status: 'client-nav',
-                      fromUrl: action.fromUrl,
-                      toUrl: action.toUrl,
-                    },
+                : action.status === 'client-nav-waiting'
+                  ? { status: 'client-nav-waiting' }
+                  : action.status === 'initial-load'
+                    ? { status: 'initial-load', toUrl: action.toUrl }
+                    : {
+                        status: 'client-nav',
+                        fromUrl: action.fromUrl,
+                        toUrl: action.toUrl,
+                      },
           }
         }
         case ACTION_INSTANT_NAVS_RESET: {

filename: test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts
patch: @@ -27,6 +27,17 @@ describe('instant-nav-panel', () => {
     })
   }
 
+  async function clickStartClientNav(browser: any) {
+    await browser.eval(() => {
+      const portal = [].slice
+        .call(document.querySelectorAll('nextjs-portal'))
+        .find((p: any) =>
+          p.shadowRoot.querySelector('[data-nextjs-toast]')
+        ) as any
+      portal?.shadowRoot?.querySelector('[data-instant-nav-client]')?.click()
+    })
+  }
+
   async function getBadgeStatus(browser: any): Promise<string> {
     return browser.eval(() => {
       const portal = [].slice
@@ -82,7 +93,7 @@ describe('instant-nav-panel', () => {
     await clickInstantNavMenuItem(browser)
   }
 
-  it('should open panel in waiting state and set cookie', async () => {
+  it('should open panel in waiting state without setting cookie', async () => {
     const browser = await next.browser('/')
     await clearInstantModeCookie(browser)
     await browser.waitForElementByCss('[data-testid="home-title"]')
@@ -102,15 +113,15 @@ describe('instant-nav-panel', () => {
       expect(text).toContain('Client navigation')
     })
 
-    // Cookie should be set to activate navigation lock
+    // Cookie should NOT be set yet (only set when user clicks Reload or Start)
     const cookie = await browser.eval(() => document.cookie)
-    expect(cookie).toContain('next-instant-navigation-testing=1')
+    expect(cookie).not.toContain('next-instant-navigation-testing=')
 
     // Clean up
     await clearInstantModeCookie(browser)
   })
 
-  it('should show client nav state after SPA navigation', async () => {
+  it('should show client nav state after clicking Start and navigating', async () => {
     const browser = await next.browser('/')
     await clearInstantModeCookie(browser)
     await browser.waitForElementByCss('[data-testid="home-title"]')
@@ -128,6 +139,22 @@ describe('instant-nav-panel', () => {
       expect(await hasPanelOpen(browser)).toBe(true)
     })
 
+    // Click Start to enter client-nav-waiting state
+    await clickStartClientNav(browser)
+
+    // Cookie should now be set
+    await retry(async () => {
+      const cookie = await browser.eval(() => document.cookie)
+      expect(cookie).toContain('next-instant-navigation-testing=1')
+    })
+
+    // Panel should show client-nav-waiting state
+    await retry(async () => {
+      const text = await getPanelText(browser)
+      expect(text).toContain('Client navigation')
+      expect(text).toContain('Click any link')
+    })
+
     // Navigate to target page via SPA (use eval to bypass overlay pointer interception)
     await browser.eval(() => {
       document.querySelector<HTMLAnchorElement>('#link-to-target')!.click()
@@ -145,18 +172,21 @@ describe('instant-nav-panel', () => {
     await clearInstantModeCookie(browser)
   })
 
-  it('should show loading skeleton during SPA navigation when panel is open', async () => {
+  it('should show loading skeleton during SPA navigation after clicking Start', async () => {
     const browser = await next.browser('/')
     await clearInstantModeCookie(browser)
     await browser.waitForElementByCss('[data-testid="home-title"]')
 
     await openInstantNavPanel(browser)
 
-    // Wait for panel to be open (instant mode is active)
+    // Wait for panel to be open
     await retry(async () => {
       expect(await hasPanelOpen(browser)).toBe(true)
     })
 
+    // Click Start to activate the navigation lock
+    await clickStartClientNav(browser)
+
     // Navigate to target page via SPA (use eval to bypass overlay pointer interception)
     await browser.eval(() => {
       document.querySelector<HTMLAnchorElement>('#link-to-target')!.click()
@@ -174,26 +204,24 @@ describe('instant-nav-panel', () => {
     await clearInstantModeCookie(browser)
   })
 
-  it('should clear cookie when closing panel via X button', async () => {
+  it('should not set cookie when closing panel from waiting state', async () => {
     const browser = await next.browser('/')
     await clearInstantModeCookie(browser)
     await browser.waitForElementByCss('[data-testid="home-title"]')
 
     await openInstantNavPanel(browser)
 
-    // Verify cookie is set
-    await retry(async () => {
-      const cookie = await browser.eval(() => document.cookie)
-      expect(cookie).toContain('next-instant-navigation-testing=')
-    })
+    // Verify cookie is NOT set (panel opened without activating lock)
+    const cookie = await browser.eval(() => document.cookie)
+    expect(cookie).not.toContain('next-instant-navigation-testing=')
 
     // Close panel via X button
     await closePanelViaHeader(browser)
 
-    // Cookie should be cleared
+    // Cookie should still not be set, and no reload should happen
     await retry(async () => {
-      const cookie = await browser.eval(() => document.cookie)
-      expect(cookie).not.toContain('next-instant-navigation-testing=')
+      const cookieAfter = await browser.eval(() => document.cookie)
+      expect(cookieAfter).not.toContain('next-instant-navigation-testing=')
     })
   })
 })