@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/LICENSE +21 -0
- package/README.md +350 -0
- package/dist/sap.js +2490 -0
- package/dist/sap.min.js +6 -0
- package/package.json +52 -0
- package/src/actions.js +310 -0
- package/src/carrier.js +128 -0
- package/src/compile.js +91 -0
- package/src/control-serialize.js +85 -0
- package/src/debug.js +0 -0
- package/src/dom.js +182 -0
- package/src/errors.js +190 -0
- package/src/helpers.js +119 -0
- package/src/lint.js +229 -0
- package/src/mount.js +84 -0
- package/src/mutation-bridge.js +195 -0
- package/src/pass.js +472 -0
- package/src/platform.js +78 -0
- package/src/sap.js +178 -0
- package/src/scheduler.js +65 -0
- package/src/scope.js +265 -0
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
|
+
}
|