brustjs 0.1.20-alpha → 0.1.22-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/README.md +12 -1
- package/example/pokedex/components/AddToTeamButton.tsx +59 -65
- package/example/pokedex/components/TeamBuilder.tsx +20 -10
- package/example/pokedex/lib/loaders.ts +13 -3
- package/example/pokedex/lib/types.ts +6 -3
- package/example/pokedex/pages/DetailPage.tsx +1 -2
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +10 -8
- package/runtime/cli/build.ts +41 -0
- package/runtime/cli/native-routes-emit.ts +28 -4
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +32 -1
- package/runtime/islands/bootstrap.ts +7 -1
- package/runtime/islands/importmap.ts +6 -0
- package/runtime/native/build.ts +147 -0
- package/runtime/native/index.ts +3 -0
- package/runtime/native/runtime.ts +324 -0
- package/runtime/render/inject-store.ts +63 -0
- package/runtime/render/stream.ts +14 -2
- package/runtime/routes.ts +66 -38
- package/runtime/store/client-hydrate.ts +16 -0
- package/runtime/store/define-store.ts +179 -0
- package/runtime/store/index.ts +8 -0
- package/runtime/store/react.ts +23 -0
- package/runtime/store/serialize.ts +32 -0
- package/runtime/store/server-context.ts +43 -0
- package/runtime/store/signal.ts +170 -0
- package/example/pokedex/components/team-bus.ts +0 -25
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Directive runtime — react-free, dom-only. Scans the DOM for x-* directives and
|
|
2
|
+
// binds them to per-element component instances via brustjs/store's `effect`.
|
|
3
|
+
import { effect, isComputed, isSignal } from '../store/index.ts'
|
|
4
|
+
|
|
5
|
+
export type Instance = Record<string, unknown>
|
|
6
|
+
export type Behavior = (ctx: { el: HTMLElement; props: unknown }) => Instance
|
|
7
|
+
|
|
8
|
+
interface Mounted {
|
|
9
|
+
disposers: Array<() => void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const registry = new Map<string, Behavior>()
|
|
13
|
+
const mounted = new WeakMap<HTMLElement, Mounted>()
|
|
14
|
+
const loading = new Map<string, Promise<unknown>>()
|
|
15
|
+
let started = false
|
|
16
|
+
|
|
17
|
+
/** Per-component behavior chunk URL. Each native interactive component is built to
|
|
18
|
+
* its OWN `<name>.directive.js` chunk (served from the islands static route) and
|
|
19
|
+
* loaded ON DEMAND — only when an `x-data="<name>"` for it actually appears (initial
|
|
20
|
+
* render OR after an SPA-nav swap). The chunk self-registers via the global below. */
|
|
21
|
+
const CHUNK_BASE = '/_brust/islands/'
|
|
22
|
+
|
|
23
|
+
/** Register a component behavior under `name`. Called by `<name>.directive.js` chunks
|
|
24
|
+
* via the global handle below (they do NOT import this module — keeps each chunk to
|
|
25
|
+
* just its behavior, with the runtime shared as the single `_directives.js` copy). */
|
|
26
|
+
export function register(name: string, behavior: Behavior): void {
|
|
27
|
+
registry.set(name, behavior)
|
|
28
|
+
}
|
|
29
|
+
// Expose `register` on a global so dynamically-imported behavior chunks self-register
|
|
30
|
+
// into THIS runtime's registry without importing/duplicating the runtime. Symbol.for
|
|
31
|
+
// → shared across chunks (same rationale as the store's brands/reactive ctx).
|
|
32
|
+
;(globalThis as { [k: symbol]: unknown })[Symbol.for('brust.directive.register')] = register
|
|
33
|
+
|
|
34
|
+
/** Scan `root` (default: document) for [x-data], mount each, and (once) attach a
|
|
35
|
+
* MutationObserver for dynamic mount/dispose. Idempotent. NOTE: `root` scopes the
|
|
36
|
+
* INITIAL scan only; the observer always watches the global `document.body` (one
|
|
37
|
+
* observer per document handles every later mount/dispose, incl. SPA-nav swaps). */
|
|
38
|
+
export function start(root?: ParentNode): void {
|
|
39
|
+
const scope: ParentNode | undefined =
|
|
40
|
+
root ?? (typeof document !== 'undefined' ? document : undefined)
|
|
41
|
+
if (!scope) return
|
|
42
|
+
const run = () => {
|
|
43
|
+
scanAndMount(scope)
|
|
44
|
+
if (!started) {
|
|
45
|
+
started = true
|
|
46
|
+
observe()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
50
|
+
document.addEventListener('DOMContentLoaded', run, { once: true })
|
|
51
|
+
} else {
|
|
52
|
+
run()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scanAndMount(scope: ParentNode): void {
|
|
57
|
+
if (scope instanceof HTMLElement && scope.hasAttribute('x-data')) mountElement(scope)
|
|
58
|
+
for (const el of Array.from(scope.querySelectorAll<HTMLElement>('[x-data]'))) {
|
|
59
|
+
mountElement(el)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mountElement(el: HTMLElement): void {
|
|
64
|
+
if (mounted.has(el)) return
|
|
65
|
+
const name = el.getAttribute('x-data') ?? ''
|
|
66
|
+
const behavior = registry.get(name)
|
|
67
|
+
if (!behavior) {
|
|
68
|
+
// Behavior chunk not loaded yet → fetch it on demand, then mount this name.
|
|
69
|
+
loadBehavior(name)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
let props: unknown = {}
|
|
73
|
+
const rawProps = el.getAttribute('x-props')
|
|
74
|
+
if (rawProps) {
|
|
75
|
+
try {
|
|
76
|
+
props = JSON.parse(rawProps)
|
|
77
|
+
} catch {
|
|
78
|
+
console.warn(`[brust] x-props on "${name}" is not valid JSON`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const instance = behavior({ el, props })
|
|
82
|
+
const m: Mounted = { disposers: [] }
|
|
83
|
+
mounted.set(el, m)
|
|
84
|
+
bindTree(el, instance, m.disposers)
|
|
85
|
+
if (typeof instance.init === 'function') {
|
|
86
|
+
try {
|
|
87
|
+
Promise.resolve((instance.init as () => unknown)()).catch((e) =>
|
|
88
|
+
console.error('[brust] x-data init() failed:', e),
|
|
89
|
+
)
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error('[brust] x-data init() threw:', e)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Dynamically import a component's behavior chunk (once), then mount every pending
|
|
97
|
+
// element for that name. The chunk self-registers via the global register handle.
|
|
98
|
+
function loadBehavior(name: string): void {
|
|
99
|
+
if (registry.has(name) || loading.has(name)) return
|
|
100
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
101
|
+
console.warn(`[brust] unsafe x-data component name "${name}" — not loaded`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// Promise.resolve().then(...) so a synchronous import() throw (e.g. happy-dom in
|
|
105
|
+
// unit tests, or a bad specifier) becomes a rejection the .catch() handles.
|
|
106
|
+
const p = Promise.resolve()
|
|
107
|
+
.then(() => import(/* @vite-ignore */ `${CHUNK_BASE}${name}.directive.js`))
|
|
108
|
+
.then(() => {
|
|
109
|
+
if (!registry.has(name)) {
|
|
110
|
+
console.warn(`[brust] "${name}.directive.js" loaded but did not register "${name}"`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
// Mount every element waiting on this name (initial + swapped-in).
|
|
114
|
+
if (typeof document !== 'undefined') {
|
|
115
|
+
for (const el of Array.from(document.querySelectorAll<HTMLElement>(`[x-data="${name}"]`))) {
|
|
116
|
+
mountElement(el)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
.catch((e) => console.error(`[brust] failed to load directive component "${name}":`, e))
|
|
121
|
+
loading.set(name, p)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bind this element's directives, then recurse — but never descend into a nested
|
|
125
|
+
// [x-data] (it owns its own subtree and is mounted independently).
|
|
126
|
+
function bindTree(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
|
|
127
|
+
if (el.hasAttribute('x-for')) {
|
|
128
|
+
bindFor(el, instance, disposers)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
bindAttrs(el, instance, disposers)
|
|
132
|
+
for (const child of Array.from(el.children)) {
|
|
133
|
+
if (!(child instanceof HTMLElement)) continue
|
|
134
|
+
if (child.hasAttribute('x-data')) continue
|
|
135
|
+
bindTree(child, instance, disposers)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FOR_RE = /^\s*(\w+)\s+in\s+([\w.]+)\s*$/
|
|
140
|
+
|
|
141
|
+
// `x-for="item in member"` — the element is the template. Replace it with a comment
|
|
142
|
+
// anchor; on each change of `member`, clear previous clones and render one per item,
|
|
143
|
+
// binding each clone with a child scope { [item]: value } prototype-linked to the
|
|
144
|
+
// instance (so instance members + methods stay visible). v1 = full re-render.
|
|
145
|
+
function bindFor(tplEl: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
|
|
146
|
+
const raw = tplEl.getAttribute('x-for') ?? ''
|
|
147
|
+
const m = FOR_RE.exec(raw)
|
|
148
|
+
if (!m) {
|
|
149
|
+
console.warn(`[brust] malformed x-for expression: "${raw}"`)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const itemName = m[1] as string
|
|
153
|
+
const listPath = m[2] as string
|
|
154
|
+
const parent = tplEl.parentNode
|
|
155
|
+
if (!parent) return
|
|
156
|
+
const anchor = tplEl.ownerDocument.createComment(`x-for:${itemName}`)
|
|
157
|
+
parent.insertBefore(anchor, tplEl)
|
|
158
|
+
tplEl.removeAttribute('x-for')
|
|
159
|
+
const template = tplEl.cloneNode(true) as HTMLElement
|
|
160
|
+
tplEl.remove()
|
|
161
|
+
|
|
162
|
+
const rendered: HTMLElement[] = []
|
|
163
|
+
const childDisposers: Array<() => void> = []
|
|
164
|
+
|
|
165
|
+
const clear = () => {
|
|
166
|
+
for (const d of childDisposers.splice(0)) {
|
|
167
|
+
try {
|
|
168
|
+
d()
|
|
169
|
+
} catch {
|
|
170
|
+
/* keep clearing */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const node of rendered.splice(0)) node.remove()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
disposers.push(
|
|
177
|
+
effect(() => {
|
|
178
|
+
clear()
|
|
179
|
+
const list = read(instance, listPath)
|
|
180
|
+
if (!Array.isArray(list)) return
|
|
181
|
+
for (const item of list) {
|
|
182
|
+
const clone = template.cloneNode(true) as HTMLElement
|
|
183
|
+
const childScope: Instance = Object.create(instance)
|
|
184
|
+
childScope[itemName] = item
|
|
185
|
+
bindTree(clone, childScope, childDisposers)
|
|
186
|
+
parent.insertBefore(clone, anchor) // before anchor → preserves order
|
|
187
|
+
rendered.push(clone)
|
|
188
|
+
}
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
disposers.push(clear)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void>): void {
|
|
195
|
+
for (const attr of Array.from(el.attributes)) {
|
|
196
|
+
const name = attr.name
|
|
197
|
+
const value = attr.value
|
|
198
|
+
if (name === 'x-data' || name === 'x-props') continue
|
|
199
|
+
if (name === 'x-text') {
|
|
200
|
+
disposers.push(
|
|
201
|
+
effect(() => {
|
|
202
|
+
const v = read(scope, value)
|
|
203
|
+
el.textContent = v == null ? '' : String(v)
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (name === 'x-show') {
|
|
209
|
+
disposers.push(
|
|
210
|
+
effect(() => {
|
|
211
|
+
el.style.display = read(scope, value) ? '' : 'none'
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (name.startsWith('x-bind-')) {
|
|
217
|
+
const target = name.slice('x-bind-'.length)
|
|
218
|
+
disposers.push(
|
|
219
|
+
effect(() => {
|
|
220
|
+
setBound(el, target, read(scope, value))
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (name.startsWith('x-on-')) {
|
|
226
|
+
const eventName = name.slice('x-on-'.length)
|
|
227
|
+
const handler = (e: Event) => callMethod(scope, value, e)
|
|
228
|
+
el.addEventListener(eventName, handler)
|
|
229
|
+
disposers.push(() => el.removeEventListener(eventName, handler))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function observe(): void {
|
|
235
|
+
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
|
|
236
|
+
if (!document.body) return // nothing to observe yet (called pre-<body>); start() re-runs on DOMContentLoaded
|
|
237
|
+
const obs = new MutationObserver((records) => {
|
|
238
|
+
for (const rec of records) {
|
|
239
|
+
for (const node of Array.from(rec.removedNodes)) {
|
|
240
|
+
if (node instanceof HTMLElement) disposeTree(node)
|
|
241
|
+
}
|
|
242
|
+
for (const node of Array.from(rec.addedNodes)) {
|
|
243
|
+
if (node instanceof HTMLElement) scanAndMount(node)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
obs.observe(document.body, { childList: true, subtree: true })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function disposeTree(node: HTMLElement): void {
|
|
251
|
+
if (mounted.has(node)) disposeElement(node)
|
|
252
|
+
for (const el of Array.from(node.querySelectorAll<HTMLElement>('[x-data]'))) {
|
|
253
|
+
disposeElement(el)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function disposeElement(el: HTMLElement): void {
|
|
258
|
+
const m = mounted.get(el)
|
|
259
|
+
if (!m) return
|
|
260
|
+
for (const d of m.disposers.splice(0)) {
|
|
261
|
+
try {
|
|
262
|
+
d()
|
|
263
|
+
} catch {
|
|
264
|
+
// disposer must not break sibling disposal
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
mounted.delete(el)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const BOOL_PROPS = new Set(['disabled', 'checked', 'hidden', 'readonly', 'required', 'selected'])
|
|
271
|
+
|
|
272
|
+
/** Apply a bound value to a DOM attr/property. class → className; boolean props →
|
|
273
|
+
* property (when present) + attribute presence; value → property; else attribute. */
|
|
274
|
+
export function setBound(el: HTMLElement, attr: string, value: unknown): void {
|
|
275
|
+
if (attr === 'class') {
|
|
276
|
+
el.className = value == null ? '' : String(value)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (attr === 'value') {
|
|
280
|
+
;(el as unknown as { value: unknown }).value = value == null ? '' : value
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (BOOL_PROPS.has(attr)) {
|
|
284
|
+
const on = Boolean(value)
|
|
285
|
+
if (attr in el) (el as unknown as Record<string, unknown>)[attr] = on
|
|
286
|
+
if (on) el.setAttribute(attr, '')
|
|
287
|
+
else el.removeAttribute(attr)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
if (value == null || value === false) el.removeAttribute(attr)
|
|
291
|
+
else el.setAttribute(attr, String(value))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- reactive read helpers (used by later tasks) -------------------------------
|
|
295
|
+
|
|
296
|
+
/** Walk a dotted member path against `scope`; at the LEAF, call signals/computeds/
|
|
297
|
+
* functions to obtain the reactive value (this read is what `effect` tracks). */
|
|
298
|
+
export function read(scope: Instance, path: string): unknown {
|
|
299
|
+
let cur: unknown = scope
|
|
300
|
+
for (const part of path.split('.')) {
|
|
301
|
+
if (cur == null) return undefined
|
|
302
|
+
cur = (cur as Record<string, unknown>)[part]
|
|
303
|
+
}
|
|
304
|
+
if (isSignal(cur) || isComputed(cur)) return (cur as () => unknown)()
|
|
305
|
+
if (typeof cur === 'function') return (cur as () => unknown)()
|
|
306
|
+
return cur
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Resolve a dotted path WITHOUT calling the leaf (for x-on handlers). */
|
|
310
|
+
export function resolveRaw(scope: Instance, path: string): unknown {
|
|
311
|
+
let cur: unknown = scope
|
|
312
|
+
for (const part of path.split('.')) {
|
|
313
|
+
if (cur == null) return undefined
|
|
314
|
+
cur = (cur as Record<string, unknown>)[part]
|
|
315
|
+
}
|
|
316
|
+
return cur
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Resolve `path` on `scope` and, if a function, call it with the event. */
|
|
320
|
+
export function callMethod(scope: Instance, path: string, event: Event): void {
|
|
321
|
+
const fn = resolveRaw(scope, path)
|
|
322
|
+
if (typeof fn === 'function') (fn as (e: Event) => unknown)(event)
|
|
323
|
+
else console.warn(`[brust] x-on target "${path}" is not a function`)
|
|
324
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { storeScriptTag } from '../store/serialize.ts'
|
|
2
|
+
|
|
3
|
+
const ENC = new TextEncoder()
|
|
4
|
+
let warned = false
|
|
5
|
+
|
|
6
|
+
/** @internal — used by tests to reset the warn-once flag. */
|
|
7
|
+
export function _resetWarnedForTests(): void {
|
|
8
|
+
warned = false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build the combined `<script type="application/json">` blob for every touched
|
|
12
|
+
* store. Returns '' when the snapshot is null/empty. */
|
|
13
|
+
export function buildStoreScripts(snap: Record<string, Record<string, unknown>> | null): string {
|
|
14
|
+
if (!snap) return ''
|
|
15
|
+
let out = ''
|
|
16
|
+
for (const [name, state] of Object.entries(snap)) out += storeScriptTag(name, state)
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Splice the store snapshot `<script>`(s) into `body` immediately before the
|
|
21
|
+
* first `</head>`. Returns the original body untouched if the snapshot is
|
|
22
|
+
* null/empty or if `</head>` is absent. */
|
|
23
|
+
export function injectBrustStore(
|
|
24
|
+
body: Uint8Array,
|
|
25
|
+
snap: Record<string, Record<string, unknown>> | null,
|
|
26
|
+
): Uint8Array {
|
|
27
|
+
const scripts = buildStoreScripts(snap)
|
|
28
|
+
if (!scripts) return body
|
|
29
|
+
const pos = findHeadCloseTag(body)
|
|
30
|
+
if (pos < 0) {
|
|
31
|
+
if (!warned) {
|
|
32
|
+
console.warn('[brust] store: no </head> in first chunk; snapshot not injected')
|
|
33
|
+
warned = true
|
|
34
|
+
}
|
|
35
|
+
return body
|
|
36
|
+
}
|
|
37
|
+
const tagBytes = ENC.encode(scripts)
|
|
38
|
+
const out = new Uint8Array(body.length + tagBytes.length)
|
|
39
|
+
out.set(body.subarray(0, pos), 0)
|
|
40
|
+
out.set(tagBytes, pos)
|
|
41
|
+
out.set(body.subarray(pos), pos + tagBytes.length)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
46
|
+
const LT = 0x3c,
|
|
47
|
+
SL = 0x2f,
|
|
48
|
+
GT = 0x3e
|
|
49
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
50
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
51
|
+
if (!isLetter(body[i + 2], 0x48)) continue // H
|
|
52
|
+
if (!isLetter(body[i + 3], 0x45)) continue // E
|
|
53
|
+
if (!isLetter(body[i + 4], 0x41)) continue // A
|
|
54
|
+
if (!isLetter(body[i + 5], 0x44)) continue // D
|
|
55
|
+
if (body[i + 6] !== GT) continue
|
|
56
|
+
return i
|
|
57
|
+
}
|
|
58
|
+
return -1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isLetter(b: number, u: number): boolean {
|
|
62
|
+
return b === u || b === (u | 0x20)
|
|
63
|
+
}
|
package/runtime/render/stream.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { injectCssLink } from './inject-css-link.ts'
|
|
|
10
10
|
import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
|
|
11
11
|
import { injectDevClient } from './inject-dev-client.ts'
|
|
12
12
|
import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
|
|
13
|
+
import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
|
|
13
14
|
import { getDevClientSnippet } from '../dev/inject.ts'
|
|
14
15
|
|
|
15
16
|
export interface RenderBranchStreamingArgs {
|
|
@@ -31,6 +32,10 @@ export interface RenderBranchStreamingArgs {
|
|
|
31
32
|
/** The matched route's fullPath (e.g. '/' or '/blog/{slug}'). Used to
|
|
32
33
|
* combine global CSS hrefs with per-route CSS hrefs before injection. */
|
|
33
34
|
routePath?: string
|
|
35
|
+
/** Per-request store snapshot collected after loaders run. Injected as a
|
|
36
|
+
* `<script data-brust-store="…">` blob before `</head>` (buffering) or into
|
|
37
|
+
* the streaming first-chunk prepend. Null/undefined → no injection. */
|
|
38
|
+
storeSnapshot?: Record<string, Record<string, unknown>> | null
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const encoder = new TextEncoder()
|
|
@@ -150,6 +155,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
150
155
|
body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
|
|
151
156
|
body = injectDevClient(body, getDevClientSnippet())
|
|
152
157
|
body = injectActionPrefix(body, getActionPrefixSnippet())
|
|
158
|
+
body = injectBrustStore(body, args.storeSnapshot ?? null)
|
|
153
159
|
const meta = makeMeta({
|
|
154
160
|
status: successStatus,
|
|
155
161
|
streaming: false,
|
|
@@ -211,8 +217,14 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
211
217
|
.join('')
|
|
212
218
|
const devTag = getDevClientSnippet() ?? ''
|
|
213
219
|
const prefixTag = getActionPrefixSnippet() ?? ''
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
|
|
221
|
+
if (
|
|
222
|
+
linkTagsStr.length > 0 ||
|
|
223
|
+
devTag.length > 0 ||
|
|
224
|
+
prefixTag.length > 0 ||
|
|
225
|
+
storeTag.length > 0
|
|
226
|
+
) {
|
|
227
|
+
const prepend = encoder.encode(linkTagsStr + prefixTag + devTag + storeTag)
|
|
216
228
|
const out = new Uint8Array(flushed.length + prepend.length)
|
|
217
229
|
out.set(flushed, 0)
|
|
218
230
|
out.set(prepend, flushed.length)
|
package/runtime/routes.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Writable } from 'node:stream'
|
|
|
10
10
|
import { Buffer } from 'node:buffer'
|
|
11
11
|
import * as native from './index.js'
|
|
12
12
|
import { renderBranchStreaming } from './render/stream.ts'
|
|
13
|
+
import { runInStoreContext, collectSnapshot } from './store/server-context.ts'
|
|
13
14
|
import {
|
|
14
15
|
loadIslandManifest,
|
|
15
16
|
resolveIslandContext,
|
|
@@ -636,7 +637,11 @@ export function makeRenderer(
|
|
|
636
637
|
if (leaf.loader) {
|
|
637
638
|
const ctx = { params: call.params, path: call.path, req: call.req }
|
|
638
639
|
try {
|
|
639
|
-
|
|
640
|
+
// Native loaders may write to a defineStore; run them in a per-request
|
|
641
|
+
// store scope so those writes are isolated per request. No snapshot is
|
|
642
|
+
// collected and no <script> is injected on native paths — Spec B owns
|
|
643
|
+
// native store delivery (hard non-goal here).
|
|
644
|
+
data = await runInStoreContext(() => leaf.loader!(ctx as any))
|
|
640
645
|
} catch (err) {
|
|
641
646
|
console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
|
|
642
647
|
// FAST LANE: native routes take dispatch_single_chunk (no chunk
|
|
@@ -745,36 +750,45 @@ export function makeRenderer(
|
|
|
745
750
|
}
|
|
746
751
|
}
|
|
747
752
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
753
|
+
// Wrap the loader run (inside buildRenderElement) AND the React render in
|
|
754
|
+
// one AsyncLocalStorage store scope so any defineStore read during loaders
|
|
755
|
+
// or render resolves the same per-request instance. Snapshot is collected
|
|
756
|
+
// after loaders (buildRenderElement resolved) — that's where Spec A stores
|
|
757
|
+
// are seeded — and threaded into the render for <script> injection.
|
|
758
|
+
return await runInStoreContext(async () => {
|
|
759
|
+
let element: ReactNode
|
|
760
|
+
let errorBoundary: ComponentType<{ error: Error }>
|
|
761
|
+
try {
|
|
762
|
+
element = await buildRenderElement(call, flat, opts.getWorkerId)
|
|
763
|
+
errorBoundary =
|
|
764
|
+
flat.errorBoundary ??
|
|
765
|
+
(({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
|
|
766
|
+
} catch (err) {
|
|
767
|
+
// Setup failure BEFORE renderToPipeableStream — loader throw, params
|
|
768
|
+
// bind throw. Shape matches the legacy "internal error" path so
|
|
769
|
+
// existing integration tests stay green.
|
|
770
|
+
console.error(`[brust] render setup failed:`, err)
|
|
771
|
+
return await emitSingleChunkResponse(view, napi, workerId, encoder, {
|
|
772
|
+
status: 500,
|
|
773
|
+
contentType: 'text/html; charset=utf-8',
|
|
774
|
+
body: 'internal error',
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
const storeSnapshot = collectSnapshot()
|
|
778
|
+
await renderBranchStreaming({
|
|
779
|
+
element,
|
|
780
|
+
view,
|
|
781
|
+
workerId,
|
|
782
|
+
napi,
|
|
783
|
+
errorBoundary,
|
|
784
|
+
status: verdict.status,
|
|
785
|
+
headers: verdict.headers,
|
|
786
|
+
routePath: flat.fullPath,
|
|
787
|
+
storeSnapshot,
|
|
764
788
|
})
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
element,
|
|
768
|
-
view,
|
|
769
|
-
workerId,
|
|
770
|
-
napi,
|
|
771
|
-
errorBoundary,
|
|
772
|
-
status: verdict.status,
|
|
773
|
-
headers: verdict.headers,
|
|
774
|
-
routePath: flat.fullPath,
|
|
789
|
+
// renderBranchStreaming wrote via the chunk channel.
|
|
790
|
+
return 0
|
|
775
791
|
})
|
|
776
|
-
// renderBranchStreaming wrote via the chunk channel.
|
|
777
|
-
return 0
|
|
778
792
|
}
|
|
779
793
|
if (call.kind === 'navigation') {
|
|
780
794
|
await navigationBranch(call, byRouteId, view, encoder, opts.getWorkerId)
|
|
@@ -942,16 +956,26 @@ async function navigationBranch(
|
|
|
942
956
|
// whose loader fields arrive undefined → throws → 500 → full-reload fallback
|
|
943
957
|
// on every internal link.
|
|
944
958
|
let fullHtml: string
|
|
959
|
+
// React nav: snapshot of stores seeded during loader+render, shipped in the
|
|
960
|
+
// nav payload so the client can apply it to its live stores. Native nav
|
|
961
|
+
// collects no snapshot (Spec B owns native store delivery).
|
|
962
|
+
let store: Record<string, Record<string, unknown>> | null = null
|
|
945
963
|
if (flat.nativeTemplate !== undefined) {
|
|
946
964
|
fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId)
|
|
947
965
|
} else {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
966
|
+
// Wrap loader run (inside buildRenderElement) + render in one store scope so
|
|
967
|
+
// store reads resolve the per-request instance; collect after render.
|
|
968
|
+
fullHtml = await runInStoreContext(async () => {
|
|
969
|
+
const element = await buildRenderElement(call as any, flat, getWorkerId)
|
|
970
|
+
if (!element) throw new Error('render setup failed')
|
|
971
|
+
// Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
|
|
972
|
+
// their RESOLVED markup, not the fallback. renderToString would only
|
|
973
|
+
// capture the shell — navigating SPA-style to a Suspense-using route
|
|
974
|
+
// would otherwise ship "loading…" and never recover.
|
|
975
|
+
const html = await renderToAwaitedString(element)
|
|
976
|
+
store = collectSnapshot()
|
|
977
|
+
return html
|
|
978
|
+
})
|
|
955
979
|
}
|
|
956
980
|
|
|
957
981
|
// Extract <main> inner content. If the page didn't render a <main>,
|
|
@@ -971,7 +995,7 @@ async function navigationBranch(
|
|
|
971
995
|
const titleMatch = fullHtml.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
|
|
972
996
|
const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
|
|
973
997
|
|
|
974
|
-
const body = JSON.stringify({ html: innerHtml, title })
|
|
998
|
+
const body = JSON.stringify({ html: innerHtml, title, store })
|
|
975
999
|
await emitSingleChunkResponse(view, napi, workerId, encoder, {
|
|
976
1000
|
status: 200,
|
|
977
1001
|
contentType: 'application/json; charset=utf-8',
|
|
@@ -1010,7 +1034,11 @@ async function renderNativeRouteToHtml(
|
|
|
1010
1034
|
|
|
1011
1035
|
let data: unknown = {}
|
|
1012
1036
|
if (leaf.loader) {
|
|
1013
|
-
|
|
1037
|
+
// Per-request store scope for native loader writes (isolation only). No
|
|
1038
|
+
// snapshot collected / no <script> injected — Spec B owns native delivery.
|
|
1039
|
+
data = await runInStoreContext(() =>
|
|
1040
|
+
leaf.loader!({ params: call.params, path: call.path, req: call.req } as any),
|
|
1041
|
+
)
|
|
1014
1042
|
}
|
|
1015
1043
|
|
|
1016
1044
|
if (isNativeVerdict(data)) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// runtime/store/client-hydrate.ts — apply a nav-payload snapshot to live client stores.
|
|
2
|
+
export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
|
|
3
|
+
const w = window as unknown as {
|
|
4
|
+
__BRUST_STORES__?: Record<string, { instance: Record<string, unknown> }>
|
|
5
|
+
}
|
|
6
|
+
const reg = w.__BRUST_STORES__
|
|
7
|
+
if (!reg) return
|
|
8
|
+
for (const [name, state] of Object.entries(snap)) {
|
|
9
|
+
const entry = reg[name]
|
|
10
|
+
if (!entry) continue // handle not defined yet on client → skip (initial-load <script> covers it)
|
|
11
|
+
for (const key of Object.keys(state)) {
|
|
12
|
+
const v = entry.instance[key] as { set?: (x: unknown) => void } | undefined
|
|
13
|
+
if (v && typeof v.set === 'function') v.set(state[key])
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|