@mindees/renderer 0.2.0 → 0.3.0

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/dist/css.js ADDED
@@ -0,0 +1,58 @@
1
+ //#region src/css.ts
2
+ /**
3
+ * Canonical CSS style serialization, shared by the DOM backend (applies per-prop to
4
+ * `el.style`) and the headless/SSR backend (serializes to a CSS string). Sharing one
5
+ * implementation guarantees **server-rendered markup matches the hydrated DOM** — the
6
+ * thing that broke when SSR emitted camelCase names with no units.
7
+ *
8
+ * @module
9
+ */
10
+ /**
11
+ * CSS properties whose numeric value is unitless (no `px`). Mirrors React DOM's
12
+ * `isUnitlessNumber` set — everything else gets `px` appended to a bare number, so a
13
+ * platform-agnostic `{ width: 12 }` renders as `12px` on web (and stays `12` on native).
14
+ */
15
+ const UNITLESS_STYLE_PROPS = new Set([
16
+ "opacity",
17
+ "flex",
18
+ "flexGrow",
19
+ "flexShrink",
20
+ "order",
21
+ "zIndex",
22
+ "fontWeight",
23
+ "lineHeight",
24
+ "aspectRatio"
25
+ ]);
26
+ /** Stringify a style value, appending `px` to a finite number on a non-unitless property. */
27
+ function styleValue(prop, value) {
28
+ if (typeof value === "number") {
29
+ if (!Number.isFinite(value)) return "";
30
+ return UNITLESS_STYLE_PROPS.has(prop) ? String(value) : `${value}px`;
31
+ }
32
+ return String(value);
33
+ }
34
+ /**
35
+ * camelCase → kebab-case CSS property name. Custom properties (`--x`) pass through
36
+ * verbatim; a leading-cap vendor prefix becomes a leading dash (`WebkitMask` →
37
+ * `-webkit-mask`), and `ms*` gets the conventional `-ms-` prefix.
38
+ */
39
+ function cssPropName(prop) {
40
+ if (prop.startsWith("--")) return prop;
41
+ return prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`).replace(/^ms-/, "-ms-");
42
+ }
43
+ /**
44
+ * Serialize a style object to a CSS declaration string (`background-color:red;margin-top:8px`),
45
+ * kebab-casing names and applying {@link styleValue} for units — matching what the DOM backend
46
+ * writes to `el.style`. Nullish / non-finite values are dropped.
47
+ */
48
+ function serializeStyle(style) {
49
+ return Object.entries(style).map(([prop, v]) => {
50
+ if (v === null || v === void 0) return "";
51
+ const val = styleValue(prop, v);
52
+ return val === "" ? "" : `${cssPropName(prop)}:${val}`;
53
+ }).filter(Boolean).join(";");
54
+ }
55
+ //#endregion
56
+ export { serializeStyle, styleValue };
57
+
58
+ //# sourceMappingURL=css.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"css.js","names":[],"sources":["../src/css.ts"],"sourcesContent":["/**\n * Canonical CSS style serialization, shared by the DOM backend (applies per-prop to\n * `el.style`) and the headless/SSR backend (serializes to a CSS string). Sharing one\n * implementation guarantees **server-rendered markup matches the hydrated DOM** — the\n * thing that broke when SSR emitted camelCase names with no units.\n *\n * @module\n */\n\n/**\n * CSS properties whose numeric value is unitless (no `px`). Mirrors React DOM's\n * `isUnitlessNumber` set — everything else gets `px` appended to a bare number, so a\n * platform-agnostic `{ width: 12 }` renders as `12px` on web (and stays `12` on native).\n */\nexport const UNITLESS_STYLE_PROPS = new Set([\n 'opacity',\n 'flex',\n 'flexGrow',\n 'flexShrink',\n 'order',\n 'zIndex',\n 'fontWeight',\n 'lineHeight',\n 'aspectRatio',\n])\n\n/** Stringify a style value, appending `px` to a finite number on a non-unitless property. */\nexport function styleValue(prop: string, value: unknown): string {\n if (typeof value === 'number') {\n if (!Number.isFinite(value)) return '' // NaN/Infinity → unset, never the literal \"NaN\"\n return UNITLESS_STYLE_PROPS.has(prop) ? String(value) : `${value}px`\n }\n return String(value)\n}\n\n/**\n * camelCase → kebab-case CSS property name. Custom properties (`--x`) pass through\n * verbatim; a leading-cap vendor prefix becomes a leading dash (`WebkitMask` →\n * `-webkit-mask`), and `ms*` gets the conventional `-ms-` prefix.\n */\nexport function cssPropName(prop: string): string {\n if (prop.startsWith('--')) return prop\n return prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`).replace(/^ms-/, '-ms-')\n}\n\n/**\n * Serialize a style object to a CSS declaration string (`background-color:red;margin-top:8px`),\n * kebab-casing names and applying {@link styleValue} for units — matching what the DOM backend\n * writes to `el.style`. Nullish / non-finite values are dropped.\n */\nexport function serializeStyle(style: Record<string, unknown>): string {\n return Object.entries(style)\n .map(([prop, v]) => {\n if (v === null || v === undefined) return ''\n const val = styleValue(prop, v)\n return val === '' ? '' : `${cssPropName(prop)}:${val}`\n })\n .filter(Boolean)\n .join(';')\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAa,uBAAuB,IAAI,IAAI;CAC1C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;AAGD,SAAgB,WAAW,MAAc,OAAwB;CAC/D,IAAI,OAAO,UAAU,UAAU;EAC7B,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG,OAAO;EACpC,OAAO,qBAAqB,IAAI,IAAI,IAAI,OAAO,KAAK,IAAI,GAAG,MAAM;CACnE;CACA,OAAO,OAAO,KAAK;AACrB;;;;;;AAOA,SAAgB,YAAY,MAAsB;CAChD,IAAI,KAAK,WAAW,IAAI,GAAG,OAAO;CAClC,OAAO,KAAK,QAAQ,WAAW,MAAM,IAAI,EAAE,YAAY,GAAG,EAAE,QAAQ,QAAQ,MAAM;AACpF;;;;;;AAOA,SAAgB,eAAe,OAAwC;CACrE,OAAO,OAAO,QAAQ,KAAK,EACxB,KAAK,CAAC,MAAM,OAAO;EAClB,IAAI,MAAM,QAAQ,MAAM,KAAA,GAAW,OAAO;EAC1C,MAAM,MAAM,WAAW,MAAM,CAAC;EAC9B,OAAO,QAAQ,KAAK,KAAK,GAAG,YAAY,IAAI,EAAE,GAAG;CACnD,CAAC,EACA,OAAO,OAAO,EACd,KAAK,GAAG;AACb"}
package/dist/dom.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"dom.d.ts","names":[],"sources":["../src/dom.ts"],"mappings":";;;;UAeU,WAAA;EACR,aAAA,CAAc,GAAA,WAAc,UAAA;EAC5B,cAAA,CAAe,IAAA,WAAe,OAAO;AAAA;AAAA,UAE7B,OAAA;EACR,UAAA,EAAY,OAAA;EACZ,WAAA,EAAa,OAAA;EACb,QAAA;EACA,WAAA,CAAY,KAAA,EAAO,OAAA,GAAU,OAAA;EAC7B,YAAA,CAAa,IAAA,EAAM,OAAA,EAAS,GAAA,EAAK,OAAA,UAAiB,OAAA;AAAA;AAAA,UAE1C,UAAA,SAAmB,OAAO;EAClC,YAAA,CAAa,IAAA,UAAc,KAAA;EAC3B,eAAA,CAAgB,IAAA;EAChB,gBAAA,CAAiB,IAAA,UAAc,QAAA,GAAW,CAAA;EAC1C,mBAAA,CAAoB,IAAA,UAAc,QAAA,GAAW,CAAA;EAC7C,KAAA,EAAO,MAAA;IAA2B,OAAA;EAAA;AAAA;AAAA,UAE1B,OAAA,SAAgB,OAAO;EAC/B,IAAI;AAAA;;iBAgBU,SAAA,CAAU,IAAY;;;;;;AA1BqB;iBAmF3C,gBAAA,CAAiB,GAAA,GAAM,WAAA,GAAc,WAAA,CAAY,OAAA"}
1
+ {"version":3,"file":"dom.d.ts","names":[],"sources":["../src/dom.ts"],"mappings":";;;;UAgBU,WAAA;EACR,aAAA,CAAc,GAAA,WAAc,UAAA;EAC5B,cAAA,CAAe,IAAA,WAAe,OAAO;AAAA;AAAA,UAE7B,OAAA;EACR,UAAA,EAAY,OAAA;EACZ,WAAA,EAAa,OAAA;EACb,QAAA;EACA,WAAA,CAAY,KAAA,EAAO,OAAA,GAAU,OAAA;EAC7B,YAAA,CAAa,IAAA,EAAM,OAAA,EAAS,GAAA,EAAK,OAAA,UAAiB,OAAA;AAAA;AAAA,UAE1C,UAAA,SAAmB,OAAO;EAClC,YAAA,CAAa,IAAA,UAAc,KAAA;EAC3B,eAAA,CAAgB,IAAA;EAChB,gBAAA,CAAiB,IAAA,UAAc,QAAA,GAAW,CAAA;EAC1C,mBAAA,CAAoB,IAAA,UAAc,QAAA,GAAW,CAAA;EAC7C,KAAA,EAAO,MAAA;IAA2B,OAAA;EAAA;AAAA;AAAA,UAE1B,OAAA,SAAgB,OAAO;EAC/B,IAAI;AAAA;;iBAgBU,SAAA,CAAU,IAAY;;;;;;AA1BqB;iBAgG3C,gBAAA,CAAiB,GAAA,GAAM,WAAA,GAAc,WAAA,CAAY,OAAA"}
package/dist/dom.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { styleValue } from "./css.js";
1
2
  //#region src/dom.ts
2
3
  const TEXT_NODE = 3;
3
4
  /** Tag aliases: MindeesNative semantic tags → HTML elements on the web target. */
@@ -13,32 +14,41 @@ const TAG_ALIASES = {
13
14
  function domTagFor(type) {
14
15
  return TAG_ALIASES[type] ?? type;
15
16
  }
16
- function isEventProp(key) {
17
- return key.length > 2 && key[0] === "o" && key[1] === "n" && key[2] === (key[2] ?? "").toUpperCase();
17
+ /**
18
+ * Inject the keyframes the `activityindicator` spinner needs, once per document. Uses a
19
+ * fuller DOM shape than {@link DomDocument}; on minimal/headless documents (no `head`) it's a
20
+ * no-op (tests don't need the animation, and the element still renders).
21
+ */
22
+ function ensureSpinnerKeyframes(document) {
23
+ const doc = document;
24
+ if (!doc.head || typeof doc.getElementById !== "function") return;
25
+ if (doc.getElementById("mindees-keyframes")) return;
26
+ const style = doc.createElement("style");
27
+ style.id = "mindees-keyframes";
28
+ style.textContent = "@keyframes mindees-spin{to{transform:rotate(360deg)}}";
29
+ doc.head.appendChild(style);
18
30
  }
19
31
  /**
20
- * CSS properties whose numeric value is unitless (no `px`). Mirrors React DOM's
21
- * `isUnitlessNumber` set everything else gets `px` appended to a bare number, so a
22
- * platform-agnostic `{ width: 12 }` renders as `12px` on web (and stays `12` on native).
32
+ * Build the web `activityindicator`: a CSS border-spinner. Size comes from the element's
33
+ * `width`/`height` style and the active arc from its `color` (`currentColor`), both applied
34
+ * normally afterwards so the backend owns only the animation + ring shape.
23
35
  */
24
- const UNITLESS_STYLE_PROPS = new Set([
25
- "opacity",
26
- "flex",
27
- "flexGrow",
28
- "flexShrink",
29
- "order",
30
- "zIndex",
31
- "fontWeight",
32
- "lineHeight",
33
- "aspectRatio"
34
- ]);
35
- /** Stringify a style value, appending `px` to a finite number on a non-unitless property. */
36
- function styleValue(prop, value) {
37
- if (typeof value === "number") {
38
- if (!Number.isFinite(value)) return "";
39
- return UNITLESS_STYLE_PROPS.has(prop) ? String(value) : `${value}px`;
40
- }
41
- return String(value);
36
+ function createSpinner(document) {
37
+ ensureSpinnerKeyframes(document);
38
+ const el = document.createElement("div");
39
+ const s = el.style;
40
+ s.boxSizing = "border-box";
41
+ s.display = "inline-block";
42
+ s.borderStyle = "solid";
43
+ s.borderWidth = "3px";
44
+ s.borderColor = "rgba(127, 127, 127, 0.30)";
45
+ s.borderTopColor = "currentColor";
46
+ s.borderRadius = "50%";
47
+ s.animation = "mindees-spin 0.8s linear infinite";
48
+ return el;
49
+ }
50
+ function isEventProp(key) {
51
+ return key.length > 2 && key[0] === "o" && key[1] === "n" && key[2] === (key[2] ?? "").toUpperCase();
42
52
  }
43
53
  /**
44
54
  * Form-control props that must be written as the live DOM **property** (not an attribute). The
@@ -68,7 +78,7 @@ function createDomBackend(doc) {
68
78
  if (!documentRef) throw new Error("createDomBackend: no document available (pass one explicitly outside a browser)");
69
79
  const document = documentRef;
70
80
  return {
71
- createElement: (type) => document.createElement(domTagFor(type)),
81
+ createElement: (type) => type === "activityindicator" ? createSpinner(document) : document.createElement(domTagFor(type)),
72
82
  createText: (value) => document.createTextNode(value),
73
83
  setProp(node, key, value, prev) {
74
84
  const el = node;
package/dist/dom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"dom.js","names":[],"sources":["../src/dom.ts"],"sourcesContent":["/**\n * DOM host backend — renders to real `Node`s in a browser (or any DOM, such as\n * happy-dom/jsdom in tests).\n *\n * MindeesNative element tags map to DOM via a small alias table so the same app\n * tree (`view`, `text`, …) renders to sensible HTML; unknown tags pass through\n * as custom elements. Props become attributes, except `onX` event props (added\n * as listeners) and `style` objects (applied to `style`).\n *\n * @module\n */\n\nimport type { HostBackend } from './backend'\n\n/** A minimal structural view of the DOM we use (so types don't require `lib.dom`). */\ninterface DomDocument {\n createElement(tag: string): DomElement\n createTextNode(data: string): DomText\n}\ninterface DomNode {\n parentNode: DomNode | null\n nextSibling: DomNode | null\n nodeType: number\n removeChild(child: DomNode): DomNode\n insertBefore(node: DomNode, ref: DomNode | null): DomNode\n}\ninterface DomElement extends DomNode {\n setAttribute(name: string, value: string): void\n removeAttribute(name: string): void\n addEventListener(type: string, listener: (e: unknown) => void): void\n removeEventListener(type: string, listener: (e: unknown) => void): void\n style: Record<string, string> & { cssText: string }\n}\ninterface DomText extends DomNode {\n data: string\n}\n\nconst TEXT_NODE = 3\n\n/** Tag aliases: MindeesNative semantic tags → HTML elements on the web target. */\nconst TAG_ALIASES: Record<string, string> = {\n view: 'div',\n text: 'span',\n image: 'img',\n scrollview: 'div',\n textinput: 'input',\n button: 'button',\n}\n\n/** Map a MindeesNative tag to its DOM tag. Unknown tags pass through. */\nexport function domTagFor(type: string): string {\n return TAG_ALIASES[type] ?? type\n}\n\nfunction isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/**\n * CSS properties whose numeric value is unitless (no `px`). Mirrors React DOM's\n * `isUnitlessNumber` set everything else gets `px` appended to a bare number, so a\n * platform-agnostic `{ width: 12 }` renders as `12px` on web (and stays `12` on native).\n */\nconst UNITLESS_STYLE_PROPS = new Set([\n 'opacity',\n 'flex',\n 'flexGrow',\n 'flexShrink',\n 'order',\n 'zIndex',\n 'fontWeight',\n 'lineHeight',\n 'aspectRatio',\n])\n\n/** Stringify a style value, appending `px` to a finite number on a non-unitless property. */\nfunction styleValue(prop: string, value: unknown): string {\n if (typeof value === 'number') {\n if (!Number.isFinite(value)) return '' // NaN/Infinity unset, never the literal \"NaN\"\n return UNITLESS_STYLE_PROPS.has(prop) ? String(value) : `${value}px`\n }\n return String(value)\n}\n\n/**\n * Form-control props that must be written as the live DOM **property** (not an attribute). The\n * `value`/`checked` *attribute* only seeds the DEFAULT; once the user edits, the property and\n * attribute diverge, so a controlled update must set the property to change what's shown.\n */\nconst FORM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate'])\n\n/** `onClick` → `click`, `onPointerDown` → `pointerdown`. */\nfunction eventNameFor(key: string): string {\n return key.slice(2).toLowerCase()\n}\n\n/** Listeners we've attached, so reactive updates can swap them cleanly. */\nconst listeners = new WeakMap<object, Map<string, (e: unknown) => void>>()\n\n/**\n * Create a {@link HostBackend} that renders to real DOM nodes.\n *\n * @param doc - The document to create nodes with. Defaults to the global\n * `document`; pass a happy-dom/jsdom document for headless tests.\n */\nexport function createDomBackend(doc?: DomDocument): HostBackend<DomNode> {\n const documentRef = doc ?? (globalThis as unknown as { document?: DomDocument }).document\n if (!documentRef) {\n throw new Error(\n 'createDomBackend: no document available (pass one explicitly outside a browser)',\n )\n }\n const document = documentRef\n\n return {\n createElement: (type) => document.createElement(domTagFor(type)),\n createText: (value) => document.createTextNode(value),\n\n setProp(node, key, value, prev): void {\n const el = node as DomElement\n if (isEventProp(key)) {\n const event = eventNameFor(key)\n let map = listeners.get(el)\n if (!map) {\n map = new Map()\n listeners.set(el, map)\n }\n const old = map.get(event)\n if (old) el.removeEventListener(event, old)\n if (typeof value === 'function') {\n const fn = value as (e: unknown) => void\n el.addEventListener(event, fn)\n map.set(event, fn)\n } else {\n map.delete(event)\n }\n return\n }\n if (key === 'style') {\n const style = el.style\n const next = value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n const prevObj = prev && typeof prev === 'object' ? (prev as Record<string, unknown>) : null\n // Clear keys present in the previous style object but absent from the\n // new one, so stale inline styles don't persist across reactive updates.\n if (prevObj) {\n for (const prop of Object.keys(prevObj)) {\n if (!next || !(prop in next)) style[prop] = ''\n }\n }\n if (next) {\n for (const [prop, v] of Object.entries(next)) {\n // Nullish → unset (don't write the literal \"undefined\"/\"null\").\n style[prop] = v === null || v === undefined ? '' : styleValue(prop, v)\n }\n }\n return\n }\n if (FORM_PROPERTIES.has(key)) {\n // Live property, so a controlled `value`/`checked` updates what the user sees.\n ;(el as unknown as Record<string, unknown>)[key] =\n value === null || value === undefined ? '' : value\n return\n }\n if (value === false || value === null || value === undefined) {\n el.removeAttribute(key)\n } else {\n el.setAttribute(key, value === true ? '' : String(value))\n }\n void prev\n },\n\n setText(node, value): void {\n ;(node as DomText).data = value\n },\n\n insert(parent, node, anchor): void {\n ;(parent as DomNode).insertBefore(node, anchor)\n },\n\n remove(parent, node): void {\n ;(parent as DomNode).removeChild(node)\n },\n\n parentOf: (node) => node.parentNode,\n nextSibling: (node) => node.nextSibling,\n isText: (node) => node.nodeType === TEXT_NODE,\n }\n}\n\nexport type { DomDocument, DomElement, DomNode, DomText }\n"],"mappings":"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"}
1
+ {"version":3,"file":"dom.js","names":[],"sources":["../src/dom.ts"],"sourcesContent":["/**\n * DOM host backend — renders to real `Node`s in a browser (or any DOM, such as\n * happy-dom/jsdom in tests).\n *\n * MindeesNative element tags map to DOM via a small alias table so the same app\n * tree (`view`, `text`, …) renders to sensible HTML; unknown tags pass through\n * as custom elements. Props become attributes, except `onX` event props (added\n * as listeners) and `style` objects (applied to `style`).\n *\n * @module\n */\n\nimport type { HostBackend } from './backend'\nimport { styleValue } from './css'\n\n/** A minimal structural view of the DOM we use (so types don't require `lib.dom`). */\ninterface DomDocument {\n createElement(tag: string): DomElement\n createTextNode(data: string): DomText\n}\ninterface DomNode {\n parentNode: DomNode | null\n nextSibling: DomNode | null\n nodeType: number\n removeChild(child: DomNode): DomNode\n insertBefore(node: DomNode, ref: DomNode | null): DomNode\n}\ninterface DomElement extends DomNode {\n setAttribute(name: string, value: string): void\n removeAttribute(name: string): void\n addEventListener(type: string, listener: (e: unknown) => void): void\n removeEventListener(type: string, listener: (e: unknown) => void): void\n style: Record<string, string> & { cssText: string }\n}\ninterface DomText extends DomNode {\n data: string\n}\n\nconst TEXT_NODE = 3\n\n/** Tag aliases: MindeesNative semantic tags → HTML elements on the web target. */\nconst TAG_ALIASES: Record<string, string> = {\n view: 'div',\n text: 'span',\n image: 'img',\n scrollview: 'div',\n textinput: 'input',\n button: 'button',\n}\n\n/** Map a MindeesNative tag to its DOM tag. Unknown tags pass through. */\nexport function domTagFor(type: string): string {\n return TAG_ALIASES[type] ?? type\n}\n\n/**\n * Inject the keyframes the `activityindicator` spinner needs, once per document. Uses a\n * fuller DOM shape than {@link DomDocument}; on minimal/headless documents (no `head`) it's a\n * no-op (tests don't need the animation, and the element still renders).\n */\nfunction ensureSpinnerKeyframes(document: DomDocument): void {\n const doc = document as unknown as {\n getElementById?: (id: string) => unknown\n head?: { appendChild: (node: unknown) => void }\n createElement: (tag: string) => { id: string; textContent: string }\n }\n if (!doc.head || typeof doc.getElementById !== 'function') return\n if (doc.getElementById('mindees-keyframes')) return\n const style = doc.createElement('style')\n style.id = 'mindees-keyframes'\n style.textContent = '@keyframes mindees-spin{to{transform:rotate(360deg)}}'\n doc.head.appendChild(style)\n}\n\n/**\n * Build the web `activityindicator`: a CSS border-spinner. Size comes from the element's\n * `width`/`height` style and the active arc from its `color` (`currentColor`), both applied\n * normally afterwards so the backend owns only the animation + ring shape.\n */\nfunction createSpinner(document: DomDocument): DomElement {\n ensureSpinnerKeyframes(document)\n const el = document.createElement('div')\n const s = el.style\n s.boxSizing = 'border-box'\n s.display = 'inline-block'\n s.borderStyle = 'solid'\n s.borderWidth = '3px'\n s.borderColor = 'rgba(127, 127, 127, 0.30)'\n s.borderTopColor = 'currentColor'\n s.borderRadius = '50%'\n s.animation = 'mindees-spin 0.8s linear infinite'\n return el\n}\n\nfunction isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/**\n * Form-control props that must be written as the live DOM **property** (not an attribute). The\n * `value`/`checked` *attribute* only seeds the DEFAULT; once the user edits, the property and\n * attribute diverge, so a controlled update must set the property to change what's shown.\n */\nconst FORM_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate'])\n\n/** `onClick` → `click`, `onPointerDown` → `pointerdown`. */\nfunction eventNameFor(key: string): string {\n return key.slice(2).toLowerCase()\n}\n\n/** Listeners we've attached, so reactive updates can swap them cleanly. */\nconst listeners = new WeakMap<object, Map<string, (e: unknown) => void>>()\n\n/**\n * Create a {@link HostBackend} that renders to real DOM nodes.\n *\n * @param doc - The document to create nodes with. Defaults to the global\n * `document`; pass a happy-dom/jsdom document for headless tests.\n */\nexport function createDomBackend(doc?: DomDocument): HostBackend<DomNode> {\n const documentRef = doc ?? (globalThis as unknown as { document?: DomDocument }).document\n if (!documentRef) {\n throw new Error(\n 'createDomBackend: no document available (pass one explicitly outside a browser)',\n )\n }\n const document = documentRef\n\n return {\n createElement: (type) =>\n type === 'activityindicator'\n ? createSpinner(document)\n : document.createElement(domTagFor(type)),\n createText: (value) => document.createTextNode(value),\n\n setProp(node, key, value, prev): void {\n const el = node as DomElement\n if (isEventProp(key)) {\n const event = eventNameFor(key)\n let map = listeners.get(el)\n if (!map) {\n map = new Map()\n listeners.set(el, map)\n }\n const old = map.get(event)\n if (old) el.removeEventListener(event, old)\n if (typeof value === 'function') {\n const fn = value as (e: unknown) => void\n el.addEventListener(event, fn)\n map.set(event, fn)\n } else {\n map.delete(event)\n }\n return\n }\n if (key === 'style') {\n const style = el.style\n const next = value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n const prevObj = prev && typeof prev === 'object' ? (prev as Record<string, unknown>) : null\n // Clear keys present in the previous style object but absent from the\n // new one, so stale inline styles don't persist across reactive updates.\n if (prevObj) {\n for (const prop of Object.keys(prevObj)) {\n if (!next || !(prop in next)) style[prop] = ''\n }\n }\n if (next) {\n for (const [prop, v] of Object.entries(next)) {\n // Nullish → unset (don't write the literal \"undefined\"/\"null\").\n style[prop] = v === null || v === undefined ? '' : styleValue(prop, v)\n }\n }\n return\n }\n if (FORM_PROPERTIES.has(key)) {\n // Live property, so a controlled `value`/`checked` updates what the user sees.\n ;(el as unknown as Record<string, unknown>)[key] =\n value === null || value === undefined ? '' : value\n return\n }\n if (value === false || value === null || value === undefined) {\n el.removeAttribute(key)\n } else {\n el.setAttribute(key, value === true ? '' : String(value))\n }\n void prev\n },\n\n setText(node, value): void {\n ;(node as DomText).data = value\n },\n\n insert(parent, node, anchor): void {\n ;(parent as DomNode).insertBefore(node, anchor)\n },\n\n remove(parent, node): void {\n ;(parent as DomNode).removeChild(node)\n },\n\n parentOf: (node) => node.parentNode,\n nextSibling: (node) => node.nextSibling,\n isText: (node) => node.nodeType === TEXT_NODE,\n }\n}\n\nexport type { DomDocument, DomElement, DomNode, DomText }\n"],"mappings":"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"}
package/dist/for.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { HostBackend } from "./backend.js";
2
+ import { KeyedRegion } from "@mindees/core";
3
+
4
+ //#region src/for.d.ts
5
+ /**
6
+ * Materialize a {@link KeyedRegion} into `parent` before `anchor`, reconciling by key on every
7
+ * change. Returns a stable live array (current content followed by a slot marker), the same
8
+ * contract as the renderer's reactive-child binding. Must run inside an owner (an enclosing
9
+ * `createRoot`/region) so its cleanup is scoped.
10
+ */
11
+ declare function bindKeyedChild<N, T>(region: KeyedRegion<T>, backend: HostBackend<N>, parent: N, initialAnchor: N | null): N[];
12
+ //#endregion
13
+ export { bindKeyedChild };
14
+ //# sourceMappingURL=for.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"for.d.ts","names":[],"sources":["../src/for.ts"],"mappings":";;;;;;;;;;iBAsEgB,cAAA,OACd,MAAA,EAAQ,WAAA,CAAY,CAAA,GACpB,OAAA,EAAS,WAAA,CAAY,CAAA,GACrB,MAAA,EAAQ,CAAA,EACR,aAAA,EAAe,CAAA,UACd,CAAA"}
package/dist/for.js ADDED
@@ -0,0 +1,187 @@
1
+ import { mountNode } from "./render.js";
2
+ import { batch, createRoot, effect, onCleanup, signal, untrack } from "@mindees/core";
3
+ //#region src/for.ts
4
+ /**
5
+ * Keyed list reconciliation — the renderer side of core's {@link KeyedRegion}.
6
+ *
7
+ * Materializes a keyed list so rows keep host-node identity (focus, caret, scroll, input state)
8
+ * across reorders, instead of the full teardown+rebuild a plain `() => items().map(...)` causes.
9
+ * On each change: existing rows are **reused** (their item/index signals patched in place), new
10
+ * keys **created** (each in its own reactive root), removed keys **disposed**, and host nodes
11
+ * **moved** via a longest-increasing-subsequence pass so the minimum number move (append → 0,
12
+ * adjacent swap → 1, full reverse → n−1). See ADR-0024.
13
+ *
14
+ * @module
15
+ */
16
+ /**
17
+ * Indices (into `seq`) forming a longest strictly-increasing subsequence — the rows already in
18
+ * correct relative order, which therefore must NOT move. Standard O(n log n) patience sort.
19
+ */
20
+ function longestIncreasingSubsequence(seq) {
21
+ const n = seq.length;
22
+ const result = /* @__PURE__ */ new Set();
23
+ if (n === 0) return result;
24
+ const tails = [];
25
+ const prev = new Array(n).fill(-1);
26
+ for (let i = 0; i < n; i++) {
27
+ const value = seq[i];
28
+ let lo = 0;
29
+ let hi = tails.length;
30
+ while (lo < hi) {
31
+ const mid = lo + hi >> 1;
32
+ if (seq[tails[mid]] < value) lo = mid + 1;
33
+ else hi = mid;
34
+ }
35
+ if (lo > 0) prev[i] = tails[lo - 1];
36
+ tails[lo] = i;
37
+ }
38
+ let k = tails[tails.length - 1];
39
+ while (k !== -1) {
40
+ result.add(k);
41
+ k = prev[k];
42
+ }
43
+ return result;
44
+ }
45
+ /**
46
+ * Materialize a {@link KeyedRegion} into `parent` before `anchor`, reconciling by key on every
47
+ * change. Returns a stable live array (current content followed by a slot marker), the same
48
+ * contract as the renderer's reactive-child binding. Must run inside an owner (an enclosing
49
+ * `createRoot`/region) so its cleanup is scoped.
50
+ */
51
+ function bindKeyedChild(region, backend, parent, initialAnchor) {
52
+ const marker = backend.createText("");
53
+ backend.insert(parent, marker, initialAnchor);
54
+ const nodes = [marker];
55
+ let cache = /* @__PURE__ */ new Map();
56
+ let fallbackNodes = [];
57
+ let fallbackDispose = null;
58
+ const keyOf = region.key ?? ((item) => item);
59
+ const removeNodes = (toRemove) => {
60
+ for (const node of toRemove) if (backend.parentOf(node) === parent) backend.remove(parent, node);
61
+ };
62
+ const clearFallback = () => {
63
+ if (fallbackDispose) {
64
+ fallbackDispose();
65
+ fallbackDispose = null;
66
+ }
67
+ removeNodes(fallbackNodes);
68
+ fallbackNodes = [];
69
+ };
70
+ const disposeEntry = (entry) => {
71
+ entry.dispose();
72
+ removeNodes(entry.nodes);
73
+ };
74
+ const rebuildLiveNodes = (order) => {
75
+ nodes.length = 0;
76
+ for (const entry of order) nodes.push(...entry.nodes);
77
+ nodes.push(marker);
78
+ };
79
+ onCleanup(() => {
80
+ for (const entry of cache.values()) disposeEntry(entry);
81
+ cache.clear();
82
+ clearFallback();
83
+ if (backend.parentOf(marker)) backend.remove(parent, marker);
84
+ });
85
+ effect(() => {
86
+ const items = region.each();
87
+ untrack(() => {
88
+ batch(() => {
89
+ const n = items.length;
90
+ const keys = new Array(n);
91
+ const seen = /* @__PURE__ */ new Set();
92
+ for (let i = 0; i < n; i++) {
93
+ const k = keyOf(items[i], i);
94
+ if (k === null || k === void 0) throw new Error("For: key must not be null or undefined");
95
+ if (seen.has(k)) throw new Error(`For: duplicate key ${String(k)}`);
96
+ seen.add(k);
97
+ keys[i] = k;
98
+ }
99
+ if (n === 0) {
100
+ for (const entry of cache.values()) disposeEntry(entry);
101
+ cache.clear();
102
+ const fallback = region.fallback;
103
+ if (fallback && !fallbackDispose) try {
104
+ fallbackNodes = createRoot((dispose) => {
105
+ fallbackDispose = dispose;
106
+ return mountNode(fallback(), backend, parent, marker);
107
+ });
108
+ } catch (error) {
109
+ clearFallback();
110
+ throw error;
111
+ }
112
+ rebuildLiveNodes([]);
113
+ return;
114
+ }
115
+ clearFallback();
116
+ const oldPos = /* @__PURE__ */ new Map();
117
+ let p = 0;
118
+ for (const entry of cache.values()) oldPos.set(entry, p++);
119
+ const newCache = /* @__PURE__ */ new Map();
120
+ const order = new Array(n);
121
+ const reused = /* @__PURE__ */ new Set();
122
+ const created = [];
123
+ try {
124
+ for (let i = 0; i < n; i++) {
125
+ const k = keys[i];
126
+ const existing = cache.get(k);
127
+ if (existing) {
128
+ existing.itemSig.set(items[i]);
129
+ existing.indexSig.set(i);
130
+ reused.add(k);
131
+ newCache.set(k, existing);
132
+ order[i] = existing;
133
+ } else {
134
+ const item = items[i];
135
+ const captured = i;
136
+ const entry = createRoot((dispose) => {
137
+ const itemSig = signal(item);
138
+ const indexSig = signal(captured);
139
+ return {
140
+ nodes: mountNode(region.mapFn(() => itemSig(), () => indexSig()), backend, parent, marker),
141
+ itemSig,
142
+ indexSig,
143
+ dispose
144
+ };
145
+ });
146
+ created.push(entry);
147
+ newCache.set(k, entry);
148
+ order[i] = entry;
149
+ }
150
+ }
151
+ } catch (error) {
152
+ for (const entry of created) disposeEntry(entry);
153
+ throw error;
154
+ }
155
+ for (const [k, entry] of cache) if (!reused.has(k)) disposeEntry(entry);
156
+ const survivorOldPositions = [];
157
+ const survivorTargetIndex = [];
158
+ for (let i = 0; i < n; i++) {
159
+ const op = oldPos.get(order[i]);
160
+ if (op !== void 0) {
161
+ survivorOldPositions.push(op);
162
+ survivorTargetIndex.push(i);
163
+ }
164
+ }
165
+ const lis = longestIncreasingSubsequence(survivorOldPositions);
166
+ const stable = /* @__PURE__ */ new Set();
167
+ for (const idx of lis) stable.add(survivorTargetIndex[idx]);
168
+ let anchor = marker;
169
+ for (let i = n - 1; i >= 0; i--) {
170
+ const entry = order[i];
171
+ if (!stable.has(i) && entry.nodes.length > 0) {
172
+ const last = entry.nodes[entry.nodes.length - 1];
173
+ if (backend.nextSibling(last) !== anchor) for (const node of entry.nodes) backend.insert(parent, node, anchor);
174
+ }
175
+ if (entry.nodes.length > 0) anchor = entry.nodes[0];
176
+ }
177
+ cache = newCache;
178
+ rebuildLiveNodes(order);
179
+ });
180
+ });
181
+ });
182
+ return nodes;
183
+ }
184
+ //#endregion
185
+ export { bindKeyedChild };
186
+
187
+ //# sourceMappingURL=for.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"for.js","names":[],"sources":["../src/for.ts"],"sourcesContent":["/**\n * Keyed list reconciliation — the renderer side of core's {@link KeyedRegion}.\n *\n * Materializes a keyed list so rows keep host-node identity (focus, caret, scroll, input state)\n * across reorders, instead of the full teardown+rebuild a plain `() => items().map(...)` causes.\n * On each change: existing rows are **reused** (their item/index signals patched in place), new\n * keys **created** (each in its own reactive root), removed keys **disposed**, and host nodes\n * **moved** via a longest-increasing-subsequence pass so the minimum number move (append → 0,\n * adjacent swap → 1, full reverse → n−1). See ADR-0024.\n *\n * @module\n */\n\nimport {\n batch,\n createRoot,\n effect,\n type KeyedRegion,\n onCleanup,\n type Signal,\n signal,\n untrack,\n} from '@mindees/core'\nimport type { HostBackend } from './backend'\nimport { mountNode } from './render'\n\n/** One reconciled row: its host nodes, reactive item/index inputs, and its disposer. */\ninterface Entry<N, T> {\n nodes: N[]\n readonly itemSig: Signal<T>\n readonly indexSig: Signal<number>\n readonly dispose: () => void\n}\n\n/**\n * Indices (into `seq`) forming a longest strictly-increasing subsequence — the rows already in\n * correct relative order, which therefore must NOT move. Standard O(n log n) patience sort.\n */\nfunction longestIncreasingSubsequence(seq: readonly number[]): Set<number> {\n const n = seq.length\n const result = new Set<number>()\n if (n === 0) return result\n const tails: number[] = [] // tails[k] = index into seq of the smallest tail of a length-(k+1) run\n const prev: number[] = new Array(n).fill(-1)\n for (let i = 0; i < n; i++) {\n const value = seq[i] as number\n let lo = 0\n let hi = tails.length\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if ((seq[tails[mid] as number] as number) < value) lo = mid + 1\n else hi = mid\n }\n if (lo > 0) prev[i] = tails[lo - 1] as number\n tails[lo] = i\n }\n let k = tails[tails.length - 1] as number\n while (k !== -1) {\n result.add(k)\n k = prev[k] as number\n }\n return result\n}\n\n/**\n * Materialize a {@link KeyedRegion} into `parent` before `anchor`, reconciling by key on every\n * change. Returns a stable live array (current content followed by a slot marker), the same\n * contract as the renderer's reactive-child binding. Must run inside an owner (an enclosing\n * `createRoot`/region) so its cleanup is scoped.\n */\nexport function bindKeyedChild<N, T>(\n region: KeyedRegion<T>,\n backend: HostBackend<N>,\n parent: N,\n initialAnchor: N | null,\n): N[] {\n // A persistent invisible marker pins the region's slot; content always mounts before it.\n const marker = backend.createText('')\n backend.insert(parent, marker, initialAnchor)\n\n const nodes: N[] = [marker] // STABLE live array: content…, then marker (mutated in place)\n let cache = new Map<unknown, Entry<N, T>>() // insertion-ordered key → row\n let fallbackNodes: N[] = []\n let fallbackDispose: (() => void) | null = null\n\n const keyOf = region.key ?? ((item: T) => item as unknown)\n\n const removeNodes = (toRemove: readonly N[]): void => {\n // Guard on parent IDENTITY (not truthiness): a nested region's outer re-run may have\n // already detached these, and the DOM backend's removeChild throws on a non-child.\n for (const node of toRemove) if (backend.parentOf(node) === parent) backend.remove(parent, node)\n }\n const clearFallback = (): void => {\n if (fallbackDispose) {\n fallbackDispose()\n fallbackDispose = null\n }\n removeNodes(fallbackNodes)\n fallbackNodes = []\n }\n const disposeEntry = (entry: Entry<N, T>): void => {\n entry.dispose()\n removeNodes(entry.nodes)\n }\n const rebuildLiveNodes = (order: readonly Entry<N, T>[]): void => {\n nodes.length = 0\n for (const entry of order) nodes.push(...entry.nodes)\n nodes.push(marker)\n }\n\n // Authoritative teardown: dispose every row + the fallback + the marker (all guarded).\n onCleanup(() => {\n for (const entry of cache.values()) disposeEntry(entry)\n cache.clear()\n clearFallback()\n if (backend.parentOf(marker)) backend.remove(parent, marker)\n })\n\n effect(() => {\n const items = region.each() // the ONLY tracked read\n untrack(() => {\n batch(() => {\n const n = items.length\n\n // Validate keys up front — before ANY host mutation, so a bad list never half-moves.\n const keys: unknown[] = new Array(n)\n const seen = new Set<unknown>()\n for (let i = 0; i < n; i++) {\n const k = keyOf(items[i] as T, i)\n if (k === null || k === undefined) {\n throw new Error('For: key must not be null or undefined')\n }\n if (seen.has(k)) throw new Error(`For: duplicate key ${String(k)}`)\n seen.add(k)\n keys[i] = k\n }\n\n // Empty → dispose all rows, show the fallback (once) in the slot.\n if (n === 0) {\n for (const entry of cache.values()) disposeEntry(entry)\n cache.clear()\n const fallback = region.fallback\n if (fallback && !fallbackDispose) {\n try {\n fallbackNodes = createRoot((dispose) => {\n fallbackDispose = dispose\n return mountNode(fallback(), backend, parent, marker)\n })\n } catch (error) {\n clearFallback() // roll back a partially-mounted fallback, then surface the error\n throw error\n }\n }\n rebuildLiveNodes([])\n return\n }\n clearFallback() // non-empty: never leave a stale fallback mounted\n\n // Record each surviving row's OLD position (insertion order). `cache` stays AUTHORITATIVE\n // until the build below fully succeeds, so a mapFn throw can't strand/duplicate survivors.\n const oldPos = new Map<Entry<N, T>, number>()\n let p = 0\n for (const entry of cache.values()) oldPos.set(entry, p++)\n\n // Build the new ordered rows: reuse on a key hit (patch signals), else create a root.\n // Exception-safe: if a row's mapFn throws, tear down only the rows created in THIS pass\n // and rethrow, leaving `cache` + the host tree exactly as they were (no duplicate/leaked\n // rows that survive even a later clean update — the bug an adversarial review caught).\n const newCache = new Map<unknown, Entry<N, T>>()\n const order: Entry<N, T>[] = new Array(n)\n const reused = new Set<unknown>()\n const created: Entry<N, T>[] = []\n try {\n for (let i = 0; i < n; i++) {\n const k = keys[i]\n const existing = cache.get(k)\n if (existing) {\n existing.itemSig.set(items[i] as T) // Object.is-gated → a same-ref item is a no-op\n existing.indexSig.set(i)\n reused.add(k)\n newCache.set(k, existing)\n order[i] = existing\n } else {\n const item = items[i] as T\n const captured = i\n const entry = createRoot<Entry<N, T>>((dispose) => {\n const itemSig = signal(item)\n const indexSig = signal(captured)\n const content = mountNode(\n region.mapFn(\n () => itemSig(),\n () => indexSig(),\n ),\n backend,\n parent,\n marker,\n )\n return { nodes: content, itemSig, indexSig, dispose }\n })\n created.push(entry)\n newCache.set(k, entry)\n order[i] = entry\n }\n }\n } catch (error) {\n for (const entry of created) disposeEntry(entry) // survivors stay in `cache`, untouched\n throw error\n }\n\n // Commit: dispose rows whose key disappeared (old cache minus the reused ones).\n for (const [k, entry] of cache) {\n if (!reused.has(k)) disposeEntry(entry)\n }\n\n // Minimal moves: the LIS of survivors' old positions is already correctly ordered and\n // stays put; everything else (created rows + out-of-order survivors) is inserted.\n const survivorOldPositions: number[] = []\n const survivorTargetIndex: number[] = []\n for (let i = 0; i < n; i++) {\n const op = oldPos.get(order[i] as Entry<N, T>)\n if (op !== undefined) {\n survivorOldPositions.push(op)\n survivorTargetIndex.push(i)\n }\n }\n const lis = longestIncreasingSubsequence(survivorOldPositions)\n const stable = new Set<number>()\n for (const idx of lis) stable.add(survivorTargetIndex[idx] as number)\n\n // Walk target order last → first; insert a non-stable row before the running anchor ONLY\n // if it isn't already there (its last node's next sibling is the anchor). The position\n // check makes a pure append move zero nodes (no spurious native detach/reattach).\n let anchor: N = marker\n for (let i = n - 1; i >= 0; i--) {\n const entry = order[i] as Entry<N, T>\n if (!stable.has(i) && entry.nodes.length > 0) {\n const last = entry.nodes[entry.nodes.length - 1] as N\n if (backend.nextSibling(last) !== anchor) {\n for (const node of entry.nodes) backend.insert(parent, node, anchor)\n }\n }\n if (entry.nodes.length > 0) anchor = entry.nodes[0] as N\n }\n\n cache = newCache\n rebuildLiveNodes(order)\n })\n })\n })\n\n return nodes\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"headless.d.ts","names":[],"sources":["../src/headless.ts"],"mappings":";;;;UAaiB,YAAA;EAIf;EAFA,IAAA;EAIA;EAFA,KAAA,EAAO,MAAA;EAIG;EAFV,IAAA;EAIQ;EAFR,QAAA,EAAU,YAAA;EAEU;EAApB,MAAA,EAAQ,YAAA;AAAA;;iBAiEM,qBAAA,IAAyB,mBAAmB,CAAC,YAAA;AAAY;AAAA,iBAkEzD,WAAA,CAAY,GAAW;;iBAOvB,kBAAA,CAAmB,IAAA,YAAgB,YAAY"}
1
+ {"version":3,"file":"headless.d.ts","names":[],"sources":["../src/headless.ts"],"mappings":";;;;UAciB,YAAA;EAIf;EAFA,IAAA;EAIA;EAFA,KAAA,EAAO,MAAA;EAIG;EAFV,IAAA;EAIQ;EAFR,QAAA,EAAU,YAAA;EAEU;EAApB,MAAA,EAAQ,YAAA;AAAA;;iBAkEM,qBAAA,IAAyB,mBAAmB,CAAC,YAAA;AAAY;AAAA,iBAkEzD,WAAA,CAAY,GAAW;;iBAOvB,kBAAA,CAAmB,IAAA,YAAgB,YAAY"}
package/dist/headless.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { serializeStyle } from "./css.js";
1
2
  //#region src/headless.ts
2
3
  const TEXT = "#text";
3
4
  function escapeAttr(value) {
@@ -6,9 +7,12 @@ function escapeAttr(value) {
6
7
  function escapeText(value) {
7
8
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
8
9
  }
9
- /** Render an attribute value: a `style` object becomes a CSS string. */
10
+ /**
11
+ * Render an attribute value: a `style` object becomes a CSS string via the SAME serializer
12
+ * the DOM backend uses (kebab-case names + `px` units), so SSR markup matches the hydrated DOM.
13
+ */
10
14
  function serializeAttrValue(value) {
11
- if (value && typeof value === "object") return Object.entries(value).map(([prop, v]) => `${prop}:${String(v)}`).join(";");
15
+ if (value && typeof value === "object") return serializeStyle(value);
12
16
  return String(value);
13
17
  }
14
18
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"headless.js","names":[],"sources":["../src/headless.ts"],"sourcesContent":["/**\n * Headless host backend — an in-memory host tree, no browser required.\n *\n * This is the **reference backend**: it implements the full {@link HostBackend}\n * (plus {@link SerializableBackend}) so the entire reconciler can be exercised\n * in CI without a DOM. It's also handy for snapshot-testing rendered output.\n *\n * @module\n */\n\nimport type { SerializableBackend, SerializeOptions } from './backend'\n\n/** A headless host node: an element (with tag/props/children) or a text node. */\nexport interface HeadlessNode {\n /** `\"#text\"` for text nodes, otherwise the element tag. */\n type: string\n /** Applied props (elements only). */\n props: Record<string, unknown>\n /** Text content (text nodes only). */\n text: string\n /** Child nodes (elements only). */\n children: HeadlessNode[]\n /** Back-pointer to the parent, or `null` when detached. */\n parent: HeadlessNode | null\n}\n\nconst TEXT = '#text'\n\nfunction escapeAttr(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n\n/** Render an attribute value: a `style` object becomes a CSS string. */\nfunction serializeAttrValue(value: unknown): string {\n if (value && typeof value === 'object') {\n return Object.entries(value as Record<string, unknown>)\n .map(([prop, v]) => `${prop}:${String(v)}`)\n .join(';')\n }\n return String(value)\n}\n\n/**\n * Whether `key` is a safe HTML attribute name. Attribute NAMES are interpolated\n * into markup unescaped, so a name containing `>`, whitespace, quotes, `=`, `/`,\n * etc. could break out of the tag and inject markup (stored XSS when props are\n * built from user/server data). We emit only names that match the HTML name\n * grammar — matching what the DOM's `setAttribute` would accept — and drop the\n * rest, exactly as an invalid name would never reach the DOM either.\n */\nfunction isValidAttrName(key: string): boolean {\n return /^[A-Za-z_:][\\w:.-]*$/.test(key)\n}\n\n/**\n * Serialize a headless node (and subtree) to HTML. A standalone function, not an\n * object method: the public {@link SerializableBackend.serialize} is typed as a\n * plain function member, so a consumer may legally detach it\n * (`const { serialize } = backend`). Recursing through this lexical helper rather\n * than `this.serialize` keeps it binding-independent.\n */\nfunction serializeHeadless(node: HeadlessNode, options?: SerializeOptions): string {\n if (node.type === TEXT) return escapeText(node.text)\n const mapTag = options?.mapTag ?? ((t: string) => t)\n const tag = mapTag(node.type)\n // The tag is interpolated into `<tag>`/`</tag>` unescaped, so a tag containing `>`,\n // whitespace, etc. would break out of the element and inject markup. Reject any tag\n // that isn't a valid name (same grammar as attribute names) — fail closed.\n if (!isValidAttrName(tag)) {\n throw new Error(`refusing to serialize unsafe element tag: ${JSON.stringify(tag)}`)\n }\n const attrs = Object.entries(node.props)\n .filter(([key]) => !isEventProp(key) && isValidAttrName(key))\n .map(([key, value]) =>\n // Boolean `true` → a valueless attribute (`disabled=\"\"`), matching the DOM\n // backend (dom.ts) so SSR markup equals hydrated markup.\n value === true ? ` ${key}=\"\"` : ` ${key}=\"${escapeAttr(serializeAttrValue(value))}\"`,\n )\n .join('')\n const inner = node.children.map((c) => serializeHeadless(c, options)).join('')\n return `<${tag}${attrs}>${inner}</${tag}>`\n}\n\n/** Create a {@link SerializableBackend} backed by an in-memory tree. */\nexport function createHeadlessBackend(): SerializableBackend<HeadlessNode> {\n return {\n createElement(type: string): HeadlessNode {\n return { type, props: {}, text: '', children: [], parent: null }\n },\n\n createText(value: string): HeadlessNode {\n return { type: TEXT, props: {}, text: value, children: [], parent: null }\n },\n\n setProp(node, key, value): void {\n // Event handlers and falsy values are tracked but not serialized as attrs.\n if (value === undefined || value === null || value === false) {\n delete node.props[key]\n } else {\n node.props[key] = value\n }\n },\n\n setText(node, value): void {\n node.text = value\n },\n\n insert(parent, node, anchor): void {\n if (node.parent) {\n const prevSiblings = node.parent.children\n const at = prevSiblings.indexOf(node)\n if (at >= 0) prevSiblings.splice(at, 1)\n }\n node.parent = parent\n if (anchor === null) {\n parent.children.push(node)\n } else {\n const idx = parent.children.indexOf(anchor)\n parent.children.splice(idx < 0 ? parent.children.length : idx, 0, node)\n }\n },\n\n remove(parent, node): void {\n const idx = parent.children.indexOf(node)\n if (idx >= 0) parent.children.splice(idx, 1)\n node.parent = null\n },\n\n parentOf(node): HeadlessNode | null {\n return node.parent\n },\n\n nextSibling(node): HeadlessNode | null {\n const parent = node.parent\n if (!parent) return null\n const idx = parent.children.indexOf(node)\n return idx >= 0 && idx + 1 < parent.children.length\n ? (parent.children[idx + 1] ?? null)\n : null\n },\n\n isText(node): boolean {\n return node.type === TEXT\n },\n\n serialize: serializeHeadless,\n }\n}\n\n/** Whether a prop key is an event handler (`onClick`, `onPress`, …). */\nexport function isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/** Convenience: create a detached headless root element (default tag `\"root\"`). */\nexport function createHeadlessRoot(type = 'root'): HeadlessNode {\n return { type, props: {}, text: '', children: [], parent: null }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"headless.js","names":[],"sources":["../src/headless.ts"],"sourcesContent":["/**\n * Headless host backend — an in-memory host tree, no browser required.\n *\n * This is the **reference backend**: it implements the full {@link HostBackend}\n * (plus {@link SerializableBackend}) so the entire reconciler can be exercised\n * in CI without a DOM. It's also handy for snapshot-testing rendered output.\n *\n * @module\n */\n\nimport type { SerializableBackend, SerializeOptions } from './backend'\nimport { serializeStyle } from './css'\n\n/** A headless host node: an element (with tag/props/children) or a text node. */\nexport interface HeadlessNode {\n /** `\"#text\"` for text nodes, otherwise the element tag. */\n type: string\n /** Applied props (elements only). */\n props: Record<string, unknown>\n /** Text content (text nodes only). */\n text: string\n /** Child nodes (elements only). */\n children: HeadlessNode[]\n /** Back-pointer to the parent, or `null` when detached. */\n parent: HeadlessNode | null\n}\n\nconst TEXT = '#text'\n\nfunction escapeAttr(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n\n/**\n * Render an attribute value: a `style` object becomes a CSS string via the SAME serializer\n * the DOM backend uses (kebab-case names + `px` units), so SSR markup matches the hydrated DOM.\n */\nfunction serializeAttrValue(value: unknown): string {\n if (value && typeof value === 'object') {\n return serializeStyle(value as Record<string, unknown>)\n }\n return String(value)\n}\n\n/**\n * Whether `key` is a safe HTML attribute name. Attribute NAMES are interpolated\n * into markup unescaped, so a name containing `>`, whitespace, quotes, `=`, `/`,\n * etc. could break out of the tag and inject markup (stored XSS when props are\n * built from user/server data). We emit only names that match the HTML name\n * grammar — matching what the DOM's `setAttribute` would accept — and drop the\n * rest, exactly as an invalid name would never reach the DOM either.\n */\nfunction isValidAttrName(key: string): boolean {\n return /^[A-Za-z_:][\\w:.-]*$/.test(key)\n}\n\n/**\n * Serialize a headless node (and subtree) to HTML. A standalone function, not an\n * object method: the public {@link SerializableBackend.serialize} is typed as a\n * plain function member, so a consumer may legally detach it\n * (`const { serialize } = backend`). Recursing through this lexical helper rather\n * than `this.serialize` keeps it binding-independent.\n */\nfunction serializeHeadless(node: HeadlessNode, options?: SerializeOptions): string {\n if (node.type === TEXT) return escapeText(node.text)\n const mapTag = options?.mapTag ?? ((t: string) => t)\n const tag = mapTag(node.type)\n // The tag is interpolated into `<tag>`/`</tag>` unescaped, so a tag containing `>`,\n // whitespace, etc. would break out of the element and inject markup. Reject any tag\n // that isn't a valid name (same grammar as attribute names) — fail closed.\n if (!isValidAttrName(tag)) {\n throw new Error(`refusing to serialize unsafe element tag: ${JSON.stringify(tag)}`)\n }\n const attrs = Object.entries(node.props)\n .filter(([key]) => !isEventProp(key) && isValidAttrName(key))\n .map(([key, value]) =>\n // Boolean `true` → a valueless attribute (`disabled=\"\"`), matching the DOM\n // backend (dom.ts) so SSR markup equals hydrated markup.\n value === true ? ` ${key}=\"\"` : ` ${key}=\"${escapeAttr(serializeAttrValue(value))}\"`,\n )\n .join('')\n const inner = node.children.map((c) => serializeHeadless(c, options)).join('')\n return `<${tag}${attrs}>${inner}</${tag}>`\n}\n\n/** Create a {@link SerializableBackend} backed by an in-memory tree. */\nexport function createHeadlessBackend(): SerializableBackend<HeadlessNode> {\n return {\n createElement(type: string): HeadlessNode {\n return { type, props: {}, text: '', children: [], parent: null }\n },\n\n createText(value: string): HeadlessNode {\n return { type: TEXT, props: {}, text: value, children: [], parent: null }\n },\n\n setProp(node, key, value): void {\n // Event handlers and falsy values are tracked but not serialized as attrs.\n if (value === undefined || value === null || value === false) {\n delete node.props[key]\n } else {\n node.props[key] = value\n }\n },\n\n setText(node, value): void {\n node.text = value\n },\n\n insert(parent, node, anchor): void {\n if (node.parent) {\n const prevSiblings = node.parent.children\n const at = prevSiblings.indexOf(node)\n if (at >= 0) prevSiblings.splice(at, 1)\n }\n node.parent = parent\n if (anchor === null) {\n parent.children.push(node)\n } else {\n const idx = parent.children.indexOf(anchor)\n parent.children.splice(idx < 0 ? parent.children.length : idx, 0, node)\n }\n },\n\n remove(parent, node): void {\n const idx = parent.children.indexOf(node)\n if (idx >= 0) parent.children.splice(idx, 1)\n node.parent = null\n },\n\n parentOf(node): HeadlessNode | null {\n return node.parent\n },\n\n nextSibling(node): HeadlessNode | null {\n const parent = node.parent\n if (!parent) return null\n const idx = parent.children.indexOf(node)\n return idx >= 0 && idx + 1 < parent.children.length\n ? (parent.children[idx + 1] ?? null)\n : null\n },\n\n isText(node): boolean {\n return node.type === TEXT\n },\n\n serialize: serializeHeadless,\n }\n}\n\n/** Whether a prop key is an event handler (`onClick`, `onPress`, …). */\nexport function isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/** Convenience: create a detached headless root element (default tag `\"root\"`). */\nexport function createHeadlessRoot(type = 'root'): HeadlessNode {\n return { type, props: {}, text: '', children: [], parent: null }\n}\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { HostBackend, SerializableBackend, isSerializable } from "./backend.js";
2
2
  import { DomDocument, DomElement, DomNode, DomText, createDomBackend, domTagFor } from "./dom.js";
3
+ import { bindKeyedChild } from "./for.js";
3
4
  import { HeadlessNode, createHeadlessBackend, createHeadlessRoot, isEventProp } from "./headless.js";
4
5
  import { CreateNodeCommand, CreateTextCommand, DisposeNodeCommand, InsertChildCommand, NativeCommand, NativeNodeId, NativePropValue, RegisterEventCommand, RemoveChildCommand, RemovePropCommand, SetPropCommand, UnregisterEventCommand, UpdateTextCommand, createNativeNodeIdFactory, isNativeCommand, isNativePropValue, normalizeNativeProp } from "./native-protocol.js";
5
6
  import { NativeCommandBackend, NativeCommandBackendOptions, NativeCommandNode, createNativeCommandBackend } from "./native-command-backend.js";
6
7
  import { CanvasBackend, NativeBackend, createCanvasBackend, createNativeBackend } from "./native.js";
7
8
  import { CreateNativeAppOptions, NativeApp, createNativeApp } from "./native-app.js";
8
9
  import { NativeHostError, ReferenceHost, ReferenceHostNode, createReferenceHost } from "./native-host.js";
9
- import { Mounted, render } from "./render.js";
10
+ import { Mounted, mountNode, render } from "./render.js";
10
11
  import { hydrate, renderToString } from "./ssr.js";
11
12
  import { Maturity, NotImplementedError, PackageInfo, notImplemented } from "@mindees/core";
12
13
 
@@ -14,7 +15,7 @@ import { Maturity, NotImplementedError, PackageInfo, notImplemented } from "@min
14
15
  /** The npm package name. */
15
16
  declare const name = "@mindees/renderer";
16
17
  /** The package version. All `@mindees/*` packages share one locked version line. */
17
- declare const VERSION = "0.2.0";
18
+ declare const VERSION = "0.3.0";
18
19
  /**
19
20
  * Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,
20
21
  * headless backend, SSR + hydration) is implemented and tested. Native
@@ -29,5 +30,5 @@ declare const maturity: Maturity;
29
30
  */
30
31
  declare const info: PackageInfo;
31
32
  //#endregion
32
- export { type CanvasBackend, type CreateNativeAppOptions, type CreateNodeCommand, type CreateTextCommand, type DisposeNodeCommand, type DomDocument, type DomElement, type DomNode, type DomText, type HeadlessNode, type HostBackend, type InsertChildCommand, type Maturity, type Mounted, type NativeApp, type NativeBackend, type NativeCommand, type NativeCommandBackend, type NativeCommandBackendOptions, type NativeCommandNode, NativeHostError, type NativeNodeId, type NativePropValue, NotImplementedError, type PackageInfo, type ReferenceHost, type ReferenceHostNode, type RegisterEventCommand, type RemoveChildCommand, type RemovePropCommand, type SerializableBackend, type SetPropCommand, type UnregisterEventCommand, type UpdateTextCommand, VERSION, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, name, normalizeNativeProp, notImplemented, render, renderToString };
33
+ export { type CanvasBackend, type CreateNativeAppOptions, type CreateNodeCommand, type CreateTextCommand, type DisposeNodeCommand, type DomDocument, type DomElement, type DomNode, type DomText, type HeadlessNode, type HostBackend, type InsertChildCommand, type Maturity, type Mounted, type NativeApp, type NativeBackend, type NativeCommand, type NativeCommandBackend, type NativeCommandBackendOptions, type NativeCommandNode, NativeHostError, type NativeNodeId, type NativePropValue, NotImplementedError, type PackageInfo, type ReferenceHost, type ReferenceHostNode, type RegisterEventCommand, type RemoveChildCommand, type RemovePropCommand, type SerializableBackend, type SetPropCommand, type UnregisterEventCommand, type UpdateTextCommand, VERSION, bindKeyedChild, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, mountNode, name, normalizeNativeProp, notImplemented, render, renderToString };
33
34
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;;;;;;;AA6FgD;AAAA,cAXnC,IAAA;;cAGA,OAAA;;AAeuE;;;;;cAPvE,QAAA,EAAU,QAAyB;;;;;;cAOnC,IAAA,EAAM,WAAiE"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;;;;;;;;AAsGA;AAAA,cAlBa,IAAA;;cAGA,OAAA;AAeuE;;;;;;AAAA,cAPvE,QAAA,EAAU,QAAyB;;;;;;cAOnC,IAAA,EAAM,WAAiE"}
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { isSerializable } from "./backend.js";
2
2
  import { createDomBackend, domTagFor } from "./dom.js";
3
+ import { mountNode, render } from "./render.js";
4
+ import { bindKeyedChild } from "./for.js";
3
5
  import { createHeadlessBackend, createHeadlessRoot, isEventProp } from "./headless.js";
4
6
  import { createNativeNodeIdFactory, isNativeCommand, isNativePropValue, normalizeNativeProp } from "./native-protocol.js";
5
7
  import { createNativeCommandBackend } from "./native-command-backend.js";
6
8
  import { createCanvasBackend, createNativeBackend } from "./native.js";
7
- import { render } from "./render.js";
8
9
  import { createNativeApp } from "./native-app.js";
9
10
  import { NativeHostError, createReferenceHost } from "./native-host.js";
10
11
  import { hydrate, renderToString } from "./ssr.js";
@@ -13,7 +14,7 @@ import { NotImplementedError, notImplemented } from "@mindees/core";
13
14
  /** The npm package name. */
14
15
  const name = "@mindees/renderer";
15
16
  /** The package version. All `@mindees/*` packages share one locked version line. */
16
- const VERSION = "0.2.0";
17
+ const VERSION = "0.3.0";
17
18
  /**
18
19
  * Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,
19
20
  * headless backend, SSR + hydration) is implemented and tested. Native
@@ -32,6 +33,6 @@ const info = Object.freeze({
32
33
  maturity
33
34
  });
34
35
  //#endregion
35
- export { NativeHostError, NotImplementedError, VERSION, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, name, normalizeNativeProp, notImplemented, render, renderToString };
36
+ export { NativeHostError, NotImplementedError, VERSION, bindKeyedChild, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, mountNode, name, normalizeNativeProp, notImplemented, render, renderToString };
36
37
 
37
38
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Maturity, PackageInfo } from '@mindees/core'\nimport { NotImplementedError, notImplemented } from '@mindees/core'\n\n/** Host-backend contract + capability detection. */\nexport {\n type HostBackend,\n isSerializable,\n type SerializableBackend,\n} from './backend'\n/** DOM (web) backend. */\nexport {\n createDomBackend,\n type DomDocument,\n type DomElement,\n type DomNode,\n type DomText,\n domTagFor,\n} from './dom'\n/** Headless (in-memory) backend — the reference/test target. */\nexport {\n createHeadlessBackend,\n createHeadlessRoot,\n type HeadlessNode,\n isEventProp,\n} from './headless'\n/**\n * Native backends. `createNativeCommandBackend` is implemented (emits a native\n * command stream); `createNativeBackend`/`createCanvasBackend` are research\n * tracks that throw `NotImplementedError`.\n */\nexport {\n type CanvasBackend,\n createCanvasBackend,\n createNativeBackend,\n createNativeCommandBackend,\n type NativeBackend,\n type NativeCommandBackend,\n type NativeCommandBackendOptions,\n type NativeCommandNode,\n} from './native'\n/** One-call native app entry — wires the command backend + host contract. */\nexport {\n type CreateNativeAppOptions,\n createNativeApp,\n type NativeApp,\n} from './native-app'\n/**\n * The strict reference native host — applies a command stream to a model tree and\n * validates it (the executable conformance contract real native hosts implement).\n */\nexport {\n createReferenceHost,\n NativeHostError,\n type ReferenceHost,\n type ReferenceHostNode,\n} from './native-host'\n/** The native command protocol: command types + serialization-safe helpers. */\nexport {\n type CreateNodeCommand,\n type CreateTextCommand,\n createNativeNodeIdFactory,\n type DisposeNodeCommand,\n type InsertChildCommand,\n isNativeCommand,\n isNativePropValue,\n type NativeCommand,\n type NativeNodeId,\n type NativePropValue,\n normalizeNativeProp,\n type RegisterEventCommand,\n type RemoveChildCommand,\n type RemovePropCommand,\n type SetPropCommand,\n type UnregisterEventCommand,\n type UpdateTextCommand,\n} from './native-protocol'\n/** The fine-grained reactive reconciler. */\nexport { type Mounted, render } from './render'\n/** Server-side rendering + hydration (web). */\nexport { hydrate, renderToString } from './ssr'\n\n/** The npm package name. */\nexport const name = '@mindees/renderer'\n\n/** The package version. All `@mindees/*` packages share one locked version line. */\nexport const VERSION = '0.2.0'\n\n/**\n * Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,\n * headless backend, SSR + hydration) is implemented and tested. Native\n * (iOS/Android) and the GPU canvas are research tracks (throw\n * `NotImplementedError`). See the repository `STATUS.md`.\n */\nexport const maturity: Maturity = 'experimental'\n\n/**\n * Static identity + maturity metadata for this package. Frozen so the\n * self-reported identity tooling introspects cannot be mutated at runtime,\n * matching the `readonly` fields of {@link PackageInfo}.\n */\nexport const info: PackageInfo = Object.freeze({ name, version: VERSION, maturity })\n\nexport type { Maturity, PackageInfo }\nexport { NotImplementedError, notImplemented }\n"],"mappings":";;;;;;;;;;;;;AAkFA,MAAa,OAAO;;AAGpB,MAAa,UAAU;;;;;;;AAQvB,MAAa,WAAqB;;;;;;AAOlC,MAAa,OAAoB,OAAO,OAAO;CAAE;CAAM,SAAS;CAAS;AAAS,CAAC"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type { Maturity, PackageInfo } from '@mindees/core'\nimport { NotImplementedError, notImplemented } from '@mindees/core'\n\n/** Host-backend contract + capability detection. */\nexport {\n type HostBackend,\n isSerializable,\n type SerializableBackend,\n} from './backend'\n/** DOM (web) backend. */\nexport {\n createDomBackend,\n type DomDocument,\n type DomElement,\n type DomNode,\n type DomText,\n domTagFor,\n} from './dom'\n/** Keyed list reconciliation (the renderer side of core's KeyedRegion). */\nexport { bindKeyedChild } from './for'\n/** Headless (in-memory) backend — the reference/test target. */\nexport {\n createHeadlessBackend,\n createHeadlessRoot,\n type HeadlessNode,\n isEventProp,\n} from './headless'\n/**\n * Native backends. `createNativeCommandBackend` is implemented (emits a native\n * command stream); `createNativeBackend`/`createCanvasBackend` are research\n * tracks that throw `NotImplementedError`.\n */\nexport {\n type CanvasBackend,\n createCanvasBackend,\n createNativeBackend,\n createNativeCommandBackend,\n type NativeBackend,\n type NativeCommandBackend,\n type NativeCommandBackendOptions,\n type NativeCommandNode,\n} from './native'\n/** One-call native app entry — wires the command backend + host contract. */\nexport {\n type CreateNativeAppOptions,\n createNativeApp,\n type NativeApp,\n} from './native-app'\n/**\n * The strict reference native host — applies a command stream to a model tree and\n * validates it (the executable conformance contract real native hosts implement).\n */\nexport {\n createReferenceHost,\n NativeHostError,\n type ReferenceHost,\n type ReferenceHostNode,\n} from './native-host'\n/** The native command protocol: command types + serialization-safe helpers. */\nexport {\n type CreateNodeCommand,\n type CreateTextCommand,\n createNativeNodeIdFactory,\n type DisposeNodeCommand,\n type InsertChildCommand,\n isNativeCommand,\n isNativePropValue,\n type NativeCommand,\n type NativeNodeId,\n type NativePropValue,\n normalizeNativeProp,\n type RegisterEventCommand,\n type RemoveChildCommand,\n type RemovePropCommand,\n type SetPropCommand,\n type UnregisterEventCommand,\n type UpdateTextCommand,\n} from './native-protocol'\n/** The fine-grained reactive reconciler. */\nexport { type Mounted, mountNode, render } from './render'\n/** Server-side rendering + hydration (web). */\nexport { hydrate, renderToString } from './ssr'\n\n/** The npm package name. */\nexport const name = '@mindees/renderer'\n\n/** The package version. All `@mindees/*` packages share one locked version line. */\nexport const VERSION = '0.3.0'\n\n/**\n * Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,\n * headless backend, SSR + hydration) is implemented and tested. Native\n * (iOS/Android) and the GPU canvas are research tracks (throw\n * `NotImplementedError`). See the repository `STATUS.md`.\n */\nexport const maturity: Maturity = 'experimental'\n\n/**\n * Static identity + maturity metadata for this package. Frozen so the\n * self-reported identity tooling introspects cannot be mutated at runtime,\n * matching the `readonly` fields of {@link PackageInfo}.\n */\nexport const info: PackageInfo = Object.freeze({ name, version: VERSION, maturity })\n\nexport type { Maturity, PackageInfo }\nexport { NotImplementedError, notImplemented }\n"],"mappings":";;;;;;;;;;;;;;AAoFA,MAAa,OAAO;;AAGpB,MAAa,UAAU;;;;;;;AAQvB,MAAa,WAAqB;;;;;;AAOlC,MAAa,OAAoB,OAAO,OAAO;CAAE;CAAM,SAAS;CAAS;AAAS,CAAC"}
@@ -1,5 +1,5 @@
1
- import { createNativeCommandBackend } from "./native-command-backend.js";
2
1
  import { render } from "./render.js";
2
+ import { createNativeCommandBackend } from "./native-command-backend.js";
3
3
  //#region src/native-app.ts
4
4
  function defaultEmit(json) {
5
5
  const host = globalThis.MindeesHost;
package/dist/render.d.ts CHANGED
@@ -23,6 +23,11 @@ interface Mounted<N> {
23
23
  */
24
24
  declare function render<N>(node: MindeesNode, backend: HostBackend<N>, container: N): Mounted<N>;
25
25
  declare function render<N, P>(component: Component<P>, props: P, backend: HostBackend<N>, container: N): Mounted<N>;
26
+ /**
27
+ * Mount a node into `parent` before `anchor`. Returns the top-level host nodes
28
+ * created (for fragments / arrays this can be more than one).
29
+ */
30
+ declare function mountNode<N>(node: MindeesNode, backend: HostBackend<N>, parent: N, anchor: N | null): N[];
26
31
  //#endregion
27
- export { Mounted, render };
32
+ export { Mounted, mountNode, render };
28
33
  //# sourceMappingURL=render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","names":[],"sources":["../src/render.ts"],"mappings":";;;;;UAgDiB,OAAA;EAmB2E;EAAA,SAjBjF,KAAA,EAAO,CAAC;EAiBI;EAfrB,OAAA;AAAA;;;;;;;;;AAe4F;AAC9F;;;iBADgB,MAAA,IAAU,IAAA,EAAM,WAAA,EAAa,OAAA,EAAS,WAAA,CAAY,CAAA,GAAI,SAAA,EAAW,CAAA,GAAI,OAAA,CAAQ,CAAA;AAAA,iBAC7E,MAAA,OACd,SAAA,EAAW,SAAA,CAAU,CAAA,GACrB,KAAA,EAAO,CAAA,EACP,OAAA,EAAS,WAAA,CAAY,CAAA,GACrB,SAAA,EAAW,CAAA,GACV,OAAA,CAAQ,CAAA"}
1
+ {"version":3,"file":"render.d.ts","names":[],"sources":["../src/render.ts"],"mappings":";;;;;UAkDiB,OAAA;EAmB2E;EAAA,SAjBjF,KAAA,EAAO,CAAC;EAiBI;EAfrB,OAAA;AAAA;;;;;;;;;AAe4F;AAC9F;;;iBADgB,MAAA,IAAU,IAAA,EAAM,WAAA,EAAa,OAAA,EAAS,WAAA,CAAY,CAAA,GAAI,SAAA,EAAW,CAAA,GAAI,OAAA,CAAQ,CAAA;AAAA,iBAC7E,MAAA,OACd,SAAA,EAAW,SAAA,CAAU,CAAA,GACrB,KAAA,EAAO,CAAA,EACP,OAAA,EAAS,WAAA,CAAY,CAAA,GACrB,SAAA,EAAW,CAAA,GACV,OAAA,CAAQ,CAAA;;;;;iBAqDK,SAAA,IACd,IAAA,EAAM,WAAA,EACN,OAAA,EAAS,WAAA,CAAY,CAAA,GACrB,MAAA,EAAQ,CAAA,EACR,MAAA,EAAQ,CAAA,UACP,CAAA"}
package/dist/render.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ELEMENT_TYPE, createRoot, effect, onCleanup, untrack } from "@mindees/core";
1
+ import { bindKeyedChild } from "./for.js";
2
+ import { ELEMENT_TYPE, createRoot, effect, isKeyedRegion, onCleanup, untrack } from "@mindees/core";
2
3
  //#region src/render.ts
3
4
  /**
4
5
  * Helix reconciler — turns a MindeesNative element tree into host nodes via a
@@ -55,6 +56,7 @@ function render(a, b, c, d) {
55
56
  */
56
57
  function mountNode(node, backend, parent, anchor) {
57
58
  if (node === null || node === void 0 || typeof node === "boolean") return [];
59
+ if (isKeyedRegion(node)) return bindKeyedChild(node, backend, parent, anchor);
58
60
  if (typeof node === "function") return bindReactiveChild(node, backend, parent, anchor);
59
61
  if (typeof node === "string" || typeof node === "number") {
60
62
  const text = backend.createText(String(node));
@@ -140,6 +142,6 @@ function bindReactiveChild(accessor, backend, parent, initialAnchor) {
140
142
  return nodes;
141
143
  }
142
144
  //#endregion
143
- export { render };
145
+ export { mountNode, render };
144
146
 
145
147
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","names":["component"],"sources":["../src/render.ts"],"sourcesContent":["/**\n * Helix reconciler — turns a MindeesNative element tree into host nodes via a\n * {@link HostBackend}, with **fine-grained reactive bindings**.\n *\n * There is no virtual-DOM diff. Instead:\n * - A dynamic prop (a function value) becomes an `effect` that patches exactly\n * that one attribute when its signals change.\n * - A dynamic child (a function returning nodes) becomes an `effect` that\n * replaces exactly that region of the host tree.\n * - Everything created during render is owned by a reactive scope, so unmounting\n * disposes every binding — no leaks.\n *\n * This is the Phase 1/2 reactivity paying off: updates are O(what-changed), not\n * O(tree).\n *\n * @module\n */\n\nimport {\n type Component,\n createRoot,\n ELEMENT_TYPE,\n effect,\n type MindeesElement,\n type MindeesNode,\n onCleanup,\n untrack,\n} from '@mindees/core'\nimport type { HostBackend } from './backend'\n\n/** A dynamic value: pass a function and the binding reacts to its signals. */\ntype MaybeReactive<T> = T | (() => T)\n\nfunction isElementLike(value: unknown): value is MindeesElement {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { $$typeof?: unknown }).$$typeof === ELEMENT_TYPE\n )\n}\n\nfunction isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/** A mounted subtree: its host nodes plus a disposer that unmounts + cleans up. */\nexport interface Mounted<N> {\n /** The top-level host nodes produced (a fragment can yield several). */\n readonly nodes: N[]\n /** Unmount: remove host nodes and dispose all reactive bindings. */\n dispose(): void\n}\n\n/**\n * Render `node` into `container` using `backend`. Returns a {@link Mounted}\n * handle whose `dispose()` removes the produced nodes and tears down every\n * reactive binding created during the render.\n *\n * @example\n * const backend = createHeadlessBackend()\n * const root = createHeadlessRoot()\n * const app = render(MyComponent, {}, backend, root) // component form\n * const m = render(element, backend, root) // element form\n * m.dispose()\n */\nexport function render<N>(node: MindeesNode, backend: HostBackend<N>, container: N): Mounted<N>\nexport function render<N, P>(\n component: Component<P>,\n props: P,\n backend: HostBackend<N>,\n container: N,\n): Mounted<N>\nexport function render<N, P>(\n a: MindeesNode | Component<P>,\n b: HostBackend<N> | P,\n c?: HostBackend<N> | N,\n d?: N,\n): Mounted<N> {\n // Disambiguate by ARITY, not `typeof a`: `MindeesNode` now includes the\n // accessor form `() => MindeesNode`, so a function `a` may be either a\n // component (4-arg form) or a reactive node (3-arg form). The component form\n // is the only one with a 4th argument (`container = d`).\n const isComponentForm = d !== undefined\n const backend = (isComponentForm ? c : b) as HostBackend<N>\n const container = (isComponentForm ? d : c) as N\n\n let nodes: N[] = []\n // Capture the disposer eagerly — createRoot passes it to the callback\n // synchronously, BEFORE the body runs. If the component or mountNode throws\n // part-way, effects/regions created before the throw are already adopted on\n // this root; createRoot does NOT auto-dispose on a throw, so without this they\n // would leak (stay subscribed forever) and the caller would get no disposer.\n // Dispose the partial scope, then rethrow — restoring the \"no leaks\" guarantee.\n let dispose!: () => void\n try {\n createRoot((d) => {\n dispose = d\n // Evaluate the component INSIDE the root scope so its effects/memos are\n // owned here and disposed with us. A non-component node is mounted as-is\n // (an accessor node becomes a reactive region during mount).\n const node: MindeesNode = isComponentForm ? (a as Component<P>)(b as P) : (a as MindeesNode)\n nodes = mountNode(node, backend, container, null)\n })\n } catch (err) {\n dispose?.()\n throw err\n }\n\n return {\n nodes,\n dispose() {\n for (const n of nodes) {\n const parent = backend.parentOf(n)\n if (parent) backend.remove(parent, n)\n }\n dispose()\n },\n }\n}\n\n/**\n * Mount a node into `parent` before `anchor`. Returns the top-level host nodes\n * created (for fragments / arrays this can be more than one).\n */\nfunction mountNode<N>(\n node: MindeesNode,\n backend: HostBackend<N>,\n parent: N,\n anchor: N | null,\n): N[] {\n // Null-ish / boolean → nothing.\n if (node === null || node === undefined || typeof node === 'boolean') return []\n\n // Function node → a reactive region (an accessor `() => MindeesNode`). Handled\n // uniformly here so it works at the top level and as a child.\n if (typeof node === 'function') {\n return bindReactiveChild(node as () => MindeesNode, backend, parent, anchor)\n }\n\n // Text-like primitives.\n if (typeof node === 'string' || typeof node === 'number') {\n const text = backend.createText(String(node))\n backend.insert(parent, text, anchor)\n return [text]\n }\n\n // Arrays / fragments → mount each child in order.\n if (Array.isArray(node)) {\n const out: N[] = []\n for (const child of node) out.push(...mountNode(child, backend, parent, anchor))\n return out\n }\n\n if (isElementLike(node)) {\n const { type } = node\n // Function component: invoked directly. We are already inside render()'s\n // createRoot owner scope, so any effects/memos the component creates are\n // owned here and disposed on unmount. `children` is passed through props.\n if (typeof type === 'function') {\n const component = type as Component<Record<string, unknown>>\n const rendered = component({ ...node.props, children: node.children })\n return mountNode(rendered, backend, parent, anchor)\n }\n\n // Host element.\n const el = backend.createElement(type)\n for (const [key, value] of Object.entries(node.props)) {\n bindProp(backend, el, key, value)\n }\n mountChildren(node.children, backend, el)\n backend.insert(parent, el, anchor)\n return [el]\n }\n\n return []\n}\n\n/** Mount a list of children into `parent`, appending in order. */\nfunction mountChildren<N>(\n children: readonly MindeesNode[],\n backend: HostBackend<N>,\n parent: N,\n): void {\n // mountNode handles every node kind uniformly, including function children\n // (reactive regions) via the function-node branch.\n for (const child of children) {\n mountNode(child, backend, parent, null)\n }\n}\n\n/**\n * Apply a prop. A function value is a **reactive binding**: an effect re-applies\n * exactly this attribute when its dependencies change. Event props (`onX`) are\n * applied once (the handler itself can close over signals).\n */\nfunction bindProp<N>(backend: HostBackend<N>, el: N, key: string, value: unknown): void {\n if (key === 'children') return\n if (isEventProp(key)) {\n backend.setProp(el, key, value, undefined)\n // Symmetric teardown: remove the listener when this scope is disposed (unmount\n // or an enclosing region re-run), so a backend's addEventListener always has a\n // matching removal — not left to GC. Passing `undefined` drives the backend's\n // own listener-removal path (e.g. the DOM backend's removeEventListener).\n onCleanup(() => backend.setProp(el, key, undefined, value))\n return\n }\n if (typeof value === 'function') {\n let prev: unknown\n effect(() => {\n const next = (value as () => unknown)()\n backend.setProp(el, key, next, prev)\n prev = next\n })\n return\n }\n backend.setProp(el, key, value, undefined)\n}\n\n/**\n * Bind a reactive child region: an effect that, when the accessor changes,\n * unmounts the previous nodes and mounts the new ones at the same position. A\n * stable text-only fast path patches the text node in place.\n *\n * The effect runs synchronously on creation, so `current` is populated before\n * we return it — letting the caller report the region's initial host nodes.\n */\nfunction bindReactiveChild<N>(\n accessor: () => MindeesNode,\n backend: HostBackend<N>,\n parent: N,\n initialAnchor: N | null,\n): N[] {\n // Pin the region's slot with a persistent, invisible empty-text marker, and\n // always (re)mount content immediately BEFORE it. This keeps the region's\n // exact position across empty↔content transitions (an empty region previously\n // collapsed — its content reappeared at the parent's end, breaking the\n // `() => cond() ? <X/> : null` pattern when the region had following siblings)\n // and keeps adjacent regions in order. The marker serializes to '' so it is\n // invisible in output.\n const marker = backend.createText('')\n backend.insert(parent, marker, initialAnchor)\n\n // `nodes` is a STABLE, live array — the region's current content followed by\n // the slot marker — mutated in place on every run. Returning the same array\n // reference (not a one-time snapshot) means a caller that captures it once\n // (e.g. render()'s root disposer) always removes the CURRENT content, not the\n // first-run nodes.\n const nodes: N[] = [marker]\n let content: N[] = []\n\n // Authoritative unmount for the region. Reading the LIVE `content`/`marker` at\n // teardown means it removes whatever is mounted NOW, regardless of how the\n // region was composed. This is required for correctness: when the region is a\n // child of a top-level array/fragment, render()'s disposer only captured a\n // flattened ONE-TIME snapshot of the host nodes (the array branch in mountNode\n // spreads `nodes` into a fresh array), so after a content swap it can no longer\n // remove the current content — that node would leak. This owner-scoped cleanup\n // closes that gap. It is owned by whoever mounted the region: render()'s root\n // (fires once, on final dispose) or an enclosing region effect (fires on each\n // re-run, tearing the nested region down). Guarded by parentOf so it is a safe\n // no-op for nodes already detached by render()'s disposer or a swap.\n onCleanup(() => {\n for (const n of content) {\n if (backend.parentOf(n)) backend.remove(parent, n)\n }\n if (backend.parentOf(marker)) backend.remove(parent, marker)\n })\n\n effect(() => {\n const value = accessor()\n untrack(() => {\n // Fast path: single existing text node + new text-like value → patch.\n if (\n content.length === 1 &&\n content[0] !== undefined &&\n backend.isText(content[0]) &&\n (typeof value === 'string' || typeof value === 'number')\n ) {\n backend.setText(content[0], String(value))\n return\n }\n // Guarded: when this region is nested in another region, the parent's\n // re-run fires this region's onCleanup first (detaching `content`), so a\n // second removal here would hit an already-detached node — the DOM\n // backend's removeChild throws on a non-child.\n for (const n of content) {\n if (backend.parentOf(n)) backend.remove(parent, n)\n }\n content = mountNode(value, backend, parent, marker)\n nodes.length = 0\n nodes.push(...content, marker)\n })\n })\n return nodes\n}\n\nexport type { MaybeReactive }\n"],"mappings":"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"}
1
+ {"version":3,"file":"render.js","names":["component"],"sources":["../src/render.ts"],"sourcesContent":["/**\n * Helix reconciler — turns a MindeesNative element tree into host nodes via a\n * {@link HostBackend}, with **fine-grained reactive bindings**.\n *\n * There is no virtual-DOM diff. Instead:\n * - A dynamic prop (a function value) becomes an `effect` that patches exactly\n * that one attribute when its signals change.\n * - A dynamic child (a function returning nodes) becomes an `effect` that\n * replaces exactly that region of the host tree.\n * - Everything created during render is owned by a reactive scope, so unmounting\n * disposes every binding — no leaks.\n *\n * This is the Phase 1/2 reactivity paying off: updates are O(what-changed), not\n * O(tree).\n *\n * @module\n */\n\nimport {\n type Component,\n createRoot,\n ELEMENT_TYPE,\n effect,\n isKeyedRegion,\n type MindeesElement,\n type MindeesNode,\n onCleanup,\n untrack,\n} from '@mindees/core'\nimport type { HostBackend } from './backend'\nimport { bindKeyedChild } from './for'\n\n/** A dynamic value: pass a function and the binding reacts to its signals. */\ntype MaybeReactive<T> = T | (() => T)\n\nfunction isElementLike(value: unknown): value is MindeesElement {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { $$typeof?: unknown }).$$typeof === ELEMENT_TYPE\n )\n}\n\nfunction isEventProp(key: string): boolean {\n return (\n key.length > 2 && key[0] === 'o' && key[1] === 'n' && key[2] === (key[2] ?? '').toUpperCase()\n )\n}\n\n/** A mounted subtree: its host nodes plus a disposer that unmounts + cleans up. */\nexport interface Mounted<N> {\n /** The top-level host nodes produced (a fragment can yield several). */\n readonly nodes: N[]\n /** Unmount: remove host nodes and dispose all reactive bindings. */\n dispose(): void\n}\n\n/**\n * Render `node` into `container` using `backend`. Returns a {@link Mounted}\n * handle whose `dispose()` removes the produced nodes and tears down every\n * reactive binding created during the render.\n *\n * @example\n * const backend = createHeadlessBackend()\n * const root = createHeadlessRoot()\n * const app = render(MyComponent, {}, backend, root) // component form\n * const m = render(element, backend, root) // element form\n * m.dispose()\n */\nexport function render<N>(node: MindeesNode, backend: HostBackend<N>, container: N): Mounted<N>\nexport function render<N, P>(\n component: Component<P>,\n props: P,\n backend: HostBackend<N>,\n container: N,\n): Mounted<N>\nexport function render<N, P>(\n a: MindeesNode | Component<P>,\n b: HostBackend<N> | P,\n c?: HostBackend<N> | N,\n d?: N,\n): Mounted<N> {\n // Disambiguate by ARITY, not `typeof a`: `MindeesNode` now includes the\n // accessor form `() => MindeesNode`, so a function `a` may be either a\n // component (4-arg form) or a reactive node (3-arg form). The component form\n // is the only one with a 4th argument (`container = d`).\n const isComponentForm = d !== undefined\n const backend = (isComponentForm ? c : b) as HostBackend<N>\n const container = (isComponentForm ? d : c) as N\n\n let nodes: N[] = []\n // Capture the disposer eagerly — createRoot passes it to the callback\n // synchronously, BEFORE the body runs. If the component or mountNode throws\n // part-way, effects/regions created before the throw are already adopted on\n // this root; createRoot does NOT auto-dispose on a throw, so without this they\n // would leak (stay subscribed forever) and the caller would get no disposer.\n // Dispose the partial scope, then rethrow — restoring the \"no leaks\" guarantee.\n let dispose!: () => void\n try {\n createRoot((d) => {\n dispose = d\n // Evaluate the component INSIDE the root scope so its effects/memos are\n // owned here and disposed with us. A non-component node is mounted as-is\n // (an accessor node becomes a reactive region during mount).\n const node: MindeesNode = isComponentForm ? (a as Component<P>)(b as P) : (a as MindeesNode)\n nodes = mountNode(node, backend, container, null)\n })\n } catch (err) {\n dispose?.()\n throw err\n }\n\n return {\n nodes,\n dispose() {\n for (const n of nodes) {\n const parent = backend.parentOf(n)\n if (parent) backend.remove(parent, n)\n }\n dispose()\n },\n }\n}\n\n/**\n * Mount a node into `parent` before `anchor`. Returns the top-level host nodes\n * created (for fragments / arrays this can be more than one).\n */\nexport function mountNode<N>(\n node: MindeesNode,\n backend: HostBackend<N>,\n parent: N,\n anchor: N | null,\n): N[] {\n // Null-ish / boolean → nothing.\n if (node === null || node === undefined || typeof node === 'boolean') return []\n\n // Keyed list region → keyed reconciliation (identity-preserving). Checked before the\n // function branch so a `For` is never routed to the full-rebuild reactive-child path.\n if (isKeyedRegion(node)) {\n return bindKeyedChild(node, backend, parent, anchor)\n }\n\n // Function node → a reactive region (an accessor `() => MindeesNode`). Handled\n // uniformly here so it works at the top level and as a child.\n if (typeof node === 'function') {\n return bindReactiveChild(node as () => MindeesNode, backend, parent, anchor)\n }\n\n // Text-like primitives.\n if (typeof node === 'string' || typeof node === 'number') {\n const text = backend.createText(String(node))\n backend.insert(parent, text, anchor)\n return [text]\n }\n\n // Arrays / fragments → mount each child in order.\n if (Array.isArray(node)) {\n const out: N[] = []\n for (const child of node) out.push(...mountNode(child, backend, parent, anchor))\n return out\n }\n\n if (isElementLike(node)) {\n const { type } = node\n // Function component: invoked directly. We are already inside render()'s\n // createRoot owner scope, so any effects/memos the component creates are\n // owned here and disposed on unmount. `children` is passed through props.\n if (typeof type === 'function') {\n const component = type as Component<Record<string, unknown>>\n const rendered = component({ ...node.props, children: node.children })\n return mountNode(rendered, backend, parent, anchor)\n }\n\n // Host element.\n const el = backend.createElement(type)\n for (const [key, value] of Object.entries(node.props)) {\n bindProp(backend, el, key, value)\n }\n mountChildren(node.children, backend, el)\n backend.insert(parent, el, anchor)\n return [el]\n }\n\n return []\n}\n\n/** Mount a list of children into `parent`, appending in order. */\nfunction mountChildren<N>(\n children: readonly MindeesNode[],\n backend: HostBackend<N>,\n parent: N,\n): void {\n // mountNode handles every node kind uniformly, including function children\n // (reactive regions) via the function-node branch.\n for (const child of children) {\n mountNode(child, backend, parent, null)\n }\n}\n\n/**\n * Apply a prop. A function value is a **reactive binding**: an effect re-applies\n * exactly this attribute when its dependencies change. Event props (`onX`) are\n * applied once (the handler itself can close over signals).\n */\nfunction bindProp<N>(backend: HostBackend<N>, el: N, key: string, value: unknown): void {\n if (key === 'children') return\n if (isEventProp(key)) {\n backend.setProp(el, key, value, undefined)\n // Symmetric teardown: remove the listener when this scope is disposed (unmount\n // or an enclosing region re-run), so a backend's addEventListener always has a\n // matching removal — not left to GC. Passing `undefined` drives the backend's\n // own listener-removal path (e.g. the DOM backend's removeEventListener).\n onCleanup(() => backend.setProp(el, key, undefined, value))\n return\n }\n if (typeof value === 'function') {\n let prev: unknown\n effect(() => {\n const next = (value as () => unknown)()\n backend.setProp(el, key, next, prev)\n prev = next\n })\n return\n }\n backend.setProp(el, key, value, undefined)\n}\n\n/**\n * Bind a reactive child region: an effect that, when the accessor changes,\n * unmounts the previous nodes and mounts the new ones at the same position. A\n * stable text-only fast path patches the text node in place.\n *\n * The effect runs synchronously on creation, so `current` is populated before\n * we return it — letting the caller report the region's initial host nodes.\n */\nfunction bindReactiveChild<N>(\n accessor: () => MindeesNode,\n backend: HostBackend<N>,\n parent: N,\n initialAnchor: N | null,\n): N[] {\n // Pin the region's slot with a persistent, invisible empty-text marker, and\n // always (re)mount content immediately BEFORE it. This keeps the region's\n // exact position across empty↔content transitions (an empty region previously\n // collapsed — its content reappeared at the parent's end, breaking the\n // `() => cond() ? <X/> : null` pattern when the region had following siblings)\n // and keeps adjacent regions in order. The marker serializes to '' so it is\n // invisible in output.\n const marker = backend.createText('')\n backend.insert(parent, marker, initialAnchor)\n\n // `nodes` is a STABLE, live array — the region's current content followed by\n // the slot marker — mutated in place on every run. Returning the same array\n // reference (not a one-time snapshot) means a caller that captures it once\n // (e.g. render()'s root disposer) always removes the CURRENT content, not the\n // first-run nodes.\n const nodes: N[] = [marker]\n let content: N[] = []\n\n // Authoritative unmount for the region. Reading the LIVE `content`/`marker` at\n // teardown means it removes whatever is mounted NOW, regardless of how the\n // region was composed. This is required for correctness: when the region is a\n // child of a top-level array/fragment, render()'s disposer only captured a\n // flattened ONE-TIME snapshot of the host nodes (the array branch in mountNode\n // spreads `nodes` into a fresh array), so after a content swap it can no longer\n // remove the current content — that node would leak. This owner-scoped cleanup\n // closes that gap. It is owned by whoever mounted the region: render()'s root\n // (fires once, on final dispose) or an enclosing region effect (fires on each\n // re-run, tearing the nested region down). Guarded by parentOf so it is a safe\n // no-op for nodes already detached by render()'s disposer or a swap.\n onCleanup(() => {\n for (const n of content) {\n if (backend.parentOf(n)) backend.remove(parent, n)\n }\n if (backend.parentOf(marker)) backend.remove(parent, marker)\n })\n\n effect(() => {\n const value = accessor()\n untrack(() => {\n // Fast path: single existing text node + new text-like value → patch.\n if (\n content.length === 1 &&\n content[0] !== undefined &&\n backend.isText(content[0]) &&\n (typeof value === 'string' || typeof value === 'number')\n ) {\n backend.setText(content[0], String(value))\n return\n }\n // Guarded: when this region is nested in another region, the parent's\n // re-run fires this region's onCleanup first (detaching `content`), so a\n // second removal here would hit an already-detached node — the DOM\n // backend's removeChild throws on a non-child.\n for (const n of content) {\n if (backend.parentOf(n)) backend.remove(parent, n)\n }\n content = mountNode(value, backend, parent, marker)\n nodes.length = 0\n nodes.push(...content, marker)\n })\n })\n return nodes\n}\n\nexport type { MaybeReactive }\n"],"mappings":"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"}
package/dist/ssr.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createDomBackend, domTagFor } from "./dom.js";
2
- import { createHeadlessBackend, createHeadlessRoot } from "./headless.js";
3
2
  import { render } from "./render.js";
3
+ import { createHeadlessBackend, createHeadlessRoot } from "./headless.js";
4
4
  //#region src/ssr.ts
5
5
  function renderToString(...args) {
6
6
  const backend = createHeadlessBackend();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindees/renderer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MindeesNative Helix — fine-grained reactive renderer with a web/DOM backend, SSR + hydration, and a headless test backend. Native and GPU-canvas backends are research tracks.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "directory": "packages/renderer"
24
24
  },
25
25
  "dependencies": {
26
- "@mindees/core": "0.2.0"
26
+ "@mindees/core": "0.3.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "happy-dom": "20.9.0"