@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
@@ -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
- foldInitialChunks({ outDir, metafile: merged, buildOptions: baseClientBuildOptions(config) }),
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
- clientProfile.time('entryImports', () => {
656
- for (const item of built) {
657
- if (item.metafile) {
658
- assignClientEntryImports(item.group.entries, merged, outDir, renames, item.prebuilt)
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
- await clientProfile.timeAsync('fingerprint', () => fingerprintClientEntries(outDir, entries))
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 reactLite = group.facts.suspense === false
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
- false,
813
+ dev === true,
779
814
  importer => {
780
- if (importer === CLIENT_RUNTIME_MODULE) return clientRuntimeSource(group.facts)
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
- try {
791
- const result = await clientProfile.timeAsync('esbuild', () =>
792
- build({
793
- ...baseClientBuildOptions(config),
794
- entryPoints: group.entries.map(entry => ({
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
- outdir: outDir,
799
- metafile: true,
800
- plugins: clientBuildPlugins(
801
- config,
802
- pipeline,
803
- [
804
- ...(prebuilt ? [prebuilt.plugin] : []),
805
- virtualEntryPlugin(group.entries),
806
- clientRuntimePlugin(group.facts),
807
- ],
808
- reactLite,
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
- // esbuild names every shared chunk "chunk-<hash>", which makes bundle reports
2103
- // unreadable. Rename each one after the module that dominates it by size in
2104
- // the metafile: the package name for node_modules code, the file stem for app
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 files = (await listFiles(chunksDir)).filter(file => file.endsWith('.js'))
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
- for (const file of files) {
2116
- const basename = path.basename(file)
2117
- if (!basename.startsWith('chunk-')) continue
2118
- renames.set(basename, `${labels.get(basename) ?? 'shared'}-${hashSuffix(basename)}.js`)
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
- // Move the external sourcemap alongside its chunk. The `sourceMappingURL`
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 source = await readText(file)
2155
- const next = replaceChunkNames(source, renames)
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 hashSuffix(file: string) {
2164
- return path.basename(file, '.js').split('-').at(-1) ?? 'chunk'
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)
@@ -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
- return { text, specifiers: [...text.matchAll(/"\.[^"]*"/g)].map(match => match[0]) }
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
@@ -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.load(),
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.load()]);
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 = island.Component ?? await island.load();
1273
+ const Component = await islandComponent(island);
1260
1274
  const rawProps = element.getAttribute('data-pnext-props') ?? '{}';
1261
- const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node), h), await staticChildren(h, element, island.id));
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 = island.Component ?? await island.load();${
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
- if (incoming && root.__pnextLive === render) {
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 = island.Component ?? await island.load();
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