brustjs 0.1.52-alpha → 0.1.54-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.52-alpha",
3
+ "version": "0.1.54-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,12 +41,12 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "optionalDependencies": {
44
- "brustjs-darwin-x64": "0.1.52-alpha",
45
- "brustjs-darwin-arm64": "0.1.52-alpha",
46
- "brustjs-linux-x64-gnu": "0.1.52-alpha",
47
- "brustjs-linux-arm64-gnu": "0.1.52-alpha",
48
- "brustjs-linux-x64-musl": "0.1.52-alpha",
49
- "brustjs-linux-arm64-musl": "0.1.52-alpha"
44
+ "brustjs-darwin-x64": "0.1.53-alpha",
45
+ "brustjs-darwin-arm64": "0.1.53-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.53-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.53-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.53-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.53-alpha"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.2.6",
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, relative, resolve } from 'node:path'
5
5
  import { buildDevClientTag } from '../dev/client.ts'
6
- import { insertGeneratorMeta, resolveGenerator } from '../generator.ts'
6
+ import { insertGeneratorMeta, insertShellMeta, resolveGenerator } from '../generator.ts'
7
7
  import { islandChunkBasename } from '../islands/chunk-id.ts'
8
8
  import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
 
@@ -285,6 +285,11 @@ export interface NativeRouteEmitOpts {
285
285
  /** Chain nodes parent→leaf. A leaf carrying `__mdSource` is a markdown
286
286
  * page (runtime/md/routes.ts) — emitted by `emitMdTemplates`, NOT here. */
287
287
  chain?: Array<{ Component?: { name?: string }; __mdSource?: unknown }>
288
+ /** SPA shell signature (FlatRoute.shellId), computed in routes.ts makeFlat.
289
+ * Baked into the composed template's head as `<meta name="brust-shell">` so
290
+ * the client full-loads on a layout-chain change. Threaded here (NOT
291
+ * recomputed) so native + React stamp the identical signature. */
292
+ shellId?: string
288
293
  }[]
289
294
  /** `.brust/jinja` absolute output dir. Created if missing. */
290
295
  outDir: string
@@ -345,6 +350,7 @@ function canonicalCompileInputs(input: {
345
350
  mergedImports: Map<string, ResolvedImport>
346
351
  hasDirectives: boolean
347
352
  generatorMeta: string
353
+ shellId: string
348
354
  devClient: boolean
349
355
  }): string {
350
356
  return JSON.stringify({
@@ -367,6 +373,7 @@ function canonicalCompileInputs(input: {
367
373
  .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
368
374
  hasDirectives: input.hasDirectives,
369
375
  generatorMeta: input.generatorMeta,
376
+ shellId: input.shellId,
370
377
  devClient: input.devClient,
371
378
  })
372
379
  }
@@ -589,7 +596,10 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<Na
589
596
  | null = null
590
597
  if (nativeRoutes.length > 0) {
591
598
  const native = await import('../index.js')
592
- compileJsx = (native as { compileJsx?: typeof compileJsx }).compileJsx ?? null
599
+ // index.ts now re-exports a public 2-arg `compileJsx` facade; this emitter
600
+ // uses the raw 5-arg native binding, so cast through `unknown` to the local
601
+ // (full-arity) signature rather than the narrowed public one.
602
+ compileJsx = (native as unknown as { compileJsx?: typeof compileJsx }).compileJsx ?? null
593
603
  if (typeof compileJsx !== 'function') {
594
604
  throw new Error(
595
605
  'brust: the native addon does not expose compileJsx — rebuild it with ' +
@@ -719,6 +729,7 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<Na
719
729
  mergedImports,
720
730
  hasDirectives,
721
731
  generatorMeta,
732
+ shellId: r.shellId ?? '',
722
733
  devClient: process.env.BRUST_DEV === '1',
723
734
  }),
724
735
  )
@@ -763,8 +774,13 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<Na
763
774
  // this on every hot reload, so the script is always present in dev.
764
775
  const withDirectives = bakeDirectivesIfUsed(compiled.template, hasDirectives)
765
776
  const withGenerator = insertGeneratorMeta(withDirectives, generatorMeta)
777
+ // SPA shell signature — baked emit-side (native routes render via
778
+ // napiRenderJinja, not the React stream injector). shellId is threaded from
779
+ // routes.ts makeFlat (never recomputed here), so native + React documents
780
+ // carry the identical signature. Empty/missing → no-op.
781
+ const withShell = insertShellMeta(withGenerator, r.shellId ?? '')
766
782
  const template =
767
- process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withGenerator) : withGenerator
783
+ process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withShell) : withShell
768
784
  writeFileSync(outPath, template)
769
785
  built.push(name)
770
786
  stats.compiled++
@@ -0,0 +1,26 @@
1
+ // Public, typed facade over the native `compileJsx` NAPI binding. The native fn
2
+ // takes (source, path, componentSources?, lucideIcons?, directiveNames?) — this
3
+ // facade exposes the common 2-arg form for compiling a single self-contained
4
+ // component (custom sections, tooling). `path` is the source location used as
5
+ // the prefix in compiler error messages (e.g. "shop/42/section/7:0:0: …").
6
+ // THROWS on invalid source (verified under Bun — unlike the fallible
7
+ // napiRegister* bindings, compileJsx propagates a real throw, not a returned
8
+ // Error). Mirrors the `templates.ts` wrapper pattern.
9
+ import * as native from './index.js'
10
+
11
+ export interface CompileJsxResult {
12
+ /** Compiled minijinja template. Interpolations are auto-escaped (`| e`). */
13
+ template: string
14
+ /** Island manifest JSON — `"[]"` when the component references no <Island>. */
15
+ islandsJson: string
16
+ /** SSR-component manifest JSON — `"[]"` when none. */
17
+ componentsJson: string
18
+ /** Non-fatal compiler diagnostics. */
19
+ warnings: string[]
20
+ }
21
+
22
+ export function compileJsx(source: string, path: string): CompileJsxResult {
23
+ return (
24
+ native as unknown as { compileJsx(source: string, path: string): CompileJsxResult }
25
+ ).compileJsx(source, path)
26
+ }
@@ -44,6 +44,22 @@ export function insertGeneratorMeta(jinja: string, metaTag: string): string {
44
44
  return jinja.slice(0, end) + metaTag + jinja.slice(end)
45
45
  }
46
46
 
47
+ /** Insert the SPA shell-signature meta immediately after the compiler-emitted
48
+ * viewport meta (same anchor as the generator meta — the native bake's emit-time
49
+ * equivalent of inject-shell-meta.ts on the React render path). Anchor missing
50
+ * (a fragment child template with no own head) → no-op, never an error: the
51
+ * signature lands in the LAYOUT's BrustPage head, which is the shell document.
52
+ * Empty shellId → no-op. CALLER CONTRACT: fresh compiler output per emit (no
53
+ * idempotency check), same as insertGeneratorMeta. */
54
+ export function insertShellMeta(jinja: string, shellId: string): string {
55
+ if (!shellId) return jinja
56
+ const at = jinja.indexOf(VIEWPORT_ANCHOR)
57
+ if (at === -1) return jinja
58
+ const end = at + VIEWPORT_ANCHOR.length
59
+ const tag = `<meta name="brust-shell" content="${shellId}">`
60
+ return jinja.slice(0, end) + tag + jinja.slice(end)
61
+ }
62
+
47
63
  /** Write the decision artifact into `dir` (a jinja out dir), creating it. */
48
64
  export function writeGeneratorArtifact(dir: string, strings: GeneratorStrings): void {
49
65
  mkdirSync(dir, { recursive: true })
package/runtime/index.js CHANGED
@@ -77,8 +77,8 @@ function requireNative() {
77
77
  try {
78
78
  const binding = require('brustjs-android-arm64')
79
79
  const bindingPackageVersion = require('brustjs-android-arm64/package.json').version
80
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
80
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
82
82
  }
83
83
  return binding
84
84
  } catch (e) {
@@ -93,8 +93,8 @@ function requireNative() {
93
93
  try {
94
94
  const binding = require('brustjs-android-arm-eabi')
95
95
  const bindingPackageVersion = require('brustjs-android-arm-eabi/package.json').version
96
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
96
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
98
98
  }
99
99
  return binding
100
100
  } catch (e) {
@@ -114,8 +114,8 @@ function requireNative() {
114
114
  try {
115
115
  const binding = require('brustjs-win32-x64-gnu')
116
116
  const bindingPackageVersion = require('brustjs-win32-x64-gnu/package.json').version
117
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
117
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
119
119
  }
120
120
  return binding
121
121
  } catch (e) {
@@ -130,8 +130,8 @@ function requireNative() {
130
130
  try {
131
131
  const binding = require('brustjs-win32-x64-msvc')
132
132
  const bindingPackageVersion = require('brustjs-win32-x64-msvc/package.json').version
133
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
133
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
135
135
  }
136
136
  return binding
137
137
  } catch (e) {
@@ -147,8 +147,8 @@ function requireNative() {
147
147
  try {
148
148
  const binding = require('brustjs-win32-ia32-msvc')
149
149
  const bindingPackageVersion = require('brustjs-win32-ia32-msvc/package.json').version
150
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
150
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
152
152
  }
153
153
  return binding
154
154
  } catch (e) {
@@ -163,8 +163,8 @@ function requireNative() {
163
163
  try {
164
164
  const binding = require('brustjs-win32-arm64-msvc')
165
165
  const bindingPackageVersion = require('brustjs-win32-arm64-msvc/package.json').version
166
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
166
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
168
168
  }
169
169
  return binding
170
170
  } catch (e) {
@@ -182,8 +182,8 @@ function requireNative() {
182
182
  try {
183
183
  const binding = require('brustjs-darwin-universal')
184
184
  const bindingPackageVersion = require('brustjs-darwin-universal/package.json').version
185
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
185
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
187
187
  }
188
188
  return binding
189
189
  } catch (e) {
@@ -198,8 +198,8 @@ function requireNative() {
198
198
  try {
199
199
  const binding = require('brustjs-darwin-x64')
200
200
  const bindingPackageVersion = require('brustjs-darwin-x64/package.json').version
201
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
201
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
203
203
  }
204
204
  return binding
205
205
  } catch (e) {
@@ -214,8 +214,8 @@ function requireNative() {
214
214
  try {
215
215
  const binding = require('brustjs-darwin-arm64')
216
216
  const bindingPackageVersion = require('brustjs-darwin-arm64/package.json').version
217
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
217
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
219
219
  }
220
220
  return binding
221
221
  } catch (e) {
@@ -234,8 +234,8 @@ function requireNative() {
234
234
  try {
235
235
  const binding = require('brustjs-freebsd-x64')
236
236
  const bindingPackageVersion = require('brustjs-freebsd-x64/package.json').version
237
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
237
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
239
239
  }
240
240
  return binding
241
241
  } catch (e) {
@@ -250,8 +250,8 @@ function requireNative() {
250
250
  try {
251
251
  const binding = require('brustjs-freebsd-arm64')
252
252
  const bindingPackageVersion = require('brustjs-freebsd-arm64/package.json').version
253
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
253
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
255
255
  }
256
256
  return binding
257
257
  } catch (e) {
@@ -271,8 +271,8 @@ function requireNative() {
271
271
  try {
272
272
  const binding = require('brustjs-linux-x64-musl')
273
273
  const bindingPackageVersion = require('brustjs-linux-x64-musl/package.json').version
274
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
274
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
276
276
  }
277
277
  return binding
278
278
  } catch (e) {
@@ -287,8 +287,8 @@ function requireNative() {
287
287
  try {
288
288
  const binding = require('brustjs-linux-x64-gnu')
289
289
  const bindingPackageVersion = require('brustjs-linux-x64-gnu/package.json').version
290
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
290
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
292
292
  }
293
293
  return binding
294
294
  } catch (e) {
@@ -305,8 +305,8 @@ function requireNative() {
305
305
  try {
306
306
  const binding = require('brustjs-linux-arm64-musl')
307
307
  const bindingPackageVersion = require('brustjs-linux-arm64-musl/package.json').version
308
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
308
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
310
310
  }
311
311
  return binding
312
312
  } catch (e) {
@@ -321,8 +321,8 @@ function requireNative() {
321
321
  try {
322
322
  const binding = require('brustjs-linux-arm64-gnu')
323
323
  const bindingPackageVersion = require('brustjs-linux-arm64-gnu/package.json').version
324
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
324
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
326
326
  }
327
327
  return binding
328
328
  } catch (e) {
@@ -339,8 +339,8 @@ function requireNative() {
339
339
  try {
340
340
  const binding = require('brustjs-linux-arm-musleabihf')
341
341
  const bindingPackageVersion = require('brustjs-linux-arm-musleabihf/package.json').version
342
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
342
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
344
344
  }
345
345
  return binding
346
346
  } catch (e) {
@@ -355,8 +355,8 @@ function requireNative() {
355
355
  try {
356
356
  const binding = require('brustjs-linux-arm-gnueabihf')
357
357
  const bindingPackageVersion = require('brustjs-linux-arm-gnueabihf/package.json').version
358
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
358
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
360
360
  }
361
361
  return binding
362
362
  } catch (e) {
@@ -373,8 +373,8 @@ function requireNative() {
373
373
  try {
374
374
  const binding = require('brustjs-linux-loong64-musl')
375
375
  const bindingPackageVersion = require('brustjs-linux-loong64-musl/package.json').version
376
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
376
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
378
378
  }
379
379
  return binding
380
380
  } catch (e) {
@@ -389,8 +389,8 @@ function requireNative() {
389
389
  try {
390
390
  const binding = require('brustjs-linux-loong64-gnu')
391
391
  const bindingPackageVersion = require('brustjs-linux-loong64-gnu/package.json').version
392
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
392
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
394
394
  }
395
395
  return binding
396
396
  } catch (e) {
@@ -407,8 +407,8 @@ function requireNative() {
407
407
  try {
408
408
  const binding = require('brustjs-linux-riscv64-musl')
409
409
  const bindingPackageVersion = require('brustjs-linux-riscv64-musl/package.json').version
410
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
410
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
412
412
  }
413
413
  return binding
414
414
  } catch (e) {
@@ -423,8 +423,8 @@ function requireNative() {
423
423
  try {
424
424
  const binding = require('brustjs-linux-riscv64-gnu')
425
425
  const bindingPackageVersion = require('brustjs-linux-riscv64-gnu/package.json').version
426
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
426
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
428
428
  }
429
429
  return binding
430
430
  } catch (e) {
@@ -440,8 +440,8 @@ function requireNative() {
440
440
  try {
441
441
  const binding = require('brustjs-linux-ppc64-gnu')
442
442
  const bindingPackageVersion = require('brustjs-linux-ppc64-gnu/package.json').version
443
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
443
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
445
445
  }
446
446
  return binding
447
447
  } catch (e) {
@@ -456,8 +456,8 @@ function requireNative() {
456
456
  try {
457
457
  const binding = require('brustjs-linux-s390x-gnu')
458
458
  const bindingPackageVersion = require('brustjs-linux-s390x-gnu/package.json').version
459
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
459
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
461
461
  }
462
462
  return binding
463
463
  } catch (e) {
@@ -476,8 +476,8 @@ function requireNative() {
476
476
  try {
477
477
  const binding = require('brustjs-openharmony-arm64')
478
478
  const bindingPackageVersion = require('brustjs-openharmony-arm64/package.json').version
479
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
479
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
481
481
  }
482
482
  return binding
483
483
  } catch (e) {
@@ -492,8 +492,8 @@ function requireNative() {
492
492
  try {
493
493
  const binding = require('brustjs-openharmony-x64')
494
494
  const bindingPackageVersion = require('brustjs-openharmony-x64/package.json').version
495
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
495
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
497
497
  }
498
498
  return binding
499
499
  } catch (e) {
@@ -508,8 +508,8 @@ function requireNative() {
508
508
  try {
509
509
  const binding = require('brustjs-openharmony-arm')
510
510
  const bindingPackageVersion = require('brustjs-openharmony-arm/package.json').version
511
- if (bindingPackageVersion !== '0.1.52-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
- throw new Error(`Native binding package version mismatch, expected 0.1.52-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
511
+ if (bindingPackageVersion !== '0.1.54-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
+ throw new Error(`Native binding package version mismatch, expected 0.1.54-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
513
513
  }
514
514
  return binding
515
515
  } catch (e) {
package/runtime/index.ts CHANGED
@@ -1183,6 +1183,10 @@ export type { InvalidateArgs } from './cache.ts'
1183
1183
 
1184
1184
  export { templates } from './templates.ts'
1185
1185
 
1186
+ // R1 (compile half) — public TSX→minijinja compile for custom sections /
1187
+ // tooling. The native fn always existed; this exposes a typed 2-arg facade.
1188
+ export { compileJsx, type CompileJsxResult } from './compile-jsx.ts'
1189
+
1186
1190
  export { getRequestContext } from './request-context.ts'
1187
1191
  export { cookies } from './cookies.ts'
1188
1192
  export type { CookieOptions } from './cookies.ts'
@@ -270,6 +270,34 @@ const scrollPositions = new Map<string, number>()
270
270
  // would record the leaving page's scroll under the wrong key.
271
271
  let currentPageKey = ''
272
272
 
273
+ // SPA shell signature of the document currently in the DOM, read ONCE at boot
274
+ // from the server-stamped <meta name="brust-shell">. navigate() compares it to a
275
+ // nav payload's `shell`: a mismatch means the layout-ancestor chain changed, so
276
+ // the fast in-place <main> swap would keep the WRONG shell — full-load instead.
277
+ // NOT updated on a same-shell swap (swapMainContent only replaces <main>'s
278
+ // children, so the head meta — and thus the signature — persists); a full load
279
+ // re-seeds this from the fresh document at boot.
280
+ let currentShell: string | null =
281
+ typeof document !== 'undefined'
282
+ ? (document.querySelector('meta[name="brust-shell"]')?.getAttribute('content') ?? null)
283
+ : null
284
+
285
+ /** True when the destination's shell signature differs from the current
286
+ * document's — the nav must full-load to render the correct shell. The
287
+ * BOTH-PRESENT requirement is load-bearing: if either side is absent (an old
288
+ * cached payload without `shell`, a stale addon that didn't bake the meta, or
289
+ * the very first nav before a meta exists) we fall through to the existing swap
290
+ * behavior and never over-full-load. */
291
+ function shellChanged(payloadShell: string | undefined): boolean {
292
+ return !!currentShell && !!payloadShell && payloadShell !== currentShell
293
+ }
294
+
295
+ /** Test-only: seed/clear the boot-read shell signature (module state, otherwise
296
+ * fixed at import before any test meta exists). */
297
+ export function __setCurrentShellForTest(shell: string | null): void {
298
+ currentShell = shell
299
+ }
300
+
273
301
  /** SPA-navigate onto a `fallback: 'client'` SSG route on a static host.
274
302
  *
275
303
  * Called from navigate() when the normal payload fetch fails: a
@@ -300,18 +328,26 @@ async function attemptClientFallback(
300
328
  if (!entry) return false
301
329
  let html: string
302
330
  let title: string | undefined
331
+ let shell: string | undefined
303
332
  try {
304
333
  const resp = await fetch(entry.payload, { headers: { Accept: 'application/json' } })
305
334
  if (!resp.ok) return false
306
- const payload = (await resp.json()) as { html?: unknown; title?: unknown }
335
+ const payload = (await resp.json()) as { html?: unknown; title?: unknown; shell?: unknown }
307
336
  if (typeof payload.html !== 'string') return false
308
337
  html = payload.html
309
338
  title = typeof payload.title === 'string' ? payload.title : undefined
339
+ shell = typeof payload.shell === 'string' ? payload.shell : undefined
310
340
  } catch {
311
341
  return false
312
342
  }
313
343
  // Superseded while fetching → a newer navigation owns the DOM; do nothing.
314
344
  if (signal?.aborted) return false
345
+ // Same shell-signature guard as the normal path: a layout-chain change must
346
+ // full-load (correct shell), not swap into the current <main>.
347
+ if (shellChanged(shell)) {
348
+ location.href = url.href
349
+ return true
350
+ }
315
351
  // Same full-document guard as the normal path: a standalone-route shell
316
352
  // cannot be swapped into the current <main> — authoritative full load.
317
353
  if (isFullDocumentPayload(html)) {
@@ -418,6 +454,16 @@ export async function navigate(url: URL, mode: 'push' | 'replace' | 'none'): Pro
418
454
  if (ac.signal.aborted) return
419
455
  }
420
456
  const { html, title, store } = payload
457
+ // SPA shell signature: if the destination's layout-ancestor chain differs
458
+ // from the current document's, the fast <main> swap would keep the WRONG
459
+ // shell (studio bug: login shown without AppLayout chrome, logout keeps it).
460
+ // Full-load so the correct shell renders. Guarded both-present (see
461
+ // shellChanged) — backward/stale-addon safe. Same-shell nav falls through
462
+ // to the existing swap untouched.
463
+ if (shellChanged(payload.shell)) {
464
+ location.href = url.href
465
+ return
466
+ }
421
467
  // A standalone (no-<main>) route ships its FULL document here. We can't swap
422
468
  // that into the current shell's <main> without nesting a second document
423
469
  // (duplicate chrome — the classic two-topbars artifact), and the current
@@ -10,6 +10,13 @@ export interface PagePayload {
10
10
  html: string
11
11
  title: string
12
12
  store?: Record<string, Record<string, unknown>> | null
13
+ /** SPA shell signature of the destination route (FlatRoute.shellId). The
14
+ * client compares it to the current document's `<meta name="brust-shell">`
15
+ * and full-loads on mismatch (correct shell), keeping the fast in-place swap
16
+ * when they match. Optional: old cached payloads / stale addons omit it, in
17
+ * which case the client falls through to the existing swap behavior. Round-
18
+ * trips through the cache automatically (the whole parsed object is stored). */
19
+ shell?: string
13
20
  /** True when this payload was delivered at HTTP 404 (a rendered catch-all
14
21
  * page, NOT a transport error). The caller swaps it in-place like any other
15
22
  * payload but must NOT cache it — a 404 is not a stable, re-servable entry,
@@ -0,0 +1,68 @@
1
+ // Render-time SPA shell-signature injection for React-streamed HTML. Mirrors
2
+ // inject-generator.ts exactly (the generator-meta dual-path precedent): the
3
+ // buffering branch splices `<meta name="brust-shell" content="…">` before
4
+ // </head> with a presence guard; the streaming branch (stream.ts) prepends the
5
+ // raw tag with the other first-chunk tags instead. The signature comes from the
6
+ // matched route's FlatRoute.shellId, threaded through RenderBranchStreamingArgs.
7
+ //
8
+ // The client (bootstrap.ts) reads this meta at boot and full-loads when a nav
9
+ // payload's `shell` differs — so a layout-chain change renders the correct shell.
10
+ const ENC = new TextEncoder()
11
+
12
+ /** Build the shell-signature meta tag for a given shellId (no escaping needed —
13
+ * shellId is a server-derived `L:`/`S:` token of component names / route paths,
14
+ * not user input). Empty → empty string (the inject path no-ops). */
15
+ export function shellMetaTag(shellId: string): string {
16
+ return shellId ? `<meta name="brust-shell" content="${shellId}">` : ''
17
+ }
18
+
19
+ const GUARD = ENC.encode('name="brust-shell"')
20
+
21
+ /** Splice the shell meta immediately before the first `</head>` (case-insensitive).
22
+ * No </head> in the chunk, empty shellId, or an existing brust-shell meta → body
23
+ * returned untouched. Byte-level (no decode) — safe with multibyte content. */
24
+ export function injectShellMeta(body: Uint8Array, shellId: string): Uint8Array {
25
+ if (!shellId) return body
26
+ const pos = findHeadCloseTag(body)
27
+ if (pos < 0) return body
28
+ if (bytesInclude(body, GUARD, pos)) return body
29
+ const tagBytes = ENC.encode(shellMetaTag(shellId))
30
+ const out = new Uint8Array(body.length + tagBytes.length)
31
+ out.set(body.subarray(0, pos), 0)
32
+ out.set(tagBytes, pos)
33
+ out.set(body.subarray(pos), pos + tagBytes.length)
34
+ return out
35
+ }
36
+
37
+ /** True if `needle` occurs in `hay[0..limit)`. Naive scan — head is small. */
38
+ function bytesInclude(hay: Uint8Array, needle: Uint8Array, limit: number): boolean {
39
+ const max = Math.min(limit, hay.length) - needle.length
40
+ outer: for (let i = 0; i <= max; i++) {
41
+ for (let j = 0; j < needle.length; j++) {
42
+ if (hay[i + j] !== needle[j]) continue outer
43
+ }
44
+ return true
45
+ }
46
+ return false
47
+ }
48
+
49
+ /** Byte scan for `</head>` (letters case-insensitive). Returns offset of `<` or -1. */
50
+ function findHeadCloseTag(body: Uint8Array): number {
51
+ const LT = 0x3c
52
+ const SL = 0x2f
53
+ const GT = 0x3e
54
+ for (let i = 0, max = body.length - 6; i < max; i++) {
55
+ if (body[i] !== LT || body[i + 1] !== SL) continue
56
+ if (!isLetter(body[i + 2], 0x48)) continue // H
57
+ if (!isLetter(body[i + 3], 0x45)) continue // E
58
+ if (!isLetter(body[i + 4], 0x41)) continue // A
59
+ if (!isLetter(body[i + 5], 0x44)) continue // D
60
+ if (body[i + 6] !== GT) continue
61
+ return i
62
+ }
63
+ return -1
64
+ }
65
+
66
+ function isLetter(b: number, u: number): boolean {
67
+ return b === u || b === (u | 0x20)
68
+ }
@@ -13,6 +13,7 @@ import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-pref
13
13
  import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
14
14
  import { getDevClientSnippet } from '../dev/inject.ts'
15
15
  import { getGeneratorMeta, injectGeneratorMeta } from './inject-generator.ts'
16
+ import { injectShellMeta, shellMetaTag } from './inject-shell-meta.ts'
16
17
 
17
18
  export interface RenderBranchStreamingArgs {
18
19
  element: ReactNode
@@ -56,6 +57,10 @@ export interface RenderBranchStreamingArgs {
56
57
  * SSG fallback shells need the bootstrap (client takeover runtime) on pages
57
58
  * that may have zero real islands. */
58
59
  forceIslands?: boolean
60
+ /** The matched route's SPA shell signature (FlatRoute.shellId). Stamped into
61
+ * the document head as `<meta name="brust-shell">` so the client can full-load
62
+ * on a layout-chain change. Empty/undefined → no injection (mirror generator). */
63
+ shellId?: string
59
64
  }
60
65
 
61
66
  const encoder = new TextEncoder()
@@ -177,6 +182,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
177
182
  const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
178
183
  body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
179
184
  body = injectGeneratorMeta(body, getGeneratorMeta())
185
+ body = injectShellMeta(body, args.shellId ?? '')
180
186
  body = injectDevClient(body, getDevClientSnippet())
181
187
  body = injectActionPrefix(body, getActionPrefixSnippet())
182
188
  body = injectBrustStore(body, args.storeSnapshot ?? null)
@@ -243,14 +249,18 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
243
249
  const prefixTag = getActionPrefixSnippet() ?? ''
244
250
  const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
245
251
  const genTag = getGeneratorMeta() ?? ''
252
+ const shellTag = shellMetaTag(args.shellId ?? '')
246
253
  if (
247
254
  linkTagsStr.length > 0 ||
248
255
  devTag.length > 0 ||
249
256
  prefixTag.length > 0 ||
250
257
  storeTag.length > 0 ||
251
- genTag.length > 0
258
+ genTag.length > 0 ||
259
+ shellTag.length > 0
252
260
  ) {
253
- const prepend = encoder.encode(genTag + linkTagsStr + prefixTag + devTag + storeTag)
261
+ const prepend = encoder.encode(
262
+ genTag + shellTag + linkTagsStr + prefixTag + devTag + storeTag,
263
+ )
254
264
  const out = new Uint8Array(flushed.length + prepend.length)
255
265
  out.set(flushed, 0)
256
266
  out.set(prepend, flushed.length)
package/runtime/routes.ts CHANGED
@@ -567,6 +567,13 @@ export interface FlatRoute {
567
567
  /** Sub-project J — Component.name when leaf had `native: true`. Captured
568
568
  * at flatten time (build-time AST identifier), so minifier-safe. */
569
569
  nativeTemplate?: string
570
+ /** SPA shell signature — identity of the layout-ancestor chain. All leaves
571
+ * under the same layout chain share an `L:` sig (→ fast in-place <main> swap);
572
+ * a standalone leaf gets a unique `S:` sig (→ full document load on cross-nav,
573
+ * so the correct shell renders). Computed in `makeFlat`. Server stamps it into
574
+ * the rendered document head (`<meta name="brust-shell">`) AND the nav payload;
575
+ * the client full-loads on mismatch. See the SPA shell-signature design. */
576
+ shellId: string
570
577
  /** Catch-all (`{ path: '*' }`) marker. When true this FlatRoute is a
571
578
  * "not found" fallback: it stays in the array at its natural index (route_id
572
579
  * stable) but install SKIPS the matchit insert for it — it only renders when
@@ -787,7 +794,15 @@ function makeFlat(chain: Route[], fullPath: string): FlatRoute {
787
794
  )
788
795
  }
789
796
  const nativeTemplate = leaf.native === true && leaf.Component ? leaf.Component.name : undefined
790
- return { fullPath, chain, middleware, errorBoundary, cache, nativeTemplate }
797
+ // SPA shell signature: the layout-ancestor chain's identity. Server runs the
798
+ // real module so `Component.name` is stable (not minified); fall back to the
799
+ // route path. Layout-wrapped leaves (ancestors.length > 0) collapse to a
800
+ // shared `L:` sig per chain; a standalone leaf gets a unique `S:` sig.
801
+ const routeIdent = (r: Route): string => r.Component?.name || r.path || '?'
802
+ const ancestors = chain.slice(0, -1)
803
+ const shellId =
804
+ ancestors.length > 0 ? 'L:' + ancestors.map(routeIdent).join('>') : 'S:' + routeIdent(leaf)
805
+ return { fullPath, chain, middleware, errorBoundary, cache, nativeTemplate, shellId }
791
806
  }
792
807
 
793
808
  /** Internal React context that carries the next-deeper rendered element to
@@ -1475,6 +1490,7 @@ export function makeRenderer(
1475
1490
  status: renderStatus,
1476
1491
  headers: flushSetCookie(verdict.headers),
1477
1492
  routePath: renderFlat.fullPath,
1493
+ shellId: renderFlat.shellId,
1478
1494
  storeSnapshot,
1479
1495
  // SSG fallback shells have zero islands on the page but the
1480
1496
  // client-loader runtime still needs the importmap + bootstrap.
@@ -1686,6 +1702,11 @@ async function navigationBranch(
1686
1702
  // route (notFound === false), so the status must be forced to 404 explicitly,
1687
1703
  // not derived from `flat.notFound`.
1688
1704
  let navStatus = flat.notFound === true ? 404 : 200
1705
+ // The shellId of the route ACTUALLY rendered into `fullHtml`. Normally the
1706
+ // matched route, but a React notFound() swap renders the catch-all instead —
1707
+ // the payload must report the catch-all's shell, not the matched route's,
1708
+ // or the next hop compares against the wrong currentShell.
1709
+ let renderedShellId: string | undefined = flat.shellId
1689
1710
  if (flat.nativeTemplate !== undefined) {
1690
1711
  fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId, slot)
1691
1712
  } else {
@@ -1728,6 +1749,7 @@ async function navigationBranch(
1728
1749
  if (nfFlat) {
1729
1750
  try {
1730
1751
  fullHtml = await renderFlatToHtml(nfFlat)
1752
+ renderedShellId = nfFlat.shellId
1731
1753
  } catch (nfErr) {
1732
1754
  // The catch-all's OWN loader/render threw (notFound or a real error):
1733
1755
  // ship the framework default 404 body — don't recurse into another
@@ -1735,12 +1757,16 @@ async function navigationBranch(
1735
1757
  console.error('[brust] catch-all nav render failed:', nfErr)
1736
1758
  fullHtml = DEFAULT_NOT_FOUND_BODY
1737
1759
  store = null
1760
+ // Default 404 body is a full <html> doc (no <main>) → client takes
1761
+ // the full-document path; leave shell unset so it never mis-matches.
1762
+ renderedShellId = undefined
1738
1763
  }
1739
1764
  } else {
1740
1765
  // No catch-all registered for this prefix → framework default 404 body
1741
1766
  // shipped as a nav payload (so the client swaps it in), at 404.
1742
1767
  fullHtml = DEFAULT_NOT_FOUND_BODY
1743
1768
  store = null
1769
+ renderedShellId = undefined
1744
1770
  }
1745
1771
  }
1746
1772
  }
@@ -1762,7 +1788,11 @@ async function navigationBranch(
1762
1788
  const titleMatch = fullHtml.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
1763
1789
  const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
1764
1790
 
1765
- const body = JSON.stringify({ html: innerHtml, title, store })
1791
+ // `shell` is the destination route's SPA shell signature. The client
1792
+ // compares it to the current document's <meta name="brust-shell"> and does a
1793
+ // full document load on mismatch (correct shell), keeping the fast in-place
1794
+ // <main> swap when they match. `flat` is the matched destination FlatRoute.
1795
+ const body = JSON.stringify({ html: innerHtml, title, store, shell: renderedShellId })
1766
1796
  await emitSingleChunkResponse(
1767
1797
  view,
1768
1798
  napi,
@@ -103,6 +103,11 @@ export interface NativeRouteEmitOpts {
103
103
  };
104
104
  __mdSource?: unknown;
105
105
  }>;
106
+ /** SPA shell signature (FlatRoute.shellId), computed in routes.ts makeFlat.
107
+ * Baked into the composed template's head as `<meta name="brust-shell">` so
108
+ * the client full-loads on a layout-chain change. Threaded here (NOT
109
+ * recomputed) so native + React stamp the identical signature. */
110
+ shellId?: string;
106
111
  }[];
107
112
  /** `.brust/jinja` absolute output dir. Created if missing. */
108
113
  outDir: string;
@@ -0,0 +1,11 @@
1
+ export interface CompileJsxResult {
2
+ /** Compiled minijinja template. Interpolations are auto-escaped (`| e`). */
3
+ template: string;
4
+ /** Island manifest JSON — `"[]"` when the component references no <Island>. */
5
+ islandsJson: string;
6
+ /** SSR-component manifest JSON — `"[]"` when none. */
7
+ componentsJson: string;
8
+ /** Non-fatal compiler diagnostics. */
9
+ warnings: string[];
10
+ }
11
+ export declare function compileJsx(source: string, path: string): CompileJsxResult;
@@ -14,6 +14,14 @@ export declare function generatorStrings(versionOn: boolean): GeneratorStrings;
14
14
  * for an existing tag, so calling it twice on the same string duplicates.
15
15
  * Every emit path recompiles from source each run, which keeps this safe. */
16
16
  export declare function insertGeneratorMeta(jinja: string, metaTag: string): string;
17
+ /** Insert the SPA shell-signature meta immediately after the compiler-emitted
18
+ * viewport meta (same anchor as the generator meta — the native bake's emit-time
19
+ * equivalent of inject-shell-meta.ts on the React render path). Anchor missing
20
+ * (a fragment child template with no own head) → no-op, never an error: the
21
+ * signature lands in the LAYOUT's BrustPage head, which is the shell document.
22
+ * Empty shellId → no-op. CALLER CONTRACT: fresh compiler output per emit (no
23
+ * idempotency check), same as insertGeneratorMeta. */
24
+ export declare function insertShellMeta(jinja: string, shellId: string): string;
17
25
  /** Write the decision artifact into `dir` (a jinja out dir), creating it. */
18
26
  export declare function writeGeneratorArtifact(dir: string, strings: GeneratorStrings): void;
19
27
  /** Read the artifact; null on missing/malformed (caller decides the fallback). */
package/types/index.d.ts CHANGED
@@ -217,6 +217,7 @@ export type { IslandsBuildResult, BuildIslandsOptions } from './islands/build.ts
217
217
  export { cache } from './cache.ts';
218
218
  export type { InvalidateArgs } from './cache.ts';
219
219
  export { templates } from './templates.ts';
220
+ export { compileJsx, type CompileJsxResult } from './compile-jsx.ts';
220
221
  export { getRequestContext } from './request-context.ts';
221
222
  export { cookies } from './cookies.ts';
222
223
  export type { CookieOptions } from './cookies.ts';
@@ -0,0 +1,8 @@
1
+ /** Build the shell-signature meta tag for a given shellId (no escaping needed —
2
+ * shellId is a server-derived `L:`/`S:` token of component names / route paths,
3
+ * not user input). Empty → empty string (the inject path no-ops). */
4
+ export declare function shellMetaTag(shellId: string): string;
5
+ /** Splice the shell meta immediately before the first `</head>` (case-insensitive).
6
+ * No </head> in the chunk, empty shellId, or an existing brust-shell meta → body
7
+ * returned untouched. Byte-level (no decode) — safe with multibyte content. */
8
+ export declare function injectShellMeta(body: Uint8Array, shellId: string): Uint8Array;
@@ -33,6 +33,10 @@ export interface RenderBranchStreamingArgs {
33
33
  * SSG fallback shells need the bootstrap (client takeover runtime) on pages
34
34
  * that may have zero real islands. */
35
35
  forceIslands?: boolean;
36
+ /** The matched route's SPA shell signature (FlatRoute.shellId). Stamped into
37
+ * the document head as `<meta name="brust-shell">` so the client can full-load
38
+ * on a layout-chain change. Empty/undefined → no injection (mirror generator). */
39
+ shellId?: string;
36
40
  }
37
41
  /** JSON.stringify the per-chunk meta. Defaults match the renderToString
38
42
  * path so single-chunk responses keep their existing wire shape. */
package/types/routes.d.ts CHANGED
@@ -367,6 +367,13 @@ export interface FlatRoute {
367
367
  /** Sub-project J — Component.name when leaf had `native: true`. Captured
368
368
  * at flatten time (build-time AST identifier), so minifier-safe. */
369
369
  nativeTemplate?: string;
370
+ /** SPA shell signature — identity of the layout-ancestor chain. All leaves
371
+ * under the same layout chain share an `L:` sig (→ fast in-place <main> swap);
372
+ * a standalone leaf gets a unique `S:` sig (→ full document load on cross-nav,
373
+ * so the correct shell renders). Computed in `makeFlat`. Server stamps it into
374
+ * the rendered document head (`<meta name="brust-shell">`) AND the nav payload;
375
+ * the client full-loads on mismatch. See the SPA shell-signature design. */
376
+ shellId: string;
370
377
  /** Catch-all (`{ path: '*' }`) marker. When true this FlatRoute is a
371
378
  * "not found" fallback: it stays in the array at its natural index (route_id
372
379
  * stable) but install SKIPS the matchit insert for it — it only renders when