@rsc-kit/core 0.20.6 → 0.20.8

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 (65) hide show
  1. package/dist/cache.d.ts +11 -0
  2. package/dist/cache.js +13 -0
  3. package/dist/cache.js.map +1 -1
  4. package/dist/clientEntries.js +4 -1
  5. package/dist/clientEntries.js.map +1 -1
  6. package/dist/files.d.ts +38 -0
  7. package/dist/files.js +109 -0
  8. package/dist/files.js.map +1 -1
  9. package/dist/host.d.ts +11 -2
  10. package/dist/host.js +37 -6
  11. package/dist/host.js.map +1 -1
  12. package/dist/js/ActivityRouter.d.ts +11 -0
  13. package/dist/js/ActivityRouter.js +24 -4
  14. package/dist/js/ActivityRouter.js.map +1 -1
  15. package/dist/js/Form.js +24 -2
  16. package/dist/js/Form.js.map +1 -1
  17. package/dist/js/Link.d.ts +1 -1
  18. package/dist/js/Link.js +25 -9
  19. package/dist/js/Link.js.map +1 -1
  20. package/dist/js/PageTransition.d.ts +27 -0
  21. package/dist/js/PageTransition.js +57 -0
  22. package/dist/js/PageTransition.js.map +1 -0
  23. package/dist/js/PathnameProvider.d.ts +15 -2
  24. package/dist/js/PathnameProvider.js +17 -2
  25. package/dist/js/PathnameProvider.js.map +1 -1
  26. package/dist/js/SegmentBoundary.js +27 -3
  27. package/dist/js/SegmentBoundary.js.map +1 -1
  28. package/dist/js/activityMarkers.d.ts +7 -0
  29. package/dist/js/activityMarkers.js +18 -0
  30. package/dist/js/activityMarkers.js.map +1 -0
  31. package/dist/js/createViteRscApp.js +122 -76
  32. package/dist/js/createViteRscApp.js.map +1 -1
  33. package/dist/js/earlyClicks.d.ts +3 -1
  34. package/dist/js/earlyClicks.js +28 -5
  35. package/dist/js/earlyClicks.js.map +1 -1
  36. package/dist/js/errors.d.ts +2 -0
  37. package/dist/js/errors.js +11 -0
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/imagePreload.d.ts +37 -0
  40. package/dist/js/imagePreload.js +116 -0
  41. package/dist/js/imagePreload.js.map +1 -0
  42. package/dist/js/navigate.d.ts +47 -1
  43. package/dist/js/navigate.js +249 -10
  44. package/dist/js/navigate.js.map +1 -1
  45. package/dist/js/perf.d.ts +25 -0
  46. package/dist/js/perf.js +57 -0
  47. package/dist/js/perf.js.map +1 -0
  48. package/dist/js/router.d.ts +10 -1
  49. package/dist/js/router.js +9 -2
  50. package/dist/js/router.js.map +1 -1
  51. package/dist/js/segmentStore.d.ts +52 -0
  52. package/dist/js/segmentStore.js +123 -2
  53. package/dist/js/segmentStore.js.map +1 -1
  54. package/dist/js/staleAssets.d.ts +10 -0
  55. package/dist/js/staleAssets.js +23 -2
  56. package/dist/js/staleAssets.js.map +1 -1
  57. package/dist/js/viewportPrefetch.d.ts +19 -10
  58. package/dist/js/viewportPrefetch.js +50 -15
  59. package/dist/js/viewportPrefetch.js.map +1 -1
  60. package/dist/shellHead.d.ts +22 -0
  61. package/dist/shellHead.js +43 -0
  62. package/dist/shellHead.js.map +1 -0
  63. package/dist/vite.js +250 -55
  64. package/dist/vite.js.map +1 -1
  65. package/package.json +5 -1
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The head of a stored shell, corrected for the url it is served for.
3
+ *
4
+ * A shell for a route that listed no urls is one file for every url the
5
+ * route matches, so its <title> is whatever the build could know without a
6
+ * url: the layouts' metadata, once the page's own generateMetadata was seen
7
+ * to read the params and left out. The host serving it does know the url,
8
+ * and the page's metadata is a function of it - so the title and the
9
+ * description are written into the head here, as a string edit on the way
10
+ * out, before React resumes the holes below. The client's DocumentTitle
11
+ * sets the title again after hydration; this is for the tab before that,
12
+ * and for whoever reads the document without running it.
13
+ */
14
+ function escapeHtml(text) {
15
+ return text
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;");
20
+ }
21
+ const TITLE = /<title(?:\s[^>]*)?>[\s\S]*?<\/title>/i;
22
+ const DESCRIPTION = /<meta\s+name=(?:"description"|'description'|description)\s+content=(?:"[^"]*"|'[^']*'|[^\s>]*)\s*\/?>/i;
23
+ const HEAD_END = /<\/head>/i;
24
+ /**
25
+ * `shell` with the given title and description in its head. Only the two
26
+ * that every share preview and every tab reads; the rest of a page's
27
+ * metadata rides with the payload.
28
+ */
29
+ export function withHead(shell, metadata) {
30
+ if (!metadata)
31
+ return shell;
32
+ let out = shell;
33
+ if (metadata.title != null) {
34
+ const tag = `<title>${escapeHtml(String(metadata.title))}</title>`;
35
+ out = TITLE.test(out) ? out.replace(TITLE, tag) : out.replace(HEAD_END, `${tag}</head>`);
36
+ }
37
+ if (metadata.description != null) {
38
+ const tag = `<meta name="description" content="${escapeHtml(String(metadata.description))}"/>`;
39
+ out = DESCRIPTION.test(out) ? out.replace(DESCRIPTION, tag) : out.replace(HEAD_END, `${tag}</head>`);
40
+ }
41
+ return out;
42
+ }
43
+ //# sourceMappingURL=shellHead.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shellHead.js","sourceRoot":"","sources":["../src/shellHead.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI;SACR,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,KAAK,GAAG,uCAAuC,CAAC;AACtD,MAAM,WAAW,GAAG,wGAAwG,CAAC;AAC7H,MAAM,QAAQ,GAAG,WAAW,CAAC;AAE7B;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CACtB,KAAa,EACb,QAA2D;IAE3D,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAE5B,IAAI,GAAG,GAAG,KAAK,CAAC;IAEhB,IAAI,QAAQ,CAAC,KAAK,IAAI,IAAI,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,UAAU,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC;QAEnE,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,GAAG,SAAS,CAAC,CAAC;IAC3F,CAAC;IAED,IAAI,QAAQ,CAAC,WAAW,IAAI,IAAI,EAAE,CAAC;QACjC,MAAM,GAAG,GAAG,qCAAqC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC;QAE/F,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,GAAG,SAAS,CAAC,CAAC;IACvG,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC","sourcesContent":["/**\n * The head of a stored shell, corrected for the url it is served for.\n *\n * A shell for a route that listed no urls is one file for every url the\n * route matches, so its <title> is whatever the build could know without a\n * url: the layouts' metadata, once the page's own generateMetadata was seen\n * to read the params and left out. The host serving it does know the url,\n * and the page's metadata is a function of it - so the title and the\n * description are written into the head here, as a string edit on the way\n * out, before React resumes the holes below. The client's DocumentTitle\n * sets the title again after hydration; this is for the tab before that,\n * and for whoever reads the document without running it.\n */\n\nfunction escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nconst TITLE = /<title(?:\\s[^>]*)?>[\\s\\S]*?<\\/title>/i;\nconst DESCRIPTION = /<meta\\s+name=(?:\"description\"|'description'|description)\\s+content=(?:\"[^\"]*\"|'[^']*'|[^\\s>]*)\\s*\\/?>/i;\nconst HEAD_END = /<\\/head>/i;\n\n/**\n * `shell` with the given title and description in its head. Only the two\n * that every share preview and every tab reads; the rest of a page's\n * metadata rides with the payload.\n */\nexport function withHead(\n shell: string,\n metadata: { title?: unknown; description?: unknown } | null,\n): string {\n if (!metadata) return shell;\n\n let out = shell;\n\n if (metadata.title != null) {\n const tag = `<title>${escapeHtml(String(metadata.title))}</title>`;\n\n out = TITLE.test(out) ? out.replace(TITLE, tag) : out.replace(HEAD_END, `${tag}</head>`);\n }\n\n if (metadata.description != null) {\n const tag = `<meta name=\"description\" content=\"${escapeHtml(String(metadata.description))}\"/>`;\n\n out = DESCRIPTION.test(out) ? out.replace(DESCRIPTION, tag) : out.replace(HEAD_END, `${tag}</head>`);\n }\n\n return out;\n}\n"]}
package/dist/vite.js CHANGED
@@ -42,8 +42,23 @@ let inlineStylesheets = "auto";
42
42
  let resolvedConfig = null;
43
43
  /** Server files importing a client library, read off the rsc graph when it is built. */
44
44
  let clientLibraryImports = [];
45
- /** Modules a runtime provides and no bundle should try to carry. */
46
- const RUNTIME_BUILTINS = ["bun", /^bun:/];
45
+ /**
46
+ * The Nitro preset this build targets, from the module's setup, for the
47
+ * decisions buildApp makes about where stored pages can live.
48
+ */
49
+ let nitroPreset = null;
50
+ /**
51
+ * Modules a runtime provides and no bundle should try to carry.
52
+ *
53
+ * Bun's, and the Workers runtime's: `cloudflare:workers` is where a Worker
54
+ * reads its bindings - `import { env } from 'cloudflare:workers'` for the
55
+ * D1 database or the R2 bucket wrangler.jsonc names - and a bundle that
56
+ * tried to resolve it at build time found nothing to resolve. Import it
57
+ * where the code runs on Workers; a `vite dev` under Bun or Node has no such
58
+ * module, so an app that also runs there imports it lazily, behind the
59
+ * check for the runtime.
60
+ */
61
+ const RUNTIME_BUILTINS = ["bun", /^bun:/, /^cloudflare:/];
47
62
  /**
48
63
  * Packages the server bundles import rather than inline, by default.
49
64
  *
@@ -394,7 +409,9 @@ function log(...args) {
394
409
  * pattern is the host's dialect: Laravel writes {slug}, Hono writes :slug, and
395
410
  * neither is the plugin's business.
396
411
  */
397
- /** `[...path]` → catchAll, `[id]` → param, `(group)` → nothing at all. */
412
+ /** The names a top-level parameter directory can have to bind the host rather than a path segment. */
413
+ const HOST_SEGMENTS = new Set(["domain", "host"]);
414
+ /** `[...path]` → catchAll, `[id]` → param, `[domain]` at the top → host, `(group)` → nothing at all. */
398
415
  function urlSegments(componentName) {
399
416
  const parts = componentName.split("/").slice(1, -1);
400
417
  const segments = [];
@@ -410,11 +427,15 @@ function urlSegments(componentName) {
410
427
  continue;
411
428
  }
412
429
  if (part.startsWith("[") && part.endsWith("]")) {
413
- // At the top of app/ - the first segment the url has - a parameter is
414
- // the host's: bound from acme.example.com, never from example.com/acme.
430
+ const value = part.slice(1, -1);
431
+ // At the top of app/ - the first segment the url has - a [domain] or a
432
+ // [host] is the host's: bound from acme.example.com, never from
433
+ // example.com/acme. Any other name there is a path parameter, as it is
434
+ // in Next: a port's app/[collection]/page.tsx was read as a tenant tree
435
+ // and every collection url answered 404.
415
436
  segments.push({
416
- type: segments.length === 0 ? "host" : "param",
417
- value: part.slice(1, -1),
437
+ type: segments.length === 0 && HOST_SEGMENTS.has(value) ? "host" : "param",
438
+ value,
418
439
  });
419
440
  continue;
420
441
  }
@@ -2453,6 +2474,7 @@ import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
2453
2474
  import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
2454
2475
  import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, "query"))}
2455
2476
  import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, "action"))}
2477
+ import { forgetCached } from ${JSON.stringify(join(packageDir, "cache"))}
2456
2478
  import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
2457
2479
  import { isOutdatedOptimizedDep, outdatedDepResponse } from ${JSON.stringify(join(packageDir, "devReload"))}
2458
2480
  import { sharedDepth } from ${JSON.stringify(join(packageDir, "routing"))}
@@ -3076,10 +3098,27 @@ function buildElement(
3076
3098
  // it reads, everything above it still paints, and that is a shell one file
3077
3099
  // can serve for every url the route matches.
3078
3100
  params: Promise<Record<string, unknown>> = Promise.resolve(props),
3101
+ // The url the client hooks answer with during a server render, when it is
3102
+ // not the page key. Undefined: the page key, or unknown when that is
3103
+ // empty. Null: unknown, whatever the key - the payload a pattern shell's
3104
+ // document boots from is keyed by its url and still must not know it.
3105
+ pathname: string | null | undefined = undefined,
3079
3106
  ) {
3080
3107
  const Component = components[component]
3081
3108
  if (!Component) throw new Error('Unknown RSC component: ' + component)
3082
3109
 
3110
+ // Rendered at the top and again inside every boundary, and always with
3111
+ // the runtime - so a segment has the same shape whichever way it arrived.
3112
+ // A page reached by a partial navigation had the provider at the root of
3113
+ // its segment, a page reached by a document load had it outside every
3114
+ // boundary, and a revalidation rendered with no page key had none: React
3115
+ // saw a different component at the root of the segment and remounted
3116
+ // everything under it. A port lost the one-time secret in a modal that
3117
+ // way. With the provider at every level the three trees agree.
3118
+ const rendered = pathname === undefined ? pageKey || null : pathname
3119
+ const withPathname = (node: unknown): unknown =>
3120
+ bootstrap ? createElement(PathnameProvider, { value: rendered }, node) : node
3121
+
3083
3122
  // Awaitable rather than spread. Spread, a page reads its slug synchronously
3084
3123
  // and renders to completion during the probe — producing a page about an
3085
3124
  // invented value, right for nothing — which is why such a route could only
@@ -3198,7 +3237,7 @@ function buildElement(
3198
3237
  // one — otherwise every page would drag React in for a seam nothing can
3199
3238
  // use, and no page could ever be JS-free.
3200
3239
  if (bootstrap) {
3201
- element = createElement(SegmentBoundary, { depth: i + 1, pageKey }, element)
3240
+ element = createElement(SegmentBoundary, { depth: i + 1, pageKey }, withPathname(element))
3202
3241
  }
3203
3242
 
3204
3243
  element = createElement(Layout, {
@@ -3208,14 +3247,8 @@ function buildElement(
3208
3247
  })
3209
3248
  }
3210
3249
 
3211
- // The url the client hooks answer with during a server render. Outside the
3212
- // boundaries, so a page keeps it across a partial navigation; omitted with
3213
- // the runtime, since a route shipping none has nothing to read it.
3214
- if (bootstrap && pageKey) {
3215
- element = createElement(PathnameProvider, { value: pageKey }, element)
3216
- }
3217
-
3218
- return element
3250
+ // And outside the boundaries, for the root layout, which is above them all.
3251
+ return withPathname(element)
3219
3252
  }
3220
3253
 
3221
3254
  // Resolve route metadata into React elements. React 19 hoists <title>/<meta>
@@ -3232,11 +3265,21 @@ async function renderTree(
3232
3265
  pageKey = '',
3233
3266
  bootstrap = true,
3234
3267
  params?: Promise<Record<string, unknown>>,
3268
+ pathname: string | null | undefined = undefined,
3269
+ // What generateMetadata is given, when it is not what the page is given.
3270
+ // A resume of a pattern shell renders the page for its real params and
3271
+ // the metadata as the shell did - left out where it read them - because
3272
+ // a tree with a <title> the shell had not got is a tree that does not
3273
+ // match, and React then fills nothing.
3274
+ metadataParams: Promise<Record<string, unknown>> | undefined = params,
3235
3275
  ) {
3236
3276
  // The FULL chain, always: a title template lives on an outer layout, and a
3237
3277
  // partial render still has to produce the same <title> the whole document
3238
3278
  // would have.
3239
- const md = await resolveMetadata(component, props, layouts)
3279
+ // The params promise travels too: during the pattern probe it never
3280
+ // settles, and a generateMetadata that reads it is then left out of the
3281
+ // shell rather than run against the placeholder - see resolveMetadata.
3282
+ const md = await resolveMetadata(component, props, layouts, metadataParams)
3240
3283
  const head: unknown[] = []
3241
3284
 
3242
3285
  // Rendered into the tree rather than written into the app's layout: React
@@ -3483,7 +3526,7 @@ async function renderTree(
3483
3526
 
3484
3527
  // Metadata elements are rendered INSIDE the document tree so React 19 hoists
3485
3528
  // <title>/<meta> into <head> (hoisting only works from within the tree).
3486
- return buildElement(component, props, layouts, loadings, parallelSlots, slotOverrides, head, from, pageKey, bootstrap, params)
3529
+ return buildElement(component, props, layouts, loadings, parallelSlots, slotOverrides, head, from, pageKey, bootstrap, params, pathname)
3487
3530
  }
3488
3531
 
3489
3532
  /**
@@ -3655,6 +3698,10 @@ export async function handleRscStream(
3655
3698
  slotOverrides: Record<string, SlotOverride> = {},
3656
3699
  from = 0,
3657
3700
  pageKey = '',
3701
+ // The url the client hooks answer with. Null for the payload a document
3702
+ // served from a pattern shell boots from: the shell did not know its url,
3703
+ // and hydration has to agree with the shell. See PathnameProvider.
3704
+ pathname: string | null | undefined = undefined,
3658
3705
  ): Promise<{ stream: ReadableStream; clientChunks: unknown; segmentDepth: number }> {
3659
3706
  await instrumented()
3660
3707
  applyHost()
@@ -3668,7 +3715,7 @@ export async function handleRscStream(
3668
3715
 
3669
3716
  return {
3670
3717
  stream: renderToReadableStream(
3671
- await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, start, pageKey),
3718
+ await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, start, pageKey, true, undefined, pathname ?? null),
3672
3719
  { onError: flightOnError },
3673
3720
  ),
3674
3721
  clientChunks: {},
@@ -3795,6 +3842,11 @@ export async function handleRscResume(
3795
3842
  postponed: unknown = null,
3796
3843
  nonce?: string,
3797
3844
  pageKey = '',
3845
+ // The request's url, for the hooks: a pattern shell is keyed with none,
3846
+ // and the holes it leaves are still rendered for a real url. A breadcrumb
3847
+ // in one streamed empty and the client, knowing the url, disagreed on
3848
+ // every document load.
3849
+ pathname: string | null | undefined = undefined,
3798
3850
  ): Promise<{ htmlStream: ReadableStream }> {
3799
3851
  await instrumented()
3800
3852
  applyHost()
@@ -3811,7 +3863,28 @@ export async function handleRscResume(
3811
3863
  // all, so the shell's remains the only one. What this flag actually decides
3812
3864
  // is whether the tree carries its SegmentBoundary, and the shell's did.
3813
3865
  const flight = renderToReadableStream(
3814
- await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, true),
3866
+ await renderTree(
3867
+ component,
3868
+ props,
3869
+ layouts,
3870
+ loadings,
3871
+ parallelSlots,
3872
+ slotOverrides,
3873
+ 0,
3874
+ pageKey,
3875
+ true,
3876
+ undefined,
3877
+ // A pattern shell was rendered for no url, and the holes that resume
3878
+ // it render the same way: a breadcrumb in one would otherwise be
3879
+ // rendered here for the url and hydrated against a payload that, like
3880
+ // the shell, does not know it. The browser fills it in.
3881
+ pageKey === '' ? null : pathname,
3882
+ // And with params that never settled, its metadata left out where it
3883
+ // read them; the same here, or the tree has a <title> the shell had
3884
+ // not and the slots stop matching. The host writes the real title into
3885
+ // the head as it serves the shell.
3886
+ pageKey === '' ? new Promise<Record<string, unknown>>(() => {}) : undefined,
3887
+ ),
3815
3888
  { onError: flightOnError },
3816
3889
  )
3817
3890
 
@@ -3826,6 +3899,8 @@ export async function handleRscResume(
3826
3899
  interface PageContext {
3827
3900
  component: string
3828
3901
  props: Record<string, unknown>
3902
+ /** The page's url, as the host matched it: the key a navigation to it renders under. */
3903
+ url?: string
3829
3904
  layouts: LayoutEntry[]
3830
3905
  loadings: string[]
3831
3906
  parallelSlots: Record<string, string>
@@ -3856,6 +3931,9 @@ async function renderRevalidated(target: string, page: PageContext): Promise<unk
3856
3931
  await runMiddleware(page.component, page.props)
3857
3932
 
3858
3933
  if (target === 'all' || target === 'page') {
3934
+ // Keyed by the page's url, as a navigation to it is: the tree replaces
3935
+ // the one on screen in place only if it has the same shape, and the
3936
+ // key decides the shape.
3859
3937
  return renderTree(
3860
3938
  page.component,
3861
3939
  page.props,
@@ -3864,7 +3942,7 @@ async function renderRevalidated(target: string, page: PageContext): Promise<unk
3864
3942
  page.parallelSlots,
3865
3943
  {},
3866
3944
  target === 'all' ? 0 : page.layouts.length,
3867
- '',
3945
+ page.url ?? '',
3868
3946
  )
3869
3947
  }
3870
3948
 
@@ -4073,6 +4151,12 @@ export async function handleAction(
4073
4151
  return { stream: renderToReadableStream(result) }
4074
4152
  }
4075
4153
 
4154
+ // The render is of the world after the write. Everything cache() answered
4155
+ // before it - the agent the middleware loaded, the settings the action
4156
+ // read to compare - was true before the write, and the sidebar rendered
4157
+ // from those memos still showed the old name after a rename.
4158
+ forgetCached()
4159
+
4076
4160
  const revalidated: Record<string, unknown> = {}
4077
4161
 
4078
4162
  for (const target of targets) {
@@ -4084,25 +4168,68 @@ export async function handleAction(
4084
4168
  return { stream: renderToReadableStream({ __rscRevalidated: revalidated, result }) }
4085
4169
  }
4086
4170
 
4171
+ /**
4172
+ * How long a generateMetadata gets to answer during the pattern probe before
4173
+ * it is taken to have awaited the params, which never settle there.
4174
+ *
4175
+ * Not zero: a function that awaits nothing of the request still crosses a
4176
+ * microtask or two. Anything still pending after this has read the params or
4177
+ * reached for the host - both are the page's data, and neither is in a shell.
4178
+ */
4179
+ const METADATA_PROBE_GRACE_MS = 20
4180
+
4181
+ const METADATA_POSTPONED = Symbol('metadata postponed')
4182
+
4087
4183
  export async function resolveMetadata(
4088
4184
  component: string,
4089
4185
  props: Record<string, unknown> = {},
4090
4186
  layouts: LayoutEntry[] = [],
4187
+ // The page's own params promise, when the caller has one. During the
4188
+ // pattern probe it never settles - see handleRscPprShell.
4189
+ params?: Promise<Record<string, unknown>>,
4091
4190
  ): Promise<Record<string, unknown> | null> {
4092
4191
  await instrumented()
4093
4192
 
4094
4193
  const pageEntry = metadataMap[component]
4095
- const page: Record<string, unknown> = pageEntry
4096
- ? (pageEntry.generate
4097
- // The same awaitables the page receives. Resolved rather than
4098
- // suspending, even during the probe: a title has to be produced for
4099
- // the shell, and there is no fallback for a <title>.
4100
- ? ((await pageEntry.generate({
4101
- params: Promise.resolve(props),
4102
- searchParams: pageSearchParams(),
4103
- })) ?? {})
4104
- : { ...(pageEntry.static ?? {}) })
4105
- : {}
4194
+
4195
+ // What a route file declares, static or generated. A generateMetadata is
4196
+ // given the same awaitables the page receives - and, during the pattern
4197
+ // probe, params that never settle and a moment: one that answers did not
4198
+ // need them and is the shell's; one still pending did, and is left out.
4199
+ // A shell for a route that listed no urls is rendered for the pattern, and
4200
+ // a title read from the params would be read from the placeholder the
4201
+ // build invented: a port's training-sessions/[id] shell said "Training
4202
+ // Session" over a payload for /new that said "New Training Session". Next
4203
+ // treats params as dynamic under PPR and postpones the metadata. The host
4204
+ // puts the real title in when it serves the shell for a url, and the
4205
+ // client's DocumentTitle sets it again after hydration.
4206
+ const declared = async (entry: (typeof metadataMap)[string] | undefined): Promise<Record<string, unknown>> => {
4207
+ if (!entry) return {}
4208
+
4209
+ if (!entry.generate) return entry.static ? { ...entry.static } : {}
4210
+
4211
+ const generated = entry.generate({
4212
+ params: params ?? Promise.resolve(props),
4213
+ searchParams: pageSearchParams(),
4214
+ })
4215
+
4216
+ const settled = params
4217
+ ? await Promise.race([
4218
+ Promise.resolve(generated),
4219
+ new Promise<typeof METADATA_POSTPONED>((resolve) =>
4220
+ setTimeout(() => resolve(METADATA_POSTPONED), METADATA_PROBE_GRACE_MS),
4221
+ ),
4222
+ ])
4223
+ : await generated
4224
+
4225
+ return settled === METADATA_POSTPONED ? {} : ((settled as Record<string, unknown> | undefined) ?? {})
4226
+ }
4227
+
4228
+ // Layouts too, outer to inner: Next runs a layout's generateMetadata, and
4229
+ // a port had the category's title in its layout - which rendered as the
4230
+ // site's name on every category page until this read it.
4231
+ const layoutMeta = await Promise.all(layouts.map((l) => declared(metadataMap[l.component])))
4232
+ const page = await declared(pageEntry)
4106
4233
 
4107
4234
  // Non-title metadata: layout defaults (outer→inner), page overrides.
4108
4235
  //
@@ -4121,26 +4248,30 @@ export async function resolveMetadata(
4121
4248
  }
4122
4249
  }
4123
4250
 
4124
- for (const l of layouts) {
4125
- const s = metadataMap[l.component]?.static
4126
-
4127
- if (s) take(s as Record<string, unknown>)
4128
- }
4251
+ for (const m of layoutMeta) take(m)
4129
4252
 
4130
4253
  take(page)
4131
4254
 
4132
4255
  if (Object.keys(other).length > 0) merged.other = other
4133
4256
 
4134
- // Title: the page title with the NEAREST layout title.template applied; if the
4135
- // page has no title, the nearest layout default/string title.
4257
+ // Title: the page's, or the nearest layout's own string or default - and
4258
+ // then the nearest template ABOVE whichever supplied it, as Next applies a
4259
+ // layout's template to the titles of the segments below it. A layout's
4260
+ // title used to stop the walk before any template: a category named in
4261
+ // its layout lost the site's suffix every page had.
4136
4262
  let title: string | undefined = typeof page.title === 'string' ? page.title : undefined
4137
4263
  for (let i = layouts.length - 1; i >= 0; i--) {
4138
- const lt = metadataMap[layouts[i].component]?.static?.title as
4264
+ const lt = layoutMeta[i]?.title as
4139
4265
  | string | { template?: string; default?: string } | undefined
4140
- if (lt && typeof lt === 'object') {
4141
- if (title != null && lt.template) { title = lt.template.replace('%s', title); break }
4142
- if (title == null && lt.default) { title = lt.default; break }
4143
- } else if (title == null && typeof lt === 'string') { title = lt; break }
4266
+
4267
+ if (title == null) {
4268
+ if (lt && typeof lt === 'object' && lt.default) title = lt.default
4269
+ else if (typeof lt === 'string') title = lt
4270
+
4271
+ continue
4272
+ }
4273
+
4274
+ if (lt && typeof lt === 'object' && lt.template) { title = lt.template.replace('%s', title); break }
4144
4275
  }
4145
4276
  if (title != null) merged.title = title
4146
4277
 
@@ -4389,6 +4520,17 @@ export async function handleRscPprShell(
4389
4520
  // page, stored, with the build reporting success.
4390
4521
  let renderFailure: string | undefined
4391
4522
 
4523
+ // A read the server could not answer, caught at a boundary: the query,
4524
+ // which a server never has, or the pathname, which a shell for a pattern
4525
+ // does not. Each hook throws with its own digest; the build names the hook.
4526
+ const caughtReadOf = (error: unknown): { hook: string; digest: string } | null => {
4527
+ const digest = (error as { digest?: string } | null)?.digest
4528
+
4529
+ if (digest === 'rsc-kit:search-params-fallback') return { hook: 'useSearchParams()', digest }
4530
+
4531
+ return null
4532
+ }
4533
+
4392
4534
  const noteFailure = (e: unknown, info?: { componentStack?: string }): string | undefined => {
4393
4535
  const digest = redirectDigest(e)
4394
4536
 
@@ -4399,10 +4541,12 @@ export async function handleRscPprShell(
4399
4541
  // there is what gets stored. Noted with its component so the build can
4400
4542
  // say so on the route's line; the digest goes into the document for the
4401
4543
  // browser to recognise.
4402
- if ((e as { digest?: string } | null)?.digest === 'rsc-kit:search-params-fallback') {
4544
+ const caught = caughtReadOf(e)
4545
+
4546
+ if (caught) {
4403
4547
  const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4404
- noteCaughtRead('useSearchParams()' + (where ? ' in ' + where : ''))
4405
- return 'rsc-kit:search-params-fallback'
4548
+ noteCaughtRead(caught.hook + (where ? ' in ' + where : ''))
4549
+ return caught.digest
4406
4550
  }
4407
4551
 
4408
4552
  // Every probe ends by aborting, so React reports that abort. Asked of the
@@ -4729,10 +4873,11 @@ export async function handleSsrPrerender(
4729
4873
  // Aborting is how this ends, so React's report of it is not news. A read
4730
4874
  // caught at a boundary is: the build attaches it to the route.
4731
4875
  onError: (error: unknown, info?: { componentStack?: string }) => {
4732
- if ((error as { digest?: string } | null)?.digest !== 'rsc-kit:search-params-fallback') return
4876
+ const caught = caughtReadOf(error)
4877
+ if (!caught) return
4733
4878
  const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4734
- noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4735
- return 'rsc-kit:search-params-fallback'
4879
+ noteFallback(caught.hook + (where ? ' in ' + where : ''))
4880
+ return caught.digest
4736
4881
  },
4737
4882
  })
4738
4883
 
@@ -4750,6 +4895,19 @@ export async function handleSsrPrerender(
4750
4895
  * so React writes it into the document. Under a boundary the developer wrote
4751
4896
  * there is nothing to say; with nothing closer than a loading.tsx, one line.
4752
4897
  */
4898
+ /**
4899
+ * A read the server could not answer, caught at a boundary: the query, which
4900
+ * a server never has, or the pathname, which a shell for a pattern does not.
4901
+ * Each hook throws with its own digest; the build names the hook.
4902
+ */
4903
+ function caughtReadOf(error: unknown): { hook: string; digest: string } | null {
4904
+ const digest = (error as { digest?: string } | null)?.digest
4905
+
4906
+ if (digest === 'rsc-kit:search-params-fallback') return { hook: 'useSearchParams()', digest }
4907
+
4908
+ return null
4909
+ }
4910
+
4753
4911
  function reportRenderError(phase: 'ssr' | 'resume') {
4754
4912
  return (error: unknown, info?: { componentStack?: string }) => {
4755
4913
  // The consumer cancelled - a browser that left mid-stream, a prefetch
@@ -4765,19 +4923,21 @@ function reportRenderError(phase: 'ssr' | 'resume') {
4765
4923
  // times or once.
4766
4924
  if (parseRedirectDigest(digest) || isNotFoundDigest(digest)) return digest
4767
4925
 
4768
- if (digest === 'rsc-kit:search-params-fallback') {
4926
+ const caught = caughtReadOf(error)
4927
+
4928
+ if (caught) {
4769
4929
  // The component is the first frame of React's stack. Noted on the
4770
4930
  // request so the build attaches it to the route; printed as one line,
4771
4931
  // not a stack, so the server says which boundary it wants.
4772
4932
  const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4773
4933
 
4774
- noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4934
+ noteFallback(caught.hook + (where ? ' in ' + where : ''))
4775
4935
 
4776
4936
  if (caughtByLoading(info?.componentStack)) {
4777
4937
  console.error(
4778
4938
  '[rsc-kit] ' + (where ? where + ': ' : '') +
4779
- 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4780
- 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4939
+ caught.hook + ' was read on the server with nothing closer than a loading.tsx, so the whole ' +
4940
+ 'segment shows that fallback until the value arrives. A <Suspense> around the component that reads ' +
4781
4941
  'keeps the rest of the page painted.',
4782
4942
  )
4783
4943
  }
@@ -5511,6 +5671,24 @@ export function rscKit(options = {}) {
5511
5671
  setup(nitro) {
5512
5672
  if (nitro.options.dev)
5513
5673
  return;
5674
+ nitroPreset = nitro.options.preset ?? null;
5675
+ // On Workers the stored pages ride the static assets, not the bundle.
5676
+ // Copied once Nitro has listed its public files, so the copies are
5677
+ // not in that list: a path in it is served as an asset before the
5678
+ // Worker runs, and a stored page of a guarded route would then be
5679
+ // public. Off the list and behind run_worker_first, a request for
5680
+ // one reaches the app, which has no such route, and the pages are
5681
+ // read only through the binding. See ASSETS_PREFIX in files.ts.
5682
+ if (nitro.options.preset?.startsWith("cloudflare")) {
5683
+ nitro.hooks.hook("compiled", async () => {
5684
+ const { storedPagesToAssets } = await import("./files.js");
5685
+ const serverDir = nitro.options.output.serverDir;
5686
+ const publicDir = nitro.options.output.publicDir ?? join(dirname(serverDir), "public");
5687
+ const copied = await storedPagesToAssets(join(serverDir, NITRO_STATIC_DIR), publicDir, join(serverDir, "wrangler.json"));
5688
+ if (copied > 0)
5689
+ log(`stored pages: ${copied} files uploaded as assets, read through the ASSETS binding`);
5690
+ });
5691
+ }
5514
5692
  // Said at the end of a bun build, only when true: which module keeps
5515
5693
  // `bun build --compile --bytecode` from applying, and where. Bun
5516
5694
  // itself names no file and exits 0. See bytecodeCheck.ts.
@@ -5816,6 +5994,17 @@ export function rscKit(options = {}) {
5816
5994
  rollupOptions: {
5817
5995
  input: { index: join(genDir, "entry.rsc.tsx") },
5818
5996
  external: serverExternals,
5997
+ // The server-references map is `id: () => import(file)` for
5998
+ // every "use server" file, and the page that passes an action
5999
+ // to <Form> imports the same file statically. Rolldown says
6000
+ // so for each one: "dynamically imported by
6001
+ // virtual:vite-rsc/server-references but also statically
6002
+ // imported by page.tsx, dynamic import will not move module
6003
+ // into another chunk". Nothing was meant to move: a server
6004
+ // bundle has no chunk to lazy-load from a browser, and the
6005
+ // map is a lookup table, not a split point. One line per
6006
+ // action in every build was a paragraph nobody could act on.
6007
+ checks: { ineffectiveDynamicImport: false },
5819
6008
  },
5820
6009
  },
5821
6010
  resolve: { noExternal: bundledClientPackages },
@@ -5826,6 +6015,8 @@ export function rscKit(options = {}) {
5826
6015
  rollupOptions: {
5827
6016
  input: { index: join(genDir, "entry.ssr.tsx") },
5828
6017
  external: serverExternals,
6018
+ // Same table, same reason.
6019
+ checks: { ineffectiveDynamicImport: false },
5829
6020
  },
5830
6021
  },
5831
6022
  resolve: { noExternal: bundledClientPackages },
@@ -6067,7 +6258,11 @@ export function rscKit(options = {}) {
6067
6258
  // module is uploaded as a sibling of the bundle and imported at runtime
6068
6259
  // when the directory is not there. Only under Nitro, whose presets are
6069
6260
  // the ones without a disk; on its own the plugin serves from outDir.
6070
- if (clientOut && existsSync(staticDir)) {
6261
+ // Not on Workers, where the pages ride the static assets instead - an
6262
+ // inline module counts against the script's size limit, and a port's
6263
+ // 571 stored pages were 69 MB of one. See the Nitro module's compiled
6264
+ // hook.
6265
+ if (clientOut && existsSync(staticDir) && !nitroPreset?.startsWith("cloudflare")) {
6071
6266
  const { inlineModuleName, inlineModuleSource, compileEntrySource } = await import("./files.js");
6072
6267
  writeFileSync(join(dirname(staticDir), inlineModuleName(NITRO_STATIC_DIR)), await inlineModuleSource(staticDir));
6073
6268
  // And the entry a binary is compiled from, so the pages above end up