@wular/pnext 0.0.9 → 0.0.11

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 (60) hide show
  1. package/package.json +1 -1
  2. package/reference/dev.md +17 -4
  3. package/src/api/dynamic.tsx +1 -0
  4. package/src/api/link.tsx +1 -0
  5. package/src/cli/analyze.ts +1 -1
  6. package/src/cli/boot/trace.ts +40 -7
  7. package/src/cli/build.ts +2 -2
  8. package/src/cli/dev.ts +5 -10
  9. package/src/cli/migrate/package-json.ts +1 -1
  10. package/src/cli/migrate/report.ts +5 -3
  11. package/src/cli/migrate/run.ts +30 -4
  12. package/src/cli/migrate/scan.ts +34 -7
  13. package/src/cli/typegen.ts +8 -1
  14. package/src/client/build.ts +4 -5
  15. package/src/client/profile.ts +4 -3
  16. package/src/client/router/runtime.ts +8 -0
  17. package/src/compat/actions/config.ts +2 -0
  18. package/src/compat/aliases.ts +2 -2
  19. package/src/compat/bundler/react-profiler.tsx +1 -0
  20. package/src/compat/cache-control.ts +49 -119
  21. package/src/compat/client/errors/global-error.ts +10 -5
  22. package/src/compat/client/errors/install.ts +30 -6
  23. package/src/compat/client/errors/lazy.ts +32 -2
  24. package/src/compat/next/client-script.tsx +1 -0
  25. package/src/compat/next/config-headers.ts +139 -0
  26. package/src/compat/next/config-loader.ts +32 -2
  27. package/src/compat/next/dynamic.tsx +1 -0
  28. package/src/compat/next/error.tsx +1 -0
  29. package/src/compat/next/form.tsx +1 -0
  30. package/src/compat/next/head.tsx +1 -0
  31. package/src/compat/next/headers.ts +12 -6
  32. package/src/compat/next/image/client.tsx +1 -0
  33. package/src/compat/next/image.tsx +1 -0
  34. package/src/compat/next/legacy-image.tsx +1 -0
  35. package/src/compat/next/legacy-request-apis.ts +62 -0
  36. package/src/compat/next/link.tsx +1 -0
  37. package/src/compat/next/rewrites.ts +7 -0
  38. package/src/compat/next/script.tsx +1 -0
  39. package/src/compat/register/bundler.ts +6 -1
  40. package/src/compat/segment/loading-boundary.ts +1 -5
  41. package/src/compat/validation/validate.ts +10 -2
  42. package/src/config.ts +12 -1
  43. package/src/css/build.ts +111 -4
  44. package/src/dev/internal-error.ts +128 -0
  45. package/src/dev/restart/global-css.ts +3 -3
  46. package/src/dev/restart/route-facts.ts +3 -3
  47. package/src/dev/server.ts +2 -2
  48. package/src/render/renderer.ts +20 -2
  49. package/src/render/slots.tsx +1 -0
  50. package/src/request/context.ts +15 -0
  51. package/src/runtime/loader.ts +2 -2
  52. package/src/runtime/module-cache.ts +12 -5
  53. package/src/runtime/modules.ts +44 -31
  54. package/src/runtime/vendor-build.ts +5 -7
  55. package/src/runtime/vendor.ts +29 -13
  56. package/src/types.ts +10 -1
  57. package/src/utils/code.ts +5 -0
  58. package/src/utils/dev-profile.ts +1 -1
  59. package/src/utils/esbuild.ts +37 -2
  60. package/src/utils/trace-flags.ts +66 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
package/reference/dev.md CHANGED
@@ -56,7 +56,20 @@ Vercel has a dedicated adapter: `pnext build --adapter vercel` writes Build Outp
56
56
 
57
57
  - `PNEXT_COMPAT=next`: Next compatibility without a config file. See [Compatibility](./compat.md).
58
58
  - `PNEXT_TYPECHECK=classic`: in-process TypeScript checker instead of the native one.
59
- - `PNEXT_CLIENT_METAFILE=1`: write the client esbuild metafile to the output directory.
60
- - `PNEXT_CLIENT_PROFILE=1`: print client-build phase timings.
61
- - `PNEXT_DEV_PROFILE=1`: print dev request and build timings.
62
- - `PNEXT_BOOT_TRACE=1`: print boot-phase timings and memory readings.
59
+ - `PNEXT_TRACE=<scopes>`: turn on diagnostic output. Comma-separated scopes, each `scope` or
60
+ `scope=value`; `1` means `boot`. A scope is all-or-nothing — it turns on everything it covers.
61
+ Example: `PNEXT_TRACE=boot,server=/tmp/modules.json`.
62
+
63
+ | scope | prints / does |
64
+ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `boot` | boot-phase timings and memory readings |
66
+ | `server` | dev request, render and build timings; per-module compile attribution; route-facts and global-css hit/miss; why each artifact was recompiled; load-plugin registrations |
67
+ | `vendor` | vendor saturation spans, esbuild plugin-callback attribution, and the preplan / native decision report |
68
+ | `client` | client-build phase timings, plus the esbuild metafile written into the output directory |
69
+ | `all` | the four above |
70
+
71
+ Two scopes take a `=<path>` value:
72
+
73
+ - `server=<path>` dumps the per-module compile list as JSON. A `.jsonl` path collects the
74
+ heavy-package facade profile instead — the two are never wanted at once, so the suffix picks.
75
+ - `vendor=<path>` appends the JSONL workload trace `bench/tools/vendor-analyze.ts` reads.
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { h, type ComponentType } from 'preact'
2
3
  import { useEffect, useRef, useState } from 'preact/hooks'
3
4
 
package/src/api/link.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { h, type ComponentChildren, type JSX } from 'preact'
2
3
  import { useEffect, useRef } from 'preact/hooks'
3
4
  import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react'
@@ -474,7 +474,7 @@ function printAnalyzeFiles(result: AnalyzeResult, options: { details: boolean })
474
474
  console.log(
475
475
  `Other files: ${formatFileCount(sorted)} ${formatTotalSize(sorted, result.compression)}`,
476
476
  )
477
- console.log('Run pnext analyze --files to list them.')
477
+ console.log('List them: pnext analyze --files')
478
478
  return
479
479
  }
480
480
 
@@ -1,22 +1,54 @@
1
1
  /**
2
- * Boot phase timings for `pnext dev`, printed when `PNEXT_BOOT_TRACE=1`.
2
+ * Boot phase timings for `pnext dev`, printed only under `PNEXT_TRACE=boot` (or `=1`).
3
3
  *
4
4
  * Timestamps are `performance.now()`, whose origin is process start, so the
5
5
  * first mark's absolute value is everything before it: the bun runtime coming
6
- * up plus this module graph evaluating. Off, a mark costs one already-resolved
7
- * boolean test.
6
+ * up plus this module graph evaluating. Off, a mark costs one clock read and
7
+ * one rss read; nothing is ever printed.
8
8
  */
9
- // eslint-disable-next-line turbo/no-undeclared-env-vars
10
- const enabled = process.env.PNEXT_BOOT_TRACE === '1'
9
+ import { loadavg, cpus, arch, platform } from 'node:os'
10
+ import { existsSync, readdirSync } from 'node:fs'
11
+ import path from 'node:path'
12
+ import { traceEnabled } from '../../utils/trace-flags'
13
+
14
+ const enabled = traceEnabled('boot')
11
15
  const marks: [string, number, number][] = []
12
16
 
17
+ // Bun (<=1.3.x) lacks the memoryUsage.rss fast path Node has.
18
+ const rss = () =>
19
+ typeof process.memoryUsage.rss === 'function'
20
+ ? process.memoryUsage.rss()
21
+ : process.memoryUsage().rss
22
+
13
23
  export function markBoot(name: string) {
14
- if (enabled) marks.push([name, performance.now(), process.memoryUsage.rss()])
24
+ if (enabled) marks.push([name, performance.now(), rss()])
25
+ }
26
+
27
+ // Environment facts a slow-boot report needs alongside the stage timings: the same trace on a
28
+ // loaded box, a cold cache, or a huge node_modules reads completely differently.
29
+ function traceHeader(): string[] {
30
+ const load = loadavg()
31
+ .map(value => value.toFixed(1))
32
+ .join(' ')
33
+ const packages = countTopLevel('node_modules')
34
+ const outDir = existsSync(path.resolve('.pnext', 'dev', 'cache')) ? 'warm' : 'cold'
35
+ return [
36
+ ` runtime ${typeof Bun !== 'undefined' ? `bun ${Bun.version}` : `node ${process.version}`} · ${platform()} ${arch()} · ${cpus().length} cpus · load ${load}`,
37
+ ` app ${packages} node_modules entries · .pnext/dev cache ${outDir}`,
38
+ ]
39
+ }
40
+
41
+ function countTopLevel(dir: string): number {
42
+ try {
43
+ return readdirSync(path.resolve(dir)).length
44
+ } catch {
45
+ return 0
46
+ }
15
47
  }
16
48
 
17
49
  export function printBootTrace() {
18
50
  if (!enabled || marks.length === 0) return
19
- const lines = ['boot trace (ms since process start):']
51
+ const lines = ['boot trace (ms since process start):', ...traceHeader()]
20
52
  let previous = 0
21
53
  for (const [name, at, rss] of marks) {
22
54
  const mb = (rss / 1048576).toFixed(0).padStart(5)
@@ -25,5 +57,6 @@ export function printBootTrace() {
25
57
  )
26
58
  previous = at
27
59
  }
60
+ lines.push(` total @${performance.now().toFixed(1)}`)
28
61
  console.log(lines.join('\n'))
29
62
  }
package/src/cli/build.ts CHANGED
@@ -1086,8 +1086,8 @@ function printBuildSummary(
1086
1086
  }
1087
1087
  console.log(` ${dim(`${parts.join(', ')} → ${outDir}`)}`)
1088
1088
  console.log('')
1089
- console.log(` Run ${cyan('pnext start')} to serve the production build.`)
1090
- console.log(` ${cyan('pnext analyze')} to analyze the build.`)
1089
+ console.log(` Serve the production build: ${cyan('pnext start')}`)
1090
+ console.log(` Analyze the build: ${cyan('pnext analyze')}`)
1091
1091
  }
1092
1092
 
1093
1093
  function staticMetadataSummaryFiles(staticFiles: Record<string, StaticFileMetadata>) {
package/src/cli/dev.ts CHANGED
@@ -125,16 +125,11 @@ function watchEsbuildMemory() {
125
125
  ),
126
126
  )
127
127
  void (async () => {
128
- try {
129
- const esbuild = await import('esbuild')
130
- await esbuild.stop()
131
- } catch {
132
- // ignore: the service may already be gone
133
- }
134
- // stop() can strand in-flight build promises unsettled; fail their
135
- // waiters so requests retry against the respawned service.
136
- const { failInFlightClientBuilds } = await import('../dev/server')
137
- failInFlightClientBuilds('esbuild service restarted by the memory watchdog')
128
+ // The idle-guarded stop: a raw esbuild.stop() under an in-flight compile
129
+ // fails that request with a 500, so a busy service drains first. In-flight
130
+ // builds are untouched, so their waiters must not be failed here.
131
+ const { stopEsbuildService } = await import('../utils/esbuild')
132
+ stopEsbuildService()
138
133
  })()
139
134
  }, 15_000)
140
135
  timer.unref()
@@ -147,7 +147,7 @@ export async function migratePackageJson(
147
147
  if (leftovers.length > 0) {
148
148
  result.reports.push({
149
149
  title: 'Next-specific packages still installed',
150
- detail: 'Optional cleanup — these are unused under pnext. See reference/compat.md.',
150
+ detail: 'Optional cleanup — these are unused under pnext. See https://pnext.dev/docs/compat.',
151
151
  files: leftovers,
152
152
  })
153
153
  }
@@ -25,10 +25,12 @@ export interface ReportItem {
25
25
  export interface MigrationResult {
26
26
  edits: Edit[]
27
27
  reports: ReportItem[]
28
+ /** Files reading cookies()/headers()/draftMode() synchronously (Next 14 style). */
29
+ syncRequestApis: string[]
28
30
  }
29
31
 
30
32
  export function emptyResult(): MigrationResult {
31
- return { edits: [], reports: [] }
33
+ return { edits: [], reports: [], syncRequestApis: [] }
32
34
  }
33
35
 
34
36
  export function printHeader(root: string, dryRun: boolean) {
@@ -63,8 +65,8 @@ export function printResult(result: MigrationResult, options: { install: string
63
65
  }
64
66
 
65
67
  lines.push(`\n${bold('Next steps')}`)
66
- lines.push(` Run ${cyan(options.install)} to install the new dependencies.`)
67
- lines.push(` ${cyan('pnext dev')} to start the app.`)
68
+ lines.push(` 1. Install the new dependencies: ${cyan(options.install)}`)
69
+ lines.push(` 2. Start the app: ${cyan('pnext dev')}`)
68
70
  console.log(lines.join('\n'))
69
71
  }
70
72
 
@@ -37,32 +37,58 @@ export async function migrateApp(directory: string | undefined, options: { dryRu
37
37
  printHeader(root, options.dryRun)
38
38
  const result = emptyResult()
39
39
  if (pkg) await migratePackageJson(root, pkg, result, options.dryRun)
40
+ // The scan runs first: what it finds (sync request APIs) decides what the config enables.
41
+ await withSpinner('Scanning sources ...', () => scanSources(root, result))
40
42
  await createPnextConfig(root, result, options.dryRun)
41
43
  await migrateTsconfig(root, result, options.dryRun)
42
44
  await removeNextEnv(root, result, options.dryRun)
43
45
  await updateGitignore(root, result, options.dryRun)
44
- await withSpinner('Scanning sources ...', () => scanSources(root, result))
45
46
 
46
47
  printResult(result, { install: installCommand(root) })
47
48
  return 0
48
49
  }
49
50
 
50
51
  async function createPnextConfig(root: string, result: MigrationResult, dryRun: boolean) {
52
+ const legacy = result.syncRequestApis.length > 0
51
53
  const existing = PNEXT_CONFIG_FILES.find(name => existsSync(path.join(root, name)))
52
54
  if (existing) {
53
55
  result.reports.push({
54
56
  title: `${existing} already exists`,
55
- detail: 'Make sure it sets compat: { next: true }. See reference/compat.md.',
57
+ detail: 'Make sure it sets compat: { next: true }. See https://pnext.dev/docs/compat.',
56
58
  })
59
+ if (legacy) reportSyncRequestApis(result, existing)
57
60
  return
58
61
  }
59
62
  if (!dryRun) {
60
63
  await writeFile(
61
64
  path.join(root, 'pnext.config.ts'),
62
- 'export default { compat: { next: true } };\n',
65
+ legacy
66
+ ? 'export default {\n' +
67
+ ' compat: {\n' +
68
+ ' // Sync cookies()/headers() still work; migrate to `await` and drop this.\n' +
69
+ ' next: { legacyRequestAPIs: true },\n' +
70
+ ' },\n' +
71
+ '};\n'
72
+ : 'export default { compat: { next: true } };\n',
63
73
  )
64
74
  }
65
- result.edits.push({ file: 'pnext.config.ts', description: 'created with compat.next enabled' })
75
+ result.edits.push({
76
+ file: 'pnext.config.ts',
77
+ description: legacy
78
+ ? 'created with compat.next enabled (legacyRequestAPIs)'
79
+ : 'created with compat.next enabled',
80
+ })
81
+ if (legacy) reportSyncRequestApis(result)
82
+ }
83
+
84
+ function reportSyncRequestApis(result: MigrationResult, existingConfig?: string) {
85
+ result.reports.push({
86
+ title: 'Sync cookies()/headers() detected',
87
+ detail: existingConfig
88
+ ? `Set compat: { next: { legacyRequestAPIs: true } } in ${existingConfig} to keep these working, then migrate to \`await\` and remove it. See https://pnext.dev/docs/compat`
89
+ : 'Enabled compat.next.legacyRequestAPIs; migrate to `await` and remove it. See https://pnext.dev/docs/compat',
90
+ files: [...new Set(result.syncRequestApis)],
91
+ })
66
92
  }
67
93
 
68
94
  async function removeNextEnv(root: string, result: MigrationResult, dryRun: boolean) {
@@ -17,6 +17,11 @@ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = {
17
17
  '.cjs': 'js',
18
18
  }
19
19
  const STREAMING_APIS = ['renderToReadableStream', 'renderToPipeableStream']
20
+ // `cookies().get(...)` / `headers()['x']` with no `await` — Next 14 sync request-API reads.
21
+ const CALL_THEN_ACCESS = /(await\s+)?\b([\w$]+)\s*\(\s*\)\s*(?:\.\s*([\w$]+)|\[)/g
22
+ const NEXT_HEADERS_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]next\/headers['"]/g
23
+ const REQUEST_APIS = new Set(['cookies', 'headers', 'draftMode'])
24
+ const PROMISE_METHODS = new Set(['then', 'catch', 'finally'])
20
25
  const SPECIAL_PAGES = ['_app', '_document', '_error']
21
26
 
22
27
  export async function scanSources(root: string, result: MigrationResult) {
@@ -49,13 +54,14 @@ export async function scanSources(root: string, result: MigrationResult) {
49
54
  }
50
55
  if (specifier === 'next/head') headImports.push(relative)
51
56
  }
57
+ if (readsRequestApiSynchronously(text)) result.syncRequestApis.push(relative)
52
58
  }
53
59
 
54
60
  if (deepImports.length > 0) {
55
61
  result.reports.push({
56
62
  title: 'Unsupported next/dist/* deep imports',
57
63
  detail:
58
- 'Only five next/dist paths are shimmed — replace these with public APIs. See reference/compat.md ("Smaller surfaces").',
64
+ 'Only five next/dist paths are shimmed — replace these with public APIs. See https://pnext.dev/docs/compat ("Smaller surfaces").',
59
65
  files: deepImports,
60
66
  })
61
67
  }
@@ -63,16 +69,16 @@ export async function scanSources(root: string, result: MigrationResult) {
63
69
  result.reports.push({
64
70
  title: 'react-dom/server streaming APIs',
65
71
  detail:
66
- 'preact/compat does not provide renderToReadableStream/renderToPipeableStream — use pnext rendering instead. See reference/compat.md.',
72
+ 'preact/compat does not provide renderToReadableStream/renderToPipeableStream — use pnext rendering instead. See https://pnext.dev/docs/compat.',
67
73
  files: [...new Set(streaming)],
68
74
  })
69
75
  }
70
76
 
71
77
  if (headImports.length > 0) {
72
78
  result.reports.push({
73
- title: 'next/head renders nothing',
79
+ title: 'next/head is ignored in the app router',
74
80
  detail:
75
- 'Move these tags to metadata exports (or the root layout <head>) — next/head is a silent no-op under pnext. See reference/compat.md.',
81
+ 'pnext ignores next/head in app-directory components (matching Next). Move these tags to metadata exports or the root layout <head>. See https://pnext.dev/docs/compat.',
76
82
  files: [...new Set(headImports)],
77
83
  })
78
84
  }
@@ -81,6 +87,27 @@ export async function scanSources(root: string, result: MigrationResult) {
81
87
  await scanNextConfig(root, result)
82
88
  }
83
89
 
90
+ /**
91
+ * A next/headers request API called and immediately read, with no `await` — the Next 14 shape that
92
+ * throws under Next 16 semantics. Local aliases (`cookies as getCookies`) count; `.then()` chains do not.
93
+ */
94
+ function readsRequestApiSynchronously(text: string): boolean {
95
+ const locals = new Set<string>()
96
+ for (const match of text.matchAll(NEXT_HEADERS_IMPORT)) {
97
+ for (const binding of match[1]!.split(',')) {
98
+ const [imported, local] = binding.trim().split(/\s+as\s+/)
99
+ if (imported && REQUEST_APIS.has(imported)) locals.add(local ?? imported)
100
+ }
101
+ }
102
+ if (locals.size === 0) return false
103
+ for (const match of text.matchAll(CALL_THEN_ACCESS)) {
104
+ if (match[1] || !locals.has(match[2]!)) continue
105
+ if (match[3] && PROMISE_METHODS.has(match[3])) continue
106
+ return true
107
+ }
108
+ return false
109
+ }
110
+
84
111
  function scanSpecialPages(root: string, result: MigrationResult) {
85
112
  const found: string[] = []
86
113
  for (const base of ['pages', path.join('src', 'pages')]) {
@@ -95,7 +122,7 @@ function scanSpecialPages(root: string, result: MigrationResult) {
95
122
  result.reports.push({
96
123
  title: 'pages/_app, _document and _error are ignored',
97
124
  detail:
98
- 'Move this setup into the app-router root layout — pnext never loads these files. See reference/compat.md.',
125
+ 'Move this setup into the app-router root layout — pnext never loads these files. See https://pnext.dev/docs/compat.',
99
126
  files: found,
100
127
  })
101
128
  }
@@ -110,8 +137,8 @@ async function scanNextConfig(root: string, result: MigrationResult) {
110
137
  result.reports.push({
111
138
  title: `${name} defines a webpack function`,
112
139
  detail: svgr
113
- ? 'The webpack function is never executed; SVGR is auto-detected from @svgr/webpack, so SVG imports keep working. See reference/compat.md.'
114
- : 'The webpack function is never executed — port any custom loaders/plugins to pnext config. See reference/compat.md.',
140
+ ? 'The webpack function is never executed; SVGR is auto-detected from @svgr/webpack, so SVG imports keep working. See https://pnext.dev/docs/compat.'
141
+ : 'The webpack function is never executed — port any custom loaders/plugins to pnext config. See https://pnext.dev/docs/compat.',
115
142
  })
116
143
  return
117
144
  }
@@ -145,7 +145,14 @@ function paramsType(route: RouteManifestEntry) {
145
145
  }
146
146
 
147
147
  async function writeIfChanged(file: string, source: string) {
148
- if (existsSync(file) && (await readFile(file, 'utf8')) === source) return
148
+ // A concurrent typegen (the dev server runs one off the critical path, a CLI run can overlap it
149
+ // on the same out-dir) can delete the file between the existence check and the read; a failed
150
+ // read just means "changed", never a crash.
151
+ try {
152
+ if (existsSync(file) && (await readFile(file, 'utf8')) === source) return
153
+ } catch {
154
+ // fall through to the write
155
+ }
149
156
  await ensureDir(path.dirname(file))
150
157
  await writeFile(file, source)
151
158
  }
@@ -56,6 +56,7 @@ import { clientReferenceId, ssrClientReference, type ClientReference } from './r
56
56
  import { shouldReactCompile, transformReactCompiler } from './react-compiler'
57
57
  import { createVerboseLogger, formatDuration } from '../utils/verbose'
58
58
  import { dim as dimText } from '../utils/ansi'
59
+ import { traceEnabled } from '../utils/trace-flags'
59
60
  import type { RouteManifestEntry } from '../types'
60
61
  import {
61
62
  applyClientSourceAsyncPreTransforms,
@@ -1016,7 +1017,7 @@ function deploymentIdDefine(): Record<string, string> {
1016
1017
  * Wrap every plugin's onResolve/onLoad so the profile reports, per plugin, how
1017
1018
  * many callbacks esbuild made and how long they held the JS thread. Each call
1018
1019
  * is an IPC round trip, so the counts are what distinguishes "our transforms are
1019
- * slow" from "we are answering 40k questions". Off unless PNEXT_CLIENT_PROFILE.
1020
+ * slow" from "we are answering 40k questions". Off unless PNEXT_TRACE=client.
1020
1021
  */
1021
1022
  function instrumentedPlugins(plugins: Plugin[]): Plugin[] {
1022
1023
  if (!clientProfile.enabled) return plugins
@@ -1657,8 +1658,7 @@ async function profileClientBuild<T>(
1657
1658
  dev: boolean | undefined,
1658
1659
  task: () => Promise<T>,
1659
1660
  ) {
1660
- // eslint-disable-next-line turbo/no-undeclared-env-vars
1661
- if (!process.env.PNEXT_DEV_PROFILE) return task()
1661
+ if (!traceEnabled('server')) return task()
1662
1662
  const start = performance.now()
1663
1663
  try {
1664
1664
  return await task()
@@ -2075,8 +2075,7 @@ function rootFromFile(file: string) {
2075
2075
  // the metafile: the package name for node_modules code, the file stem for app
2076
2076
  // code. Falls back to "shared" when a build ran without a metafile.
2077
2077
  async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
2078
- // eslint-disable-next-line turbo/no-undeclared-env-vars
2079
- if (metafile && process.env.PNEXT_CLIENT_METAFILE) {
2078
+ if (metafile && traceEnabled('client')) {
2080
2079
  await writeText(path.join(outDir, 'metafile.json'), JSON.stringify(metafile))
2081
2080
  }
2082
2081
  const chunksDir = path.join(outDir, 'chunks')
@@ -1,8 +1,9 @@
1
1
  // Phase decomposition for the client stage (read / transform / react-compiler /
2
- // esbuild / write), off unless PNEXT_CLIENT_PROFILE is set. The bench needs the
2
+ // esbuild / write), off unless PNEXT_TRACE=client. The bench needs the
3
3
  // split to prove where the residual sits; a build must not pay for it.
4
- // eslint-disable-next-line turbo/no-undeclared-env-vars
5
- const enabled = Boolean(process.env.PNEXT_CLIENT_PROFILE)
4
+ import { traceEnabled } from '../utils/trace-flags'
5
+
6
+ const enabled = traceEnabled('client')
6
7
 
7
8
  const totals = new Map<string, number>()
8
9
  const spans = new Map<string, [number, number][]>()
@@ -5138,7 +5138,11 @@ export function onPopState() {
5138
5138
  }
5139
5139
 
5140
5140
  const eagerLinks = new WeakSet<Element>()
5141
+ // Per DOCUMENT, not per process: an observer belongs to the document whose links it watches.
5142
+ // A browser page never swaps documents, but multi-document hosts (jsdom embedders, the test
5143
+ // process) do, and a stale observer silently never fires for the new document's links.
5141
5144
  let visibleLinkObserver: IntersectionObserver | undefined
5145
+ let visibleLinkObserverDoc: Document | undefined
5142
5146
 
5143
5147
  // `load` links prefetch as soon as they appear; `visible` links when they
5144
5148
  // enter the viewport. Both arrive with the page or with later client renders,
@@ -5168,6 +5172,10 @@ function scanEagerPrefetchLinks(root: Element) {
5168
5172
  if (href) void prefetchRoute(href, { element: link, full: isFullPrefetchLink(link) })
5169
5173
  continue
5170
5174
  }
5175
+ if (visibleLinkObserverDoc !== document) {
5176
+ visibleLinkObserver = undefined
5177
+ visibleLinkObserverDoc = document
5178
+ }
5171
5179
  visibleLinkObserver ??= new IntersectionObserver(entries => {
5172
5180
  for (const entry of entries) {
5173
5181
  if (!entry.isIntersecting) {
@@ -22,6 +22,8 @@ export interface NextConfigShape {
22
22
  experimental?: { serverActions?: { bodySizeLimit?: unknown } | boolean }
23
23
  rewrites?: () => unknown
24
24
  redirects?: () => unknown
25
+ headers?: () => unknown
26
+ poweredByHeader?: boolean
25
27
  }
26
28
 
27
29
  export async function loadServerActionsConfig(root: string): Promise<ServerActionsConfig> {
@@ -22,7 +22,7 @@ export function nextCompatEnabled(config: CompatConfig) {
22
22
  return Boolean(config.compat?.next)
23
23
  }
24
24
 
25
- /** The only `next/dist/*` deep imports pnext shims (see reference/compat.md "Smaller surfaces"). */
25
+ /** The only `next/dist/*` deep imports pnext shims (see https://pnext.dev/docs/compat "Smaller surfaces"). */
26
26
  export const SHIMMED_NEXT_DIST_PATHS = [
27
27
  'next/dist/client/components/app-router-headers',
28
28
  'next/dist/server/web/spec-extension/unstable-cache',
@@ -50,7 +50,7 @@ export function missingCompatImportError(
50
50
  `'${specifier}' is not a supported import: next/dist internals are not part of the ` +
51
51
  `public surface pnext shims. Only these next/dist/* paths are shimmed:\n` +
52
52
  SHIMMED_NEXT_DIST_PATHS.map(path => ` - ${path}`).join('\n') +
53
- `\nSee reference/compat.md ("Smaller surfaces") and report the library that needs a new shim.`
53
+ `\nSee https://pnext.dev/docs/compat ("Smaller surfaces") and report the library that needs a new shim.`
54
54
  )
55
55
  }
56
56
  }
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { Fragment, type ComponentChildren } from 'preact'
2
3
  import { useLayoutEffect, useRef } from 'preact/hooks'
3
4