Skip to content

Commit 47ceda3

Browse files
authored
[cache components] persist cache bypass UI until it's disabled (#85190)
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
1 parent b66c1d6 commit 47ceda3

File tree

4 files changed

+226
-31
lines changed

4 files changed

+226
-31
lines changed

packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,14 @@ export function NextLogo({
4545
const isCacheFilling = state.cacheIndicator === 'filling'
4646
const isCacheBypassing = state.cacheIndicator === 'bypass'
4747

48-
// Determine if we should show any status
48+
// Determine if we should show any status (excluding cache bypass, which renders like error badge)
4949
const shouldShowStatus =
50-
state.buildingIndicator ||
51-
state.renderingIndicator ||
52-
isCacheFilling ||
53-
(isCacheBypassing && state.renderingIndicator)
50+
state.buildingIndicator || state.renderingIndicator || isCacheFilling
5451

5552
// Delay showing for 400ms to catch fast operations,
5653
// and keep visible for minimum time (longer for warnings)
5754
const { rendered: showStatusIndicator } = useDelayedRender(shouldShowStatus, {
58-
enterDelay: isCacheBypassing && state.renderingIndicator ? 0 : 400, // Bypass warning shows immediately, others delayed
55+
enterDelay: 400,
5956
exitDelay: 500,
6057
})
6158

@@ -72,7 +69,10 @@ export function NextLogo({
7269
const displayStatus = showStatusIndicator ? currentStatus : Status.None
7370

7471
const isExpanded =
75-
isErrorExpanded || showStatusIndicator || state.disableDevIndicator
72+
isErrorExpanded ||
73+
isCacheBypassing ||
74+
showStatusIndicator ||
75+
state.disableDevIndicator
7676
const width = measuredWidth === 0 ? 'auto' : measuredWidth
7777

7878
return (
@@ -175,12 +175,12 @@ export function NextLogo({
175175
}
176176
}
177177
178-
&[data-status='cache-bypassing'] {
179-
background: rgba(251, 211, 141, 0.95); /* Warm amber background */
180-
--color-inner-border: rgba(245, 158, 11, 0.8);
178+
&[data-cache-bypassing='true']:not([data-error='true']) {
179+
background: rgba(217, 119, 6, 0.95);
180+
--color-inner-border: rgba(245, 158, 11, 0.9);
181181
182-
[data-indicator-status] {
183-
color: rgba(92, 45, 10, 1); /* Dark brown text */
182+
[data-issues-open] {
183+
color: white;
184184
}
185185
}
186186
@@ -378,7 +378,8 @@ export function NextLogo({
378378
data-next-badge
379379
data-error={hasError}
380380
data-error-expanded={isExpanded}
381-
data-status={hasError ? Status.None : currentStatus}
381+
data-status={hasError || isCacheBypassing ? Status.None : currentStatus}
382+
data-cache-bypassing={isCacheBypassing}
382383
data-animate={newErrorDetected}
383384
style={{ width }}
384385
>
@@ -397,7 +398,10 @@ export function NextLogo({
397398
aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
398399
data-nextjs-dev-tools-button
399400
style={{
400-
display: showStatusIndicator && !hasError ? 'none' : 'flex',
401+
display:
402+
showStatusIndicator && !hasError && !isCacheBypassing
403+
? 'none'
404+
: 'flex',
401405
}}
402406
{...buttonProps}
403407
>
@@ -472,11 +476,22 @@ export function NextLogo({
472476
)}
473477
</div>
474478
)}
475-
{/* Status indicator shown when no errors */}
479+
{/* Cache bypass badge shown when cache is being bypassed */}
480+
{isCacheBypassing && !hasError && !state.disableDevIndicator && (
481+
<CacheBypassBadge
482+
onTriggerClick={onTriggerClick}
483+
triggerRef={triggerRef}
484+
/>
485+
)}
486+
{/* Status indicator shown when no errors and no cache bypass */}
476487
{showStatusIndicator &&
477488
!hasError &&
489+
!isCacheBypassing &&
478490
!state.disableDevIndicator && (
479-
<StatusIndicator status={displayStatus} />
491+
<StatusIndicator
492+
status={displayStatus}
493+
onClick={onTriggerClick}
494+
/>
480495
)}
481496
</>
482497
)}
@@ -507,6 +522,43 @@ function AnimateCount({
507522
)
508523
}
509524

525+
function CacheBypassBadge({
526+
onTriggerClick,
527+
triggerRef,
528+
}: {
529+
onTriggerClick: () => void
530+
triggerRef: React.RefObject<HTMLButtonElement | null>
531+
}) {
532+
const [dismissed, setDismissed] = useState(false)
533+
534+
if (dismissed) {
535+
return null
536+
}
537+
538+
return (
539+
<div data-issues data-cache-bypass-badge>
540+
<button
541+
data-issues-open
542+
aria-label="Open Next.js Dev Tools"
543+
onClick={onTriggerClick}
544+
>
545+
Cache disabled
546+
</button>
547+
<button
548+
data-issues-collapse
549+
aria-label="Collapse cache bypass badge"
550+
onClick={() => {
551+
setDismissed(true)
552+
// Move focus to the trigger to prevent having it stuck on this element
553+
triggerRef.current?.focus()
554+
}}
555+
>
556+
<Cross data-cross />
557+
</button>
558+
</div>
559+
)
560+
}
561+
510562
function NextMark() {
511563
return (
512564
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">

packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/status-indicator.tsx

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,15 @@ export function getCurrentStatus(
1515
cacheIndicator: CacheIndicatorState
1616
): Status {
1717
const isCacheFilling = cacheIndicator === 'filling'
18-
const isCacheBypassing = cacheIndicator === 'bypass'
1918

20-
// Priority order: cache bypassing > prerendering > compiling > rendering
21-
if (isCacheBypassing && renderingIndicator) {
22-
return Status.CacheBypassing
19+
// Priority order: compiling > prerendering > rendering
20+
// Note: cache bypassing is now handled as a badge, not a status indicator
21+
if (buildingIndicator) {
22+
return Status.Compiling
2323
}
2424
if (isCacheFilling) {
2525
return Status.Prerendering
2626
}
27-
if (buildingIndicator) {
28-
return Status.Compiling
29-
}
3027
if (renderingIndicator) {
3128
return Status.Rendering
3229
}
@@ -35,9 +32,10 @@ export function getCurrentStatus(
3532

3633
interface StatusIndicatorProps {
3734
status: Status
35+
onClick?: () => void
3836
}
3937

40-
export function StatusIndicator({ status }: StatusIndicatorProps) {
38+
export function StatusIndicator({ status, onClick }: StatusIndicatorProps) {
4139
const statusText: Record<Status, string> = {
4240
[Status.None]: '',
4341
[Status.CacheBypassing]: 'Cache disabled',
@@ -78,6 +76,15 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
7876
font-size: var(--size-13);
7977
font-weight: 500;
8078
white-space: nowrap;
79+
border: none;
80+
background: transparent;
81+
cursor: pointer;
82+
outline: none;
83+
}
84+
85+
[data-indicator-status]:focus-visible {
86+
outline: 2px solid var(--color-blue-800, #3b82f6);
87+
outline-offset: 3px;
8188
}
8289
8390
[data-status-dot] {
@@ -149,7 +156,11 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
149156
}
150157
`}
151158
</style>
152-
<div data-indicator-status>
159+
<button
160+
data-indicator-status
161+
onClick={onClick}
162+
aria-label="Open Next.js Dev Tools"
163+
>
153164
{statusDotColor[status] && (
154165
<div
155166
data-status-dot
@@ -161,29 +172,34 @@ export function StatusIndicator({ status }: StatusIndicatorProps) {
161172
<AnimateStatusText
162173
key={status} // Key here triggers re-mount and animation
163174
statusKey={status}
175+
showEllipsis={status !== Status.CacheBypassing}
164176
>
165177
{statusText[status]}
166178
</AnimateStatusText>
167-
</div>
179+
</button>
168180
</>
169181
)
170182
}
171183

172184
function AnimateStatusText({
173185
children: text,
186+
showEllipsis = true,
174187
}: {
175188
children: string
176189
statusKey?: string // Keep for type compatibility but unused
190+
showEllipsis?: boolean
177191
}) {
178192
return (
179193
<div data-status-text-animation>
180194
<div data-status-text-enter>
181195
{text}
182-
<span data-status-ellipsis>
183-
<span>.</span>
184-
<span>.</span>
185-
<span>.</span>
186-
</span>
196+
{showEllipsis && (
197+
<span data-status-ellipsis>
198+
<span>.</span>
199+
<span>.</span>
200+
<span>.</span>
201+
</span>
202+
)}
187203
</div>
188204
</div>
189205
)

packages/next/src/server/app-render/app-render.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2477,6 +2477,10 @@ async function renderToStream(
24772477
if (isBypassingCachesInDev(renderOpts, requestStore)) {
24782478
// Mark the RSC payload to indicate that caches were bypassed in dev.
24792479
// This lets the client know not to cache anything based on this render.
2480+
if (renderOpts.setCacheStatus) {
2481+
// we know this is available when cacheComponents is enabled, but typeguard to be safe
2482+
renderOpts.setCacheStatus('bypass', htmlRequestId, requestId)
2483+
}
24802484
payload._bypassCachesInDev = createElement(WarnForBypassCachesInDev, {
24812485
route: workStore.route,
24822486
})

test/development/app-dir/cache-indicator/cache-indicator.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,128 @@ describe('cache-indicator', () => {
3939
const status = await badge.getAttribute('data-status')
4040
expect(status).toBe('none')
4141
})
42+
43+
it('shows cache-bypassing badge when cache is disabled', async () => {
44+
const browser = await next.browser('/', {
45+
extraHTTPHeaders: { 'cache-control': 'no-cache' },
46+
})
47+
48+
// Wait for the badge to appear and show cache-bypassing status
49+
await retry(async () => {
50+
const badge = await browser.elementByCss('[data-next-badge]')
51+
const cacheBypassingAttr = await badge.getAttribute(
52+
'data-cache-bypassing'
53+
)
54+
expect(cacheBypassingAttr).toBe('true')
55+
})
56+
57+
// Verify the cache bypass badge is visible
58+
await retry(async () => {
59+
const hasCacheBypassBadge = await browser.hasElementByCss(
60+
'[data-cache-bypass-badge]'
61+
)
62+
expect(hasCacheBypassBadge).toBe(true)
63+
})
64+
65+
// Verify the badge shows "Cache disabled" text
66+
const badgeButton = await browser.elementByCss(
67+
'[data-cache-bypass-badge] [data-issues-open]'
68+
)
69+
const badgeText = await badgeButton.text()
70+
expect(badgeText).toBe('Cache disabled')
71+
})
72+
73+
it('persists cache-bypassing badge after navigation when cache is disabled', async () => {
74+
const browser = await next.browser('/', {
75+
extraHTTPHeaders: { 'cache-control': 'no-cache' },
76+
})
77+
78+
// Wait for initial cache-bypassing badge
79+
await retry(async () => {
80+
const hasCacheBypassBadge = await browser.hasElementByCss(
81+
'[data-cache-bypass-badge]'
82+
)
83+
expect(hasCacheBypassBadge).toBe(true)
84+
})
85+
86+
// Navigate to another page
87+
const link = await browser.waitForElementByCss('a[href="/navigation"]')
88+
await link.click()
89+
90+
// Wait for navigation to complete
91+
await retry(async () => {
92+
const text = await browser.elementByCss('#navigation-page').text()
93+
expect(text).toContain('Hello navigation page!')
94+
})
95+
96+
// Verify cache-bypassing badge persists after navigation
97+
await retry(async () => {
98+
const hasCacheBypassBadge = await browser.hasElementByCss(
99+
'[data-cache-bypass-badge]'
100+
)
101+
expect(hasCacheBypassBadge).toBe(true)
102+
})
103+
104+
// Verify the badge still shows "Cache disabled" text
105+
const badgeButton = await browser.elementByCss(
106+
'[data-cache-bypass-badge] [data-issues-open]'
107+
)
108+
const badgeText = await badgeButton.text()
109+
expect(badgeText).toBe('Cache disabled')
110+
})
111+
112+
it('opens devtools menu when clicking cache-bypassing badge', async () => {
113+
const browser = await next.browser('/', {
114+
extraHTTPHeaders: { 'cache-control': 'no-cache' },
115+
})
116+
117+
// Wait for the cache-bypassing badge to appear
118+
await retry(async () => {
119+
const hasCacheBypassBadge = await browser.hasElementByCss(
120+
'[data-cache-bypass-badge]'
121+
)
122+
expect(hasCacheBypassBadge).toBe(true)
123+
})
124+
125+
// Click the cache bypass badge
126+
const badgeButton = await browser.elementByCss(
127+
'[data-cache-bypass-badge] [data-issues-open]'
128+
)
129+
await badgeButton.click()
130+
131+
// Verify devtools menu opens
132+
await retry(async () => {
133+
const hasMenu = await browser.hasElementByCss('#nextjs-dev-tools-menu')
134+
expect(hasMenu).toBe(true)
135+
})
136+
})
137+
138+
it('can dismiss cache-bypassing badge', async () => {
139+
const browser = await next.browser('/', {
140+
extraHTTPHeaders: { 'cache-control': 'no-cache' },
141+
})
142+
143+
// Wait for the cache-bypassing badge to appear
144+
await retry(async () => {
145+
const hasCacheBypassBadge = await browser.hasElementByCss(
146+
'[data-cache-bypass-badge]'
147+
)
148+
expect(hasCacheBypassBadge).toBe(true)
149+
})
150+
151+
// Click the collapse button
152+
const collapseButton = await browser.elementByCss(
153+
'[data-cache-bypass-badge] [data-issues-collapse]'
154+
)
155+
await collapseButton.click()
156+
157+
// Verify badge is dismissed
158+
await retry(async () => {
159+
const hasCacheBypassBadge = await browser.hasElementByCss(
160+
'[data-cache-bypass-badge]'
161+
)
162+
expect(hasCacheBypassBadge).toBe(false)
163+
})
164+
})
42165
}
43166
})

0 commit comments

Comments
 (0)