@rsc-kit/core 0.20.12 → 0.21.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 (56) hide show
  1. package/dist/apiPrerender.js +1 -1
  2. package/dist/apiPrerender.js.map +1 -1
  3. package/dist/appAssets.d.ts +6 -0
  4. package/dist/appAssets.js +34 -0
  5. package/dist/appAssets.js.map +1 -1
  6. package/dist/appleSplash.d.ts +10 -0
  7. package/dist/appleSplash.js +74 -0
  8. package/dist/appleSplash.js.map +1 -0
  9. package/dist/earlyHints.js +7 -2
  10. package/dist/earlyHints.js.map +1 -1
  11. package/dist/export.js +3 -1
  12. package/dist/export.js.map +1 -1
  13. package/dist/files.d.ts +0 -8
  14. package/dist/files.js +7 -18
  15. package/dist/files.js.map +1 -1
  16. package/dist/headers.d.ts +8 -0
  17. package/dist/headers.js +8 -0
  18. package/dist/headers.js.map +1 -1
  19. package/dist/host.d.ts +15 -0
  20. package/dist/host.js +133 -27
  21. package/dist/host.js.map +1 -1
  22. package/dist/js/Form.d.ts +47 -54
  23. package/dist/js/Form.js +41 -48
  24. package/dist/js/Form.js.map +1 -1
  25. package/dist/js/chunkPreload.d.ts +26 -0
  26. package/dist/js/chunkPreload.js +66 -0
  27. package/dist/js/chunkPreload.js.map +1 -0
  28. package/dist/js/installCapture.d.ts +15 -0
  29. package/dist/js/installCapture.js +17 -0
  30. package/dist/js/installCapture.js.map +1 -0
  31. package/dist/js/navigate.js +4 -0
  32. package/dist/js/navigate.js.map +1 -1
  33. package/dist/js/useInstall.d.ts +15 -0
  34. package/dist/js/useInstall.js +102 -0
  35. package/dist/js/useInstall.js.map +1 -0
  36. package/dist/manifest.d.ts +2 -0
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/metadata.d.ts +31 -0
  39. package/dist/metadata.js.map +1 -1
  40. package/dist/prerender.d.ts +24 -5
  41. package/dist/prerender.js +107 -19
  42. package/dist/prerender.js.map +1 -1
  43. package/dist/request.js +3 -2
  44. package/dist/request.js.map +1 -1
  45. package/dist/shellHead.js +4 -2
  46. package/dist/shellHead.js.map +1 -1
  47. package/dist/stableNames.d.ts +52 -0
  48. package/dist/stableNames.js +91 -0
  49. package/dist/stableNames.js.map +1 -0
  50. package/dist/vite.d.ts +5 -3
  51. package/dist/vite.js +215 -13
  52. package/dist/vite.js.map +1 -1
  53. package/dist/webManifest.d.ts +37 -0
  54. package/dist/webManifest.js +51 -6
  55. package/dist/webManifest.js.map +1 -1
  56. package/package.json +8 -4
package/dist/vite.js CHANGED
@@ -20,7 +20,8 @@ import { spawnSync } from "node:child_process";
20
20
  import { basename, dirname, join, relative, resolve } from "node:path";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import rsc, { getPluginApi } from "@vitejs/plugin-rsc";
23
- import { loadEnv } from "vite";
23
+ import { loadEnv, parseAst } from "vite";
24
+ import { stampDisplayNames } from "./stableNames.js";
24
25
  import { REPORT_FILE, buildReport } from "./buildReport.js";
25
26
  import { MANIFEST_PATH, manifestWarning, webManifest } from "./webManifest.js";
26
27
  import { ASSET_BASE, allAppAssets, appAssets, headTags, typeOf, } from "./appAssets.js";
@@ -192,6 +193,8 @@ let foundAssets = {
192
193
  favicon: null,
193
194
  icons: [],
194
195
  appleIcon: null,
196
+ appleSplash: [],
197
+ screenshots: [],
195
198
  openGraph: null,
196
199
  twitter: null,
197
200
  };
@@ -390,6 +393,16 @@ function resolvePaths(options) {
390
393
  icons: foundAssets.icons.map((icon) => icon.href),
391
394
  };
392
395
  }
396
+ // Screenshots the same way: found by name, their size and form factor read
397
+ // from it when the manifest is written.
398
+ if (webManifestOptions &&
399
+ !webManifestOptions.screenshots?.length &&
400
+ foundAssets.screenshots.length) {
401
+ webManifestOptions = {
402
+ ...webManifestOptions,
403
+ screenshots: foundAssets.screenshots.map((shot) => shot.href),
404
+ };
405
+ }
393
406
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
394
407
  }
395
408
  function log(...args) {
@@ -620,6 +633,10 @@ function routeManifest() {
620
633
  // A synthesised robots.txt or sitemap.xml runs no guard: it exists to be
621
634
  // read by anyone, and a guard on the root would 401 the crawler.
622
635
  middleware: generated ? [] : ancestors(name, "middleware"),
636
+ // And the host's, for the same reason: `export const middleware =
637
+ // ['auth']` on a directory guards the route.ts files in it, or adding
638
+ // an endpoint under a guarded path opened a hole in it.
639
+ hostMiddleware: generated ? [] : hostMiddleware(dirOf(name)),
623
640
  })),
624
641
  };
625
642
  }
@@ -1222,6 +1239,15 @@ function copyAppAssets(clientDir) {
1222
1239
  copyFileSync(join(sourceDir, "app", asset.file), to);
1223
1240
  }
1224
1241
  log(`icons: ${all.length} copied from app/`);
1242
+ // A launch screen whose size no device has would be a media query that
1243
+ // matches nothing - served, linked from no page, shown to no one. Named
1244
+ // here, with the sizes that do exist a click away in the guide.
1245
+ const unmatched = foundAssets.appleSplash.filter((splash) => splash.media === null).map((splash) => splash.file);
1246
+ if (unmatched.length) {
1247
+ log(`apple-splash: ${unmatched.join(", ")} ${unmatched.length === 1 ? "matches" : "match"} no iPhone or iPad screen, ` +
1248
+ "so no page links to it. Name each file by the device's size in pixels, portrait or landscape - " +
1249
+ "apple-splash-1179x2556.png is an iPhone 15. The sizes are in the PWA guide.");
1250
+ }
1225
1251
  }
1226
1252
  function writeWebManifest(clientDir, options) {
1227
1253
  if (!existsSync(clientDir))
@@ -1230,6 +1256,9 @@ function writeWebManifest(clientDir, options) {
1230
1256
  const warning = manifestWarning(options);
1231
1257
  if (warning)
1232
1258
  log(warning);
1259
+ else if (!options.screenshots?.length)
1260
+ log(`manifest: ${options.name} is installable. Chrome shows its basic install sheet; ` +
1261
+ "a screenshot-wide-*.png and screenshot-narrow-*.png in app/ get the richer one.");
1233
1262
  else
1234
1263
  log(`manifest: ${options.name} is installable`);
1235
1264
  }
@@ -2265,6 +2294,18 @@ const FALLBACK_BODY = ` const answer = await devHandler(request)
2265
2294
  headers.set('x-forwarded-host', here.host)
2266
2295
  headers.set('x-forwarded-proto', here.protocol.replace(':', ''))
2267
2296
 
2297
+ // And never the visitor's own. The backend trusts this proxy, so an
2298
+ // X-Forwarded-For the browser wrote would be read as the client address -
2299
+ // a visitor choosing their own IP for a throttle on /login. Nothing here
2300
+ // knows the peer's address to put in its place, so the chain is dropped
2301
+ // unless this server sits behind a proxy of its own that wrote it, which
2302
+ // RSC_TRUST_FORWARDED=1 says.
2303
+ if (!(typeof process !== 'undefined' && process.env.RSC_TRUST_FORWARDED === '1')) {
2304
+ for (const name of ['x-forwarded-for', 'forwarded', 'x-real-ip', 'x-client-ip', 'x-forwarded-port', 'x-forwarded-prefix']) {
2305
+ headers.delete(name)
2306
+ }
2307
+ }
2308
+
2268
2309
  const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
2269
2310
 
2270
2311
  try {
@@ -2333,10 +2374,7 @@ const NITRO_PRERENDERED = ` prerendered: import.meta.env.PROD
2333
2374
  ? prerenderedBeside(import.meta.url, ${JSON.stringify(NITRO_STATIC_DIR)})
2334
2375
  : undefined,
2335
2376
  `;
2336
- const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
2337
- ...match.params,
2338
- ...Object.fromEntries(new URL(request.url).searchParams),
2339
- }),
2377
+ const NITRO_HANDLER_OPTIONS = ` props: queryAndParams,
2340
2378
  // A built server gzips what it answers, where the runtime can; the dev
2341
2379
  // server answers raw, which is what a person reading a response wants.
2342
2380
  compress: import.meta.env.PROD,
@@ -2475,7 +2513,7 @@ import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyE
2475
2513
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
2476
2514
  import { noteRequestRead, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
2477
2515
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
2478
- import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
2516
+ import { createRscHandler, queryAndParams } from ${JSON.stringify(join(packageDir, "host"))}
2479
2517
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, "hostCalls"))}
2480
2518
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
2481
2519
  import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
@@ -3512,11 +3550,47 @@ async function renderTree(
3512
3550
  }
3513
3551
  }
3514
3552
 
3553
+ // How the app behaves added to an iPhone's home screen - Next's shape.
3554
+ // Both capable names: Safari reads the apple- one, Chrome warns about it
3555
+ // and reads the plain one, and Next switched between them in a minor.
3556
+ if (md.appleWebApp != null && md.appleWebApp !== false) {
3557
+ const web = (md.appleWebApp === true ? { capable: true } : md.appleWebApp) as Record<string, unknown>
3558
+
3559
+ if (web.capable) {
3560
+ head.push(tag('mobile-web-app-capable', 'yes'))
3561
+ head.push(tag('apple-mobile-web-app-capable', 'yes'))
3562
+ }
3563
+
3564
+ if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
3565
+ if (web.statusBarStyle != null) head.push(tag('apple-mobile-web-app-status-bar-style', web.statusBarStyle))
3566
+
3567
+ if (web.startupImage != null) {
3568
+ const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
3569
+
3570
+ for (const item of startup) {
3571
+ if (item == null) continue
3572
+
3573
+ const image = typeof item === 'object' && !(item instanceof URL)
3574
+ ? (item as Record<string, unknown>)
3575
+ : { url: item }
3576
+
3577
+ head.push(
3578
+ createElement('link', {
3579
+ key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
3580
+ rel: 'apple-touch-startup-image',
3581
+ href: absolute(image.url),
3582
+ ...(image.media ? { media: image.media } : {}),
3583
+ }),
3584
+ )
3585
+ }
3586
+ }
3587
+ }
3588
+
3515
3589
  // other is flattened in beside the named keys, because it is a place to put
3516
3590
  // meta tags rather than a meta tag by that name. A key at the top level
3517
3591
  // still renders - the type no longer invites one, but an app written
3518
3592
  // against the old shape must not silently lose its tags.
3519
- const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
3593
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
3520
3594
  const named = Object.entries(md).filter(([k]) => !structured.has(k))
3521
3595
  const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
3522
3596
 
@@ -3606,6 +3680,11 @@ async function runHostMiddleware(component) {
3606
3680
  for (const route of manifest().routes) {
3607
3681
  if (route.hostMiddleware?.length) hostChains[route.component] = route.hostMiddleware
3608
3682
  }
3683
+
3684
+ // Api routes, keyed by module name - the key runRouteMiddleware is asked with.
3685
+ for (const api of manifest().apis ?? []) {
3686
+ if (api.hostMiddleware?.length) hostChains[api.name] = api.hostMiddleware
3687
+ }
3609
3688
  }
3610
3689
 
3611
3690
  const names = hostChains[component] ?? []
@@ -3903,9 +3982,20 @@ export async function handleRscResume(
3903
3982
  )
3904
3983
 
3905
3984
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
3906
- const htmlStream = await ssr.handleSsrResume(flight, postponed, nonce)
3985
+ // Whether React could replay the recorded tree at all. A bundler that
3986
+ // merges scopes renames components, and a name is how React matches a
3987
+ // slot: the same page, built once and bundled again - which is what
3988
+ // bun build --compile does - has different names at runtime than the
3989
+ // shell recorded. Reported so the host can stop resuming this shell and
3990
+ // render the page whole, rather than serving one whose holes are all
3991
+ // client-rendered. Set before the stream ends, because that is when
3992
+ // React reports it.
3993
+ let replayFailed = false
3994
+ const htmlStream = await ssr.handleSsrResume(flight, postponed, nonce, () => {
3995
+ replayFailed = true
3996
+ })
3907
3997
 
3908
- return { htmlStream }
3998
+ return { htmlStream, replayed: () => !replayFailed }
3909
3999
  }
3910
4000
 
3911
4001
  // Server action (worker: rsc-action).
@@ -4460,6 +4550,8 @@ export async function handleRscPayload(
4460
4550
  // data, so only the shell is safe to cache
4461
4551
  // A page that sets neither is genuinely static and can be prerendered fully.
4462
4552
  const PPR_SHELL_TIMEOUT_MS = Number(process.env.RSC_PPR_TIMEOUT_MS || 2000)
4553
+ // How long a render goes without a Flight row before it counts as waiting.
4554
+ const PPR_QUIET_MS = 50
4463
4555
 
4464
4556
  export async function handleRscPprShell(
4465
4557
  component: string,
@@ -4496,6 +4588,11 @@ export async function handleRscPprShell(
4496
4588
  // installed, because those are different statements: a test that installed
4497
4589
  // one is not a build that can reach the host.
4498
4590
  canReachHost = false,
4591
+ // Told once, when the render has stopped producing rows and is only
4592
+ // waiting. The budget still runs - a slow query can still land and be
4593
+ // stored - but the caller no longer has to hold a slot for a render that
4594
+ // is doing nothing but watching a clock.
4595
+ onQuiet?: () => void,
4499
4596
  ): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
4500
4597
  // Deliberately no middleware here. The probe is asking whether the content is
4501
4598
  // the same for everyone, which is a question about the page. Whether a
@@ -4524,6 +4621,35 @@ export async function handleRscPprShell(
4524
4621
  const controller = new AbortController()
4525
4622
  const budget = setTimeout(() => controller.abort(), budgetMs)
4526
4623
 
4624
+ // Quiet is a stretch with no Flight row: what is left is awaiting something,
4625
+ // and whether that something ever answers is what the budget decides. A
4626
+ // guess in one direction only - a render mistaken for quiet just overlaps
4627
+ // with the next, and is still given its whole budget.
4628
+ let quietTimer: ReturnType<typeof setTimeout> | undefined
4629
+ const stir = () => {
4630
+ if (!onQuiet) return
4631
+ clearTimeout(quietTimer)
4632
+ quietTimer = setTimeout(() => {
4633
+ const told = onQuiet
4634
+ onQuiet = undefined
4635
+ told?.()
4636
+ }, PPR_QUIET_MS)
4637
+ }
4638
+ const watchForQuiet = (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> => {
4639
+ if (!onQuiet) return stream
4640
+
4641
+ stir()
4642
+
4643
+ return stream.pipeThrough(
4644
+ new TransformStream<Uint8Array, Uint8Array>({
4645
+ transform(chunk, out) {
4646
+ stir()
4647
+ out.enqueue(chunk)
4648
+ },
4649
+ }),
4650
+ )
4651
+ }
4652
+
4527
4653
  // Anything that failed while producing this shell.
4528
4654
  //
4529
4655
  // A rejection inside a Suspense boundary does NOT reach the caller: React
@@ -4605,7 +4731,7 @@ export async function handleRscPprShell(
4605
4731
  )
4606
4732
  // Quiet about a redirect: during the probe it is a classification, not
4607
4733
  // a failure, and React would otherwise print a stack for every one.
4608
- const flight = renderToReadableStream(tree, { onError: noteFailure })
4734
+ const flight = watchForQuiet(renderToReadableStream(tree, { onError: noteFailure }))
4609
4735
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4610
4736
  // The flight stream stays open across this. A Flight stream that has
4611
4737
  // closed tells the decoder the connection ended, so the boundary waiting
@@ -4636,6 +4762,7 @@ export async function handleRscPprShell(
4636
4762
 
4637
4763
  await produce
4638
4764
  clearTimeout(budget)
4765
+ clearTimeout(quietTimer)
4639
4766
 
4640
4767
  // timedOut used to mean the stopwatch ran out. It now means React has
4641
4768
  // boundaries it could not finish — the thing the caller was always asking
@@ -4804,6 +4931,7 @@ function generateEntrySsr() {
4804
4931
  const redirectDigestModule = join(packageDir, "redirectDigest");
4805
4932
  const notFoundModule = join(packageDir, "notFound");
4806
4933
  const earlyClicks = join(packageDir, "js/earlyClicks");
4934
+ const installCapture = join(packageDir, "js/installCapture");
4807
4935
  return `// GENERATED by rscKit() — do not edit.
4808
4936
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
4809
4937
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
@@ -4814,6 +4942,11 @@ import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackRe
4814
4942
  import { parseRedirectDigest } from ${JSON.stringify(redirectDigestModule)}
4815
4943
  import { isNotFoundDigest } from ${JSON.stringify(notFoundModule)}
4816
4944
  import { EARLY_CLICKS } from ${JSON.stringify(earlyClicks)}
4945
+ ${webManifestOptions ? `import { INSTALL_CAPTURE } from ${JSON.stringify(installCapture)}` : ""}
4946
+
4947
+ // What runs ahead of the runtime: held taps, and - in an app that can be
4948
+ // installed at all - the browser's offer to install it. See installCapture.ts.
4949
+ const EARLY = EARLY_CLICKS${webManifestOptions ? " + INSTALL_CAPTURE" : ""}
4817
4950
 
4818
4951
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
4819
4952
  // links root-relative in dev, which would send the browser to the host for
@@ -4836,7 +4969,7 @@ export async function handleSsr(
4836
4969
  // seconds before the runtime hydrates is held for it rather than given
4837
4970
  // to the browser as a document load. See earlyClicks.ts.
4838
4971
  const bootstrapScriptContent = bootstrap
4839
- ? EARLY_CLICKS + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4972
+ ? EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4840
4973
  : undefined
4841
4974
 
4842
4975
  // Without an onError handler React rejects each abortable task on its own,
@@ -4878,7 +5011,7 @@ export async function handleSsrPrerender(
4878
5011
  const bootstrapScriptContent =
4879
5012
  options.bootstrap === false
4880
5013
  ? undefined
4881
- : EARLY_CLICKS + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5014
+ : EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4882
5015
 
4883
5016
  const { prelude, postponed } = await prerender(root as any, {
4884
5017
  bootstrapScriptContent,
@@ -4977,6 +5110,11 @@ export async function handleSsrResume(
4977
5110
  rscStream: ReadableStream,
4978
5111
  postponed: unknown,
4979
5112
  nonce?: string,
5113
+ // Told when React refuses to replay: the tree it recorded and the tree
5114
+ // rendering now put different components in a slot, so every hole below
5115
+ // is client-rendered instead of filled here. The caller decides what to
5116
+ // do about it; see the host.
5117
+ onReplayMismatch?: () => void,
4980
5118
  ): Promise<ReadableStream> {
4981
5119
  const root = await createFromReadableStream(rscStream)
4982
5120
 
@@ -4987,7 +5125,13 @@ export async function handleSsrResume(
4987
5125
  // the designed path, on every resume of a shell whose hole holds one -
4988
5126
  // printed as "[rsc-kit:resume] Error: useSearchParams() was read..." on
4989
5127
  // every request, with advice the app had already followed.
4990
- onError: reportRenderError('resume'),
5128
+ onError: (error: unknown, info: unknown) => {
5129
+ const message = String((error as { message?: string } | null)?.message ?? error)
5130
+
5131
+ if (message.includes('instead it rendered') || message.includes('resumable slots')) onReplayMismatch?.()
5132
+
5133
+ return reportRenderError('resume')(error, info)
5134
+ },
4991
5135
  })
4992
5136
 
4993
5137
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -5030,9 +5174,21 @@ function generateEntryBrowser() {
5030
5174
  }))
5031
5175
  : null;
5032
5176
  const refreshModule = join(packageDir, "js/navigate");
5177
+ const chunkPreload = join(packageDir, "js/chunkPreload");
5033
5178
  return `// GENERATED by rscKit() — do not edit.
5034
5179
  import { createViteRscApp } from ${JSON.stringify(clientBootstrap)}
5035
5180
  import { refresh } from ${JSON.stringify(refreshModule)}
5181
+ import { setChunkLoader } from ${JSON.stringify(chunkPreload)}
5182
+ import * as clientReferences from 'virtual:vite-rsc/client-references'
5183
+
5184
+ // How a client component's id becomes its chunk, for loading the ones a
5185
+ // prefetched page uses before the tap - see chunkPreload.ts. The same map the
5186
+ // runtime loads them through, so a chunk loaded here is the one it finds.
5187
+ // A production build only: in development the runtime imports by path.
5188
+ if (import.meta.env.PROD) {
5189
+ const references = (clientReferences as { default: Record<string, () => Promise<unknown>> }).default
5190
+ setChunkLoader((id) => references[id]?.())
5191
+ }
5036
5192
 
5037
5193
  createViteRscApp(document, ${JSON.stringify(interceptManifest())}, ${JSON.stringify({
5038
5194
  staticPayloads: staticPayloads || null,
@@ -5246,6 +5402,25 @@ const JSX_PLUGIN_PATTERN = /react|babel|oxc/i;
5246
5402
  * class is ever constructed on the server.
5247
5403
  */
5248
5404
  const CLIENT_STUB = /registerClientReference\(\s*\(\) => \{ throw new Error\("Unexpectedly client reference export '"/g;
5405
+ /**
5406
+ * The server's render of client components, with names a second bundler
5407
+ * cannot change. See stableNames.ts: a replay matches slots by component
5408
+ * name, and `bun build --compile` renames whatever collides when it merges
5409
+ * module scopes. Stamped on the ssr output as written, so the names are
5410
+ * the ones the prerender recorded - it renders from this same output - and
5411
+ * the ones anything serving it reads, compiled or not.
5412
+ */
5413
+ function stableComponentNames() {
5414
+ return {
5415
+ name: "rsc-kit:stable-component-names",
5416
+ apply: "build",
5417
+ applyToEnvironment: (environment) => environment.name === "ssr",
5418
+ renderChunk(code) {
5419
+ const stamped = stampDisplayNames(code, (source) => parseAst(source));
5420
+ return stamped === code ? null : { code: stamped, map: null };
5421
+ },
5422
+ };
5423
+ }
5249
5424
  function extendableClientReferences() {
5250
5425
  return {
5251
5426
  name: "rsc-kit:extendable-client-references",
@@ -6031,6 +6206,32 @@ export function rscKit(options = {}) {
6031
6206
  external: serverExternals,
6032
6207
  // Same table, same reason.
6033
6208
  checks: { ineffectiveDynamicImport: false },
6209
+ output: {
6210
+ // One copy of a dependency, however many client components
6211
+ // reach for it.
6212
+ //
6213
+ // Every client component is its own entry here - the SSR
6214
+ // side of a client reference, imported by id - and a
6215
+ // dependency two of them share is copied into each one's
6216
+ // chunk group. Two copies of one component are two
6217
+ // different functions, and that is fine until something
6218
+ // merges the module scopes: `bun build --compile` does,
6219
+ // renames the second copy, and a partially prerendered
6220
+ // page records its tree BY COMPONENT NAME. The shell then
6221
+ // says <J>, the binary renders <J2>, React refuses the
6222
+ // replay and every hole falls to the browser - a page
6223
+ // that arrives looking finished with nothing wired up. A
6224
+ // port found it on one page, in production, on cold
6225
+ // loads only.
6226
+ //
6227
+ // Grouping node_modules into one chunk means a shared
6228
+ // dependency exists once, so there is nothing to collide.
6229
+ // The server does not pay for the bigger chunk the way a
6230
+ // browser would: it reads from disk, once, at startup.
6231
+ advancedChunks: {
6232
+ groups: [{ name: "vendor", test: /[\\/]node_modules[\\/]/ }],
6233
+ },
6234
+ },
6034
6235
  },
6035
6236
  },
6036
6237
  resolve: { noExternal: bundledClientPackages },
@@ -6369,6 +6570,7 @@ export function rscKit(options = {}) {
6369
6570
  metadataRoutesPlugin(),
6370
6571
  openApiPlugin(),
6371
6572
  extendableClientReferences(),
6573
+ stableComponentNames(),
6372
6574
  typecheckPlugin(),
6373
6575
  clientImportsAudit(),
6374
6576
  routesPlugin,