@wular/pnext 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4810,9 +4810,11 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4810
4810
  // Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
4811
4811
  // `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
4812
4812
  // still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
4813
- // swap), so re-apply the departure snapshot as the controls appear.
4813
+ // swap), so re-apply the departure snapshot as the controls appear - but only to the DOM
4814
+ // this swap replaced, never to a control a live island's own render owns (see
4815
+ // islandOwnedControl).
4814
4816
  else if (departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
4815
- restoreFormStateWhenMounted(departingBfcacheId, sequence)
4817
+ restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
4816
4818
  }
4817
4819
 
4818
4820
  // A navigation that only changed parallel slots (same children path AND unchanged search
@@ -4907,13 +4909,25 @@ function restoreControl(
4907
4909
  if (control.value !== saved.value) control.value = saved.value
4908
4910
  }
4909
4911
 
4910
- function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false) {
4912
+ /**
4913
+ * A control inside a mounted island is owned by that island's preact diff, which reproduces
4914
+ * React's reconciliation - including an app key (`key={searchParams.get('q')}`) that
4915
+ * deliberately remounts the subtree with an EMPTY control. Re-applying the departure snapshot
4916
+ * there fights the app; only DOM the raw swap replaced is pnext's own loss to compensate.
4917
+ */
4918
+ function islandOwnedControl(control: Element): boolean {
4919
+ const root: LiveIslandRoot | null = control.closest('pnext-client[data-pnext-client]')
4920
+ return Boolean(root?.__pnextLive)
4921
+ }
4922
+
4923
+ function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false, skipIslands = false) {
4911
4924
  if (!bfcacheId) return
4912
4925
  const state = entryFormStateCache.get(bfcacheId)
4913
4926
  if (!state) return
4914
4927
  const controls = formControls()
4915
4928
  if (controls.length !== state.length) return
4916
4929
  for (const [index, control] of controls.entries()) {
4930
+ if (skipIslands && islandOwnedControl(control)) continue
4917
4931
  // `onlyEmpty`: a freshly mounted control is empty, so filling it restores
4918
4932
  // what the remount lost — while anything already carrying a value (the app
4919
4933
  // set it, or the user typed after the commit) is left alone.
@@ -4951,10 +4965,10 @@ function restoreSharedLayoutFormState(
4951
4965
  * describes can appear after the swap. Keep trying while the DOM changes until the control
4952
4966
  * set matches the snapshot.
4953
4967
  */
4954
- function restoreFormStateWhenMounted(bfcacheId: string, sequence: number): void {
4968
+ function restoreFormStateWhenMounted(bfcacheId: string, sequence: number, skipIslands = false) {
4955
4969
  const state = entryFormStateCache.get(bfcacheId)
4956
4970
  if (!state) return
4957
- restoreFormState(bfcacheId, true)
4971
+ restoreFormState(bfcacheId, true, skipIslands)
4958
4972
  // The controls can be rebuilt more than once while the commit settles, so the restore stays
4959
4973
  // armed for a short window instead of firing once. It only ever fills EMPTY controls, so a
4960
4974
  // value the app or the user set after the commit is never clobbered.
@@ -4963,7 +4977,7 @@ function restoreFormStateWhenMounted(bfcacheId: string, sequence: number): void
4963
4977
  observer.disconnect()
4964
4978
  return
4965
4979
  }
4966
- restoreFormState(bfcacheId, true)
4980
+ restoreFormState(bfcacheId, true, skipIslands)
4967
4981
  })
4968
4982
  observer.observe(document.body, { childList: true, subtree: true })
4969
4983
  window.setTimeout(() => observer.disconnect(), FORM_STATE_RESTORE_WINDOW_MS)
@@ -1,6 +1,7 @@
1
1
  import type { Metadata, MetadataOpenGraph, PageProps, RouteManifestEntry, Viewport } from '../types'
2
2
  import { staticMetadataForPathFromFiles, type StaticMetadataFile } from '../routing/metadata-files'
3
3
  import { coerceString } from '../render/metadata'
4
+ import { legacyRequestAPIs } from '../request/context'
4
5
  import { readSourceText } from '../resolve/source-text'
5
6
  import { SPAN_TYPE, withChildSpan } from './otel/tracer'
6
7
  import { settledPromise } from '../render/renderer'
@@ -146,7 +147,9 @@ function resolvedParentMetadata(parent: Metadata): Metadata {
146
147
  }
147
148
  }
148
149
 
149
- function resolvedParentOpenGraph(openGraph: MetadataOpenGraph | undefined): MetadataOpenGraph {
150
+ function resolvedParentOpenGraph(
151
+ openGraph: MetadataOpenGraph | null | undefined,
152
+ ): MetadataOpenGraph {
150
153
  if (!openGraph) return {}
151
154
  return {
152
155
  ...openGraph,
@@ -165,7 +168,7 @@ function resolvedParentOpenGraph(openGraph: MetadataOpenGraph | undefined): Meta
165
168
  }
166
169
 
167
170
  function normalizeKeywordList(value: Metadata['keywords']): (string | number)[] | undefined {
168
- if (value === undefined) return undefined
171
+ if (value === undefined || value === null) return undefined
169
172
  return Array.isArray(value) ? value : [value]
170
173
  }
171
174
 
@@ -192,6 +195,14 @@ export async function warnNextMetadataBuildIssues(
192
195
  if (route.kind !== 'page') continue
193
196
  const routeFacts = await Promise.all(routeSourceFiles(appPath, route).map(factsFor))
194
197
  const some = (pick: (fact: MetadataSourceFacts) => boolean) => routeFacts.some(pick)
198
+ // themeColor-in-metadata deprecation is Next 15+; legacy (Next-14 era) apps never saw it.
199
+ if (!legacyRequestAPIs() && some(fact => fact.themeColor)) {
200
+ warnOnce(
201
+ warned,
202
+ `themeColor:${route.route}`,
203
+ `Unsupported metadata themeColor is configured in metadata export in ${route.route || '/'}. Please move it to viewport\nRead more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport`,
204
+ )
205
+ }
195
206
  if (some(fact => fact.appleTouchFullscreen)) {
196
207
  warnOnce(
197
208
  warned,
@@ -239,6 +250,7 @@ async function readSourceTextOrEmpty(file: string) {
239
250
  * nearly every file has none of these keys.
240
251
  */
241
252
  interface MetadataSourceFacts {
253
+ themeColor: boolean
242
254
  appleTouchFullscreen: boolean
243
255
  appleTouchIconPrecomposed: boolean
244
256
  metadataBase: boolean
@@ -247,6 +259,7 @@ interface MetadataSourceFacts {
247
259
 
248
260
  function metadataSourceFacts(source: string): MetadataSourceFacts {
249
261
  return {
262
+ themeColor: themeColorInMetadataExport(source),
250
263
  appleTouchFullscreen:
251
264
  source.includes('apple-touch-fullscreen') && /['"]apple-touch-fullscreen['"]/.test(source),
252
265
  appleTouchIconPrecomposed:
@@ -257,6 +270,35 @@ function metadataSourceFacts(source: string): MetadataSourceFacts {
257
270
  }
258
271
  }
259
272
 
273
+ // A themeColor inside a viewport export is where the notice says to put it, so
274
+ // only occurrences outside viewport/generateViewport blocks count.
275
+ function themeColorInMetadataExport(source: string): boolean {
276
+ if (!source.includes('themeColor') || !/\bthemeColor\s*:/.test(source)) return false
277
+ return /\bthemeColor\s*:/.test(stripViewportExports(source))
278
+ }
279
+
280
+ function stripViewportExports(source: string): string {
281
+ const starts =
282
+ /export\s+(?:const|let|var)\s+viewport\b|export\s+(?:async\s+)?function\s+generateViewport\b/g
283
+ let out = ''
284
+ let last = 0
285
+ for (let match = starts.exec(source); match; match = starts.exec(source)) {
286
+ const open = source.indexOf('{', match.index)
287
+ if (open === -1) break
288
+ let depth = 0
289
+ let end = open
290
+ for (; end < source.length; end++) {
291
+ const char = source[end]
292
+ if (char === '{') depth++
293
+ else if (char === '}' && --depth === 0) break
294
+ }
295
+ out += source.slice(last, match.index)
296
+ last = end + 1
297
+ starts.lastIndex = last
298
+ }
299
+ return out + source.slice(last)
300
+ }
301
+
260
302
  function routeUsesRelativeSocialImage(
261
303
  route: RouteManifestEntry,
262
304
  facts: MetadataSourceFacts[],
@@ -112,10 +112,16 @@ function findConfigPath(root: string): string | undefined {
112
112
  return undefined
113
113
  }
114
114
 
115
+ // `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
116
+ // bun-types typecheck this file and a bare `Bun` identifier would fail them.
117
+ const { Bun: bun } = globalThis as unknown as {
118
+ Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
119
+ }
120
+
115
121
  /** Stable per config content, so the emitted bundle keeps one name per boot. */
116
122
  function configSourceKey(configPath: string): string {
117
123
  try {
118
- return Bun.hash(readFileSync(configPath)).toString(36)
124
+ return bun.hash(readFileSync(configPath)).toString(36)
119
125
  } catch {
120
126
  return 'config'
121
127
  }
@@ -9,8 +9,10 @@ import {
9
9
  registerBuildSteps,
10
10
  type BuildStepContext,
11
11
  } from '../../extensions'
12
+ import { legacyRequestAPIs } from '../../request/context'
12
13
  import type { RouteManifestEntry } from '../../types'
13
14
  import { nextCompatEnabled } from '../aliases'
15
+ import { writeTsconfigDefaults } from '../tsconfig-defaults'
14
16
  import { typecheckDisabled, validateTypesOffThread } from '../typecheck/check'
15
17
  import { generateTypedRoutes } from '../typed-routes/generate'
16
18
 
@@ -20,6 +22,8 @@ export function registerTypedRoutesExtensions(_config: ResolvedConfig): void {
20
22
 
21
23
  async function runTypedRoutesBuildStep(ctx: BuildStepContext): Promise<void> {
22
24
  if (!nextCompatEnabled(ctx.config)) return
25
+ // Legacy (Next-14 era) apps keep their tsconfig untouched; current apps get Next's defaults.
26
+ if (!legacyRequestAPIs()) await writeTsconfigDefaults(ctx.config)
23
27
  await generateTypedRoutes(ctx.config, ctx.routes as RouteManifestEntry[])
24
28
  // The typecheck reads only files that already exist on disk, so it runs on a worker thread
25
29
  // alongside client bundling and prerendering rather than ahead of them; the CLI awaits it before
@@ -372,6 +372,10 @@ async function writeTypecheckProject(
372
372
  },
373
373
  include: [
374
374
  path.join(dir, 'next-shims.d.ts'),
375
+ // Generated per-route assignability checks (typegen writes them next to this
376
+ // project, not under the app-included `types/` dir, so only pnext's own compiler
377
+ // reads them). Included by hand to keep that coverage.
378
+ path.join(dir, 'checks', '**/*.ts'),
375
379
  ...(nodeTypes ? [path.join(nodeTypes, 'index.d.ts')] : []),
376
380
  path.join(config.root, 'next-env.d.ts'),
377
381
  // The build emits `.next/types/validator.ts` (page/layout/route-handler export validation) just
@@ -661,7 +665,7 @@ declare module 'react/jsx-runtime' {
661
665
  `
662
666
  const requestType = (type: string) =>
663
667
  legacyRequestTypes ? `Promise<${type}> & ${type}` : `Promise<${type}>`
664
- const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(name:string,value:string,options?:CookieSetOptions):CookieStore;set(options:CookieSetOptions):CookieStore;delete(name:string):boolean;clear():CookieStore;[Symbol.iterator]():IterableIterator<readonly[string,{name?:string;value:string}]>}export function cookies():${requestType('CookieStore')};export function headers():${requestType('Headers')};export function draftMode():${requestType(`{isEnabled:boolean;enable():void;disable():void}`)}}
668
+ const requestShims = `declare module 'next/headers'{interface CookieSetOptions{name?:string;value?:string;path?:string;maxAge?:number;expires?:Date|number;httpOnly?:boolean;secure?:boolean;sameSite?:'strict'|'lax'|'none'|boolean;domain?:string}interface CookieStore{get(name:string):{name:string;value:string}|undefined;get(options:{name:string;value?:string}):{name:string;value:string}|undefined;getAll(name?:string):{name:string;value:string}[];getAll(options:{name:string;value?:string}):{name:string;value:string}[];has(name:string):boolean;readonly size:number;set(options:CookieSetOptions):CookieStore;set(name:string,value:string,options?:CookieSetOptions):CookieStore;delete(name:string):boolean;clear():CookieStore;[Symbol.iterator]():IterableIterator<readonly[string,{name?:string;value:string}]>}export function cookies():${requestType('CookieStore')};export function headers():${requestType('Headers')};export function draftMode():${requestType(`{isEnabled:boolean;enable():void;disable():void}`)}}
665
669
  `
666
670
  const fontShims = hasRealNext
667
671
  ? ''
package/src/config.ts CHANGED
@@ -21,6 +21,12 @@ export type ResolvedConfig = Required<Pick<PNextConfig, 'outDir' | 'basePath'>>
21
21
  outRootPath: string
22
22
  /** Generated types — always under the out root, because tsconfig points at it. */
23
23
  typesPath: string
24
+ /**
25
+ * Generated per-route assignability checks. Deliberately OUTSIDE typesPath: an app's
26
+ * tsconfig includes `<outRoot>/types/**` and a foreign compiler must never see pnext's
27
+ * own internals. pnext's private typecheck project includes this directory instead.
28
+ */
29
+ checksPath: string
24
30
  }
25
31
 
26
32
  /**
@@ -146,6 +152,7 @@ export async function loadConfig(
146
152
  outPath: options.dev ? path.join(outRootPath, devOutSegment) : outRootPath,
147
153
  outRootPath,
148
154
  typesPath: path.join(outRootPath, 'types'),
155
+ checksPath: path.join(outRootPath, 'typecheck', 'checks'),
149
156
  }
150
157
  }
151
158
 
package/src/dev/server.ts CHANGED
@@ -32,7 +32,12 @@ import {
32
32
  serverBundleTargetForRuntime,
33
33
  } from '../runtime/loader'
34
34
  import { applyProxyResponse, createProxyRunner, validateProxyFiles } from '../routing/proxy'
35
- import { errorLogTrace, renderGlobalNotFoundResponse, renderPageResponse } from '../render/renderer'
35
+ import {
36
+ errorLogTrace,
37
+ renderGlobalNotFoundResponse,
38
+ renderPageResponse,
39
+ withDevReloadScript,
40
+ } from '../render/renderer'
36
41
  import { markErrorLogged } from '../utils/error-log'
37
42
  import { escapeHtml } from '../utils/html'
38
43
  import { internalErrorHtml } from './internal-error'
@@ -100,6 +105,7 @@ import {
100
105
  } from '../runtime/modules'
101
106
  import { ssrClientReference } from '../client/reference'
102
107
  import { cacheRoot, setDevWatcherFreshness } from '../runtime/module-cache'
108
+ import { abortUpstreamFetchOnDisconnect } from '../runtime/fetch-host'
103
109
  import { markBoot } from '../cli/boot/trace'
104
110
  import { formatDuration } from '../utils/verbose'
105
111
 
@@ -454,6 +460,7 @@ export async function startDevServer(options: DevServerOptions) {
454
460
  syncWatchRoots()
455
461
  }, 0)
456
462
  if (profile) flushDevProfileLines()
463
+ abortUpstreamFetchOnDisconnect(logged.body, request.signal)
457
464
  return flushWorkUnitOnClose(logged, unit, request.signal)
458
465
  }
459
466
 
@@ -790,10 +797,15 @@ function devLastResortResponse(error: unknown, request: Request): Response {
790
797
  } catch {
791
798
  // A malformed URL is exactly the kind of request that gets here; keep the raw text.
792
799
  }
793
- return devLastResortError(error, route, request.url)
800
+ return devLastResortError(error, route, request.url, request)
794
801
  }
795
802
 
796
- function devLastResortError(error: unknown, route = '(unknown)', url = route): Response {
803
+ export function devLastResortError(
804
+ error: unknown,
805
+ route = '(unknown)',
806
+ url = route,
807
+ request?: Request,
808
+ ): Response {
797
809
  const resolved = error instanceof Error ? error : new Error(String(error))
798
810
  if (markErrorLogged(resolved)) console.error(errorLogTrace(resolved))
799
811
  let html: string
@@ -807,7 +819,7 @@ function devLastResortError(error: unknown, route = '(unknown)', url = route): R
807
819
  } catch {
808
820
  html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>pnext error</title></head><body><h1>pnext could not render this request</h1><pre>${escapeHtml(resolved.stack ?? resolved.message)}</pre></body></html>`
809
821
  }
810
- return new Response(html, {
822
+ return new Response(withDevReloadScript(html, request), {
811
823
  status: 500,
812
824
  headers: { 'content-type': 'text/html; charset=utf-8' },
813
825
  })
package/src/extensions.ts CHANGED
@@ -15,6 +15,12 @@ import type { BuildManifest, ClientEntryReason, RouteManifestEntry, RouteParamVa
15
15
  import type { VerboseLogger } from './utils/verbose'
16
16
  import { escapeRegex } from './utils/code'
17
17
 
18
+ // `Bun` through globalThis: pnext ships TypeScript source, so app compilers without
19
+ // bun-types typecheck this file and a bare `Bun` identifier would fail them.
20
+ const { Bun: bun } = globalThis as unknown as {
21
+ Bun: { hash(input: string | ArrayBufferView): { toString(radix?: number): string } }
22
+ }
23
+
18
24
  /** Per-build wiring for the server plugin chain. */
19
25
  export interface ServerEsbuildPluginOptions {
20
26
  /**
@@ -1416,7 +1422,7 @@ export function classifyRouteDependencies(
1416
1422
 
1417
1423
  /** Every reason a registered detector requires a client entry for this source. */
1418
1424
  export function sourceClientEntryReasons(source: string): ClientEntryReason[] {
1419
- const key = `${source.length}\0${Bun.hash(source).toString(36)}`
1425
+ const key = `${source.length}\0${bun.hash(source).toString(36)}`
1420
1426
  const cached = clientEntryReasonCache.get(key)
1421
1427
  if (cached) return cached
1422
1428
  const reasons: ClientEntryReason[] = []
@@ -7,6 +7,7 @@ import type {
7
7
  MetadataLink,
8
8
  MetadataOpenGraph,
9
9
  MetadataRobots,
10
+ MetadataValue,
10
11
  ResourceHint,
11
12
  MetadataTwitter,
12
13
  Viewport,
@@ -19,6 +20,13 @@ import {
19
20
  import { escapeHtml, stringifyHtmlValue } from '../utils/html'
20
21
  import { getTrailingSlashUrls, withBasePath } from '../routing/href'
21
22
 
23
+ /** Every metadata field is nullable: null means "not set", exactly like undefined. */
24
+ type MetaContent = MetadataValue | null | undefined
25
+
26
+ function absent(value: unknown): value is null | undefined {
27
+ return value === undefined || value === null
28
+ }
29
+
22
30
  export type MetadataExport = Metadata | (() => Metadata | Promise<Metadata>)
23
31
 
24
32
  export type ViewportExport = Viewport | (() => Viewport | Promise<Viewport>)
@@ -224,12 +232,15 @@ function prefixImageBasePath(image: MetadataImage): MetadataImage {
224
232
  export function metadataTags(metadata: Metadata, context: MetadataContext): string {
225
233
  const meta = metadata
226
234
  const tags: string[] = []
227
- const push = (name: string, content: string | undefined) => {
228
- if (content) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}"/>`)
235
+ const push = (name: string, content: MetaContent) => {
236
+ if (content)
237
+ tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(coerceString(content))}"/>`)
229
238
  }
230
- const pushProperty = (property: string, content: string | undefined) => {
239
+ const pushProperty = (property: string, content: MetaContent) => {
231
240
  if (content)
232
- tags.push(`<meta property="${escapeHtml(property)}" content="${escapeHtml(content)}"/>`)
241
+ tags.push(
242
+ `<meta property="${escapeHtml(property)}" content="${escapeHtml(coerceString(content))}"/>`,
243
+ )
233
244
  }
234
245
 
235
246
  push('description', meta.description)
@@ -263,8 +274,8 @@ export function metadataTags(metadata: Metadata, context: MetadataContext): stri
263
274
  }
264
275
  if (meta.facebook?.appId) pushProperty('fb:app_id', meta.facebook.appId)
265
276
  for (const admin of list(meta.facebook?.admins)) pushProperty('fb:admins', admin)
266
- if (meta.pinterest?.richPin !== undefined) {
267
- pushProperty('pinterest-rich-pin', meta.pinterest.richPin ? 'true' : 'false')
277
+ if (!absent(meta.pinterest?.richPin)) {
278
+ pushProperty('pinterest-rich-pin', String(meta.pinterest.richPin))
268
279
  }
269
280
  for (const [property, value] of appLinkTags(meta.appLinks)) pushProperty(property, value)
270
281
 
@@ -278,13 +289,15 @@ export function metadataTags(metadata: Metadata, context: MetadataContext): stri
278
289
  function openGraphTags(og: MetadataOpenGraph | undefined, context: MetadataContext): string[] {
279
290
  if (!og) return []
280
291
  const tags: string[] = []
281
- const push = (property: string, content: string | undefined) => {
292
+ const push = (property: string, content: MetaContent) => {
282
293
  if (content)
283
- tags.push(`<meta property="${escapeHtml(property)}" content="${escapeHtml(content)}"/>`)
294
+ tags.push(
295
+ `<meta property="${escapeHtml(property)}" content="${escapeHtml(coerceString(content))}"/>`,
296
+ )
284
297
  }
285
298
  push('og:title', og.title === undefined ? undefined : resolveTitleChain([{ title: og.title }]))
286
299
  push('og:description', og.description)
287
- push('og:url', og.url === undefined ? undefined : resolveMetadataUrl(og.url, context))
300
+ push('og:url', resolveMetadataUrl(og.url, context))
288
301
  push('og:site_name', og.siteName)
289
302
  push('og:locale', og.locale)
290
303
  push('og:type', og.type)
@@ -313,8 +326,9 @@ function openGraphTags(og: MetadataOpenGraph | undefined, context: MetadataConte
313
326
  function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataContext): string[] {
314
327
  if (!twitter) return []
315
328
  const tags: string[] = []
316
- const push = (name: string, content: string | undefined) => {
317
- if (content) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}"/>`)
329
+ const push = (name: string, content: MetaContent) => {
330
+ if (content)
331
+ tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(coerceString(content))}"/>`)
318
332
  }
319
333
  push('twitter:card', twitterCard(twitter))
320
334
  push('twitter:site', twitter.site)
@@ -334,10 +348,13 @@ function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataCont
334
348
  push('twitter:image:alt', image.alt)
335
349
  push('twitter:image:type', image.type)
336
350
  }
337
- push('twitter:player', resolveMetadataUrl(twitter.players?.playerUrl, context))
338
- push('twitter:player:stream', resolveMetadataUrl(twitter.players?.streamUrl, context))
339
- push('twitter:player:width', coerceString(twitter.players?.width) || undefined)
340
- push('twitter:player:height', coerceString(twitter.players?.height) || undefined)
351
+ const players = twitter.players === undefined ? [] : [twitter.players].flat()
352
+ for (const player of players) {
353
+ push('twitter:player', resolveMetadataUrl(player.playerUrl, context))
354
+ push('twitter:player:stream', resolveMetadataUrl(player.streamUrl, context))
355
+ push('twitter:player:width', coerceString(player.width) || undefined)
356
+ push('twitter:player:height', coerceString(player.height) || undefined)
357
+ }
341
358
  for (const [device, id] of Object.entries(twitter.app?.id ?? {})) {
342
359
  push(`twitter:app:id:${device}`, id)
343
360
  }
@@ -349,7 +366,7 @@ function twitterTags(twitter: MetadataTwitter | undefined, context: MetadataCont
349
366
 
350
367
  // theme-color: string | { color, media } | array of those.
351
368
  export function themeColorTags(value: Viewport['themeColor']): string {
352
- if (value === undefined) return ''
369
+ if (absent(value)) return ''
353
370
  const list = Array.isArray(value) ? value : [value]
354
371
  return list
355
372
  .map(entry => {
@@ -514,13 +531,13 @@ function coerceKeywords(value: Metadata['keywords']): string | undefined {
514
531
  }
515
532
 
516
533
  function normalizeKeywordList(value: Metadata['keywords']): (string | number)[] | undefined {
517
- if (value === undefined) return undefined
534
+ if (absent(value)) return undefined
518
535
  return Array.isArray(value) ? value : [value]
519
536
  }
520
537
 
521
538
  // authors -> list of display names for meta[name=author].
522
539
  function coerceAuthors(value: Metadata['authors']): string[] {
523
- if (value === undefined) return []
540
+ if (absent(value)) return []
524
541
  const list = Array.isArray(value) ? value : [value]
525
542
  return list
526
543
  .map(author => (typeof author === 'string' ? author : author.name))
@@ -530,7 +547,7 @@ function coerceAuthors(value: Metadata['authors']): string[] {
530
547
 
531
548
  // robots: string passthrough, or object -> "index, follow, noarchive" style.
532
549
  function coerceRobots(value: Metadata['robots']): string | undefined {
533
- if (value === undefined) return undefined
550
+ if (absent(value)) return undefined
534
551
  if (typeof value === 'string') return value
535
552
  const flags: string[] = []
536
553
  const r = value
@@ -601,7 +618,7 @@ function coerceCanonical(
601
618
  context: MetadataContext,
602
619
  ): string | undefined {
603
620
  const canonical = value?.canonical
604
- if (canonical === undefined) return undefined
621
+ if (absent(canonical)) return undefined
605
622
  if (typeof canonical === 'string') return resolveMetadataUrl(canonical, context)
606
623
  if (canonical instanceof URL) return canonical.href
607
624
  if (typeof canonical === 'object' && 'url' in canonical)
@@ -687,7 +704,7 @@ function joinUrlPath(...parts: string[]) {
687
704
 
688
705
  // Normalizes every icon form (string | URL | object | group) into <link> descriptors.
689
706
  function coerceIcons(value: Metadata['icons'], context: MetadataContext): MetadataLink[] {
690
- if (value === undefined) return []
707
+ if (absent(value)) return []
691
708
  if (isIconGroups(value)) {
692
709
  return [
693
710
  ...iconList(value.icon, 'icon', context),
@@ -706,11 +723,11 @@ function isIconGroups(value: NonNullable<Metadata['icons']>): value is MetadataI
706
723
  }
707
724
 
708
725
  function iconList(
709
- value: MetadataIcon | MetadataIcon[] | undefined,
726
+ value: MetadataIcon | MetadataIcon[] | null | undefined,
710
727
  rel: string,
711
728
  context: MetadataContext,
712
729
  ): MetadataLink[] {
713
- if (value === undefined) return []
730
+ if (absent(value)) return []
714
731
  const list = Array.isArray(value) ? value : [value]
715
732
  return list.map((icon): MetadataLink => {
716
733
  if (typeof icon === 'string') return { rel, url: resolveMetadataUrl(icon, context) ?? icon }
@@ -725,7 +742,7 @@ function iconList(
725
742
  }
726
743
 
727
744
  function authorLinks(value: Metadata['authors'], context: MetadataContext): MetadataLink[] {
728
- if (value === undefined) return []
745
+ if (absent(value)) return []
729
746
  return (Array.isArray(value) ? value : [value])
730
747
  .filter((author): author is MetadataAuthor => typeof author === 'object' && author !== null)
731
748
  .flatMap(author => {
@@ -775,7 +792,7 @@ function alternateLinks(value: Metadata['alternates'], context: MetadataContext)
775
792
  for (const [type, entries] of Object.entries(value.types ?? {})) {
776
793
  const listEntries = Array.isArray(entries) ? entries : [entries]
777
794
  for (const entry of listEntries) {
778
- const record =
795
+ const record: { url: unknown; title?: string } =
779
796
  typeof entry === 'object' && !(entry instanceof URL) && entry !== null && 'url' in entry
780
797
  ? entry
781
798
  : { url: entry }
@@ -794,8 +811,8 @@ function appleStartupLinks(
794
811
  value: Metadata['appleWebApp'],
795
812
  context: MetadataContext,
796
813
  ): MetadataLink[] {
797
- if (!value || typeof value !== 'object' || value.startupImage === undefined) return []
798
- const images = Array.isArray(value.startupImage) ? value.startupImage : [value.startupImage]
814
+ if (!value || typeof value !== 'object' || absent(value.startupImage)) return []
815
+ const images = [value.startupImage].flat()
799
816
  return images.map(image => {
800
817
  if (typeof image === 'string' || image instanceof URL) {
801
818
  return {
@@ -853,8 +870,10 @@ function appLinkTags(value: Metadata['appLinks']): [string, string][] {
853
870
  if (!value) return []
854
871
  const tags: [string, string][] = []
855
872
  for (const [platform, entries] of Object.entries(value)) {
856
- for (const [name, item] of Object.entries(entries)) {
857
- if (item !== undefined) tags.push([`al:${platform}:${name}`, String(item)])
873
+ for (const target of entries === undefined ? [] : [entries].flat()) {
874
+ for (const [name, item] of Object.entries(target)) {
875
+ if (item !== undefined) tags.push([`al:${platform}:${name}`, coerceString(item)])
876
+ }
858
877
  }
859
878
  }
860
879
  return tags
@@ -890,11 +909,14 @@ function resolveSocialMetadata(meta: Metadata): {
890
909
  images: ownImages ? twitter?.images : og.images,
891
910
  }
892
911
  }
893
- return { openGraph: og && inherited(og, meta), twitter: twitter && inherited(twitter, meta) }
912
+ return {
913
+ openGraph: og ? inherited(og, meta) : undefined,
914
+ twitter: twitter ? inherited(twitter, meta) : undefined,
915
+ }
894
916
  }
895
917
 
896
918
  // A block that renders no title/description of its own falls back to the page's.
897
- function inherited<T extends { title?: Metadata['title']; description?: string }>(
919
+ function inherited<T extends { title?: Metadata['title']; description?: string | null }>(
898
920
  block: T,
899
921
  meta: Metadata,
900
922
  ): T {
@@ -3060,6 +3060,7 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
3060
3060
  url: String(options.url),
3061
3061
  digest: (resolved as Error & { digest?: string }).digest,
3062
3062
  }),
3063
+ options,
3063
3064
  )
3064
3065
  }
3065
3066
  }
@@ -3161,6 +3162,7 @@ async function renderGlobalErrorPage(
3161
3162
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3162
3163
  options.config,
3163
3164
  ),
3165
+ options,
3164
3166
  )
3165
3167
  }
3166
3168
  try {
@@ -3190,6 +3192,7 @@ async function renderGlobalErrorPage(
3190
3192
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3191
3193
  options.config,
3192
3194
  ),
3195
+ options,
3193
3196
  )
3194
3197
  } catch {
3195
3198
  const defaultUi = nextCompatEnabled(options.config)
@@ -3202,6 +3205,7 @@ async function renderGlobalErrorPage(
3202
3205
  withGlobalErrorReportScript(`<!DOCTYPE html>${rendered}`, errorProps),
3203
3206
  options.config,
3204
3207
  ),
3208
+ options,
3205
3209
  )
3206
3210
  }
3207
3211
  }
@@ -3216,7 +3220,19 @@ async function renderGlobalErrorPage(
3216
3220
  function withGlobalErrorReportScript(html: string, error: SerializedError): string {
3217
3221
  const script = getRenderExtensions().globalErrorReportScript(error)
3218
3222
  if (!script) return html
3219
- return html.includes('</body>') ? html.replace('</body>', `${script}</body>`) : html + script
3223
+ return appendToDocumentBody(html, script)
3224
+ }
3225
+
3226
+ function appendToDocumentBody(html: string, content: string): string {
3227
+ const closingBodies = [...html.matchAll(/<\/body\s*>/gi)]
3228
+ const closingBody = closingBodies.at(-1)?.index
3229
+ if (closingBody === undefined) return html + content
3230
+ return html.slice(0, closingBody) + content + html.slice(closingBody)
3231
+ }
3232
+
3233
+ /** Keep a standalone development document connected to the dev server's reload stream. */
3234
+ export function withDevReloadScript(html: string, request?: Request): string {
3235
+ return appendToDocumentBody(html, devReloadScript(documentNonce(request)))
3220
3236
  }
3221
3237
 
3222
3238
  function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string {
@@ -3227,13 +3243,16 @@ function withGlobalErrorStylesheet(html: string, config: ResolvedConfig): string
3227
3243
  return html.replace(/<html(?:\s[^>]*)?>/i, tag => `${tag}<head>${link}</head>`)
3228
3244
  }
3229
3245
 
3230
- // A pre-rendered document (its own <html>/<body>) served verbatim. The document
3231
- // shell is skipped entirely: renderGlobalDocument wraps the markup, not
3232
- // renderDocumentStart/End.
3233
- function globalErrorPage(html: string): PageRender {
3246
+ // A pre-rendered document (its own <html>/<body>). The ordinary document shell
3247
+ // is skipped entirely, so development adds its reload client directly.
3248
+ function globalErrorPage(
3249
+ html: string,
3250
+ options: Pick<RenderOptions, 'dev' | 'request'>,
3251
+ ): PageRender {
3252
+ const body = options.dev ? withDevReloadScript(html, options.request) : html
3234
3253
  return {
3235
3254
  shell: 'global-error',
3236
- body: html,
3255
+ body,
3237
3256
  htmlProps: {},
3238
3257
  bodyProps: {},
3239
3258
  head: '',
@@ -4535,15 +4554,17 @@ async function renderTreeOrFallback(
4535
4554
  try {
4536
4555
  return await renderTree(options, tree, stream, metadata, extra)
4537
4556
  } catch {
4538
- return generatedFallbackPage(tree, metadata, extra.status ?? 500)
4557
+ return generatedFallbackPage(options, tree, metadata, extra.status ?? 500)
4539
4558
  }
4540
4559
  }
4541
4560
 
4542
4561
  function generatedFallbackPage(
4562
+ options: RenderOptions,
4543
4563
  tree: ComponentChildren,
4544
4564
  metadata: Metadata,
4545
4565
  status: number,
4546
4566
  ): PageRender {
4567
+ const nonce = documentNonce(options.request)
4547
4568
  return {
4548
4569
  shell: 'generated',
4549
4570
  body: serializeNeutralClientIslands(renderToString(h(Fragment, null, tree))),
@@ -4557,11 +4578,12 @@ function generatedFallbackPage(
4557
4578
  propsScript: '',
4558
4579
  routeScript: '',
4559
4580
  clientScript: '',
4560
- devScript: '',
4581
+ devScript: options.dev ? devReloadScript() : '',
4561
4582
  streamChunks: [],
4562
4583
  status,
4563
4584
  viewport: {},
4564
4585
  fontCss: '',
4586
+ ...(nonce ? { nonce } : {}),
4565
4587
  }
4566
4588
  }
4567
4589
 
@@ -7679,12 +7701,13 @@ function componentChildren(value: ServerComponentResult): ComponentChildren {
7679
7701
 
7680
7702
  // data-pnext-dev tells the soft-navigation runtime to skip this script on
7681
7703
  // body swaps — the current document already holds an open event stream.
7682
- function devReloadScript() {
7704
+ function devReloadScript(nonce?: string) {
7683
7705
  // css-update swaps every same-origin stylesheet for a cache-busted copy and
7684
7706
  // drops the old one once the new one has loaded, so the page never flashes
7685
7707
  // unstyled and never loses client state. Anything the server could not prove
7686
7708
  // was CSS-only still arrives as `reload`.
7687
- return `<script data-pnext-dev>
7709
+ const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
7710
+ return `<script${nonceAttribute} data-pnext-dev>
7688
7711
  let events;
7689
7712
  let generation;
7690
7713
  // The stream is a long-lived connection counted against the browser's per-origin