@wular/pnext 0.0.24 → 0.1.0

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.
Files changed (53) hide show
  1. package/bin/pnext +1 -1
  2. package/package.json +2 -2
  3. package/reference/data/bench.json +398 -223
  4. package/reference/getting-started.md +1 -1
  5. package/src/api/client-navigation.ts +7 -5
  6. package/src/cli/adapters/vercel-warm.ts +120 -28
  7. package/src/cli/build.ts +56 -15
  8. package/src/cli/create.ts +1 -1
  9. package/src/cli/dev.ts +2 -1
  10. package/src/cli/index.ts +146 -10
  11. package/src/cli/migrate/report.ts +1 -1
  12. package/src/cli/serve/pipeline.ts +23 -6
  13. package/src/cli/serve/ui.ts +1 -1
  14. package/src/cli/start.ts +2 -1
  15. package/src/cli/typegen.ts +5 -2
  16. package/src/client/build.ts +217 -58
  17. package/src/client/chunk-fold.ts +7 -1
  18. package/src/client/entry.ts +44 -7
  19. package/src/client/router/runtime.ts +1016 -230
  20. package/src/client/router/types.ts +15 -0
  21. package/src/compat/actions/client-plugin.ts +10 -0
  22. package/src/compat/actions/rewrite.ts +1 -0
  23. package/src/compat/bundler/optimize-package-imports.ts +375 -28
  24. package/src/compat/client/navigation-scroll.ts +29 -3
  25. package/src/compat/client/optimistic-routing.ts +2 -2
  26. package/src/compat/client/segment-cache-policy.ts +4 -18
  27. package/src/compat/client/segment-cache.ts +104 -0
  28. package/src/compat/client/segment-prefetch.ts +6 -6
  29. package/src/compat/next/config-loader.ts +6 -4
  30. package/src/compat/next/navigation.ts +16 -2
  31. package/src/compat/pages/router.ts +102 -9
  32. package/src/compat/protocol.ts +10 -1
  33. package/src/compat/register/actions.ts +2 -0
  34. package/src/compat/register/bundler.ts +4 -6
  35. package/src/compat/register/routing.ts +3 -4
  36. package/src/compat/tsconfig-defaults.ts +3 -2
  37. package/src/config.ts +3 -3
  38. package/src/css/postcss.ts +450 -17
  39. package/src/dev/client-actions.ts +2 -0
  40. package/src/dev/server.ts +87 -14
  41. package/src/render/island-context.ts +3 -0
  42. package/src/render/ppr.ts +14 -0
  43. package/src/render/renderer.ts +268 -42
  44. package/src/request/context.ts +32 -11
  45. package/src/resolve/scan-facts.ts +23 -4
  46. package/src/routing/proxy.ts +29 -25
  47. package/src/routing/routes.ts +1 -1
  48. package/src/runtime/loader.ts +47 -1
  49. package/src/runtime/modules.ts +546 -65
  50. package/src/runtime/vendor-build.ts +109 -22
  51. package/src/runtime/vendor.ts +7 -3
  52. package/src/utils/ansi.ts +3 -1
  53. package/src/utils/fs.ts +17 -3
@@ -89,24 +89,6 @@ function record(input: SegmentCacheRecord): void {
89
89
  complete: input.complete,
90
90
  runtime: input.runtime,
91
91
  })
92
- // App Shells: the per-URL prerender carried the route's shared shell. File it
93
- // at the route's fallback vary path (empty vary = every param value) as a
94
- // SHELL — it paints, but a navigation still fetches the per-URL remainder.
95
- if (decoded.shell) {
96
- recordSegment({
97
- segmentPath: input.segmentPath,
98
- pathname: input.pathname,
99
- search: '',
100
- html: decoded.shell.html,
101
- staleTimeMs: input.staleTimeMs,
102
- complete: false,
103
- static: true,
104
- runtime: false,
105
- vary: [],
106
- route: decoded.shell.route,
107
- params: decoded.params,
108
- })
109
- }
110
92
  recordSegment({
111
93
  segmentPath: input.segmentPath,
112
94
  pathname: input.pathname,
@@ -116,6 +98,7 @@ function record(input: SegmentCacheRecord): void {
116
98
  complete: input.complete,
117
99
  static: input.static,
118
100
  runtime: input.runtime,
101
+ ...(input.postponedShell ? { postponedShell: true } : {}),
119
102
  vary: decoded.vary,
120
103
  // w9-segment-split: pass the layout/page vary sets through so the cache can
121
104
  // file the two frames alongside the whole-document entry.
@@ -124,6 +107,7 @@ function record(input: SegmentCacheRecord): void {
124
107
  // The route's static siblings, so a learned dynamic pattern never keys onto
125
108
  // a literal sibling's URL.
126
109
  ...(decoded.staticSiblings ? { staticSiblings: decoded.staticSiblings } : {}),
110
+ ...(decoded.shell ? { shell: decoded.shell } : {}),
127
111
  route: decoded.route,
128
112
  params: decoded.params,
129
113
  })
@@ -140,6 +124,7 @@ function take(input: {
140
124
  html: hit.html,
141
125
  networkFree: hit.networkFree,
142
126
  prefetchSatisfied: hit.prefetchSatisfied,
127
+ postponedShell: hit.postponedShell,
143
128
  }
144
129
  }
145
130
  // w9-segment-split: the whole-document entry missed because ONE of the two
@@ -154,6 +139,7 @@ function take(input: {
154
139
  html: composed.html,
155
140
  networkFree: composed.networkFree,
156
141
  prefetchSatisfied: composed.prefetchSatisfied,
142
+ postponedShell: composed.postponedShell,
157
143
  }
158
144
  : null
159
145
  }
@@ -96,6 +96,10 @@ export interface SegmentRecordInput extends SegmentPayloadMeta {
96
96
  static: boolean
97
97
  /** True when the payload came from a runtime (request-sampled) prefetch. */
98
98
  runtime: boolean
99
+ /** True when the server marked the payload its POSTPONED (PPR) shell for this URL. */
100
+ postponedShell?: boolean
101
+ /** True when this is an extracted route-wide App Shell, valid for sibling params too. */
102
+ sharedAppShell?: boolean
99
103
  /** True when the route's catch-all is optional (`[[...slug]]`). */
100
104
  catchAllOptional?: boolean
101
105
  }
@@ -110,6 +114,8 @@ interface SegmentEntry {
110
114
  complete: boolean
111
115
  static: boolean
112
116
  runtime: boolean
117
+ postponedShell: boolean
118
+ sharedAppShell: boolean
113
119
  }
114
120
 
115
121
  export interface SegmentHit {
@@ -123,6 +129,11 @@ export interface SegmentHit {
123
129
  */
124
130
  prefetchSatisfied: boolean
125
131
  runtime: boolean
132
+ /**
133
+ * The entry is an authorised static shell stage: either this URL's own
134
+ * postponed PPR shell, or an extracted route-wide App Shell.
135
+ */
136
+ postponedShell: boolean
126
137
  }
127
138
 
128
139
  interface LearnedRoute {
@@ -158,6 +169,26 @@ function now(): number {
158
169
  // ---------------------------------------------------------------------------
159
170
 
160
171
  export function recordSegment(input: SegmentRecordInput): void {
172
+ // A fully-static prerender may carry the route's params-hanging App Shell
173
+ // alongside its concrete per-URL body. File that extracted shell through the
174
+ // same cache seam as every other response so all producers (the router policy
175
+ // and the standalone segment-prefetch scheduler) prime the fallback vary
176
+ // path consistently.
177
+ if (input.shell) {
178
+ recordAppShell({
179
+ segmentPath: input.segmentPath,
180
+ pathname: input.pathname,
181
+ search: '',
182
+ html: input.shell.html,
183
+ staleTimeMs: input.staleTimeMs,
184
+ complete: false,
185
+ static: true,
186
+ runtime: false,
187
+ vary: [],
188
+ route: input.shell.route,
189
+ params: input.params,
190
+ })
191
+ }
161
192
  recordOneSegment(input)
162
193
  // w9-segment-split: a body response the server split also files its two
163
194
  // frames, each under its OWN vary set. Additive — the whole-document entry
@@ -202,6 +233,27 @@ export function recordSegment(input: SegmentRecordInput): void {
202
233
  }
203
234
  }
204
235
 
236
+ /**
237
+ * Extract and file a route-wide App Shell from a prerender response.
238
+ *
239
+ * Next publishes a byte boundary and decodes the Flight prefix again. PNext's
240
+ * segment payload carries HTML, whose equivalent boundary is the first
241
+ * streamed continuation: removing those resolved chunks leaves the original
242
+ * params-hanging Suspense shell.
243
+ */
244
+ export function recordAppShell(input: SegmentRecordInput): void {
245
+ recordSegment({
246
+ ...input,
247
+ html: stripStreamedContinuation(input.html),
248
+ complete: false,
249
+ static: true,
250
+ runtime: false,
251
+ postponedShell: true,
252
+ sharedAppShell: true,
253
+ vary: [],
254
+ })
255
+ }
256
+
205
257
  /**
206
258
  * A payload is REWRITE-PRODUCED when the route the server resolved does not describe the URL it was
207
259
  * served at - either structurally, or in its concrete param values.
@@ -282,6 +334,22 @@ function recordOneSegment(rawInput: SegmentRecordInput): void {
282
334
  // map without limit (memory-pressure leak-slope test).
283
335
  trimLearnedRoutes(byRoute)
284
336
  }
337
+ const sharedAppShell = input.sharedAppShell === true || isRouteWideAppShell(input)
338
+ const existing = entries.get(key)
339
+ // A fully-static response can extract and cache this route shell with its
340
+ // own static window, then a concurrent runtime-shell response for the same
341
+ // fallback key can arrive with the full response's zero window. Next tracks
342
+ // shell-stage freshness independently; do not let that duplicate erase the
343
+ // already-fresh extracted shell.
344
+ if (
345
+ sharedAppShell &&
346
+ input.staleTimeMs <= 0 &&
347
+ existing?.sharedAppShell &&
348
+ now() - existing.time < existing.staleTimeMs
349
+ ) {
350
+ touch(key)
351
+ return
352
+ }
285
353
  entries.set(key, {
286
354
  key,
287
355
  html: input.html,
@@ -291,11 +359,39 @@ function recordOneSegment(rawInput: SegmentRecordInput): void {
291
359
  complete: input.complete,
292
360
  static: input.static,
293
361
  runtime: input.runtime,
362
+ postponedShell: input.postponedShell === true,
363
+ sharedAppShell,
294
364
  })
295
365
  touch(key)
296
366
  trim()
297
367
  }
298
368
 
369
+ /**
370
+ * A runtime App Shell response renders the route with its params hanging. Its
371
+ * empty fallback vary path and absent concrete route params distinguish it
372
+ * from an ordinary postponed response for one URL.
373
+ */
374
+ function isRouteWideAppShell(input: SegmentRecordInput): boolean {
375
+ if (!input.postponedShell || !input.static || !input.route || input.vary?.length !== 0)
376
+ return false
377
+ const concrete = matchRoutePattern(input.route, input.pathname, true)
378
+ if (concrete !== null && Object.keys(concrete).some(name => input.params[name] === undefined)) {
379
+ return true
380
+ }
381
+ // PNext restamps concrete params onto the shell payload for client state,
382
+ // even though the render itself hung them. The navigation-state child keeps
383
+ // the params-hanging route pattern, unlike an ordinary exact-URL PPR shell.
384
+ const match = /<script id="__PNEXT_NAV_STATE__" type="application\/json">(.*?)<\/script>/s.exec(
385
+ input.html,
386
+ )
387
+ if (!match?.[1]) return false
388
+ try {
389
+ return (JSON.parse(match[1]) as { children?: unknown }).children === input.route
390
+ } catch {
391
+ return false
392
+ }
393
+ }
394
+
299
395
  // ---------------------------------------------------------------------------
300
396
  // Lookup.
301
397
  // ---------------------------------------------------------------------------
@@ -371,6 +467,11 @@ export function takeSegment(options: {
371
467
  // match. The navigation that follows still fetches the dynamic remainder.
372
468
  prefetchSatisfied: !shared || (optimisticRoutingEnabled() && (entry.static || entry.runtime)),
373
469
  runtime: entry.runtime,
470
+ // An ordinary postponed response only describes the URL it rendered, so
471
+ // sharing clears its paint licence. An extracted App Shell was rendered
472
+ // with route params deliberately hanging and is the server's shell stage
473
+ // for every sibling param, so it retains that licence.
474
+ postponedShell: entry.postponedShell && (!shared || entry.sharedAppShell),
374
475
  }
375
476
  }
376
477
  return null
@@ -472,6 +573,9 @@ export function takeComposedSegment(options: {
472
573
  networkFree: layout.networkFree && page.networkFree,
473
574
  prefetchSatisfied: layout.prefetchSatisfied && page.prefetchSatisfied,
474
575
  runtime: layout.runtime || page.runtime,
576
+ // Composed from two frames: it is the destination's own static stage only when both
577
+ // halves are.
578
+ postponedShell: layout.postponedShell && page.postponedShell,
475
579
  }
476
580
  }
477
581
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  decodeSegmentPayload,
3
+ recordAppShell,
3
4
  recordSegment,
4
5
  segmentPrefetchCovered,
5
6
  takeSegment,
@@ -209,6 +210,7 @@ function recordBodySegment(url: URL, response: Response, body: string, staleTime
209
210
  static: response.headers.get('x-nextjs-prerender') === '1',
210
211
  runtime,
211
212
  vary: decoded.vary,
213
+ ...(decoded.shell ? { shell: decoded.shell } : {}),
212
214
  route: decoded.route,
213
215
  params: decoded.params,
214
216
  })
@@ -332,23 +334,21 @@ async function fetchAppShell(pathname: string): Promise<void> {
332
334
  if (!response.ok) return
333
335
  const decoded = decodeSegmentPayload(await response.text(), INDEX_SEGMENT_PATH)
334
336
  if (!decoded) return
335
- recordSegment({
337
+ recordAppShell({
336
338
  segmentPath: INDEX_SEGMENT_PATH,
337
339
  pathname,
338
340
  search: '',
339
- html: decoded.html,
341
+ html: decoded.shell?.html ?? decoded.html,
340
342
  // Recorded with the response's own window (NaN → the old 300s fallback):
341
343
  // the 30s floor is a re-prefetch dedupe rule, not a payload lifetime.
342
344
  staleTimeMs: (seconds => (Number.isFinite(seconds) ? seconds : 300) * 1000)(
343
345
  headerStaleTimeSeconds(response),
344
346
  ),
345
- // A shell, by construction: the params-dependent subtree is still a hole,
346
- // so a navigation paints it and then fetches the per-URL remainder.
347
347
  complete: false,
348
348
  static: response.headers.get('x-nextjs-prerender') === '1',
349
349
  runtime: false,
350
- vary: decoded.vary,
351
- route: decoded.route,
350
+ vary: [],
351
+ route: decoded.shell?.route ?? decoded.route,
352
352
  params: decoded.params,
353
353
  })
354
354
  }
@@ -558,8 +558,8 @@ const DEPRECATED_CONFIG_OPTIONS: readonly { key: string; reason: (file: string)
558
558
  // re-run every call - it throws.
559
559
  const warnedConfigPaths = new Set<string>()
560
560
 
561
- function validateConfig(config: NextConfigObject, configPath: string): void {
562
- if (!warnedConfigPaths.has(configPath)) {
561
+ function validateConfig(config: NextConfigObject, configPath: string, emitWarnings: boolean): void {
562
+ if (emitWarnings && !warnedConfigPaths.has(configPath)) {
563
563
  warnedConfigPaths.add(configPath)
564
564
  const configFileName = path.basename(configPath)
565
565
  warnUnknownConfigKeys(config)
@@ -647,7 +647,7 @@ function validateDevIndicatorPosition(config: NextConfigObject): void {
647
647
  */
648
648
  export async function loadNextConfig(
649
649
  root: string,
650
- options: { dev?: boolean; serve?: boolean } = {},
650
+ options: { dev?: boolean; serve?: boolean; warnings?: boolean } = {},
651
651
  ): Promise<LoadNextConfigResult> {
652
652
  const configPath = findConfigPath(root)
653
653
  if (!configPath) {
@@ -681,7 +681,9 @@ export async function loadNextConfig(
681
681
  await resolveConfigExport(exported, Boolean(options.dev)),
682
682
  root,
683
683
  )
684
- validateConfig(config, configPath)
684
+ // Internal build workers need the resolved config but must not surface a
685
+ // second copy of CLI warnings already emitted by the owning build process.
686
+ validateConfig(config, configPath, options.warnings !== false)
685
687
  setNextConfig(config)
686
688
  applyEnv(config)
687
689
  applyRuntimeConfig(config)
@@ -16,7 +16,7 @@ import {
16
16
  ReadonlyURLSearchParams,
17
17
  redirect as pnextRedirect,
18
18
  } from '../../api/navigation'
19
- import { isPostpone } from '../../render/postpone'
19
+ import { isPostpone, PostponeError } from '../../render/postpone'
20
20
  import { currentParams, currentRequest } from '../../request/context'
21
21
  import { useContext } from 'preact/hooks'
22
22
  import {
@@ -24,7 +24,11 @@ import {
24
24
  currentParamsSnapshot,
25
25
  hasLayoutSegmentScope,
26
26
  } from '../../render/slots'
27
- import { LayoutSegmentContext, RouteParamsContext } from '../../render/island-context'
27
+ import {
28
+ CsrBailoutContext,
29
+ LayoutSegmentContext,
30
+ RouteParamsContext,
31
+ } from '../../render/island-context'
28
32
  import { canonicalUrlHref } from './canonical-url'
29
33
  import { isUnrecognizedActionError } from '../actions/unrecognized-error'
30
34
  import { stripBasePath } from '../client/base-path'
@@ -96,9 +100,19 @@ export function usePathname() {
96
100
  }
97
101
 
98
102
  export function useSearchParams(): ReadonlyURLSearchParams {
103
+ if (useContext(CsrBailoutContext) && isStaticCsrBailout()) {
104
+ throw new PostponeError('useSearchParams')
105
+ }
99
106
  return new ReadonlyURLSearchParams(currentUrl().search)
100
107
  }
101
108
 
109
+ function isStaticCsrBailout(): boolean {
110
+ const storage = (globalThis as Record<PropertyKey, unknown>)[
111
+ Symbol.for('pnext.csrBailoutStorage')
112
+ ] as { getStore?: () => boolean | undefined } | undefined
113
+ return storage?.getStore?.() === true
114
+ }
115
+
102
116
  export function useParams<Route extends RoutePath = RoutePath>() {
103
117
  return resolveParams() as RouteParams<Route>
104
118
  }
@@ -1,10 +1,20 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, statSync } from 'node:fs'
3
- import { copyFile, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import {
4
+ copyFile,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ symlink,
11
+ writeFile,
12
+ } from 'node:fs/promises'
4
13
  import path from 'node:path'
5
14
  import { tmpdir } from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
6
16
  import { hasUseClientDirective } from '../../client/reference-stub'
7
- import { listFiles, toPosixPath } from '../../utils/fs'
17
+ import { listFiles, listFilesSync, toPosixPath } from '../../utils/fs'
8
18
  import { getNextConfig } from '../next/config-loader'
9
19
 
10
20
  const basePageExtensions = ['tsx', 'ts', 'jsx', 'js', 'mjs']
@@ -51,17 +61,103 @@ export async function materializePagesApp(root: string): Promise<string | undefi
51
61
  const pagesPath = path.join(root, 'pages')
52
62
  if (!existsSync(pagesPath)) return undefined
53
63
 
64
+ const nativeApp = path.join(root, 'app')
65
+ const pages = await discoverPages(pagesPath)
66
+ const apiRoutes = await discoverPagesApi(pagesPath)
67
+ if (pages.length === 0 && apiRoutes.length === 0) return undefined
68
+
69
+ // Content-addressed, never rebuilt in place. Every pnext process that resolves this app's config
70
+ // materializes — a build and the warm child it spawns do so concurrently — so wiping and repopulating
71
+ // one shared directory let one process scan a half-materialized tree ("page.js doesn't have a root
72
+ // layout" on a build that passes on the next run). Naming the directory after what goes in it makes
73
+ // the second writer a no-op, and staging + rename means a reader sees the whole tree or nothing.
54
74
  const generatedRoot = path.join(
55
75
  tmpdir(),
56
76
  'pnext-pages-compat',
57
- createHash('sha256').update(root).digest('hex').slice(0, 16),
77
+ materializationKey(root, nativeApp, pages, apiRoutes),
58
78
  )
79
+ const appPath = path.join(generatedRoot, 'app')
80
+ if (existsSync(appPath)) return appPath
81
+
82
+ const staging = `${generatedRoot}.${process.pid.toString(36)}.staging`
83
+ await buildMaterializedApp(staging, root, pagesPath, nativeApp, pages, apiRoutes)
84
+ try {
85
+ await rename(staging, generatedRoot)
86
+ } catch {
87
+ // Another process materialized the same key first; its tree is byte-identical to ours.
88
+ await rm(staging, { recursive: true, force: true })
89
+ if (!existsSync(appPath)) throw new Error(`pnext: could not materialize ${root}/pages`)
90
+ }
91
+ void sweepStaleMaterializations(path.dirname(generatedRoot))
92
+ return appPath
93
+ }
94
+
95
+ /**
96
+ * Everything the materialized tree is derived from: the pages facts (already parsed out of the
97
+ * sources), the native app tree's shape, and this module — whose `wrapperSource` writes the files.
98
+ */
99
+ function materializationKey(
100
+ root: string,
101
+ nativeApp: string,
102
+ pages: PagesRoute[],
103
+ apiRoutes: PagesApiRoute[],
104
+ ): string {
105
+ const stamp = (file: string) => {
106
+ const info = statSync(file, { throwIfNoEntry: false })
107
+ return info ? `${info.size}:${info.mtimeMs}` : ''
108
+ }
109
+ const nativeFiles = existsSync(nativeApp)
110
+ ? listFilesSync(nativeApp)
111
+ .map(file => `${toPosixPath(path.relative(nativeApp, file))}\0${stamp(file)}`)
112
+ .sort()
113
+ : []
114
+ // Sorted: discovery follows readdir order, and two processes that disagreed on it would key the
115
+ // same tree twice.
116
+ const byRelative = <T extends { relative: string }>(routes: T[]) =>
117
+ [...routes].sort((a, b) => a.relative.localeCompare(b.relative))
118
+ return createHash('sha256')
119
+ .update(
120
+ JSON.stringify([
121
+ root,
122
+ materializeExtensions(),
123
+ byRelative(pages),
124
+ byRelative(apiRoutes),
125
+ nativeFiles,
126
+ stamp(fileURLToPath(import.meta.url)),
127
+ ]),
128
+ )
129
+ .digest('hex')
130
+ .slice(0, 16)
131
+ }
132
+
133
+ // Abandoned stagings and superseded trees, swept on an age that no live process can still be using:
134
+ // a dev server holds its materialized tree open for as long as it runs.
135
+ const STALE_MATERIALIZATION_MS = 7 * 24 * 60 * 60_000
136
+
137
+ async function sweepStaleMaterializations(base: string) {
138
+ try {
139
+ const cutoff = Date.now() - STALE_MATERIALIZATION_MS
140
+ for (const entry of await readdir(base)) {
141
+ const dir = path.join(base, entry)
142
+ const info = statSync(dir, { throwIfNoEntry: false })
143
+ if (info && info.mtimeMs < cutoff) await rm(dir, { recursive: true, force: true })
144
+ }
145
+ } catch {
146
+ // Best-effort: a stale tree only costs a few KB of temp space.
147
+ }
148
+ }
149
+
150
+ async function buildMaterializedApp(
151
+ generatedRoot: string,
152
+ root: string,
153
+ pagesPath: string,
154
+ nativeApp: string,
155
+ pages: PagesRoute[],
156
+ apiRoutes: PagesApiRoute[],
157
+ ): Promise<void> {
59
158
  const appPath = path.join(generatedRoot, 'app')
60
159
  const sourcePages = path.join(generatedRoot, 'source-pages')
61
160
  const sourceApp = path.join(generatedRoot, 'source-app')
62
- const pages = await discoverPages(pagesPath)
63
- const apiRoutes = await discoverPagesApi(pagesPath)
64
- if (pages.length === 0 && apiRoutes.length === 0) return undefined
65
161
 
66
162
  await rm(generatedRoot, { recursive: true, force: true })
67
163
  await mkdir(appPath, { recursive: true })
@@ -77,7 +173,6 @@ export async function materializePagesApp(root: string): Promise<string | undefi
77
173
  const publicDir = path.join(root, 'public')
78
174
  if (existsSync(publicDir)) await symlink(publicDir, path.join(generatedRoot, 'public'), 'dir')
79
175
 
80
- const nativeApp = path.join(root, 'app')
81
176
  if (existsSync(nativeApp)) {
82
177
  await symlink(nativeApp, sourceApp, 'dir')
83
178
  await materializeNativeApp(sourceApp, appPath)
@@ -135,8 +230,6 @@ export async function materializePagesApp(root: string): Promise<string | undefi
135
230
  ),
136
231
  )
137
232
  }
138
-
139
- return appPath
140
233
  }
141
234
 
142
235
  async function materializeNativeApp(sourceApp: string, appPath: string): Promise<void> {
@@ -71,12 +71,21 @@ const APP_ROUTE_KINDS = new Set<ResponseFinalizerContext['routeKind']>([
71
71
 
72
72
  const protocolFinalizer: ResponseFinalizer = ctx => {
73
73
  if (!APP_ROUTE_KINDS.has(ctx.routeKind)) return
74
- mergeVary(ctx.headers, ROUTER_VARY)
74
+ if (!isPlainRedirect(ctx)) mergeVary(ctx.headers, ROUTER_VARY)
75
75
  normalizeRscRedirectStatus(ctx)
76
76
  applyRewrittenHeaders(ctx)
77
77
  applyEdgeRuntimeHeader(ctx)
78
78
  }
79
79
 
80
+ function isPlainRedirect(ctx: ResponseFinalizerContext): boolean {
81
+ if (ctx.status < 300 || ctx.status >= 400 || !ctx.headers.has('location')) return false
82
+ return (
83
+ ctx.request.headers.get('rsc') !== '1' &&
84
+ !ctx.request.headers.has('next-router-prefetch') &&
85
+ !ctx.request.headers.has('next-router-segment-prefetch')
86
+ )
87
+ }
88
+
80
89
  // Next's edge SSR/route wrappers stamp `x-edge-runtime: 1` on every response
81
90
  // from a route whose segment config selects the edge runtime (edge-ssr-app.ts).
82
91
  // The matched route's runtime rides on the work-unit response hints.
@@ -33,6 +33,7 @@ import {
33
33
  actionStubSourceFor,
34
34
  clientActionModulesRoot,
35
35
  isClientActionModule,
36
+ loadClientActionSource,
36
37
  } from '../actions/client-plugin'
37
38
  import { validateServerActionsConfigSync } from '../actions/config'
38
39
  import { hoistNonCapturingInlineActions } from '../actions/hoist'
@@ -102,5 +103,6 @@ export function registerActionExtensions(_config: ResolvedConfig): void {
102
103
  },
103
104
  isClientActionModule,
104
105
  stubSource: file => actionStubSourceFor(file, clientActionModulesRoot() ?? undefined),
106
+ loadClientSource: (file, root) => loadClientActionSource(file, root),
105
107
  })
106
108
  }
@@ -67,6 +67,7 @@ import {
67
67
  import { createModularizeImportsTransform } from '../bundler/modularize-imports'
68
68
  import {
69
69
  createOptimizePackageImportsTransform,
70
+ optimizePackageImportsSniffTokens,
70
71
  optimizedPackageSet,
71
72
  } from '../bundler/optimize-package-imports'
72
73
  import { createRelayTransform } from '../bundler/relay-transform'
@@ -207,6 +208,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
207
208
  // route named. Same chain position as modularizeImports, so the emitted leaf specifiers still flow
208
209
  // through alias/path resolution.
209
210
  const optimizedPackages = optimizedPackageSet(optimizePackageImports())
211
+ const optimizeSniff = optimizePackageImportsSniffTokens(optimizedPackages)
210
212
  const optimizeTransform = createOptimizePackageImportsTransform(optimizedPackages, config.root)
211
213
  registerServerSourcePreTransforms(optimizeTransform)
212
214
  registerClientSourceTransforms(optimizeTransform)
@@ -363,7 +365,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
363
365
  // a vendor pass runs over the same workspace sources.
364
366
  const firstPartyRewrite = createFirstPartySourceRewrite(config, {
365
367
  transform: optimizeTransform,
366
- sniff: specifierSniffTokens(optimizedPackages),
368
+ sniff: optimizeSniff,
367
369
  })
368
370
  const priorClient = getBundlerExtensions().clientEsbuildPlugins
369
371
  const priorServer = getBundlerExtensions().serverEsbuildPlugins
@@ -411,11 +413,7 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
411
413
  ...optional(clientExternalPackageResolvePlugin(cfg, externals)),
412
414
  // First-party source outside every source root gets no
413
415
  // registerClientSourceTransforms pass — the OPI rewrite must live here too.
414
- firstPartyClientOptimizeImportsLoadPlugin(
415
- cfg,
416
- optimizeTransform,
417
- specifierSniffTokens(optimizedPackages),
418
- ),
416
+ firstPartyClientOptimizeImportsLoadPlugin(cfg, optimizeTransform, optimizeSniff),
419
417
  clientDynamicCommonJsImportsPlugin(cfg.root),
420
418
  ...priorClient(cfg),
421
419
  ],
@@ -297,10 +297,9 @@ function usesNextRequestImport(source: string): boolean {
297
297
  // __prerender_bypass cookie opts into a fresh dynamic render at request time.
298
298
  const draftModeOnlyImport =
299
299
  /\bimport\s+\{\s*draftMode\s*(?:as\s+[\w$]+\s*)?,?\s*\}\s*from\s*['"]next\/headers(?:\.js)?['"]/g
300
- return (
301
- nextHeadersImportPattern().test(withoutComments.replace(draftModeOnlyImport, '')) ||
302
- importsNextNavigationRequestHook(withoutComments)
303
- )
300
+ const headers = nextHeadersImportPattern().test(withoutComments.replace(draftModeOnlyImport, ''))
301
+ const client = /^\s*['"]use client['"]\s*;?/.test(withoutComments)
302
+ return headers || (!client && importsNextNavigationRequestHook(withoutComments))
304
303
  }
305
304
 
306
305
  function nextHeadersImportPattern() {
@@ -171,13 +171,14 @@ function typeDefinitionGlobPatterns(distDir: string): string[] {
171
171
  */
172
172
  export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<void> {
173
173
  const tsConfigPath = path.join(config.root, 'tsconfig.json')
174
+ const tempDirectory = path.dirname(config.root)
174
175
  let isFirstTimeSetup = false
175
176
  if (!existsSync(tsConfigPath)) {
176
177
  if (!hasTypescriptSources(config.root)) return
177
178
  isFirstTimeSetup = true
178
179
  // Atomic: the client build's tsconfig-paths reader runs in a parallel phase
179
180
  // and must never see a truncated file.
180
- await writeFileAtomic(tsConfigPath, '{}\n')
181
+ await writeFileAtomic(tsConfigPath, '{}\n', tempDirectory)
181
182
  }
182
183
 
183
184
  let userTsConfig: TsconfigShape
@@ -271,7 +272,7 @@ export async function writeTsconfigDefaults(config: ResolvedConfig): Promise<voi
271
272
 
272
273
  if (suggestedActions.length === 0 && requiredActions.length === 0) return
273
274
 
274
- await writeFileAtomic(tsConfigPath, `${JSON.stringify(userTsConfig, null, 2)}\n`)
275
+ await writeFileAtomic(tsConfigPath, `${JSON.stringify(userTsConfig, null, 2)}\n`, tempDirectory)
275
276
 
276
277
  if (isFirstTimeSetup) {
277
278
  console.log(
package/src/config.ts CHANGED
@@ -61,7 +61,7 @@ export interface ConfigSourceOverrides {
61
61
  }
62
62
  export type ConfigSource = (
63
63
  root: string,
64
- options: { dev?: boolean; serve?: boolean },
64
+ options: { dev?: boolean; serve?: boolean; warnings?: boolean },
65
65
  ) => Promise<ConfigSourceOverrides>
66
66
 
67
67
  let configSource: ConfigSource | undefined
@@ -73,7 +73,7 @@ export function registerConfigSource(source: ConfigSource): void {
73
73
  async function resolveConfigSource(
74
74
  config: PNextConfig,
75
75
  root: string,
76
- options: { dev?: boolean; serve?: boolean },
76
+ options: { dev?: boolean; serve?: boolean; warnings?: boolean },
77
77
  ): Promise<ConfigSourceOverrides> {
78
78
  if (!config.compat?.next) return {}
79
79
  if (!configSource) {
@@ -87,7 +87,7 @@ async function resolveConfigSource(
87
87
  // then consume what the build emitted instead of regenerating it, since request time can be read-only.
88
88
  export async function loadConfig(
89
89
  rootInput = process.cwd(),
90
- options: { dev?: boolean; serve?: boolean } = {},
90
+ options: { dev?: boolean; serve?: boolean; warnings?: boolean } = {},
91
91
  ): Promise<ResolvedConfig> {
92
92
  const root = canonicalRoot(path.resolve(rootInput))
93
93
  await loadEnv(root, options)