brustjs 0.1.35-alpha → 0.1.37-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/index.ts CHANGED
@@ -49,6 +49,15 @@ export interface ServeOptions {
49
49
  /** Max ms a render waits for a free worker before 503 (AllBusy queues
50
50
  * instead of failing fast). Default 10000. */
51
51
  claimTimeoutMs?: number
52
+ /** tokio I/O runtime worker-thread count for the hyper server. Runs inside
53
+ * Bun (which has its own threads + render workers), so this is NOT
54
+ * one-per-core. Default `min(availableParallelism, 4)` (fallback 2). */
55
+ workerThreads?: number
56
+ /** Render slots per Bun worker — concurrent in-flight renders per isolate.
57
+ * Default 1 (byte-identical to single in-flight render per worker). The
58
+ * count is propagated to each worker via the `BRUST_RENDER_SLOTS` env var
59
+ * and scales the per-worker SAB so each slot keeps the single-slot capacity. */
60
+ renderSlots?: number
52
61
  }
53
62
  }
54
63
 
@@ -56,10 +65,11 @@ export interface ServeOptions {
56
65
  // (the worker wrote `[meta_len][meta][body]` into the SAB; Rust reads it
57
66
  // directly), `0` → the worker used the chunk channel via
58
67
  // `napi.renderChunk(workerId, len)` (React Suspense streaming) or owns the
59
- // socket independently (SSE/WS). The argument is a JSON envelope
60
- // `{ route_id, path, params }` produced by Rust's route table see
68
+ // socket independently (SSE/WS). The argument is the INLINE JSON envelope
69
+ // `{ route_id, path, params }` produced by Rust's route table (the request
70
+ // always crosses as a string — SAB-request is closed) — see
61
71
  // runtime/routes.ts::RouteCall.
62
- export type RenderFn = (envelopeJsonOrLen: number | string) => Promise<number>
72
+ export type RenderFn = (envelopeJson: string, slot: number) => Promise<number>
63
73
 
64
74
  // Bun Workers run in the same OS process as the main thread; the `env` option
65
75
  // only patches the JS-visible process.env, not the native OS environment that
@@ -127,7 +137,16 @@ export const brust = {
127
137
  // leaving the prefix at its default — which broke custom-prefix routing.
128
138
  actionPrefix: opts.actionPrefix,
129
139
  })
130
- const baseEnv = { ...process.env }
140
+ // Render slots per worker. Propagated to each worker via env (Bun Workers
141
+ // share the OS process, so the worker reads it from process.env at
142
+ // registerRenderer time). Precedence: explicit `tuning.renderSlots`, else the
143
+ // `BRUST_RENDER_SLOTS` env (so it's configurable like BRUST_WORKERS without
144
+ // per-app wiring), else 1 — byte-identical single in-flight render.
145
+ const renderSlots = Math.max(
146
+ 1,
147
+ opts.tuning?.renderSlots ?? (Number(process.env.BRUST_RENDER_SLOTS) || 1),
148
+ )
149
+ const baseEnv = { ...process.env, BRUST_RENDER_SLOTS: String(renderSlots) }
131
150
  const workersArr: Worker[] = []
132
151
  for (let i = 0; i < opts.workers; i++) {
133
152
  // Bun.Worker requires the JS entry (post-bundling). For the skeleton,
@@ -142,9 +161,25 @@ export const brust = {
142
161
  const { registerInitialPool } = await import('./dev/worker-registry.ts')
143
162
  registerInitialPool(workersArr, opts.entry, opts.workers, baseEnv as Record<string, string>)
144
163
  }
145
- // Bun Workers intercept SIGINT before Rust's ctrl_c() handler fires.
146
- // Install a JS-level handler so the process actually exits on SIGINT.
147
- process.on('SIGINT', () => process.exit(0))
164
+ // Bun Workers intercept SIGINT before Rust's ctrl_c() handler fires, so a
165
+ // JS-level handler owns process exit. GRACEFUL DRAIN: stop accepting new
166
+ // connections, let in-flight renders/streams finish (bounded by the drain
167
+ // timeout), THEN exit — so a slow stream isn't cut off mid-response (the
168
+ // teardown that produced spurious `split_meta` errors under load). A second
169
+ // signal (impatient Ctrl-C) forces an immediate exit. SIGTERM too, for
170
+ // container orchestrators (Docker/k8s send SIGTERM on stop).
171
+ let draining = false
172
+ const drainTimeoutMs = Number(process.env.BRUST_DRAIN_TIMEOUT_MS) || 10_000
173
+ const gracefulExit = (code: number) => {
174
+ if (draining) process.exit(code)
175
+ draining = true
176
+ ;(native as any)
177
+ .beginDrain(drainTimeoutMs)
178
+ .catch(() => {})
179
+ .finally(() => process.exit(0))
180
+ }
181
+ process.on('SIGINT', () => gracefulExit(130))
182
+ process.on('SIGTERM', () => gracefulExit(143))
148
183
  await (native as any).untilReady(opts.bootTimeoutMs ?? 5000)
149
184
  await (native as any).untilShutdown()
150
185
  },
@@ -247,8 +282,8 @@ export const brust = {
247
282
  // (or any ArrayBuffer the worker keeps rooted) — Rust captures the backing-store
248
283
  // pointer once here and reuses it for every render call. The buffer is held alive
249
284
  // by the worker's module scope; do not let it go out of scope.
250
- registerRenderer(buf: Uint8Array, fn: RenderFn): number {
251
- return (native as any).registerRenderer(buf, fn)
285
+ registerRenderer(buf: Uint8Array, slots: number, fn: RenderFn): number {
286
+ return (native as any).registerRenderer(buf, slots, fn)
252
287
  },
253
288
 
254
289
  /**
@@ -337,11 +372,13 @@ export const brust = {
337
372
  console.log(`[brust] main: spawning ${workers} worker threads`)
338
373
  if (cacheMaxEntries !== undefined) this.configureCache({ maxEntries: cacheMaxEntries })
339
374
 
340
- // Component CSS pipeline. Must run BEFORE buildIslands so the Bun.plugin
341
- // is active during island bundling otherwise Bun's default loader would
342
- // see .module.css imports as separate asset outputs, producing duplicate-
343
- // output-path errors when an island and its module share a basename
344
- // (e.g. Counter.tsx + Counter.module.css → both want to emit Counter.js).
375
+ // Component CSS pipeline. Build the manifest + capture the loader plugin
376
+ // BEFORE buildIslands and pass it EXPLICITLY below (global Bun.plugin()
377
+ // does NOT reach Bun.build) otherwise Bun's default loader emits
378
+ // .module.css as a separate asset and collides when an island and its
379
+ // module share a basename (Counter.tsx + Counter.module.css → both
380
+ // want to emit Counter.js).
381
+ const cssBuildPlugins: import('bun').BunPlugin[] = []
345
382
  {
346
383
  const { readComponentCssManifest } = await import('./css/manifest.ts')
347
384
  const { cssLoaderPlugin } = await import('./css/component-loader.ts')
@@ -355,9 +392,19 @@ export const brust = {
355
392
  const scan = await scanCssImports(scanRoot)
356
393
  if (scan.size > 0) {
357
394
  const { buildComponentCss } = await import('./css/component-build.ts')
395
+ const { scanImports } = await import('./cli/native-routes-emit.ts')
396
+ const routesFile = path.join(scanRoot, 'routes.tsx')
397
+ const idents = existsSync(routesFile)
398
+ ? scanImports(routesFile)
399
+ : new Map<string, string>()
358
400
  const routeForCss = opts.routes.map((r) => ({
359
401
  fullPath: r.fullPath,
360
- componentSource: path.join(scanRoot, 'routes.tsx'),
402
+ // Resolve the route's component chain (layout → leaf) to source
403
+ // files; computeRouteChunks BFS-walks each subtree for CSS deps.
404
+ componentSources: r.chain
405
+ .map((node) => (node as { Component?: { name?: string } }).Component?.name)
406
+ .map((name) => (name ? idents.get(name) : undefined))
407
+ .filter((p): p is string => typeof p === 'string'),
361
408
  }))
362
409
  const cssOutDir = path.join(process.cwd(), '.brust', 'css')
363
410
  manifest = await buildComponentCss({
@@ -373,7 +420,9 @@ export const brust = {
373
420
  }
374
421
 
375
422
  if (manifest) {
376
- Bun.plugin(cssLoaderPlugin(manifest))
423
+ const plugin = cssLoaderPlugin(manifest)
424
+ Bun.plugin(plugin) // runtime/SSR resolution of .module.css in the worker isolate
425
+ cssBuildPlugins.push(plugin) // explicit resolution for island Bun.build
377
426
  for (const [routePath, hrefs] of Object.entries(manifest.routeChunks)) {
378
427
  configureCssHrefsForRoute(routePath, hrefs)
379
428
  }
@@ -394,7 +443,7 @@ export const brust = {
394
443
  const islandMap = scanIslandChunks(routesPath)
395
444
  let islandsDir: string | undefined
396
445
  if (islandMap.size > 0) {
397
- const islands = await build(islandMap)
446
+ const islands = await build(islandMap, { plugins: cssBuildPlugins })
398
447
  islandsDir = islands.outDir
399
448
  console.log(`[brust] main: built ${islands.islandCount} island chunk(s)`)
400
449
  }
@@ -605,9 +654,17 @@ export const brust = {
605
654
  if (scan.size === 0) return
606
655
  const { buildComponentCss } = await import('./css/component-build.ts')
607
656
  const { cssLoaderPlugin } = await import('./css/component-loader.ts')
657
+ const { scanImports } = await import('./cli/native-routes-emit.ts')
658
+ const routesFile = pathModule.join(scanRoot, 'routes.tsx')
659
+ const idents = existsSync(routesFile)
660
+ ? scanImports(routesFile)
661
+ : new Map<string, string>()
608
662
  const routeForCss = opts.routes.map((r) => ({
609
663
  fullPath: r.fullPath,
610
- componentSource: pathModule.join(scanRoot, 'routes.tsx'),
664
+ componentSources: r.chain
665
+ .map((node) => (node as { Component?: { name?: string } }).Component?.name)
666
+ .map((name) => (name ? idents.get(name) : undefined))
667
+ .filter((p): p is string => typeof p === 'string'),
611
668
  }))
612
669
  const cssOutDir = pathModule.join(process.cwd(), '.brust', 'css')
613
670
  const manifest = await buildComponentCss({
@@ -709,9 +766,10 @@ export const brust = {
709
766
  }
710
767
 
711
768
  // Worker: register the component CSS Bun.plugin so .module.css imports
712
- // resolve to the same hash map main saw. (Workers don't seed
713
- // configureCssHrefsForRoute that's a renderer concern only on the
714
- // main thread.)
769
+ // resolve to the same hash map main saw, AND seed the per-route CSS hrefs.
770
+ // The streaming renderer (render/stream.ts) runs HERE in the worker and
771
+ // reads getCssHrefsForRoute to inject the <link> before </head> — so the
772
+ // route→chunk map must be configured in the worker, not just on main.
715
773
  {
716
774
  const { readComponentCssManifest } = await import('./css/manifest.ts')
717
775
  const { cssLoaderPlugin } = await import('./css/component-loader.ts')
@@ -721,10 +779,17 @@ export const brust = {
721
779
  const manifest = await readComponentCssManifest(manifestPath)
722
780
  if (manifest) {
723
781
  Bun.plugin(cssLoaderPlugin(manifest))
782
+ for (const [routePath, hrefs] of Object.entries(manifest.routeChunks)) {
783
+ configureCssHrefsForRoute(routePath, hrefs)
784
+ }
724
785
  }
725
786
  }
726
787
 
727
- const sab = new SharedArrayBuffer(opts.sabBytes ?? 256 * 1024)
788
+ // Render slots for this worker (set in serve() via the worker env). The
789
+ // SAB scales with the slot count so each slot's disjoint sub-region keeps
790
+ // the single-slot capacity; at K=1 this is byte-identical to before.
791
+ const renderSlots = Math.max(1, Number(process.env.BRUST_RENDER_SLOTS ?? 1) || 1)
792
+ const sab = new SharedArrayBuffer((opts.sabBytes ?? 256 * 1024) * renderSlots)
728
793
  const view = new Uint8Array(sab)
729
794
 
730
795
  let mcpManifest: import('./mcp/manifest.ts').McpManifest | null
@@ -766,8 +831,9 @@ export const brust = {
766
831
  actions: endpoints,
767
832
  getWorkerId: () => wid,
768
833
  mcp: mcpServer,
834
+ slots: renderSlots,
769
835
  })
770
- wid = this.registerRenderer(view, renderer)
836
+ wid = this.registerRenderer(view, renderSlots, renderer)
771
837
  }
772
838
  },
773
839
  }
@@ -84,6 +84,20 @@ function registerTrigger(el: HTMLElement, trigger: Trigger, fire: () => void): v
84
84
  // Exported for unit testing — the public entry is hydrateMarkersIn, but the
85
85
  // createRoot/hydrateRoot auto-detect branch is cleanest to assert by driving
86
86
  // hydrateOne directly (the `load` trigger fires it as a detached microtask).
87
+ // id → content-addressed chunk URL, emitted by buildIslands as _islands.js.
88
+ // Loaded once (memoized). On any failure the map is empty and resolution falls
89
+ // back to the legacy `/_brust/islands/<id>.js` URL, so an older build still works.
90
+ let chunkMapPromise: Promise<Record<string, string>> | null = null
91
+ function islandChunkMap(): Promise<Record<string, string>> {
92
+ if (!chunkMapPromise) {
93
+ const url = '/_brust/islands/_islands.js' // variable specifier → runtime import, not bundled
94
+ chunkMapPromise = import(url)
95
+ .then((m) => (m.default ?? {}) as Record<string, string>)
96
+ .catch(() => ({}))
97
+ }
98
+ return chunkMapPromise
99
+ }
100
+
87
101
  export async function hydrateOne(el: HTMLElement): Promise<void> {
88
102
  const id = el.getAttribute('data-brust-island')
89
103
  if (!id) return
@@ -96,7 +110,8 @@ export async function hydrateOne(el: HTMLElement): Promise<void> {
96
110
  return
97
111
  }
98
112
  try {
99
- const mod = await import(`/_brust/islands/${id}.js`)
113
+ const url = (await islandChunkMap())[id] ?? `/_brust/islands/${id}.js`
114
+ const mod = await import(url)
100
115
  const Component = (mod.default ?? mod) as React.ComponentType<Record<string, unknown>>
101
116
  if (typeof Component !== 'function') {
102
117
  console.error(`[brust] island "${id}": chunk has no default-exported component`)
@@ -1,6 +1,8 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { readFileSync } from 'node:fs'
2
- import { mkdir, rm } from 'node:fs/promises'
3
- import { isAbsolute, resolve } from 'node:path'
3
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
4
+ import { isAbsolute, relative, resolve } from 'node:path'
5
+ import type { BunPlugin } from 'bun'
4
6
  import { scanImports } from '../cli/native-routes-emit.ts'
5
7
 
6
8
  export interface IslandsBuildResult {
@@ -8,11 +10,20 @@ export interface IslandsBuildResult {
8
10
  outDir: string
9
11
  /** Number of island chunks emitted (excludes runtime + bootstrap). */
10
12
  islandCount: number
13
+ /** id → content-addressed chunk URL (`/_brust/islands/<id>_<hash>.js`). Also
14
+ * written to `_islands.js` for the client bootstrap to resolve at runtime. */
15
+ chunks: Record<string, string>
11
16
  }
12
17
 
13
18
  export interface BuildIslandsOptions {
14
19
  /** Override the output directory. Default: `<cwd>/.brust/islands`. */
15
20
  outDir?: string
21
+ /** Build plugins passed straight to `Bun.build` for the per-island chunks.
22
+ * Needed for the component-CSS loader: global `Bun.plugin()` registrations do
23
+ * NOT apply to `Bun.build`, so an island that `import`s a `.module.css` must
24
+ * get the resolver here or Bun emits the CSS as a separate asset and collides
25
+ * on the output filename (X.module.css + X.tsx → both X.js). */
26
+ plugins?: BunPlugin[]
16
27
  }
17
28
 
18
29
  /** Scan a routes entry file for `<Island component={X} />` usage and derive the
@@ -27,6 +38,16 @@ export interface BuildIslandsOptions {
27
38
  * 4. Dedup islands that reuse the same component+path; throw on two different
28
39
  * files whose island components share a name (ids must be app-unique).
29
40
  */
41
+ /** Content-addressed island chunk basename = `<Name>_<8hex(sha256 cwd-relative
42
+ * source path)>`. Stable + app-unique (mirrors the directive chunk scheme) so
43
+ * the URL is content-busting-stable; the bootstrap resolves the plain marker id
44
+ * to this via the `_islands.js` map. */
45
+ export function islandChunkBasename(name: string, absSourcePath: string): string {
46
+ const rel = relative(process.cwd(), absSourcePath).replaceAll('\\', '/')
47
+ const hash = createHash('sha256').update(rel).digest('hex').slice(0, 8)
48
+ return `${name}_${hash}`
49
+ }
50
+
30
51
  export function scanIslandChunks(routesEntryFile: string): Map<string, string> {
31
52
  const chunks = new Map<string, string>()
32
53
  const visited = new Set<string>()
@@ -109,8 +130,15 @@ export async function buildIslands(
109
130
  // 2. react-dom/client (react external; consumes _react.js via importmap).
110
131
  await buildOne([`${entriesDir}/react-dom.ts`], outDir, '_react-dom.js', ['react'])
111
132
 
112
- // 3. Per-island chunks (all 3 runtime specifiers external).
133
+ // 3. Per-island chunks (all 3 runtime specifiers external). Island sources may
134
+ // `import styles from './X.module.css'`, so the component-CSS plugins resolve
135
+ // those imports to the scoped name map (otherwise Bun emits the CSS as an asset).
113
136
  const externals = ['react', 'react/jsx-runtime', 'react-dom/client']
137
+ const plugins = options.plugins ?? []
138
+ // id (plain Component name) → content-addressed chunk URL. The chunk filename
139
+ // is `<Name>_<hash>.js`; the data-brust-island marker stays the plain name, so
140
+ // the bootstrap resolves it to the hashed chunk via the `_islands.js` map below.
141
+ const chunks: Record<string, string> = {}
114
142
  let count = 0
115
143
  for (const [id, entry] of islands) {
116
144
  if (!isValidIslandId(id)) {
@@ -119,15 +147,26 @@ export async function buildIslands(
119
147
  `allowed: [A-Za-z0-9_-]+ (matches the server's filename safety check)`,
120
148
  )
121
149
  }
122
- await buildOne([entry], outDir, `${id}.js`, externals)
150
+ const file = `${islandChunkBasename(id, entry)}.js`
151
+ await buildOne([entry], outDir, file, externals, plugins)
152
+ chunks[id] = `/_brust/islands/${file}`
123
153
  count++
124
154
  }
125
155
 
156
+ // id → chunk URL map, served at /_brust/islands/_islands.js. The bootstrap
157
+ // loads it once and resolves a marker's plain id to its hashed chunk (with a
158
+ // legacy `/_brust/islands/<id>.js` fallback). ESM default export.
159
+ await writeFile(
160
+ resolve(outDir, '_islands.js'),
161
+ `export default ${JSON.stringify(chunks)}\n`,
162
+ 'utf-8',
163
+ )
164
+
126
165
  // 4. Bootstrap (react + react-dom/client external; uses importmap).
127
166
  const bootstrapSrc = resolve(import.meta.dir, 'bootstrap.ts')
128
167
  await buildOne([bootstrapSrc], outDir, '_bootstrap.js', externals)
129
168
 
130
- return { outDir, islandCount: count }
169
+ return { outDir, islandCount: count, chunks }
131
170
  }
132
171
 
133
172
  async function buildOne(
@@ -135,6 +174,7 @@ async function buildOne(
135
174
  outdir: string,
136
175
  naming: string,
137
176
  external: string[],
177
+ plugins: BunPlugin[] = [],
138
178
  ): Promise<void> {
139
179
  const result = await Bun.build({
140
180
  entrypoints,
@@ -144,6 +184,7 @@ async function buildOne(
144
184
  target: 'browser',
145
185
  external,
146
186
  minify: true,
187
+ plugins,
147
188
  define: {
148
189
  'process.env.NODE_ENV': '"production"',
149
190
  },
@@ -1,4 +1,4 @@
1
- import { createElement, type ComponentType, type ReactNode } from 'react'
1
+ import { createContext, createElement, useContext, type ComponentType, type ReactNode } from 'react'
2
2
  import type { IsrConfig } from './isr-jsx.ts'
3
3
 
4
4
  /** Triggers that activate hydration of an island marker. */
@@ -44,18 +44,30 @@ export interface IslandProps<P> {
44
44
  isr?: IsrConfig
45
45
  }
46
46
 
47
- /** Module-scope flag flipped by every `<Island>` render. `makeRenderer`
48
- * reads + resets it once per render to decide whether to prepend the
49
- * importmap + bootstrap script. */
50
- let __used = false
47
+ /** Per-render box tracking whether any `<Island>` rendered. Created fresh per
48
+ * render and provided through {@link IslandUsedContext}; the renderer reads
49
+ * `box.used` once at the end to decide whether to prepend the importmap +
50
+ * bootstrap script.
51
+ *
52
+ * This is REQUEST-SCOPED (not a module-scope flag) so concurrent renders in one
53
+ * isolate (renderSlots>1) never cross-contaminate: React restores each render's
54
+ * context stack across Suspense resumption, so an interleaved peer setting its
55
+ * own box never flips ours. A module `let` could not give that guarantee. */
56
+ export interface IslandUsedBox {
57
+ used: boolean
58
+ }
51
59
 
52
- /** Internal flipped by Island, read by makeRenderer. */
53
- export function consumeIslandUsedFlag(): boolean {
54
- const v = __used
55
- __used = false
56
- return v
60
+ /** Fresh per-render box. */
61
+ export function createIslandUsedBox(): IslandUsedBox {
62
+ return { used: false }
57
63
  }
58
64
 
65
+ /** Carries the per-render {@link IslandUsedBox} down to every `<Island>`. The
66
+ * renderer wraps the tree in a Provider with a fresh box; an `<Island>` rendered
67
+ * with no Provider (e.g. a standalone `renderToString` outside the React render
68
+ * path) reads `null` and is a no-op. */
69
+ export const IslandUsedContext = createContext<IslandUsedBox | null>(null)
70
+
59
71
  // Constraint is `object`, NOT `Record<string, unknown>`: a TS `interface` has no
60
72
  // implicit index signature, so it does NOT satisfy `extends Record<string, unknown>`
61
73
  // — that made `P` fall back to the constraint and rejected any component whose
@@ -67,7 +79,10 @@ export function Island<P extends object>({
67
79
  props,
68
80
  hydrate = 'load',
69
81
  }: IslandProps<P>): ReactNode {
70
- __used = true
82
+ // Request-scoped: flip THIS render's box (see IslandUsedContext). No-op if
83
+ // rendered without a Provider (standalone renderToString).
84
+ const usedBox = useContext(IslandUsedContext)
85
+ if (usedBox) usedBox.used = true
71
86
  const resolvedId = Component.name
72
87
  if (!resolvedId) {
73
88
  throw new Error(
@@ -4,7 +4,7 @@
4
4
  import { renderToPipeableStream, renderToString } from 'react-dom/server.node'
5
5
  import { createElement, type ReactNode, type ComponentType } from 'react'
6
6
  import { Writable } from 'node:stream'
7
- import { consumeIslandUsedFlag } from '../islands/island.tsx'
7
+ import { IslandUsedContext, createIslandUsedBox } from '../islands/island.tsx'
8
8
  import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
  import { injectCssLink } from './inject-css-link.ts'
10
10
  import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
@@ -15,11 +15,26 @@ import { getDevClientSnippet } from '../dev/inject.ts'
15
15
 
16
16
  export interface RenderBranchStreamingArgs {
17
17
  element: ReactNode
18
+ /** The render's SAB sub-view (whole buffer at slots=1). All chunk encodes
19
+ * write at offset 0 of this view → automatically the slot's base. */
18
20
  view: Uint8Array
21
+ /** The render slot index, passed to every napi.renderChunk* call so Rust
22
+ * reads/writes the matching SAB sub-region. Default 0 (single-slot path). */
23
+ slot?: number
19
24
  workerId: bigint
20
25
  napi: {
21
- renderChunk: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
22
- renderChunkFinal: (workerId: bigint, len: number, sabBytes: Uint8Array) => Promise<void>
26
+ renderChunk: (
27
+ workerId: bigint,
28
+ slot: number,
29
+ len: number,
30
+ sabBytes: Uint8Array,
31
+ ) => Promise<void>
32
+ renderChunkFinal: (
33
+ workerId: bigint,
34
+ slot: number,
35
+ len: number,
36
+ sabBytes: Uint8Array,
37
+ ) => Promise<void>
23
38
  }
24
39
  errorBoundary: ComponentType<{ error: Error }>
25
40
  /** Status for the successful (non-error) render. Default 200. Used by
@@ -97,15 +112,18 @@ function concatBuffers(parts: Uint8Array[], withBootstrap: boolean): Uint8Array
97
112
 
98
113
  export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<void> {
99
114
  const { element, view, workerId, napi, errorBoundary } = args
115
+ const slot = args.slot ?? 0
100
116
  const successStatus = args.status ?? 200
101
117
  const extraHeaders = args.headers ?? {}
102
118
 
103
- // Reset the islands flag at the start of every render the streaming path
104
- // (which doesn't read the flag at the end) would otherwise leak its setting
105
- // to the next render. consumeIslandUsedFlag() reads-and-resets so calling
106
- // here is safe; the actual read for the buffering path happens at _final
107
- // time and sees only flips made during THIS render's React work.
108
- consumeIslandUsedFlag()
119
+ // Request-scoped islands signal: a fresh box per render, provided to every
120
+ // <Island> through IslandUsedContext. The buffering path reads `box.used` at
121
+ // _final to decide whether to prepend the importmap + bootstrap. Per-render
122
+ // (not a module flag) so concurrent renders in one isolate (renderSlots>1)
123
+ // never cross-contaminate React restores each render's context across
124
+ // Suspense resumption. No start-of-render reset needed: the box starts false.
125
+ const islandUsedBox = createIslandUsedBox()
126
+ const renderTree = createElement(IslandUsedContext.Provider, { value: islandUsedBox }, element)
109
127
 
110
128
  return new Promise<void>((resolve, reject) => {
111
129
  let finalSent = false
@@ -113,7 +131,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
113
131
  if (finalSent) return
114
132
  finalSent = true
115
133
  try {
116
- await napi.renderChunk(workerId, 0, view)
134
+ await napi.renderChunk(workerId, slot, 0, view)
117
135
  resolve()
118
136
  } catch (e) {
119
137
  reject(e)
@@ -139,7 +157,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
139
157
  // Wait for the header chunk to be flushed before sending body chunks.
140
158
  if (headerSent) await headerSent
141
159
  const len = encodeBodyChunk(view, chunk)
142
- await napi.renderChunk(workerId, len, view)
160
+ await napi.renderChunk(workerId, slot, len, view)
143
161
  }
144
162
  cb()
145
163
  } catch (e) {
@@ -149,7 +167,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
149
167
  async final(cb: (e?: Error | null) => void) {
150
168
  try {
151
169
  if (mode === 'buffering') {
152
- const islandsUsed = consumeIslandUsedFlag()
170
+ const islandsUsed = islandUsedBox.used
153
171
  let body = concatBuffers(buffer, islandsUsed)
154
172
  const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
155
173
  body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
@@ -162,7 +180,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
162
180
  headers: extraHeaders,
163
181
  })
164
182
  const len = encodeFirstChunk(view, meta, body)
165
- await napi.renderChunkFinal(workerId, len, view)
183
+ await napi.renderChunkFinal(workerId, slot, len, view)
166
184
  finalSent = true
167
185
  resolve()
168
186
  mode = 'done'
@@ -184,7 +202,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
184
202
  let allReadyFired = false
185
203
  let stream: ReturnType<typeof renderToPipeableStream>
186
204
  try {
187
- stream = renderToPipeableStream(element, {
205
+ stream = renderToPipeableStream(renderTree, {
188
206
  onShellReady() {
189
207
  // React fires onAllReady synchronously AFTER onShellReady in the
190
208
  // same microtask queue flush when there is no pending Suspense.
@@ -247,7 +265,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
247
265
  ;(async () => {
248
266
  try {
249
267
  const len = encodeFirstChunk(view, meta, flushed)
250
- await napi.renderChunk(workerId, len, view)
268
+ await napi.renderChunk(workerId, slot, len, view)
251
269
  resolveHeader()
252
270
  } catch (e) {
253
271
  rejectHeader(e)
@@ -267,7 +285,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
267
285
  ;(async () => {
268
286
  try {
269
287
  const len = encodeFirstChunk(view, meta, encoder.encode(html))
270
- await napi.renderChunkFinal(workerId, len, view)
288
+ await napi.renderChunkFinal(workerId, slot, len, view)
271
289
  finalSent = true
272
290
  resolve()
273
291
  } catch (e) {
@@ -285,7 +303,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
285
303
  ;(async () => {
286
304
  try {
287
305
  const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
288
- await napi.renderChunkFinal(workerId, len, view)
306
+ await napi.renderChunkFinal(workerId, slot, len, view)
289
307
  finalSent = true
290
308
  resolve()
291
309
  } catch (e) {
@@ -307,7 +325,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
307
325
  ;(async () => {
308
326
  try {
309
327
  const len = encodeFirstChunk(view, meta, encoder.encode('Internal Server Error'))
310
- await napi.renderChunkFinal(workerId, len, view)
328
+ await napi.renderChunkFinal(workerId, slot, len, view)
311
329
  finalSent = true
312
330
  resolve()
313
331
  } catch (ee) {