@panphora/sapjs 0.2.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/src/lint.js ADDED
@@ -0,0 +1,229 @@
1
+ // Mount-time static analysis. Loud, attributed, teaching failures: a structural
2
+ // contradiction halts the whole app (no listeners arm, file bytes stay frozen);
3
+ // warnings never halt. This is the agent-authorability gate.
4
+
5
+ import { parseStateDecl, rowsOf, templateOf } from "./dom.js";
6
+ import {
7
+ teachForAttr, RESERVED, HTML_GLOBALS, NATIVE_BOOLEANS, didYouMean, setBeacon,
8
+ } from "./errors.js";
9
+
10
+ const NATIVE_BOOLEAN_SET = new Set(NATIVE_BOOLEANS);
11
+ // `editmode` is a hyperclayjs platform prefix (editmode:onclick etc.), not a Sap
12
+ // verb. It is allow-listed here so Sap stays quiet about it when co-loaded; the
13
+ // single entry covers the whole editmode:* family, so new editmode: attributes in
14
+ // hyperclayjs need no change. Sap has no engine branch for it (it is a no-op in Sap).
15
+ const COLON_PREFIXES = new Set(["calc", "text", "attr", "class", "css", "set", "move", "sort", "option", "option-not", "show-when", "hide-when", "editmode"]);
16
+ // The show-when family compares against a literal attribute value (or `|`-list),
17
+ // never a JS expression. An expression-shaped value means the author reached for
18
+ // show= by mistake; catch it loudly instead of failing the match silently.
19
+ const WHEN_PREFIXES = new Set(["show-when", "hide-when", "option", "option-not"]);
20
+ const EXPR_SHAPED = /\b(?:state|item|root)\.|===|!==|&&|\|\||[()]|^\s*!/;
21
+ const KNOWN_BARE = new Set([
22
+ "sap", "scope", "items", "item", "template", "bind", "show", "effect", "invalid",
23
+ "detail", "state", "transient", "confirm", "default", "persist", "sortable",
24
+ "trigger-add", "trigger-remove", "trigger-reset", "sap-ignore", "sap-error",
25
+ "no-save", "no-watch", "no-undo",
26
+ // hyperclayjs platform region markers (canonical + legacy aliases). sapjs tolerates
27
+ // them so a co-loaded region attribute inside a [sap] root is never flagged a typo.
28
+ "no-trigger-autosave", "freeze", "mutations-ignore", "save-remove", "save-ignore", "save-freeze",
29
+ ]);
30
+
31
+ function isControl(el) {
32
+ return el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA";
33
+ }
34
+
35
+ function isBound(el) {
36
+ return el.hasAttribute("bind");
37
+ }
38
+
39
+ // Walk every element under the app, skipping inert subtrees, calling visit(el).
40
+ function walkAll(root, visit) {
41
+ visit(root);
42
+ for (const child of root.children) descend(child);
43
+ function descend(el) {
44
+ if (el.nodeType !== 1) return;
45
+ if (el.hasAttribute("sap-ignore") || el.hasAttribute("template")) {
46
+ // still scan template descendants for structural errors, but not sap-ignore
47
+ if (el.hasAttribute("sap-ignore")) return;
48
+ }
49
+ visit(el);
50
+ for (const c of el.children) descend(c);
51
+ }
52
+ }
53
+
54
+ export function lintApp(root, diag) {
55
+ let halted = false;
56
+ const halt = (code, el, info) => {
57
+ diag.error(code, el, info);
58
+ halted = true;
59
+ };
60
+
61
+ // moustaches in any text node
62
+ const tw = root.ownerDocument.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
63
+ let node;
64
+ while ((node = tw.nextNode())) {
65
+ if (/\{\{.*\}\}/.test(node.nodeValue || "")) {
66
+ halt("E02", node.parentElement || root, {
67
+ problem: "moustaches in text content are not a Sap feature",
68
+ fix: 'move the expression onto the element: text="state.x"',
69
+ });
70
+ break;
71
+ }
72
+ }
73
+
74
+ walkAll(root, (el) => {
75
+ const inDetail = el.closest && el.closest("[detail]");
76
+
77
+ for (const a of [...el.attributes]) {
78
+ const name = a.name;
79
+
80
+ // foreign-dialect attributes
81
+ const teach = teachForAttr(name);
82
+ if (teach) {
83
+ halt("E01", el, {
84
+ attr: name, expr: a.value,
85
+ problem: `"${name}" is a foreign-dialect attribute; Sap has no ${name.split(/[:=]/)[0]} directive`,
86
+ fix: teach + " — or wrap the subtree in sap-ignore if intentional",
87
+ });
88
+ continue;
89
+ }
90
+
91
+ // colon-prefixed attribute checks
92
+ if (name.includes(":")) {
93
+ const prefix = name.slice(0, name.indexOf(":"));
94
+ if (!COLON_PREFIXES.has(prefix)) {
95
+ const dym = didYouMean(prefix, [...COLON_PREFIXES]);
96
+ diag.warn("W03", el, {
97
+ attr: name,
98
+ problem: `unknown "${prefix}:" attribute`,
99
+ didYouMean: dym ? `${dym}:` : null,
100
+ });
101
+ } else if (WHEN_PREFIXES.has(prefix) && EXPR_SHAPED.test(a.value)) {
102
+ const field = name.slice(name.indexOf(":") + 1);
103
+ diag.warn("W04", el, {
104
+ attr: name, expr: a.value,
105
+ problem: `${prefix}: compares the "${field}" attribute against a literal value, not a JS expression`,
106
+ fix: `use show="${a.value}" for an expression, or a literal like ${prefix}:${field}="overview"`,
107
+ });
108
+ }
109
+ } else if (
110
+ !KNOWN_BARE.has(name) &&
111
+ !HTML_GLOBALS.has(name) &&
112
+ !NATIVE_BOOLEAN_SET.has(name) &&
113
+ !/^(data-|aria-|on)/.test(name) &&
114
+ !(name in el)
115
+ ) {
116
+ // Bare (no-colon) attribute that looks like a typo'd Sap directive. Only
117
+ // warn when it is near a known directive (didYouMean returns a match) and
118
+ // is not a native HTML attribute (no matching IDL property), so ordinary
119
+ // HTML attributes never trip it.
120
+ const dym = didYouMean(name, [...KNOWN_BARE]);
121
+ if (dym) {
122
+ diag.warn("W03", el, { attr: name, problem: `unknown "${name}" attribute`, didYouMean: dym });
123
+ }
124
+ }
125
+
126
+ // attr:hidden redirect
127
+ if (name === "attr:hidden") {
128
+ diag.warn("W03", el, { attr: name, problem: "attr:hidden paints the wrong attribute", fix: 'use show="expr"' });
129
+ }
130
+
131
+ // attr:value / attr:checked / attr:selected on a bound control
132
+ if ((name === "attr:value" || name === "attr:checked" || name === "attr:selected") && isBound(el)) {
133
+ halt("E30", el, { attr: name, problem: "attr: on a bound control collides with persist ownership", fix: "remove it; bind owns this attribute" });
134
+ }
135
+
136
+ // effect assigning value/checked on a bound control (rule-b side door)
137
+ if (name === "effect" && isBound(el) && /\b(value|checked)\s*=[^=]/.test(a.value)) {
138
+ halt("E30", el, { attr: name, expr: a.value, problem: "effect writes value/checked on a bound control (writes a stale value with no synthetic event)", fix: "write through onclick + Sap(this) instead" });
139
+ }
140
+
141
+ // text / show on a form control
142
+ if ((name === "text" || name.startsWith("text:") || name === "show") && isControl(el)) {
143
+ if (name === "show") {
144
+ // show on a control is allowed (visibility); only text paints are the error
145
+ } else {
146
+ halt("E18", el, { attr: name, problem: "paint belongs on output elements, not form controls", fix: "move the paint to an <output> or <span>" });
147
+ }
148
+ }
149
+ }
150
+
151
+ // state= declarations
152
+ if (el.hasAttribute("state")) {
153
+ const decls = parseStateDecl(el.getAttribute("state"));
154
+ const seen = new Set();
155
+ for (const d of decls) {
156
+ if (d.name.includes("-")) {
157
+ halt("E08", el, { attr: "state", problem: `field "${d.name}" is not a valid identifier (the hyphen parses as subtraction)`, fix: `rename to ${d.name.replace(/-/g, "")}` });
158
+ }
159
+ if (seen.has(d.name)) halt("E04", el, { attr: "state", problem: `field "${d.name}" is declared twice in one scope` });
160
+ seen.add(d.name);
161
+ if (RESERVED.has(d.name)) halt("E05", el, { attr: "state", problem: `"${d.name}" is a reserved Sap word` });
162
+ if (HTML_GLOBALS.has(d.name)) halt("E06", el, { attr: "state", problem: `"${d.name}" is a global HTML attribute name; pick another field name` });
163
+ // dialog state=open
164
+ if (d.name === "open" && el.tagName === "DIALOG") {
165
+ halt("E33", el, { attr: "state", problem: "dialog open is transient", fix: "use showModal() — declare state= only on <details> or popovers" });
166
+ }
167
+ }
168
+ }
169
+
170
+ // bind matrix mount errors
171
+ if (isBound(el)) {
172
+ if (el.hasAttribute("-")) { /* noop */ }
173
+ if (el.tagName === "INPUT") {
174
+ const t = (el.getAttribute("type") || "text").toLowerCase();
175
+ if (t === "file") halt("E32", el, { attr: "bind", problem: "files never serialize into an HTML file", fix: 'use no-save + effect instead of bind' });
176
+ if (t === "password" && !el.hasAttribute("transient")) {
177
+ halt("E31", el, { attr: "bind", problem: "a password must never serialize into a world-readable file", fix: "add transient to the password input" });
178
+ }
179
+ } else if (el.tagName !== "SELECT" && el.tagName !== "TEXTAREA") {
180
+ // A bind on a leaf reads/writes textContent. On a container (element
181
+ // children) that is not contenteditable, the first write would wipe the
182
+ // subtree — that is never a valid two-way control.
183
+ const ce = el.getAttribute("contenteditable");
184
+ const editable = ce != null && ce !== "false";
185
+ if (!editable && el.children.length > 0) {
186
+ halt("E20", el, { attr: "bind", problem: "bind on a container element is not a control; a write would overwrite its children", fix: "bind a control (input/select/textarea), a contenteditable, or an empty text leaf" });
187
+ }
188
+ }
189
+ }
190
+
191
+ // transient + persist are opposite intents on one control: transient strips
192
+ // the value every pass (never serialized); persist forces it into the saved
193
+ // bytes and wins, leaking exactly what transient promised to drop.
194
+ if (el.hasAttribute("transient") && el.hasAttribute("persist")) {
195
+ halt("E34", el, {
196
+ problem: "transient and persist are opposite: persist forces the live value into the saved bytes, leaking exactly what transient drops",
197
+ fix: "remove one — transient to keep it out of the file, or persist to save it",
198
+ });
199
+ }
200
+
201
+ // orphan item
202
+ if (el.hasAttribute("item") && !el.hasAttribute("template")) {
203
+ const list = el.parentElement;
204
+ if (!list || !list.hasAttribute("items")) {
205
+ halt("E10", el, { problem: "an [item] must be a direct child of an [items] list", fix: "wrap it in a container with items=\"name\"" });
206
+ }
207
+ }
208
+
209
+ // nested items inside detail
210
+ if (el.hasAttribute("items") && inDetail) {
211
+ halt("E17", el, { attr: "items", problem: "nested items inside a detail panel is a v1 mount error (nested collections ship in v1.1)" });
212
+ }
213
+
214
+ // list with trigger-add but no template
215
+ if (el.hasAttribute("items")) {
216
+ const targeted = root.querySelector(`[trigger-add="${el.getAttribute("items")}"]`);
217
+ if (!templateOf(el) && rowsOf(el).length === 0 && targeted) {
218
+ halt("E17", el, { attr: "items", problem: `trigger-add targets items="${el.getAttribute("items")}" but it has no [item template] to clone` });
219
+ }
220
+ }
221
+ });
222
+
223
+ if (halted) {
224
+ diag.halted = true;
225
+ diag.haltReason = diag.errors[0] ? diag.errors[0].code : "halt";
226
+ setBeacon(root, diag.haltReason);
227
+ }
228
+ return !halted;
229
+ }
package/src/mount.js ADDED
@@ -0,0 +1,84 @@
1
+ // Mount one [sap] root: lint, then (if clean) adopt the runtime stylesheet and
2
+ // run the first synchronous pass. A halted app arms no listeners and writes nothing,
3
+ // so its file bytes stay frozen. Returns the app record the scheduler drives.
4
+
5
+ import { Diagnostics } from "./errors.js";
6
+ import { lintApp } from "./lint.js";
7
+ import { runPass } from "./pass.js";
8
+ import { rehydrateControlFromAttributes } from "./control-serialize.js";
9
+
10
+ const STYLE_ID = "sap-styles";
11
+ const STYLE_TEXT =
12
+ "[item][template]{display:none!important}" +
13
+ "[hidden]{display:none!important}" +
14
+ "[sap-error]{outline:2px solid #e5484d;outline-offset:1px}";
15
+
16
+ const styledDocs = new WeakSet();
17
+
18
+ // Apply Sap's presentation rules without leaving a node in the saved file:
19
+ // adoptedStyleSheets live in the CSSOM, not the DOM tree, so a Hyperclay save
20
+ // never serializes them. Engines without constructable stylesheets fall back to
21
+ // a <style> node, which does serialize but keeps the rules working everywhere.
22
+ function injectStyles(doc) {
23
+ if (styledDocs.has(doc)) return;
24
+ const view = doc.defaultView;
25
+ const SheetCtor = view && view.CSSStyleSheet;
26
+ if (SheetCtor && "adoptedStyleSheets" in doc) {
27
+ try {
28
+ const sheet = new SheetCtor();
29
+ sheet.replaceSync(STYLE_TEXT);
30
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
31
+ styledDocs.add(doc);
32
+ return;
33
+ } catch {
34
+ // Older engine: fall through to the serialized <style> node.
35
+ }
36
+ }
37
+ if (!doc.getElementById(STYLE_ID)) {
38
+ const style = doc.createElement("style");
39
+ style.id = STYLE_ID;
40
+ style.textContent = STYLE_TEXT;
41
+ (doc.head || doc.documentElement).appendChild(style);
42
+ }
43
+ styledDocs.add(doc);
44
+ }
45
+
46
+ let appSeq = 0;
47
+
48
+ function now() {
49
+ return typeof performance !== "undefined" && performance.now ? performance.now() : 0;
50
+ }
51
+
52
+ export function mountApp(root) {
53
+ const name = root.id || root.getAttribute("sap") || `app-${appSeq++}`;
54
+ const diag = new Diagnostics(name);
55
+ const appRec = {
56
+ root, name, diag,
57
+ _state: null, _stats: null, _lastPass: null,
58
+ _passes: 0, _broken: false, _mountMs: 0, _mountWrites: 0,
59
+ };
60
+
61
+ const ok = lintApp(root, diag);
62
+ if (!ok) {
63
+ appRec.halted = true;
64
+ return appRec;
65
+ }
66
+
67
+ injectStyles(root.ownerDocument);
68
+
69
+ // Restore any persisted textarea values from data-value before the first read,
70
+ // so a [persist] textarea round-trips on load without a separate save step.
71
+ for (const ta of root.querySelectorAll("textarea[data-value]")) rehydrateControlFromAttributes(ta);
72
+
73
+ const t0 = now();
74
+ runPass(appRec, { trigger: "mount" });
75
+ appRec._mountMs = now() - t0;
76
+ appRec._mountWrites = appRec._stats ? appRec._stats.writes : 0;
77
+ if (appRec._mountWrites > 0) {
78
+ diag.warn("W30", root, {
79
+ problem: `${appRec._mountWrites} paint(s) ran at mount; the saved file was out of sync with its declared state`,
80
+ fix: "re-save once so the file mounts clean (a settled file writes nothing)",
81
+ });
82
+ }
83
+ return appRec;
84
+ }
@@ -0,0 +1,195 @@
1
+ // Universal DOM-mutation reactivity: sapjs re-derives when the page changes by ANY
2
+ // means, not just its own delegated events. Two interchangeable backends sit behind
3
+ // one MutationSource seam:
4
+ //
5
+ // - hubSource: when window.hyperclay.Mutation is present, ride the platform's
6
+ // central, pause-gated observer. The hub already coordinates the
7
+ // live-sync / undo pause windows, so we inherit them for free.
8
+ // - nativeSource: standalone, a sapjs-owned MutationObserver on document.body.
9
+ //
10
+ // Self-paint suppression: every pass runs inside withDomMutationPaused(), so a pass's
11
+ // own DOM writes are vacuumed away from the bridge and never schedule another pass:
12
+ // - hub: Mutation.pause()/resume() — the hub drains the pass's records to its
13
+ // NON-pausable consumers only, so our pausable subscription never sees them.
14
+ // - native: observer.takeRecords() at the pause boundary discards the pass's records
15
+ // before the browser delivers them.
16
+ //
17
+ // Exactly one source is active at a time. A native source CEDES to a hub that arrives
18
+ // late (teardown + re-subscribe), so the two never observe at once. Degrades to a
19
+ // plain no-op when neither a hub nor a MutationObserver is present.
20
+
21
+ let activeSource = null;
22
+ let installed = false;
23
+ let lateHubListener = null;
24
+
25
+ function hubSource(M) {
26
+ let unsub = null;
27
+ return {
28
+ kind: "hub",
29
+ subscribe(onChanges) {
30
+ // pausable (the default) is load-bearing: it is WHY the hub's resume()-drain
31
+ // routes our own pass's writes to non-pausable consumers only, excluding us.
32
+ const ret = M.onAnyChange({ debounce: 0, require: "observed" }, onChanges);
33
+ if (typeof ret === "function") unsub = ret;
34
+ },
35
+ suppress(fn) {
36
+ M.pause(); // bridges undo.pause + gates the pausable lanes (autosave included)
37
+ try { return fn(); } finally { M.resume(); }
38
+ },
39
+ teardown() { if (unsub) { try { unsub(); } catch { /* ignore */ } unsub = null; } },
40
+ };
41
+ }
42
+
43
+ function nativeSource() {
44
+ let observer = null;
45
+ return {
46
+ kind: "native",
47
+ subscribe(onChanges) {
48
+ observer = new MutationObserver((records) => onChanges(changesFromRecords(records)));
49
+ const target = (typeof document !== "undefined" && document.body) ||
50
+ (typeof document !== "undefined" && document.documentElement);
51
+ if (target) {
52
+ observer.observe(target, { childList: true, subtree: true, attributes: true, characterData: true });
53
+ }
54
+ },
55
+ suppress(fn) {
56
+ // No hub to gate us: drop our own pass's records before the browser delivers them.
57
+ // Pause undo too if it somehow exists hub-less (presence-guarded; normally it does not).
58
+ const u = typeof window !== "undefined" && window.hyperclay && window.hyperclay.undo;
59
+ if (u && u.pause) u.pause();
60
+ try {
61
+ return fn();
62
+ } finally {
63
+ if (observer) observer.takeRecords();
64
+ if (u && u.resume) u.resume();
65
+ }
66
+ },
67
+ teardown() { if (observer) { observer.disconnect(); observer = null; } },
68
+ };
69
+ }
70
+
71
+ // Normalize MutationRecords into the hub's change shape { type, element, parent },
72
+ // mirroring the hub's added-subtree expansion and removed-node parent resolution, and
73
+ // skipping sap-inert subtrees (sap-ignore / template) — the standalone analogue of the
74
+ // hub's intake isInert drop.
75
+ function changesFromRecords(records) {
76
+ const out = [];
77
+ for (const rec of records) {
78
+ if (rec.type === "attributes") {
79
+ const el = rec.target;
80
+ if (el && el.nodeType === 1 && !inertSubtree(el)) out.push({ type: "attribute", element: el });
81
+ } else if (rec.type === "characterData") {
82
+ const el = rec.target && rec.target.parentElement;
83
+ if (el && !inertSubtree(el)) out.push({ type: "characterData", element: el });
84
+ } else if (rec.type === "childList") {
85
+ for (const node of rec.addedNodes) {
86
+ if (node.nodeType !== 1 || inertSubtree(node)) continue;
87
+ out.push({ type: "add", element: node });
88
+ if (node.querySelectorAll) {
89
+ for (const desc of node.querySelectorAll("*")) {
90
+ if (!inertSubtree(desc)) out.push({ type: "add", element: desc });
91
+ }
92
+ }
93
+ }
94
+ for (const node of rec.removedNodes) {
95
+ if (node.nodeType !== 1) continue;
96
+ out.push({ type: "remove", element: node, parent: rec.target });
97
+ }
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function inertSubtree(el) {
104
+ return !!(el.closest && el.closest("[sap-ignore],[template]"));
105
+ }
106
+
107
+ // Shared by both backends: resolve each change to its owning [sap] app and schedule it
108
+ // once. Prune apps whose roots have left the DOM (keeps the per-batch cost O(apps), not
109
+ // a full-document re-scan), and re-mount when a fresh [sap] root is injected.
110
+ function scheduleAffectedApps(runtime, changes) {
111
+ runtime.pruneDisconnected();
112
+ const affected = new Set();
113
+ let needRemount = false;
114
+ for (const c of changes) {
115
+ const target = c.type === "remove" ? c.parent : c.element; // a removed node is detached
116
+ const app = target && runtime.appFor(target);
117
+ if (app) affected.add(app);
118
+ if (c.type === "add" && c.element && c.element.nodeType === 1 &&
119
+ c.element.hasAttribute && c.element.hasAttribute("sap") &&
120
+ !(runtime.isRegistered && runtime.isRegistered(c.element))) {
121
+ needRemount = true; // an injected [sap] root
122
+ }
123
+ }
124
+ if (needRemount) runtime.remountIfPresent();
125
+ for (const app of affected) if (app.root.isConnected) runtime.schedule(app, "mutation");
126
+ }
127
+
128
+ // The single suppression entry point. pass.js wraps the whole pass in this; it delegates
129
+ // to the active source. With no source yet (pre-install / no DOM), it preserves the
130
+ // historical undo-pause-if-present behavior so derived writes still stay off the undo stack.
131
+ export function withDomMutationPaused(fn) {
132
+ if (activeSource) return activeSource.suppress(fn);
133
+ const u = typeof window !== "undefined" && window.hyperclay && window.hyperclay.undo;
134
+ if (u && u.pause && u.resume) {
135
+ u.pause();
136
+ try { return fn(); } finally { u.resume(); }
137
+ }
138
+ return fn();
139
+ }
140
+
141
+ function activate(source, runtime) {
142
+ installed = true;
143
+ activeSource = source;
144
+ source.subscribe((changes) => scheduleAffectedApps(runtime, changes));
145
+ }
146
+
147
+ // Idempotent. Called from mountAll/mount; re-arms after resetMutationBridge().
148
+ export function installMutationBridge(runtime) {
149
+ if (installed) return;
150
+ if (typeof window === "undefined" || typeof document === "undefined") return;
151
+ const w = window;
152
+ const M = w.hyperclay && w.hyperclay.Mutation;
153
+
154
+ if (M && typeof M.onAnyChange === "function") { activate(hubSource(M), runtime); return; }
155
+
156
+ // No usable Mutation hub yet. We do NOT treat a bare window.hyperclay as a promise that
157
+ // a hub is coming: another library may merely own that namespace (e.g. a consent shim)
158
+ // and never publish a Mutation hub, which would strand us in a permanent wait with dead
159
+ // reactivity. So observe natively now and CEDE to a real hub if one arrives later
160
+ // (teardown + re-subscribe on the document-dispatched hyperclay:mutation-ready — a
161
+ // window-dispatched event is ignored, matching the hub's own target). The handoff is
162
+ // safe: passes are idempotent, so the brief pre-hub native window costs at most a little
163
+ // redundant work, never correctness, and the two never observe simultaneously.
164
+ if (typeof MutationObserver === "function") {
165
+ activate(nativeSource(), runtime);
166
+ lateHubListener = function onLateHub() {
167
+ const M2 = w.hyperclay && w.hyperclay.Mutation;
168
+ if (!M2) return;
169
+ document.removeEventListener("hyperclay:mutation-ready", lateHubListener);
170
+ lateHubListener = null;
171
+ if (activeSource) activeSource.teardown();
172
+ installed = false;
173
+ activate(hubSource(M2), runtime);
174
+ };
175
+ document.addEventListener("hyperclay:mutation-ready", lateHubListener);
176
+ }
177
+ // else: no DOM observer (SSR / ancient) -> event-only, exactly as before.
178
+ }
179
+
180
+ // Tear down and re-arm. Sap._reset() calls this so each test gets a fresh bridge that
181
+ // reflects the current window.hyperclay; production never calls it.
182
+ export function resetMutationBridge() {
183
+ if (activeSource) { try { activeSource.teardown(); } catch { /* ignore */ } }
184
+ if (lateHubListener && typeof document !== "undefined") {
185
+ document.removeEventListener("hyperclay:mutation-ready", lateHubListener);
186
+ }
187
+ activeSource = null;
188
+ installed = false;
189
+ lateHubListener = null;
190
+ }
191
+
192
+ // Test hook: which backend is active ("hub" | "native" | null).
193
+ export function _activeSourceKind() {
194
+ return activeSource ? activeSource.kind : null;
195
+ }