Skip to content

Commit 8cc2dd0

Browse files
ijjkhuozhi
andauthored
Add prerender indicator for app router (vercel#67306)
## Background Currently in app router during development all pages are dynamic rendered and streamed since we don't know if a page will be prerendered until after the page is rendered. During build time we do a render pass for each path and if it doesn't bail from static generation it is considered prerendered/ISR'd. Since it can be tricky to track all the bail outs for static generation and doing numerous builds to track this isn't feasible we want to have some sort of signal in development to show if it would have been prerendered. ## New Handling To alleviate the above pain point we are planning to bring back the prerendered indicator from pages initially and have a config to disable if desired. We are also going to investigate other possible indicators potentially through headers or similar. <details> <summary>Preview</summary> ![CleanShot 2024-06-29 at 08 56 11@2x](https://github.com/vercel/next.js/assets/22380829/37d4d351-903d-49a2-9541-932e7d5a1e54) ![CleanShot 2024-06-29 at 08 55 46@2x](https://github.com/vercel/next.js/assets/22380829/8356f287-e0d0-4c37-aa1e-1f08639dd478) </details> --------- Co-authored-by: Jiachi Liu <inbox@huozhi.im>
1 parent 026f887 commit 8cc2dd0

File tree

27 files changed

+573
-17
lines changed

27 files changed

+573
-17
lines changed

docs/02-app/02-api-reference/05-next-config-js/devIndicators.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ module.exports = {
3131
}
3232
```
3333

34+
There is also an indicator to show whether a page will be prerendered during a build in dev. It can be hidden per-page by clicking although if you never want it to show it can be disabled:
35+
36+
```js filename="next.config.js"
37+
module.exports = {
38+
devIndicators: {
39+
appIsrStatus: false,
40+
},
41+
}
42+
```
43+
3444
</AppOnly>
3545

3646
<PagesOnly>

packages/next/src/build/webpack/plugins/define-env-plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ export function getDefineEnv({
173173
? 'nodejs'
174174
: '',
175175
'process.env.NEXT_MINIMAL': '',
176+
'process.env.__NEXT_APP_ISR_INDICATOR': Boolean(
177+
config.devIndicators.appIsrStatus
178+
),
176179
'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr),
177180
'process.env.__NEXT_AFTER': config.experimental.after ?? false,
178181
'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false,

packages/next/src/client/app-index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ window.console.error = (...args) => {
3737
}
3838
}
3939

40+
if (process.env.NODE_ENV === 'development') {
41+
const initializePrerenderIndicator =
42+
require('./components/prerender-indicator')
43+
.default as typeof import('./components/prerender-indicator').default
44+
45+
initializePrerenderIndicator((handlers) => {
46+
window.next.isrIndicatorHandlers = handlers
47+
})
48+
}
49+
4050
/// <reference types="react-dom/experimental" />
4151

4252
const appElement: HTMLElement | Document | null = document
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import type { ShowHideHandler } from '../dev/dev-build-watcher'
2+
3+
export default function initializePrerenderIndicator(
4+
toggleCallback: (handlers: ShowHideHandler) => void
5+
) {
6+
const shadowHost = document.createElement('div')
7+
shadowHost.id = '__next-prerender-indicator'
8+
// Make sure container is fixed and on a high zIndex so it shows
9+
shadowHost.style.position = 'fixed'
10+
shadowHost.style.bottom = '20px'
11+
shadowHost.style.right = '10px'
12+
shadowHost.style.width = '0'
13+
shadowHost.style.height = '0'
14+
shadowHost.style.zIndex = '99998'
15+
shadowHost.style.transition = 'all 100ms ease'
16+
17+
document.body.appendChild(shadowHost)
18+
19+
let shadowRoot
20+
let prefix = ''
21+
22+
if (shadowHost.attachShadow) {
23+
shadowRoot = shadowHost.attachShadow({ mode: 'open' })
24+
} else {
25+
// If attachShadow is undefined then the browser does not support
26+
// the Shadow DOM, we need to prefix all the names so there
27+
// will be no conflicts
28+
shadowRoot = shadowHost
29+
prefix = '__next-prerender-indicator-'
30+
}
31+
32+
// Container
33+
const container = createContainer(prefix)
34+
shadowRoot.appendChild(container)
35+
36+
// CSS
37+
const css = createCss(prefix)
38+
shadowRoot.appendChild(css)
39+
40+
const expandEl = container.querySelector('a')
41+
const closeEl: HTMLDivElement | null = container.querySelector(
42+
`#${prefix}close`
43+
) as any
44+
45+
if (closeEl) {
46+
closeEl.title = 'Click to disable indicator for one hour in this session'
47+
}
48+
49+
// State
50+
const dismissKey = '__NEXT_DISMISS_PRERENDER_INDICATOR'
51+
const dismissUntil = parseInt(
52+
window.localStorage.getItem(dismissKey) || '',
53+
10
54+
)
55+
const dismissed = dismissUntil > new Date().getTime()
56+
57+
if (dismissed) return
58+
59+
let isVisible = false
60+
61+
function updateContainer() {
62+
if (isVisible) {
63+
container.classList.add(`${prefix}visible`)
64+
} else {
65+
container.classList.remove(`${prefix}visible`)
66+
}
67+
}
68+
const expandedClass = `${prefix}expanded`
69+
let toggleTimeout: ReturnType<typeof setTimeout>
70+
71+
const toggleExpand = (expand = true) => {
72+
clearTimeout(toggleTimeout)
73+
74+
toggleTimeout = setTimeout(() => {
75+
if (expand) {
76+
expandEl?.classList.add(expandedClass)
77+
if (closeEl) {
78+
closeEl.style.display = 'flex'
79+
}
80+
} else {
81+
expandEl?.classList.remove(expandedClass)
82+
if (closeEl) {
83+
closeEl.style.display = 'none'
84+
}
85+
}
86+
}, 50)
87+
}
88+
89+
closeEl?.addEventListener('click', () => {
90+
const oneHourAway = new Date().getTime() + 1 * 60 * 60 * 1000
91+
window.localStorage.setItem(dismissKey, oneHourAway + '')
92+
isVisible = false
93+
updateContainer()
94+
})
95+
closeEl?.addEventListener('mouseenter', () => toggleExpand())
96+
closeEl?.addEventListener('mouseleave', () => toggleExpand(false))
97+
expandEl?.addEventListener('mouseenter', () => toggleExpand())
98+
expandEl?.addEventListener('mouseleave', () => toggleExpand(false))
99+
100+
toggleCallback({
101+
show: () => {
102+
isVisible = true
103+
updateContainer()
104+
},
105+
hide: () => {
106+
isVisible = false
107+
updateContainer()
108+
},
109+
})
110+
}
111+
112+
function createContainer(prefix: string) {
113+
const container = document.createElement('div')
114+
container.id = `${prefix}container`
115+
container.innerHTML = `
116+
<button id="${prefix}close" title="Hide indicator for session">
117+
<span>×</span>
118+
</button>
119+
<a href="https://nextjs.org/docs/api-reference/next.config.js/devIndicators" target="_blank" rel="noreferrer">
120+
<div id="${prefix}icon-wrapper">
121+
<svg width="15" height="20" viewBox="0 0 60 80" fill="none" xmlns="http://www.w3.org/2000/svg">
122+
<path d="M36 3L30.74 41H8L36 3Z" fill="black"/>
123+
<path d="M25 77L30.26 39H53L25 77Z" fill="black"/>
124+
<path d="M13.5 33.5L53 39L47.5 46.5L7 41.25L13.5 33.5Z" fill="black"/>
125+
</svg>
126+
Prerendered Page
127+
</div>
128+
</a>
129+
`
130+
return container
131+
}
132+
133+
function createCss(prefix: string) {
134+
const css = document.createElement('style')
135+
css.textContent = `
136+
#${prefix}container {
137+
position: absolute;
138+
display: none;
139+
bottom: 10px;
140+
right: 15px;
141+
}
142+
143+
#${prefix}close {
144+
top: -10px;
145+
right: -10px;
146+
border: none;
147+
width: 18px;
148+
height: 18px;
149+
color: #333333;
150+
font-size: 16px;
151+
cursor: pointer;
152+
display: none;
153+
position: absolute;
154+
background: #ffffff;
155+
border-radius: 100%;
156+
align-items: center;
157+
flex-direction: column;
158+
justify-content: center;
159+
}
160+
161+
#${prefix}container a {
162+
color: inherit;
163+
text-decoration: none;
164+
width: 15px;
165+
height: 23px;
166+
overflow: hidden;
167+
168+
border-radius: 3px;
169+
background: #fff;
170+
color: #000;
171+
font: initial;
172+
cursor: pointer;
173+
letter-spacing: initial;
174+
text-shadow: initial;
175+
text-transform: initial;
176+
visibility: initial;
177+
font-size: 14px;
178+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
179+
180+
padding: 4px 2px;
181+
align-items: center;
182+
box-shadow: 0 11px 40px 0 rgba(0, 0, 0, 0.25), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
183+
184+
display: flex;
185+
transition: opacity 0.1s ease, bottom 0.1s ease, width 0.3s ease;
186+
animation: ${prefix}fade-in 0.1s ease-in-out;
187+
}
188+
189+
#${prefix}icon-wrapper {
190+
width: 140px;
191+
height: 20px;
192+
display: flex;
193+
flex-shrink: 0;
194+
align-items: center;
195+
position: relative;
196+
}
197+
198+
#${prefix}icon-wrapper svg {
199+
flex-shrink: 0;
200+
margin-right: 3px;
201+
}
202+
203+
#${prefix}container a.${prefix}expanded {
204+
width: 135px;
205+
}
206+
207+
#${prefix}container.${prefix}visible {
208+
display: flex;
209+
bottom: 10px;
210+
opacity: 1;
211+
}
212+
213+
@keyframes ${prefix}fade-in {
214+
from {
215+
bottom: 0px;
216+
opacity: 0;
217+
}
218+
to {
219+
bottom: 10px;
220+
opacity: 1;
221+
}
222+
}
223+
`
224+
225+
return css
226+
}

packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { ReactNode } from 'react'
2-
import { useCallback, useEffect, startTransition, useMemo } from 'react'
2+
import { useCallback, useEffect, startTransition, useMemo, useRef } from 'react'
33
import stripAnsi from 'next/dist/compiled/strip-ansi'
44
import formatWebpackMessages from '../internal/helpers/format-webpack-messages'
5-
import { useRouter } from '../../navigation'
5+
import { usePathname, useRouter } from '../../navigation'
66
import {
77
ACTION_BEFORE_REFRESH,
88
ACTION_BUILD_ERROR,
@@ -33,6 +33,7 @@ import type {
3333
import { extractModulesFromTurbopackMessage } from '../../../../server/dev/extract-modules-from-turbopack-message'
3434
import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared'
3535
import type { HydrationErrorState } from '../internal/helpers/hydration-error-info'
36+
import type { ShowHideHandler } from '../../../dev/dev-build-watcher'
3637
interface Dispatcher {
3738
onBuildOk(): void
3839
onBuildError(message: string): void
@@ -241,7 +242,9 @@ function processMessage(
241242
sendMessage: (message: string) => void,
242243
processTurbopackMessage: (msg: TurbopackMsgToBrowser) => void,
243244
router: ReturnType<typeof useRouter>,
244-
dispatcher: Dispatcher
245+
dispatcher: Dispatcher,
246+
appIsrManifestRef: ReturnType<typeof useRef>,
247+
pathnameRef: ReturnType<typeof useRef>
245248
) {
246249
if (!('action' in obj)) {
247250
return
@@ -292,6 +295,29 @@ function processMessage(
292295
}
293296

294297
switch (obj.action) {
298+
case HMR_ACTIONS_SENT_TO_BROWSER.APP_ISR_MANIFEST: {
299+
if (process.env.__NEXT_APP_ISR_INDICATOR) {
300+
if (appIsrManifestRef) {
301+
appIsrManifestRef.current = obj.data
302+
303+
const isrIndicatorHandlers: ShowHideHandler | undefined =
304+
window.next?.isrIndicatorHandlers
305+
306+
// handle initial status on receiving manifest
307+
// navigation is handled in useEffect for pathname changes
308+
// as we'll receive the updated manifest before usePathname
309+
// triggers for new value
310+
if (isrIndicatorHandlers) {
311+
if ((pathnameRef.current as string) in obj.data) {
312+
isrIndicatorHandlers.show()
313+
} else {
314+
isrIndicatorHandlers.hide()
315+
}
316+
}
317+
}
318+
}
319+
break
320+
}
295321
case HMR_ACTIONS_SENT_TO_BROWSER.BUILDING: {
296322
startLatency = Date.now()
297323
console.log('[Fast Refresh] rebuilding')
@@ -522,6 +548,30 @@ export default function HotReload({
522548
)
523549

524550
const router = useRouter()
551+
const pathname = usePathname()
552+
const appIsrManifestRef = useRef<Record<string, false | number>>({})
553+
const pathnameRef = useRef(pathname)
554+
555+
if (process.env.__NEXT_APP_ISR_INDICATOR) {
556+
// this conditional is only for dead-code elimination which
557+
// isn't a runtime conditional only build-time so ignore hooks rule
558+
// eslint-disable-next-line react-hooks/rules-of-hooks
559+
useEffect(() => {
560+
pathnameRef.current = pathname
561+
const isrIndicatorHandlers: ShowHideHandler | undefined =
562+
window.next?.isrIndicatorHandlers
563+
564+
const appIsrManifest = appIsrManifestRef.current
565+
566+
if (isrIndicatorHandlers && appIsrManifest) {
567+
if (pathname in appIsrManifest) {
568+
isrIndicatorHandlers.show()
569+
} else {
570+
isrIndicatorHandlers.hide()
571+
}
572+
}
573+
}, [pathname])
574+
}
525575

526576
useEffect(() => {
527577
const websocket = webSocketRef.current
@@ -535,7 +585,9 @@ export default function HotReload({
535585
sendMessage,
536586
processTurbopackMessage,
537587
router,
538-
dispatcher
588+
dispatcher,
589+
appIsrManifestRef,
590+
pathnameRef
539591
)
540592
} catch (err: any) {
541593
console.warn(
@@ -546,7 +598,14 @@ export default function HotReload({
546598

547599
websocket.addEventListener('message', handler)
548600
return () => websocket.removeEventListener('message', handler)
549-
}, [sendMessage, router, webSocketRef, dispatcher, processTurbopackMessage])
601+
}, [
602+
sendMessage,
603+
router,
604+
webSocketRef,
605+
dispatcher,
606+
processTurbopackMessage,
607+
appIsrManifestRef,
608+
])
550609

551610
return (
552611
<ReactDevOverlay onReactError={handleOnReactError} state={state}>

packages/next/src/client/dev/hot-middleware-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default (mode: 'webpack' | 'turbopack') => {
5959
}
6060
case 'serverError':
6161
case 'devPagesManifestUpdate':
62+
case 'appIsrManifest':
6263
case 'building':
6364
case 'finishBuilding': {
6465
return

0 commit comments

Comments
 (0)