@brust/runtime-dom 0.0.0-stage → 2.0.0-alpha.2

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 CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @brust/runtime-dom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React-free browser runtime that binds a compiled client chunk (`defineBehavior`) to server-rendered HTML
4
+ through `x-*` attributes. **This README is the output format the M1c client backend prints against**; every
5
+ behaviour below is pinned by a test in `test/`. Zero dependencies, eval-free, bundle < 17 KB minified
6
+ (`bun scripts/build.ts`).
7
+
8
+ ## Public surface
9
+
10
+ ```ts
11
+ import { signal, computed, effect, batch, untracked, defineBehavior, setChunkLoader, mount, unmount } from '@brust/runtime-dom'
12
+ ```
13
+
14
+ - Signals (`test/signal.test.ts`): writes are `Object.is`-deduped; `computed` is lazy and cached; `effect(fn)` runs now and on
15
+ change, a returned function is a cleanup that runs before each re-run and on dispose; `batch` defers effects to its end;
16
+ `untracked` reads without subscribing.
17
+ - `defineBehavior(name, factory)`: `factory({ el, props, effect, onCleanup, ref })` returns the instance **members**
18
+ (signals, computeds, functions, plain values). `props` is a **signal** of the props object. An `init` member, if a function, runs after the factory.
19
+ - `setChunkLoader(load)`: called once per unknown behavior name; the loader is expected to call `defineBehavior(name, …)`. Default: none (hosts wait).
20
+ - `mount(root = document.body)`: idempotent; mounts every `[x-data]` in document order (parents first) and starts a `MutationObserver`
21
+ that mounts added hosts and disposes removed ones (a removed-then-reinserted host is disposed, then mounted fresh).
22
+ `unmount(root)` disposes instances under `root` and stops the observer. A factory that throws is logged and its instance discarded; other hosts still mount.
23
+
24
+ ## Directive value grammar
25
+
26
+ ```
27
+ value := path (":" bindings)?
28
+ path := ident ("." ident)* // looked up on the instance members; a signal/computed hop is unwrapped
29
+ bindings := ident ("," ident)* // loop-scope names (x-for item/index); the member must be a function and is called with them
30
+ ```
31
+
32
+ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the DOM is left as rendered. An unknown member behaves the same.
33
+
34
+ ## Attributes
35
+
36
+ - **`x-data="name"`**: one instance per host. Nested hosts are bound by their own instance, not the parent's walker.
37
+ - **`x-props='{"json":1}'`**: seeds `ctx.props`. Invalid JSON warns and seeds `{}`.
38
+ - **`x-props-bind="member[:binding]"`**: on a child host, rebinds the child's `props` signal reactively to the nearest ancestor instance's member
39
+ (an object, typically a `computed`; function values pass through). Inside `x-for` the row's scope supplies the bindings. If the parent is not mounted yet
40
+ (chunk still loading) the child links up when the parent mounts. No ancestor: warns, keeps the `x-props` seed.
41
+ - **`x-text="value"`**: sets `textContent`; `null`/`undefined` → `''`.
42
+ - **`x-show="value"`**: `style.display = ''` when truthy, `'none'` otherwise.
43
+ - **`x-if="value"`**: the element is replaced by a `<!--x-if-->` comment anchor; a clone is inserted after it while truthy and removed otherwise.
44
+ Nested `x-data` hosts in the clone mount/dispose with it. Server HTML omits the element when false.
45
+ - **`x-bind-<attr>="value"`**: `class` → `className`; `value` → `.value`; `disabled checked selected readonly required hidden open multiple` → property + attribute (present/absent);
46
+ anything else: `null`/`undefined`/`false` removes the attribute, otherwise `setAttribute`. **Refused** (warn once): `on*` and `srcdoc` attributes, and, in `href src action formaction poster data`, any URL whose parsed scheme is not `http(s):`/`mailto:`/`tel:` (relative URLs are fine; `java\tscript:` is caught because the URL is parsed, not pattern-matched).
47
+ - **`x-on-<event>="member[:binding]"`**: calls the member with the bound scope values first, then the event: `pick(item, index, event)`.
48
+ - **`x-model="member"`**: the member must be a writable signal (else warns). text-like: `input` event ↔ `.value`; checkbox: `change` ↔ boolean `.checked`;
49
+ radio: `change` writes `.value` when checked, signal sets `.checked`; single `select`: `change` ↔ `.value`, re-applied whenever its options change. `select[multiple]` is unsupported (warns).
50
+ - **`x-for="item[, index] in source by keyFn"`**: `source := path (":" bindings)?` is a member path to an array (or signal/computed of one); with bindings the member is a function called with the outer loop names, so a nested list reads the outer row, e.g. `<tr x-for="row in rows by k1"><td x-for="c in cells:row by k2">`; `keyFn` is a member function of the item.
51
+ Server-rendered sibling rows carrying the same `x-for` are **adopted in order** on first run; with zero rows the server emits a `<!--x-for-->` comment followed by one `hidden` template element
52
+ (carrying `x-for`) which is cloned. Rows are reconciled by key (moves keep DOM identity); a reused row whose item/index changed has its binders re-run. Duplicate keys warn once and fall back to index identity.
53
+ Elements with `x-for` are owned by the directive: their other `x-*` attributes are bound on the rows, with scope `{ item, index }`.
54
+ - **`x-ref="name"`**: `ctx.ref('name').current` is the element after mount, `null` after dispose.
55
+
56
+ ## First-paint rule
57
+
58
+ Nothing re-renders on mount: server HTML is the initial state. A binder writes the DOM only when its effect re-runs after a change, **or** when the initial `x-text` value differs from the server text,
59
+ in which case it corrects it and warns once (`first-paint mismatch …`): a mismatch is a compiler bug the runtime must not hide. Elements created after mount (new rows, `x-if` clones) never trip this check.
60
+
61
+ ## Gates
62
+
63
+ `bun test` · `bunx tsc --noEmit -p .` (`bun check` needs Bun ≥ 1.4.3) · `bun scripts/build.ts` (fails if the bundle imports React).
64
+
65
+ ## React islands
66
+
67
+ The server paints a react-tier component as
68
+ `<brust-island data-id="<componentId>" x-props='<json>'>…server HTML…</brust-island>`.
69
+ The island's chunk registers its hydrate function without importing the runtime:
70
+
71
+ ;(globalThis.__brustIslands ||= []).push(['<componentId>', (host, props) => hydrateRoot(host, <Comp {...props}/>)])
72
+ globalThis.__brustIslandReady?.()
73
+
74
+ The runtime hydrates every host once, when the browser is idle (`requestIdleCallback`, else
75
+ `setTimeout(…, 1)`), and sets `data-hydrated="1"` on success. Load order between the runtime
76
+ script and island chunks does not matter. Bad `x-props` JSON hydrates with `{}` and warns once;
77
+ a hydrate that throws is reported on `console.error` and the server HTML stays. This is the only
78
+ strategy in M2 (spec S12).
79
+
80
+ ## Store
81
+ The 0.1.x `brustjs/store` API on the same reactive core the directives use (one graph, even across module copies:
82
+ tracking state lives on `globalThis[Symbol.for('brust.reactive.ctx')]`).
83
+
84
+ - `@brust/runtime-dom/store` (also the runtime entry, and `@brust/core/store`): `signal computed effect batch
85
+ untracked isSignal isComputed defineStore` and the types `Signal Computed Dispose StoreHandle Snapshot`.
86
+ - `defineStore(name, factory)` returns a handle: `store.field`, `store.snapshot()`, `store.serialize()`,
87
+ `store.hydrate(state)`, `store.subscribe(cb)`. First factory wins per name.
88
+ - The browser keeps one instance per name in `globalThis[Symbol.for('brust.stores')]`. The first access to a
89
+ name seeds it from `<script type="application/json" data-brust-store="NAME">` (lazy, once).
90
+ - `@brust/runtime-dom/store/react` (also `@brust/core/store/react`): `useStore(store)` via
91
+ `useSyncExternalStore`. React is an **optional peer**, imported only by this subpath; the runtime bundle stays
92
+ React-free.
93
+ - `@brust/runtime-dom/store/internal`: the server-resolver hook and serialization helpers the Bun worker uses.
94
+ Not part of the public surface.
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-alpha.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ },
10
+ "./store": {
11
+ "types": "./src/store/index.ts",
12
+ "default": "./src/store/index.ts"
13
+ },
14
+ "./store/react": {
15
+ "types": "./src/store/react.ts",
16
+ "default": "./src/store/react.ts"
17
+ },
18
+ "./store/internal": {
19
+ "types": "./src/store/internal.ts",
20
+ "default": "./src/store/internal.ts"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "test": "bun test",
25
+ "typecheck": "bun check",
26
+ "build": "bun scripts/build.ts"
27
+ },
28
+ "dependencies": {},
29
+ "peerDependencies": {
30
+ "react": "^19.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "react": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "@happy-dom/global-registrator": "^20.0.0",
39
+ "@types/bun": "^1.4.0",
40
+ "@types/react": "^19.2.0",
41
+ "@types/react-dom": "^19.2.0",
42
+ "happy-dom": "^20.0.0",
43
+ "react": "^19.2.0",
44
+ "react-dom": "^19.2.0",
45
+ "typescript": "^7.0.2"
46
+ },
47
+ "files": [
48
+ "src"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
@@ -0,0 +1,32 @@
1
+ import { BINDERS } from './index'
2
+ import { parsed, read } from './common'
3
+ import { warnOnce } from '../warn'
4
+
5
+ // Attributes that execute or load code from a string; a bound value must never reach them as-is.
6
+ const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
7
+ // Scheme allowlist on the parsed URL (relative URLs resolve to http): new URL() applies the browser's tab/newline/control-char stripping.
8
+ const safeUrl = (v: string): boolean => { try { return ['http:', 'https:', 'mailto:', 'tel:'].includes(new URL(v, 'http://x').protocol) } catch { return false } }
9
+ const BOOLEAN = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'open', 'multiple'])
10
+ BINDERS.push({
11
+ match: (a) => a.startsWith('x-bind-'),
12
+ bind: ({ inst, el, attr, raw, scope }) => {
13
+ const name = attr.slice('x-bind-'.length)
14
+ if (name.startsWith('on') || name === 'srcdoc') { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name} refused (use x-on-*)`); return }
15
+ const p = parsed(inst, attr, raw); if (!p) return
16
+ inst.effect(() => {
17
+ const r = read(inst, p, scope, attr); if (!r.ok) return
18
+ const v = r.value
19
+ if (name === 'class') { (el as HTMLElement).className = v == null ? '' : String(v); return }
20
+ if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
21
+ if (BOOLEAN.has(name)) {
22
+ const on = Boolean(v)
23
+ if (name in el) (el as unknown as Record<string, unknown>)[name] = on
24
+ if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
25
+ return
26
+ }
27
+ if (v == null || v === false) { el.removeAttribute(name); return }
28
+ if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
29
+ el.setAttribute(name, String(v))
30
+ })
31
+ },
32
+ })
@@ -0,0 +1,23 @@
1
+ import { parseValue, resolve, resolveMember, type ParsedValue, type Scope } from '../value'
2
+ import { warnOnce } from '../warn'
3
+ import type { Instance } from '../instance'
4
+
5
+ export function parsed(inst: Instance, attr: string, raw: string): ParsedValue | null {
6
+ const p = parseValue(raw)
7
+ if (!p) warnOnce(`p:${inst.name}:${attr}:${raw}`, `${attr}="${raw}" in ${inst.name}: not a member path`)
8
+ return p
9
+ }
10
+ export function read(inst: Instance, p: ParsedValue, scope: Scope, attr: string): { ok: boolean; value: unknown } {
11
+ const r = resolve(inst.members, p, scope)
12
+ if (!r.ok) warnOnce(`m:${inst.name}:${attr}:${p.path.join('.')}`, `${attr}="${p.path.join('.')}" in ${inst.name}: no such member`)
13
+ return r.ok ? r : { ok: false, value: undefined }
14
+ }
15
+ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
16
+ const m = resolveMember(inst.members, p)
17
+ if (m === undefined) warnOnce(`m:${inst.name}:${attr}:${p.path.join('.')}`, `${attr}="${p.path.join('.')}" in ${inst.name}: no such member`)
18
+ return m
19
+ }
20
+ export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
21
+ if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
22
+ warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
23
+ }
@@ -0,0 +1,85 @@
1
+ import { BINDERS, bindElement, walkChildren } from './index'
2
+ import { parsed, read, member } from './common'
3
+ import { warnOnce } from '../warn'
4
+ import { computed, signal, type Signal } from '../signal'
5
+ import { hooks } from '../instance'
6
+
7
+ type RowScope = Record<string, unknown> & { __own: Signal<number> }
8
+
9
+ const SYNTAX = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s+in\s+([A-Za-z_$][\w$.]*(?::[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?)\s+by\s+([A-Za-z_$][\w$.]*)\s*$/
10
+ const done = new WeakSet<Element>()
11
+
12
+ BINDERS.push({
13
+ match: (a) => a === 'x-for',
14
+ bind: ({ inst, el, attr, raw, scope }) => {
15
+ if (done.has(el)) return
16
+ const m = SYNTAX.exec(raw)
17
+ if (!m) { warnOnce(`for:${inst.name}:${raw}`, `x-for="${raw}" in ${inst.name}: bad syntax`); return }
18
+ const [, itemName, indexName, sourcePath, keyPath] = m as unknown as [string, string, string | undefined, string, string]
19
+ const src = parsed(inst, attr, sourcePath)!; const keyP = parsed(inst, attr, keyPath)!
20
+
21
+ // All server-rendered siblings carrying the same x-for are rows of this list; the first is the template.
22
+ const parent = el.parentElement!
23
+ const siblings = Array.from(parent.children).filter((c) => c.getAttribute('x-for') === raw)
24
+ siblings.forEach((s) => done.add(s))
25
+ const template = el.cloneNode(true) as Element
26
+ template.removeAttribute('x-for'); template.removeAttribute('hidden')
27
+ const anchor = document.createComment('x-for')
28
+ parent.insertBefore(anchor, el)
29
+ // Server-rendered rows (not the hidden zero-row template) are adopted in order on the first run.
30
+ const adopt = siblings.filter((s) => !s.hasAttribute('hidden'))
31
+ if (!adopt.includes(el)) el.remove()
32
+ let firstRun = true
33
+
34
+ let rows = new Map<unknown, Element[]>() // key -> elements (array to tolerate duplicates)
35
+ let order: Element[] = []
36
+
37
+ inst.effect(() => {
38
+ const r = read(inst, src, scope, attr); if (!r.ok) return
39
+ const list = Array.isArray(r.value) ? (r.value as unknown[]) : []
40
+ const keyFn = member(inst, keyP, attr)
41
+ if (typeof keyFn !== 'function') return
42
+ const created: Element[] = []
43
+ const next = new Map<unknown, Element[]>(); const nextOrder: Element[] = []
44
+ const seen = new Set<unknown>()
45
+ list.forEach((item, i) => {
46
+ let key = (keyFn as (x: unknown) => unknown)(item)
47
+ if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
48
+ seen.add(key)
49
+ const pool = rows.get(key)
50
+ let node = pool?.shift()
51
+ if (node) {
52
+ // Reused row: refresh its scope and poke its tick so every binder that read the scope re-runs.
53
+ const rs = (node as unknown as { __scope: RowScope }).__scope
54
+ if (!Object.is(rs[itemName], item) || (indexName && rs[indexName] !== i)) {
55
+ rs[itemName] = item; if (indexName) rs[indexName] = i
56
+ rs.__own.set((n) => n + 1)
57
+ }
58
+ }
59
+ if (!node) {
60
+ const adopted = firstRun ? adopt.shift() : undefined
61
+ node = adopted ?? (template.cloneNode(true) as Element)
62
+ if (!adopted) created.push(node)
63
+ adopted?.removeAttribute('x-for')
64
+ const own = signal(0)
65
+ const outer = scope.__tick as (() => number) | undefined
66
+ const rowScope: RowScope = { ...scope, [itemName]: item, ...(indexName ? { [indexName]: i } : {}), __own: own, __tick: computed(() => (outer ? outer() : 0) + own()) }
67
+ ;(node as unknown as { __scope: RowScope }).__scope = rowScope
68
+ // a row that is itself a nested host is bound by its own instance
69
+ if (!node.hasAttribute('x-data')) { bindElement(inst, node, rowScope); walkChildren(inst, node, rowScope) }
70
+ }
71
+ ;(next.get(key) ?? next.set(key, []).get(key)!).push(node)
72
+ nextOrder.push(node)
73
+ })
74
+ for (const [, pool] of rows) for (const stale of pool) stale.remove()
75
+ for (const left of adopt.splice(0)) left.remove()
76
+ firstRun = false
77
+ // place in order after the anchor
78
+ let ref: Node = anchor
79
+ for (const node of nextOrder) { if (ref.nextSibling !== node) parent.insertBefore(node, ref.nextSibling); ref = node }
80
+ rows = next; order = nextOrder
81
+ for (const n of created) hooks.mountTree(n) // nested hosts in new rows mount now, not on the observer's next tick
82
+ })
83
+ inst.onCleanup(() => { for (const n of order) n.remove() })
84
+ },
85
+ })
@@ -0,0 +1,27 @@
1
+ import { BINDERS, bindElement, walkChildren } from './index'
2
+ import { parsed, read } from './common'
3
+ import { hooks } from '../instance'
4
+ BINDERS.push({
5
+ match: (a) => a === 'x-if',
6
+ bind: ({ inst, el, attr, raw, scope }) => {
7
+ const p = parsed(inst, attr, raw); if (!p) return
8
+ const anchor = document.createComment('x-if')
9
+ const template = el.cloneNode(true) as Element
10
+ template.removeAttribute('x-if'); template.removeAttribute('hidden')
11
+ el.replaceWith(anchor) // the server-rendered element becomes the first clone below
12
+ let current: Element | null = null
13
+ inst.effect(() => {
14
+ const r = read(inst, p, scope, attr); if (!r.ok) return
15
+ const want = Boolean(r.value)
16
+ if (want && !current) {
17
+ current = template.cloneNode(true) as Element
18
+ anchor.after(current)
19
+ bindElement(inst, current, scope); walkChildren(inst, current, scope)
20
+ hooks.mountTree(current)
21
+ } else if (!want && current) {
22
+ current.remove(); current = null // the observer disposes nested hosts
23
+ }
24
+ })
25
+ inst.onCleanup(() => { current?.remove() })
26
+ },
27
+ })
@@ -0,0 +1,33 @@
1
+ import type { Instance } from '../instance'
2
+ import type { Scope } from '../value'
3
+
4
+ export type Binder = (ctx: { inst: Instance; el: Element; attr: string; raw: string; scope: Scope }) => void
5
+ /** attribute-name prefix → binder. Exact names first, then prefixes (x-bind-, x-on-). */
6
+ export const BINDERS: Array<{ match: (attr: string) => boolean; bind: Binder }> = []
7
+
8
+ const HOST_ATTR = 'x-data'
9
+ /** Install binders for every x-* attribute under `el` (inclusive), not descending into nested hosts. */
10
+ export function bindHost(inst: Instance, el: Element, scope: Scope): void {
11
+ bindElement(inst, el, scope)
12
+ walkChildren(inst, el, scope)
13
+ }
14
+ export function walkChildren(inst: Instance, el: Element, scope: Scope): void {
15
+ for (const child of Array.from(el.children)) {
16
+ if ('__scope' in child) continue // x-for row: already bound with its own scope
17
+ if (child.hasAttribute('x-for')) { bindElement(inst, child, scope); continue } // x-for owns the element (even when it is also a host)
18
+ if (child.hasAttribute(HOST_ATTR)) continue // nested host: its own instance binds it
19
+ bindElement(inst, child, scope)
20
+ if (!child.hasAttribute('x-if')) walkChildren(inst, child, scope) // x-if owns its subtree
21
+ }
22
+ }
23
+ export function bindElement(inst: Instance, el: Element, scope: Scope): void {
24
+ // Structural directives own the element: x-for/x-if re-create it from a template, so the
25
+ // remaining attributes are bound on the clones (with the right scope), never on this original.
26
+ const structural = el.hasAttribute('x-for') ? 'x-for' : el.hasAttribute('x-if') ? 'x-if' : null
27
+ for (const attr of Array.from(el.attributes)) {
28
+ if (structural && attr.name !== structural) continue
29
+ if (!attr.name.startsWith('x-') || attr.name === HOST_ATTR || attr.name === 'x-props' || attr.name === 'x-props-bind') continue
30
+ const b = BINDERS.find((b) => b.match(attr.name))
31
+ if (b) b.bind({ inst, el, attr: attr.name, raw: attr.value, scope })
32
+ }
33
+ }
@@ -0,0 +1,33 @@
1
+ import { BINDERS } from './index'
2
+ import { parsed, member } from './common'
3
+ import { isSignal, type Signal } from '../signal'
4
+ import { warnOnce } from '../warn'
5
+ BINDERS.push({
6
+ match: (a) => a === 'x-model',
7
+ bind: ({ inst, el, attr, raw }) => {
8
+ const p = parsed(inst, attr, raw); if (!p) return
9
+ const sig = member(inst, p, attr)
10
+ if (!isSignal(sig) || typeof (sig as Signal<unknown>).set !== 'function') {
11
+ warnOnce(`model:${inst.name}:${raw}`, `x-model="${raw}" in ${inst.name} needs a signal`); return
12
+ }
13
+ const s = sig as Signal<unknown>
14
+ const input = el as HTMLInputElement
15
+ const type = input.type
16
+ if (input instanceof HTMLInputElement && type === 'checkbox') {
17
+ const h = () => s.set(input.checked); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
18
+ inst.effect(() => { input.checked = Boolean(s()) })
19
+ } else if (input instanceof HTMLInputElement && type === 'radio') {
20
+ const h = () => { if (input.checked) s.set(input.value) }; el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
21
+ inst.effect(() => { input.checked = s() === input.value })
22
+ } else if (el instanceof HTMLSelectElement) {
23
+ if (el.multiple) { warnOnce(`mm:${inst.name}`, `select[multiple] unsupported`); return }
24
+ const h = () => s.set(el.value); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
25
+ const apply = () => { const v = s(); if (el.value !== String(v)) el.value = String(v ?? '') }
26
+ inst.effect(apply)
27
+ const mo = new MutationObserver(() => apply()); mo.observe(el, { childList: true }); inst.onCleanup(() => mo.disconnect())
28
+ } else {
29
+ const h = () => s.set(input.value); el.addEventListener('input', h); inst.onCleanup(() => el.removeEventListener('input', h))
30
+ inst.effect(() => { const v = s(); if (input.value !== String(v ?? '')) input.value = String(v ?? '') })
31
+ }
32
+ },
33
+ })
@@ -0,0 +1,17 @@
1
+ import { BINDERS } from './index'
2
+ import { parsed, member } from './common'
3
+ BINDERS.push({
4
+ match: (a) => a.startsWith('x-on-'),
5
+ bind: ({ inst, el, attr, raw, scope }) => {
6
+ const event = attr.slice('x-on-'.length)
7
+ const p = parsed(inst, attr, raw); if (!p) return
8
+ const handler = (e: Event) => {
9
+ const fn = member(inst, p, attr)
10
+ if (typeof fn !== 'function') return
11
+ const args = p.bindings.map((b) => scope[b])
12
+ ;(fn as (...a: unknown[]) => void)(...args, e)
13
+ }
14
+ el.addEventListener(event, handler)
15
+ inst.onCleanup(() => el.removeEventListener(event, handler))
16
+ },
17
+ })
@@ -0,0 +1,9 @@
1
+ import { BINDERS } from './index'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-ref',
4
+ bind: ({ inst, el, raw }) => {
5
+ const r = inst.ref(raw.trim())
6
+ r.current = el as HTMLElement
7
+ inst.onCleanup(() => { if (r.current === el) r.current = null })
8
+ },
9
+ })
@@ -0,0 +1,8 @@
1
+ import './text'
2
+ import './show'
3
+ import './bind'
4
+ import './on'
5
+ import './if'
6
+ import './model'
7
+ import './for'
8
+ import './ref'
@@ -0,0 +1,12 @@
1
+ import { BINDERS } from './index'
2
+ import { parsed, read } from './common'
3
+ BINDERS.push({
4
+ match: (a) => a === 'x-show',
5
+ bind: ({ inst, el, attr, raw, scope }) => {
6
+ const p = parsed(inst, attr, raw); if (!p) return
7
+ inst.effect(() => {
8
+ const r = read(inst, p, scope, attr); if (!r.ok) return
9
+ ;(el as HTMLElement).style.display = r.value ? '' : 'none'
10
+ })
11
+ },
12
+ })
@@ -0,0 +1,15 @@
1
+ import { BINDERS } from './index'
2
+ import { parsed, read, mismatch } from './common'
3
+ BINDERS.push({
4
+ match: (a) => a === 'x-text',
5
+ bind: ({ inst, el, attr, raw, scope }) => {
6
+ const p = parsed(inst, attr, raw); if (!p) return
7
+ let first = true
8
+ inst.effect(() => {
9
+ const r = read(inst, p, scope, attr); if (!r.ok) return
10
+ const next = r.value == null ? '' : String(r.value)
11
+ if (first) { first = false; if (el.textContent !== next) { mismatch(inst, attr, el, el.textContent, next); el.textContent = next }; return }
12
+ el.textContent = next
13
+ })
14
+ },
15
+ })
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
2
+ export type { Signal, Computed, Dispose } from './signal'
3
+ export { defineStore } from './store/define-store'
4
+ export type { StoreHandle, Snapshot } from './store/define-store'
5
+ export { defineBehavior, setChunkLoader } from './registry'
6
+ export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
+ export { mount, unmount } from './mount'
8
+ export { defineIsland } from './island'
9
+ export type { IslandHydrate } from './island'
@@ -0,0 +1,56 @@
1
+ import { effect as coreEffect, signal, type Dispose, type Signal } from './signal'
2
+ import type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
3
+
4
+ export class Instance {
5
+ members: BehaviorInstance = {}
6
+ props: Signal<Record<string, unknown>>
7
+ refs = new Map<string, { current: Element | null }>()
8
+ children = new Set<Instance>()
9
+ private disposers: Dispose[] = []
10
+ private cleanups: Array<() => void> = []
11
+ disposed = false
12
+ /** false until the initial bind pass finishes; the first-paint tripwire only applies before then. */
13
+ booted = false
14
+
15
+ constructor(public host: HTMLElement, public name: string, public parent: Instance | null, initialProps: Record<string, unknown>) {
16
+ this.props = signal(initialProps)
17
+ parent?.children.add(this)
18
+ }
19
+
20
+ effect(fn: () => void | (() => void)): Dispose {
21
+ const d = coreEffect(fn); this.disposers.push(d); return d
22
+ }
23
+ onCleanup(fn: () => void): void { this.cleanups.push(fn) }
24
+ ref<E extends Element = HTMLElement>(name: string): { current: E | null } {
25
+ let r = this.refs.get(name)
26
+ if (!r) { r = { current: null }; this.refs.set(name, r) }
27
+ return r as { current: E | null }
28
+ }
29
+ ctx(): BehaviorCtx {
30
+ return { el: this.host, props: this.props, effect: (fn) => this.effect(fn), onCleanup: (fn) => this.onCleanup(fn), ref: (n) => this.ref(n) }
31
+ }
32
+ init(factory: BehaviorFactory): void {
33
+ const m = factory(this.ctx())
34
+ if (m && typeof m === 'object') this.members = m
35
+ if (typeof this.members.init === 'function') (this.members.init as () => void)()
36
+ }
37
+ dispose(): void {
38
+ if (this.disposed) return
39
+ this.disposed = true
40
+ for (const c of Array.from(this.children)) c.dispose()
41
+ this.parent?.children.delete(this)
42
+ for (const d of this.disposers.splice(0)) d()
43
+ for (const c of this.cleanups.splice(0)) { try { c() } catch (e) { console.error('[brust] cleanup threw', e) } }
44
+ }
45
+ }
46
+
47
+ export const instances = new WeakMap<Element, Instance>()
48
+ export function instanceOf(el: Element): Instance | undefined { return instances.get(el) }
49
+ /** Instance of the nearest ancestor HOST. A host that has not mounted yet (chunk loading) yields null: wait, never skip to a farther ancestor. */
50
+ export function nearestInstance(el: Element): Instance | null {
51
+ const h = el.parentElement?.closest('[x-data]')
52
+ return (h && instances.get(h)) || null
53
+ }
54
+
55
+ /** Late-bound by mount.ts so binders (x-if, x-for) can mount nested hosts synchronously without an import cycle. */
56
+ export const hooks: { mountTree: (root: ParentNode) => void } = { mountTree: () => {} }
package/src/island.ts ADDED
@@ -0,0 +1,84 @@
1
+ // React islands: the server paints <brust-island data-id x-props>HTML</brust-island>; the island's
2
+ // chunk registers a hydrate function through a global queue (no import of the runtime); the runtime
3
+ // hydrates each host once, when idle (spec S12). Load order between runtime and chunks is free.
4
+ import { warnOnce } from './warn'
5
+
6
+ export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
7
+ type QueueEntry = [string, IslandHydrate]
8
+ declare global { var __brustIslands: QueueEntry[] | undefined; var __brustIslandReady: (() => void) | undefined }
9
+
10
+ // One entry per island id: the hydrate fn once registered, else the callbacks waiting for it.
11
+ const slots = new Map<string, IslandHydrate | Array<(h: IslandHydrate) => void>>()
12
+
13
+ export function defineIsland(id: string, hydrate: IslandHydrate): void {
14
+ const w = slots.get(id)
15
+ slots.set(id, hydrate)
16
+ if (Array.isArray(w)) w.forEach((cb) => cb(hydrate))
17
+ }
18
+
19
+ /** Move every queued [id, hydrate] from the global array into the registry. Idempotent. */
20
+ function drain(): void {
21
+ const q = (globalThis.__brustIslands ||= [])
22
+ while (q.length) defineIsland(...q.shift()!)
23
+ globalThis.__brustIslandReady = drain
24
+ }
25
+
26
+ export function whenIsland(id: string, cb: (h: IslandHydrate) => void): void {
27
+ drain()
28
+ const s = slots.get(id)
29
+ if (typeof s === 'function') cb(s)
30
+ else if (s) s.push(cb)
31
+ else slots.set(id, [cb])
32
+ }
33
+
34
+ // ---- scheduler ----------------------------------------------------------------------------
35
+ type Idle = (cb: () => void) => void
36
+ const defaultIdle: Idle = (cb) => {
37
+ const ric = (globalThis as { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback
38
+ if (typeof ric === 'function') ric(cb); else setTimeout(cb, 1)
39
+ }
40
+ let idle: Idle = defaultIdle
41
+ export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
42
+
43
+ const pending = new Set<HTMLElement>()
44
+
45
+ function readIslandProps(host: HTMLElement, id: string): Record<string, unknown> {
46
+ const raw = host.getAttribute('x-props')
47
+ if (!raw) return {}
48
+ try { const v = JSON.parse(raw); return v && typeof v === 'object' ? v : {} }
49
+ catch { warnOnce(`ij:${id}:${raw.slice(0, 40)}`, `bad x-props JSON in island ${id}`); return {} }
50
+ }
51
+
52
+ function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): void {
53
+ pending.delete(host)
54
+ if (!host.isConnected || host.hasAttribute('data-hydrated')) return
55
+ try { hydrate(host, readIslandProps(host, id)); host.setAttribute('data-hydrated', '1') }
56
+ catch (e) { console.error(`[brust] hydrate threw: ${id}`, e) }
57
+ }
58
+
59
+ function scheduleHost(host: HTMLElement): void {
60
+ if (pending.has(host) || host.hasAttribute('data-hydrated')) return
61
+ const id = host.getAttribute('data-id')!
62
+ pending.add(host)
63
+ whenIsland(id, (hydrate) => { if (pending.has(host)) idle(() => hydrateHost(host, id, hydrate)) })
64
+ }
65
+
66
+ /** Schedule every not-yet-hydrated island under `root` (inclusive). Called by mount() and the observer. */
67
+ export function scheduleIslands(root: ParentNode): void {
68
+ if (root instanceof HTMLElement && root.tagName === 'BRUST-ISLAND' && root.hasAttribute('data-id')) scheduleHost(root)
69
+ root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach(scheduleHost)
70
+ }
71
+
72
+ /** Forget pending hosts under a subtree that left the document. */
73
+ export function cancelIslands(root: Node): void {
74
+ if (!(root instanceof Element)) return
75
+ if (root instanceof HTMLElement && pending.has(root)) pending.delete(root)
76
+ root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach((h) => pending.delete(h))
77
+ }
78
+
79
+ /** Test helper: forget registrations, waiters, pending hosts and the global queue. */
80
+ export function _resetIslands(): void {
81
+ slots.clear(); pending.clear(); idle = defaultIdle
82
+ globalThis.__brustIslands = []
83
+ globalThis.__brustIslandReady = undefined
84
+ }
package/src/mount.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { Instance, hooks, instances, nearestInstance } from './instance'
2
+ import { untracked } from './signal'
3
+ import { whenBehavior } from './registry'
4
+ import { bindHost } from './directives/index'
5
+ import { warnOnce } from './warn'
6
+ import { bindPropsFromParent } from './props-bind'
7
+ import { scheduleIslands, cancelIslands } from './island'
8
+ import './directives/register' // Tasks 3–6 add binders here (file created in Task 3; create it empty now)
9
+
10
+ let observer: MutationObserver | null = null
11
+
12
+ function readProps(host: HTMLElement): Record<string, unknown> {
13
+ const raw = host.getAttribute('x-props')
14
+ if (!raw) return {}
15
+ try { const v = JSON.parse(raw); return v && typeof v === 'object' ? v : {} }
16
+ catch { warnOnce(`j:${host.outerHTML.slice(0, 80)}`, `bad x-props JSON in ${host.getAttribute('x-data')}`); return {} }
17
+ }
18
+
19
+ function mountHost(host: HTMLElement): void {
20
+ if (instances.has(host) || !host.isConnected) return
21
+ const name = host.getAttribute('x-data')!
22
+ whenBehavior(name, (factory) => {
23
+ if (instances.has(host) || !host.isConnected) return
24
+ const parent = nearestInstance(host)
25
+ const inst = new Instance(host, name, parent, readProps(host))
26
+ instances.set(host, inst)
27
+ try { inst.init(factory) } catch (e) { console.error(`[brust] init threw: ${name}`, e); inst.dispose(); instances.delete(host); return }
28
+ bindHost(inst, host, {})
29
+ inst.booted = true
30
+ bindPropsFromParent(inst)
31
+ // Children that mounted before this instance existed (parent chunk arrived late) link up now.
32
+ host.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => {
33
+ const child = instances.get(h)
34
+ if (child && !child.parent && h.parentElement?.closest('[x-data]') === host) { child.parent = inst; inst.children.add(child); bindPropsFromParent(child) }
35
+ })
36
+ })
37
+ }
38
+
39
+ function mountTree(root: ParentNode): void {
40
+ const hosts: HTMLElement[] = []
41
+ if (root instanceof HTMLElement && root.hasAttribute('x-data')) hosts.push(root)
42
+ root.querySelectorAll<HTMLElement>('[x-data]').forEach((h) => hosts.push(h))
43
+ // document order = parents before children, so nearestInstance finds a mounted parent
44
+ for (const h of hosts) mountHost(h)
45
+ scheduleIslands(root)
46
+ }
47
+ hooks.mountTree = (root) => untracked(() => mountTree(root))
48
+
49
+ function disposeTree(root: Node): void {
50
+ if (!(root instanceof Element)) return
51
+ cancelIslands(root)
52
+ const hosts: Element[] = root.hasAttribute('x-data') ? [root] : []
53
+ root.querySelectorAll('[x-data]').forEach((h) => hosts.push(h))
54
+ for (const h of hosts) { const i = instances.get(h); if (i) { i.dispose(); instances.delete(h) } }
55
+ }
56
+
57
+ export function mount(root: ParentNode = document.body): void {
58
+ // Observe first: binders (x-if, x-for) insert nested hosts during the initial pass.
59
+ if (!observer) startObserver(root); else if (root !== observedRoot) observer.observe(root, { childList: true, subtree: true })
60
+ mountTree(root)
61
+ }
62
+ let observedRoot: ParentNode | null = null
63
+ function startObserver(root: ParentNode): void {
64
+ observedRoot = root
65
+ observer = new MutationObserver((records) => {
66
+ for (const r of records) {
67
+ r.removedNodes.forEach((n) => { if (!n.isConnected) disposeTree(n) })
68
+ r.addedNodes.forEach((n) => { if (n instanceof Element && n.isConnected) mountTree(n) })
69
+ }
70
+ })
71
+ observer.observe(root, { childList: true, subtree: true })
72
+ }
73
+ export function unmount(root: ParentNode = document.body): void {
74
+ if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null }
75
+ disposeTree(root as Node)
76
+ }
@@ -0,0 +1,30 @@
1
+ import type { Instance } from './instance'
2
+ import { parseValue, resolve } from './value'
3
+ import { warnOnce } from './warn'
4
+ import { nearestInstance } from './instance'
5
+
6
+ function scopeOf(el: Element): Record<string, unknown> {
7
+ let e: Element | null = el
8
+ while (e) { const s = (e as unknown as { __scope?: Record<string, unknown> }).__scope; if (s) return s; e = e.parentElement }
9
+ return {}
10
+ }
11
+
12
+ export function bindPropsFromParent(inst: Instance): void {
13
+ const raw = inst.host.getAttribute('x-props-bind'); if (!raw) return
14
+ const p = parseValue(raw)
15
+ if (!p) { warnOnce(`pp:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: not a member path`); return }
16
+ const parent = inst.parent ?? nearestInstance(inst.host)
17
+ if (!parent) {
18
+ // An unmounted ancestor host means "wait" (the parent's mount links us); only a truly parentless host is an error.
19
+ if (!inst.host.parentElement?.closest('[x-data]')) warnOnce(`po:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: no parent`)
20
+ return
21
+ }
22
+ const scope = scopeOf(inst.host)
23
+ inst.effect(() => {
24
+ const r = resolve(parent.members, p, scope)
25
+ if (!r.ok) { warnOnce(`pm:${inst.name}`, `x-props-bind="${raw}": no such member in ${parent.name}`); return }
26
+ const v = r.value
27
+ if (v && typeof v === 'object') inst.props.set(v as Record<string, unknown>)
28
+ else warnOnce(`pn:${inst.name}`, `x-props-bind="${raw}" in ${inst.name}: not an object`)
29
+ })
30
+ }
@@ -0,0 +1,38 @@
1
+ import type { Dispose, Signal } from './signal'
2
+
3
+ export interface BehaviorCtx {
4
+ el: HTMLElement
5
+ props: Signal<Record<string, unknown>>
6
+ effect: (fn: () => void | (() => void)) => Dispose
7
+ onCleanup: (fn: () => void) => void
8
+ ref: <E extends Element = HTMLElement>(name: string) => { current: E | null }
9
+ }
10
+ export type BehaviorInstance = Record<string, unknown>
11
+ export type BehaviorFactory = (ctx: BehaviorCtx) => BehaviorInstance | void
12
+
13
+ const registry = new Map<string, BehaviorFactory>()
14
+ const waiters = new Map<string, Array<(f: BehaviorFactory) => void>>()
15
+ let loader: ((name: string) => Promise<unknown>) | null = null
16
+ const requested = new Set<string>()
17
+
18
+ export function defineBehavior(name: string, factory: BehaviorFactory): BehaviorFactory {
19
+ registry.set(name, factory)
20
+ const w = waiters.get(name); waiters.delete(name)
21
+ w?.forEach((cb) => cb(factory))
22
+ return factory
23
+ }
24
+ export function setChunkLoader(load: (name: string) => Promise<unknown>): void { loader = load; requested.clear() }
25
+ export function getBehavior(name: string): BehaviorFactory | undefined { return registry.get(name) }
26
+
27
+ /** Resolve a factory now or when its chunk arrives. Requests the chunk at most once per name. */
28
+ export function whenBehavior(name: string, cb: (f: BehaviorFactory) => void): void {
29
+ const f = registry.get(name)
30
+ if (f) { cb(f); return }
31
+ const list = waiters.get(name) ?? []; list.push(cb); waiters.set(name, list)
32
+ if (loader && !requested.has(name)) {
33
+ requested.add(name)
34
+ loader(name).catch((e) => console.error(`[brust] chunk load failed: ${name}`, e))
35
+ }
36
+ }
37
+ /** Test helper: forget everything. */
38
+ export function _resetRegistry(): void { registry.clear(); waiters.clear(); loader = null; requested.clear() }
package/src/signal.ts ADDED
@@ -0,0 +1,110 @@
1
+ // A small push-pull reactive core. Signals push "dirty" to subscribers; computeds pull lazily.
2
+ type Subscriber = { mark(): void; deps: Set<Node> }
3
+ type Node = { subs: Set<Subscriber> }
4
+ type Runnable = { disposed: boolean; run(): void }
5
+ interface ReactiveCtx { currentSub: Subscriber | null; batchDepth: number; pending: Set<Runnable> }
6
+
7
+ const CTX = Symbol.for('brust.reactive.ctx') // M3-F C7
8
+ const SIGNAL = Symbol.for('brust.signal') // M3-F C7
9
+ const COMPUTED = Symbol.for('brust.computed') // M3-F C7
10
+ // ONE object per realm. Every copy of this module binds `ctx` once at load; hot paths do one
11
+ // property load on a module-constant object. The shape is fixed: a copy never adds fields, so
12
+ // copies of any version agree.
13
+ const ctx: ReactiveCtx = ((globalThis as { [CTX]?: ReactiveCtx })[CTX] ??= { currentSub: null, batchDepth: 0, pending: new Set() })
14
+
15
+ function track(node: Node) {
16
+ const s = ctx.currentSub
17
+ if (s) { node.subs.add(s); s.deps.add(node) }
18
+ }
19
+ function notify(node: Node) {
20
+ for (const s of Array.from(node.subs)) s.mark()
21
+ }
22
+ function flush() {
23
+ if (ctx.batchDepth > 0) return
24
+ const pending = ctx.pending
25
+ while (pending.size) {
26
+ const batch = Array.from(pending); pending.clear()
27
+ for (const e of batch) if (!e.disposed) e.run()
28
+ }
29
+ }
30
+
31
+ export interface Signal<T> { (): T; set(next: T | ((prev: T) => T)): void; readonly peek: () => T; readonly [SIGNAL]: true }
32
+ export interface Computed<T> { (): T; readonly peek: () => T; readonly [COMPUTED]: true }
33
+ export type Dispose = () => void
34
+
35
+ export function signal<T>(initial: T): Signal<T> {
36
+ let value = initial
37
+ const node: Node = { subs: new Set() }
38
+ const read = (() => { track(node); return value }) as Signal<T>
39
+ read.set = (next) => {
40
+ const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
41
+ if (Object.is(v, value)) return
42
+ value = v
43
+ notify(node)
44
+ flush()
45
+ }
46
+ ;(read as { peek: () => T }).peek = () => value
47
+ ;(read as { [SIGNAL]: true })[SIGNAL] = true
48
+ return read
49
+ }
50
+
51
+ export function computed<T>(fn: () => T): Computed<T> {
52
+ let value: T; let dirty = true
53
+ const node: Node = { subs: new Set() }
54
+ const sub: Subscriber = { deps: new Set(), mark() { if (!dirty) { dirty = true; notify(node) } } }
55
+ const recompute = () => {
56
+ for (const d of sub.deps) d.subs.delete(sub); sub.deps.clear()
57
+ const prev = ctx.currentSub; ctx.currentSub = sub
58
+ try { value = fn() } finally { ctx.currentSub = prev }
59
+ dirty = false
60
+ }
61
+ const read = (() => { track(node); if (dirty) recompute(); return value }) as Computed<T>
62
+ ;(read as { peek: () => T }).peek = () => { if (dirty) recompute(); return value }
63
+ ;(read as { [COMPUTED]: true })[COMPUTED] = true
64
+ return read
65
+ }
66
+
67
+ // Cleanups run untracked: a read inside one must not subscribe whichever effect is running around it.
68
+ function runCleanup(c: () => void) { const prev = ctx.currentSub; ctx.currentSub = null; try { c() } finally { ctx.currentSub = prev } }
69
+
70
+ class EffectImpl implements Subscriber {
71
+ deps = new Set<Node>(); disposed = false; cleanup: void | (() => void) = undefined
72
+ constructor(private fn: () => void | (() => void)) {}
73
+ mark() { ctx.pending.add(this) }
74
+ run() {
75
+ if (this.disposed) return
76
+ if (this.cleanup) { const c = this.cleanup; this.cleanup = undefined; runCleanup(c) }
77
+ for (const d of this.deps) d.subs.delete(this); this.deps.clear()
78
+ const prev = ctx.currentSub; ctx.currentSub = this
79
+ try { this.cleanup = this.fn() } finally { ctx.currentSub = prev }
80
+ }
81
+ dispose() {
82
+ if (this.disposed) return
83
+ this.disposed = true; ctx.pending.delete(this)
84
+ for (const d of this.deps) d.subs.delete(this); this.deps.clear()
85
+ if (this.cleanup) { const c = this.cleanup; this.cleanup = undefined; runCleanup(c) }
86
+ }
87
+ }
88
+
89
+ export function effect(fn: () => void | (() => void)): Dispose {
90
+ const e = new EffectImpl(fn)
91
+ e.run()
92
+ return () => e.dispose()
93
+ }
94
+
95
+ export function batch(fn: () => void): void {
96
+ ctx.batchDepth++
97
+ try { fn() } finally { ctx.batchDepth--; flush() }
98
+ }
99
+
100
+ export function untracked<T>(fn: () => T): T {
101
+ const prev = ctx.currentSub; ctx.currentSub = null
102
+ try { return fn() } finally { ctx.currentSub = prev }
103
+ }
104
+
105
+ export function isSignal(x: unknown): x is Signal<unknown> { return typeof x === 'function' && (x as { [SIGNAL]?: true })[SIGNAL] === true }
106
+ export function isComputed(x: unknown): x is Computed<unknown> { return typeof x === 'function' && (x as { [COMPUTED]?: true })[COMPUTED] === true }
107
+ /** Signal or computed: what directive paths unwrap. Internal (not in D1). */
108
+ export function isReactive(x: unknown): x is Signal<unknown> | Computed<unknown> {
109
+ return typeof x === 'function' && ((x as { [SIGNAL]?: true })[SIGNAL] === true || (x as { [COMPUTED]?: true })[COMPUTED] === true)
110
+ }
@@ -0,0 +1,111 @@
1
+ // defineStore (M3-F D1): the 0.1.x `brustjs/store` handle on the shared reactive core and the
2
+ // globalThis registry. Resolution (browser registry / server resolver) lives in ./registry.
3
+ import { type Computed, type Signal, isComputed, isSignal } from '../signal'
4
+ import { type StoreInstanceRecord, resolveRecord } from './registry'
5
+
6
+ // Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses `infer` (not
7
+ // `extends Signal<unknown>`): `Signal<T>.set(next: T)` is contravariant in T, so
8
+ // `Signal<TeamMember[]>` is NOT assignable to `Signal<unknown>` — a concrete-`unknown` check wrongly
9
+ // drops every typed signal key from the snapshot.
10
+ type StoreValue<X> = X extends Signal<infer T> ? T : X extends Computed<infer T> ? T : never
11
+
12
+ // Snapshot = plain-value view of a store: Signal<T>/Computed<T> → T, plain values kept as-is, action
13
+ // functions dropped. The `[…] extends [never]` tuple wrap stops `never` from distributing.
14
+ export type Snapshot<S> = {
15
+ [K in keyof S as [StoreValue<S[K]>] extends [never]
16
+ ? S[K] extends (...a: never[]) => unknown
17
+ ? never
18
+ : K
19
+ : K]: [StoreValue<S[K]>] extends [never] ? S[K] : StoreValue<S[K]>
20
+ }
21
+
22
+ // C8. `then` is reserved so a store with a signal named `then` can't make the proxy an accidental
23
+ // thenable (await/Promise.resolve(handle) misbehaving).
24
+ const RESERVED = new Set(['name', 'subscribe', 'snapshot', 'serialize', 'hydrate', 'then'])
25
+
26
+ export interface StoreHandle<S extends object> {
27
+ (): S
28
+ readonly name: string
29
+ subscribe(cb: () => void): () => void
30
+ snapshot(): Snapshot<S>
31
+ serialize(): Record<string, unknown>
32
+ hydrate(state: Record<string, unknown>): void
33
+ }
34
+
35
+ // Each instance's signals must notify the store's subscriber set on write. After factory(), for every
36
+ // signal property we wrap .set to also bump the version and fire the store-level subscribers (computeds
37
+ // downstream recompute lazily; React re-reads snapshot).
38
+ function bridgeSubscribers(instance: Record<string, unknown>, subs: Set<() => void>, version: { n: number }): void {
39
+ for (const key of Object.keys(instance)) {
40
+ const sig = instance[key]
41
+ if (!isSignal(sig)) continue
42
+ const origSet = sig.set.bind(sig)
43
+ sig.set = (next) => {
44
+ // peek, not sig(): 0.1.x read with sig(), which subscribes a running effect to the written signal.
45
+ const before = sig.peek()
46
+ origSet(next as never)
47
+ if (Object.is(before, sig.peek())) return
48
+ version.n += 1
49
+ for (const cb of Array.from(subs)) cb()
50
+ }
51
+ }
52
+ }
53
+
54
+ export function hydrateRecord(rec: StoreInstanceRecord, state: Record<string, unknown>): void {
55
+ const inst = rec.instance as Record<string, unknown>
56
+ for (const key of Object.keys(state)) {
57
+ const v = inst[key]
58
+ if (isSignal(v)) v.set(state[key])
59
+ }
60
+ }
61
+
62
+ export function defineStore<S extends object>(name: string, factory: () => S): StoreHandle<S> & S {
63
+ const create = (server: boolean): StoreInstanceRecord => {
64
+ const instance = factory() as Record<string, unknown>
65
+ const rec: StoreInstanceRecord = { instance, subs: new Set(), version: { n: 0 }, snap: null }
66
+ bridgeSubscribers(instance, rec.subs, rec.version)
67
+ if (server) rec.handle = handle // collectSnapshot() serializes through it
68
+ return rec
69
+ }
70
+ const resolve = () => resolveRecord(name, create, hydrateRecord)
71
+
72
+ const handle = (() => resolve().instance) as StoreHandle<S> & S
73
+ Object.defineProperty(handle, 'name', { value: name })
74
+ handle.subscribe = (cb) => {
75
+ const rec = resolve()
76
+ rec.subs.add(cb)
77
+ return () => {
78
+ rec.subs.delete(cb)
79
+ }
80
+ }
81
+ handle.snapshot = () => {
82
+ const rec = resolve()
83
+ if (rec.snap && rec.snap.version === rec.version.n) return rec.snap.value as Snapshot<S>
84
+ const out: Record<string, unknown> = {}
85
+ for (const key of Object.keys(rec.instance)) {
86
+ const v = (rec.instance as Record<string, unknown>)[key]
87
+ if (isSignal(v) || isComputed(v)) out[key] = v()
88
+ }
89
+ rec.snap = { value: out, version: rec.version.n }
90
+ return out as Snapshot<S>
91
+ }
92
+ handle.serialize = () => {
93
+ const out: Record<string, unknown> = {}
94
+ for (const [k, s] of Object.entries(resolve().instance)) {
95
+ if (!isSignal(s)) continue
96
+ const v = s.peek()
97
+ if (v === undefined || typeof v === 'function') continue // spec §4
98
+ if (typeof v === 'bigint') throw new TypeError(`store '${name}': '${k}' holds a BigInt, which JSON cannot carry`)
99
+ out[k] = v
100
+ }
101
+ return out
102
+ }
103
+ handle.hydrate = (state) => hydrateRecord(resolve(), state)
104
+
105
+ return new Proxy(handle, {
106
+ get(target, prop, recv) {
107
+ if (typeof prop === 'symbol' || RESERVED.has(prop)) return Reflect.get(target, prop, recv)
108
+ return (resolve().instance as Record<string, unknown>)[prop]
109
+ },
110
+ })
111
+ }
@@ -0,0 +1,5 @@
1
+ // The D1 surface, nothing else (aliasable to the runtime URL, C6)
2
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from '../signal'
3
+ export type { Signal, Computed, Dispose } from '../signal'
4
+ export { defineStore } from './define-store'
5
+ export type { StoreHandle, Snapshot } from './define-store'
@@ -0,0 +1,5 @@
1
+ // Server and later-wave plumbing; never documented, never aliased to the runtime URL.
2
+ export { __setServerResolver, applyStoreSnapshot } from './registry'
3
+ export type { ServerResolver, StoreInstanceRecord } from './registry'
4
+ export { hydrateRecord } from './define-store'
5
+ export { toScriptJson, storeScriptTag, parseStoreScript } from './serialize'
@@ -0,0 +1,19 @@
1
+ // React bridge (M3-F D8). React is a PEER of @brust/runtime-dom, imported only here; src/index.ts never
2
+ // reaches this file, so the runtime bundle stays react-free (scripts/build.ts). Type-only import from
3
+ // the core: bundling this file into an island adds no second core (ruling R2).
4
+ import { useSyncExternalStore } from 'react'
5
+ import type { Snapshot, StoreHandle } from './define-store'
6
+
7
+ export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<S> {
8
+ // An ssr island can render (renderToString) on the server OUTSIDE a request store scope; there
9
+ // store.snapshot() throws. Degrade to an empty snapshot for the server render so the island SSRs its
10
+ // factory-default markup; the client resolves the real registry instance and hydrates.
11
+ const serverSnapshot = (): Snapshot<S> => {
12
+ try {
13
+ return store.snapshot()
14
+ } catch {
15
+ return {} as Snapshot<S>
16
+ }
17
+ }
18
+ return useSyncExternalStore(store.subscribe, store.snapshot, serverSnapshot)
19
+ }
@@ -0,0 +1,85 @@
1
+ // Browser store registry + the server-resolver hook (M3-F D3/D6/C7).
2
+ // The registry lives on globalThis (C7) so two copies of this module share one instance per name.
3
+ import { parseStoreScript } from './serialize'
4
+
5
+ // The resolved per-scope store record, shared by the browser registry and the server per-request
6
+ // map. `version` bumps on every signal write so `snapshot()` returns a referentially-stable object
7
+ // (useSyncExternalStore contract) until a change; `snap` memoizes it. `handle` is set only on
8
+ // records created for a resolver, so collectSnapshot() can serialize a touched store.
9
+ export interface StoreInstanceRecord {
10
+ instance: object
11
+ subs: Set<() => void>
12
+ version: { n: number }
13
+ snap: { value: Record<string, unknown>; version: number } | null
14
+ handle?: { serialize(): Record<string, unknown> }
15
+ }
16
+
17
+ export type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
18
+
19
+ const STORES = Symbol.for('brust.stores') // C7
20
+ type Registry = Record<string, StoreInstanceRecord>
21
+
22
+ // Module-local on purpose (as in 0.1.x): a second runtime-dom copy on the SERVER has no resolver
23
+ // and throws loudly instead of leaking request state into a shared registry.
24
+ let serverResolver: ServerResolver | null = null
25
+
26
+ /** Installed once at worker boot by packages/brust/src/store/server-context.ts; null uninstalls (tests). */
27
+ export function __setServerResolver(fn: ServerResolver | null): void {
28
+ serverResolver = fn
29
+ }
30
+
31
+ function browserRegistry(): Registry {
32
+ const g = globalThis as { [STORES]?: Registry }
33
+ return (g[STORES] ??= Object.create(null) as Registry)
34
+ }
35
+
36
+ // Attribute comparison, never a built selector: the name is not escaped for CSS.
37
+ function seedScript(name: string): Element | null {
38
+ for (const el of Array.from(document.querySelectorAll('script[data-brust-store]'))) {
39
+ if (el.getAttribute('data-brust-store') === name) return el
40
+ }
41
+ return null
42
+ }
43
+
44
+ /**
45
+ * Resolution order (ruling R4): an installed resolver takes EVERY access; otherwise the browser
46
+ * registry when `document` exists (created, then lazily seeded from the page's script tag on the
47
+ * first access per name, D6); otherwise throw.
48
+ */
49
+ export function resolveRecord(
50
+ name: string,
51
+ create: (server: boolean) => StoreInstanceRecord,
52
+ seed: (rec: StoreInstanceRecord, state: Record<string, unknown>) => void,
53
+ ): StoreInstanceRecord {
54
+ if (serverResolver) return serverResolver(name, () => create(true))
55
+ if (typeof document === 'undefined') {
56
+ throw new Error(`store '${name}' accessed on the server without a request scope`)
57
+ }
58
+ const reg = browserRegistry()
59
+ let rec = reg[name]
60
+ if (!rec) {
61
+ rec = reg[name] = create(false)
62
+ const el = seedScript(name)
63
+ if (el) seed(rec, parseStoreScript(el))
64
+ }
65
+ return rec
66
+ }
67
+
68
+ /** Apply a navigation-payload snapshot to live client stores. Reserved for the SPA wave (D9): no callers yet. */
69
+ export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
70
+ const reg = (globalThis as { [STORES]?: Registry })[STORES]
71
+ if (!reg) return
72
+ for (const [name, state] of Object.entries(snap)) {
73
+ const entry = reg[name]
74
+ if (!entry) continue // not defined on the client yet: the initial-load <script> covers it
75
+ const inst = entry.instance as Record<string, { set?: (x: unknown) => void } | undefined>
76
+ for (const key of Object.keys(state)) {
77
+ const v = inst[key]
78
+ if (v && typeof v.set === 'function') v.set(state[key])
79
+ }
80
+ }
81
+ }
82
+
83
+ export function _resetStores(): void {
84
+ delete (globalThis as { [STORES]?: unknown })[STORES]
85
+ }
@@ -0,0 +1,33 @@
1
+ // Snapshot serialization for the store (M3-F D6). JSON for embedding in a <script> TEXT node (not an
2
+ // attribute): brust runs AutoEscape::None and a request-derived value can reach a serialized signal,
3
+ // so escape against </script> / <!-- breakout. C4: the Rust emitter (render.rs::inject_assets_into)
4
+ // mirrors these bytes; test 'C4 golden bytes' is the shared contract.
5
+
6
+ const ESC: Record<string, string> = {
7
+ '<': '\\u003c',
8
+ '>': '\\u003e',
9
+ '&': '\\u0026',
10
+ '\u2028': '\\u2028',
11
+ '\u2029': '\\u2029',
12
+ }
13
+
14
+ export function toScriptJson(value: unknown): string {
15
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => ESC[c]!)
16
+ }
17
+
18
+ export function storeScriptTag(name: string, state: unknown): string {
19
+ // name comes from defineStore (developer literal), not request data; still
20
+ // guard the attribute against quote breakout by rejecting unexpected chars.
21
+ const safeName = String(name).replace(/[^a-zA-Z0-9_.:-]/g, '')
22
+ return `<script type="application/json" data-brust-store="${safeName}">${toScriptJson(state)}</script>`
23
+ }
24
+
25
+ export function parseStoreScript(el: { textContent: string | null }): Record<string, unknown> {
26
+ const text = el.textContent ?? '{}'
27
+ try {
28
+ return JSON.parse(text) as Record<string, unknown>
29
+ } catch (e) {
30
+ console.warn('[brust] store: invalid snapshot JSON', e)
31
+ return {}
32
+ }
33
+ }
package/src/value.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { isReactive } from './signal'
2
+ export interface ParsedValue { path: string[]; bindings: string[] }
3
+ const IDENT = /^[A-Za-z_$][\w$]*$/
4
+
5
+ export function parseValue(raw: string): ParsedValue | null {
6
+ const [pathPart, bindPart, ...rest] = raw.trim().split(':')
7
+ if (rest.length || !pathPart) return null
8
+ const path = pathPart.split('.')
9
+ if (!path.every((p) => IDENT.test(p))) return null
10
+ const bindings = bindPart === undefined ? [] : bindPart.split(',').map((b) => b.trim())
11
+ if (!bindings.every((b) => IDENT.test(b))) return null
12
+ return { path, bindings }
13
+ }
14
+
15
+ export type Scope = Record<string, unknown>
16
+
17
+ /** Resolve a parsed directive value against instance members. Reads signals (tracked). */
18
+ export function resolve(members: Record<string, unknown>, v: ParsedValue, scope: Scope): { ok: true; value: unknown } | { ok: false } {
19
+ // x-for rows expose a tick that changes when the row's item/index is refreshed; reading it subscribes.
20
+ const tick = scope.__tick
21
+ if (typeof tick === 'function') tick()
22
+ let cur: unknown = members
23
+ for (const key of v.path) {
24
+ if (isReactive(cur)) cur = cur()
25
+ if (cur === null || typeof cur !== 'object') return { ok: false }
26
+ if (!(key in (cur as object))) return { ok: false }
27
+ cur = (cur as Record<string, unknown>)[key]
28
+ }
29
+ if (v.bindings.length) {
30
+ if (typeof cur !== 'function') return { ok: false }
31
+ const args = v.bindings.map((b) => scope[b])
32
+ return { ok: true, value: (cur as (...a: unknown[]) => unknown)(...args) }
33
+ }
34
+ if (isReactive(cur)) cur = cur()
35
+ return { ok: true, value: cur }
36
+ }
37
+
38
+ /** Like resolve but returns the member itself (for handlers and x-model signals). */
39
+ export function resolveMember(members: Record<string, unknown>, v: ParsedValue): unknown {
40
+ let cur: unknown = members
41
+ for (const key of v.path) {
42
+ if (isReactive(cur)) cur = cur()
43
+ if (cur === null || typeof cur !== 'object' || !(key in (cur as object))) return undefined
44
+ cur = (cur as Record<string, unknown>)[key]
45
+ }
46
+ return cur
47
+ }
package/src/warn.ts ADDED
@@ -0,0 +1,9 @@
1
+ const seen = new Set<string>()
2
+ export function warnOnce(key: string, message: string): void {
3
+ if (seen.has(key)) return
4
+ seen.add(key)
5
+ console.warn(`[brust] ${message}`)
6
+ }
7
+
8
+ /** test-only; the browser harness calls it between cases */
9
+ export function __resetWarnOnce(): void { seen.clear() }