@brust/runtime-dom 2.0.0-alpha.3 → 2.0.0-alpha.30

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  React-free browser runtime that binds a compiled client chunk (`defineBehavior`) to server-rendered HTML
4
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
5
+ behaviour below is pinned by a test in `test/`. Zero dependencies, eval-free, bundle < 18 KB minified
6
6
  (`bun scripts/build.ts`).
7
7
 
8
8
  ## Public surface
@@ -33,12 +33,15 @@ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the
33
33
 
34
34
  ## Attributes
35
35
 
36
+ Directives come only from compiler output: the compiler never prints an `x-*` attribute written in component JSX (warning `reserved-attr`), and the runtime does not bind inside `dangerouslySetInnerHTML` markup (`x-html` / `x-raw`) or a React island (`<brust-island>`).
37
+
36
38
  - **`x-data="name"`**: one instance per host. Nested hosts are bound by their own instance, not the parent's walker.
37
39
  - **`x-props='{"json":1}'`**: seeds `ctx.props`. Invalid JSON warns and seeds `{}`.
38
40
  - **`x-props-bind="member[:binding]"`**: on a child host, rebinds the child's `props` signal reactively to the nearest ancestor instance's member
39
41
  (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
42
  (chunk still loading) the child links up when the parent mounts. No ancestor: warns, keeps the `x-props` seed.
41
43
  - **`x-text="value"`**: sets `textContent`; `null`/`undefined` → `''`.
44
+ - **`x-html="value"`**: sets `innerHTML` (raw markup, unescaped); `null`/`undefined` → `''`. Emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint; the author sanitises the markup (same contract as React). The first-paint check compares parsed markup, so equivalent spellings do not warn.
42
45
  - **`x-show="value"`**: `style.display = ''` when truthy, `'none'` otherwise.
43
46
  - **`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
47
  Nested `x-data` hosts in the clone mount/dispose with it. Server HTML omits the element when false.
@@ -55,7 +58,7 @@ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the
55
58
 
56
59
  ## First-paint rule
57
60
 
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,
61
+ 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` / `x-html` value differs from the server text,
59
62
  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
63
 
61
64
  ## Gates
@@ -92,3 +95,13 @@ tracking state lives on `globalThis[Symbol.for('brust.reactive.ctx')]`).
92
95
  React-free.
93
96
  - `@brust/runtime-dom/store/internal`: the server-resolver hook and serialization helpers the Bun worker uses.
94
97
  Not part of the public surface.
98
+
99
+ ## Feature modules (L33, F139)
100
+
101
+ The runtime is a core plus feature modules, so a page loads only what its components use and the client JS does not grow as the compiler learns more of React.
102
+
103
+ - `@brust/runtime-dom/core` (`src/core.ts`): signals, `defineBehavior`/`setChunkLoader`, `mount`/`unmount`, the binder registry (`BINDERS`) and the internals features need (`src/core-api.ts`). It knows no directive by name.
104
+ - `@brust/runtime-dom/features/<name>` (`src/features/*.ts`): `basics` (`x-text`, `x-show`, `x-bind-*`, `x-on-*`, `x-ref`), `if`, `for`, `model`, `html`, `island` (React island hydration glue). A feature registers itself with the core when imported and imports the core only through `core-api`; in the browser build `core-api` is the shared core file, so there is one signal graph and one binder registry per page.
105
+ - `@brust/runtime-dom` (`src/index.ts`) is core + every feature, for tests and non-split consumers. The browser build never uses it.
106
+
107
+ **The rule for a new feature.** The micro-directives nearly every interactive page uses ship together in `basics`. Anything else gets its OWN module when it is at least about 1 KB gzip OR used by a minority of pages: `store`, `island`, `if`/`for`/`model`/`html` as measured, and every future feature (context, portal, focus trap, ...). Do not add a feature to `basics` or the core to save a file; add it as a module the compiler requests only for routes that use it. The CI byte budget (core + `basics`) holds the line.
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.30",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./src/index.ts",
8
8
  "default": "./src/index.ts"
9
9
  },
10
+ "./core": {
11
+ "types": "./src/core.ts",
12
+ "default": "./src/core.ts"
13
+ },
14
+ "./features/*": {
15
+ "types": "./src/features/*.ts",
16
+ "default": "./src/features/*.ts"
17
+ },
10
18
  "./store": {
11
19
  "types": "./src/store/index.ts",
12
20
  "default": "./src/store/index.ts"
@@ -36,7 +44,7 @@
36
44
  },
37
45
  "devDependencies": {
38
46
  "@happy-dom/global-registrator": "^20.0.0",
39
- "@types/bun": "^1.4.0",
47
+ "@types/bun": "^1.4.4",
40
48
  "@types/react": "^19.2.0",
41
49
  "@types/react-dom": "^19.2.0",
42
50
  "happy-dom": "^20.0.0",
@@ -49,5 +57,14 @@
49
57
  ],
50
58
  "publishConfig": {
51
59
  "access": "public"
60
+ },
61
+ "homepage": "https://brust.dev",
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/AssetsArt/brust.git",
65
+ "directory": "packages/runtime-dom"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/AssetsArt/brust/issues"
52
69
  }
53
70
  }
@@ -0,0 +1,6 @@
1
+ // GENERATED from crates/brust-jinja/src/lib.rs (BOOLEAN_ATTRS, OVERLOADED_ATTRS, STRINGIFY_ATTRS); do not edit.
2
+ // Regenerate: bun scripts/gen-attr-kinds.ts. test/attr-kinds.test.ts fails when this drifts.
3
+ const set = (s: string): Set<string> => new Set(s.split(' '))
4
+ export const BOOLEAN = set('allowfullscreen async autofocus autoplay checked controls default defer disabled disablepictureinpicture disableremoteplayback formnovalidate hidden inert itemscope loop multiple muted nomodule novalidate open playsinline readonly required reversed scoped seamless selected')
5
+ export const OVERLOADED = set('capture download')
6
+ export const STRINGIFY = set('autoreverse contenteditable draggable externalresourcesrequired focusable preservealpha spellcheck value')
@@ -0,0 +1,11 @@
1
+ // What a feature module may import from the core, and nothing else. In the browser build the core is one shared
2
+ // ESM file and every feature is a separate file that imports these names from it (bundle.ts maps this module to the
3
+ // core URL), so a feature registers on the page's single binder registry and signal graph.
4
+ export { BINDERS, bindHost, bindElement, bindRef, walkChildren } from './directives/index'
5
+ export type { Binder } from './directives/index'
6
+ export { parsed, read, member, mismatch, insideRaw, RAW_SEL } from './directives/common'
7
+ export { hooks, islandHooks, collectionHooks } from './instance'
8
+ export type { CollectionItem } from './instance'
9
+ export { warnOnce } from './warn'
10
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
11
+ export type { Signal, Computed, Dispose } from './signal'
package/src/core.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The always-loaded core: signals, the behavior registry, mount/unmount and the binder registry. Features
2
+ // (./features/*) register their binders on import; nothing here knows a directive by name.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
4
+ export type { Signal, Computed, Dispose } from './signal'
5
+ export { defineBehavior, setChunkLoader } from './registry'
6
+ export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
+ export { mount, unmount } from './mount'
8
+ export * from './core-api'
@@ -1,12 +1,38 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read } from './common'
3
- import { warnOnce } from '../warn'
1
+ import { reactEvent } from './on'
2
+ import { BINDERS, parsed, read, warnOnce } from '../core-api'
3
+ import { BOOLEAN, OVERLOADED, STRINGIFY } from '../attr-kinds'
4
+ import type { Instance } from '../instance'
4
5
 
5
6
  // Attributes that execute or load code from a string; a bound value must never reach them as-is.
6
7
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
7
8
  // Scheme allowlist on the parsed URL (relative URLs resolve to http): new URL() applies the browser's tab/newline/control-char stripping.
8
9
  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
+ /** Writes one bound attribute value with React's rules (shared by `x-bind-*` and `x-prop-*`, A2: one print path). */
11
+ function writeAttr(inst: Instance, attr: string, el: Element, name: string, v: unknown): void {
12
+ // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
13
+ // F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
14
+ if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
15
+ // F434: a multiple select is bound to an array, one pick per option (`null`/`undefined` leave the picks alone, as React does)
16
+ if (name === 'value' && el instanceof HTMLSelectElement && el.multiple) { if (v != null) { const vs = ([] as unknown[]).concat(v).map(String); for (const o of el.options) o.selected = vs.includes(o.value) } return }
17
+ if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
18
+ // React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
19
+ const custom = el.localName.includes('-')
20
+ if (!custom && BOOLEAN.has(name)) {
21
+ const on = Boolean(v)
22
+ if (name in el) (el as unknown as Record<string, unknown>)[name] = on
23
+ if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
24
+ return
25
+ }
26
+ // aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
27
+ if (!custom && (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name))) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
28
+ if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
29
+ if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
30
+ // React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
31
+ if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
32
+ if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
33
+ el.setAttribute(name, String(v))
34
+ }
35
+
10
36
  BINDERS.push({
11
37
  match: (a) => a.startsWith('x-bind-'),
12
38
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -15,18 +41,39 @@ BINDERS.push({
15
41
  const p = parsed(inst, attr, raw); if (!p) return
16
42
  inst.effect(() => {
17
43
  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))
44
+ writeAttr(inst, attr, el, name, r.value)
30
45
  })
31
46
  },
32
47
  })
48
+
49
+ // F329b: `x-prop-<attribute>="<prop key>"` binds the attribute to a prop of THIS instance (`inst.props()`), the key being
50
+ // the VALUE (a prop key is not a member path: `aria-checked`, `tabIndex`). The key set is a property of one call site, so it is
51
+ // printed in the template, never as a chunk member (the chunk stays shared). The attribute name is the lowercase DOM name the
52
+ // server print used (the shared React -> attribute table), so both ends agree.
53
+ // Refused, as `x-bind-*` refuses them and more: event handlers, `srcdoc`, `x-*`, and the keys the server print never
54
+ // binds through a spread (class, style, value, checked, selected, URL-bearing attributes).
55
+ const PROP_REFUSED = new Set(['srcdoc', 'class', 'style', 'value', 'checked', 'selected', ...URL_ATTRS])
56
+ BINDERS.push({
57
+ match: (a) => a.startsWith('x-prop-'),
58
+ bind: ({ inst, el, attr, raw }) => {
59
+ const name = attr.slice('x-prop-'.length)
60
+ // F369c: `x-prop-on-<event>="<prop key>"` calls the instance's prop (a function a caller handed through the child's rest)
61
+ // with the event, reading it at call time so a prop that changes is the one that runs. The event is `[a-z0-9]+`.
62
+ if (name.startsWith('on-')) {
63
+ const event = name.slice(3)
64
+ if (!raw || !/^[a-z0-9]+$/.test(event)) { warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return }
65
+ const handler = (e: Event) => {
66
+ reactEvent(e)
67
+ const ps = inst.props(); const fn = Object.hasOwn(ps, raw) ? ps[raw] : undefined
68
+ if (typeof fn === 'function') (fn as (e: Event) => void)(e)
69
+ }
70
+ el.addEventListener(event, handler)
71
+ inst.onCleanup(() => el.removeEventListener(event, handler))
72
+ return
73
+ }
74
+ if (!name || !raw || name.startsWith('on') || name.startsWith('x-') || PROP_REFUSED.has(name)) {
75
+ warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return
76
+ }
77
+ inst.effect(() => { const ps = inst.props(); writeAttr(inst, attr, el, name, Object.hasOwn(ps, raw) ? ps[raw] : undefined) })
78
+ },
79
+ })
@@ -2,6 +2,10 @@ import { parseValue, resolve, resolveMember, type ParsedValue, type Scope } from
2
2
  import { warnOnce } from '../warn'
3
3
  import type { Instance } from '../instance'
4
4
 
5
+ /** Markup inside a `dangerouslySetInnerHTML` element (`x-html` / `x-raw`, compiler-emitted) or a React island is inert: nothing under it is bound, mounted or hydrated. */
6
+ export const RAW_SEL = '[x-html],[x-raw],brust-island'
7
+ export const insideRaw = (el: Element): boolean => !!el.parentElement?.closest(RAW_SEL)
8
+
5
9
  export function parsed(inst: Instance, attr: string, raw: string): ParsedValue | null {
6
10
  const p = parseValue(raw)
7
11
  if (!p) warnOnce(`p:${inst.name}:${attr}:${raw}`, `${attr}="${raw}" in ${inst.name}: not a member path`)
@@ -19,5 +23,7 @@ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
19
23
  }
20
24
  export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
21
25
  if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
26
+ if (inst.late) return // F301: mounted after the page booted (a navigation's markup, a React client mount): no first paint to compare
27
+ if (inst.reseeded) return // F277: a host created later, started from its parent's current props, not the printed ones
22
28
  warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
23
29
  }
@@ -1,13 +1,14 @@
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'
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, member, warnOnce, computed, signal, hooks, type Signal } from '../core-api'
6
2
 
7
3
  type RowScope = Record<string, unknown> & { __own: Signal<number> }
8
4
 
9
5
  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
6
  const done = new WeakSet<Element>()
7
+ /** The comments this binder inserted: the server's own `<!--x-for-->` marker is not among them. */
8
+ const anchors = new WeakSet<Node>()
9
+ /** F396: the zero-row template is the element the server prints right after its `<!--x-for-->` marker. A row is not
10
+ * told apart by `hidden`: a row whose own root is `hidden={cond}` is server-rendered hidden and is still a row. */
11
+ const isTemplate = (s: Element) => { const p = s.previousSibling; return p instanceof Comment && p.data === 'x-for' && !anchors.has(p) }
11
12
 
12
13
  BINDERS.push({
13
14
  match: (a) => a === 'x-for',
@@ -21,16 +22,20 @@ BINDERS.push({
21
22
  // All server-rendered siblings carrying the same x-for are rows of this list; the first is the template.
22
23
  const parent = el.parentElement!
23
24
  const siblings = Array.from(parent.children).filter((c) => c.getAttribute('x-for') === raw)
25
+ const templates = new Set(siblings.filter(isTemplate))
24
26
  siblings.forEach((s) => done.add(s))
25
27
  const template = el.cloneNode(true) as Element
26
28
  template.removeAttribute('x-for'); template.removeAttribute('hidden')
27
- const anchor = document.createComment('x-for')
29
+ const anchor = document.createComment('x-for'); anchors.add(anchor)
28
30
  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
+ // Server-rendered rows (not the zero-row template) are adopted in order on the first run.
32
+ const adopt = siblings.filter((s) => !templates.has(s))
31
33
  if (!adopt.includes(el)) el.remove()
32
34
  let firstRun = true
33
35
 
36
+ // F415: a removed row's effects and cleanups go with it (they were bound under `inst.capture`).
37
+ const drops = new Map<Element, () => void>()
38
+ const drop = (n: Element) => { drops.get(n)?.(); drops.delete(n); n.remove() }
34
39
  let rows = new Map<unknown, Element[]>() // key -> elements (array to tolerate duplicates)
35
40
  let order: Element[] = []
36
41
 
@@ -44,7 +49,10 @@ BINDERS.push({
44
49
  const seen = new Set<unknown>()
45
50
  list.forEach((item, i) => {
46
51
  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}` }
52
+ // `undefined` is a row whose element arm is null (`it.link && <li key={it.link.id}/>`): it
53
+ // carries no key in React either, so it is positional and never a duplicate.
54
+ if (key === undefined) key = `__nil_${i}`
55
+ else if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
48
56
  seen.add(key)
49
57
  const pool = rows.get(key)
50
58
  let node = pool?.shift()
@@ -66,12 +74,12 @@ BINDERS.push({
66
74
  const rowScope: RowScope = { ...scope, [itemName]: item, ...(indexName ? { [indexName]: i } : {}), __own: own, __tick: computed(() => (outer ? outer() : 0) + own()) }
67
75
  ;(node as unknown as { __scope: RowScope }).__scope = rowScope
68
76
  // 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) }
77
+ if (!node.hasAttribute('x-data')) { const n = node; drops.set(n, inst.capture(() => { bindElement(inst, n, rowScope); walkChildren(inst, n, rowScope) })) }
70
78
  }
71
79
  ;(next.get(key) ?? next.set(key, []).get(key)!).push(node)
72
80
  nextOrder.push(node)
73
81
  })
74
- for (const [, pool] of rows) for (const stale of pool) stale.remove()
82
+ for (const [, pool] of rows) for (const stale of pool) drop(stale)
75
83
  for (const left of adopt.splice(0)) left.remove()
76
84
  firstRun = false
77
85
  // place in order after the anchor
@@ -80,6 +88,6 @@ BINDERS.push({
80
88
  rows = next; order = nextOrder
81
89
  for (const n of created) hooks.mountTree(n) // nested hosts in new rows mount now, not on the observer's next tick
82
90
  })
83
- inst.onCleanup(() => { for (const n of order) n.remove() })
91
+ inst.onCleanup(() => { for (const n of order) drop(n) })
84
92
  },
85
93
  })
@@ -0,0 +1,20 @@
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
2
+ /** Both sides through the HTML parser (inert `<template>`), so `<br/>` vs `<br>` or entity spelling is not a mismatch. */
3
+ function sameMarkup(el: Element, next: string): boolean {
4
+ const t = document.createElement('template'); t.innerHTML = next
5
+ return t.innerHTML === el.innerHTML
6
+ }
7
+ // `x-html` is emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint.
8
+ BINDERS.push({
9
+ match: (a) => a === 'x-html',
10
+ bind: ({ inst, el, attr, raw, scope }) => {
11
+ const p = parsed(inst, attr, raw); if (!p) return
12
+ let first = true
13
+ inst.effect(() => {
14
+ const r = read(inst, p, scope, attr); if (!r.ok) return
15
+ const next = r.value == null ? '' : String(r.value)
16
+ if (first) { first = false; if (!sameMarkup(el, next)) { mismatch(inst, attr, el, el.innerHTML, next); el.innerHTML = next }; return }
17
+ el.innerHTML = next
18
+ })
19
+ },
20
+ })
@@ -1,6 +1,11 @@
1
- import { BINDERS, bindElement, walkChildren } from './index'
2
- import { parsed, read } from './common'
3
- import { hooks } from '../instance'
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, hooks } from '../core-api'
2
+ /** The callback refs of the branch being inserted: attached once its whole subtree is bound and mounted (React attaches
3
+ * refs in the commit, after the DOM is placed and before layout effects). An inner branch inserted with it hands its
4
+ * refs over, so the outermost attaches them all in DOM post-order (children first, as React's commit does). */
5
+ type Attach = [el: Element, on: () => void]
6
+ let attach: Attach[] | null = null
7
+ /** `a` first when `b` contains it (8) or follows it (4) without being inside it (16). */
8
+ const postOrder = ([a]: Attach, [b]: Attach) => { const p = a.compareDocumentPosition(b); return p & 8 || (p & 20) === 4 ? -1 : 1 }
4
9
  BINDERS.push({
5
10
  match: (a) => a === 'x-if',
6
11
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -10,18 +15,46 @@ BINDERS.push({
10
15
  template.removeAttribute('x-if'); template.removeAttribute('hidden')
11
16
  el.replaceWith(anchor) // the server-rendered element becomes the first clone below
12
17
  let current: Element | null = null
18
+ // F415/F416: what the branch bound (its effects, its x-ref) is let go before the element leaves, as React detaches
19
+ // a removed host's refs in the same commit.
20
+ let drop = () => {}
21
+ const remove = () => { drop(); drop = () => {}; current?.remove(); current = null } // the observer disposes nested hosts
13
22
  inst.effect(() => {
14
23
  const r = read(inst, p, scope, attr); if (!r.ok) return
15
24
  const want = Boolean(r.value)
16
25
  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
- }
26
+ const node = (current = template.cloneNode(true) as Element)
27
+ anchor.after(node)
28
+ const outer = attach; const mine: Attach[] = (attach = [])
29
+ try {
30
+ // nested hosts mount inside the capture too: an `x-cref` on one registers its detach on this branch
31
+ drop = inst.capture(() => { bindElement(inst, node, scope); walkChildren(inst, node, scope); hooks.mountTree(node) })
32
+ } finally { attach = outer }
33
+ if (outer) outer.push(...mine)
34
+ else for (const [, on] of mine.sort(postOrder)) on()
35
+ } else if (!want && current) remove()
24
36
  })
25
- inst.onCleanup(() => { current?.remove() })
37
+ inst.onCleanup(remove)
38
+ },
39
+ })
40
+ // F288 phase B (G5): `x-cref="cell"`, a callback ref inside a branch. The cell holds the callback (or a ref object); it
41
+ // gets the node when the branch is inserted and `null` (or the callback's returned cleanup) when it is removed. On a
42
+ // nested host the host's own instance meets it while it mounts, inside the branch's capture. (A host whose behavior
43
+ // chunk is not loaded yet binds later through whenBehavior, outside the capture: it attaches at once and detaches only
44
+ // with the owner, not the branch: reachable only if an inlined child's root can have its behavior in another chunk.)
45
+ BINDERS.push({
46
+ match: (a) => a === 'x-cref',
47
+ bind: ({ inst, el, raw }) => {
48
+ // on a nested host (an inlined native child's root) the cell is the caller's, as `x-pref`'s is
49
+ const owner = el === inst.host ? inst.parent : inst
50
+ if (!owner) return
51
+ const r = owner.ref(raw.trim()).current as unknown
52
+ let off: (() => void) | void
53
+ owner.onCleanup(() => { off?.(); off = undefined })
54
+ const on = () => {
55
+ if (typeof r === 'function') { const c = r(el); off = typeof c === 'function' ? c : () => r(null) }
56
+ else if (r) { (r as { current: unknown }).current = el; off = () => { (r as { current: unknown }).current = null } }
57
+ }
58
+ attach ? attach.push([el, on]) : on()
26
59
  },
27
60
  })
@@ -12,14 +12,25 @@ export function bindHost(inst: Instance, el: Element, scope: Scope): void {
12
12
  walkChildren(inst, el, scope)
13
13
  }
14
14
  export function walkChildren(inst: Instance, el: Element, scope: Scope): void {
15
+ if (el.hasAttribute('x-html') || el.hasAttribute('x-raw') || el.localName === 'brust-island') return // raw markup / React island: inert
15
16
  for (const child of Array.from(el.children)) {
16
17
  if ('__scope' in child) continue // x-for row: already bound with its own scope
17
18
  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
+ if (child.hasAttribute(HOST_ATTR)) { // nested host: its own instance binds it,
20
+ const pref = child.getAttribute('x-pref') // but the ref its caller passed is the caller's (F267/F288)
21
+ if (pref) bindRef(inst, child, pref)
22
+ continue
23
+ }
19
24
  bindElement(inst, child, scope)
20
25
  if (!child.hasAttribute('x-if')) walkChildren(inst, child, scope) // x-if owns its subtree
21
26
  }
22
27
  }
28
+ /** `x-ref` / `x-pref`: the instance's ref cell `name` holds `el` while it is bound. */
29
+ export function bindRef(inst: Instance, el: Element, name: string): void {
30
+ const r = inst.ref(name.trim())
31
+ r.current = el as HTMLElement
32
+ inst.onCleanup(() => { if (r.current === el) r.current = null })
33
+ }
23
34
  export function bindElement(inst: Instance, el: Element, scope: Scope): void {
24
35
  // Structural directives own the element: x-for/x-if re-create it from a template, so the
25
36
  // remaining attributes are bound on the clones (with the right scope), never on this original.
@@ -1,7 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, member } from './common'
3
- import { isSignal, type Signal } from '../signal'
4
- import { warnOnce } from '../warn'
1
+ import { BINDERS, parsed, member, isSignal, warnOnce, type Signal } from '../core-api'
5
2
  BINDERS.push({
6
3
  match: (a) => a === 'x-model',
7
4
  bind: ({ inst, el, attr, raw }) => {
@@ -20,9 +17,10 @@ BINDERS.push({
20
17
  const h = () => { if (input.checked) s.set(input.value) }; el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
21
18
  inst.effect(() => { input.checked = s() === input.value })
22
19
  } 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 ?? '') }
20
+ // F434: a multiple select is an array of the picked values
21
+ const many = el.multiple
22
+ const h = () => s.set(many ? Array.from(el.options).filter((o) => o.selected).map((o) => o.value) : el.value); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
23
+ const apply = () => { const v = s(); if (many) { if (v != null) { const vs = ([] as unknown[]).concat(v).map(String); for (const o of el.options) o.selected = vs.includes(o.value) } } else if (el.value !== String(v)) el.value = String(v ?? '') }
26
24
  inst.effect(apply)
27
25
  const mo = new MutationObserver(() => apply()); mo.observe(el, { childList: true }); inst.onCleanup(() => mo.disconnect())
28
26
  } else {
@@ -1,11 +1,26 @@
1
- import { BINDERS } from './index'
2
- import { parsed, member } from './common'
1
+ import { BINDERS, parsed, member } from '../core-api'
2
+
3
+ // React's SyntheticEvent has five members a DOM event lacks; code written for React calls them on the event it is handed
4
+ // (Radix: `event.isPropagationStopped()`), and on a DOM event that is a TypeError (F195). Added to the event the handler
5
+ // receives, never to Event.prototype, so nothing else on the page changes.
6
+ const SYNTHETIC: PropertyDescriptorMap = {
7
+ isPropagationStopped: { value(this: Event) { return this.cancelBubble } },
8
+ isDefaultPrevented: { value(this: Event) { return this.defaultPrevented } },
9
+ persist: { value() {} },
10
+ isPersistent: { value: () => true },
11
+ nativeEvent: { get(this: Event) { return this } },
12
+ }
13
+ /** Gives a DOM event the React members (once). Shared with `x-prop-on-*`. */
14
+ export function reactEvent(e: Event): void {
15
+ if (!('isPropagationStopped' in e)) Object.defineProperties(e, SYNTHETIC)
16
+ }
3
17
  BINDERS.push({
4
18
  match: (a) => a.startsWith('x-on-'),
5
19
  bind: ({ inst, el, attr, raw, scope }) => {
6
20
  const event = attr.slice('x-on-'.length)
7
21
  const p = parsed(inst, attr, raw); if (!p) return
8
22
  const handler = (e: Event) => {
23
+ reactEvent(e)
9
24
  const fn = member(inst, p, attr)
10
25
  if (typeof fn !== 'function') return
11
26
  const args = p.bindings.map((b) => scope[b])
@@ -1,9 +1,5 @@
1
- import { BINDERS } from './index'
1
+ import { BINDERS, bindRef } from '../core-api'
2
2
  BINDERS.push({
3
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
- },
4
+ bind: ({ inst, el, raw }) => bindRef(inst, el, raw),
9
5
  })
@@ -1,5 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read } from './common'
1
+ import { BINDERS, parsed, read } from '../core-api'
3
2
  BINDERS.push({
4
3
  match: (a) => a === 'x-show',
5
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -1,5 +1,4 @@
1
- import { BINDERS } from './index'
2
- import { parsed, read, mismatch } from './common'
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
3
2
  BINDERS.push({
4
3
  match: (a) => a === 'x-text',
5
4
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -0,0 +1,6 @@
1
+ // The directives nearly every interactive page uses, shipped as ONE module (see README "Feature modules").
2
+ import '../directives/text'
3
+ import '../directives/show'
4
+ import '../directives/bind'
5
+ import '../directives/on'
6
+ import '../directives/ref'
@@ -0,0 +1,40 @@
1
+ // The collection feature (R34.140-R34.141): the items a Radix `createCollection` tracks, as the DOM already has them.
2
+ // A root is an element with `x-collection-root`; an item is an element with `data-radix-collection-item` whose NEAREST root
3
+ // above it is that root (a collection nested inside another keeps its items to itself). Document order is the DOM's own,
4
+ // so there is no registry to sort. An item's data (`id`, `focusable`, ...) is what its host's `x-collection-item` member
5
+ // evaluates to when asked: read at call time, so it is never stale.
6
+ import { BINDERS, collectionHooks, parsed, read, RAW_SEL } from '../core-api'
7
+ import type { CollectionItem } from '../core-api'
8
+
9
+ const ROOT = '[x-collection-root]'
10
+ const ITEM = '[data-radix-collection-item]'
11
+ const DATA = new WeakMap<Element, () => unknown>()
12
+
13
+ collectionHooks.items = (from) => {
14
+ const root = from.closest(ROOT)
15
+ if (!root) return []
16
+ const out: CollectionItem[] = []
17
+ for (const el of Array.from(root.querySelectorAll(ITEM))) {
18
+ // the nearest root ABOVE the item (not the item itself, which may be a root of its own)
19
+ if (el.parentElement?.closest(ROOT) !== root) continue
20
+ // an island or raw markup (x-html / x-raw) between the root and the item is inert: its items are not this collection's
21
+ // (Radix keeps only the nodes registered through its own context, never what a foreign widget or CMS markup holds)
22
+ const raw = el.parentElement?.closest(RAW_SEL)
23
+ if (raw && root.contains(raw)) continue
24
+ const d = DATA.get(el)?.()
25
+ out.push({ ...(d && typeof d === 'object' ? (d as object) : {}), ref: { current: el }, element: el })
26
+ }
27
+ return out
28
+ }
29
+
30
+ // markers: a root, and a host that reads `ctx.items()` (so the module loads for it); neither binds anything
31
+ BINDERS.push({ match: (a) => a === 'x-collection-root' || a === 'x-collection-use', bind: () => {} })
32
+ BINDERS.push({
33
+ match: (a) => a === 'x-collection-item',
34
+ bind: ({ inst, el, attr, raw, scope }) => {
35
+ const p = parsed(inst, attr, raw)
36
+ if (!p) return
37
+ DATA.set(el, () => { const r = read(inst, p, scope, attr); return r.ok ? r.value : undefined })
38
+ inst.onCleanup(() => DATA.delete(el))
39
+ },
40
+ })
@@ -0,0 +1 @@
1
+ import '../directives/for'
@@ -0,0 +1 @@
1
+ import '../directives/html'
@@ -0,0 +1 @@
1
+ import '../directives/if'
@@ -0,0 +1,9 @@
1
+ // Every feature at once: what `@brust/runtime-dom` (the full entry) loads. The browser build never uses this file;
2
+ // it imports exactly the features a route needs.
3
+ import './basics'
4
+ import './if'
5
+ import './for'
6
+ import './model'
7
+ import './html'
8
+ import './island'
9
+ import './collection'
@@ -0,0 +1,9 @@
1
+ // React island hydration glue. Registers on the core's island hooks; hosts already in the document when this module
2
+ // arrives (the core mounts first) are scheduled right away.
3
+ import { islandHooks } from '../core-api'
4
+ import { scheduleIslands, cancelIslands } from '../island'
5
+ islandHooks.schedule = scheduleIslands
6
+ islandHooks.cancel = cancelIslands
7
+ if (typeof document !== 'undefined') scheduleIslands(document.documentElement)
8
+ export { defineIsland } from '../island'
9
+ export type { IslandHydrate } from '../island'
@@ -0,0 +1 @@
1
+ import '../directives/model'
@@ -0,0 +1,6 @@
1
+ // The store module: `defineStore` plus the reactive primitives a store's code imports (`@brust/core/store` aliases here),
2
+ // re-exported from the core so there is one signal graph per page.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from '../core-api'
4
+ export type { Signal, Computed, Dispose } from '../core-api'
5
+ export { defineStore } from '../store/define-store'
6
+ export type { StoreHandle, Snapshot } from '../store/define-store'