@excom/quark 0.1.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.
Files changed (130) hide show
  1. package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +4 -0
  12. package/language.ts +7 -0
  13. package/package.json +45 -0
  14. package/rush-logs/quark.apply-exports.cache.log +1 -0
  15. package/rush-logs/quark.apply-exports.log +1 -0
  16. package/rush-logs/quark.build_package-metas.cache.log +1 -0
  17. package/rush-logs/quark.build_package-metas.log +1 -0
  18. package/src/actions.ts +445 -0
  19. package/src/ast.ts +187 -0
  20. package/src/bindings.ts +115 -0
  21. package/src/builtin-modules.ts +401 -0
  22. package/src/constants.ts +47 -0
  23. package/src/cycle-check.ts +121 -0
  24. package/src/devtools-hook.ts +303 -0
  25. package/src/element-api.ts +109 -0
  26. package/src/evaluator.ts +317 -0
  27. package/src/form-controls.ts +69 -0
  28. package/src/language-tables.ts +137 -0
  29. package/src/language.ts +1225 -0
  30. package/src/observer.ts +110 -0
  31. package/src/paint.ts +305 -0
  32. package/src/properties.ts +818 -0
  33. package/src/props.ts +94 -0
  34. package/src/quark-element.ts +7 -0
  35. package/src/quark-internal.ts +365 -0
  36. package/src/quark.ts +817 -0
  37. package/src/resolvers.ts +867 -0
  38. package/src/rule.ts +800 -0
  39. package/src/scope-id.ts +47 -0
  40. package/src/selector-utils.ts +430 -0
  41. package/src/settle.ts +117 -0
  42. package/src/types.ts +155 -0
  43. package/src/utils.ts +79 -0
  44. package/src/variables.ts +359 -0
  45. package/support/demos/css-variables.html +18 -0
  46. package/support/demos/dispatch.html +20 -0
  47. package/support/demos/events.html +9 -0
  48. package/support/demos/iterate.html +13 -0
  49. package/support/demos/js-api.html +17 -0
  50. package/support/demos/provider-list.html +16 -0
  51. package/support/demos/toggle-content.html +14 -0
  52. package/support/demos/unset.html +12 -0
  53. package/support/demos/view-transition.html +36 -0
  54. package/support/docs/ATTRIBUTES.md +37 -0
  55. package/support/docs/AT_RULES.md +45 -0
  56. package/support/docs/BUILTINS.md +57 -0
  57. package/support/docs/CONTENT.md +40 -0
  58. package/support/docs/CSS_VARIABLES.md +16 -0
  59. package/support/docs/DECLARATIONS.md +26 -0
  60. package/support/docs/DELAY.md +37 -0
  61. package/support/docs/DIAGNOSTICS.md +32 -0
  62. package/support/docs/DISPATCH.md +81 -0
  63. package/support/docs/ELEMENT_PROPERTIES.md +34 -0
  64. package/support/docs/EXPRESSIONS.md +31 -0
  65. package/support/docs/INTERNAL.md +8 -0
  66. package/support/docs/JS_API.md +53 -0
  67. package/support/docs/JS_WRITES.md +36 -0
  68. package/support/docs/LIMITATIONS.md +26 -0
  69. package/support/docs/LOOP_GUARD.md +30 -0
  70. package/support/docs/METHODS.md +43 -0
  71. package/support/docs/MODULES.md +111 -0
  72. package/support/docs/NO_REVERSION.md +18 -0
  73. package/support/docs/ON.md +87 -0
  74. package/support/docs/REACTIVITY.md +24 -0
  75. package/support/docs/README.md +143 -0
  76. package/support/docs/SELECTORS.md +87 -0
  77. package/support/docs/SHEETS.md +67 -0
  78. package/support/docs/SYNTAX.md +36 -0
  79. package/support/docs/USE.md +24 -0
  80. package/support/docs/VALUES.md +27 -0
  81. package/support/docs/VARIABLES.md +47 -0
  82. package/support/docs/VIEW_TRANSITION.md +64 -0
  83. package/support/docs-sections.json +65 -0
  84. package/support/package-meta.json +208 -0
  85. package/support/scripts/build-language-docs.mjs +196 -0
  86. package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
  87. package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
  88. package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
  89. package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
  90. package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
  91. package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
  92. package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
  93. package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
  94. package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
  95. package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
  96. package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
  97. package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
  98. package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
  99. package/support/tests/actions.test.ts +437 -0
  100. package/support/tests/builtin-modules.test.ts +337 -0
  101. package/support/tests/builtins.test.ts +189 -0
  102. package/support/tests/css-variables.view.test.ts +35 -0
  103. package/support/tests/delay.test.ts +244 -0
  104. package/support/tests/devtools-hook.test.ts +496 -0
  105. package/support/tests/diagnostics.test.ts +224 -0
  106. package/support/tests/dispatch.view.test.ts +43 -0
  107. package/support/tests/element-api.test.ts +202 -0
  108. package/support/tests/evaluator.test.ts +237 -0
  109. package/support/tests/events.view.test.ts +40 -0
  110. package/support/tests/form-controls.test.ts +452 -0
  111. package/support/tests/helpers.ts +354 -0
  112. package/support/tests/internals.test.ts +568 -0
  113. package/support/tests/iterate.view.test.ts +40 -0
  114. package/support/tests/js-api.view.test.ts +45 -0
  115. package/support/tests/language-docs.test.ts +147 -0
  116. package/support/tests/lifecycle.test.ts +440 -0
  117. package/support/tests/loop-guard.test.ts +323 -0
  118. package/support/tests/provider-list.view.test.ts +47 -0
  119. package/support/tests/quark-features.test.ts +2887 -0
  120. package/support/tests/quark-with-providers.test.ts +169 -0
  121. package/support/tests/quark.test.ts +359 -0
  122. package/support/tests/resolvers.test.ts +346 -0
  123. package/support/tests/selector-analysis.test.ts +281 -0
  124. package/support/tests/selectors.test.ts +670 -0
  125. package/support/tests/toggle-content.view.test.ts +40 -0
  126. package/support/tests/unset.view.test.ts +42 -0
  127. package/support/tests/view-helpers.ts +81 -0
  128. package/support/tests/view-transition.test.ts +1069 -0
  129. package/support/tests/view-transition.view.test.ts +76 -0
  130. package/tsconfig.json +5 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Keeps `src/language.ts` (the language metadata) in step with the engine
3
+ * and with the generated tables on the doc pages (`support/docs/*.md`).
4
+ */
5
+ import {
6
+ ALLOWED_METHODS,
7
+ AT_RULES,
8
+ BUILTIN_FUNCTIONS,
9
+ DECLARATION_KINDS,
10
+ PSEUDO_CLASSES,
11
+ VALUE_KEYWORDS,
12
+ } from "../../src/language";
13
+ import {
14
+ ALLOWED_METHOD_NAMES,
15
+ METHOD_ALLOWLIST,
16
+ PSEUDO_CLASS_SUPPORT,
17
+ } from "../../src/language-tables";
18
+ import * as languageEntry from "../../language";
19
+ import { VALUE_MAP } from "../../src/constants";
20
+ import { FIELD_RESOLVERS } from "../../src/resolvers";
21
+ import { BUILTIN_NAMES } from "../../src/variables";
22
+ import {
23
+ readLanguageDocs,
24
+ renderLanguageDocs,
25
+ } from "../scripts/build-language-docs.mjs";
26
+ import {
27
+ describe,
28
+ expect,
29
+ it,
30
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
31
+ import { parse } from "@excom/quark-parser";
32
+ import { readdirSync, readFileSync } from "node:fs";
33
+ import path from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ const DOCS_DIR = path.resolve(
37
+ path.dirname(fileURLToPath(import.meta.url)),
38
+ "../docs"
39
+ );
40
+
41
+ /** Every consumer doc page (README + topic pages; INTERNAL.md is contributor notes). */
42
+ const README = readdirSync(DOCS_DIR)
43
+ .filter((f) => f.endsWith(".md") && f !== "INTERNAL.md")
44
+ .sort()
45
+ .map((f) => readFileSync(path.join(DOCS_DIR, f), "utf8"))
46
+ .join("\n");
47
+
48
+ const names = (entries: readonly { name: string }[]) =>
49
+ [...entries.map((e) => e.name)].sort();
50
+
51
+ describe("language metadata", () => {
52
+ it("documents exactly the built-in names the scope resolves", () => {
53
+ expect(names(BUILTIN_FUNCTIONS)).toEqual([...BUILTIN_NAMES].sort());
54
+ });
55
+
56
+ it("documents exactly the value keywords", () => {
57
+ expect(names(VALUE_KEYWORDS)).toEqual(Object.keys(VALUE_MAP).sort());
58
+ });
59
+
60
+ it("documents exactly the runtime method allowlist", () => {
61
+ expect([...METHOD_ALLOWLIST].sort()).toEqual(names(ALLOWED_METHODS));
62
+ expect([...ALLOWED_METHOD_NAMES].sort()).toEqual(names(ALLOWED_METHODS));
63
+ expect(new Set(names(ALLOWED_METHODS)).size).toBe(ALLOWED_METHODS.length);
64
+ });
65
+
66
+ it("documents exactly the pseudo-classes the runtime classifies", () => {
67
+ expect(names(PSEUDO_CLASSES)).toEqual(
68
+ Object.keys(PSEUDO_CLASS_SUPPORT).sort()
69
+ );
70
+ for (const doc of PSEUDO_CLASSES) {
71
+ const support = PSEUDO_CLASS_SUPPORT[doc.name];
72
+ expect(doc.kind, doc.name).toBe(support.kind);
73
+ expect(doc.attributes, doc.name).toEqual(support.attributes);
74
+ expect(doc.syntax.startsWith(":")).toBe(true);
75
+ expect(doc.description.length).toBeGreaterThan(0);
76
+ }
77
+ });
78
+
79
+ it("ships the documented metadata on the language entry, not the main one", async () => {
80
+ expect(languageEntry.PSEUDO_CLASSES).toBe(PSEUDO_CLASSES);
81
+ expect(languageEntry.METHOD_ALLOWLIST).toBe(METHOD_ALLOWLIST);
82
+ const main = await import("../../index");
83
+ expect("QuarkLanguage" in main).toBe(false);
84
+ });
85
+
86
+ it("documents the listener at-rules the resolver executes", () => {
87
+ const syntaxes = AT_RULES.map((a) => a.syntax);
88
+ expect(syntaxes.some((s) => s.startsWith("@on "))).toBe(true);
89
+ expect(syntaxes.some((s) => s.startsWith("@on <event> (option"))).toBe(true);
90
+ expect(syntaxes.some((s) => s.startsWith("@dispatch "))).toBe(true);
91
+ expect(syntaxes.some((s) => s.startsWith("@command "))).toBe(true);
92
+ expect(syntaxes.some((s) => s.startsWith("@off "))).toBe(false);
93
+ expect(syntaxes.some((s) => s.startsWith("@use "))).toBe(true);
94
+ expect(syntaxes.some((s) => s.startsWith("@scope"))).toBe(true);
95
+ expect(syntaxes.some((s) => s.startsWith("@view-transition "))).toBe(true);
96
+ expect(
97
+ syntaxes.some((s) => s.startsWith("@view-transition (option"))
98
+ ).toBe(true);
99
+ expect(Object.keys(FIELD_RESOLVERS)).toContain("listener");
100
+ });
101
+
102
+ it("documents every named field resolver as a declaration kind", () => {
103
+ const keys = DECLARATION_KINDS.map((d) => d.key);
104
+ for (const resolver of Object.keys(FIELD_RESOLVERS)) {
105
+ // `variable` / `listener` / `styleProperty` / `attribute` are key-shape
106
+ // dispatches documented under their shapes
107
+ if (
108
+ ["variable", "listener", "styleProperty", "attribute"].includes(
109
+ resolver
110
+ )
111
+ ) {
112
+ continue;
113
+ }
114
+ expect(keys).toContain(resolver);
115
+ }
116
+ expect(keys).toEqual(
117
+ expect.arrayContaining([
118
+ "$name",
119
+ "--name",
120
+ "<anything else>",
121
+ ])
122
+ );
123
+ });
124
+ });
125
+
126
+ describe("doc pages language reference", () => {
127
+ it("has every generated region up to date (run support/scripts/build-language-docs.mjs)", () => {
128
+ const current = readLanguageDocs(README);
129
+ const expected = renderLanguageDocs();
130
+ for (const name of Object.keys(expected)) {
131
+ expect(current[name], `region ${name}`).toBe(expected[name]);
132
+ }
133
+ });
134
+
135
+ it("parses every quark example", () => {
136
+ const re = /^```quark[^\n]*\n([\s\S]*?)^```/gm;
137
+ let match: RegExpExecArray | null;
138
+ let count = 0;
139
+ while ((match = re.exec(README))) {
140
+ // tutorial blocks elide bodies with `…`; those are not meant to parse
141
+ if (match[1].includes("\u2026")) continue;
142
+ count++;
143
+ expect(() => parse(match![1])).not.toThrow();
144
+ }
145
+ expect(count).toBeGreaterThan(5);
146
+ });
147
+ });
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Sheet lifecycle and scheduling edge cases: registration guards, hosts
3
+ * that disappear, queued elements that leave the DOM, fan-out ordering of
4
+ * mixed mutation kinds, prop() subscriptions on late-defined elements, and
5
+ * verbose logging paths.
6
+ */
7
+ import { Quark } from "../../index";
8
+ import { subscribeProp } from "../../src/props";
9
+ import { QuarkLogger } from "../../src/utils";
10
+ import {
11
+ afterEach,
12
+ describe,
13
+ expect,
14
+ fixture,
15
+ it,
16
+ vi,
17
+ wait,
18
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
19
+ import { createSheet, flush, mount, unregisterAll } from "./helpers";
20
+
21
+ /** A WeakRef whose target is already gone (GC cannot be forced). */
22
+ const deadRef = <T extends object>(): WeakRef<T> => {
23
+ const ref = Object.create(WeakRef.prototype);
24
+ ref.deref = () => undefined;
25
+ return ref;
26
+ };
27
+
28
+ describe("lifecycle", () => {
29
+ afterEach(() => {
30
+ unregisterAll();
31
+ document.body.innerHTML = "";
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe("registration", () => {
36
+ it("refuses to register twice", async () => {
37
+ const { quark, register } = createSheet(`<p></p>`, `p { data-x: "1"; }`);
38
+ register();
39
+ expect(() => register()).toThrow("already registered");
40
+ await flush();
41
+ });
42
+
43
+ it("skips the first run when unregistered while @use modules load", async () => {
44
+ const original = Quark.moduleLoader;
45
+ Quark.moduleLoader = () =>
46
+ new Promise((resolve) =>
47
+ setTimeout(() => resolve({ greet: () => "hi" }), 5)
48
+ );
49
+ const { root, quark, register } = createSheet(
50
+ `<p bind-x></p>`,
51
+ `@use "/slow" as *;
52
+ [bind-x] { content: greet(); }`
53
+ );
54
+ register();
55
+ quark.unregister();
56
+ await wait(20);
57
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("");
58
+ Quark.moduleLoader = original;
59
+ });
60
+
61
+ it('wraps a sheet that only imports and derives an empty namespace from "/"', async () => {
62
+ const original = Quark.moduleLoader;
63
+ Quark.moduleLoader = async () => ({ hi: () => "x" });
64
+ const onlyUse = new Quark({
65
+ src: `@use "/x" as *;`,
66
+ options: { isScoped: true },
67
+ });
68
+ expect(onlyUse.src).toBe(`@use "/x" as *;@scope{}`);
69
+ const { root, register } = createSheet(
70
+ `<p bind-x></p>`,
71
+ `@use "/";
72
+ [bind-x] { content: "ok"; }`
73
+ );
74
+ register();
75
+ await flush();
76
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("ok");
77
+ Quark.moduleLoader = original;
78
+ });
79
+
80
+ it("logs errors instead of throwing when the host is gone", async () => {
81
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
82
+ const original = Quark.moduleLoader;
83
+ Quark.moduleLoader = async () => ({});
84
+ const { root, quark, register } = createSheet(
85
+ `<p bind-x></p>`,
86
+ `@use "/x" as *;
87
+ [bind-x] { content: $n; data-len: prop("value"); }`
88
+ );
89
+ register();
90
+ const realHost = quark.host;
91
+ quark.host = deadRef();
92
+ await flush();
93
+ // runAll + observer creation both report the missing host
94
+ expect(error.mock.calls.length).toBeGreaterThanOrEqual(2);
95
+ expect(quark.observer).toBeNull();
96
+ const p = root.querySelector("[bind-x]") as HTMLElement;
97
+ quark.applyBindingChange(p, { name: "$n" });
98
+ quark.handlePropChange(
99
+ new CustomEvent("quark-prop-change", { detail: { names: ["value"] } })
100
+ );
101
+ quark.runElement(p, ["RUN_ALL"]);
102
+ // unregistering without a host skips the publication and marker release
103
+ quark.unregister();
104
+ expect(quark.isRegistered).toBe(false);
105
+ quark.host = realHost;
106
+ Quark.moduleLoader = original;
107
+ });
108
+
109
+ it("runs once without listening or observing when observe is off", async () => {
110
+ const { root, quark, register } = createSheet(
111
+ `<p bind-x data-mode="a"></p>`,
112
+ `[bind-x][data-mode="a"] { content: "a"; }
113
+ [bind-x][data-mode="b"] { content: "b"; }`,
114
+ undefined,
115
+ { observe: false }
116
+ );
117
+ register();
118
+ await flush();
119
+ const p = root.querySelector("[bind-x]")!;
120
+ expect(p.textContent).toBe("a");
121
+ expect(quark.observer).toBeNull();
122
+ p.setAttribute("data-mode", "b");
123
+ await flush();
124
+ expect(p.textContent).toBe("a");
125
+ });
126
+
127
+ it("loads @use imports written inside a rule block", async () => {
128
+ const original = Quark.moduleLoader;
129
+ Quark.moduleLoader = async () => ({ greet: () => "hi" });
130
+ const { root, register } = createSheet(
131
+ `<p bind-x></p>`,
132
+ `[bind-x] { @use "/x" as *; content: greet(); }`
133
+ );
134
+ register();
135
+ await flush();
136
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("hi");
137
+ Quark.moduleLoader = original;
138
+ });
139
+
140
+ it("ignores prop subscriptions before registration", () => {
141
+ const quark = new Quark({ src: `p { data-x: prop("value"); }` });
142
+ const el = document.createElement("input");
143
+ expect(() => quark.subscribeProp(el, "value")).not.toThrow();
144
+ });
145
+ });
146
+
147
+ describe("scheduling", () => {
148
+ it("drops queued elements that left the DOM before the run", async () => {
149
+ const { quark } = mount(`<p></p>`, `p { data-x: "1"; }`);
150
+ await flush();
151
+ const run = vi.spyOn(quark, "run");
152
+ const detached = document.createElement("p");
153
+ quark.queueRunRules({ element: detached, attribute: "NEW_SELF" });
154
+ await flush();
155
+ expect(run).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("orders mixed mutation kinds per element deterministically", async () => {
159
+ const { root, quark } = mount(
160
+ `<div bind-a><span></span></div><div bind-b><span></span></div>
161
+ <div bind-c><span></span></div><div bind-d><span></span></div>
162
+ <div bind-e><span></span></div>`,
163
+ `div span { data-x: "1"; }`
164
+ );
165
+ await flush();
166
+ root.querySelectorAll("span").forEach((s) => s.removeAttribute("data-x"));
167
+ const [a, b, c, d, e] = ["a", "b", "c", "d", "e"].map(
168
+ (n) => root.querySelector(`div[bind-${n}]`) as HTMLElement
169
+ );
170
+ // a connected element outside the host is never fanned out from
171
+ const outside = document.createElement("div");
172
+ outside.innerHTML = "<span></span>";
173
+ document.body.appendChild(outside);
174
+ quark.ELEMENTS_TO_MATCH.set(a, new Set(["data-y", "RUN_ALL"]));
175
+ quark.ELEMENTS_TO_MATCH.set(
176
+ b,
177
+ new Set(["data-y", "NEW_SELF", "content"])
178
+ );
179
+ quark.ELEMENTS_TO_MATCH.set(
180
+ c,
181
+ new Set(["content", "NEW_SELF", "data-y"])
182
+ );
183
+ quark.ELEMENTS_TO_MATCH.set(d, new Set(["RUN_ALL", "content", "data-y"]));
184
+ quark.ELEMENTS_TO_MATCH.set(e, new Set(["data-y", "data-z", "content"]));
185
+ quark.ELEMENTS_TO_MATCH.set(
186
+ outside,
187
+ new Set(["NEW_SELF", "content", "$n"])
188
+ );
189
+ quark.runRules();
190
+ await flush();
191
+ expect(
192
+ [...root.querySelectorAll("span")].map((s) => s.getAttribute("data-x"))
193
+ ).toEqual(["1", "1", "1", "1", "1"]);
194
+ expect(outside.querySelector("span")?.hasAttribute("data-x")).toBe(false);
195
+ });
196
+
197
+ it("fans out from a mid-path ancestor gate", async () => {
198
+ const { root } = mount(
199
+ `<div><article><span bind-x></span></article></div>`,
200
+ `div[data-a] article[data-b] [bind-x] { content: "deep"; }`
201
+ );
202
+ await flush();
203
+ root.querySelector("div:not(#sheet)")!.setAttribute("data-a", "");
204
+ await flush();
205
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("");
206
+ root.querySelector("article")!.setAttribute("data-b", "");
207
+ await flush();
208
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("deep");
209
+ });
210
+
211
+ it("re-runs only the readers of a binding written from JS, not sibling listeners", async () => {
212
+ const handler = vi.fn();
213
+ const { root, quark } = mount(
214
+ `<article><button type="button" bind-x></button></article>`,
215
+ `article { $n: 1; }
216
+ [bind-x] { @on click (handle: handler); content: $n; }`,
217
+ { handler }
218
+ );
219
+ await flush();
220
+ const article = root.querySelector("article") as HTMLElement;
221
+ const listenerRun = vi.spyOn(quark.rules[1].listeners[0], "_run");
222
+ article.quark.setProperty("$n", 2);
223
+ await flush();
224
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("2");
225
+ expect(listenerRun).not.toHaveBeenCalled();
226
+ // a write queued outside any run is announced by the next run
227
+ quark.queueBindingChange(article, { name: "$n" });
228
+ quark.runElement(article, ["RUN_ALL"]);
229
+ await flush();
230
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("2");
231
+ });
232
+
233
+ it("skips the property whose own key triggered the run", async () => {
234
+ const { root } = mount(
235
+ `<p bind-x data-a="a"></p>`,
236
+ `[bind-x] { data-b: attr("data-a"); data-a: "x"; }`
237
+ );
238
+ await flush();
239
+ const p = root.querySelector("[bind-x]")!;
240
+ expect(p.getAttribute("data-a")).toBe("x");
241
+ expect(p.getAttribute("data-b")).toBe("x");
242
+ p.setAttribute("data-a", "y");
243
+ await flush();
244
+ // data-b re-read the attribute; data-a itself was not re-applied
245
+ expect(p.getAttribute("data-b")).toBe("y");
246
+ expect(p.getAttribute("data-a")).toBe("y");
247
+ });
248
+
249
+ it("fans out from a non-host ancestor in a global sheet", async () => {
250
+ const { root } = mount(
251
+ `<article><span bind-x></span></article>`,
252
+ `article[data-open] [bind-x] { content: "open"; }`,
253
+ undefined,
254
+ { isScoped: false }
255
+ );
256
+ await flush();
257
+ root.querySelector("article")!.setAttribute("data-open", "");
258
+ await flush();
259
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("open");
260
+ });
261
+
262
+ it("matches every selector of a comma list", async () => {
263
+ const { root } = mount(
264
+ `<p></p><span></span>`,
265
+ `p, span { data-x: "1"; }`
266
+ );
267
+ await flush();
268
+ expect(root.querySelector("p")?.getAttribute("data-x")).toBe("1");
269
+ expect(root.querySelector("span")?.getAttribute("data-x")).toBe("1");
270
+ });
271
+ });
272
+
273
+ describe("binding change routing", () => {
274
+ it("ignores events without a name, from outside the host, or already-run readers", async () => {
275
+ const { root, quark } = mount(
276
+ `<article><p bind-x></p></article>`,
277
+ `article { $n: 1; }
278
+ [bind-x] { content: $n; }`
279
+ );
280
+ await flush();
281
+ const p = root.querySelector("[bind-x]") as HTMLElement;
282
+ const run = vi.spyOn(quark, "run");
283
+ quark.applyBindingChange(root.querySelector("article") as HTMLElement);
284
+ const outside = document.createElement("div");
285
+ document.body.appendChild(outside);
286
+ quark.applyBindingChange(outside, { name: "$n" });
287
+ expect(run).not.toHaveBeenCalled();
288
+ // a deferred-write trace showing the reader already ran after the write
289
+ const reader = quark.rules[1].attributes[0];
290
+ quark.applyBindingChange(p, {
291
+ name: "$n",
292
+ trace: {
293
+ seq: 3,
294
+ ran: new Map([[p, new Map([[reader, 3]])]]),
295
+ visited: new Map(),
296
+ written: new Set(),
297
+ coverage: new Map(),
298
+ },
299
+ });
300
+ expect(run).toHaveBeenCalledTimes(1);
301
+ expect(p.textContent).toBe("1");
302
+ });
303
+
304
+ it("dispatches sibling-subtree writes that no reader above depends on", async () => {
305
+ const { root } = mount(
306
+ `<div class="a"><span bind-x></span></div><div class="b"><span bind-y></span></div>`,
307
+ `.a { $x: "A"; }
308
+ .a [bind-x] { content: $x; }
309
+ .b [bind-y] { content: $x or "none"; }`
310
+ );
311
+ await flush();
312
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("A");
313
+ expect(root.querySelector("[bind-y]")?.textContent).toBe("none");
314
+ });
315
+ });
316
+
317
+ describe("prop change routing", () => {
318
+ it("ignores prop events without names or from outside a scoped host", async () => {
319
+ const { quark } = mount(
320
+ `<input value="v">`,
321
+ `input { data-len: prop("value").length; }`
322
+ );
323
+ await flush();
324
+ const run = vi.spyOn(quark, "run");
325
+ const outside = document.createElement("input");
326
+ document.body.appendChild(outside);
327
+ outside.dispatchEvent(
328
+ new CustomEvent("quark-prop-change", {
329
+ bubbles: true,
330
+ detail: { names: ["value"] },
331
+ })
332
+ );
333
+ outside.dispatchEvent(
334
+ new CustomEvent("quark-prop-change", { bubbles: true })
335
+ );
336
+ expect(run).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it("re-observes a custom element's property once it upgrades", async () => {
340
+ const el = document.createElement("x-late-upgrade") as HTMLElement & {
341
+ provision?: string;
342
+ };
343
+ document.body.appendChild(el);
344
+ const release = subscribeProp(el, "provision");
345
+ const heard = vi.fn();
346
+ el.addEventListener("quark-prop-change", heard);
347
+ customElements.define("x-late-upgrade", class extends HTMLElement {});
348
+ await customElements.whenDefined("x-late-upgrade");
349
+ await wait(0);
350
+ el.provision = "after";
351
+ await wait(0);
352
+ expect(heard).toHaveBeenCalledTimes(1);
353
+ release();
354
+ release();
355
+ el.provision = "released";
356
+ await wait(0);
357
+ expect(heard).toHaveBeenCalledTimes(1);
358
+ });
359
+
360
+ it("does not re-observe a subscription released before the upgrade", async () => {
361
+ const el = document.createElement("x-late-release") as HTMLElement & {
362
+ provision?: string;
363
+ };
364
+ document.body.appendChild(el);
365
+ const release = subscribeProp(el, "provision");
366
+ release();
367
+ customElements.define("x-late-release", class extends HTMLElement {});
368
+ await customElements.whenDefined("x-late-release");
369
+ await wait(0);
370
+ const heard = vi.fn();
371
+ el.addEventListener("quark-prop-change", heard);
372
+ el.provision = "after";
373
+ await wait(0);
374
+ expect(heard).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it("drops coalesced changes for elements that left the DOM", async () => {
378
+ const el = document.createElement("div") as HTMLElement & {
379
+ thing?: number;
380
+ };
381
+ document.body.appendChild(el);
382
+ const release = subscribeProp(el, "thing");
383
+ const heard = vi.fn();
384
+ el.addEventListener("quark-prop-change", heard);
385
+ el.thing = 1;
386
+ el.remove();
387
+ await wait(0);
388
+ expect(heard).not.toHaveBeenCalled();
389
+ release();
390
+ });
391
+ });
392
+
393
+ describe("verbose logging", () => {
394
+ it("logs run, match and paint details at info level", async () => {
395
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
396
+ const level = QuarkLogger.level;
397
+ QuarkLogger.level = 4;
398
+ try {
399
+ const { root } = mount(
400
+ `<ul><template><li bind-label></li></template></ul>`,
401
+ `ul { $items: items; content: iterate($items); }
402
+ [bind-label] { content: item; }`,
403
+ { items: ["a"] }
404
+ );
405
+ await flush();
406
+ expect(root.querySelector("li")?.textContent).toBe("a");
407
+ } finally {
408
+ QuarkLogger.level = level;
409
+ }
410
+ const methods = log.mock.calls.map((args) => String(args[2] ?? ""));
411
+ expect(methods.some((m) => m.startsWith("run"))).toBe(true);
412
+ expect(methods.some((m) => m.startsWith("foundElements"))).toBe(true);
413
+ expect(methods.some((m) => m.startsWith("triggerPaint"))).toBe(true);
414
+ });
415
+ });
416
+
417
+ describe("registry", () => {
418
+ it("finds rules by id and by selector tail", async () => {
419
+ const { quark } = mount(
420
+ `<p bind-x></p>`,
421
+ `article [bind-x] { data-x: "1"; }`
422
+ );
423
+ await flush();
424
+ const { QuarkRegistry } = await import("../../src/quark");
425
+ expect(QuarkRegistry.findRules(quark.rules[0].id)).toEqual([
426
+ quark.rules[0],
427
+ ]);
428
+ expect(QuarkRegistry.findRules("bind-x")).toContain(quark.rules[0]);
429
+ expect(QuarkRegistry.findRules("article")).not.toContain(quark.rules[0]);
430
+ });
431
+ });
432
+ });
433
+
434
+ describe("fixture sanity", () => {
435
+ it("keeps custom-element definitions from leaking between tests", () => {
436
+ expect(customElements.get("x-late-upgrade")).toBeDefined();
437
+ expect(fixture<HTMLElement>("<div></div>").tagName).toBe("DIV");
438
+ document.body.innerHTML = "";
439
+ });
440
+ });