@wular/pnext 0.0.20 → 0.0.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,324 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import type { ResolvedConfig } from '../../config'
6
+ import { frameworkFingerprint } from '../../runtime/fingerprint'
7
+ import type { BuildManifest } from '../../types'
8
+ import { writeFileAtomic } from '../../utils/fs'
9
+
10
+ const CACHE_VERSION = 2
11
+ const CACHE_SEGMENTS = ['cache', 'build'] as const
12
+ const SKIP_INPUT_DIRS = new Set([
13
+ '.claude',
14
+ '.git',
15
+ '.husky',
16
+ '.next',
17
+ '.pnext',
18
+ '.tmp',
19
+ '.turbo',
20
+ '.vercel',
21
+ '.vscode',
22
+ 'node_modules',
23
+ ])
24
+ const SKIP_INPUT_FILE = /(?:^\.DS_Store$|\.(?:log|pid)$)/i
25
+ const LOCK_FILES = [
26
+ 'bun.lock',
27
+ 'bun.lockb',
28
+ 'package-lock.json',
29
+ 'npm-shrinkwrap.json',
30
+ 'pnpm-lock.yaml',
31
+ 'yarn.lock',
32
+ ]
33
+
34
+ type InputRecord = [mtimeMs: number, size: number, hash: string]
35
+
36
+ interface CacheRecord {
37
+ version: number
38
+ key: string
39
+ inputs: Record<string, InputRecord>
40
+ outputHash: string
41
+ outputs: Record<string, string>
42
+ }
43
+
44
+ export interface BuildCacheOptions {
45
+ adapter?: 'vercel'
46
+ debugBuildPaths?: string
47
+ buildMode?: 'compile' | 'generate'
48
+ debugPrerender?: boolean
49
+ }
50
+
51
+ export interface BuildCacheSession {
52
+ enabled: boolean
53
+ context?: string
54
+ inputs?: Record<string, InputRecord>
55
+ manifest?: BuildManifest
56
+ reason?: string
57
+ }
58
+
59
+ /** Validate the standard production build before its output directory is touched. */
60
+ export async function lookupBuildCache(
61
+ config: ResolvedConfig,
62
+ options: BuildCacheOptions,
63
+ ): Promise<BuildCacheSession> {
64
+ if (!cacheEnabled(options)) return { enabled: false }
65
+ const record = await readRecord(config.outPath)
66
+ const inputs = await inputRecords(config, record?.inputs)
67
+ const context = buildContext()
68
+ const key = buildKey(context, inputs)
69
+ if (record?.key !== key) return { enabled: true, context, inputs, reason: 'inputs' }
70
+ const output = await outputSnapshot(config.outPath)
71
+ if (output.hash !== record.outputHash) {
72
+ const changed = changedFiles(record.outputs, output.files).slice(0, 3).join(', ')
73
+ return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
74
+ }
75
+ try {
76
+ const manifest = JSON.parse(
77
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
78
+ ) as BuildManifest
79
+ return { enabled: true, context, inputs, manifest, reason: 'hit' }
80
+ } catch {
81
+ return { enabled: true, context, inputs, reason: 'manifest' }
82
+ }
83
+ }
84
+
85
+ /** Publish a successful build only after every production artifact has landed. */
86
+ export async function writeBuildCache(
87
+ config: ResolvedConfig,
88
+ options: BuildCacheOptions,
89
+ session: BuildCacheSession,
90
+ ) {
91
+ if (!session.enabled || !session.context || !cacheEnabled(options)) return
92
+ const inputs = await inputRecords(config, session.inputs)
93
+ const output = await outputSnapshot(config.outPath)
94
+ const record: CacheRecord = {
95
+ version: CACHE_VERSION,
96
+ key: buildKey(session.context, inputs),
97
+ inputs,
98
+ outputHash: output.hash,
99
+ outputs: output.files,
100
+ }
101
+ await mkdir(cacheDir(config.outPath), { recursive: true })
102
+ await writeFileAtomic(recordFile(config.outPath), JSON.stringify(record))
103
+ }
104
+
105
+ function cacheEnabled(options: BuildCacheOptions) {
106
+ // Opt-in until the no-change short-circuit is finished: the key currently hashes the whole
107
+ // environment, so cross-shell hits are rare and the cache adds bookkeeping without payoff.
108
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
109
+ if (process.env.PNEXT_BUILD_CACHE !== '1') return false
110
+ return (
111
+ options.adapter === undefined &&
112
+ options.debugBuildPaths === undefined &&
113
+ options.buildMode === undefined &&
114
+ options.debugPrerender !== true
115
+ )
116
+ }
117
+
118
+ function cacheDir(outPath: string) {
119
+ return path.join(outPath, ...CACHE_SEGMENTS)
120
+ }
121
+
122
+ function recordFile(outPath: string) {
123
+ return path.join(cacheDir(outPath), 'result.json')
124
+ }
125
+
126
+ async function readRecord(outPath: string): Promise<CacheRecord | undefined> {
127
+ try {
128
+ const parsed = JSON.parse(await readFile(recordFile(outPath), 'utf8')) as CacheRecord
129
+ if (
130
+ parsed.version === CACHE_VERSION &&
131
+ parsed.key &&
132
+ parsed.inputs &&
133
+ parsed.outputHash &&
134
+ parsed.outputs
135
+ ) {
136
+ return parsed
137
+ }
138
+ } catch {
139
+ // A partial or older entry is a miss, never a stale hit.
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ function buildContext() {
145
+ const hash = createHash('sha256')
146
+ hash.update(`pnext-build-cache:${CACHE_VERSION}\0`)
147
+ hash.update(frameworkFingerprint())
148
+ hash.update(`\0bun:${Bun.version}\0`)
149
+ for (const [name, value] of Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b))) {
150
+ hash.update(`${name}=${value ?? ''}\0`)
151
+ }
152
+ return hash.digest('hex')
153
+ }
154
+
155
+ function buildKey(context: string, inputs: Record<string, InputRecord>) {
156
+ const hash = createHash('sha256')
157
+ hash.update(context)
158
+ for (const [file, record] of Object.entries(inputs).sort(([a], [b]) => a.localeCompare(b))) {
159
+ hash.update(`${file}\0${record[2]}\0`)
160
+ }
161
+ return hash.digest('hex')
162
+ }
163
+
164
+ async function inputRecords(
165
+ config: ResolvedConfig,
166
+ previous: Record<string, InputRecord> | undefined,
167
+ ) {
168
+ const files = new Map<string, string>()
169
+ await collectTree(config.root, 'root', files, config.outPath)
170
+ await collectModuleGraphInputs(config, files)
171
+
172
+ // Dependency trees are represented by package-manager content identities,
173
+ // while first-party modules above are keyed by their own bytes.
174
+ for (const [prefix, root] of [
175
+ ['root-deps', config.root],
176
+ ['workspace-deps', config.workspaceRoot],
177
+ ] as const) {
178
+ for (const name of LOCK_FILES) addFile(files, `${prefix}:${name}`, path.join(root, name))
179
+ addFile(
180
+ files,
181
+ `${prefix}:node_modules/.package-lock.json`,
182
+ path.join(root, 'node_modules', '.package-lock.json'),
183
+ )
184
+ }
185
+
186
+ const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
187
+ addFile(files, 'framework:package.json', path.join(frameworkRoot, 'package.json'))
188
+ for (const name of LOCK_FILES) {
189
+ addFile(files, `framework:${name}`, path.join(frameworkRoot, name))
190
+ addFile(files, `framework-workspace:${name}`, path.join(frameworkRoot, '..', name))
191
+ }
192
+
193
+ const entries = await Promise.all(
194
+ [...files].map(async ([key, file]): Promise<[string, InputRecord] | undefined> => {
195
+ const stats = await lstat(file).catch(() => undefined)
196
+ if (!stats?.isFile() && !stats?.isSymbolicLink()) return undefined
197
+ const old = previous?.[key]
198
+ if (old?.[0] === stats.mtimeMs && old[1] === stats.size) return [key, old]
199
+ const contents = stats.isSymbolicLink()
200
+ ? Buffer.from(`link:${await readlink(file)}`)
201
+ : await readFile(file)
202
+ return [key, [stats.mtimeMs, stats.size, contentHash(contents)]]
203
+ }),
204
+ )
205
+ return Object.fromEntries(
206
+ entries.filter((entry): entry is [string, InputRecord] => Boolean(entry)),
207
+ )
208
+ }
209
+
210
+ interface ModuleGraphIndex {
211
+ files?: Record<string, unknown>
212
+ }
213
+
214
+ async function collectModuleGraphInputs(config: ResolvedConfig, files: Map<string, string>) {
215
+ let parsed: ModuleGraphIndex
216
+ try {
217
+ parsed = JSON.parse(
218
+ await readFile(path.join(config.outPath, 'cache', 'server', 'graph.json'), 'utf8'),
219
+ ) as ModuleGraphIndex
220
+ } catch {
221
+ return
222
+ }
223
+ const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
224
+ for (const source of Object.keys(parsed.files ?? {})) {
225
+ const value = source.slice(2).split('/').join(path.sep)
226
+ const file = source.startsWith('w:')
227
+ ? path.resolve(config.workspaceRoot, value)
228
+ : source.startsWith('f:')
229
+ ? path.resolve(frameworkRoot, value)
230
+ : source.startsWith('a:')
231
+ ? value
232
+ : undefined
233
+ if (file) addFile(files, `module:${source}`, file)
234
+ }
235
+ }
236
+
237
+ async function collectTree(
238
+ root: string,
239
+ prefix: string,
240
+ files: Map<string, string>,
241
+ outPath: string,
242
+ dir = root,
243
+ ): Promise<void> {
244
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
245
+ await Promise.all(
246
+ entries.map(async entry => {
247
+ const file = path.join(dir, entry.name)
248
+ if (file === outPath || file.startsWith(`${outPath}${path.sep}`)) return
249
+ if (entry.isDirectory()) {
250
+ if (!SKIP_INPUT_DIRS.has(entry.name)) await collectTree(root, prefix, files, outPath, file)
251
+ return
252
+ }
253
+ if (!entry.isFile() && !entry.isSymbolicLink()) return
254
+ const relative = path.relative(root, file)
255
+ // Local server logs and top-level response captures are not compiler
256
+ // inputs; watching processes commonly rewrite them during a build.
257
+ if (
258
+ SKIP_INPUT_FILE.test(entry.name) ||
259
+ (!relative.includes(path.sep) && /\.html?$/i.test(entry.name))
260
+ ) {
261
+ return
262
+ }
263
+ addFile(files, `${prefix}:${toPosix(relative)}`, file)
264
+ }),
265
+ )
266
+ }
267
+
268
+ function addFile(files: Map<string, string>, key: string, file: string) {
269
+ if (existsSync(file)) files.set(key, file)
270
+ }
271
+
272
+ async function outputSnapshot(outPath: string) {
273
+ const files = new Map<string, string>()
274
+ const entries = await readdir(outPath, { withFileTypes: true }).catch(() => [])
275
+ await Promise.all(
276
+ entries.map(async entry => {
277
+ if (entry.name === 'cache' || entry.name === 'dev') return
278
+ const file = path.join(outPath, entry.name)
279
+ if (entry.isDirectory()) await collectOutputTree(outPath, file, files)
280
+ else if (entry.isFile() || entry.isSymbolicLink()) {
281
+ files.set(toPosix(entry.name), file)
282
+ }
283
+ }),
284
+ )
285
+ const hash = createHash('sha256')
286
+ const hashes: Record<string, string> = {}
287
+ for (const [name, file] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
288
+ const stats = await lstat(file)
289
+ const contents = stats.isSymbolicLink()
290
+ ? Buffer.from(`link:${await readlink(file)}`)
291
+ : await readFile(file)
292
+ const contentsHash = contentHash(contents)
293
+ hashes[name] = contentsHash
294
+ hash.update(`${name}\0${contentsHash}\0`)
295
+ }
296
+ return { hash: hash.digest('hex'), files: hashes }
297
+ }
298
+
299
+ function changedFiles(before: Record<string, string>, after: Record<string, string>) {
300
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
301
+ file => before[file] !== after[file],
302
+ )
303
+ }
304
+
305
+ async function collectOutputTree(root: string, dir: string, files: Map<string, string>) {
306
+ const entries = await readdir(dir, { withFileTypes: true })
307
+ await Promise.all(
308
+ entries.map(async entry => {
309
+ const file = path.join(dir, entry.name)
310
+ if (entry.isDirectory()) await collectOutputTree(root, file, files)
311
+ else if (entry.isFile() || entry.isSymbolicLink()) {
312
+ files.set(toPosix(path.relative(root, file)), file)
313
+ }
314
+ }),
315
+ )
316
+ }
317
+
318
+ function contentHash(contents: Uint8Array) {
319
+ return createHash('sha256').update(contents).digest('hex')
320
+ }
321
+
322
+ function toPosix(file: string) {
323
+ return path.sep === '/' ? file : file.split(path.sep).join('/')
324
+ }
package/src/cli/build.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  } from '../routing/routes'
85
85
  import { interceptionMarkerLevels } from '../routing/slots'
86
86
  import { writeTypegen } from './typegen'
87
+ import { lookupBuildCache, writeBuildCache } from './build/cache'
87
88
  import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
88
89
  import { bold, cyan, dim, green } from '../utils/ansi'
89
90
  import type {
@@ -179,9 +180,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
179
180
  `${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
180
181
  )
181
182
  const config = await loadConfig(root)
182
- // Load Tailwind on the CSS worker while the steps below run, so the first
183
- // stylesheet doesn't pay its cold boot.
184
- warmCssPipeline(config)
183
+ const buildCache = lookupBuildCache(config, options)
185
184
  // Compat plugin loader: the single gated seam that populates the core
186
185
  // extension registries when compat is enabled (no-op for pure-core apps).
187
186
  // A build compiles immediately, so it takes both tiers up front.
@@ -192,6 +191,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
192
191
  // server-boot-only hooks out of the build - Next never runs register() during `next build`, so
193
192
  // build prerenders must see a noop tracer.
194
193
  runInitHooks(config, { build: true })
194
+ const cached = await buildCache
195
+ log.log(`build cache ${cached.reason ?? 'disabled'}`)
196
+ if (cached.manifest) {
197
+ printBuildSummary(
198
+ config,
199
+ cached.manifest.routes,
200
+ cached.manifest.staticFiles ?? {},
201
+ performance.now() - startedAt,
202
+ Boolean(cached.manifest.proxyModule),
203
+ )
204
+ return cached.manifest
205
+ }
206
+ // Load Tailwind on the CSS worker while the steps below run, so the first
207
+ // stylesheet doesn't pay its cold boot.
208
+ warmCssPipeline(config)
195
209
  // The vercel adapter's warm pass runs in its own process; start it here so
196
210
  // its boot overlaps the build and only the warming itself lands on the
197
211
  // adapter step.
@@ -917,6 +931,8 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
917
931
  // (typecheck) are awaited next and reported on their own lines.
918
932
  const buildDurationMs = performance.now() - startedAt
919
933
  const phases = await settleBuildParallelPhases()
934
+ flushDevModuleCaches()
935
+ await writeBuildCache(config, options, cached)
920
936
 
921
937
  printBuildSummary(
922
938
  config,
package/src/cli/create.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { bold, cyan, dim, green } from '../utils/ansi'
5
- import { pnextVersionRange } from '../utils/fs'
5
+ import { latestPnextVersionRange } from '../utils/registry'
6
6
  import { formatDuration } from '../utils/verbose'
7
7
 
8
8
  export interface CreateAppOptions {
@@ -27,7 +27,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
27
27
  const name = sanitizePackageName(path.basename(target))
28
28
  console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
29
29
 
30
- const files = scaffoldFiles(name)
30
+ const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
31
31
  await Promise.all(
32
32
  Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
33
33
  )
@@ -112,7 +112,7 @@ function printNextSteps(dir: string, installed: boolean) {
112
112
  console.log(dim(`└${'─'.repeat(width + 2)}┘`))
113
113
  }
114
114
 
115
- function scaffoldFiles(name: string): Record<string, string> {
115
+ function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
116
116
  return {
117
117
  'package.json': `${JSON.stringify(
118
118
  {
@@ -126,7 +126,7 @@ function scaffoldFiles(name: string): Record<string, string> {
126
126
  },
127
127
  dependencies: { preact: '^10' },
128
128
  devDependencies: {
129
- '@wular/pnext': pnextVersionRange(),
129
+ '@wular/pnext': pnextRange,
130
130
  typescript: '^5',
131
131
  '@types/bun': '^1',
132
132
  },
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { pnextOptionalDependencyRange, pnextVersionRange } from '../../utils/fs'
6
+ import { pnextOptionalDependencyRange } from '../../utils/fs'
7
+ import { latestPnextVersionRange } from '../../utils/registry'
7
8
  import type { MigrationResult } from './report'
8
9
 
9
10
  const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
@@ -124,7 +125,7 @@ export async function migratePackageJson(
124
125
  }
125
126
  }
126
127
  if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
127
- const versionRange = pnextVersionRange()
128
+ const versionRange = await latestPnextVersionRange('migrate')
128
129
  devDependencies['@wular/pnext'] = versionRange
129
130
  result.edits.push({
130
131
  file: 'package.json',
package/src/cli/start.ts CHANGED
@@ -48,14 +48,9 @@ export async function start(options: StartOptions = {}) {
48
48
  const startedAt = Date.now()
49
49
  const port = options.port ?? 3000
50
50
  const hostname = options.hostname ?? 'localhost'
51
- // Export builds are not served by a production start (Next throws). Standalone builds still serve -
52
- // Next only WARNS, pointing at the standalone launcher, and the launcher re-execs this path with
53
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set to suppress that warning. Handle both before binding a
54
- // port, matching Next's messages.
51
+ // Export builds are not served; pnext serves its standalone output directly.
55
52
  const config = await guardUnservableOutputMode(options.root)
56
- const manifest = JSON.parse(
57
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
58
- ) as BuildManifest
53
+ const manifest = await readProductionManifest(config)
59
54
  await ensurePortFree(port, hostname)
60
55
 
61
56
  // The pipeline import starts right after listen; a request that beats it
@@ -110,10 +105,20 @@ function installProcessErrorHandlers(): void {
110
105
  process.on('uncaughtException', report)
111
106
  }
112
107
 
113
- // Next's output-mode handling on start: an export build throws (fatal - it is unservable), while a
114
- // standalone build only warns and serves on, unless the standalone launcher re-execs with
115
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
116
- // mode from the build-compat registry, and reproduce Next's exact messages.
108
+ export async function readProductionManifest(config: ResolvedConfig): Promise<BuildManifest> {
109
+ try {
110
+ return JSON.parse(
111
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
112
+ ) as BuildManifest
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
115
+ throw new Error("No production build found. Run 'pnext build' first.")
116
+ }
117
+ throw error
118
+ }
119
+ }
120
+
121
+ // Export output is unservable; pnext production output remains serveable for standalone apps.
117
122
  async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
118
123
  const config = await loadConfig(root, { serve: true })
119
124
  await bootstrapCompat(config)
@@ -125,13 +130,6 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
125
130
  )
126
131
  process.exit(1)
127
132
  }
128
- // eslint-disable-next-line turbo/no-undeclared-env-vars
129
- if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
130
- console.warn(
131
- '"next start" does not work with "output: standalone" configuration. ' +
132
- 'Use "node .next/standalone/server.js" instead.',
133
- )
134
- }
135
133
  return config
136
134
  }
137
135
 
@@ -479,16 +479,19 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
479
479
  const outfile = path.join(outDir, `${entryName}.js`)
480
480
  const pipeline = createClientSourcePipeline(config, dev === true, route.id)
481
481
  pipeline.warmRoutes([route])
482
- const runtimeFacts = clientRuntimeFacts(
483
- [
484
- {
485
- route,
486
- shell: hasClientRootLayout(config, route),
487
- },
488
- ],
489
- nextCompatEnabled(config),
490
- suspense,
491
- )
482
+ const runtimeFacts: ClientRuntimeFacts = {
483
+ ...clientRuntimeFacts(
484
+ [
485
+ {
486
+ route,
487
+ shell: hasClientRootLayout(config, route),
488
+ },
489
+ ],
490
+ nextCompatEnabled(config),
491
+ suspense,
492
+ ),
493
+ dev: dev === true,
494
+ }
492
495
  const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
493
496
  preparePrebuilt(
494
497
  config,
@@ -139,6 +139,142 @@ function islandVNodeExpr(
139
139
  : `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
140
140
  }
141
141
 
142
+ /**
143
+ * A streamed Suspense hole is the one region a live island stops owning: `$RC` replaces the hole's
144
+ * children wholesale before the graft's materialize pass turns the arriving island markers into
145
+ * hosts. Rendering the hole through this component leaves a seam that pass can re-adopt through -
146
+ * INSIDE the parent island's tree, so the new islands keep the parent's whole context chain. Mounting
147
+ * such a host as its own preact root instead severs it from every provider the parent renders.
148
+ */
149
+ function holeSeamSource() {
150
+ return `
151
+ class PnextHole extends Component {
152
+ componentDidMount() {
153
+ if (this.base) this.base.__pnextHole = this;
154
+ }
155
+ componentWillUnmount() {
156
+ if (this.base && this.base.__pnextHole === this) this.base.__pnextHole = undefined;
157
+ }
158
+ componentWillReceiveProps(next) {
159
+ // A soft navigation re-adopts the parent from the incoming document's DOM;
160
+ // the previous document's grafted content must not outlive its children.
161
+ if (next.children !== this.props.children) this.setState({ adopted: undefined });
162
+ }
163
+ render(props, state) {
164
+ return h('pnext-hole', props.attrs, state.adopted !== undefined ? state.adopted : props.children);
165
+ }
166
+ }
167
+ `
168
+ }
169
+
170
+ /**
171
+ * The late-host half of the seam. `adoptHoleSeam` re-renders one hole through its live parent;
172
+ * `claimNestedIslandRoot` is what the island scan calls instead of mounting a nested host.
173
+ */
174
+ function lateIslandClaimSource() {
175
+ return `
176
+ // The graft ($RC) swapped this hole's DOM wholesale, so re-adopting ALL of it is total: nothing in
177
+ // the hole is still preact-owned. The re-render lands inside the parent island's tree, which is what
178
+ // makes the new islands inherit its context.
179
+ // Detaching an element costs it every piece of state the browser keeps outside the DOM - a lazy
180
+ // image's deferred load observation, an iframe's document, playing media, focus - and re-inserting
181
+ // it does not bring any of that back. moveBefore is the state-preserving move (Chrome 133+); it
182
+ // needs both ends connected and refuses some moves outright, so a plain insert stays the fallback.
183
+ function movePnextNode(parent, node) {
184
+ if (parent.moveBefore && parent.isConnected && node.isConnected) {
185
+ try {
186
+ parent.moveBefore(node, null);
187
+ return;
188
+ } catch (error) {}
189
+ }
190
+ parent.append(node);
191
+ }
192
+
193
+ // A subtree rebuilt from adopted vnodes is made of freshly created elements, and a graft can bring
194
+ // in ones the browser had already deferred. Either way a lazy image can end up in the document with
195
+ // no live intersection observation - no request ever fires, and it stays blank forever. Re-running
196
+ // its source selection re-arms it WITHOUT giving up laziness; one already loading (currentSrc set)
197
+ // or already loaded is left alone, so this never forces a fetch or duplicates one.
198
+ // Drop the capture holder, moving its nodes into \`into\` first when the adoption cannot use them.
199
+ function releaseHoleCapture(hole, into) {
200
+ const captured = hole.__pnextCaptured;
201
+ if (!captured) return;
202
+ hole.__pnextCaptured = undefined;
203
+ if (into) for (const node of [...captured.childNodes]) movePnextNode(into, node);
204
+ captured.remove();
205
+ }
206
+
207
+ function rekickLazyLoadables(root) {
208
+ for (const img of root.querySelectorAll('img[loading=lazy]')) {
209
+ if (img.complete || img.currentSrc) continue;
210
+ for (const name of ['src', 'srcset']) {
211
+ const value = img.getAttribute(name);
212
+ if (value === null) continue;
213
+ img.removeAttribute(name);
214
+ img.setAttribute(name, value);
215
+ }
216
+ }
217
+ }
218
+
219
+ async function adoptHoleSeam(hole) {
220
+ const pending = hole.__pnextAdopting;
221
+ if (pending) return pending;
222
+ const seam = hole.__pnextHole;
223
+ // No seam ever appeared: put the captured nodes back rather than losing them.
224
+ if (!seam) return releaseHoleCapture(hole, hole);
225
+ const adopting = (async () => {
226
+ const captured = hole.__pnextCaptured;
227
+ const children = await domChildren(captured ?? hole);
228
+ // Preact rebuilds the subtree from the adopted vnodes, so the server nodes they were read from
229
+ // would survive as unowned duplicates. Clearing and re-rendering within one task keeps the swap
230
+ // inside a single frame - preact's rerender is a microtask, so no paint sees the empty hole.
231
+ releaseHoleCapture(hole);
232
+ hole.replaceChildren();
233
+ seam.setState({ adopted: children ?? null }, () => rekickLazyLoadables(hole));
234
+ })();
235
+ hole.__pnextAdopting = adopting;
236
+ try {
237
+ await adopting;
238
+ } finally {
239
+ if (hole.__pnextAdopting === adopting) hole.__pnextAdopting = undefined;
240
+ }
241
+ }
242
+
243
+ // A nested host belongs to its parent island's DOM adoption pass, never to a mount of its own. Wait
244
+ // out an adoption already in flight (its walk may still reach this host), and claim only what the
245
+ // parent can no longer see - a host the graft materialized after the parent went live - through the
246
+ // hole seam that owns the grafted DOM. A host under a parent that has not mounted yet stays put.
247
+ async function claimNestedIslandRoot(root, parent) {
248
+ // The innermost hole whose seam is live owns this host. A nearer hole with no seam is raw grafted
249
+ // DOM inside an outer seam's region, so that outer one is the tree to re-adopt through.
250
+ let hole;
251
+ for (let node = root.parentElement; node && node !== parent; node = node.parentElement) {
252
+ if (node.localName !== 'pnext-hole') continue;
253
+ if (node.__pnextHole) { hole = node; break; }
254
+ hole ??= node;
255
+ }
256
+ // The parent is mid-adoption and has no seam here yet: its walk may already have snapshotted this
257
+ // hole's fallback, and its hydration then drops the DOM the graft just landed. Park the grafted
258
+ // nodes synchronously - ahead of that render - and adopt them from the holder once the tree is
259
+ // live. A walk that had NOT reached the hole yet simply adopts an empty one and gets the same
260
+ // content back through the seam. The holder is a hidden ELEMENT rather than a fragment: a
261
+ // disconnected parent is exactly what strips browser state, and moveBefore refuses one.
262
+ if (hole && !hole.__pnextHole && !hole.__pnextCaptured && parent.__pnextMounting) {
263
+ const holder = document.createElement('pnext-hole-capture');
264
+ holder.hidden = true;
265
+ document.body.append(holder);
266
+ hole.__pnextCaptured = holder;
267
+ for (const node of [...hole.childNodes]) movePnextNode(holder, node);
268
+ }
269
+ // A parent whose module failed still settles: the claim has to release its capture rather than
270
+ // leave the grafted nodes parked in a holder nobody will ever adopt from.
271
+ await parent.__pnextMounting?.catch(() => {});
272
+ if (!hole || (!hole.__pnextCaptured && !root.isConnected)) return;
273
+ return adoptHoleSeam(hole);
274
+ }
275
+ `
276
+ }
277
+
142
278
  // Two mount passes can reach the same island root concurrently - mountRoute and the streamed-Suspense
143
279
  // re-scan both walk the document, and their "already live" guard runs BEFORE mountIsland awaits the
144
280
  // component module. Without a synchronous claim both passes mount: the first tree is orphaned over the
@@ -517,15 +653,21 @@ function keepsMountedRoot(root, keep) {
517
653
  // The per-route half of the lifecycle, closing over the route's own mount
518
654
  // state. mountRoute runs the steps the route actually has: an island scan, the
519
655
  // client-shell hydration, the client page.
520
- function routeLifecycleSource({ nextCompat, islands, page, shell }: ClientRuntimeFacts) {
656
+ function routeLifecycleSource({ nextCompat, islands, page, shell, dev }: ClientRuntimeFacts) {
521
657
  const gate = nextCompat
522
658
  ? ' pnextGate(markRouteHydrated, pnextIslandMounts, pnextVisibleMount);\n'
523
659
  : ''
660
+ const islandMounts = dev
661
+ ? `pnextIslandScan.done.then(() => {
662
+ diagnoseSkippedIslands();
663
+ return pnextIslandScan.mounted;
664
+ })`
665
+ : 'pnextIslandScan.done.then(() => pnextIslandScan.mounted)'
524
666
  const mountBody = [
525
667
  islands
526
668
  ? ` if (islands.length > 0) {
527
669
  pnextIslandScan = scanIslands();
528
- pnextIslandMounts = pnextIslandScan.done.then(() => pnextIslandScan.mounted);
670
+ pnextIslandMounts = ${islandMounts};
529
671
  }`
530
672
  : '',
531
673
  shell ? ' if (shell) pnextVisibleMount = hydrateClientShell();' : '',
@@ -655,6 +797,8 @@ ${overlay}installClientErrors({ errorComponent: ${
655
797
  * ships only when some route in the app can actually use it.
656
798
  */
657
799
  export interface ClientRuntimeFacts {
800
+ /** Development-only diagnostics; omitted entirely from production bundles. */
801
+ dev?: boolean
658
802
  nextCompat?: boolean
659
803
  /** Any route mounts islands (island scan, revival, static children). */
660
804
  islands?: boolean
@@ -710,7 +854,7 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
710
854
  // it revives island DOM exactly like an island mount does.
711
855
  const revival = Boolean(facts.islands || shell)
712
856
  return `
713
- import { h, hydrate, render } from 'preact';
857
+ import { ${revival ? 'Component, ' : ''}h, hydrate, render } from 'preact';
714
858
  ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
715
859
  ${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
716
860
  ${suspense ? "import { Suspense } from 'preact/compat';" : ''}
@@ -1137,6 +1281,10 @@ function domProps(element) {
1137
1281
  return props;
1138
1282
  }
1139
1283
 
1284
+ // A nested root belongs to its parent island's DOM adoption pass, never to a
1285
+ // mount of its own. This entry carries no streamed-hole seam (see the shared
1286
+ // runtime): its whole point is a preact-free first paint, and a host grafted in
1287
+ // after a visible island mounted would be dead weight in every bundle.
1140
1288
  function isNestedIslandRoot(root) {
1141
1289
  return root.parentElement?.closest('pnext-client') != null;
1142
1290
  }
@@ -1343,9 +1491,39 @@ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
1343
1491
  `
1344
1492
  }
1345
1493
 
1346
- function hydrateIslandsSource({ nextCompat, visibleIslands, deferredIslands }: ClientRuntimeFacts) {
1494
+ function hydrateIslandsSource({
1495
+ nextCompat,
1496
+ visibleIslands,
1497
+ deferredIslands,
1498
+ dev,
1499
+ }: ClientRuntimeFacts) {
1500
+ const diagnostic = dev
1501
+ ? `
1502
+ function diagnoseSkippedIslands() {
1503
+ for (const root of document.querySelectorAll('pnext-client[data-pnext-client]')) {
1504
+ if (root.__pnextLive) continue;
1505
+ const id = root.getAttribute('data-pnext-client');
1506
+ const island = id ? islandById.get(id) : undefined;
1507
+ const parent = nestedIslandParent(root);
1508
+ // A visible island is deliberately inert until intersection. A nested
1509
+ // island whose parent is still pending is likewise owned by that parent's
1510
+ // adoption pass, one parked in a capture holder is mid-adoption, and one the
1511
+ // claim pass adopted is no longer in the document. Everything else is a host
1512
+ // this route can never hydrate.
1513
+ if (island?.options?.load === 'visible') continue;
1514
+ if (root.closest('pnext-hole-capture')) continue;
1515
+ if (parent && !parent.__pnextLive) continue;
1516
+ const reason = island
1517
+ ? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
1518
+ : 'has no entry in the client island manifest';
1519
+ console.warn('pnext: client island ' + (id ?? '<missing id>') + ' ' + reason);
1520
+ }
1521
+ }
1522
+ `
1523
+ : ''
1347
1524
  return `
1348
1525
  const islandById = new Map(islands.map(island => [island.id, island]));
1526
+ ${lateIslandClaimSource()}${diagnostic}
1349
1527
 
1350
1528
  // The mounted count is known as soon as the scan loop finishes, before any
1351
1529
  // island module resolves — so the client shell can decide whether it owns the
@@ -1364,7 +1542,11 @@ for (const island of islands) {
1364
1542
  }
1365
1543
  for (const island of islands) {
1366
1544
  for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
1367
- if (isNestedIslandRoot(root)) continue;
1545
+ const parent = nestedIslandParent(root);
1546
+ if (parent) {
1547
+ pending.push(claimNestedIslandRoot(root, parent));
1548
+ continue;
1549
+ }
1368
1550
  // Already mounted (e.g. a re-scan after a streamed Suspense hole materialized new islands):
1369
1551
  // mount() stamps __pnextLive, so skipping these leaves the shell's live islands untouched. A root
1370
1552
  // preserved across a soft navigation is ALSO live, but carries the incoming placeholder (fresh SSR
@@ -1476,6 +1658,10 @@ async function domNode(node) {
1476
1658
 
1477
1659
  const element = node;
1478
1660
  if (element.localName === ${JSON.stringify(ISLAND_BOUNDARY_ERROR_ELEMENT)}) return pnextBoundaryErrorVNode(element);
1661
+ // The seam a late streamed graft re-adopts through (see holeSeamSource).
1662
+ if (element.localName === 'pnext-hole') {
1663
+ return h(PnextHole, { attrs: domProps(element) }, await domChildren(element));
1664
+ }
1479
1665
  if (element.localName === 'pnext-client') {
1480
1666
  const id = element.getAttribute('data-pnext-client');
1481
1667
  const island = id ? islandById.get(id) : undefined;
@@ -1522,12 +1708,15 @@ function domProps(element) {
1522
1708
  return props;
1523
1709
  }
1524
1710
 
1525
- function isNestedIslandRoot(root) {
1526
- return root.parentElement?.closest('pnext-client') != null;
1711
+ // The island host that owns this root's DOM, if any. A nested root is never
1712
+ // mounted here: the parent adopts it, or claimNestedIslandRoot routes it through
1713
+ // the parent's hole seam.
1714
+ function nestedIslandParent(root) {
1715
+ return root.parentElement?.closest('pnext-client');
1527
1716
  }
1528
- ${
1529
- visibleIslands
1530
- ? `
1717
+ ${holeSeamSource()}${
1718
+ visibleIslands
1719
+ ? `
1531
1720
  function visibleTarget(root) {
1532
1721
  // The host's OWN box: hostMeasureSource delegates the patched one to the
1533
1722
  // parent, and IntersectionObserver never fires for a display:contents target.
@@ -1536,8 +1725,8 @@ function visibleTarget(root) {
1536
1725
  return root.parentElement ?? root;
1537
1726
  }
1538
1727
  `
1539
- : ''
1540
- }${mountOnceSource()}${
1728
+ : ''
1729
+ }${mountOnceSource()}${
1541
1730
  deferredIslands
1542
1731
  ? `
1543
1732
  function DeferredIsland({ load, props, children }) {
@@ -192,13 +192,6 @@ export async function warnNextMetadataBuildIssues(
192
192
  if (route.kind !== 'page') continue
193
193
  const routeFacts = await Promise.all(routeSourceFiles(appPath, route).map(factsFor))
194
194
  const some = (pick: (fact: MetadataSourceFacts) => boolean) => routeFacts.some(pick)
195
- if (some(fact => fact.themeColor)) {
196
- warnOnce(
197
- warned,
198
- `themeColor:${route.route}`,
199
- `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`,
200
- )
201
- }
202
195
  if (some(fact => fact.appleTouchFullscreen)) {
203
196
  warnOnce(
204
197
  warned,
@@ -246,7 +239,6 @@ async function readSourceTextOrEmpty(file: string) {
246
239
  * nearly every file has none of these keys.
247
240
  */
248
241
  interface MetadataSourceFacts {
249
- themeColor: boolean
250
242
  appleTouchFullscreen: boolean
251
243
  appleTouchIconPrecomposed: boolean
252
244
  metadataBase: boolean
@@ -255,7 +247,6 @@ interface MetadataSourceFacts {
255
247
 
256
248
  function metadataSourceFacts(source: string): MetadataSourceFacts {
257
249
  return {
258
- themeColor: source.includes('themeColor') && /\bthemeColor\s*:/.test(source),
259
250
  appleTouchFullscreen:
260
251
  source.includes('apple-touch-fullscreen') && /['"]apple-touch-fullscreen['"]/.test(source),
261
252
  appleTouchIconPrecomposed:
@@ -8,6 +8,7 @@ import { recordRewrite } from '../protocol'
8
8
  import { recordCanonicalUrl } from '../next/canonical-url'
9
9
  import { getNextConfig } from '../next/config-loader'
10
10
  import { activateTestProxy } from '../lifecycle/testmode'
11
+ import { legacyRequestAPIs } from '../../request/context'
11
12
 
12
13
  export function registerProxyExtensions(_config: ResolvedConfig): void {
13
14
  setProxyResponseProtocol({
@@ -72,7 +73,8 @@ async function validateNextProxyFiles(config: ResolvedConfig) {
72
73
  `Both middleware file "${relativeProxyPath(config, middleware)}" and proxy file "${relativeProxyPath(config, proxy)}" are detected. Please use "${relativeProxyPath(config, proxy)}" only.`,
73
74
  )
74
75
  }
75
- if (middleware && !proxy) {
76
+ // Proxy is a Next 16 migration; legacy request APIs explicitly target the middleware era.
77
+ if (middleware && !proxy && !legacyRequestAPIs()) {
76
78
  console.warn('The "middleware" file convention is deprecated. Please use "proxy" instead.')
77
79
  }
78
80
  await Promise.all(files.map(file => validateNextProxyFile(config, file)))
@@ -11,7 +11,6 @@ import {
11
11
  } from '../../extensions'
12
12
  import type { RouteManifestEntry } from '../../types'
13
13
  import { nextCompatEnabled } from '../aliases'
14
- import { writeTsconfigDefaults } from '../tsconfig-defaults'
15
14
  import { typecheckDisabled, validateTypesOffThread } from '../typecheck/check'
16
15
  import { generateTypedRoutes } from '../typed-routes/generate'
17
16
 
@@ -21,7 +20,6 @@ export function registerTypedRoutesExtensions(_config: ResolvedConfig): void {
21
20
 
22
21
  async function runTypedRoutesBuildStep(ctx: BuildStepContext): Promise<void> {
23
22
  if (!nextCompatEnabled(ctx.config)) return
24
- await writeTsconfigDefaults(ctx.config)
25
23
  await generateTypedRoutes(ctx.config, ctx.routes as RouteManifestEntry[])
26
24
  // The typecheck reads only files that already exist on disk, so it runs on a worker thread
27
25
  // alongside client bundling and prerendering rather than ahead of them; the CLI awaits it before
@@ -15,6 +15,7 @@ import path from 'node:path'
15
15
  import type { ResolvedConfig } from '../../config'
16
16
  import { extraPageExtensions } from '../../extensions'
17
17
  import type { RouteManifestEntry } from '../../types'
18
+ import { legacyRequestAPIs } from '../../request/context'
18
19
  import { getNextConfig } from '../next/config-loader'
19
20
  import { stripComments } from '../../utils/code'
20
21
  import {
@@ -271,6 +272,7 @@ function warnBuildDiagnostics(config: ResolvedConfig, routes: RouteManifestEntry
271
272
  console.warn('Disabled SWC as replacement for Babel because of custom Babel configuration')
272
273
  }
273
274
  if (
275
+ !legacyRequestAPIs() &&
274
276
  typeof nextConfig.outputFileTracingRoot !== 'string' &&
275
277
  !(
276
278
  nextConfig.turbopack &&
@@ -4470,6 +4470,7 @@ export function errorLogTrace(error: Error): string {
4470
4470
  }
4471
4471
 
4472
4472
  function logDevError(options: RenderOptions, error: Error, clientSsrError = false) {
4473
+ if (error.name === 'DynamicServerError') return
4473
4474
  const seen = isBuildError(error) ? loggedBuildErrors : loggedRuntimeErrors
4474
4475
  if (seen.has(error)) return
4475
4476
  seen.add(error)
@@ -6733,7 +6734,7 @@ function anchorInlineSuspenseHoles(html: string): string {
6733
6734
  .replace(/<!--\/\$s:[^>]+?-->/g, '</pnext-hole>')
6734
6735
  }
6735
6736
 
6736
- function clientSuspenseRefreshScript(nonce: string | undefined) {
6737
+ export function clientSuspenseRefreshScript(nonce: string | undefined) {
6737
6738
  const nonceAttribute = nonce ? ` nonce="${escapeHtml(nonce)}"` : ''
6738
6739
  // Graft each streamed island's content over its hole. The hole is a `<pnext-hole>` element rather
6739
6740
  // than preact's `$s:ID` comment markers: hydration of an ancestor client layout removes comment
@@ -6873,7 +6874,7 @@ function streamPageSlotMode(options: RenderOptions): PageSlotMarkerMode {
6873
6874
  return pageSlotIsMountTarget(options) ? 'materialize' : 'keep'
6874
6875
  }
6875
6876
 
6876
- function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6877
+ export function clientIslandMarkerBootstrapSource(mode: PageSlotMarkerMode = 'materialize') {
6877
6878
  // Pages that mount nothing on #pnext-page never need the wrapper div: the
6878
6879
  // page's children stay direct children of <body>, matching Next's document
6879
6880
  // structure (tests assert structural selectors like `body > p`). `keep`
@@ -0,0 +1,70 @@
1
+ // Which @wular/pnext a generated project depends on. `bunx @wular/pnext` reuses a cached copy of the
2
+ // package instead of re-resolving the dist-tag, so a stale laptop runs an old CLI — and an old CLI
3
+ // stamping its own version pins the new project to it. The range comes from the registry instead,
4
+ // with the running version as the offline fallback.
5
+
6
+ import { bold, cyan, dim } from './ansi'
7
+ import { pnextVersionRange } from './fs'
8
+
9
+ const REGISTRY_URL = 'https://registry.npmjs.org/@wular%2Fpnext'
10
+ // Short: create/migrate must not hang behind a slow or captive network.
11
+ const TIMEOUT_MS = 2000
12
+
13
+ async function fetchLatestPublishedVersion() {
14
+ const response = await fetch(REGISTRY_URL, {
15
+ signal: AbortSignal.timeout(TIMEOUT_MS),
16
+ // The abbreviated document — dist-tags plus versions, without the full metadata.
17
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
18
+ })
19
+ if (!response.ok) return undefined
20
+ const body = (await response.json()) as { 'dist-tags'?: { latest?: string } }
21
+ return body['dist-tags']?.latest
22
+ }
23
+
24
+ // Test seam: the create/migrate suites assert the stamped range and must never hit the network.
25
+ let fetchLatestVersion: () => Promise<string | undefined> = fetchLatestPublishedVersion
26
+
27
+ /** @internal Test-only. Returns a restore function. */
28
+ export function setLatestVersionFetcher(fetcher: () => Promise<string | undefined>) {
29
+ const previous = fetchLatestVersion
30
+ fetchLatestVersion = fetcher
31
+ return () => {
32
+ fetchLatestVersion = previous
33
+ }
34
+ }
35
+
36
+ /**
37
+ * '^' + the latest published @wular/pnext, for a project this CLI generates. Never throws and never
38
+ * blocks for long — any failure falls back to the running version. Warns when the CLI itself is
39
+ * stale, which is the symptom the user actually has to fix.
40
+ */
41
+ export async function latestPnextVersionRange(command: 'create' | 'migrate') {
42
+ const running = pnextVersionRange().slice(1)
43
+ const latest = await fetchLatestVersion().catch(() => undefined)
44
+ if (!latest) return `^${running}`
45
+ if (isNewer(latest, running)) warnStaleCli(command, running, latest)
46
+ return `^${latest}`
47
+ }
48
+
49
+ function warnStaleCli(command: string, running: string, latest: string) {
50
+ console.error(
51
+ `\n${bold('⚠ Your pnext CLI is out of date')} — running ${running}, latest is ${latest}.\n` +
52
+ dim(
53
+ ` bunx reused a cached copy. This project depends on ^${latest}, but ${command} ran with ${running}.\n`,
54
+ ) +
55
+ ` Re-run with: ${cyan(`bunx @wular/pnext@latest ${command}`)}\n`,
56
+ )
57
+ }
58
+
59
+ /** Numeric major.minor.patch only; anything unparseable counts as "not newer". */
60
+ function isNewer(candidate: string, current: string) {
61
+ const parts = (value: string) => value.split('-')[0]!.split('.').map(Number)
62
+ const [a, b] = [parts(candidate), parts(current)]
63
+ for (let index = 0; index < 3; index += 1) {
64
+ const left = a[index] ?? 0
65
+ const right = b[index] ?? 0
66
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return false
67
+ if (left !== right) return left > right
68
+ }
69
+ return false
70
+ }