@wular/pnext 0.1.3 → 0.1.5

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 (44) hide show
  1. package/config/ts/react.json +2 -1
  2. package/package.json +1 -1
  3. package/reference/navigation.md +3 -1
  4. package/src/api/client-navigation.ts +53 -4
  5. package/src/api/link.tsx +6 -7
  6. package/src/cli/adapters/vercel-warm.ts +6 -1
  7. package/src/cli/adapters/vercel.ts +1 -1
  8. package/src/cli/build/cache.ts +8 -3
  9. package/src/cli/build.ts +3 -2
  10. package/src/cli/serve/pipeline.ts +11 -3
  11. package/src/cli/typegen.ts +65 -3
  12. package/src/client/build.ts +10 -7
  13. package/src/client/entry.ts +50 -29
  14. package/src/client/reference-stub.ts +1 -1
  15. package/src/client/router/hub.ts +17 -9
  16. package/src/client/router/page-slot.ts +58 -0
  17. package/src/client/router/runtime.ts +387 -388
  18. package/src/compat/bundler/symlink-imports.ts +1 -1
  19. package/src/compat/client/errors/bare-boundary.ts +1 -1
  20. package/src/compat/client/errors/error-boundary.ts +1 -1
  21. package/src/compat/client/errors/global-error.ts +40 -79
  22. package/src/compat/client/errors/install.ts +1 -1
  23. package/src/compat/client/errors/lazy.ts +7 -0
  24. package/src/compat/client/errors/primitive-throw.ts +36 -53
  25. package/src/compat/client/segment-cache-policy.ts +5 -22
  26. package/src/compat/client/segment-cache.ts +7 -26
  27. package/src/compat/css/modules.ts +1 -7
  28. package/src/compat/css/sass.ts +1 -7
  29. package/src/compat/react/parity.ts +1 -1
  30. package/src/compat/register/bundler.ts +9 -1
  31. package/src/compat/tsconfig-defaults.ts +4 -7
  32. package/src/compat-bootstrap.ts +15 -0
  33. package/src/css/build.ts +18 -15
  34. package/src/dev/server.ts +2 -2
  35. package/src/index.ts +2 -0
  36. package/src/render/renderer.ts +209 -24
  37. package/src/render/resource-hints.ts +4 -1
  38. package/src/resolve/imports.ts +13 -8
  39. package/src/routing/routes.ts +32 -18
  40. package/src/runtime/loader.ts +29 -10
  41. package/src/runtime/modules.ts +28 -7
  42. package/src/runtime/vendor-build.ts +2 -2
  43. package/src/types.ts +2 -0
  44. package/src/utils/fs.ts +122 -0
package/src/css/build.ts CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path'
4
4
  import type { Plugin } from 'esbuild'
5
5
  import { hashedAssetName } from '../utils/asset-hash'
6
6
  import { build } from '../utils/esbuild'
7
- import { ensureDir, readText } from '../utils/fs'
7
+ import { cssModuleScopePath, ensureDir, readText } from '../utils/fs'
8
8
  import { postcssConfigFile, runPostcss } from './postcss'
9
9
  import { extraPageExtensions, getCssExtensions } from '../extensions'
10
10
  import { nextCompatEnabled } from '../render/hooks'
@@ -132,8 +132,10 @@ export function globalCssSource(config: ResolvedConfig) {
132
132
  return globalCssSources(config)[0]
133
133
  }
134
134
 
135
- export function globalCssSources(config: Pick<ResolvedConfig, 'root' | 'appPath'>) {
136
- return globalCssSourcesForPaths(config.root, config.appPath)
135
+ export function globalCssSources(
136
+ config: Pick<ResolvedConfig, 'root' | 'appPath'> & Partial<Pick<ResolvedConfig, 'workspaceRoot'>>,
137
+ ) {
138
+ return globalCssSourcesForPaths(config.root, config.appPath, config.workspaceRoot)
137
139
  }
138
140
 
139
141
  // The walk below reads and resolves the ROOT LAYOUT'S WHOLE IMPORT GRAPH, and every render asks it
@@ -157,8 +159,8 @@ export function setGlobalCssSourceStore(store: GlobalCssSourceStore | undefined)
157
159
  globalCssStore = store
158
160
  }
159
161
 
160
- export function globalCssSourcesForPaths(root: string, appPath: string) {
161
- const key = `${root}\0${appPath}`
162
+ export function globalCssSourcesForPaths(root: string, appPath: string, workspaceRoot?: string) {
163
+ const key = `${root}\0${appPath}\0${workspaceRoot ?? ''}`
162
164
  const cached = globalCssSourceCache.get(key)
163
165
  if (cached) return cached
164
166
 
@@ -171,7 +173,14 @@ export function globalCssSourcesForPaths(root: string, appPath: string) {
171
173
  const sources = new Set<string>()
172
174
  const visited = new Set<string>()
173
175
  const missing = new Set<string>()
174
- collectGlobalCssSources(root, rootLayoutFile(appPath, missing), sources, visited, missing)
176
+ collectGlobalCssSources(
177
+ root,
178
+ rootLayoutFile(appPath, missing),
179
+ sources,
180
+ visited,
181
+ missing,
182
+ workspaceRoot,
183
+ )
175
184
 
176
185
  const resolved = [...sources].map(file => path.resolve(file))
177
186
  globalCssSourceCache.set(key, resolved)
@@ -194,6 +203,7 @@ function collectGlobalCssSources(
194
203
  sources: Set<string>,
195
204
  visited: Set<string>,
196
205
  missing?: Set<string>,
206
+ workspaceRoot?: string,
197
207
  ) {
198
208
  const resolvedFile = path.resolve(file)
199
209
  if (visited.has(resolvedFile)) return
@@ -204,12 +214,12 @@ function collectGlobalCssSources(
204
214
  visited.add(resolvedFile)
205
215
 
206
216
  for (const specifier of moduleSpecifiers(readFileSync(resolvedFile, 'utf8'))) {
207
- const resolved = resolveImport(root, resolvedFile, specifier)
217
+ const resolved = resolveImport(root, resolvedFile, specifier, workspaceRoot)
208
218
  if (!resolved) continue
209
219
  if (isCssFile(resolved)) {
210
220
  sources.add(resolved)
211
221
  } else {
212
- collectGlobalCssSources(root, resolved, sources, visited, missing)
222
+ collectGlobalCssSources(root, resolved, sources, visited, missing, workspaceRoot)
213
223
  }
214
224
  }
215
225
  }
@@ -898,13 +908,6 @@ function cssModuleClassName(file: string, className: string) {
898
908
  return `${base}_${className}_${hash}`
899
909
  }
900
910
 
901
- function cssModuleScopePath(file: string) {
902
- const normalized = file.split(path.sep).join('/')
903
- const appIndex = normalized.lastIndexOf('/app/')
904
- if (appIndex !== -1) return normalized.slice(appIndex + 1)
905
- return normalized.split('/').slice(-3).join('/')
906
- }
907
-
908
911
  function rewriteClassSelector(css: string, scopedClassName: string, originalClassName: string) {
909
912
  const esbuildNamePattern = new RegExp(
910
913
  `\\.${escapeRegex(originalClassName)}(?![-_a-zA-Z0-9])`,
package/src/dev/server.ts CHANGED
@@ -285,7 +285,7 @@ export async function startDevServer(options: DevServerOptions) {
285
285
  // stylesheet, and both the warm tier and the render force it at Ready+0.
286
286
  installGlobalCssCache(config)
287
287
  markBoot('boot:registry')
288
- let routes = await scanRoutes(config.appPath)
288
+ let routes = await scanRoutes(config.appPath, config.workspaceRoot)
289
289
  setDevClientRoutes(config, routes)
290
290
  markBoot('boot:scanRoutes')
291
291
  await validateProxyFiles(config)
@@ -359,7 +359,7 @@ export async function startDevServer(options: DevServerOptions) {
359
359
  // The route table is built from file paths only, so only an added, removed
360
360
  // or renamed file can change it — a plain save never does.
361
361
  if (structural) {
362
- routes = await scanRoutes(config.appPath)
362
+ routes = await scanRoutes(config.appPath, config.workspaceRoot)
363
363
  setDevClientRoutes(config, routes)
364
364
  }
365
365
  await validateProxyFiles(config)
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -- Ambient CSS declarations have no runtime module.
2
+ /// <reference path="./styles.d.ts" />
1
3
  export type {
2
4
  LayoutProps,
3
5
  Metadata,
@@ -286,6 +286,10 @@ const renderBufferScope = ((globalThis as Record<PropertyKey, unknown>)[RENDER_B
286
286
  new AsyncLocalStorage<RenderBufferFrame>()) as AsyncLocalStorage<RenderBufferFrame>
287
287
  /** True while a render that SUSPENDS in place is running; see installRawTextRendering. */
288
288
  const suspendingStreamScope = new AsyncLocalStorage<boolean>()
289
+ /** The shell body's hoisted resource links; see applyHoistable. */
290
+ const hoistScope = new AsyncLocalStorage<{ nodes?: VNode<Record<string, unknown>>[] }>()
291
+ /** The segment that renders the islands resolved inside it; see islandIdSeed. */
292
+ const islandOwnerScope = new AsyncLocalStorage<string>()
289
293
 
290
294
  type PNextPreactOptions = typeof preactOptions & {
291
295
  [rawTextRenderingInstalled]?: true
@@ -315,8 +319,100 @@ function installRawTextRendering() {
315
319
  applyServerActionFormVNode(vnode)
316
320
  applyLayoutSegmentSnapshot(vnode)
317
321
  }
322
+ // `__b` is preact's mangled diff hook: render-to-string calls it with parents linked.
323
+ const diffOptions = preactOptions as { __b?: (vnode: VNode) => void }
324
+ const previousDiff = diffOptions.__b
325
+ diffOptions.__b = vnode => {
326
+ applyHoistable(vnode)
327
+ previousDiff?.(vnode)
328
+ }
329
+ }
330
+
331
+ // React 19 hoists resource links rendered in the body into `<head>`, once per resource. Hydrating
332
+ // trees keep theirs in place, as do head, svg and noscript content and Suspense fallbacks.
333
+ const HOIST_BARRIERS = new Set([
334
+ 'head',
335
+ 'svg',
336
+ 'noscript',
337
+ 'template',
338
+ 'pnext-client',
339
+ 'pnext-suspense',
340
+ ])
341
+ const RESOURCE_LINKS = new Set([
342
+ 'stylesheet',
343
+ 'preload',
344
+ 'modulepreload',
345
+ 'preconnect',
346
+ 'dns-prefetch',
347
+ ])
348
+
349
+ function applyHoistable(vnode: VNode) {
350
+ if (vnode.type !== 'link') return
351
+ const nodes = hoistScope.getStore()?.nodes
352
+ if (!nodes) return
353
+ const { precedence, ...props } = vnode.props as Record<string, unknown>
354
+ if (
355
+ !RESOURCE_LINKS.has(props.rel as string) ||
356
+ typeof props.href !== 'string' ||
357
+ !props.href ||
358
+ props.itemProp != null ||
359
+ props.disabled != null ||
360
+ props.onLoad ||
361
+ props.onError ||
362
+ (props.rel === 'stylesheet' && typeof precedence !== 'string')
363
+ )
364
+ return
365
+ for (let parent = (vnode as HoistVNode).__; parent; parent = parent.__) {
366
+ const host = parent.type
367
+ if (
368
+ typeof host === 'string'
369
+ ? HOIST_BARRIERS.has(host)
370
+ : host === SuspenseFallback || isClientPageComponent(host)
371
+ )
372
+ return
373
+ }
374
+ const key = resourceKey(props)
375
+ if (!nodes.some(node => resourceKey(node.props) === key)) {
376
+ if (precedence != null) props['data-precedence'] = precedence
377
+ nodes.push(h('link', props))
378
+ }
379
+ vnode.props = { vnode, type: vnode.type, props: vnode.props } as never
380
+ vnode.type = RestoreHoisted as VNode['type']
381
+ }
382
+
383
+ // Renders nothing in the hoisted link's place and restores the vnode, which may be module-shared.
384
+ function RestoreHoisted({
385
+ vnode,
386
+ type,
387
+ props,
388
+ }: {
389
+ vnode: VNode
390
+ type: VNode['type']
391
+ props: VNode['props']
392
+ }) {
393
+ vnode.type = type
394
+ vnode.props = props
395
+ return null
396
+ }
397
+
398
+ // React keys a stylesheet by href, a hint also by what picks its fetched variant.
399
+ function resourceKey(props: Record<string, unknown>) {
400
+ const key = [props.rel, props.href]
401
+ const cors = props.crossOrigin ?? props.crossorigin
402
+ if (props.rel !== 'stylesheet')
403
+ key.push(
404
+ props.as,
405
+ props.imageSrcSet ?? props.imagesrcset,
406
+ props.imageSizes ?? props.imagesizes,
407
+ props.media,
408
+ // React's CORS buckets: absent, use-credentials, anonymous for any other value.
409
+ cors == null || cors === 'use-credentials' ? cors : 'anonymous',
410
+ )
411
+ return key.join('\n')
318
412
  }
319
413
 
414
+ type HoistVNode = VNode & { __?: HoistVNode }
415
+
320
416
  const LAYOUT_SCOPE_PROP = '__pnextLayoutScope'
321
417
  // Rides a client template's stub vnode into clientIslandVNode, which strips it
322
418
  // and stamps the island host with `data-pnext-template` (see ClientIsland).
@@ -551,6 +647,7 @@ interface PageRender {
551
647
  hasClientMounts?: boolean
552
648
  devScript: string
553
649
  streamChunks: Promise<string>[]
650
+ shellTime?: boolean
554
651
  /** Chunks written AFTER `</html>` (late metadata); no document consumer waits on them. */
555
652
  lateChunks?: Promise<string>[]
556
653
  status?: number
@@ -802,6 +899,8 @@ interface DocumentPage extends MetadataRenderPage {
802
899
  documentHeadTags?: string
803
900
  deferredScripts?: string
804
901
  nonce?: string
902
+ /** A streamed document load: stamp the shell's first frame for PROMO_RUNTIME. */
903
+ shellTime?: boolean
805
904
  }
806
905
 
807
906
  interface RenderTreeExtra {
@@ -894,7 +993,7 @@ function renderDocumentStart(page: DocumentPage) {
894
993
  ${page.routeScript}
895
994
  ${page.clientScript}
896
995
  ${page.deferredScripts ?? ''}
897
- ${page.devScript}`
996
+ ${page.devScript}${page.shellTime ? `<script>${SHELL_TIME_SCRIPT}</script>` : ''}`
898
997
  return dropLoadingDepthMarkers(stampDocumentNonce(html, page.nonce))
899
998
  }
900
999
 
@@ -2087,6 +2186,11 @@ export function renderPprResponse(options: RenderOptions, prebuilt: PrebuiltShel
2087
2186
  if (process.env.__NEXT_TEST_MODE) {
2088
2187
  controller.enqueue(encoder.encode('<!-- PPR_BOUNDARY_SENTINEL -->'))
2089
2188
  }
2189
+ if (!routerPayload(options)) {
2190
+ const nonce = documentNonce(options.request)
2191
+ const stamp = `<script${nonce ? ` nonce="${escapeHtml(nonce)}"` : ''}>${SHELL_TIME_SCRIPT}</script>`
2192
+ controller.enqueue(encoder.encode(stamp))
2193
+ }
2090
2194
  schedulePprShellUpgrade(options)
2091
2195
  void (async () => {
2092
2196
  try {
@@ -3327,6 +3431,11 @@ function normalizeRenderOptions(options: RenderOptions): RenderOptions {
3327
3431
  // Streaming isolates each Suspense boundary, dropping client-provider context
3328
3432
  // from ancestor layouts. Compat can narrow this through the streamRoute hook;
3329
3433
  // core routes stream by default.
3434
+ /** A response the client router fetched, not a document load. */
3435
+ function routerPayload(options: RenderOptions) {
3436
+ return Boolean(options.nav?.soft) || options.request?.headers.get('rsc') === '1'
3437
+ }
3438
+
3330
3439
  function canStreamRoute(options: RenderOptions) {
3331
3440
  return getStreamRouteExtensions().canStreamRoute({
3332
3441
  config: options.config,
@@ -3551,10 +3660,13 @@ async function renderTreeInFrame(
3551
3660
  : await resolveServerTree(layoutRender.tree, resolveState)
3552
3661
  : layoutRender.tree,
3553
3662
  )
3554
- const renderBody =
3555
- stream && (options.route.client || streamState.inlineSuspense)
3556
- ? (vnode: VNode) => renderClientPageStreamShell(vnode, streamState)
3557
- : renderVNodeToString
3663
+ const hoist: { nodes?: VNode<Record<string, unknown>>[] } = { nodes: [] }
3664
+ const renderBody = (vnode: VNode, state?: ActionSerializeState) =>
3665
+ hoistScope.run(hoist, () =>
3666
+ stream && (options.route.client || streamState.inlineSuspense)
3667
+ ? renderClientPageStreamShell(vnode, streamState)
3668
+ : renderVNodeToString(vnode, state),
3669
+ )
3558
3670
  const document = await profileRenderStep(profile, 'render document body', async () =>
3559
3671
  layoutRender.documentLayoutFile
3560
3672
  ? await readDocumentLayout(
@@ -3571,6 +3683,10 @@ async function renderTreeInFrame(
3571
3683
  body: await renderBody(h(Fragment, null, resolved), resolveState),
3572
3684
  },
3573
3685
  )
3686
+ // Later streamed renders keep theirs in place: the head has gone out by then.
3687
+ const hoisted = hoist.nodes!
3688
+ hoist.nodes = undefined
3689
+ if (hoisted.length) document.head += renderToString(h(Fragment, null, hoisted))
3574
3690
  // Stable per-root-layout id so the client router can detect root-layout
3575
3691
  // switches and fall back to an MPA navigation (rootLayoutChanged).
3576
3692
  if (layoutRender.documentLayoutFile) {
@@ -3613,8 +3729,7 @@ async function renderTreeInFrame(
3613
3729
  // render and only metadata that misses the flush stays below it (conformance "metadata body
3614
3730
  // placement matches Next" vs metadata-streaming "should delay the metadata render to body").
3615
3731
  // A router payload has no head to hoist into, so it always rides the body.
3616
- const navMetadataRender =
3617
- Boolean(options.nav?.soft) || options.request?.headers.get('rsc') === '1'
3732
+ const navMetadataRender = routerPayload(options)
3618
3733
  const startedPageMetadata =
3619
3734
  metadataMayStream && !navMetadataRender && typeof pageMetadata === 'function'
3620
3735
  ? runInCacheScope(() => pageMetadata(mergeMetadataEntries(layoutRender.metadata)))
@@ -3978,6 +4093,7 @@ async function renderTreeInFrame(
3978
4093
  hasClientMounts: pageSlotIsMountTarget(options),
3979
4094
  devScript,
3980
4095
  streamChunks: streamState.deferred,
4096
+ shellTime: streamState.deferred.length > 0 && !routerPayload(options),
3981
4097
  lateChunks,
3982
4098
  status: extra.status,
3983
4099
  viewport,
@@ -4867,6 +4983,7 @@ function ClientIsland(
4867
4983
  __pnextComponent?: ComponentType<Record<string, unknown>>
4868
4984
  __pnextSerializedProps?: string
4869
4985
  __pnextLayoutScope?: LayoutSegmentSnapshot
4986
+ __pnextOwner?: string
4870
4987
  __pnextParamsScope?: RouteParamsSnapshot
4871
4988
  __pnextTemplate?: boolean
4872
4989
  __pnextSkipSsr?: boolean
@@ -4878,6 +4995,7 @@ function ClientIsland(
4878
4995
  __pnextComponent,
4879
4996
  __pnextSerializedProps,
4880
4997
  __pnextLayoutScope,
4998
+ __pnextOwner,
4881
4999
  __pnextParamsScope,
4882
5000
  __pnextTemplate,
4883
5001
  __pnextSkipSsr,
@@ -4928,6 +5046,12 @@ function ClientIsland(
4928
5046
  islandProps['data-pnext-params'] = serializeProps(__pnextParamsScope)
4929
5047
  }
4930
5048
  const staticChildren = islandStaticChildren(__pnextClient.id, islandChildren)
5049
+ const seed = islandIdSeed(
5050
+ __pnextOwner ?? '',
5051
+ __pnextClient.id,
5052
+ islandProps['data-pnext-props'] as string,
5053
+ )
5054
+ islandProps['data-pnext-id'] = seed
4931
5055
 
4932
5056
  if (__pnextSkipSsr) {
4933
5057
  return staticChildren
@@ -4964,6 +5088,8 @@ function ClientIsland(
4964
5088
  revivePromiseProps(ssrProps),
4965
5089
  plainChildren !== undefined ? islandChildren : probedChildren,
4966
5090
  )
5091
+ // useId's mask (preact's mangled `_mask`): the client seeds the island root from data-pnext-id.
5092
+ ;(island as { __m?: unknown }).__m = [seed, 0]
4967
5093
  if (__pnextLayoutScope) {
4968
5094
  island = h(
4969
5095
  LayoutSegmentContext.Provider,
@@ -5007,6 +5133,36 @@ function ClientIsland(
5007
5133
  })
5008
5134
  }
5009
5135
 
5136
+ // React numbers useId across its one root; each island root here seeds its own id space from its
5137
+ // owning segment and identity, so ids stay unique across islands and across documents that keep a
5138
+ // layout's islands. Identical islands of one owner in one render take the next copy suffix.
5139
+ function islandIdSeed(owner: string, id: string, props: string) {
5140
+ let hash = 0
5141
+ for (const text of [owner, id, props])
5142
+ for (let index = 0; index < text.length; index++)
5143
+ hash = (Math.imul(31, hash) + text.charCodeAt(index)) | 0
5144
+ const seed = (hash >>> 0).toString(36)
5145
+ const frame = renderBufferScope.getStore()
5146
+ const seeds = frame
5147
+ ? (frame.islandSeeds ??= new Map<string, number>())
5148
+ : new Map<string, number>()
5149
+ const copies = seeds.get(seed) ?? 0
5150
+ seeds.set(seed, copies + 1)
5151
+ return copies ? `${seed}_${copies}` : seed
5152
+ }
5153
+
5154
+ // Layout and template markers own the islands they render, the page slot owns the route's.
5155
+ function inIslandOwner<T>(node: VNode, options: RenderOptions, resolve: () => T): T {
5156
+ const props = node.props as Record<string, unknown>
5157
+ const owner =
5158
+ node.type === 'pnext-layout'
5159
+ ? (props['data-pnext-segment'] as string)
5160
+ : props.id === 'pnext-page'
5161
+ ? `page${options.route.route}`
5162
+ : undefined
5163
+ return owner === undefined ? resolve() : islandOwnerScope.run(owner, resolve)
5164
+ }
5165
+
5010
5166
  // Marks the island's static-children wrapper as rendered when the component
5011
5167
  // actually renders its children (depth-first stringification reaches this
5012
5168
  // probe before the fallback sibling below).
@@ -5257,7 +5413,10 @@ async function resolveServerTree(node: unknown, state: ResolveState): Promise<Co
5257
5413
  if (state.partial?.shellPending && node.type === 'html') {
5258
5414
  state.partial.shellPending = false
5259
5415
  }
5260
- return elementVNode(node, await resolveServerTree(node.props.children, state), state)
5416
+ const children = await inIslandOwner(node, state.options, () =>
5417
+ resolveServerTree(node.props.children, state),
5418
+ )
5419
+ return elementVNode(node, children, state)
5261
5420
  }
5262
5421
 
5263
5422
  const component = node.type
@@ -5708,14 +5867,18 @@ async function inlineSuspenseBoundary(
5708
5867
  const fallback = await resolveServerTreeForStream(node.props.fallback, state)
5709
5868
  // The fallback must be a single vnode: the stream renderer renders it with no
5710
5869
  // parent vnode, and its array branch would crash on one (swallowing the
5711
- // fallback entirely). A Fragment wrapper is byte-neutral.
5870
+ // fallback entirely). The wrapper is byte-neutral and marks it for applyHoistable.
5712
5871
  return h(
5713
5872
  boundaryType,
5714
- { ...node.props, fallback: h(Fragment, null, fallback) },
5873
+ { ...node.props, fallback: h(SuspenseFallback, null, fallback) },
5715
5874
  h(Suspended, null),
5716
5875
  )
5717
5876
  }
5718
5877
 
5878
+ function SuspenseFallback(props: { children?: ComponentChildren }) {
5879
+ return props.children
5880
+ }
5881
+
5719
5882
  // Wraps a pending subtree as a suspending component. A throw AFTER the boundary suspended can only
5720
5883
  // ride the boundary's own replacement (the shell and its status already flushed), so it maps to the
5721
5884
  // same markup deferSuspenseBoundary emits. A throw that lands BEFORE the boundary ever suspended -
@@ -5934,7 +6097,9 @@ function resolveServerTreeForStream(
5934
6097
  // component) wrongly takes the doc-shell blocking branch and awaits its slow
5935
6098
  // content before the shell can flush. Mirrors resolveServerTree's html flip.
5936
6099
  if (isDocument) state.shellPending = false
5937
- const children = resolveServerTreeForStream(node.props.children, state)
6100
+ const children = inIslandOwner(node, state.options, () =>
6101
+ resolveServerTreeForStream(node.props.children, state),
6102
+ )
5938
6103
  if (isPromise(children)) {
5939
6104
  return children.then(resolved => elementVNode(node, resolved, state))
5940
6105
  }
@@ -6375,7 +6540,15 @@ function dynamicTargetReference(
6375
6540
  ) {
6376
6541
  const target = dynamicReference.target
6377
6542
  if (!target) return undefined
6378
- return state.clientReferences.get(`c-${clientReferenceId(target.file, target.exportName)}`)
6543
+ const stored = state.clientReferences.get(
6544
+ `c-${clientReferenceId(target.file, target.exportName)}`,
6545
+ )
6546
+ if (stored) return stored
6547
+ for (const reference of state.clientReferences.values()) {
6548
+ if (reference.file === target.file && reference.exportName === target.exportName)
6549
+ return reference
6550
+ }
6551
+ return undefined
6379
6552
  }
6380
6553
 
6381
6554
  function clientIslandVNode(
@@ -6424,6 +6597,7 @@ function clientIslandVNode(
6424
6597
  __pnextComponent: component,
6425
6598
  __pnextTemplate: templateMarker === true,
6426
6599
  __pnextLayoutScope: layoutScope as LayoutSegmentSnapshot | undefined,
6600
+ __pnextOwner: islandOwnerScope.getStore(),
6427
6601
  // Params visible at this island's position in the route (route params plus
6428
6602
  // any enclosing parallel-route slot's own dynamic/catch-all captures). Read
6429
6603
  // from the active params scope (render/slots.ts) so a slot island's
@@ -7279,10 +7453,11 @@ const PROMO_RUNTIME =
7279
7453
  'if(d.nodeType===8){var mm=/^\\$ps:(\\d+)$/.exec(d.data);if(mm){cur=m[mm[1]]=[];continue}' +
7280
7454
  'if(/^\\/\\$ps:\\d+$/.test(d.data)){cur=null;continue}}' +
7281
7455
  'if(cur)cur.push(d);}return m}' +
7456
+ "function B(i){return document.querySelector('pnext-suspense[data-pnext-suspense=\"'+i+'\"]')}" +
7282
7457
  'function tryPromote(i){' +
7283
7458
  "var t=document.querySelector('[data-pnext-stream=\"'+i+'\"]');" +
7284
7459
  'if(!t){delete P[i];return true;}' +
7285
- "var e=document.querySelector('pnext-suspense[data-pnext-suspense=\"'+i+'\"]');" +
7460
+ 'var e=B(i);' +
7286
7461
  'if(!e){P[i]=1;return false;}' +
7287
7462
  // A boundary inside a client island: this promotion rewrites DOM the island's mounted
7288
7463
  // preact tree adopted at hydration, which still names the fallback. Mark the root so the
@@ -7304,17 +7479,23 @@ const PROMO_RUNTIME =
7304
7479
  '}else{while(t.firstChild)f.appendChild(t.firstChild);}' +
7305
7480
  'e.replaceWith(f);t.remove();delete P[i];return true;' +
7306
7481
  '}' +
7307
- 'function drain(){var changed=true;while(changed){changed=false;var k=Object.keys(P);for(var j=0;j<k.length;j++){if(P[k[j]]&&tryPromote(k[j]))changed=true;}}}' +
7308
- 'if(document.addEventListener)document.addEventListener("DOMContentLoaded",drain);' +
7482
+ 'function drain(){var any,changed=true;while(changed){changed=false;var k=Object.keys(P);for(var j=0;j<k.length;j++){if(P[k[j]]&&tryPromote(k[j]))changed=any=true;}}return any}' +
7483
+ // Islands in a promoted chunk hydrate only now: the entry's scan skips unrevealed chunks.
7484
+ 'function M(){window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__&&window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__(document);' +
7485
+ 'window.__PNEXT_MOUNT_ISLANDS__&&window.__PNEXT_MOUNT_ISLANDS__();}' +
7486
+ 'if(document.addEventListener)document.addEventListener("DOMContentLoaded",function(){drain()&&M()});' +
7309
7487
  'return function(i){' +
7310
- 'var ok=tryPromote(i);' +
7311
- 'window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__&&window.__PNEXT_MATERIALIZE_CLIENT_ISLANDS__(document);' +
7312
- 'window.__PNEXT_MOUNT_ISLANDS__&&window.__PNEXT_MOUNT_ISLANDS__();' +
7313
- 'if(ok)drain();' +
7314
- '};' +
7488
+ // React's $RC: reveal in the shell's first frame, else no sooner than 300 ms after the last reveal.
7489
+ // Inside an island at once: a held reveal lands after hydration, under the island's adopted tree.
7490
+ '(B(i)?.closest("pnext-client")?f=>f():self.$RT?setTimeout:requestAnimationFrame)(function(){$RT=performance.now();' +
7491
+ 'if(tryPromote(i))drain();M();' +
7492
+ '},self.$RT+300-performance.now())};' +
7315
7493
  '}'
7316
7494
 
7317
- function suspenseReplaceScript(id: string) {
7495
+ // React Fizz's shell-time stamp: PROMO_RUNTIME reveals no sooner than 300 ms after this frame.
7496
+ const SHELL_TIME_SCRIPT = 'requestAnimationFrame(()=>$RT=performance.now())'
7497
+
7498
+ export function suspenseReplaceScript(id: string) {
7318
7499
  return `(function(){(window.__PNEXT_PROMO__=window.__PNEXT_PROMO__||(${PROMO_RUNTIME})())(${JSON.stringify(id)});var s=document.currentScript;if(s)s.remove();}())`
7319
7500
  }
7320
7501
 
@@ -7595,7 +7776,7 @@ async function applyLayouts(
7595
7776
  tree = h(
7596
7777
  'pnext-layout',
7597
7778
  {
7598
- 'data-pnext-segment': segmentIdentityToken(file),
7779
+ 'data-pnext-segment': segmentIdentityToken(relative(options.config.root, file)),
7599
7780
  'data-pnext-skip': '',
7600
7781
  ...(scope !== undefined ? { 'data-pnext-scope': scope } : {}),
7601
7782
  style: { display: 'contents' },
@@ -7728,7 +7909,11 @@ async function wrapSegment(
7728
7909
  })
7729
7910
  return (component as ClientComponent)[clientReferenceSymbol]
7730
7911
  ? segment
7731
- : serverSegment(layoutFile, segment, segmentScopeFor(options, dir))
7912
+ : serverSegment(
7913
+ relative(options.config.root, layoutFile),
7914
+ segment,
7915
+ segmentScopeFor(options, dir),
7916
+ )
7732
7917
  }
7733
7918
 
7734
7919
  // An explicit `@children` parallel-route slot directory is Next's way to supply
@@ -7769,7 +7954,7 @@ async function wrapInTemplate(
7769
7954
  const Template = markServerReference(module.default)
7770
7955
  if (!Template) return tree
7771
7956
  return serverSegment(
7772
- templateFile,
7957
+ relative(options.config.root, templateFile),
7773
7958
  h(Template as ComponentType<ServerVNodeProps>, { children: tree }),
7774
7959
  segmentScopeFor(options, dirname(templateFile)),
7775
7960
  )
@@ -21,7 +21,10 @@ export const RENDER_BUFFER_SCOPE = Symbol.for('pnext.renderBufferScope')
21
21
  const RENDER_BUFFER_STRAYS = Symbol.for('pnext.renderBufferStrays')
22
22
 
23
23
  export type RenderBufferKind = 'hints' | 'head' | 'deferred'
24
- export type RenderBufferFrame = Record<RenderBufferKind, unknown[]>
24
+ export type RenderBufferFrame = Record<RenderBufferKind, unknown[]> & {
25
+ /** Islands rendered per useId seed, so identical islands get distinct seeds. */
26
+ islandSeeds?: Map<string, number>
27
+ }
25
28
 
26
29
  export const newRenderBufferFrame = (): RenderBufferFrame => ({ hints: [], head: [], deferred: [] })
27
30
 
@@ -189,7 +189,12 @@ export function resolveModuleAlias(specifier: string): string | undefined {
189
189
  * Resolve a specifier to a LOCAL source file, or undefined when it belongs to
190
190
  * an external package. Callers read undefined as "leave this to node/esbuild".
191
191
  */
192
- export function resolveImport(root: string, fromFile: string, specifier: string) {
192
+ export function resolveImport(
193
+ root: string,
194
+ fromFile: string,
195
+ specifier: string,
196
+ workspaceRoot?: string,
197
+ ) {
193
198
  // A `?query`/`#fragment` resource belongs to its loader chain (turbopack
194
199
  // rules, `.wasm?module`): those plugins strip the suffix and resolve the base
195
200
  // themselves, and the query is part of the module's identity — so it is never
@@ -215,7 +220,7 @@ export function resolveImport(root: string, fromFile: string, specifier: string)
215
220
  return resolved ? realImportPath(resolved) : undefined
216
221
  }
217
222
 
218
- const workspaceResolved = resolveWorkspacePackageImport(root, specifier)
223
+ const workspaceResolved = resolveWorkspacePackageImport(root, specifier, workspaceRoot)
219
224
  if (workspaceResolved) return workspaceResolved
220
225
 
221
226
  return resolveAppTreePackage(resolutionRoot, root, fromFile, specifier)
@@ -344,8 +349,8 @@ type PackageExport = unknown
344
349
  * on-disk subpath fallback no standard resolver takes, because the published `exports` point at build output
345
350
  * that does not exist in a checkout.
346
351
  */
347
- function resolveWorkspacePackageImport(root: string, specifier: string) {
348
- const workspaceRoot = findWorkspaceRoot(root)
352
+ function resolveWorkspacePackageImport(root: string, specifier: string, boundary?: string) {
353
+ const workspaceRoot = boundary ? path.resolve(boundary) : findWorkspaceRoot(root)
349
354
  if (!workspaceRoot) return undefined
350
355
 
351
356
  const packageName = packageNameFromSpecifier(specifier)
@@ -999,14 +1004,14 @@ const workspaceMembershipCache = new Map<string, boolean>()
999
1004
  * dependency. An app outside the workspace (a checkout linked into it) sees the package only through
1000
1005
  * node_modules, so the package's own real dir is asked about its manifest too.
1001
1006
  */
1002
- export function isWorkspacePackage(root: string, name: string): boolean {
1003
- const key = `${path.resolve(root)}\0${name}`
1007
+ export function isWorkspacePackage(root: string, name: string, boundary?: string): boolean {
1008
+ const key = `${path.resolve(root)}\0${name}\0${boundary ?? ''}`
1004
1009
  const cached = workspaceMembershipCache.get(key)
1005
1010
  if (cached !== undefined) return cached
1006
1011
 
1007
- const workspaceRoot = findWorkspaceRoot(root)
1012
+ const workspaceRoot = boundary ? path.resolve(boundary) : findWorkspaceRoot(root)
1008
1013
  let member = Boolean(workspaceRoot && workspacePackages(workspaceRoot).has(name))
1009
- if (!member) {
1014
+ if (!member && !boundary) {
1010
1015
  const packageRoot = findNodePackageRoot(path.resolve(root), name)
1011
1016
  const real = packageRoot ? realImportPath(packageRoot) : undefined
1012
1017
  const linkedRoot = real ? findWorkspaceRoot(real) : undefined