@mindees/renderer 0.2.0 → 0.4.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/backend.d.ts +7 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/css.js +58 -0
- package/dist/css.js.map +1 -0
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +47 -25
- package/dist/dom.js.map +1 -1
- package/dist/for.d.ts +14 -0
- package/dist/for.d.ts.map +1 -0
- package/dist/for.js +187 -0
- package/dist/for.js.map +1 -0
- package/dist/headless.d.ts +10 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +13 -4
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/native-app.js +1 -1
- package/dist/native-command-backend.js +50 -0
- package/dist/native-command-backend.js.map +1 -1
- package/dist/portal.d.ts +14 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +53 -0
- package/dist/portal.js.map +1 -0
- package/dist/render.d.ts +6 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +12 -3
- package/dist/render.js.map +1 -1
- package/dist/ssr.js +1 -1
- package/package.json +2 -2
package/dist/backend.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ interface HostBackend<N> {
|
|
|
44
44
|
nextSibling(node: N): N | null;
|
|
45
45
|
/** True if `node` is a text host node (vs an element). */
|
|
46
46
|
isText(node: N): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Optional capability: the host node that portaled children mount into — an overlay/root layer
|
|
49
|
+
* above normal flow. The renderer's portal binding is the ONLY caller. Omit (or return `null`)
|
|
50
|
+
* and portals fall back to their local parent (in-place mount) — the correct, deterministic
|
|
51
|
+
* SSR / no-layer behavior.
|
|
52
|
+
*/
|
|
53
|
+
overlayRoot?(): N | null;
|
|
47
54
|
}
|
|
48
55
|
/** Options controlling {@link SerializableBackend.serialize}. */
|
|
49
56
|
interface SerializeOptions {
|
package/dist/backend.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backend.d.ts","names":[],"sources":["../src/backend.ts"],"mappings":";;AAoBA;;;;;;;;;;;;;;;;;;UAAiB,WAAA;
|
|
1
|
+
{"version":3,"file":"backend.d.ts","names":[],"sources":["../src/backend.ts"],"mappings":";;AAoBA;;;;;;;;;;;;;;;;;;UAAiB,WAAA;EAgCE;EA9BjB,aAAA,CAAc,IAAA,WAAe,CAAA;EAA7B;EAEA,UAAA,CAAW,KAAA,WAAgB,CAAA;EAFE;;;;;EAQ7B,OAAA,CAAQ,IAAA,EAAM,CAAA,EAAG,GAAA,UAAa,KAAA,WAAgB,IAAA;EAAtC;EAER,OAAA,CAAQ,IAAA,EAAM,CAAA,EAAG,KAAA;EAFa;;;;EAO9B,MAAA,CAAO,MAAA,EAAQ,CAAA,EAAG,IAAA,EAAM,CAAA,EAAG,MAAA,EAAQ,CAAA;EALlB;EAOjB,MAAA,CAAO,MAAA,EAAQ,CAAA,EAAG,IAAA,EAAM,CAAA;EAFT;EAIf,QAAA,CAAS,IAAA,EAAM,CAAA,GAAI,CAAA;EAJK;EAMxB,WAAA,CAAY,IAAA,EAAM,CAAA,GAAI,CAAA;EANa;EAQnC,MAAA,CAAO,IAAA,EAAM,CAAA;EANb;;;;;;EAaA,WAAA,KAAgB,CAAA;AAAA;;UAID,gBAAA;EAbG;;;;EAkBlB,MAAA,IAAU,IAAY;AAAA;;;;AATL;UAgBF,mBAAA,YAA+B,WAAA,CAAY,CAAA;EAZ3B;EAc/B,SAAA,CAAU,IAAA,EAAM,CAAA,EAAG,OAAA,GAAU,gBAAA;AAAA;AATP;AAAA,iBAaR,cAAA,IAAkB,OAAA,EAAS,WAAA,CAAY,CAAA,IAAK,OAAA,IAAW,mBAAA,CAAoB,CAAA"}
|
package/dist/backend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backend.js","names":[],"sources":["../src/backend.ts"],"sourcesContent":["/**\n * Helix host-backend contract.\n *\n * A {@link HostBackend} is the seam between the renderer and a concrete target\n * (DOM today; native iOS/Android and a GPU canvas are research tracks). The\n * reconciler ({@link import('./render').render}) speaks only this interface, so\n * a new platform is \"implement `HostBackend<N>`\" — nothing in the reconciler\n * changes.\n *\n * `N` is the opaque host-node type (a real DOM `Node`, a headless record, a\n * native view handle, …). The backend never interprets MindeesNative elements;\n * it just creates, mutates, and arranges host nodes on command.\n *\n * @module\n */\n\n/**\n * A platform target. Implementations create/mutate/arrange host nodes of type\n * `N`. All methods are synchronous and side-effecting on the host tree.\n */\nexport interface HostBackend<N> {\n /** Create an element host node for `type` (e.g. `\"view\"`, `\"text\"`). */\n createElement(type: string): N\n /** Create a text host node holding `value`. */\n createText(value: string): N\n /**\n * Apply a single prop/attribute. `prev` is the previously-applied value (or\n * `undefined` on first apply) so the backend can diff/cleanup if needed\n * (e.g. removing an old event listener). Event props are `onX` (capitalized).\n */\n setProp(node: N, key: string, value: unknown, prev: unknown): void\n /** Update a text host node's value. */\n setText(node: N, value: string): void\n /**\n * Insert `node` into `parent` immediately before `anchor`, or append when\n * `anchor` is `null`.\n */\n insert(parent: N, node: N, anchor: N | null): void\n /** Remove `node` from `parent`. */\n remove(parent: N, node: N): void\n /** The parent of `node`, or `null` if it has none (detached / root). */\n parentOf(node: N): N | null\n /** The next sibling of `node`, or `null`. Used to compute insertion anchors. */\n nextSibling(node: N): N | null\n /** True if `node` is a text host node (vs an element). */\n isText(node: N): boolean\n}\n\n/** Options controlling {@link SerializableBackend.serialize}. */\nexport interface SerializeOptions {\n /**\n * Map a MindeesNative element tag to the tag actually emitted (e.g. the web\n * target maps `view`→`div`, `text`→`span`). Defaults to identity.\n */\n mapTag?: (type: string) => string\n}\n\n/**\n * Optional capability: serialize a host subtree to an HTML string. Backends\n * that support server-side rendering implement this; the headless backend does.\n */\nexport interface SerializableBackend<N> extends HostBackend<N> {\n /** Serialize `node` (and its subtree) to an HTML string. */\n serialize(node: N, options?: SerializeOptions): string\n}\n\n/** Type guard: does `backend` support {@link SerializableBackend.serialize}? */\nexport function isSerializable<N>(backend: HostBackend<N>): backend is SerializableBackend<N> {\n return typeof (backend as Partial<SerializableBackend<N>>).serialize === 'function'\n}\n"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"backend.js","names":[],"sources":["../src/backend.ts"],"sourcesContent":["/**\n * Helix host-backend contract.\n *\n * A {@link HostBackend} is the seam between the renderer and a concrete target\n * (DOM today; native iOS/Android and a GPU canvas are research tracks). The\n * reconciler ({@link import('./render').render}) speaks only this interface, so\n * a new platform is \"implement `HostBackend<N>`\" — nothing in the reconciler\n * changes.\n *\n * `N` is the opaque host-node type (a real DOM `Node`, a headless record, a\n * native view handle, …). The backend never interprets MindeesNative elements;\n * it just creates, mutates, and arranges host nodes on command.\n *\n * @module\n */\n\n/**\n * A platform target. Implementations create/mutate/arrange host nodes of type\n * `N`. All methods are synchronous and side-effecting on the host tree.\n */\nexport interface HostBackend<N> {\n /** Create an element host node for `type` (e.g. `\"view\"`, `\"text\"`). */\n createElement(type: string): N\n /** Create a text host node holding `value`. */\n createText(value: string): N\n /**\n * Apply a single prop/attribute. `prev` is the previously-applied value (or\n * `undefined` on first apply) so the backend can diff/cleanup if needed\n * (e.g. removing an old event listener). Event props are `onX` (capitalized).\n */\n setProp(node: N, key: string, value: unknown, prev: unknown): void\n /** Update a text host node's value. */\n setText(node: N, value: string): void\n /**\n * Insert `node` into `parent` immediately before `anchor`, or append when\n * `anchor` is `null`.\n */\n insert(parent: N, node: N, anchor: N | null): void\n /** Remove `node` from `parent`. */\n remove(parent: N, node: N): void\n /** The parent of `node`, or `null` if it has none (detached / root). */\n parentOf(node: N): N | null\n /** The next sibling of `node`, or `null`. Used to compute insertion anchors. */\n nextSibling(node: N): N | null\n /** True if `node` is a text host node (vs an element). */\n isText(node: N): boolean\n /**\n * Optional capability: the host node that portaled children mount into — an overlay/root layer\n * above normal flow. The renderer's portal binding is the ONLY caller. Omit (or return `null`)\n * and portals fall back to their local parent (in-place mount) — the correct, deterministic\n * SSR / no-layer behavior.\n */\n overlayRoot?(): N | null\n}\n\n/** Options controlling {@link SerializableBackend.serialize}. */\nexport interface SerializeOptions {\n /**\n * Map a MindeesNative element tag to the tag actually emitted (e.g. the web\n * target maps `view`→`div`, `text`→`span`). Defaults to identity.\n */\n mapTag?: (type: string) => string\n}\n\n/**\n * Optional capability: serialize a host subtree to an HTML string. Backends\n * that support server-side rendering implement this; the headless backend does.\n */\nexport interface SerializableBackend<N> extends HostBackend<N> {\n /** Serialize `node` (and its subtree) to an HTML string. */\n serialize(node: N, options?: SerializeOptions): string\n}\n\n/** Type guard: does `backend` support {@link SerializableBackend.serialize}? */\nexport function isSerializable<N>(backend: HostBackend<N>): backend is SerializableBackend<N> {\n return typeof (backend as Partial<SerializableBackend<N>>).serialize === 'function'\n}\n"],"mappings":";;AA0EA,SAAgB,eAAkB,SAA4D;CAC5F,OAAO,OAAQ,QAA4C,cAAc;AAC3E"}
|
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
|
package/dist/css.js.map
ADDED
|
@@ -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":";;;;
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
@@ -67,8 +77,9 @@ function createDomBackend(doc) {
|
|
|
67
77
|
const documentRef = doc ?? globalThis.document;
|
|
68
78
|
if (!documentRef) throw new Error("createDomBackend: no document available (pass one explicitly outside a browser)");
|
|
69
79
|
const document = documentRef;
|
|
80
|
+
let overlay = null;
|
|
70
81
|
return {
|
|
71
|
-
createElement: (type) => document.createElement(domTagFor(type)),
|
|
82
|
+
createElement: (type) => type === "activityindicator" ? createSpinner(document) : document.createElement(domTagFor(type)),
|
|
72
83
|
createText: (value) => document.createTextNode(value),
|
|
73
84
|
setProp(node, key, value, prev) {
|
|
74
85
|
const el = node;
|
|
@@ -116,7 +127,18 @@ function createDomBackend(doc) {
|
|
|
116
127
|
},
|
|
117
128
|
parentOf: (node) => node.parentNode,
|
|
118
129
|
nextSibling: (node) => node.nextSibling,
|
|
119
|
-
isText: (node) => node.nodeType === TEXT_NODE
|
|
130
|
+
isText: (node) => node.nodeType === TEXT_NODE,
|
|
131
|
+
overlayRoot() {
|
|
132
|
+
if (overlay) return overlay;
|
|
133
|
+
const host = document;
|
|
134
|
+
const mount = host.body ?? host.documentElement;
|
|
135
|
+
if (!mount) return null;
|
|
136
|
+
const layer = document.createElement("div");
|
|
137
|
+
layer.setAttribute("data-mindees-overlay", "");
|
|
138
|
+
mount.appendChild(layer);
|
|
139
|
+
overlay = layer;
|
|
140
|
+
return overlay;
|
|
141
|
+
}
|
|
120
142
|
};
|
|
121
143
|
}
|
|
122
144
|
//#endregion
|
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 let overlay: DomElement | null = null // lazily-created portal overlay layer (see overlayRoot)\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 overlayRoot(): DomNode | null {\n if (overlay) return overlay\n // Lazily create ONE overlay layer on <body> (fallback <html>) — zero-config portals.\n const host = document as unknown as {\n body?: { appendChild(n: DomNode): void }\n documentElement?: { appendChild(n: DomNode): void }\n }\n const mount = host.body ?? host.documentElement\n if (!mount) return null // no DOM host (e.g. a bare document) → portal falls back in place\n const layer = document.createElement('div')\n layer.setAttribute('data-mindees-overlay', '')\n mount.appendChild(layer)\n overlay = layer\n return overlay\n },\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
|
package/dist/for.js.map
ADDED
|
@@ -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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"}
|
package/dist/headless.d.ts
CHANGED
|
@@ -14,8 +14,17 @@ interface HeadlessNode {
|
|
|
14
14
|
/** Back-pointer to the parent, or `null` when detached. */
|
|
15
15
|
parent: HeadlessNode | null;
|
|
16
16
|
}
|
|
17
|
+
/** Options for {@link createHeadlessBackend}. */
|
|
18
|
+
interface HeadlessBackendOptions {
|
|
19
|
+
/**
|
|
20
|
+
* A designated overlay node for portals. Omit (the default) and `overlayRoot` is unimplemented,
|
|
21
|
+
* so portals mount IN PLACE — the SSR-correct behavior (`renderToString` only serializes the
|
|
22
|
+
* root's own children). Pass a node to test relocated portal placement.
|
|
23
|
+
*/
|
|
24
|
+
readonly overlayRoot?: HeadlessNode;
|
|
25
|
+
}
|
|
17
26
|
/** Create a {@link SerializableBackend} backed by an in-memory tree. */
|
|
18
|
-
declare function createHeadlessBackend(): SerializableBackend<HeadlessNode>;
|
|
27
|
+
declare function createHeadlessBackend(options?: HeadlessBackendOptions): SerializableBackend<HeadlessNode>;
|
|
19
28
|
/** Whether a prop key is an event handler (`onClick`, `onPress`, …). */
|
|
20
29
|
declare function isEventProp(key: string): boolean;
|
|
21
30
|
/** Convenience: create a detached headless root element (default tag `"root"`). */
|
package/dist/headless.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headless.d.ts","names":[],"sources":["../src/headless.ts"],"mappings":";;;;
|
|
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;;UAkEO,sBAAA;EAMoB;AAIrC;;;;EAJqC,SAA1B,WAAA,GAAc,YAAY;AAAA;;iBAIrB,qBAAA,CACd,OAAA,GAAS,sBAAA,GACR,mBAAA,CAAoB,YAAA;;iBAwEP,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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
8
9
|
}
|
|
9
|
-
/**
|
|
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
|
|
15
|
+
if (value && typeof value === "object") return serializeStyle(value);
|
|
12
16
|
return String(value);
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
@@ -36,8 +40,8 @@ function serializeHeadless(node, options) {
|
|
|
36
40
|
return `<${tag}${Object.entries(node.props).filter(([key]) => !isEventProp(key) && isValidAttrName(key)).map(([key, value]) => value === true ? ` ${key}=""` : ` ${key}="${escapeAttr(serializeAttrValue(value))}"`).join("")}>${node.children.map((c) => serializeHeadless(c, options)).join("")}</${tag}>`;
|
|
37
41
|
}
|
|
38
42
|
/** Create a {@link SerializableBackend} backed by an in-memory tree. */
|
|
39
|
-
function createHeadlessBackend() {
|
|
40
|
-
|
|
43
|
+
function createHeadlessBackend(options = {}) {
|
|
44
|
+
const backend = {
|
|
41
45
|
createElement(type) {
|
|
42
46
|
return {
|
|
43
47
|
type,
|
|
@@ -95,6 +99,11 @@ function createHeadlessBackend() {
|
|
|
95
99
|
},
|
|
96
100
|
serialize: serializeHeadless
|
|
97
101
|
};
|
|
102
|
+
if (options.overlayRoot) {
|
|
103
|
+
const target = options.overlayRoot;
|
|
104
|
+
backend.overlayRoot = () => target;
|
|
105
|
+
}
|
|
106
|
+
return backend;
|
|
98
107
|
}
|
|
99
108
|
/** Whether a prop key is an event handler (`onClick`, `onPress`, …). */
|
|
100
109
|
function isEventProp(key) {
|
package/dist/headless.js.map
CHANGED
|
@@ -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, '&').replace(/\"/g, '"').replace(/</g, '<')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n
|
|
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, '&').replace(/\"/g, '"').replace(/</g, '<')\n}\n\nfunction escapeText(value: string): string {\n return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\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/** Options for {@link createHeadlessBackend}. */\nexport interface HeadlessBackendOptions {\n /**\n * A designated overlay node for portals. Omit (the default) and `overlayRoot` is unimplemented,\n * so portals mount IN PLACE — the SSR-correct behavior (`renderToString` only serializes the\n * root's own children). Pass a node to test relocated portal placement.\n */\n readonly overlayRoot?: HeadlessNode\n}\n\n/** Create a {@link SerializableBackend} backed by an in-memory tree. */\nexport function createHeadlessBackend(\n options: HeadlessBackendOptions = {},\n): SerializableBackend<HeadlessNode> {\n const backend: SerializableBackend<HeadlessNode> = {\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 // Only expose overlayRoot when a target was provided, so the default stays in-place (SSR-correct).\n if (options.overlayRoot) {\n const target = options.overlayRoot\n backend.overlayRoot = () => target\n }\n return backend\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,14 @@
|
|
|
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 {
|
|
10
|
+
import { bindPortalChild } from "./portal.js";
|
|
11
|
+
import { Mounted, mountNode, render } from "./render.js";
|
|
10
12
|
import { hydrate, renderToString } from "./ssr.js";
|
|
11
13
|
import { Maturity, NotImplementedError, PackageInfo, notImplemented } from "@mindees/core";
|
|
12
14
|
|
|
@@ -14,7 +16,7 @@ import { Maturity, NotImplementedError, PackageInfo, notImplemented } from "@min
|
|
|
14
16
|
/** The npm package name. */
|
|
15
17
|
declare const name = "@mindees/renderer";
|
|
16
18
|
/** The package version. All `@mindees/*` packages share one locked version line. */
|
|
17
|
-
declare const VERSION = "0.
|
|
19
|
+
declare const VERSION = "0.4.0";
|
|
18
20
|
/**
|
|
19
21
|
* Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,
|
|
20
22
|
* headless backend, SSR + hydration) is implemented and tested. Native
|
|
@@ -29,5 +31,5 @@ declare const maturity: Maturity;
|
|
|
29
31
|
*/
|
|
30
32
|
declare const info: PackageInfo;
|
|
31
33
|
//#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 };
|
|
34
|
+
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, bindPortalChild, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, mountNode, name, normalizeNativeProp, notImplemented, render, renderToString };
|
|
33
35
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;;;;;;;;;;cAsFa,IAAA;;cAGA,OAAA;;;;;;;cAQA,QAAA,EAAU,QAAyB;;;;;;cAOnC,IAAA,EAAM,WAAiE"}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { isSerializable } from "./backend.js";
|
|
2
2
|
import { createDomBackend, domTagFor } from "./dom.js";
|
|
3
|
+
import { bindPortalChild } from "./portal.js";
|
|
4
|
+
import { mountNode, render } from "./render.js";
|
|
5
|
+
import { bindKeyedChild } from "./for.js";
|
|
3
6
|
import { createHeadlessBackend, createHeadlessRoot, isEventProp } from "./headless.js";
|
|
4
7
|
import { createNativeNodeIdFactory, isNativeCommand, isNativePropValue, normalizeNativeProp } from "./native-protocol.js";
|
|
5
8
|
import { createNativeCommandBackend } from "./native-command-backend.js";
|
|
6
9
|
import { createCanvasBackend, createNativeBackend } from "./native.js";
|
|
7
|
-
import { render } from "./render.js";
|
|
8
10
|
import { createNativeApp } from "./native-app.js";
|
|
9
11
|
import { NativeHostError, createReferenceHost } from "./native-host.js";
|
|
10
12
|
import { hydrate, renderToString } from "./ssr.js";
|
|
@@ -13,7 +15,7 @@ import { NotImplementedError, notImplemented } from "@mindees/core";
|
|
|
13
15
|
/** The npm package name. */
|
|
14
16
|
const name = "@mindees/renderer";
|
|
15
17
|
/** The package version. All `@mindees/*` packages share one locked version line. */
|
|
16
|
-
const VERSION = "0.
|
|
18
|
+
const VERSION = "0.4.0";
|
|
17
19
|
/**
|
|
18
20
|
* Current maturity. The Helix **web/DOM** renderer (reconciler, DOM backend,
|
|
19
21
|
* headless backend, SSR + hydration) is implemented and tested. Native
|
|
@@ -32,6 +34,6 @@ const info = Object.freeze({
|
|
|
32
34
|
maturity
|
|
33
35
|
});
|
|
34
36
|
//#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 };
|
|
37
|
+
export { NativeHostError, NotImplementedError, VERSION, bindKeyedChild, bindPortalChild, createCanvasBackend, createDomBackend, createHeadlessBackend, createHeadlessRoot, createNativeApp, createNativeBackend, createNativeCommandBackend, createNativeNodeIdFactory, createReferenceHost, domTagFor, hydrate, info, isEventProp, isNativeCommand, isNativePropValue, isSerializable, maturity, mountNode, name, normalizeNativeProp, notImplemented, render, renderToString };
|
|
36
38
|
|
|
37
39
|
//# 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.
|
|
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/** Portal reconciliation (the renderer side of core's PortalRegion). */\nexport { bindPortalChild } from './portal'\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.4.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":";;;;;;;;;;;;;;;AAsFA,MAAa,OAAO;;AAGpB,MAAa,UAAU;;;;;;;AAQvB,MAAa,WAAqB;;;;;;AAOlC,MAAa,OAAoB,OAAO,OAAO;CAAE;CAAM,SAAS;CAAS;AAAS,CAAC"}
|
package/dist/native-app.js
CHANGED
|
@@ -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;
|
|
@@ -43,6 +43,7 @@ function createNativeCommandBackend(options = {}) {
|
|
|
43
43
|
parent: null,
|
|
44
44
|
children: []
|
|
45
45
|
};
|
|
46
|
+
let overlay = null;
|
|
46
47
|
const pending = [];
|
|
47
48
|
/** handlerId → handler function. The function never enters the command stream. */
|
|
48
49
|
const handlers = /* @__PURE__ */ new Map();
|
|
@@ -196,6 +197,24 @@ function createNativeCommandBackend(options = {}) {
|
|
|
196
197
|
childId: node.id,
|
|
197
198
|
index
|
|
198
199
|
});
|
|
200
|
+
if (parent === root && node !== overlay && overlay !== null && root.children[root.children.length - 1] !== overlay) {
|
|
201
|
+
const at = root.children.indexOf(overlay);
|
|
202
|
+
if (at >= 0) {
|
|
203
|
+
root.children.splice(at, 1);
|
|
204
|
+
root.children.push(overlay);
|
|
205
|
+
emit({
|
|
206
|
+
type: "removeChild",
|
|
207
|
+
parentId: root.id,
|
|
208
|
+
childId: overlay.id
|
|
209
|
+
});
|
|
210
|
+
emit({
|
|
211
|
+
type: "insertChild",
|
|
212
|
+
parentId: root.id,
|
|
213
|
+
childId: overlay.id,
|
|
214
|
+
index: root.children.length - 1
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
199
218
|
},
|
|
200
219
|
remove(parent, node) {
|
|
201
220
|
const at = parent.children.indexOf(node);
|
|
@@ -220,6 +239,37 @@ function createNativeCommandBackend(options = {}) {
|
|
|
220
239
|
isText(node) {
|
|
221
240
|
return node.kind === "text";
|
|
222
241
|
},
|
|
242
|
+
overlayRoot() {
|
|
243
|
+
if (overlay) return overlay;
|
|
244
|
+
const node = {
|
|
245
|
+
id: nextId(),
|
|
246
|
+
kind: "element",
|
|
247
|
+
tag: "overlay",
|
|
248
|
+
text: "",
|
|
249
|
+
parent: root,
|
|
250
|
+
children: []
|
|
251
|
+
};
|
|
252
|
+
emit({
|
|
253
|
+
type: "createNode",
|
|
254
|
+
id: node.id,
|
|
255
|
+
tag: "overlay"
|
|
256
|
+
});
|
|
257
|
+
emit({
|
|
258
|
+
type: "setProp",
|
|
259
|
+
id: node.id,
|
|
260
|
+
name: "data-mindees-overlay",
|
|
261
|
+
value: "true"
|
|
262
|
+
});
|
|
263
|
+
root.children.push(node);
|
|
264
|
+
emit({
|
|
265
|
+
type: "insertChild",
|
|
266
|
+
parentId: root.id,
|
|
267
|
+
childId: node.id,
|
|
268
|
+
index: root.children.length - 1
|
|
269
|
+
});
|
|
270
|
+
overlay = node;
|
|
271
|
+
return overlay;
|
|
272
|
+
},
|
|
223
273
|
getCommands() {
|
|
224
274
|
return pending.slice();
|
|
225
275
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native-command-backend.js","names":[],"sources":["../src/native-command-backend.ts"],"sourcesContent":["/**\n * The **native command backend** — a {@link HostBackend} that, instead of\n * mutating a DOM, records a stream of {@link NativeCommand}s describing how to\n * build and update a native view tree.\n *\n * This is the bridge between Helix (the reconciler + fine-grained reactivity) and\n * a native host: render an app against this backend and you get a deterministic,\n * serializable command stream a UIKit or Android View host can replay. It depends\n * only on `@mindees/core` reactivity\n * (via the reconciler) — no DOM, no browser globals — so it runs in Node tests.\n *\n * It is **not** an end-to-end native app bridge. It produces the protocol that the\n * verified host projects in `examples/native-hosts/` replay/render in CI; the direct\n * runtime backend and embedded JS engine remain research tracks\n * ({@link createNativeBackend}).\n *\n * @module\n */\n\nimport type { HostBackend } from './backend'\nimport { isEventProp } from './headless'\nimport {\n createNativeNodeIdFactory,\n type NativeCommand,\n type NativeNodeId,\n normalizeNativeProp,\n} from './native-protocol'\n\n/**\n * An opaque native host node. The reconciler treats it as a handle; the backend\n * tracks structure (parent/children) on it to translate the {@link HostBackend}\n * tree operations into index-based {@link NativeCommand}s.\n */\nexport interface NativeCommandNode {\n /** Stable id, shared with the host via the command stream. */\n readonly id: NativeNodeId\n /** `\"element\"` or `\"text\"`. */\n kind: 'element' | 'text'\n /** Element tag (empty for text nodes). */\n tag: string\n /** Text content (text nodes). */\n text: string\n /** Parent node, or `null` when detached / the root. */\n parent: NativeCommandNode | null\n /** Ordered child nodes. */\n children: NativeCommandNode[]\n}\n\n/** Options for {@link createNativeCommandBackend}. */\nexport interface NativeCommandBackendOptions {\n /** Id of the host's pre-existing root container. Defaults to a per-instance id. */\n rootId?: NativeNodeId\n /**\n * Custom node-id generator. Must return **unique, finite** ids (a non-finite\n * number is rejected at the boundary). Defaults to a collision-free per-instance\n * factory; uniqueness of a custom factory is the caller's responsibility.\n */\n idFactory?: () => NativeNodeId\n /** Called synchronously for every emitted command. */\n onCommand?: (command: NativeCommand) => void\n /** Called by {@link NativeCommandBackend.flushCommands} with the flushed batch. */\n onBatch?: (commands: readonly NativeCommand[]) => void\n}\n\n/** A {@link HostBackend} that emits a {@link NativeCommand} stream. */\nexport interface NativeCommandBackend<N> extends HostBackend<N> {\n /** Discriminator for backend kind. */\n readonly kind: 'native-command'\n /** Id of the host root container (the `parentId` of top-level inserts). */\n readonly rootId: NativeNodeId\n /** The root node to pass as the `container` to `render()`. */\n readonly root: N\n /** A readonly snapshot of all commands buffered since the last flush/clear. */\n getCommands(): readonly NativeCommand[]\n /** Return the buffered commands as a batch, fire `onBatch`, and clear the buffer. */\n flushCommands(): readonly NativeCommand[]\n /** Drop all buffered commands without firing `onBatch`. */\n clearCommands(): void\n /**\n * Invoke a registered event handler by id (what a host calls when a native\n * event fires). Returns `true` if a handler was found and called.\n */\n dispatchEvent(handlerId: string, event?: unknown): boolean\n}\n\n/**\n * Private, monotonic instance counter used only to give each backend a distinct\n * id prefix so default node ids never collide across instances. Not observable\n * outside the module; callers that want stable ids pass their own `idFactory`.\n */\nlet backendInstanceSeq = 0\n\n/** `onPress` → `press`, `onPointerDown` → `pointerdown`. */\nfunction eventNameFor(key: string): string {\n return key.slice(2).toLowerCase()\n}\n\n/**\n * Enforce the protocol's id invariant at the backend boundary: a non-finite number\n * id would silently corrupt to `null` through JSON and break node identity on the\n * wire. Throws on misuse (e.g. a custom `idFactory`/`rootId` yielding `NaN`).\n */\nfunction validateNodeId(id: NativeNodeId): NativeNodeId {\n if (typeof id === 'number' && !Number.isFinite(id)) {\n throw new TypeError(`native node id must be a string or finite number, received ${String(id)}`)\n }\n return id\n}\n\n/**\n * Create a {@link NativeCommandBackend}. Render against it to capture the native\n * command stream:\n *\n * @example\n * const backend = createNativeCommandBackend()\n * const app = render(MyComponent, {}, backend, backend.root)\n * const commands = backend.flushCommands() // replay these on a native host\n */\nexport function createNativeCommandBackend(\n options: NativeCommandBackendOptions = {},\n): NativeCommandBackend<NativeCommandNode> {\n const prefix = `b${backendInstanceSeq++}`\n const rawNextId = options.idFactory ?? createNativeNodeIdFactory(`${prefix}n`)\n // Validate every id at the boundary. Uniqueness is the idFactory's contract (the\n // default factory guarantees it); we deliberately don't track every id forever to\n // detect duplicates, which would leak memory in the long-running apps this targets.\n const nextId = (): NativeNodeId => validateNodeId(rawNextId())\n const nextHandlerId = createNativeNodeIdFactory(`${prefix}h`)\n const rootId = validateNodeId(options.rootId ?? `${prefix}root`)\n\n const root: NativeCommandNode = {\n id: rootId,\n kind: 'element',\n tag: 'root',\n text: '',\n parent: null,\n children: [],\n }\n\n const pending: NativeCommand[] = []\n /** handlerId → handler function. The function never enters the command stream. */\n const handlers = new Map<string, (event?: unknown) => void>()\n /** node → (eventName → handlerId), so we can unregister on change/dispose. */\n const nodeEvents = new WeakMap<NativeCommandNode, Map<string, string>>()\n\n function emit(command: NativeCommand): void {\n pending.push(command)\n options.onCommand?.(command)\n }\n\n function applyEvent(node: NativeCommandNode, eventName: string, value: unknown): void {\n let events = nodeEvents.get(node)\n const existing = events?.get(eventName)\n if (existing !== undefined) {\n handlers.delete(existing)\n events?.delete(eventName)\n emit({ type: 'unregisterEvent', id: node.id, eventName, handlerId: existing })\n }\n if (typeof value === 'function') {\n const handlerId = nextHandlerId()\n handlers.set(handlerId, value as (event?: unknown) => void)\n if (!events) {\n events = new Map()\n nodeEvents.set(node, events)\n }\n events.set(eventName, handlerId)\n emit({ type: 'registerEvent', id: node.id, eventName, handlerId })\n }\n }\n\n /** Tear down a removed subtree: unregister its events, dispose deepest-first. */\n function disposeSubtree(node: NativeCommandNode): void {\n for (const child of node.children) disposeSubtree(child)\n const events = nodeEvents.get(node)\n if (events) {\n for (const [eventName, handlerId] of events) {\n handlers.delete(handlerId)\n emit({ type: 'unregisterEvent', id: node.id, eventName, handlerId })\n }\n nodeEvents.delete(node)\n }\n emit({ type: 'disposeNode', id: node.id })\n // Detach every disposed node so parentOf() reports it removed. The reconciler's\n // region cleanup re-checks parentOf before removing (render.ts bindReactiveChild),\n // so without this a descendant whose parent pointer still pointed at the (already\n // removed) parent would be removed + disposed a SECOND time — a host double-free.\n node.parent = null\n node.children = []\n }\n\n return {\n kind: 'native-command',\n rootId,\n root,\n\n createElement(type: string): NativeCommandNode {\n const node: NativeCommandNode = {\n id: nextId(),\n kind: 'element',\n tag: type,\n text: '',\n parent: null,\n children: [],\n }\n emit({ type: 'createNode', id: node.id, tag: type })\n return node\n },\n\n createText(value: string): NativeCommandNode {\n const node: NativeCommandNode = {\n id: nextId(),\n kind: 'text',\n tag: '',\n text: value,\n parent: null,\n children: [],\n }\n emit({ type: 'createText', id: node.id, text: value })\n return node\n },\n\n setProp(node: NativeCommandNode, key: string, value: unknown, prev: unknown): void {\n if (isEventProp(key)) {\n applyEvent(node, eventNameFor(key), value)\n return\n }\n const normalized = normalizeNativeProp(value)\n if (normalized === undefined) {\n // Only emit a removal if there was actually a representable value before.\n if (normalizeNativeProp(prev) !== undefined) {\n emit({ type: 'removeProp', id: node.id, name: key })\n }\n return\n }\n emit({ type: 'setProp', id: node.id, name: key, value: normalized })\n },\n\n setText(node: NativeCommandNode, value: string): void {\n node.text = value\n emit({ type: 'updateText', id: node.id, text: value })\n },\n\n insert(\n parent: NativeCommandNode,\n node: NativeCommandNode,\n anchor: NativeCommandNode | null,\n ): void {\n // A move: detach from the old parent first so indices stay correct.\n if (node.parent) {\n const old = node.parent\n const oldIndex = old.children.indexOf(node)\n if (oldIndex >= 0) old.children.splice(oldIndex, 1)\n emit({ type: 'removeChild', parentId: old.id, childId: node.id })\n }\n let index: number\n if (anchor === null) {\n index = parent.children.length\n parent.children.push(node)\n } else {\n const at = parent.children.indexOf(anchor)\n index = at < 0 ? parent.children.length : at\n parent.children.splice(index, 0, node)\n }\n node.parent = parent\n emit({ type: 'insertChild', parentId: parent.id, childId: node.id, index })\n },\n\n remove(parent: NativeCommandNode, node: NativeCommandNode): void {\n const at = parent.children.indexOf(node)\n if (at >= 0) parent.children.splice(at, 1)\n node.parent = null\n emit({ type: 'removeChild', parentId: parent.id, childId: node.id })\n // The reconciler discards removed nodes, so free the whole subtree + handlers.\n disposeSubtree(node)\n },\n\n parentOf(node: NativeCommandNode): NativeCommandNode | null {\n return node.parent\n },\n\n nextSibling(node: NativeCommandNode): NativeCommandNode | null {\n const parent = node.parent\n if (!parent) return null\n const at = parent.children.indexOf(node)\n return at >= 0 && at + 1 < parent.children.length ? (parent.children[at + 1] ?? null) : null\n },\n\n isText(node: NativeCommandNode): boolean {\n return node.kind === 'text'\n },\n\n getCommands(): readonly NativeCommand[] {\n return pending.slice()\n },\n\n flushCommands(): readonly NativeCommand[] {\n const batch = pending.slice()\n pending.length = 0\n options.onBatch?.(batch)\n return batch\n },\n\n clearCommands(): void {\n pending.length = 0\n },\n\n dispatchEvent(handlerId: string, event?: unknown): boolean {\n const handler = handlers.get(handlerId)\n if (!handler) return false\n handler(event)\n return true\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"native-command-backend.js","names":[],"sources":["../src/native-command-backend.ts"],"sourcesContent":["/**\n * The **native command backend** — a {@link HostBackend} that, instead of\n * mutating a DOM, records a stream of {@link NativeCommand}s describing how to\n * build and update a native view tree.\n *\n * This is the bridge between Helix (the reconciler + fine-grained reactivity) and\n * a native host: render an app against this backend and you get a deterministic,\n * serializable command stream a UIKit or Android View host can replay. It depends\n * only on `@mindees/core` reactivity\n * (via the reconciler) — no DOM, no browser globals — so it runs in Node tests.\n *\n * It is **not** an end-to-end native app bridge. It produces the protocol that the\n * verified host projects in `examples/native-hosts/` replay/render in CI; the direct\n * runtime backend and embedded JS engine remain research tracks\n * ({@link createNativeBackend}).\n *\n * @module\n */\n\nimport type { HostBackend } from './backend'\nimport { isEventProp } from './headless'\nimport {\n createNativeNodeIdFactory,\n type NativeCommand,\n type NativeNodeId,\n normalizeNativeProp,\n} from './native-protocol'\n\n/**\n * An opaque native host node. The reconciler treats it as a handle; the backend\n * tracks structure (parent/children) on it to translate the {@link HostBackend}\n * tree operations into index-based {@link NativeCommand}s.\n */\nexport interface NativeCommandNode {\n /** Stable id, shared with the host via the command stream. */\n readonly id: NativeNodeId\n /** `\"element\"` or `\"text\"`. */\n kind: 'element' | 'text'\n /** Element tag (empty for text nodes). */\n tag: string\n /** Text content (text nodes). */\n text: string\n /** Parent node, or `null` when detached / the root. */\n parent: NativeCommandNode | null\n /** Ordered child nodes. */\n children: NativeCommandNode[]\n}\n\n/** Options for {@link createNativeCommandBackend}. */\nexport interface NativeCommandBackendOptions {\n /** Id of the host's pre-existing root container. Defaults to a per-instance id. */\n rootId?: NativeNodeId\n /**\n * Custom node-id generator. Must return **unique, finite** ids (a non-finite\n * number is rejected at the boundary). Defaults to a collision-free per-instance\n * factory; uniqueness of a custom factory is the caller's responsibility.\n */\n idFactory?: () => NativeNodeId\n /** Called synchronously for every emitted command. */\n onCommand?: (command: NativeCommand) => void\n /** Called by {@link NativeCommandBackend.flushCommands} with the flushed batch. */\n onBatch?: (commands: readonly NativeCommand[]) => void\n}\n\n/** A {@link HostBackend} that emits a {@link NativeCommand} stream. */\nexport interface NativeCommandBackend<N> extends HostBackend<N> {\n /** Discriminator for backend kind. */\n readonly kind: 'native-command'\n /** Id of the host root container (the `parentId` of top-level inserts). */\n readonly rootId: NativeNodeId\n /** The root node to pass as the `container` to `render()`. */\n readonly root: N\n /** A readonly snapshot of all commands buffered since the last flush/clear. */\n getCommands(): readonly NativeCommand[]\n /** Return the buffered commands as a batch, fire `onBatch`, and clear the buffer. */\n flushCommands(): readonly NativeCommand[]\n /** Drop all buffered commands without firing `onBatch`. */\n clearCommands(): void\n /**\n * Invoke a registered event handler by id (what a host calls when a native\n * event fires). Returns `true` if a handler was found and called.\n */\n dispatchEvent(handlerId: string, event?: unknown): boolean\n}\n\n/**\n * Private, monotonic instance counter used only to give each backend a distinct\n * id prefix so default node ids never collide across instances. Not observable\n * outside the module; callers that want stable ids pass their own `idFactory`.\n */\nlet backendInstanceSeq = 0\n\n/** `onPress` → `press`, `onPointerDown` → `pointerdown`. */\nfunction eventNameFor(key: string): string {\n return key.slice(2).toLowerCase()\n}\n\n/**\n * Enforce the protocol's id invariant at the backend boundary: a non-finite number\n * id would silently corrupt to `null` through JSON and break node identity on the\n * wire. Throws on misuse (e.g. a custom `idFactory`/`rootId` yielding `NaN`).\n */\nfunction validateNodeId(id: NativeNodeId): NativeNodeId {\n if (typeof id === 'number' && !Number.isFinite(id)) {\n throw new TypeError(`native node id must be a string or finite number, received ${String(id)}`)\n }\n return id\n}\n\n/**\n * Create a {@link NativeCommandBackend}. Render against it to capture the native\n * command stream:\n *\n * @example\n * const backend = createNativeCommandBackend()\n * const app = render(MyComponent, {}, backend, backend.root)\n * const commands = backend.flushCommands() // replay these on a native host\n */\nexport function createNativeCommandBackend(\n options: NativeCommandBackendOptions = {},\n): NativeCommandBackend<NativeCommandNode> {\n const prefix = `b${backendInstanceSeq++}`\n const rawNextId = options.idFactory ?? createNativeNodeIdFactory(`${prefix}n`)\n // Validate every id at the boundary. Uniqueness is the idFactory's contract (the\n // default factory guarantees it); we deliberately don't track every id forever to\n // detect duplicates, which would leak memory in the long-running apps this targets.\n const nextId = (): NativeNodeId => validateNodeId(rawNextId())\n const nextHandlerId = createNativeNodeIdFactory(`${prefix}h`)\n const rootId = validateNodeId(options.rootId ?? `${prefix}root`)\n\n const root: NativeCommandNode = {\n id: rootId,\n kind: 'element',\n tag: 'root',\n text: '',\n parent: null,\n children: [],\n }\n\n let overlay: NativeCommandNode | null = null // lazily-created portal overlay (see overlayRoot)\n const pending: NativeCommand[] = []\n /** handlerId → handler function. The function never enters the command stream. */\n const handlers = new Map<string, (event?: unknown) => void>()\n /** node → (eventName → handlerId), so we can unregister on change/dispose. */\n const nodeEvents = new WeakMap<NativeCommandNode, Map<string, string>>()\n\n function emit(command: NativeCommand): void {\n pending.push(command)\n options.onCommand?.(command)\n }\n\n function applyEvent(node: NativeCommandNode, eventName: string, value: unknown): void {\n let events = nodeEvents.get(node)\n const existing = events?.get(eventName)\n if (existing !== undefined) {\n handlers.delete(existing)\n events?.delete(eventName)\n emit({ type: 'unregisterEvent', id: node.id, eventName, handlerId: existing })\n }\n if (typeof value === 'function') {\n const handlerId = nextHandlerId()\n handlers.set(handlerId, value as (event?: unknown) => void)\n if (!events) {\n events = new Map()\n nodeEvents.set(node, events)\n }\n events.set(eventName, handlerId)\n emit({ type: 'registerEvent', id: node.id, eventName, handlerId })\n }\n }\n\n /** Tear down a removed subtree: unregister its events, dispose deepest-first. */\n function disposeSubtree(node: NativeCommandNode): void {\n for (const child of node.children) disposeSubtree(child)\n const events = nodeEvents.get(node)\n if (events) {\n for (const [eventName, handlerId] of events) {\n handlers.delete(handlerId)\n emit({ type: 'unregisterEvent', id: node.id, eventName, handlerId })\n }\n nodeEvents.delete(node)\n }\n emit({ type: 'disposeNode', id: node.id })\n // Detach every disposed node so parentOf() reports it removed. The reconciler's\n // region cleanup re-checks parentOf before removing (render.ts bindReactiveChild),\n // so without this a descendant whose parent pointer still pointed at the (already\n // removed) parent would be removed + disposed a SECOND time — a host double-free.\n node.parent = null\n node.children = []\n }\n\n return {\n kind: 'native-command',\n rootId,\n root,\n\n createElement(type: string): NativeCommandNode {\n const node: NativeCommandNode = {\n id: nextId(),\n kind: 'element',\n tag: type,\n text: '',\n parent: null,\n children: [],\n }\n emit({ type: 'createNode', id: node.id, tag: type })\n return node\n },\n\n createText(value: string): NativeCommandNode {\n const node: NativeCommandNode = {\n id: nextId(),\n kind: 'text',\n tag: '',\n text: value,\n parent: null,\n children: [],\n }\n emit({ type: 'createText', id: node.id, text: value })\n return node\n },\n\n setProp(node: NativeCommandNode, key: string, value: unknown, prev: unknown): void {\n if (isEventProp(key)) {\n applyEvent(node, eventNameFor(key), value)\n return\n }\n const normalized = normalizeNativeProp(value)\n if (normalized === undefined) {\n // Only emit a removal if there was actually a representable value before.\n if (normalizeNativeProp(prev) !== undefined) {\n emit({ type: 'removeProp', id: node.id, name: key })\n }\n return\n }\n emit({ type: 'setProp', id: node.id, name: key, value: normalized })\n },\n\n setText(node: NativeCommandNode, value: string): void {\n node.text = value\n emit({ type: 'updateText', id: node.id, text: value })\n },\n\n insert(\n parent: NativeCommandNode,\n node: NativeCommandNode,\n anchor: NativeCommandNode | null,\n ): void {\n // A move: detach from the old parent first so indices stay correct.\n if (node.parent) {\n const old = node.parent\n const oldIndex = old.children.indexOf(node)\n if (oldIndex >= 0) old.children.splice(oldIndex, 1)\n emit({ type: 'removeChild', parentId: old.id, childId: node.id })\n }\n let index: number\n if (anchor === null) {\n index = parent.children.length\n parent.children.push(node)\n } else {\n const at = parent.children.indexOf(anchor)\n index = at < 0 ? parent.children.length : at\n parent.children.splice(index, 0, node)\n }\n node.parent = parent\n emit({ type: 'insertChild', parentId: parent.id, childId: node.id, index })\n\n // Keep the portal overlay painting LAST: when app content lands directly in root while an\n // overlay exists, move the overlay to the end so order-paint hosts (Android `addView`) draw\n // the modal on top. (A host may instead map the 'overlay' tag to a top-layer container.)\n if (\n parent === root &&\n node !== overlay &&\n overlay !== null &&\n root.children[root.children.length - 1] !== overlay\n ) {\n const at = root.children.indexOf(overlay)\n if (at >= 0) {\n root.children.splice(at, 1)\n root.children.push(overlay)\n emit({ type: 'removeChild', parentId: root.id, childId: overlay.id })\n emit({\n type: 'insertChild',\n parentId: root.id,\n childId: overlay.id,\n index: root.children.length - 1,\n })\n }\n }\n },\n\n remove(parent: NativeCommandNode, node: NativeCommandNode): void {\n const at = parent.children.indexOf(node)\n if (at >= 0) parent.children.splice(at, 1)\n node.parent = null\n emit({ type: 'removeChild', parentId: parent.id, childId: node.id })\n // The reconciler discards removed nodes, so free the whole subtree + handlers.\n disposeSubtree(node)\n },\n\n parentOf(node: NativeCommandNode): NativeCommandNode | null {\n return node.parent\n },\n\n nextSibling(node: NativeCommandNode): NativeCommandNode | null {\n const parent = node.parent\n if (!parent) return null\n const at = parent.children.indexOf(node)\n return at >= 0 && at + 1 < parent.children.length ? (parent.children[at + 1] ?? null) : null\n },\n\n isText(node: NativeCommandNode): boolean {\n return node.kind === 'text'\n },\n\n overlayRoot(): NativeCommandNode | null {\n if (overlay) return overlay\n // A dedicated 'overlay' element under root: better z-order than the content container, and a\n // host can map the `data-mindees-overlay` marker to a window-level container. Until a host\n // honors it, modals render here (a child of root) — declarative, never silently dropped.\n const node: NativeCommandNode = {\n id: nextId(),\n kind: 'element',\n tag: 'overlay',\n text: '',\n parent: root,\n children: [],\n }\n emit({ type: 'createNode', id: node.id, tag: 'overlay' })\n emit({ type: 'setProp', id: node.id, name: 'data-mindees-overlay', value: 'true' })\n root.children.push(node)\n emit({\n type: 'insertChild',\n parentId: root.id,\n childId: node.id,\n index: root.children.length - 1,\n })\n overlay = node\n return overlay\n },\n\n getCommands(): readonly NativeCommand[] {\n return pending.slice()\n },\n\n flushCommands(): readonly NativeCommand[] {\n const batch = pending.slice()\n pending.length = 0\n options.onBatch?.(batch)\n return batch\n },\n\n clearCommands(): void {\n pending.length = 0\n },\n\n dispatchEvent(handlerId: string, event?: unknown): boolean {\n const handler = handlers.get(handlerId)\n if (!handler) return false\n handler(event)\n return true\n },\n }\n}\n"],"mappings":"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"}
|
package/dist/portal.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { HostBackend } from "./backend.js";
|
|
2
|
+
import { PortalRegion } from "@mindees/core";
|
|
3
|
+
|
|
4
|
+
//#region src/portal.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Materialize a {@link PortalRegion}: mount its children into the overlay target, return only the
|
|
7
|
+
* logical-tree slot marker. Must run inside an owner (it does, via `mountNode`) so its cleanup is
|
|
8
|
+
* scoped — the portaled content is NOT in the returned array, so the owner's cleanup is the only
|
|
9
|
+
* thing that unmounts it.
|
|
10
|
+
*/
|
|
11
|
+
declare function bindPortalChild<N>(region: PortalRegion, backend: HostBackend<N>, parent: N, initialAnchor: N | null): N[];
|
|
12
|
+
//#endregion
|
|
13
|
+
export { bindPortalChild };
|
|
14
|
+
//# sourceMappingURL=portal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal.d.ts","names":[],"sources":["../src/portal.ts"],"mappings":";;;;;;;;;;iBAwBgB,eAAA,IACd,MAAA,EAAQ,YAAA,EACR,OAAA,EAAS,WAAA,CAAY,CAAA,GACrB,MAAA,EAAQ,CAAA,EACR,aAAA,EAAe,CAAA,UACd,CAAA"}
|
package/dist/portal.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mountNode } from "./render.js";
|
|
2
|
+
import { effect, onCleanup, untrack } from "@mindees/core";
|
|
3
|
+
//#region src/portal.ts
|
|
4
|
+
/**
|
|
5
|
+
* Portal reconciliation — the renderer side of core's {@link PortalRegion}.
|
|
6
|
+
*
|
|
7
|
+
* A portal's children render into an **overlay target** (`backend.overlayRoot()`, e.g. a layer on
|
|
8
|
+
* `document.body`) instead of their position in the tree, so a modal/tooltip escapes parent
|
|
9
|
+
* clipping and stacking. A marker in the LOGICAL parent pins the portal's slot (siblings stay
|
|
10
|
+
* ordered); a content marker in the TARGET anchors the relocated content. Reactive ownership is
|
|
11
|
+
* untouched — `onCleanup` binds to the current owner, not the host parent — so closing/unmounting
|
|
12
|
+
* tears the overlay content down and (via Atlas) restores focus. With no overlay (headless/SSR),
|
|
13
|
+
* the target falls back to the local parent: content mounts in place and stays crawlable.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Materialize a {@link PortalRegion}: mount its children into the overlay target, return only the
|
|
19
|
+
* logical-tree slot marker. Must run inside an owner (it does, via `mountNode`) so its cleanup is
|
|
20
|
+
* scoped — the portaled content is NOT in the returned array, so the owner's cleanup is the only
|
|
21
|
+
* thing that unmounts it.
|
|
22
|
+
*/
|
|
23
|
+
function bindPortalChild(region, backend, parent, initialAnchor) {
|
|
24
|
+
const target = region.mount ?? backend.overlayRoot?.() ?? parent;
|
|
25
|
+
const marker = backend.createText("");
|
|
26
|
+
backend.insert(parent, marker, initialAnchor);
|
|
27
|
+
const contentMarker = backend.createText("");
|
|
28
|
+
backend.insert(target, contentMarker, null);
|
|
29
|
+
let content = [];
|
|
30
|
+
const removeContent = () => {
|
|
31
|
+
for (const n of content) {
|
|
32
|
+
const p = backend.parentOf(n);
|
|
33
|
+
if (p) backend.remove(p, n);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
onCleanup(() => {
|
|
37
|
+
removeContent();
|
|
38
|
+
if (backend.parentOf(contentMarker)) backend.remove(target, contentMarker);
|
|
39
|
+
if (backend.parentOf(marker)) backend.remove(parent, marker);
|
|
40
|
+
});
|
|
41
|
+
effect(() => {
|
|
42
|
+
const value = region.children();
|
|
43
|
+
untrack(() => {
|
|
44
|
+
removeContent();
|
|
45
|
+
content = mountNode(value, backend, target, contentMarker);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return [marker];
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { bindPortalChild };
|
|
52
|
+
|
|
53
|
+
//# sourceMappingURL=portal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal.js","names":[],"sources":["../src/portal.ts"],"sourcesContent":["/**\n * Portal reconciliation — the renderer side of core's {@link PortalRegion}.\n *\n * A portal's children render into an **overlay target** (`backend.overlayRoot()`, e.g. a layer on\n * `document.body`) instead of their position in the tree, so a modal/tooltip escapes parent\n * clipping and stacking. A marker in the LOGICAL parent pins the portal's slot (siblings stay\n * ordered); a content marker in the TARGET anchors the relocated content. Reactive ownership is\n * untouched — `onCleanup` binds to the current owner, not the host parent — so closing/unmounting\n * tears the overlay content down and (via Atlas) restores focus. With no overlay (headless/SSR),\n * the target falls back to the local parent: content mounts in place and stays crawlable.\n *\n * @module\n */\n\nimport { effect, onCleanup, type PortalRegion, untrack } from '@mindees/core'\nimport type { HostBackend } from './backend'\nimport { mountNode } from './render'\n\n/**\n * Materialize a {@link PortalRegion}: mount its children into the overlay target, return only the\n * logical-tree slot marker. Must run inside an owner (it does, via `mountNode`) so its cleanup is\n * scoped — the portaled content is NOT in the returned array, so the owner's cleanup is the only\n * thing that unmounts it.\n */\nexport function bindPortalChild<N>(\n region: PortalRegion,\n backend: HostBackend<N>,\n parent: N,\n initialAnchor: N | null,\n): N[] {\n // Target: explicit `mount` override → the backend's overlay root → the local parent (in-place\n // fallback, the SSR/no-layer default).\n const target: N = (region.mount as N | undefined) ?? backend.overlayRoot?.() ?? parent\n\n // A marker in the LOGICAL parent pins the portal's slot so following siblings stay ordered; the\n // portal itself occupies zero visible space here.\n const marker = backend.createText('')\n backend.insert(parent, marker, initialAnchor)\n\n // A marker in the TARGET anchors the relocated content (reactive swaps re-mount before it).\n const contentMarker = backend.createText('')\n backend.insert(target, contentMarker, null)\n\n let content: N[] = []\n const removeContent = (): void => {\n // Content lives in TARGET, not `parent`, so resolve each node's ACTUAL parent — passing the\n // logical `parent` would throw on DOM and silently leak (+ leave live listeners) on\n // headless/native. parentOf-guarded so an already-detached node is a safe no-op.\n for (const n of content) {\n const p = backend.parentOf(n)\n if (p) backend.remove(p, n)\n }\n }\n\n // Authoritative teardown, owned by the current reactive owner (orthogonal to host placement), so\n // it fires on the owner's dispose or a gating region's re-run (e.g. Modal `visible` → false).\n onCleanup(() => {\n removeContent()\n if (backend.parentOf(contentMarker)) backend.remove(target, contentMarker)\n if (backend.parentOf(marker)) backend.remove(parent, marker)\n })\n\n effect(() => {\n const value = region.children()\n untrack(() => {\n removeContent()\n content = mountNode(value, backend, target, contentMarker)\n })\n })\n\n return [marker]\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,gBACd,QACA,SACA,QACA,eACK;CAGL,MAAM,SAAa,OAAO,SAA2B,QAAQ,cAAc,KAAK;CAIhF,MAAM,SAAS,QAAQ,WAAW,EAAE;CACpC,QAAQ,OAAO,QAAQ,QAAQ,aAAa;CAG5C,MAAM,gBAAgB,QAAQ,WAAW,EAAE;CAC3C,QAAQ,OAAO,QAAQ,eAAe,IAAI;CAE1C,IAAI,UAAe,CAAC;CACpB,MAAM,sBAA4B;EAIhC,KAAK,MAAM,KAAK,SAAS;GACvB,MAAM,IAAI,QAAQ,SAAS,CAAC;GAC5B,IAAI,GAAG,QAAQ,OAAO,GAAG,CAAC;EAC5B;CACF;CAIA,gBAAgB;EACd,cAAc;EACd,IAAI,QAAQ,SAAS,aAAa,GAAG,QAAQ,OAAO,QAAQ,aAAa;EACzE,IAAI,QAAQ,SAAS,MAAM,GAAG,QAAQ,OAAO,QAAQ,MAAM;CAC7D,CAAC;CAED,aAAa;EACX,MAAM,QAAQ,OAAO,SAAS;EAC9B,cAAc;GACZ,cAAc;GACd,UAAU,UAAU,OAAO,SAAS,QAAQ,aAAa;EAC3D,CAAC;CACH,CAAC;CAED,OAAO,CAAC,MAAM;AAChB"}
|
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
|
package/dist/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","names":[],"sources":["../src/render.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"render.d.ts","names":[],"sources":["../src/render.ts"],"mappings":";;;;;UAoDiB,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,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bindPortalChild } from "./portal.js";
|
|
2
|
+
import { bindKeyedChild } from "./for.js";
|
|
3
|
+
import { ELEMENT_TYPE, createRoot, effect, isKeyedRegion, isPortal, onCleanup, untrack } from "@mindees/core";
|
|
2
4
|
//#region src/render.ts
|
|
3
5
|
/**
|
|
4
6
|
* Helix reconciler — turns a MindeesNative element tree into host nodes via a
|
|
@@ -55,6 +57,8 @@ function render(a, b, c, d) {
|
|
|
55
57
|
*/
|
|
56
58
|
function mountNode(node, backend, parent, anchor) {
|
|
57
59
|
if (node === null || node === void 0 || typeof node === "boolean") return [];
|
|
60
|
+
if (isKeyedRegion(node)) return bindKeyedChild(node, backend, parent, anchor);
|
|
61
|
+
if (isPortal(node)) return bindPortalChild(node, backend, parent, anchor);
|
|
58
62
|
if (typeof node === "function") return bindReactiveChild(node, backend, parent, anchor);
|
|
59
63
|
if (typeof node === "string" || typeof node === "number") {
|
|
60
64
|
const text = backend.createText(String(node));
|
|
@@ -73,9 +77,14 @@ function mountNode(node, backend, parent, anchor) {
|
|
|
73
77
|
children: node.children
|
|
74
78
|
}), backend, parent, anchor);
|
|
75
79
|
const el = backend.createElement(type);
|
|
76
|
-
for (const [key, value] of Object.entries(node.props))
|
|
80
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
81
|
+
if (key === "ref") continue;
|
|
82
|
+
bindProp(backend, el, key, value);
|
|
83
|
+
}
|
|
77
84
|
mountChildren(node.children, backend, el);
|
|
78
85
|
backend.insert(parent, el, anchor);
|
|
86
|
+
const ref = node.props.ref;
|
|
87
|
+
if (typeof ref === "function") ref(el);
|
|
79
88
|
return [el];
|
|
80
89
|
}
|
|
81
90
|
return [];
|
|
@@ -140,6 +149,6 @@ function bindReactiveChild(accessor, backend, parent, initialAnchor) {
|
|
|
140
149
|
return nodes;
|
|
141
150
|
}
|
|
142
151
|
//#endregion
|
|
143
|
-
export { render };
|
|
152
|
+
export { mountNode, render };
|
|
144
153
|
|
|
145
154
|
//# sourceMappingURL=render.js.map
|
package/dist/render.js.map
CHANGED
|
@@ -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 isPortal,\n type MindeesElement,\n type MindeesNode,\n onCleanup,\n untrack,\n} from '@mindees/core'\nimport type { HostBackend } from './backend'\nimport { bindKeyedChild } from './for'\nimport { bindPortalChild } from './portal'\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 // Portal region → relocate children to the overlay target (renders above the tree).\n if (isPortal(node)) {\n return bindPortalChild(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 if (key === 'ref') continue // a callback, not an attr — invoked after insert\n bindProp(backend, el, key, value)\n }\n mountChildren(node.children, backend, el)\n backend.insert(parent, el, anchor)\n // `ref: (hostNode) => void` — fired once the element is in the tree, so a caller (e.g. a\n // focus scope) can capture the host node. Host-string elements only; opaque node type `N`.\n const ref = node.props.ref\n if (typeof ref === 'function') (ref as (node: N) => void)(el)\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.
|
|
3
|
+
"version": "0.4.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.
|
|
26
|
+
"@mindees/core": "0.4.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"happy-dom": "20.9.0"
|