@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
package/src/client/build.ts
CHANGED
|
@@ -99,6 +99,13 @@ interface ClientBatchBuildOptions {
|
|
|
99
99
|
routes: RouteManifestEntry[]
|
|
100
100
|
outDir: string
|
|
101
101
|
verbose?: boolean
|
|
102
|
+
/**
|
|
103
|
+
* Dev-server batch build: one esbuild graph over every client route, exactly like prod.
|
|
104
|
+
* Per-route dev builds gave each route its own module graph, so shared libraries existed
|
|
105
|
+
* as per-route copies — context identity broke across navigations and every soft nav
|
|
106
|
+
* remounted the app shell (user-visible splash). Module identity must hold by construction.
|
|
107
|
+
*/
|
|
108
|
+
dev?: boolean
|
|
102
109
|
/**
|
|
103
110
|
* The app defines at least one server action (the build's action manifest). Unset means "unknown" -
|
|
104
111
|
* dev, where the manifest is discovered per request - and the action runtime is emitted, since only
|
|
@@ -597,6 +604,7 @@ export async function buildClientEntries({
|
|
|
597
604
|
verbose,
|
|
598
605
|
hasServerActions,
|
|
599
606
|
pipeline: warmed,
|
|
607
|
+
dev,
|
|
600
608
|
}: ClientBatchBuildOptions) {
|
|
601
609
|
if (!warmed) conventionCache.clear()
|
|
602
610
|
const actions = appUsesActions(routes, hasServerActions)
|
|
@@ -607,6 +615,7 @@ export async function buildClientEntries({
|
|
|
607
615
|
route,
|
|
608
616
|
entryName: clientEntryName(route),
|
|
609
617
|
source: clientEntrySource({
|
|
618
|
+
deferredDynamicHref: devDeferredDynamicHref(dev, route.id),
|
|
610
619
|
pageFile: route.client ? route.file : undefined,
|
|
611
620
|
clientReferences: route.clientReferences,
|
|
612
621
|
nextCompat: nextCompatEnabled(config),
|
|
@@ -626,14 +635,14 @@ export async function buildClientEntries({
|
|
|
626
635
|
// Every route's client sources start their transform and React Compiler pass here, in one batch on
|
|
627
636
|
// oxc's threadpool, while esbuild is still booting and walking the graph. A caller-warmed pipeline
|
|
628
637
|
// has them in flight already; warming twice is a Map hit, so the same call covers both.
|
|
629
|
-
const pipeline = warmed ?? createClientSourcePipeline(config)
|
|
638
|
+
const pipeline = warmed ?? createClientSourcePipeline(config, dev === true)
|
|
630
639
|
pipeline.warmRoutes(entries.map(entry => entry.route))
|
|
631
640
|
await ensureDir(outDir)
|
|
632
641
|
const groups = surfaceGroups(config, entries, actions)
|
|
633
642
|
clientProfile.count('group#', groups.length)
|
|
634
643
|
const built: BuiltEntryGroup[] = []
|
|
635
644
|
for (const group of groups) {
|
|
636
|
-
built.push(await buildEntryGroup({ config, outDir, entries, group, pipeline, verbose }))
|
|
645
|
+
built.push(await buildEntryGroup({ config, outDir, entries, group, pipeline, verbose, dev }))
|
|
637
646
|
}
|
|
638
647
|
|
|
639
648
|
// Fold and rename once over the finished output, never per group. Chunk names
|
|
@@ -647,19 +656,42 @@ export async function buildClientEntries({
|
|
|
647
656
|
Object.assign(merged.outputs, item.metafile.outputs)
|
|
648
657
|
}
|
|
649
658
|
merged = await clientProfile.timeAsync('fold', () =>
|
|
650
|
-
|
|
659
|
+
// `dev` is load-bearing, not cosmetic: the fold rides the minify flag and must stay a no-op in
|
|
660
|
+
// dev. Dev emits absolute publicPath specifiers, which the fold's rewriter cannot express — it
|
|
661
|
+
// would unlink the merged members and leave every entry importing a deleted chunk.
|
|
662
|
+
foldInitialChunks({
|
|
663
|
+
outDir,
|
|
664
|
+
metafile: merged,
|
|
665
|
+
buildOptions: baseClientBuildOptions(config, dev),
|
|
666
|
+
}),
|
|
651
667
|
)
|
|
652
668
|
const renames = await clientProfile.timeAsync('write', () =>
|
|
653
669
|
nameSharedClientChunks(outDir, merged),
|
|
654
670
|
)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
671
|
+
// Entry-import lists become modulepreload links with prod /_next/static URLs; dev serves
|
|
672
|
+
// chunks under /__pnext/client and re-derives everything per request, so baking prod URLs
|
|
673
|
+
// onto the routes would emit preloads that 404 in the dev document.
|
|
674
|
+
if (!dev) {
|
|
675
|
+
clientProfile.time('entryImports', () => {
|
|
676
|
+
for (const item of built) {
|
|
677
|
+
if (item.metafile) {
|
|
678
|
+
assignClientEntryImports(item.group.entries, merged, outDir, renames, item.prebuilt)
|
|
679
|
+
}
|
|
659
680
|
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
// Dev serves entries by their stable route-derived names (/__pnext/client/<id>.js) and
|
|
684
|
+
// re-resolves them per request; the immutable-content rename is prod-deploy machinery and
|
|
685
|
+
// would also mutate route.clientEntry under the dev renderer's feet.
|
|
686
|
+
if (!dev) {
|
|
687
|
+
await clientProfile.timeAsync('fingerprint', () => fingerprintClientEntries(outDir, entries))
|
|
688
|
+
}
|
|
689
|
+
if (dev && devDynamicSplitEnabled() && pipeline.deferredRefs().size > 0) {
|
|
690
|
+
await writeText(
|
|
691
|
+
path.join(outDir, DEFERRED_DYNAMIC_SIDECAR),
|
|
692
|
+
JSON.stringify(Object.fromEntries(pipeline.deferredRefs())),
|
|
693
|
+
)
|
|
694
|
+
}
|
|
663
695
|
clientProfile.report('client stage')
|
|
664
696
|
}
|
|
665
697
|
|
|
@@ -761,6 +793,7 @@ async function buildEntryGroup({
|
|
|
761
793
|
group,
|
|
762
794
|
pipeline,
|
|
763
795
|
verbose,
|
|
796
|
+
dev,
|
|
764
797
|
}: {
|
|
765
798
|
config: ResolvedConfig
|
|
766
799
|
outDir: string
|
|
@@ -769,15 +802,17 @@ async function buildEntryGroup({
|
|
|
769
802
|
group: SurfaceGroup
|
|
770
803
|
pipeline: ClientSourcePipeline
|
|
771
804
|
verbose?: boolean
|
|
805
|
+
dev?: boolean
|
|
772
806
|
}): Promise<BuiltEntryGroup> {
|
|
773
|
-
const
|
|
807
|
+
const facts: ClientRuntimeFacts = dev ? { ...group.facts, dev: true } : group.facts
|
|
808
|
+
const reactLite = facts.suspense === false
|
|
774
809
|
const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
|
|
775
810
|
preparePrebuilt(
|
|
776
811
|
config,
|
|
777
812
|
outDir,
|
|
778
|
-
|
|
813
|
+
dev === true,
|
|
779
814
|
importer => {
|
|
780
|
-
if (importer === CLIENT_RUNTIME_MODULE) return clientRuntimeSource(
|
|
815
|
+
if (importer === CLIENT_RUNTIME_MODULE) return clientRuntimeSource(facts)
|
|
781
816
|
const entry = entries.find(item => item.route.id === importer)
|
|
782
817
|
return entry?.source ?? pipeline.sourceOf(importer) ?? readTextSyncSafe(importer)
|
|
783
818
|
},
|
|
@@ -787,29 +822,51 @@ async function buildEntryGroup({
|
|
|
787
822
|
)
|
|
788
823
|
|
|
789
824
|
let metafile: Metafile | undefined
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
825
|
+
const split = Boolean(dev) && devDynamicSplitEnabled()
|
|
826
|
+
const groupDynEntries = () => {
|
|
827
|
+
const byId = new Map<string, DeferredDynamicEntry>()
|
|
828
|
+
if (!split) return []
|
|
829
|
+
for (const entry of group.entries) {
|
|
830
|
+
for (const dynEntry of deferredDynamicEntries(entry.route)) byId.set(dynEntry.id, dynEntry)
|
|
831
|
+
}
|
|
832
|
+
return [...byId.values()]
|
|
833
|
+
}
|
|
834
|
+
let dynEntries = groupDynEntries()
|
|
835
|
+
const runGroupBuild = () =>
|
|
836
|
+
build({
|
|
837
|
+
...baseClientBuildOptions(config, dev),
|
|
838
|
+
entryPoints: [
|
|
839
|
+
...group.entries.map(entry => ({
|
|
795
840
|
in: virtualEntryPath(entry.route.id),
|
|
796
841
|
out: entry.entryName,
|
|
797
842
|
})),
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
843
|
+
...dynEntries.map(entry => ({ in: entry.file, out: entry.id })),
|
|
844
|
+
],
|
|
845
|
+
// Absolute chunk URLs: an on-demand dyn output is served from a different path than
|
|
846
|
+
// the entry, and a relative specifier would fetch one chunk under two URLs — two
|
|
847
|
+
// module instances (see buildClientEntry).
|
|
848
|
+
...(split ? { publicPath: devClientPublicPath } : {}),
|
|
849
|
+
outdir: outDir,
|
|
850
|
+
metafile: true,
|
|
851
|
+
plugins: clientBuildPlugins(
|
|
852
|
+
config,
|
|
853
|
+
pipeline,
|
|
854
|
+
[
|
|
855
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
856
|
+
virtualEntryPlugin(group.entries),
|
|
857
|
+
...(split ? [deferredDynamicExternalPlugin()] : []),
|
|
858
|
+
clientRuntimePlugin(facts),
|
|
859
|
+
],
|
|
860
|
+
reactLite,
|
|
861
|
+
),
|
|
862
|
+
})
|
|
863
|
+
try {
|
|
864
|
+
const result = await clientProfile.timeAsync('esbuild', () => runGroupBuild())
|
|
812
865
|
metafile = result.metafile
|
|
866
|
+
// NOTE deliberately no discovered-refs second pass here (unlike buildClientEntry): a
|
|
867
|
+
// rebuild re-emits raw chunk-*.js after the prebuilt runtime settled against the first
|
|
868
|
+
// pass's names, and the merged rename map then orphans them. Mid-build-discovered
|
|
869
|
+
// dynamic refs fall back to the shared graph until the next generation.
|
|
813
870
|
} catch (error) {
|
|
814
871
|
// A batched build cannot attribute a "Could not resolve" error to a single route, so fall back to
|
|
815
872
|
// per-route builds - the offending route then throws with its precise client import trace. Other
|
|
@@ -817,7 +874,7 @@ async function buildEntryGroup({
|
|
|
817
874
|
// per-route build surface a more confusing downstream error.
|
|
818
875
|
if (!hasUnresolvedImportError(error)) throw error
|
|
819
876
|
for (const { route } of group.entries) {
|
|
820
|
-
await buildClientEntry({ config, route, outDir })
|
|
877
|
+
await buildClientEntry({ config, route, outDir, dev })
|
|
821
878
|
}
|
|
822
879
|
throw error
|
|
823
880
|
}
|
|
@@ -2099,23 +2156,96 @@ function rootFromFile(file: string) {
|
|
|
2099
2156
|
return path.dirname(file)
|
|
2100
2157
|
}
|
|
2101
2158
|
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
|
|
2105
|
-
// code. Falls back to "shared" when a build ran without a metafile.
|
|
2106
|
-
async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
2159
|
+
// Give every split output a payload-addressed name after its import specifiers
|
|
2160
|
+
// have their final values. Equal final bytes therefore collapse to one URL.
|
|
2161
|
+
export async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
2107
2162
|
if (metafile && traceEnabled('client')) {
|
|
2108
2163
|
await writeText(path.join(outDir, 'metafile.json'), JSON.stringify(metafile))
|
|
2109
2164
|
}
|
|
2110
2165
|
const chunksDir = path.join(outDir, 'chunks')
|
|
2111
|
-
const
|
|
2166
|
+
const chunkFiles = await listFiles(chunksDir)
|
|
2167
|
+
const files = chunkFiles.filter(file => file.endsWith('.js'))
|
|
2112
2168
|
const labels = metafile ? chunkLabelsFromMetafile(metafile) : new Map<string, string>()
|
|
2113
2169
|
const renames = new Map<string, string>()
|
|
2170
|
+
const mapRenames = new Map<string, string>()
|
|
2171
|
+
await Promise.all(
|
|
2172
|
+
chunkFiles
|
|
2173
|
+
.filter(file => file.endsWith('.js.map'))
|
|
2174
|
+
.map(async file => {
|
|
2175
|
+
const basename = path.basename(file)
|
|
2176
|
+
const next = `chunk-map-${assetContentHash(await readFile(file))}.js.map`
|
|
2177
|
+
mapRenames.set(basename, next)
|
|
2178
|
+
if (basename !== next) await rename(file, path.join(chunksDir, next))
|
|
2179
|
+
}),
|
|
2180
|
+
)
|
|
2181
|
+
const sources = new Map(
|
|
2182
|
+
await Promise.all(
|
|
2183
|
+
files.map(async file => [file, replaceChunkNames(await readText(file), mapRenames)] as const),
|
|
2184
|
+
),
|
|
2185
|
+
)
|
|
2186
|
+
const sourceByName = new Map(
|
|
2187
|
+
[...sources].map(([file, source]) => [path.basename(file), source] as const),
|
|
2188
|
+
)
|
|
2189
|
+
const names = [...sourceByName.keys()].sort()
|
|
2190
|
+
const dependencies = new Map(
|
|
2191
|
+
names.map(name => [
|
|
2192
|
+
name,
|
|
2193
|
+
new Set(names.filter(candidate => sourceByName.get(name)!.includes(candidate))),
|
|
2194
|
+
]),
|
|
2195
|
+
)
|
|
2114
2196
|
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2197
|
+
// A chunk's final bytes include its dependencies' final names. Collapse
|
|
2198
|
+
// cycles, then process the component DAG dependency-first so the hash put in
|
|
2199
|
+
// every acyclic filename is computed from exactly the bytes we write.
|
|
2200
|
+
const groups = chunkDependencyGroups(names, dependencies)
|
|
2201
|
+
const groupByName = new Map(groups.flatMap((group, index) => group.map(name => [name, index])))
|
|
2202
|
+
const pending = new Set(groups.map((_, index) => index))
|
|
2203
|
+
const finalSources = new Map<string, string>()
|
|
2204
|
+
while (pending.size > 0) {
|
|
2205
|
+
let progressed = false
|
|
2206
|
+
for (const groupIndex of pending) {
|
|
2207
|
+
const group = groups[groupIndex]!
|
|
2208
|
+
const waitsFor = group.some(name =>
|
|
2209
|
+
[...(dependencies.get(name) ?? [])].some(dependency => {
|
|
2210
|
+
const dependencyGroup = groupByName.get(dependency)
|
|
2211
|
+
return dependencyGroup !== groupIndex && pending.has(dependencyGroup!)
|
|
2212
|
+
}),
|
|
2213
|
+
)
|
|
2214
|
+
if (waitsFor) continue
|
|
2215
|
+
|
|
2216
|
+
const rewritten = new Map(
|
|
2217
|
+
group.map(name => [name, replaceChunkNames(sourceByName.get(name)!, renames)]),
|
|
2218
|
+
)
|
|
2219
|
+
const cyclic = group.length > 1 || (dependencies.get(group[0]!)?.has(group[0]!) ?? false)
|
|
2220
|
+
if (cyclic) {
|
|
2221
|
+
// A cryptographic self-reference has no practical fixed point. Hash a
|
|
2222
|
+
// canonical form whose intra-cycle names are stable placeholders, and
|
|
2223
|
+
// put that group hash in every member name. Any member change therefore
|
|
2224
|
+
// renames the whole strongly-connected component together.
|
|
2225
|
+
const placeholders = new Map(
|
|
2226
|
+
group.map((name, index) => [name, `__pnext_cycle_${index}.js`]),
|
|
2227
|
+
)
|
|
2228
|
+
const canonical = group
|
|
2229
|
+
.map(name => replaceChunkNames(rewritten.get(name)!, placeholders))
|
|
2230
|
+
.join('\0')
|
|
2231
|
+
const groupHash = assetContentHash(canonical)
|
|
2232
|
+
for (const [index, name] of group.entries()) {
|
|
2233
|
+
renames.set(name, `${clientChunkLabel(name, labels)}-${groupHash}-${index}.js`)
|
|
2234
|
+
}
|
|
2235
|
+
} else {
|
|
2236
|
+
const name = group[0]!
|
|
2237
|
+
renames.set(
|
|
2238
|
+
name,
|
|
2239
|
+
`${clientChunkLabel(name, labels)}-${assetContentHash(rewritten.get(name)!)}.js`,
|
|
2240
|
+
)
|
|
2241
|
+
}
|
|
2242
|
+
for (const name of group) {
|
|
2243
|
+
finalSources.set(name, replaceChunkNames(rewritten.get(name)!, renames))
|
|
2244
|
+
}
|
|
2245
|
+
pending.delete(groupIndex)
|
|
2246
|
+
progressed = true
|
|
2247
|
+
}
|
|
2248
|
+
if (!progressed) throw new Error('Unable to order client chunk dependency groups')
|
|
2119
2249
|
}
|
|
2120
2250
|
|
|
2121
2251
|
if (renames.size === 0) return renames
|
|
@@ -2132,17 +2262,7 @@ async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
|
2132
2262
|
// the reference rewrite below still points at the renamed file.
|
|
2133
2263
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
2134
2264
|
}
|
|
2135
|
-
|
|
2136
|
-
// comment in the renamed chunk is rewritten below (replaceChunkNames matches
|
|
2137
|
-
// the `chunk-<hash>.js` prefix inside `chunk-<hash>.js.map`), so the map file
|
|
2138
|
-
// must adopt the new name to keep the link resolvable.
|
|
2139
|
-
const fromMap = `${fromPath}.map`
|
|
2140
|
-
if (!existsSync(fromMap)) return
|
|
2141
|
-
try {
|
|
2142
|
-
await rename(fromMap, `${toPath}.map`)
|
|
2143
|
-
} catch (error) {
|
|
2144
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
2145
|
-
}
|
|
2265
|
+
await writeText(toPath, finalSources.get(from)!)
|
|
2146
2266
|
}),
|
|
2147
2267
|
)
|
|
2148
2268
|
|
|
@@ -2151,8 +2271,9 @@ async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
|
2151
2271
|
const outputs = (await listFiles(outDir)).filter(item => item.endsWith('.js'))
|
|
2152
2272
|
await Promise.all(
|
|
2153
2273
|
outputs.map(async file => {
|
|
2154
|
-
const
|
|
2155
|
-
const
|
|
2274
|
+
const originalName = path.basename(file)
|
|
2275
|
+
const source = sources.get(file) ?? (await readText(file))
|
|
2276
|
+
const next = finalSources.get(originalName) ?? replaceChunkNames(source, renames)
|
|
2156
2277
|
if (next !== source) await writeText(file, next)
|
|
2157
2278
|
}),
|
|
2158
2279
|
)
|
|
@@ -2160,8 +2281,9 @@ async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
|
2160
2281
|
return renames
|
|
2161
2282
|
}
|
|
2162
2283
|
|
|
2163
|
-
function
|
|
2164
|
-
|
|
2284
|
+
function clientChunkLabel(file: string, labels: Map<string, string>) {
|
|
2285
|
+
if (file.startsWith('chunk-')) return labels.get(file) ?? 'shared'
|
|
2286
|
+
return path.basename(file, '.js').replace(/-(?:[A-Z0-9]{8}|[0-9a-f]{16})$/, '')
|
|
2165
2287
|
}
|
|
2166
2288
|
|
|
2167
2289
|
function chunkLabelsFromMetafile(metafile: Metafile) {
|
|
@@ -2206,6 +2328,43 @@ function sanitizeChunkLabel(label: string) {
|
|
|
2206
2328
|
return clean || undefined
|
|
2207
2329
|
}
|
|
2208
2330
|
|
|
2331
|
+
function chunkDependencyGroups(names: string[], dependencies: Map<string, Set<string>>) {
|
|
2332
|
+
let nextIndex = 0
|
|
2333
|
+
const indices = new Map<string, number>()
|
|
2334
|
+
const lowLinks = new Map<string, number>()
|
|
2335
|
+
const stack: string[] = []
|
|
2336
|
+
const onStack = new Set<string>()
|
|
2337
|
+
const groups: string[][] = []
|
|
2338
|
+
|
|
2339
|
+
const visit = (name: string) => {
|
|
2340
|
+
const index = nextIndex++
|
|
2341
|
+
indices.set(name, index)
|
|
2342
|
+
lowLinks.set(name, index)
|
|
2343
|
+
stack.push(name)
|
|
2344
|
+
onStack.add(name)
|
|
2345
|
+
for (const dependency of dependencies.get(name) ?? []) {
|
|
2346
|
+
if (!indices.has(dependency)) {
|
|
2347
|
+
visit(dependency)
|
|
2348
|
+
lowLinks.set(name, Math.min(lowLinks.get(name)!, lowLinks.get(dependency)!))
|
|
2349
|
+
} else if (onStack.has(dependency)) {
|
|
2350
|
+
lowLinks.set(name, Math.min(lowLinks.get(name)!, indices.get(dependency)!))
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (lowLinks.get(name) !== indices.get(name)) return
|
|
2354
|
+
const group: string[] = []
|
|
2355
|
+
for (;;) {
|
|
2356
|
+
const member = stack.pop()!
|
|
2357
|
+
onStack.delete(member)
|
|
2358
|
+
group.push(member)
|
|
2359
|
+
if (member === name) break
|
|
2360
|
+
}
|
|
2361
|
+
groups.push(group.sort())
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
for (const name of names) if (!indices.has(name)) visit(name)
|
|
2365
|
+
return groups
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2209
2368
|
function replaceChunkNames(source: string, renames: Map<string, string>) {
|
|
2210
2369
|
const pattern = new RegExp([...renames.keys()].map(escapeRegex).join('|'), 'g')
|
|
2211
2370
|
return source.replace(pattern, match => renames.get(match) ?? match)
|
package/src/client/chunk-fold.ts
CHANGED
|
@@ -360,6 +360,10 @@ function memberRewriter(
|
|
|
360
360
|
new RegExp(String.raw`import\s*\*\s*as\s*[\w$]+\s*from\s*${specifier}`),
|
|
361
361
|
new RegExp(String.raw`export\s*\*[^;]*from\s*${specifier}`),
|
|
362
362
|
new RegExp(String.raw`import\(\s*${specifier}`),
|
|
363
|
+
// The merged chunk's href is computed RELATIVE to each referrer, so a member named through an
|
|
364
|
+
// absolute specifier (a publicPath URL) cannot be re-pointed. Unlinking it anyway leaves the
|
|
365
|
+
// referrer importing a file that no longer exists — a 404 for every chunk, and a blank app.
|
|
366
|
+
new RegExp(String.raw`"[^".][^"]*(?:${memberPattern})"`),
|
|
363
367
|
]
|
|
364
368
|
const namedImport = new RegExp(
|
|
365
369
|
String.raw`(import|export)\s*${namedClause}\s*from\s*(${specifier})`,
|
|
@@ -471,7 +475,9 @@ interface FoldOutput {
|
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
function foldOutput(text: string): FoldOutput {
|
|
474
|
-
|
|
478
|
+
// Absolute specifiers are indexed too, though the rewriter cannot express them: a referrer the
|
|
479
|
+
// index cannot see is a referrer the fold silently strands. Being visible lets the veto fire.
|
|
480
|
+
return { text, specifiers: [...text.matchAll(/"[./][^"]*"/g)].map(match => match[0]) }
|
|
475
481
|
}
|
|
476
482
|
|
|
477
483
|
/** Every output's text plus a basename->referrers index, read once. Chunk names
|
package/src/client/entry.ts
CHANGED
|
@@ -1155,7 +1155,7 @@ function mountIsland(root, island) {
|
|
|
1155
1155
|
async function mountIslandTree(root, island) {
|
|
1156
1156
|
const [{ h, hydrate, render }${facts.suspense !== false ? ', { Suspense }' : ''}, Component] = await Promise.all([
|
|
1157
1157
|
import('preact'),${facts.suspense !== false ? "\n import('preact/compat')," : ''}
|
|
1158
|
-
island
|
|
1158
|
+
islandComponent(island),
|
|
1159
1159
|
]);
|
|
1160
1160
|
${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
1161
1161
|
const source = preservedSource(root, render);
|
|
@@ -1169,7 +1169,7 @@ ${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawPr
|
|
|
1169
1169
|
}
|
|
1170
1170
|
|
|
1171
1171
|
async function mountIslandTree(root, island) {
|
|
1172
|
-
const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), island
|
|
1172
|
+
const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), islandComponent(island)]);
|
|
1173
1173
|
const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
1174
1174
|
const source = preservedSource(root, render);
|
|
1175
1175
|
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
|
|
@@ -1178,6 +1178,20 @@ async function mountIslandTree(root, island) {
|
|
|
1178
1178
|
}`
|
|
1179
1179
|
}
|
|
1180
1180
|
|
|
1181
|
+
// Dev route entries each bundle their own copy of a shared island component, and Preact
|
|
1182
|
+
// diffs by component IDENTITY: mounting the destination entry's copy would remount every
|
|
1183
|
+
// preserved island on navigation (a splash gate re-splashes, a reel restarts). The first
|
|
1184
|
+
// loaded instance of an island id wins for the document's lifetime; a dev recompile
|
|
1185
|
+
// full-reloads the page, which resets the registry. Prod ships one entry, so this is a
|
|
1186
|
+
// no-op there.
|
|
1187
|
+
// NOTE: pinning a single component instance across dev route entries is NOT safe here:
|
|
1188
|
+
// each dev entry carries its own module graph, and a pinned provider's context object
|
|
1189
|
+
// would not match the consuming entry's copy (breaks e.g. QueryClientProvider). Module
|
|
1190
|
+
// graphs must be deduped at the bundler level instead.
|
|
1191
|
+
async function islandComponent(island) {
|
|
1192
|
+
return island.Component ?? await island.load();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1181
1195
|
// A root preserved across a soft navigation carries the incoming placeholder
|
|
1182
1196
|
// (fresh SSR children) stashed by the router; render children from it when this
|
|
1183
1197
|
// preact instance owns the live tree. A foreign entry copy (dev bundles) can't
|
|
@@ -1256,9 +1270,15 @@ async function domNode(h, node) {
|
|
|
1256
1270
|
const id = element.getAttribute('data-pnext-client');
|
|
1257
1271
|
const island = islands.find(candidate => candidate.id === id);
|
|
1258
1272
|
if (!island) return h(element.localName, domProps(element), await domChildren(h, element));
|
|
1259
|
-
const Component =
|
|
1273
|
+
const Component = await islandComponent(island);
|
|
1260
1274
|
const rawProps = element.getAttribute('data-pnext-props') ?? '{}';
|
|
1261
|
-
const
|
|
1275
|
+
const props = await islandProps(rawProps, element, node => domChildren(h, node), h);
|
|
1276
|
+
// Adopted DOM is diffed positionally, and SSR-state-only siblings (a splash overlay the
|
|
1277
|
+
// server rendered that the live tree no longer shows) shift positions between renders.
|
|
1278
|
+
// A nested island must match its previous incarnation by IDENTITY, or the shift remounts
|
|
1279
|
+
// it and resets its state - Next never remounts layout components on navigation.
|
|
1280
|
+
if (props.key === undefined) props.key = 'pnext-island:' + id;
|
|
1281
|
+
const vnode = islandVNode(h, Component, props, await staticChildren(h, element, island.id));
|
|
1262
1282
|
return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
|
|
1263
1283
|
}
|
|
1264
1284
|
|
|
@@ -1574,8 +1594,19 @@ function mountIsland(root, island) {
|
|
|
1574
1594
|
return mountIslandOnce(root, () => mountIslandTree(root, island));
|
|
1575
1595
|
}
|
|
1576
1596
|
|
|
1597
|
+
// Dev route entries each bundle their own copy of a shared island component; Preact diffs
|
|
1598
|
+
// by identity, so mounting another entry's copy would remount every preserved island on
|
|
1599
|
+
// navigation. First loaded instance per island id wins; a dev recompile full-reloads.
|
|
1600
|
+
// NOTE: pinning a single component instance across dev route entries is NOT safe here:
|
|
1601
|
+
// each dev entry carries its own module graph, and a pinned provider's context object
|
|
1602
|
+
// would not match the consuming entry's copy (breaks e.g. QueryClientProvider). Module
|
|
1603
|
+
// graphs must be deduped at the bundler level instead.
|
|
1604
|
+
async function islandComponent(island) {
|
|
1605
|
+
return island.Component ?? await island.load();
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1577
1608
|
async function mountIslandTree(root, island) {
|
|
1578
|
-
const Component =
|
|
1609
|
+
const Component = await islandComponent(island);${
|
|
1579
1610
|
nextCompat
|
|
1580
1611
|
? `
|
|
1581
1612
|
window.__PNEXT_ACTIONS__?.registerClientReference(island.id, Component);`
|
|
@@ -1584,7 +1615,13 @@ async function mountIslandTree(root, island) {
|
|
|
1584
1615
|
const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
1585
1616
|
const incoming = root.__pnextIncoming;
|
|
1586
1617
|
root.__pnextIncoming = undefined;
|
|
1587
|
-
|
|
1618
|
+
// A streamed chunk promoted INSIDE this root's adopted children (the hard-loaded document
|
|
1619
|
+
// finishes streaming long after hydration) replaced DOM this preact tree owns without
|
|
1620
|
+
// telling it. Its vdom still names the fallback, so a diff would build the incoming
|
|
1621
|
+
// children BESIDE the promoted ones. Rebuild from the incoming DOM instead.
|
|
1622
|
+
const streamGrafted = root.__pnextStreamGrafted;
|
|
1623
|
+
root.__pnextStreamGrafted = undefined;
|
|
1624
|
+
if (incoming && root.__pnextLive === render && !streamGrafted) {
|
|
1588
1625
|
// Preserved across a soft navigation: re-render in place with the incoming
|
|
1589
1626
|
// document's props/children so component state survives while the routed
|
|
1590
1627
|
// content under the island updates.
|
|
@@ -1675,7 +1712,7 @@ async function domNode(node) {
|
|
|
1675
1712
|
}`
|
|
1676
1713
|
: ''
|
|
1677
1714
|
}
|
|
1678
|
-
const Component =
|
|
1715
|
+
const Component = await islandComponent(island);
|
|
1679
1716
|
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element', nextCompat), nextCompat)};
|
|
1680
1717
|
}
|
|
1681
1718
|
|