@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 +93 -2
- package/package.json +51 -4
- package/src/directives/bind.ts +32 -0
- package/src/directives/common.ts +23 -0
- package/src/directives/for.ts +85 -0
- package/src/directives/if.ts +27 -0
- package/src/directives/index.ts +33 -0
- package/src/directives/model.ts +33 -0
- package/src/directives/on.ts +17 -0
- package/src/directives/ref.ts +9 -0
- package/src/directives/register.ts +8 -0
- package/src/directives/show.ts +12 -0
- package/src/directives/text.ts +15 -0
- package/src/index.ts +9 -0
- package/src/instance.ts +56 -0
- package/src/island.ts +84 -0
- package/src/mount.ts +76 -0
- package/src/props-bind.ts +30 -0
- package/src/registry.ts +38 -0
- package/src/signal.ts +110 -0
- package/src/store/define-store.ts +111 -0
- package/src/store/index.ts +5 -0
- package/src/store/internal.ts +5 -0
- package/src/store/react.ts +19 -0
- package/src/store/registry.ts +85 -0
- package/src/store/serialize.ts +33 -0
- package/src/value.ts +47 -0
- package/src/warn.ts +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/runtime-dom
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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'
|
package/src/instance.ts
ADDED
|
@@ -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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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() }
|