Repository: vercel/next.js
Pull request: [cache components] persist cache bypass UI until it's disabled

Previously we were only showing this during navigations when caches are bypassed. We want to make it clearer that you are in this state and dev might not work as intended while caches are disabled.

This will show the cache bypass status on initial load and will reset the next time a router action occurs that doesn't bypass cache.

https://github.com/user-attachments/assets/f82a98c6-b5be-44d0-ba86-e4f7ca7010e4



Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx
patch: @@ -45,17 +45,14 @@ export function NextLogo({
   const isCacheFilling = state.cacheIndicator === 'filling'
   const isCacheBypassing = state.cacheIndicator === 'bypass'
 
-  // Determine if we should show any status
+  // Determine if we should show any status (excluding cache bypass, which renders like error badge)
   const shouldShowStatus =
-    state.buildingIndicator ||
-    state.renderingIndicator ||
-    isCacheFilling ||
-    (isCacheBypassing && state.renderingIndicator)
+    state.buildingIndicator || state.renderingIndicator || isCacheFilling
 
   // Delay showing for 400ms to catch fast operations,
   // and keep visible for minimum time (longer for warnings)
   const { rendered: showStatusIndicator } = useDelayedRender(shouldShowStatus, {
-    enterDelay: isCacheBypassing && state.renderingIndicator ? 0 : 400, // Bypass warning shows immediately, others delayed
+    enterDelay: 400,
     exitDelay: 500,
   })
 
@@ -72,7 +69,10 @@ export function NextLogo({
   const displayStatus = showStatusIndicator ? currentStatus : Status.None
 
   const isExpanded =
-    isErrorExpanded || showStatusIndicator || state.disableDevIndicator
+    isErrorExpanded ||
+    isCacheBypassing ||
+    showStatusIndicator ||
+    state.disableDevIndicator
   const width = measuredWidth === 0 ? 'auto' : measuredWidth
 
   return (
@@ -175,12 +175,12 @@ export function NextLogo({
               }
             }
 
-            &[data-status='cache-bypassing'] {
-              background: rgba(251, 211, 141, 0.95); /* Warm amber background */
-              --color-inner-border: rgba(245, 158, 11, 0.8);
+            &[data-cache-bypassing='true']:not([data-error='true']) {
+              background: rgba(217, 119, 6, 0.95);
+              --color-inner-border: rgba(245, 158, 11, 0.9);
 
-              [data-indicator-status] {
-                color: rgba(92, 45, 10, 1); /* Dark brown text */
+              [data-issues-open] {
+                color: white;
               }
             }
 
@@ -378,7 +378,8 @@ export function NextLogo({
         data-next-badge
         data-error={hasError}
         data-error-expanded={isExpanded}
-        data-status={hasError ? Status.None : currentStatus}
+        data-status={hasError || isCacheBypassing ? Status.None : currentStatus}
+        data-cache-bypassing={isCacheBypassing}
         data-animate={newErrorDetected}
         style={{ width }}
       >
@@ -397,7 +398,10 @@ export function NextLogo({
               aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
               data-nextjs-dev-tools-button
               style={{
-                display: showStatusIndicator && !hasError ? 'none' : 'flex',
+                display:
+                  showStatusIndicator && !hasError && !isCacheBypassing
+                    ? 'none'
+                    : 'flex',
               }}
               {...buttonProps}
             >
@@ -472,11 +476,22 @@ export function NextLogo({
                   )}
                 </div>
               )}
-              {/* Status indicator shown when no errors */}
+              {/* Cache bypass badge shown when cache is being bypassed */}
+              {isCacheBypassing && !hasError && !state.disableDevIndicator && (
+                <CacheBypassBadge
+                  onTriggerClick={onTriggerClick}
+                  triggerRef={triggerRef}
+                />
+              )}
+              {/* Status indicator shown when no errors and no cache bypass */}
               {showStatusIndicator &&
                 !hasError &&
+                !isCacheBypassing &&
                 !state.disableDevIndicator && (
-                  <StatusIndicator status={displayStatus} />
+                  <StatusIndicator
+                    status={displayStatus}
+                    onClick={onTriggerClick}
+                  />
                 )}
             </>
           )}
@@ -507,6 +522,43 @@ function AnimateCount({
   )
 }
 
+function CacheBypassBadge({
+  onTriggerClick,
+  triggerRef,
+}: {
+  onTriggerClick: () => void
+  triggerRef: React.RefObject<HTMLButtonElement | null>
+}) {
+  const [dismissed, setDismissed] = useState(false)
+
+  if (dismissed) {
+    return null
+  }
+
+  return (
+    <div data-issues data-cache-bypass-badge>
+      <button
+        data-issues-open
+        aria-label="Open Next.js Dev Tools"
+        onClick={onTriggerClick}
+      >
+        Cache disabled
+      </button>
+      <button
+        data-issues-collapse
+        aria-label="Collapse cache bypass badge"
+        onClick={() => {
+          setDismissed(true)
+          // Move focus to the trigger to prevent having it stuck on this element
+          triggerRef.current?.focus()
+        }}
+      >
+        <Cross data-cross />
+      </button>
+    </div>
+  )
+}
+
 function NextMark() {
   return (
     <svg width="40" height="40" viewBox="0 0 40 40" fill="none">

filename: packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/status-indicator.tsx
patch: @@ -15,18 +15,15 @@ export function getCurrentStatus(
   cacheIndicator: CacheIndicatorState
 ): Status {
   const isCacheFilling = cacheIndicator === 'filling'
-  const isCacheBypassing = cacheIndicator === 'bypass'
 
-  // Priority order: cache bypassing > prerendering > compiling > rendering
-  if (isCacheBypassing && renderingIndicator) {
-    return Status.CacheBypassing
+  // Priority order: compiling > prerendering > rendering
+  // Note: cache bypassing is now handled as a badge, not a status indicator
+  if (buildingIndicator) {
+    return Status.Compiling
   }
   if (isCacheFilling) {
     return Status.Prerendering
   }
-  if (buildingIndicator) {
-    return Status.Compiling
-  }
   if (renderingIndicator) {
     return Status.Rendering
   }
@@ -35,9 +32,10 @@ export function getCurrentStatus(
 
 interface StatusIndicatorProps {
   status: Status
+  onClick?: () => void
 }
 
-export function StatusIndicator({ status }: StatusIndicatorProps) {
+export function StatusIndicator({ status, onClick }: StatusIndicatorProps) {
   const statusText: Record<Status, string> = {
     [Status.None]: '',
     [Status.CacheBypassing]: 'Cache disabled',
@@ -78,6 +76,15 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
             font-size: var(--size-13);
             font-weight: 500;
             white-space: nowrap;
+            border: none;
+            background: transparent;
+            cursor: pointer;
+            outline: none;
+          }
+
+          [data-indicator-status]:focus-visible {
+            outline: 2px solid var(--color-blue-800, #3b82f6);
+            outline-offset: 3px;
           }
 
           [data-status-dot] {
@@ -149,7 +156,11 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
           }
         `}
       </style>
-      <div data-indicator-status>
+      <button
+        data-indicator-status
+        onClick={onClick}
+        aria-label="Open Next.js Dev Tools"
+      >
         {statusDotColor[status] && (
           <div
             data-status-dot
@@ -161,29 +172,34 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
         <AnimateStatusText
           key={status} // Key here triggers re-mount and animation
           statusKey={status}
+          showEllipsis={status !== Status.CacheBypassing}
         >
           {statusText[status]}
         </AnimateStatusText>
-      </div>
+      </button>
     </>
   )
 }
 
 function AnimateStatusText({
   children: text,
+  showEllipsis = true,
 }: {
   children: string
   statusKey?: string // Keep for type compatibility but unused
+  showEllipsis?: boolean
 }) {
   return (
     <div data-status-text-animation>
       <div data-status-text-enter>
         {text}
-        <span data-status-ellipsis>
-          <span>.</span>
-          <span>.</span>
-          <span>.</span>
-        </span>
+        {showEllipsis && (
+          <span data-status-ellipsis>
+            <span>.</span>
+            <span>.</span>
+            <span>.</span>
+          </span>
+        )}
       </div>
     </div>
   )

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -2477,6 +2477,10 @@ async function renderToStream(
         if (isBypassingCachesInDev(renderOpts, requestStore)) {
           // Mark the RSC payload to indicate that caches were bypassed in dev.
           // This lets the client know not to cache anything based on this render.
+          if (renderOpts.setCacheStatus) {
+            // we know this is available  when cacheComponents is enabled, but typeguard to be safe
+            renderOpts.setCacheStatus('bypass', htmlRequestId, requestId)
+          }
           payload._bypassCachesInDev = createElement(WarnForBypassCachesInDev, {
             route: workStore.route,
           })

filename: test/development/app-dir/cache-indicator/cache-indicator.test.ts
patch: @@ -39,5 +39,128 @@ describe('cache-indicator', () => {
       const status = await badge.getAttribute('data-status')
       expect(status).toBe('none')
     })
+
+    it('shows cache-bypassing badge when cache is disabled', async () => {
+      const browser = await next.browser('/', {
+        extraHTTPHeaders: { 'cache-control': 'no-cache' },
+      })
+
+      // Wait for the badge to appear and show cache-bypassing status
+      await retry(async () => {
+        const badge = await browser.elementByCss('[data-next-badge]')
+        const cacheBypassingAttr = await badge.getAttribute(
+          'data-cache-bypassing'
+        )
+        expect(cacheBypassingAttr).toBe('true')
+      })
+
+      // Verify the cache bypass badge is visible
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(true)
+      })
+
+      // Verify the badge shows "Cache disabled" text
+      const badgeButton = await browser.elementByCss(
+        '[data-cache-bypass-badge] [data-issues-open]'
+      )
+      const badgeText = await badgeButton.text()
+      expect(badgeText).toBe('Cache disabled')
+    })
+
+    it('persists cache-bypassing badge after navigation when cache is disabled', async () => {
+      const browser = await next.browser('/', {
+        extraHTTPHeaders: { 'cache-control': 'no-cache' },
+      })
+
+      // Wait for initial cache-bypassing badge
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(true)
+      })
+
+      // Navigate to another page
+      const link = await browser.waitForElementByCss('a[href="/navigation"]')
+      await link.click()
+
+      // Wait for navigation to complete
+      await retry(async () => {
+        const text = await browser.elementByCss('#navigation-page').text()
+        expect(text).toContain('Hello navigation page!')
+      })
+
+      // Verify cache-bypassing badge persists after navigation
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(true)
+      })
+
+      // Verify the badge still shows "Cache disabled" text
+      const badgeButton = await browser.elementByCss(
+        '[data-cache-bypass-badge] [data-issues-open]'
+      )
+      const badgeText = await badgeButton.text()
+      expect(badgeText).toBe('Cache disabled')
+    })
+
+    it('opens devtools menu when clicking cache-bypassing badge', async () => {
+      const browser = await next.browser('/', {
+        extraHTTPHeaders: { 'cache-control': 'no-cache' },
+      })
+
+      // Wait for the cache-bypassing badge to appear
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(true)
+      })
+
+      // Click the cache bypass badge
+      const badgeButton = await browser.elementByCss(
+        '[data-cache-bypass-badge] [data-issues-open]'
+      )
+      await badgeButton.click()
+
+      // Verify devtools menu opens
+      await retry(async () => {
+        const hasMenu = await browser.hasElementByCss('#nextjs-dev-tools-menu')
+        expect(hasMenu).toBe(true)
+      })
+    })
+
+    it('can dismiss cache-bypassing badge', async () => {
+      const browser = await next.browser('/', {
+        extraHTTPHeaders: { 'cache-control': 'no-cache' },
+      })
+
+      // Wait for the cache-bypassing badge to appear
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(true)
+      })
+
+      // Click the collapse button
+      const collapseButton = await browser.elementByCss(
+        '[data-cache-bypass-badge] [data-issues-collapse]'
+      )
+      await collapseButton.click()
+
+      // Verify badge is dismissed
+      await retry(async () => {
+        const hasCacheBypassBadge = await browser.hasElementByCss(
+          '[data-cache-bypass-badge]'
+        )
+        expect(hasCacheBypassBadge).toBe(false)
+      })
+    })
   }
 })