brustjs 0.1.45-alpha → 0.1.47-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.
@@ -201,7 +201,7 @@ export async function buildIslands(
201
201
  return { outDir, islandCount: count, chunks }
202
202
  }
203
203
 
204
- async function buildOne(
204
+ export async function buildOne(
205
205
  entrypoints: string[],
206
206
  outdir: string,
207
207
  naming: string,
@@ -0,0 +1,198 @@
1
+ // runtime/islands/fallback.ts — client takeover runtime for `fallback: 'client'`
2
+ // SSG routes (Phase B of the ssg-dynamic-params design). A non-prerendered
3
+ // dynamic path serves a placeholder shell whose leaf is
4
+ // `<div data-brust-fallback-root data-brust-fallback="<pattern>">…</div>`;
5
+ // this module matches the pattern against the live pathname, loads the
6
+ // route's fallback chunk (`Component` + `clientLoader`), and client-renders
7
+ // the real page into the container.
8
+ //
9
+ // Imported BY bootstrap.ts (Task B6): react / react-dom/client are static
10
+ // imports on purpose — bootstrap bundles them as externals resolved via the
11
+ // importmap, and this module rides the same bundle. Import-safe without a
12
+ // DOM: nothing touches document/location at module top level.
13
+
14
+ import { createElement } from 'react'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+
17
+ export interface FallbackEntry {
18
+ pattern: string
19
+ doc: string
20
+ payload: string
21
+ chunk: string
22
+ }
23
+
24
+ // React roots created by takeover, keyed by their container, so navigation
25
+ // can unmount them before replacing the DOM (a detached live root keeps
26
+ // React's scheduler posting work to dead nodes — hung tab). Same discipline
27
+ // as bootstrap's islandRoots.
28
+ const fallbackRoots = new WeakMap<HTMLElement, Root>()
29
+
30
+ /** Match a route pattern (`/blog/{slug}`) against a concrete pathname.
31
+ * Segment-wise: lengths must agree, `{name}` segments capture one
32
+ * decodeURIComponent-decoded segment (malformed percent-encoding falls back
33
+ * to the raw segment; an EMPTY segment never matches a capture), everything
34
+ * else must compare literally. Returns the captured params, or null. */
35
+ export function matchFallback(pattern: string, pathname: string): Record<string, string> | null {
36
+ const pat = pattern.split('/')
37
+ // Route-table patterns never carry a trailing slash; static hosts (CF Pages
38
+ // directory rewrites among them) may append one to the live pathname —
39
+ // normalize so '/blog/foo/' still matches '/blog/{slug}'.
40
+ const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
41
+ const path = normalized.split('/')
42
+ if (pat.length !== path.length) return null
43
+ const params: Record<string, string> = {}
44
+ for (let i = 0; i < pat.length; i++) {
45
+ const p = pat[i] as string
46
+ const seg = path[i] as string
47
+ if (p.startsWith('{') && p.endsWith('}') && p.length > 2) {
48
+ if (seg === '') return null // empty capture → no match
49
+ let value: string
50
+ try {
51
+ value = decodeURIComponent(seg)
52
+ } catch {
53
+ value = seg // malformed percent-encoding → raw segment
54
+ }
55
+ params[p.slice(1, -1)] = value
56
+ } else if (p !== seg) {
57
+ return null
58
+ }
59
+ }
60
+ return params
61
+ }
62
+
63
+ let manifestPromise: Promise<FallbackEntry[]> | null = null
64
+
65
+ function isFallbackEntry(e: unknown): e is FallbackEntry {
66
+ if (typeof e !== 'object' || e === null) return false
67
+ const r = e as Record<string, unknown>
68
+ return (
69
+ typeof r.pattern === 'string' && typeof r.payload === 'string' && typeof r.chunk === 'string'
70
+ )
71
+ }
72
+
73
+ /** Fetch the static-export fallback manifest (`/_brust/routes.json`).
74
+ * Memoized for the document's lifetime — including the EMPTY result: a site
75
+ * with no fallback routes (404 / invalid / no `fallbacks` array) resolves []
76
+ * once and never refetches per navigation. Entries missing a string
77
+ * pattern/payload/chunk are filtered out. */
78
+ export function loadFallbackManifest(): Promise<FallbackEntry[]> {
79
+ if (!manifestPromise) {
80
+ manifestPromise = fetch('/_brust/routes.json')
81
+ .then(async (resp) => {
82
+ if (!resp.ok) return []
83
+ const json = (await resp.json()) as { fallbacks?: unknown }
84
+ if (!Array.isArray(json.fallbacks)) return []
85
+ return json.fallbacks.filter(isFallbackEntry)
86
+ })
87
+ .catch(() => [])
88
+ }
89
+ return manifestPromise
90
+ }
91
+
92
+ function takeoverError(container: HTMLElement, message: string, err?: unknown): void {
93
+ if (err === undefined) console.error(`[brust] fallback: ${message}`)
94
+ else console.error(`[brust] fallback: ${message}`, err)
95
+ container.setAttribute('data-brust-fallback-error', '1')
96
+ }
97
+
98
+ /** Client-render a fallback route into its placeholder container.
99
+ *
100
+ * Reads the route pattern from `data-brust-fallback`, derives params from
101
+ * `location.pathname`, dynamic-imports the route's fallback chunk from the
102
+ * manifest, runs `clientLoader({ params, path })`, then replaces the
103
+ * placeholder children with `createRoot(container).render(<Component
104
+ * params path data />)` — the server prop shape minus `req`/`workerId`
105
+ * (those don't exist in a browser).
106
+ *
107
+ * On any failure (mismatched pattern, missing manifest entry, bad chunk,
108
+ * clientLoader throw) the container KEEPS its placeholder, gets a
109
+ * `data-brust-fallback-error` attribute, and the error is logged —
110
+ * author-level recovery is a catch inside clientLoader returning
111
+ * error-shaped data.
112
+ *
113
+ * CONTRACT: the caller must unmount any island roots inside the container
114
+ * FIRST (a placeholder may carry islands) — bootstrap's unmountIslandsIn
115
+ * does this before calling takeover. The call lives there, not here, to
116
+ * avoid a circular import between fallback.ts and bootstrap.ts.
117
+ *
118
+ * `signal`: a superseding navigation aborts it. Checked after every await and
119
+ * BEFORE createRoot — without that gate, a takeover resumed after the next
120
+ * navigation's swap would mount a React root into a DETACHED container that
121
+ * unmountFallbackRootsIn can never find again (hung-tab hazard). */
122
+ export async function takeover(container: HTMLElement, signal?: AbortSignal): Promise<void> {
123
+ const pattern = container.getAttribute('data-brust-fallback')
124
+ if (pattern === null) {
125
+ console.error('[brust] fallback: container has no data-brust-fallback attribute')
126
+ return
127
+ }
128
+ const path = location.pathname
129
+ const params = matchFallback(pattern, path)
130
+ if (params === null) {
131
+ takeoverError(container, `pattern "${pattern}" does not match pathname "${path}"`)
132
+ return
133
+ }
134
+ const manifest = await loadFallbackManifest()
135
+ const entry = manifest.find((e) => e.pattern === pattern)
136
+ if (!entry) {
137
+ takeoverError(container, `no manifest entry for pattern "${pattern}"`)
138
+ return
139
+ }
140
+ let Component: React.ComponentType<Record<string, unknown>>
141
+ let clientLoader: (args: { params: Record<string, string>; path: string }) => unknown
142
+ try {
143
+ // Variable specifier → stays a runtime dynamic import in the bundle
144
+ // (same trick as bootstrap's islandChunkMap).
145
+ const chunkUrl = entry.chunk
146
+ const mod = (await import(chunkUrl)) as Record<string, unknown>
147
+ if (typeof mod.Component !== 'function' || typeof mod.clientLoader !== 'function') {
148
+ takeoverError(container, `chunk "${entry.chunk}" must export Component and clientLoader`)
149
+ return
150
+ }
151
+ Component = mod.Component as React.ComponentType<Record<string, unknown>>
152
+ clientLoader = mod.clientLoader as typeof clientLoader
153
+ } catch (e) {
154
+ takeoverError(container, `failed to load chunk "${entry.chunk}"`, e)
155
+ return
156
+ }
157
+ if (signal?.aborted) return
158
+ let data: unknown
159
+ try {
160
+ data = await clientLoader({ params, path })
161
+ } catch (e) {
162
+ takeoverError(container, `clientLoader threw for "${pattern}"`, e)
163
+ return
164
+ }
165
+ // Superseded while the data was loading → the newer navigation owns the
166
+ // DOM; mounting here would render into a detached container.
167
+ if (signal?.aborted) return
168
+ // Success: drop the placeholder, then mount a fresh client root.
169
+ while (container.firstChild) container.removeChild(container.firstChild)
170
+ const root = createRoot(container)
171
+ root.render(createElement(Component, { params, path, data }))
172
+ fallbackRoots.set(container, root)
173
+ }
174
+
175
+ /** Unmount any takeover roots inside `scope`. Must run BEFORE removing or
176
+ * replacing their DOM (same reason as bootstrap's unmountIslandsIn, which
177
+ * delegates here in Task B6 — islandRoots and fallbackRoots stay
178
+ * module-private to their owners). */
179
+ export function unmountFallbackRootsIn(scope: ParentNode): void {
180
+ const containers = scope.querySelectorAll<HTMLElement>('[data-brust-fallback-root]')
181
+ for (const el of Array.from(containers)) {
182
+ const root = fallbackRoots.get(el)
183
+ if (root) {
184
+ try {
185
+ root.unmount()
186
+ } catch (e) {
187
+ console.warn('[brust] fallback root unmount failed', e)
188
+ }
189
+ fallbackRoots.delete(el)
190
+ }
191
+ }
192
+ }
193
+
194
+ // Test-only: drop the memoized manifest so the next loadFallbackManifest
195
+ // refetches. (fallbackRoots is a WeakMap — nothing to clear.)
196
+ export function __resetFallbackForTest(): void {
197
+ manifestPromise = null
198
+ }
@@ -0,0 +1,108 @@
1
+ // runtime/islands/page-cache.ts — in-memory cache for SPA navigation payloads
2
+ // (/_brust/page/*) plus the fetch/dedupe machinery behind hover prefetch.
3
+ // Browser-only at call time (fetch/navigator), but import-safe anywhere.
4
+ //
5
+ // Lifetime: one document load. A full reload (dev rebuild WS, full-document
6
+ // fallback, hard refresh) drops the whole cache with the JS heap, so dev never
7
+ // serves stale pages across rebuilds.
8
+
9
+ export interface PagePayload {
10
+ html: string
11
+ title: string
12
+ store?: Record<string, Record<string, unknown>>
13
+ }
14
+
15
+ /** Bound the cache so a long session can't grow memory unbounded (LRU evict). */
16
+ export const PAGE_CACHE_MAX = 64
17
+ /** Forward navigations reuse an entry for this long; back/forward (popstate)
18
+ * passes Infinity and reuses any entry regardless of age (bfcache-like). */
19
+ export const PAGE_STALE_MS = 5 * 60_000
20
+
21
+ interface Entry {
22
+ payload: PagePayload
23
+ at: number
24
+ }
25
+
26
+ const cache = new Map<string, Entry>()
27
+ const inflight = new Map<string, Promise<PagePayload>>()
28
+
29
+ export function pageCacheKey(url: URL): string {
30
+ return url.pathname + url.search
31
+ }
32
+
33
+ export function getCachedPage(key: string, maxAgeMs: number = PAGE_STALE_MS): PagePayload | null {
34
+ const e = cache.get(key)
35
+ if (!e) return null
36
+ if (Date.now() - e.at > maxAgeMs) {
37
+ cache.delete(key)
38
+ return null
39
+ }
40
+ // LRU bump: Map iteration order is insertion order, so re-inserting moves
41
+ // this key to the "most recently used" end.
42
+ cache.delete(key)
43
+ cache.set(key, e)
44
+ return e.payload
45
+ }
46
+
47
+ export function setCachedPage(key: string, payload: PagePayload): void {
48
+ cache.delete(key)
49
+ cache.set(key, { payload, at: Date.now() })
50
+ if (cache.size > PAGE_CACHE_MAX) {
51
+ const oldest = cache.keys().next().value
52
+ if (oldest !== undefined) cache.delete(oldest)
53
+ }
54
+ }
55
+
56
+ /** Fetch a nav payload and cache it on success. Shared by navigate (direct,
57
+ * abortable) and prefetch (deduped, signal-less). */
58
+ export async function fetchPagePayload(url: URL, signal?: AbortSignal): Promise<PagePayload> {
59
+ const resp = await fetch(`/_brust/page${url.pathname}${url.search}`, {
60
+ signal,
61
+ headers: { Accept: 'application/json' },
62
+ })
63
+ if (!resp.ok) throw new Error(`navigation: status ${resp.status}`)
64
+ const payload = (await resp.json()) as PagePayload
65
+ setCachedPage(pageCacheKey(url), payload)
66
+ return payload
67
+ }
68
+
69
+ /** @internal — the in-flight prefetch for `key`, if any. navigate() awaits it
70
+ * instead of double-fetching when a click lands mid-prefetch. */
71
+ export function inflightPage(key: string): Promise<PagePayload> | undefined {
72
+ return inflight.get(key)
73
+ }
74
+
75
+ function prefetchAllowed(): boolean {
76
+ if (typeof navigator === 'undefined') return false
77
+ const conn = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } })
78
+ .connection
79
+ if (conn?.saveData) return false
80
+ if (typeof conn?.effectiveType === 'string' && conn.effectiveType.includes('2g')) return false
81
+ return true
82
+ }
83
+
84
+ /** Speculatively load a page into the cache (hover/touch intent). No-op when
85
+ * already cached fresh or when the connection asks not to (Save-Data, 2g).
86
+ * Failures are silent — the eventual click just fetches normally. */
87
+ export function prefetchPage(url: URL): Promise<void> {
88
+ if (!prefetchAllowed()) return Promise.resolve()
89
+ const key = pageCacheKey(url)
90
+ if (getCachedPage(key) !== null) return Promise.resolve()
91
+ let p = inflight.get(key)
92
+ if (!p) {
93
+ p = fetchPagePayload(url).finally(() => {
94
+ inflight.delete(key)
95
+ })
96
+ inflight.set(key, p)
97
+ }
98
+ return p.then(
99
+ () => undefined,
100
+ () => undefined,
101
+ )
102
+ }
103
+
104
+ // Test-only: drop all entries and in-flight markers.
105
+ export function __resetPageCacheForTest(): void {
106
+ cache.clear()
107
+ inflight.clear()
108
+ }
@@ -51,6 +51,10 @@ export interface RenderBranchStreamingArgs {
51
51
  * `<script data-brust-store="…">` blob before `</head>` (buffering) or into
52
52
  * the streaming first-chunk prepend. Null/undefined → no injection. */
53
53
  storeSnapshot?: Record<string, Record<string, unknown>> | null
54
+ /** Inject the islands importmap + bootstrap even when no <Island> rendered.
55
+ * SSG fallback shells need the bootstrap (client takeover runtime) on pages
56
+ * that may have zero real islands. */
57
+ forceIslands?: boolean
54
58
  }
55
59
 
56
60
  const encoder = new TextEncoder()
@@ -167,7 +171,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
167
171
  async final(cb: (e?: Error | null) => void) {
168
172
  try {
169
173
  if (mode === 'buffering') {
170
- const islandsUsed = islandUsedBox.used
174
+ const islandsUsed = islandUsedBox.used || (args.forceIslands ?? false)
171
175
  let body = concatBuffers(buffer, islandsUsed)
172
176
  const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
173
177
  body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
package/runtime/routes.ts CHANGED
@@ -189,6 +189,23 @@ export interface RouteCacheConfig<Params = Record<string, string>> {
189
189
  tags?: string[]
190
190
  }
191
191
 
192
+ /** SSG config — read ONLY by `brust build --ssg`. Live server / dev ignore it.
193
+ * See docs/superpowers/specs/2026-06-12-ssg-dynamic-params-design.md. */
194
+ export interface RouteSsgConfig {
195
+ /** generateStaticParams: concrete param records to prerender. Each record
196
+ * must cover every `{name}` in the route's full path with a non-empty
197
+ * string. Sync or async. Values are URL-encoded into the crawl path. */
198
+ params?: () => Array<Record<string, string>> | Promise<Array<Record<string, string>>>
199
+ /** What non-prerendered paths do on a static host. 'none' (default) = skip
200
+ * → host 404 (today's behavior). 'client' = client-loader takeover
201
+ * (Phase B; requires `export const clientLoader` in the leaf component
202
+ * file, leaf must be a DEFAULT import in routes.tsx, React routes only). */
203
+ fallback?: 'none' | 'client'
204
+ /** Server-rendered loading UI baked into the fallback shell (Phase B).
205
+ * Renders in the leaf position with NO data. */
206
+ placeholder?: ComponentType
207
+ }
208
+
192
209
  /** Shape returned by a middleware or by the terminal `next()` (loader + render).
193
210
  * Middleware can short-circuit by returning a RouteResponse without calling next,
194
211
  * or call next() and mutate the returned response (status, headers). */
@@ -322,6 +339,9 @@ export interface Route<Params = Record<string, string>, Data = unknown> {
322
339
  /** Opt-in cache. Cache config from the leaf only — parent's cache is
323
340
  * ignored when the route is reached as part of a chain. */
324
341
  cache?: RouteCacheConfig
342
+ /** Opt-in SSG behavior for dynamic-param routes (`/blog/{slug}`). Only
343
+ * consulted by `brust build --ssg`. */
344
+ ssg?: RouteSsgConfig
325
345
  /** Per-route middleware chain. Runs in declaration order; concatenated
326
346
  * with parent middlewares (parent runs before child). Cache lookup
327
347
  * still happens BEFORE any middleware (existing rule). */
@@ -1105,10 +1125,13 @@ export function makeRenderer(
1105
1125
  // after loaders (buildRenderElement resolved) — that's where Spec A stores
1106
1126
  // are seeded — and threaded into the render for <script> injection.
1107
1127
  return await runInRequestContext(call.req?.cookies ?? {}, async () => {
1128
+ // Computed ONCE and shared by buildRenderElement (leaf swap) and
1129
+ // renderBranchStreaming (forceIslands) so the two can never diverge.
1130
+ const shellMode = wantsSsgFallbackShell(flat, call)
1108
1131
  let element: ReactNode
1109
1132
  let errorBoundary: ComponentType<{ error: Error }>
1110
1133
  try {
1111
- element = await buildRenderElement(call, flat, opts.getWorkerId)
1134
+ element = await buildRenderElement(call, flat, opts.getWorkerId, shellMode)
1112
1135
  errorBoundary =
1113
1136
  flat.errorBoundary ??
1114
1137
  (({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
@@ -1142,6 +1165,9 @@ export function makeRenderer(
1142
1165
  headers: flushSetCookie(verdict.headers),
1143
1166
  routePath: flat.fullPath,
1144
1167
  storeSnapshot,
1168
+ // SSG fallback shells have zero islands on the page but the
1169
+ // client-loader runtime still needs the importmap + bootstrap.
1170
+ forceIslands: shellMode,
1145
1171
  })
1146
1172
  // renderBranchStreaming wrote via the chunk channel.
1147
1173
  return 0
@@ -1363,8 +1389,17 @@ async function navigationBranch(
1363
1389
  } else {
1364
1390
  // Wrap loader run (inside buildRenderElement) + render in one store scope so
1365
1391
  // store reads resolve the per-request instance; collect after render.
1392
+ // Shell mode here only swaps the leaf for the marker div — no bootstrap
1393
+ // injection is needed in a NAV payload: the payload is swapped into a
1394
+ // document that already booted the bootstrap (the navigator IS the
1395
+ // bootstrap), and the takeover runtime imports its chunk itself.
1366
1396
  fullHtml = await runInRequestContext(call.req?.cookies ?? {}, async () => {
1367
- const element = await buildRenderElement(call as any, flat, getWorkerId)
1397
+ const element = await buildRenderElement(
1398
+ call as any,
1399
+ flat,
1400
+ getWorkerId,
1401
+ wantsSsgFallbackShell(flat, call as any),
1402
+ )
1368
1403
  if (!element) throw new Error('render setup failed')
1369
1404
  // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
1370
1405
  // their RESOLVED markup, not the fallback. renderToString would only
@@ -1498,6 +1533,33 @@ async function renderNativeRouteToHtml(
1498
1533
  return decoder.decode(view.subarray(2 + metaLen, len))
1499
1534
  }
1500
1535
 
1536
+ /** Param value the SSG build crawler substitutes for every `{param}` when it
1537
+ * requests the fallback SHELL of an `ssg.fallback: 'client'` route. */
1538
+ const SSG_FALLBACK_SENTINEL = '__brust_fallback__'
1539
+
1540
+ /** True when this render/navigation call is the SSG build crawler asking for
1541
+ * the fallback shell: the leaf declares `ssg.fallback: 'client'`, the route
1542
+ * has ≥1 param and EVERY param value is the literal sentinel, AND the request
1543
+ * carries `x-brust-ssg: 1` (BrustRequest.headers is a plain lower-cased
1544
+ * Record — see the type doc). Live traffic never sends the header, so the
1545
+ * sentinel namespace is unreachable in production; a forged header yields a
1546
+ * shell that renders LESS than a normal request (leaf loader skipped). */
1547
+ function wantsSsgFallbackShell(
1548
+ flat: FlatRoute,
1549
+ call: { params: Record<string, string>; req: BrustRequest },
1550
+ ): boolean {
1551
+ const leaf = flat.chain[flat.chain.length - 1]
1552
+ if (leaf?.ssg?.fallback !== 'client') return false
1553
+ const names = Object.keys(call.params ?? {})
1554
+ // Parameterless routes can never be in shell mode — intentional, not a
1555
+ // vacuous-truth bug: expandDynamicRoutes already rejects ssg config on
1556
+ // routes without {param}, and a no-param route is always fully
1557
+ // prerenderable.
1558
+ if (names.length === 0) return false
1559
+ if (!names.every((name) => call.params[name] === SSG_FALLBACK_SENTINEL)) return false
1560
+ return call.req?.headers?.['x-brust-ssg'] === '1'
1561
+ }
1562
+
1501
1563
  /** Build the React element for a render or navigation call: runs loaders
1502
1564
  * top-down, builds the element bottom-up wrapping in OutletContext.Provider
1503
1565
  * so nested routes receive the deeper element via <Outlet />. The caller
@@ -1505,6 +1567,12 @@ async function renderNativeRouteToHtml(
1505
1567
  * middleware chain BEFORE calling this — this helper assumes middleware
1506
1568
  * has already passed.
1507
1569
  *
1570
+ * SSG fallback-shell mode (`wantsSsgFallbackShell`): parent loaders still run
1571
+ * (layouts need their data) but the LEAF loader is skipped and the leaf
1572
+ * Component is replaced by the marker div the client-loader runtime mounts
1573
+ * into. Both the render branch and navigationBranch flow through here, so the
1574
+ * shell swap covers full-document AND SPA-navigation payloads.
1575
+ *
1508
1576
  * Throws on setup failure (loader throw, etc.). The caller synthesises a 500
1509
1577
  * in that case.
1510
1578
  */
@@ -1512,19 +1580,25 @@ async function buildRenderElement(
1512
1580
  call: Extract<RouteCall, { kind: 'render' }>,
1513
1581
  flat: FlatRoute,
1514
1582
  getWorkerId?: () => number | null,
1583
+ // Computed ONCE by the caller (and reused there for forceIslands) so the
1584
+ // shell decision and the bootstrap-injection decision can never diverge.
1585
+ shellMode = false,
1515
1586
  ): Promise<ReactNode> {
1516
1587
  call.req.signal = NEVER_ABORTS
1517
1588
  const workerId = getWorkerId ? getWorkerId() : null
1518
1589
  const chainNodes = flat.chain
1590
+ const leafIdx = chainNodes.length - 1
1519
1591
 
1520
1592
  // 1. Run loaders top-down (parent → leaf). Each Component receives ONLY
1521
- // its own loader's data — no merge, no inheritance.
1593
+ // its own loader's data — no merge, no inheritance. Shell mode skips
1594
+ // the LEAF loader (its data comes from the clientLoader at runtime).
1522
1595
  const datas: unknown[] = new Array(chainNodes.length)
1523
1596
  for (let i = 0; i < chainNodes.length; i++) {
1524
1597
  const r = chainNodes[i]
1525
- datas[i] = r.loader
1526
- ? await r.loader({ params: call.params, path: call.path, req: call.req })
1527
- : undefined
1598
+ datas[i] =
1599
+ r.loader && !(shellMode && i === leafIdx)
1600
+ ? await r.loader({ params: call.params, path: call.path, req: call.req })
1601
+ : undefined
1528
1602
  }
1529
1603
 
1530
1604
  // 2. Build the React element bottom-up. Each level wraps the deeper level
@@ -1533,13 +1607,23 @@ async function buildRenderElement(
1533
1607
  let element: ReactNode = null
1534
1608
  for (let i = chainNodes.length - 1; i >= 0; i--) {
1535
1609
  const r = chainNodes[i]
1536
- const node = createElement(r.Component!, {
1537
- params: call.params,
1538
- path: call.path,
1539
- data: datas[i],
1540
- workerId,
1541
- req: call.req,
1542
- })
1610
+ const node =
1611
+ shellMode && i === leafIdx
1612
+ ? createElement(
1613
+ 'div',
1614
+ {
1615
+ 'data-brust-fallback-root': '',
1616
+ 'data-brust-fallback': flat.fullPath,
1617
+ },
1618
+ r.ssg?.placeholder ? createElement(r.ssg.placeholder) : null,
1619
+ )
1620
+ : createElement(r.Component!, {
1621
+ params: call.params,
1622
+ path: call.path,
1623
+ data: datas[i],
1624
+ workerId,
1625
+ req: call.req,
1626
+ })
1543
1627
  element = createElement(OutletContext.Provider, { value: element }, node)
1544
1628
  }
1545
1629
  return element