@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.
- package/bin/pnext +1 -1
- package/package.json +2 -2
- package/reference/data/bench.json +398 -223
- package/reference/getting-started.md +1 -1
- package/src/api/client-navigation.ts +7 -5
- package/src/cli/adapters/vercel-warm.ts +120 -28
- package/src/cli/build.ts +56 -15
- package/src/cli/create.ts +1 -1
- package/src/cli/dev.ts +2 -1
- package/src/cli/index.ts +146 -10
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/serve/pipeline.ts +23 -6
- package/src/cli/serve/ui.ts +1 -1
- package/src/cli/start.ts +2 -1
- package/src/cli/typegen.ts +5 -2
- package/src/client/build.ts +217 -58
- package/src/client/chunk-fold.ts +7 -1
- package/src/client/entry.ts +44 -7
- package/src/client/router/runtime.ts +1016 -230
- package/src/client/router/types.ts +15 -0
- package/src/compat/actions/client-plugin.ts +10 -0
- package/src/compat/actions/rewrite.ts +1 -0
- package/src/compat/bundler/optimize-package-imports.ts +375 -28
- package/src/compat/client/navigation-scroll.ts +29 -3
- package/src/compat/client/optimistic-routing.ts +2 -2
- package/src/compat/client/segment-cache-policy.ts +4 -18
- package/src/compat/client/segment-cache.ts +104 -0
- package/src/compat/client/segment-prefetch.ts +6 -6
- package/src/compat/next/config-loader.ts +6 -4
- package/src/compat/next/navigation.ts +16 -2
- package/src/compat/pages/router.ts +102 -9
- package/src/compat/protocol.ts +10 -1
- package/src/compat/register/actions.ts +2 -0
- package/src/compat/register/bundler.ts +4 -6
- package/src/compat/register/routing.ts +3 -4
- package/src/compat/tsconfig-defaults.ts +3 -2
- package/src/config.ts +3 -3
- package/src/css/postcss.ts +450 -17
- package/src/dev/client-actions.ts +2 -0
- package/src/dev/server.ts +87 -14
- package/src/render/island-context.ts +3 -0
- package/src/render/ppr.ts +14 -0
- package/src/render/renderer.ts +268 -42
- package/src/request/context.ts +32 -11
- package/src/resolve/scan-facts.ts +23 -4
- package/src/routing/proxy.ts +29 -25
- package/src/routing/routes.ts +1 -1
- package/src/runtime/loader.ts +47 -1
- package/src/runtime/modules.ts +546 -65
- package/src/runtime/vendor-build.ts +109 -22
- package/src/runtime/vendor.ts +7 -3
- package/src/utils/ansi.ts +3 -1
- 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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// demand, so the existing per-package grouping
|
|
8
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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 {
|
|
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
|
|
31
|
-
* package that
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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' :
|
|
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
|