@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
@@ -4,6 +4,8 @@ export interface PrefetchedPage {
4
4
  html: string
5
5
  finalUrl: string
6
6
  ok: boolean
7
+ /** Entry-bound whole-page client props restored before mounting a history traversal. */
8
+ p?: unknown
7
9
  /** `x-nextjs-stale-time` from the response (seconds), when the server sent it. */
8
10
  staleTimeSeconds?: number
9
11
  /** Complete body segment was emitted from a prerendered route artifact. */
@@ -207,6 +209,12 @@ export interface SegmentCacheRecord {
207
209
  static: boolean
208
210
  /** True when the payload came from a runtime (request-sampled) prefetch. */
209
211
  runtime: boolean
212
+ /**
213
+ * The server DECLARED this payload its POSTPONED (PPR) shell for this exact URL - the
214
+ * truncated prefetch response `x-nextjs-postponed` marks. Such a payload is the
215
+ * destination's own prerendered static stage, holes and all.
216
+ */
217
+ postponedShell?: boolean
210
218
  }
211
219
 
212
220
  export interface SegmentCacheLookup {
@@ -226,6 +234,13 @@ export interface SegmentCacheHit {
226
234
  * dynamic remainder. Absent (older policies) reads as `networkFree`.
227
235
  */
228
236
  prefetchSatisfied?: boolean
237
+ /**
238
+ * This hit is THIS URL's own postponed (PPR) shell - the static stage the server
239
+ * prerendered for it, not a sibling's bytes. It paints even when its whole page is one
240
+ * unresolved boundary, because that boundary IS what the destination renders until its
241
+ * dynamic stage lands.
242
+ */
243
+ postponedShell?: boolean
229
244
  }
230
245
 
231
246
  export interface SegmentCachePolicy {
@@ -117,6 +117,10 @@ export function actionClientStubPlugin(): Plugin {
117
117
 
118
118
  async function stubSourceFor(file: string, root?: string) {
119
119
  const source = await readText(file)
120
+ return stubSourceFromSource(file, source, root)
121
+ }
122
+
123
+ export function stubSourceFromSource(file: string, source: string, root?: string) {
120
124
  if (!moduleLevelUseServer(source) && !moduleLevelUseCache(source)) return 'export {};'
121
125
  const exports = moduleActionExports(source)
122
126
  const actionIds: Record<string, string> = {}
@@ -124,6 +128,12 @@ async function stubSourceFor(file: string, root?: string) {
124
128
  return clientStubSource({ modulePath: file, exports, actionIds })
125
129
  }
126
130
 
131
+ export async function loadClientActionSource(file: string, root?: string) {
132
+ const source = await readText(file)
133
+ if (!moduleLevelUseServer(source) && !moduleLevelUseCache(source)) return { source }
134
+ return { source, stubSource: stubSourceFromSource(file, source, root) }
135
+ }
136
+
127
137
  /**
128
138
  * Compute an action wire id from a realpath-canonical (file, root) basis so every producer -
129
139
  * discovery/registry, the client stub, and render-side form tagging - agrees regardless of
@@ -24,6 +24,7 @@ export function rewriteInlineActionTags(source: string, file: string, root?: str
24
24
  const insertions: { at: number; text: string }[] = []
25
25
  const fallbacks: string[] = []
26
26
  for (const action of topLevel) {
27
+ if (source.includes(`__pnextTagInlineAction(${action.name},`)) continue
27
28
  const id = inlineActionId(key, action.name)
28
29
  const tag = `\n;typeof ${action.name} === 'function' && globalThis.__pnextTagInlineAction && globalThis.__pnextTagInlineAction(${action.name}, ${JSON.stringify(id)});`
29
30
  const at = declarationEnd(source, action.directiveEnd)
@@ -2,18 +2,19 @@
2
2
  // from one entry and the vendor pipeline compiles the ENTRY, so a route naming four icons makes the
3
3
  // server evaluate the whole barrel.
4
4
  //
5
- // The fix is Next's: for a listed package, rewrite `import { A, B } from 'pkg'` into per-member
6
- // imports of the LEAF modules its barrel re-exports them from. Each leaf becomes an ordinary vendor
7
- // demand, so the existing per-package grouping compiles the demanded subpaths as entries of one
8
- // `splitting: true` build - the shared graph stays one instance.
5
+ // Rewrite `import { A, B } from 'pkg'` into per-member imports of the LEAF modules. Configured and
6
+ // Next-default packages use explicit re-exports; large generated catalogues are detected from their
7
+ // export/leaf shape. Each leaf becomes an ordinary vendor demand, so the existing per-package grouping
8
+ // compiles demanded subpaths as entries of one `splitting: true` build.
9
9
  //
10
10
  // Unlike `modularizeImports` (a user-written path template), the leaf map is DERIVED from the
11
11
  // barrel itself, so nothing here encodes a package's layout. Derivation is also the safety gate:
12
- // the entry must be re-exports and nothing else - no declarations, no side effects - or the package
13
- // is left alone, which is why an over-broad list cannot produce wrong code.
12
+ // listed entries must be pure re-export barrels, and automatic catalogue detection additionally
13
+ // verifies each export's re-export target against its same-named sibling file, or - for a catalogue
14
+ // that aliases many names onto few leaves - that the entry is nothing but unambiguous re-exports.
14
15
 
15
16
  import path from 'node:path'
16
- import { readFileSync } from 'node:fs'
17
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
17
18
  // Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
18
19
  const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
19
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -23,12 +24,12 @@ import { rewriteFacts, type ImportBinding } from '../../resolve/scan-facts'
23
24
  import { spliceSource } from '../../runtime/module-transform'
24
25
  import { resolvePackageSpecifier, resolveVendorPackageSpecifier } from '../../resolve/imports'
25
26
  import { withSniff, type ServerSourceTransform } from '../../extensions'
26
- import { specifierSniffTokens } from '../../utils/code'
27
+ import { escapeRegex, stripComments } from '../../utils/code'
27
28
 
28
29
  /**
29
30
  * Next's default `optimizePackageImports` set - the barrels big enough that compiling the entry is the
30
- * dominant cost. Extended by the config option. Every name is still gated by the derivation below, so a
31
- * package that is not a pure re-export barrel costs nothing but one parse.
31
+ * dominant cost. Extended by the config option. Every name is still gated by the applicable derivation
32
+ * below, so a package that fails its safety gate costs nothing but one parse.
32
33
  */
33
34
  const DEFAULT_OPTIMIZED_PACKAGES = [
34
35
  'lucide-react',
@@ -57,6 +58,11 @@ const DEFAULT_OPTIMIZED_PACKAGES = [
57
58
  'rxjs',
58
59
  ]
59
60
 
61
+ const GENERATED_BARREL_MIN_BYTES = 64 * 1024
62
+ const GENERATED_BARREL_MIN_EXPORTS = 16
63
+ const GENERATED_BARREL_LEAF_RATIO = 0.8
64
+ const OPTIMIZE_PACKAGE_IMPORTS_SNIFF_TOKENS = ['import'] as const
65
+
60
66
  export function optimizedPackageSet(configured: readonly string[]): string[] {
61
67
  // Rewriting a barrel changes what the vendor pipeline compiles, so it is also
62
68
  // what a slow or wrong artifact has to be bisected against — and how the
@@ -72,6 +78,8 @@ interface BarrelMember {
72
78
  source: string
73
79
  /** The name to take from it: `default`, `*`, or an export name. */
74
80
  imported: string
81
+ /** Font Awesome entry binding, checked lazily against the demanded leaf. */
82
+ fontAwesomeLocal?: string
75
83
  }
76
84
 
77
85
  /** The exported names a pure re-export barrel publishes, and their leaves. */
@@ -80,19 +88,19 @@ type BarrelMap = ReadonlyMap<string, BarrelMember>
80
88
  const barrelMaps = new Map<string, BarrelMap | undefined>()
81
89
 
82
90
  /**
83
- * The leaf map of a barrel entry, or undefined when the entry is not a pure
84
- * re-export barrel. Cached per entry file: one parse per package per process,
85
- * however many modules import it.
91
+ * The leaf map of a barrel entry, or undefined when its configured/automatic
92
+ * safety gate fails. Cached per entry file and mode.
86
93
  */
87
- function barrelMap(entry: string): BarrelMap | undefined {
88
- if (barrelMaps.has(entry)) return barrelMaps.get(entry)
94
+ function barrelMap(entry: string, automatic: boolean): BarrelMap | undefined {
95
+ const key = `${automatic ? 'auto' : 'listed'}\0${entry}`
96
+ if (barrelMaps.has(key)) return barrelMaps.get(key)
89
97
  let map: BarrelMap | undefined
90
98
  try {
91
- map = parseBarrel(entry)
99
+ map = automatic ? parseGeneratedBarrel(entry) : parseBarrel(entry)
92
100
  } catch {
93
101
  map = undefined
94
102
  }
95
- barrelMaps.set(entry, map)
103
+ barrelMaps.set(key, map)
96
104
  return map
97
105
  }
98
106
 
@@ -136,18 +144,241 @@ function parseBarrel(entry: string): BarrelMap | undefined {
136
144
  const imported =
137
145
  kind === 'Default' ? 'default' : kind === 'All' ? '*' : record.importName.name
138
146
  if (!imported) return undefined
139
- members.set(exported, { source: request, imported })
147
+ if (!addMember(members, exported, { source: request, imported })) return undefined
140
148
  continue
141
149
  }
142
150
  const local = record.localName.name
143
151
  const namespace = local === null ? undefined : namespaces.get(local)
144
152
  if (!namespace) return undefined
145
- members.set(exported, { source: namespace, imported: '*' })
153
+ if (!addMember(members, exported, { source: namespace, imported: '*' })) return undefined
146
154
  }
147
155
  }
148
156
  return members.size > 0 ? members : undefined
149
157
  }
150
158
 
159
+ function addMember(members: Map<string, BarrelMember>, name: string, member: BarrelMember) {
160
+ const prior = members.get(name)
161
+ if (prior && (prior.source !== member.source || prior.imported !== member.imported)) return false
162
+ members.set(name, member)
163
+ return true
164
+ }
165
+
166
+ /**
167
+ * Detect generated catalogues whose entry explicitly re-exports same-named
168
+ * sibling modules. Requiring a large entry, a side-effect-free package, and an
169
+ * overwhelming filename/export match keeps this automatic path away from
170
+ * ordinary package entries. Inline definitions are not equivalent merely
171
+ * because a sibling has the same name; the only exception is Font Awesome's
172
+ * generated Pro/Sharp shape, whose leaf definition is checked against the
173
+ * entry definition before it is mapped.
174
+ */
175
+ function parseGeneratedBarrel(entry: string): BarrelMap | undefined {
176
+ if (statSync(entry).size < GENERATED_BARREL_MIN_BYTES) return undefined
177
+ const packageDir = packageDirOf(entry)
178
+ if (!packageDir || !packageHasNoSideEffects(packageDir)) return undefined
179
+
180
+ const source = readFileSync(entry, 'utf8')
181
+ if (isGeneratedFontAwesomePackage(packageDir)) {
182
+ return parseGeneratedFontAwesomeBarrel(entry, source)
183
+ }
184
+ const parsed = parseSync(entry, source, { lang: 'js' })
185
+ if (parsed.errors.length > 0) return undefined
186
+ const siblings = siblingFilesByStem(path.dirname(entry), entry)
187
+ const exports = new Map<string, BarrelMember>()
188
+ const exportNames = new Set<string>()
189
+ let exportCount = 0
190
+ for (const statement of parsed.module.staticExports) {
191
+ for (const record of statement.entries) {
192
+ if (record.isType) continue
193
+ const name = record.exportName.name
194
+ if (!name || exportNames.has(name)) return undefined
195
+ exportNames.add(name)
196
+ exportCount++
197
+ const request = record.moduleRequest?.value
198
+ if (!request) {
199
+ return undefined
200
+ }
201
+ const kind: string = record.importName.kind
202
+ if (kind === 'AllButDefault') return undefined
203
+ const imported =
204
+ kind === 'Default' ? 'default' : kind === 'All' ? '*' : record.importName.name
205
+ if (!imported) return undefined
206
+ exports.set(name, { source: request, imported })
207
+ }
208
+ }
209
+ if (exportCount < GENERATED_BARREL_MIN_EXPORTS) return undefined
210
+
211
+ const members = new Map<string, BarrelMember>()
212
+ for (const [name, member] of exports) {
213
+ const files = siblings.get(name)
214
+ const file = files?.[0]
215
+ if (
216
+ file &&
217
+ files.length === 1 &&
218
+ path.resolve(path.dirname(entry), member.source) === path.resolve(path.dirname(entry), file)
219
+ ) {
220
+ members.set(name, member)
221
+ }
222
+ }
223
+ if (members.size / exportCount >= GENERATED_BARREL_LEAF_RATIO) return members
224
+ return manyToFewBarrelMembers(parsed.program.body, exports, entry)
225
+ }
226
+
227
+ /**
228
+ * The same-named-sibling ratio describes one-export-per-file catalogues. A generated barrel may
229
+ * instead publish thousands of aliases over a handful of leaves, where no export name is a filename.
230
+ * The invariant that makes THAT shape safe is the one the listed path already relies on: every
231
+ * demanded name resolves statically to exactly one re-export record, and the entry runs no code
232
+ * beyond those re-exports. Members whose leaf stem is ambiguous on disk are dropped, so a demanded
233
+ * name that could resolve to a different extension stays on the barrel. Leaf contents are still only
234
+ * read for demanded members.
235
+ */
236
+ function manyToFewBarrelMembers(
237
+ body: readonly { type: string; declaration?: unknown }[],
238
+ exports: ReadonlyMap<string, BarrelMember>,
239
+ entry: string,
240
+ ): BarrelMap | undefined {
241
+ for (const node of body) {
242
+ if (node.type !== 'ExportNamedDeclaration' || node.declaration) return undefined
243
+ }
244
+ const byDirectory = new Map<string, Map<string, string[]>>()
245
+ const members = new Map<string, BarrelMember>()
246
+ for (const [name, member] of exports) {
247
+ if (!member.source.startsWith('.')) continue
248
+ const file = path.resolve(path.dirname(entry), member.source)
249
+ const directory = path.dirname(file)
250
+ let siblings = byDirectory.get(directory)
251
+ if (!siblings) {
252
+ siblings = siblingFilesByStem(directory, entry)
253
+ byDirectory.set(directory, siblings)
254
+ }
255
+ if (siblings.get(path.basename(file).replace(/\.(?:c|m)?js$/, ''))?.length !== 1) continue
256
+ members.set(name, member)
257
+ }
258
+ return members.size > 0 ? members : undefined
259
+ }
260
+
261
+ /** The `.js`-family files of a directory, grouped by stem, so an ambiguous stem is visible. */
262
+ function siblingFilesByStem(directory: string, exclude: string) {
263
+ const siblings = new Map<string, string[]>()
264
+ for (const file of readdirSync(directory)) {
265
+ const match = /^(.*)\.(?:c|m)?js$/.exec(file)
266
+ if (!match?.[1] || path.resolve(directory, file) === exclude) continue
267
+ const files = siblings.get(match[1]) ?? []
268
+ files.push(file)
269
+ siblings.set(match[1], files)
270
+ }
271
+ return siblings
272
+ }
273
+
274
+ function isGeneratedFontAwesomePackage(packageDir: string) {
275
+ const packageName = packageNameOfDir(packageDir)
276
+ return packageName !== undefined && /^@fortawesome\/(?:pro|sharp)-.+-svg-icons$/.test(packageName)
277
+ }
278
+
279
+ /**
280
+ * Font Awesome's generated Pro/Sharp entries are multi-megabyte inline catalogues with one final
281
+ * export list. Parsing the whole catalogue synchronously costs more than a second per style even
282
+ * though an import demands only a handful of leaves. Read that explicit export list directly, then
283
+ * retain the same per-demand definition/leaf identity check in `resolveLeafSpecifier`.
284
+ */
285
+ function parseGeneratedFontAwesomeBarrel(entry: string, source: string): BarrelMap | undefined {
286
+ const start = source.lastIndexOf('\nexport {')
287
+ if (start < 0) return undefined
288
+ const open = source.indexOf('{', start)
289
+ const close = source.indexOf('};', open + 1)
290
+ if (open < 0 || close < 0 || source.slice(close + 2).trim() !== '') return undefined
291
+
292
+ const siblings = siblingFilesByStem(path.dirname(entry), entry)
293
+
294
+ const members = new Map<string, BarrelMember>()
295
+ let exportCount = 0
296
+ for (const raw of source.slice(open + 1, close).split(',')) {
297
+ const match = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(raw)
298
+ if (!match?.[1]) return undefined
299
+ const local = match[1]
300
+ const exported = match[2] ?? local
301
+ exportCount++
302
+ const files = siblings.get(exported)
303
+ const file = files?.[0]
304
+ if (!file || files.length !== 1 || members.has(exported)) continue
305
+ members.set(exported, {
306
+ source: `./${file}`,
307
+ imported: exported,
308
+ fontAwesomeLocal: local,
309
+ })
310
+ }
311
+ if (exportCount < GENERATED_BARREL_MIN_EXPORTS) return undefined
312
+ if (members.size / exportCount < GENERATED_BARREL_LEAF_RATIO) return undefined
313
+ fontAwesomeBarrelSources.set(entry, source)
314
+ return members
315
+ }
316
+
317
+ const fontAwesomeBarrelSources = new Map<string, string>()
318
+
319
+ function leafMatchesFontAwesomeDefinition(file: string, exported: string, definition: string) {
320
+ const leaf = readFileSync(file, 'utf8')
321
+ if (
322
+ !new RegExp(`(?:^|\\n)exports\\.${escapeRegex(exported)}\\s*=\\s*exports\\.definition;`).test(
323
+ leaf,
324
+ )
325
+ ) {
326
+ return false
327
+ }
328
+ for (const field of ['prefix', 'iconName', 'unicode', 'svgPathData']) {
329
+ const match = new RegExp(`(?:^|\\n)var ${field} = (['"])(.*)\\1;`).exec(leaf)
330
+ if (!match?.[2] || !definition.includes(match[2])) return false
331
+ }
332
+ return true
333
+ }
334
+
335
+ function fontAwesomeEntryDefinition(source: string, exportedLocal: string) {
336
+ let local = exportedLocal
337
+ const visited = new Set<string>()
338
+ while (!visited.has(local)) {
339
+ visited.add(local)
340
+ const marker = `var ${local} = `
341
+ const markerAt = source.indexOf(`\n${marker}`)
342
+ const start = markerAt >= 0 ? markerAt + 1 : source.startsWith(marker) ? 0 : -1
343
+ if (start < 0) return undefined
344
+ const valueStart = start + marker.length
345
+ if (source[valueStart] === '{') {
346
+ const end = source.indexOf('\n};', valueStart)
347
+ return end < 0 ? undefined : source.slice(start, end + 3)
348
+ }
349
+ const end = source.indexOf(';', valueStart)
350
+ if (end < 0) return undefined
351
+ const alias = source.slice(valueStart, end).trim()
352
+ if (!/^[A-Za-z_$][\w$]*$/.test(alias)) return undefined
353
+ local = alias
354
+ }
355
+ return undefined
356
+ }
357
+
358
+ function packageHasNoSideEffects(packageDir: string) {
359
+ return packageMetadata(packageDir)?.sideEffects === false
360
+ }
361
+
362
+ const packageMetadataCache = new Map<
363
+ string,
364
+ { sideEffects?: unknown; exports?: unknown } | undefined
365
+ >()
366
+
367
+ function packageMetadata(packageDir: string) {
368
+ if (packageMetadataCache.has(packageDir)) return packageMetadataCache.get(packageDir)
369
+ let metadata: { sideEffects?: unknown; exports?: unknown } | undefined
370
+ try {
371
+ metadata = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as {
372
+ sideEffects?: unknown
373
+ exports?: unknown
374
+ }
375
+ } catch {
376
+ metadata = undefined
377
+ }
378
+ packageMetadataCache.set(packageDir, metadata)
379
+ return metadata
380
+ }
381
+
151
382
  /**
152
383
  * The barrel entry, but only when both layers resolve the specifier to the SAME
153
384
  * file: a package that splits its entry per condition publishes a different
@@ -169,7 +400,7 @@ function barrelEntry(root: string, file: string, specifier: string) {
169
400
  * because one rewritten source is compiled for both.
170
401
  */
171
402
  function leafSpecifier(root: string, entry: string, member: BarrelMember): string | undefined {
172
- const key = `${entry}\0${member.source}`
403
+ const key = `${entry}\0${member.source}\0${member.imported}`
173
404
  if (leafSpecifiers.has(key)) return leafSpecifiers.get(key)
174
405
  const specifier = resolveLeafSpecifier(root, entry, member)
175
406
  leafSpecifiers.set(key, specifier)
@@ -184,16 +415,121 @@ const SERVER_CONDITIONS = ['react-server', 'node', 'module', 'import']
184
415
  function resolveLeafSpecifier(root: string, entry: string, member: BarrelMember) {
185
416
  if (!member.source.startsWith('.')) return undefined
186
417
  const file = path.resolve(path.dirname(entry), member.source)
418
+ if (member.fontAwesomeLocal) {
419
+ const source = fontAwesomeBarrelSources.get(entry)
420
+ const definition = source && fontAwesomeEntryDefinition(source, member.fontAwesomeLocal)
421
+ if (!definition || !leafMatchesFontAwesomeDefinition(file, member.imported, definition)) {
422
+ return undefined
423
+ }
424
+ }
187
425
  const packageDir = packageDirOf(entry)
188
426
  if (!packageDir) return undefined
189
427
  const name = packageNameOfDir(packageDir)
190
428
  const relative = path.relative(packageDir, file)
191
429
  if (!name || relative.startsWith('..') || path.isAbsolute(relative)) return undefined
192
- const specifier = `${name}/${relative.split(path.sep).join('/')}`
193
- for (const conditions of [CLIENT_CONDITIONS, SERVER_CONDITIONS]) {
194
- if (resolvePackageSpecifier(root, entry, specifier, conditions) !== file) return undefined
430
+ const subpath = relative.split(path.sep).join('/')
431
+ const extensionless = subpath.replace(/\.(?:c|m)?js$/, '')
432
+ const full = `${name}/${subpath}`
433
+ const short = `${name}/${extensionless}`
434
+ const mirrored =
435
+ packageMetadata(packageDir)?.exports === undefined || extensionless === subpath
436
+ ? [full]
437
+ : [short, full]
438
+ // The published subpath first: a package whose `exports` maps its leaves under a rewritten prefix
439
+ // (`"./icons/*": "./dist/icons/*.js"`) has no public specifier that mirrors the file path, and
440
+ // pnext's resolver would otherwise settle for one `exports` withholds.
441
+ const candidates = new Set([...exportsMapSpecifiers(packageDir, name, subpath), ...mirrored])
442
+ for (const specifier of candidates) {
443
+ if (
444
+ [CLIENT_CONDITIONS, SERVER_CONDITIONS].every(
445
+ conditions => resolvePackageSpecifier(root, entry, specifier, conditions) === file,
446
+ ) &&
447
+ leafHasExport(file, member.imported)
448
+ ) {
449
+ return specifier
450
+ }
195
451
  }
196
- return specifier
452
+ return undefined
453
+ }
454
+
455
+ /**
456
+ * The specifiers a package's `exports` map could publish a leaf file under, by inverting each
457
+ * subpath target against the file. Only a proposal: every candidate is still accepted by
458
+ * re-resolving it back to the same file under both layers' conditions.
459
+ */
460
+ function exportsMapSpecifiers(packageDir: string, name: string, subpath: string): string[] {
461
+ const map = packageMetadata(packageDir)?.exports
462
+ if (map === undefined) return []
463
+ const target = `./${subpath}`
464
+ const specifiers: string[] = []
465
+ const visit = (key: string, value: unknown) => {
466
+ if (typeof value === 'string') {
467
+ const matched = matchExportsTarget(key, value, target)
468
+ if (matched !== undefined)
469
+ specifiers.push(`${name}${matched === '.' ? '' : matched.slice(1)}`)
470
+ return
471
+ }
472
+ if (Array.isArray(value)) {
473
+ for (const item of value) visit(key, item)
474
+ return
475
+ }
476
+ if (!value || typeof value !== 'object') return
477
+ for (const [inner, nested] of Object.entries(value)) {
478
+ visit(inner.startsWith('.') ? inner : key, nested)
479
+ }
480
+ }
481
+ visit('.', map)
482
+ return specifiers
483
+ }
484
+
485
+ /** The subpath key `target` publishes `file` under, substituting one wildcard. */
486
+ function matchExportsTarget(key: string, target: string, file: string) {
487
+ if (!key.startsWith('.')) return undefined
488
+ const star = target.indexOf('*')
489
+ if (star < 0) return target === file ? key : undefined
490
+ const prefix = target.slice(0, star)
491
+ const suffix = target.slice(star + 1)
492
+ if (!file.startsWith(prefix) || !file.endsWith(suffix)) return undefined
493
+ const value = file.slice(prefix.length, file.length - suffix.length)
494
+ return value && key.includes('*') ? key.replace('*', value) : undefined
495
+ }
496
+
497
+ const leafExports = new Map<string, ReadonlySet<string> | undefined>()
498
+
499
+ function leafHasExport(file: string, name: string) {
500
+ if (name === '*') return true
501
+ if (!leafExports.has(file)) {
502
+ let names: Set<string> | undefined
503
+ try {
504
+ const source = readFileSync(file, 'utf8')
505
+ const parsed = parseSync(file, source, { lang: 'js' })
506
+ if (parsed.errors.length === 0) {
507
+ names = new Set<string>()
508
+ for (const statement of parsed.module.staticExports) {
509
+ for (const record of statement.entries) {
510
+ if (record.isType) continue
511
+ const kind: string = record.exportName.kind
512
+ if (kind === 'Default') names.add('default')
513
+ else if (record.exportName.name) names.add(record.exportName.name)
514
+ }
515
+ }
516
+ const code = stripComments(source)
517
+ for (const match of code.matchAll(/(?:^|\n)\s*exports\.([A-Za-z_$][\w$]*)\s*=/g)) {
518
+ if (match[1]) names.add(match[1])
519
+ }
520
+ if (/(?:^|\n)\s*module\.exports\s*=/.test(code)) names.add('default')
521
+ }
522
+ } catch {
523
+ names = undefined
524
+ }
525
+ leafExports.set(file, names)
526
+ }
527
+ const names = leafExports.get(file)
528
+ if (names?.has(name)) return true
529
+ // Computed CommonJS export names are rare but valid identifiers in generated leaves.
530
+ if (!names) return false
531
+ const source = stripComments(readFileSync(file, 'utf8'))
532
+ return new RegExp(`(?:^|\\n)\\s*exports\\[(['"])${escapeRegex(name)}\\1\\]\\s*=`).test(source)
197
533
  }
198
534
 
199
535
  /** The installed package a barrel entry belongs to, by its `node_modules` segment. */
@@ -263,13 +599,14 @@ export function createOptimizePackageImportsTransform(
263
599
  ): ServerSourceTransform {
264
600
  if (packages.length === 0) return withSniff([], source => source)
265
601
  const optimized = new Set(packages)
266
- return withSniff(specifierSniffTokens(packages), (source, file) => {
267
- if (!source.includes('import')) return source
602
+ return withSniff(OPTIMIZE_PACKAGE_IMPORTS_SNIFF_TOKENS, (source, file) => {
268
603
  const edits: { start: number; end: number; value: string }[] = []
269
604
  for (const statement of rewriteFacts(file, source).imports) {
270
- if (!optimized.has(statement.specifier)) continue
605
+ const listed = optimized.has(statement.specifier)
606
+ if (!listed && packageNameOfDirSpecifier(statement.specifier) !== statement.specifier)
607
+ continue
271
608
  const entry = barrelEntry(root, file, statement.specifier)
272
- const map = entry && barrelMap(entry)
609
+ const map = entry && barrelMap(entry, !listed)
273
610
  if (!map || !entry) continue
274
611
  const expanded = expandBarrelImport(statement.bindings, map, root, entry)
275
612
  if (expanded !== undefined) {
@@ -279,3 +616,13 @@ export function createOptimizePackageImportsTransform(
279
616
  return edits.length > 0 ? spliceSource(source, edits) : source
280
617
  })
281
618
  }
619
+
620
+ function packageNameOfDirSpecifier(specifier: string) {
621
+ const parts = specifier.split('/')
622
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
623
+ }
624
+
625
+ /** Sniff used by load hooks that re-offer this transform outside source roots. */
626
+ export function optimizePackageImportsSniffTokens(packages: readonly string[]) {
627
+ return packages.length > 0 ? OPTIMIZE_PACKAGE_IMPORTS_SNIFF_TOKENS : []
628
+ }
@@ -20,7 +20,33 @@ export function topOnlyScrollPosition(scrollX: number): [number, number] {
20
20
  return [scrollX, 0]
21
21
  }
22
22
 
23
+ // A pop restore fires right after the body swap, when the incoming tree can still be shorter
24
+ // than the saved offset — the browser clamps scrollTo against the live height and the position
25
+ // is lost even though a reapply comes a frame later. Reserving the departing document's
26
+ // recorded height keeps the restore un-clamped; released once content is tall enough (the
27
+ // reapply) or on the next navigation of any kind. The attribute both marks the reservation as
28
+ // pnext's own and preserves an app-authored inline min-height for the release.
29
+ const SCROLL_RESERVE_ATTR = 'data-pnext-scroll-reserve'
30
+
31
+ function releaseScrollHeightReservation() {
32
+ const root = document.documentElement
33
+ const original = root.getAttribute(SCROLL_RESERVE_ATTR)
34
+ if (original === null) return
35
+ root.removeAttribute(SCROLL_RESERVE_ATTR)
36
+ root.style.minHeight = original
37
+ }
38
+
39
+ function restoreScrollWithReservedHeight(x: number, y: number, height: number | undefined) {
40
+ const root = document.documentElement
41
+ if (height && root.scrollHeight < y + root.clientHeight) {
42
+ root.setAttribute(SCROLL_RESERVE_ATTR, root.style.minHeight)
43
+ root.style.minHeight = `${height}px`
44
+ }
45
+ window.scrollTo(x, y)
46
+ }
47
+
23
48
  export const applyNextNavigationScroll: NavigationScrollAction = (url, options) => {
49
+ releaseScrollHeightReservation()
24
50
  const segmentElements = changedSegmentElements()
25
51
  const target = scrollFocusTarget(segmentElements)
26
52
  const plan = nextScrollPlan(
@@ -29,9 +55,9 @@ export const applyNextNavigationScroll: NavigationScrollAction = (url, options)
29
55
  target ? targetTopInViewport(target, document.documentElement.clientHeight) : undefined,
30
56
  )
31
57
  if (plan === 'restore') {
32
- const state = history.state as { __pnextScroll?: [number, number] } | null
33
- const [x, y] = state?.__pnextScroll ?? [0, 0]
34
- window.scrollTo(x, y)
58
+ const state = history.state as { __pnextScroll?: [number, number, number?] } | null
59
+ const [x, y, height] = state?.__pnextScroll ?? [0, 0]
60
+ restoreScrollWithReservedHeight(x, y, height)
35
61
  return
36
62
  }
37
63
  // React hoists a rendered <style precedence>/<link rel=stylesheet>/metadata element out of the segment
@@ -165,12 +165,12 @@ function materializeClientMarkers(doc: Document): void {
165
165
  const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT)
166
166
  while (walker.nextNode()) comments.push(walker.currentNode as Comment)
167
167
  for (const start of comments.reverse()) {
168
- const match = /^(pnext-client|pnext-page):([^>]*)$/.exec(start.data)
168
+ const match = /^(pnext-client|pnext-static-children|pnext-page):([^>]*)$/.exec(start.data)
169
169
  const kind = match?.[1]
170
170
  const encoded = match?.[2]
171
171
  if (!kind || !encoded || !start.parentNode) continue
172
172
  const container = doc.createElement('div')
173
- const tag = kind === 'pnext-page' ? 'div' : 'pnext-client'
173
+ const tag = kind === 'pnext-page' ? 'div' : kind
174
174
  container.innerHTML = `<${tag} ${encoded}></${tag}>`
175
175
  const island = container.firstElementChild
176
176
  if (!island) continue