@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.
- package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +4 -0
- package/language.ts +7 -0
- package/package.json +45 -0
- package/rush-logs/quark.apply-exports.cache.log +1 -0
- package/rush-logs/quark.apply-exports.log +1 -0
- package/rush-logs/quark.build_package-metas.cache.log +1 -0
- package/rush-logs/quark.build_package-metas.log +1 -0
- package/src/actions.ts +445 -0
- package/src/ast.ts +187 -0
- package/src/bindings.ts +115 -0
- package/src/builtin-modules.ts +401 -0
- package/src/constants.ts +47 -0
- package/src/cycle-check.ts +121 -0
- package/src/devtools-hook.ts +303 -0
- package/src/element-api.ts +109 -0
- package/src/evaluator.ts +317 -0
- package/src/form-controls.ts +69 -0
- package/src/language-tables.ts +137 -0
- package/src/language.ts +1225 -0
- package/src/observer.ts +110 -0
- package/src/paint.ts +305 -0
- package/src/properties.ts +818 -0
- package/src/props.ts +94 -0
- package/src/quark-element.ts +7 -0
- package/src/quark-internal.ts +365 -0
- package/src/quark.ts +817 -0
- package/src/resolvers.ts +867 -0
- package/src/rule.ts +800 -0
- package/src/scope-id.ts +47 -0
- package/src/selector-utils.ts +430 -0
- package/src/settle.ts +117 -0
- package/src/types.ts +155 -0
- package/src/utils.ts +79 -0
- package/src/variables.ts +359 -0
- package/support/demos/css-variables.html +18 -0
- package/support/demos/dispatch.html +20 -0
- package/support/demos/events.html +9 -0
- package/support/demos/iterate.html +13 -0
- package/support/demos/js-api.html +17 -0
- package/support/demos/provider-list.html +16 -0
- package/support/demos/toggle-content.html +14 -0
- package/support/demos/unset.html +12 -0
- package/support/demos/view-transition.html +36 -0
- package/support/docs/ATTRIBUTES.md +37 -0
- package/support/docs/AT_RULES.md +45 -0
- package/support/docs/BUILTINS.md +57 -0
- package/support/docs/CONTENT.md +40 -0
- package/support/docs/CSS_VARIABLES.md +16 -0
- package/support/docs/DECLARATIONS.md +26 -0
- package/support/docs/DELAY.md +37 -0
- package/support/docs/DIAGNOSTICS.md +32 -0
- package/support/docs/DISPATCH.md +81 -0
- package/support/docs/ELEMENT_PROPERTIES.md +34 -0
- package/support/docs/EXPRESSIONS.md +31 -0
- package/support/docs/INTERNAL.md +8 -0
- package/support/docs/JS_API.md +53 -0
- package/support/docs/JS_WRITES.md +36 -0
- package/support/docs/LIMITATIONS.md +26 -0
- package/support/docs/LOOP_GUARD.md +30 -0
- package/support/docs/METHODS.md +43 -0
- package/support/docs/MODULES.md +111 -0
- package/support/docs/NO_REVERSION.md +18 -0
- package/support/docs/ON.md +87 -0
- package/support/docs/REACTIVITY.md +24 -0
- package/support/docs/README.md +143 -0
- package/support/docs/SELECTORS.md +87 -0
- package/support/docs/SHEETS.md +67 -0
- package/support/docs/SYNTAX.md +36 -0
- package/support/docs/USE.md +24 -0
- package/support/docs/VALUES.md +27 -0
- package/support/docs/VARIABLES.md +47 -0
- package/support/docs/VIEW_TRANSITION.md +64 -0
- package/support/docs-sections.json +65 -0
- package/support/package-meta.json +208 -0
- package/support/scripts/build-language-docs.mjs +196 -0
- package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
- package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
- package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
- package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
- package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
- package/support/tests/actions.test.ts +437 -0
- package/support/tests/builtin-modules.test.ts +337 -0
- package/support/tests/builtins.test.ts +189 -0
- package/support/tests/css-variables.view.test.ts +35 -0
- package/support/tests/delay.test.ts +244 -0
- package/support/tests/devtools-hook.test.ts +496 -0
- package/support/tests/diagnostics.test.ts +224 -0
- package/support/tests/dispatch.view.test.ts +43 -0
- package/support/tests/element-api.test.ts +202 -0
- package/support/tests/evaluator.test.ts +237 -0
- package/support/tests/events.view.test.ts +40 -0
- package/support/tests/form-controls.test.ts +452 -0
- package/support/tests/helpers.ts +354 -0
- package/support/tests/internals.test.ts +568 -0
- package/support/tests/iterate.view.test.ts +40 -0
- package/support/tests/js-api.view.test.ts +45 -0
- package/support/tests/language-docs.test.ts +147 -0
- package/support/tests/lifecycle.test.ts +440 -0
- package/support/tests/loop-guard.test.ts +323 -0
- package/support/tests/provider-list.view.test.ts +47 -0
- package/support/tests/quark-features.test.ts +2887 -0
- package/support/tests/quark-with-providers.test.ts +169 -0
- package/support/tests/quark.test.ts +359 -0
- package/support/tests/resolvers.test.ts +346 -0
- package/support/tests/selector-analysis.test.ts +281 -0
- package/support/tests/selectors.test.ts +670 -0
- package/support/tests/toggle-content.view.test.ts +40 -0
- package/support/tests/unset.view.test.ts +42 -0
- package/support/tests/view-helpers.ts +81 -0
- package/support/tests/view-transition.test.ts +1069 -0
- package/support/tests/view-transition.view.test.ts +76 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@view-transition [(options)] { … }`: paints of the writes inside the
|
|
3
|
+
* block commit inside `document.startViewTransition()` (paint.ts), after
|
|
4
|
+
* Quark settles (settle.ts). happy-dom has no view transitions, so a
|
|
5
|
+
* browser-like stub stands in (helpers `installViewTransitionStub`).
|
|
6
|
+
*/
|
|
7
|
+
import { Quark } from "../../index";
|
|
8
|
+
import * as settleModule from "../../src/settle";
|
|
9
|
+
import { QuarkLogger } from "../../src/utils";
|
|
10
|
+
import {
|
|
11
|
+
type DevtoolsHook,
|
|
12
|
+
NUCLEUS_DEVTOOLS_HOOK_KEY,
|
|
13
|
+
pathMatches,
|
|
14
|
+
type PublicizeMeta,
|
|
15
|
+
type PublicizePath,
|
|
16
|
+
} from "@excom/kit-devtools";
|
|
17
|
+
import {
|
|
18
|
+
afterEach,
|
|
19
|
+
describe,
|
|
20
|
+
expect,
|
|
21
|
+
it,
|
|
22
|
+
vi,
|
|
23
|
+
wait,
|
|
24
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
25
|
+
import { LoopGuard, type LoopGuardTrip } from "@excom/kit-utils";
|
|
26
|
+
import type { QuarkRenderer } from "../../src/devtools-hook";
|
|
27
|
+
import {
|
|
28
|
+
bypassSelectorCache,
|
|
29
|
+
createSheet,
|
|
30
|
+
flush,
|
|
31
|
+
installViewTransitionStub,
|
|
32
|
+
mount,
|
|
33
|
+
unregisterAll,
|
|
34
|
+
} from "./helpers";
|
|
35
|
+
|
|
36
|
+
type Stub = ReturnType<typeof installViewTransitionStub>;
|
|
37
|
+
|
|
38
|
+
/** Messages of `QuarkLogger.warn` calls (console output is off below warn level). */
|
|
39
|
+
const spyWarnings = () => {
|
|
40
|
+
const warn = vi.spyOn(QuarkLogger, "warn").mockImplementation(() => {});
|
|
41
|
+
return () => warn.mock.calls.map(([arg]) => String((arg as { message?: string })?.message ?? arg));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Past the sheet's first render: the next change can transition. */
|
|
45
|
+
const settle = async () => {
|
|
46
|
+
await flush();
|
|
47
|
+
await Quark.whenSettled();
|
|
48
|
+
await wait(0);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const waitFor = async (condition: () => boolean, ticks = 80) => {
|
|
52
|
+
for (let i = 0; i < ticks && !condition(); i++) await wait(0);
|
|
53
|
+
expect(condition()).toBe(true);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const typesOf = (stub: Stub, index = 0) =>
|
|
57
|
+
[...(stub.calls[index]?.types ?? [])].sort();
|
|
58
|
+
|
|
59
|
+
describe("@view-transition", () => {
|
|
60
|
+
let stub: Stub | undefined;
|
|
61
|
+
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
// let every transition finish so module state never leaks between tests
|
|
64
|
+
if (stub) {
|
|
65
|
+
await Promise.race([
|
|
66
|
+
Promise.all(stub.calls.map((call) => call.runUpdate().then(() => call.finished))),
|
|
67
|
+
wait(1500),
|
|
68
|
+
]);
|
|
69
|
+
stub.restore();
|
|
70
|
+
stub = undefined;
|
|
71
|
+
}
|
|
72
|
+
await Quark.whenSettled({ timeout: 200 });
|
|
73
|
+
unregisterAll();
|
|
74
|
+
document.body.innerHTML = "";
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("committing writes", () => {
|
|
79
|
+
it("commits plainly, without warnings, where the API is missing", async () => {
|
|
80
|
+
const warn = vi.spyOn(console, "warn");
|
|
81
|
+
const error = vi.spyOn(console, "error");
|
|
82
|
+
const { root } = mount(
|
|
83
|
+
`<p id="out" data-n="1"></p>`,
|
|
84
|
+
`#out { @view-transition (types: "count") { data-copy: attr("data-n"); } }`
|
|
85
|
+
);
|
|
86
|
+
await settle();
|
|
87
|
+
const out = root.querySelector("#out")!;
|
|
88
|
+
expect(out.getAttribute("data-copy")).toBe("1");
|
|
89
|
+
out.setAttribute("data-n", "2");
|
|
90
|
+
await settle();
|
|
91
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
92
|
+
expect(warn).not.toHaveBeenCalled();
|
|
93
|
+
expect(error).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("holds a flagged write while the transition is pending and commits it inside update", async () => {
|
|
97
|
+
stub = installViewTransitionStub({ autoUpdate: false });
|
|
98
|
+
// the reader rule runs before the def: its deferred re-run is still first render
|
|
99
|
+
const { root } = mount(
|
|
100
|
+
`<p id="out" data-n="1"></p>`,
|
|
101
|
+
`#out { @view-transition (types: "count") { data-copy: $n; } }
|
|
102
|
+
#out { $n: attr("data-n"); }`
|
|
103
|
+
);
|
|
104
|
+
await settle();
|
|
105
|
+
expect(stub.calls).toHaveLength(0);
|
|
106
|
+
const out = root.querySelector("#out")!;
|
|
107
|
+
expect(out.getAttribute("data-copy")).toBe("1");
|
|
108
|
+
out.setAttribute("data-n", "2");
|
|
109
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
110
|
+
await wait(20);
|
|
111
|
+
// pending: the browser has not called update, nothing commits
|
|
112
|
+
expect(out.getAttribute("data-copy")).toBe("1");
|
|
113
|
+
expect(typesOf(stub)).toEqual(["count"]);
|
|
114
|
+
await stub.calls[0].runUpdate();
|
|
115
|
+
expect(stub.calls[0].isUpdated).toBe(true);
|
|
116
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
117
|
+
await settle();
|
|
118
|
+
expect(stub.calls).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("commits one tick's flagged and unflagged writes in one transition, nodes before attributes", async () => {
|
|
122
|
+
stub = installViewTransitionStub({ autoUpdate: false });
|
|
123
|
+
const { root } = mount(
|
|
124
|
+
`<div id="box" data-n="1"><span></span></div>`,
|
|
125
|
+
`#box {
|
|
126
|
+
$n: attr("data-n");
|
|
127
|
+
@view-transition (types: "a") { data-copy: $n; }
|
|
128
|
+
@view-transition (types: "b") { span { content: $n; } }
|
|
129
|
+
data-plain: $n;
|
|
130
|
+
}`
|
|
131
|
+
);
|
|
132
|
+
await settle();
|
|
133
|
+
const box = root.querySelector("#box")!;
|
|
134
|
+
const span = box.querySelector("span")!;
|
|
135
|
+
box.setAttribute("data-n", "2");
|
|
136
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
137
|
+
await wait(10);
|
|
138
|
+
expect([box.getAttribute("data-copy"), box.getAttribute("data-plain"), span.textContent]).toEqual(["1", "1", "1"]);
|
|
139
|
+
const records: string[] = [];
|
|
140
|
+
const observer = new MutationObserver((list) =>
|
|
141
|
+
list.forEach((record) => records.push(record.type === "attributes" ? record.attributeName! : "content"))
|
|
142
|
+
);
|
|
143
|
+
observer.observe(box, { attributes: true, childList: true, subtree: true });
|
|
144
|
+
await stub.calls[0].runUpdate();
|
|
145
|
+
observer.disconnect();
|
|
146
|
+
expect([box.getAttribute("data-copy"), box.getAttribute("data-plain"), span.textContent]).toEqual(["2", "2", "2"]);
|
|
147
|
+
expect(typesOf(stub)).toEqual(["a", "b"]);
|
|
148
|
+
expect(records[0]).toBe("content");
|
|
149
|
+
expect(records).toEqual(expect.arrayContaining(["data-copy", "data-plain"]));
|
|
150
|
+
expect(stub.calls).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("never transitions the sheet's first render unless the block says first-render", async () => {
|
|
154
|
+
stub = installViewTransitionStub();
|
|
155
|
+
mount(`<p id="a" data-n="1"></p>`, `#a { @view-transition { data-copy: attr("data-n"); } }`);
|
|
156
|
+
await settle();
|
|
157
|
+
expect(stub.calls).toHaveLength(0);
|
|
158
|
+
const { root } = mount(
|
|
159
|
+
`<p id="b" data-n="1"></p>`,
|
|
160
|
+
`#b { @view-transition (first-render, types: "load") { data-copy: attr("data-n"); } }`
|
|
161
|
+
);
|
|
162
|
+
await settle();
|
|
163
|
+
expect(stub.calls).toHaveLength(1);
|
|
164
|
+
expect(typesOf(stub)).toEqual(["load"]);
|
|
165
|
+
expect(root.querySelector("#b")!.getAttribute("data-copy")).toBe("1");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("evaluates types per write: strings, lists and interpolations, unioned per transition", async () => {
|
|
169
|
+
stub = installViewTransitionStub();
|
|
170
|
+
const { root } = mount(
|
|
171
|
+
`<div id="box" data-n="1" data-op="add"></div>`,
|
|
172
|
+
`#box {
|
|
173
|
+
$n: attr("data-n");
|
|
174
|
+
@view-transition (types: "a b") { data-one: $n; }
|
|
175
|
+
@view-transition (types: ("b", "c d")) { data-two: $n; }
|
|
176
|
+
@view-transition (types: "todo-#{attr("data-op")}") { data-three: $n; }
|
|
177
|
+
}`
|
|
178
|
+
);
|
|
179
|
+
await settle();
|
|
180
|
+
root.querySelector("#box")!.setAttribute("data-n", "2");
|
|
181
|
+
await settle();
|
|
182
|
+
expect(stub.calls).toHaveLength(1);
|
|
183
|
+
expect(typesOf(stub)).toEqual(["a", "b", "c", "d", "todo-add"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("starts no transition for writes that change nothing", async () => {
|
|
187
|
+
stub = installViewTransitionStub();
|
|
188
|
+
const { root } = mount(
|
|
189
|
+
`<div id="box" data-word="abc"><span></span></div>`,
|
|
190
|
+
`#box {
|
|
191
|
+
$word: attr("data-word");
|
|
192
|
+
@view-transition { data-len: $word.length; span { content: $word.length; } }
|
|
193
|
+
}`
|
|
194
|
+
);
|
|
195
|
+
await settle();
|
|
196
|
+
const box = root.querySelector("#box")!;
|
|
197
|
+
box.setAttribute("data-word", "xyz");
|
|
198
|
+
await settle();
|
|
199
|
+
expect(stub.calls).toHaveLength(0);
|
|
200
|
+
expect(box.getAttribute("data-len")).toBe("3");
|
|
201
|
+
// a no-op and a real change in one commit: one transition
|
|
202
|
+
box.setAttribute("data-word", "wxyz");
|
|
203
|
+
await settle();
|
|
204
|
+
expect(stub.calls).toHaveLength(1);
|
|
205
|
+
expect(box.querySelector("span")!.textContent).toBe("4");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("waits for dependent writes: rendered rows are complete when the new state is captured", async () => {
|
|
209
|
+
stub = installViewTransitionStub({ autoUpdate: false });
|
|
210
|
+
const rows = (n: string) =>
|
|
211
|
+
Array.from({ length: Number(n) }, (_, i) => ({ id: i, label: `row ${i}` }));
|
|
212
|
+
const { root } = mount(
|
|
213
|
+
`<ul id="list" data-count="2"><template><li><span bind-label></span></li></template></ul>`,
|
|
214
|
+
`#list {
|
|
215
|
+
$rows: rows(attr("data-count"));
|
|
216
|
+
@view-transition (types: "rows") { content: iterate($rows, none, "id"); }
|
|
217
|
+
}
|
|
218
|
+
[bind-label] { content: item.label; }`,
|
|
219
|
+
{ rows }
|
|
220
|
+
);
|
|
221
|
+
await settle();
|
|
222
|
+
const list = root.querySelector("#list")!;
|
|
223
|
+
expect(list.querySelectorAll("[bind-label]")).toHaveLength(2);
|
|
224
|
+
list.setAttribute("data-count", "4");
|
|
225
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
226
|
+
expect(list.querySelectorAll("li")).toHaveLength(2);
|
|
227
|
+
await stub.calls[0].runUpdate();
|
|
228
|
+
// the new rows and their labels (a later rule pass) are in the cut
|
|
229
|
+
expect([...list.querySelectorAll("[bind-label]")].map((el) => el.textContent)).toEqual([
|
|
230
|
+
"row 0",
|
|
231
|
+
"row 1",
|
|
232
|
+
"row 2",
|
|
233
|
+
"row 3",
|
|
234
|
+
]);
|
|
235
|
+
expect(typesOf(stub)).toEqual(["rows"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("captures the new state after timeout when Quark does not settle, warning once per block", async () => {
|
|
239
|
+
stub = installViewTransitionStub();
|
|
240
|
+
const warnings = spyWarnings();
|
|
241
|
+
const hang = (n: string) => (n === "1" ? "ready" : new Promise(() => {}));
|
|
242
|
+
const { root } = mount(
|
|
243
|
+
`<div id="box" data-n="1"><span></span></div>`,
|
|
244
|
+
`#box {
|
|
245
|
+
$n: attr("data-n");
|
|
246
|
+
@view-transition (timeout: 40) { data-copy: $n; }
|
|
247
|
+
span { content: hang($n); }
|
|
248
|
+
}`,
|
|
249
|
+
{ hang }
|
|
250
|
+
);
|
|
251
|
+
await settle();
|
|
252
|
+
const box = root.querySelector("#box")!;
|
|
253
|
+
box.setAttribute("data-n", "2");
|
|
254
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
255
|
+
const started = Date.now();
|
|
256
|
+
await stub.calls[0].updateCallbackDone;
|
|
257
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(30);
|
|
258
|
+
expect(box.getAttribute("data-copy")).toBe("2");
|
|
259
|
+
box.setAttribute("data-n", "3");
|
|
260
|
+
await waitFor(() => stub!.calls.length === 2);
|
|
261
|
+
await stub.calls[1].updateCallbackDone;
|
|
262
|
+
expect(warnings().filter((m) => m.includes("did not settle within 40ms"))).toHaveLength(1);
|
|
263
|
+
box.remove();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("holds delayed writes back and commits them in their own transition", async () => {
|
|
267
|
+
stub = installViewTransitionStub();
|
|
268
|
+
const { root } = mount(
|
|
269
|
+
`<div id="box" data-n="1"></div>`,
|
|
270
|
+
`#box {
|
|
271
|
+
$n: attr("data-n");
|
|
272
|
+
@view-transition (delay: 60, types: "late") { data-copy: $n; }
|
|
273
|
+
data-plain: $n;
|
|
274
|
+
}`
|
|
275
|
+
);
|
|
276
|
+
await settle();
|
|
277
|
+
const box = root.querySelector("#box")!;
|
|
278
|
+
box.setAttribute("data-n", "2");
|
|
279
|
+
await flush();
|
|
280
|
+
expect(box.getAttribute("data-plain")).toBe("2");
|
|
281
|
+
expect(box.getAttribute("data-copy")).toBe("1");
|
|
282
|
+
expect(stub.calls).toHaveLength(0);
|
|
283
|
+
await wait(80);
|
|
284
|
+
await settle();
|
|
285
|
+
expect(box.getAttribute("data-copy")).toBe("2");
|
|
286
|
+
expect(stub.calls).toHaveLength(1);
|
|
287
|
+
expect(typesOf(stub)).toEqual(["late"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("commits plainly under prefers-reduced-motion: reduce and in a hidden document", async () => {
|
|
291
|
+
stub = installViewTransitionStub();
|
|
292
|
+
vi.spyOn(window, "matchMedia").mockImplementation(
|
|
293
|
+
(query: string) => ({ matches: query.includes("reduce"), media: query }) as MediaQueryList
|
|
294
|
+
);
|
|
295
|
+
const { root } = mount(
|
|
296
|
+
`<p id="out" data-n="1"></p>`,
|
|
297
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
298
|
+
);
|
|
299
|
+
await settle();
|
|
300
|
+
const out = root.querySelector("#out")!;
|
|
301
|
+
out.setAttribute("data-n", "2");
|
|
302
|
+
await settle();
|
|
303
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
304
|
+
expect(stub.calls).toHaveLength(0);
|
|
305
|
+
vi.restoreAllMocks();
|
|
306
|
+
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
307
|
+
out.setAttribute("data-n", "3");
|
|
308
|
+
await settle();
|
|
309
|
+
expect(out.getAttribute("data-copy")).toBe("3");
|
|
310
|
+
expect(stub.calls).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("commits plainly on engines that reject the options object (no transition types)", async () => {
|
|
314
|
+
stub = installViewTransitionStub({ withTypes: false });
|
|
315
|
+
const { root } = mount(
|
|
316
|
+
`<p id="out" data-n="1"></p>`,
|
|
317
|
+
`#out { @view-transition (types: "count") { data-copy: attr("data-n"); } }`
|
|
318
|
+
);
|
|
319
|
+
await settle();
|
|
320
|
+
const out = root.querySelector("#out")!;
|
|
321
|
+
out.setAttribute("data-n", "2");
|
|
322
|
+
await settle();
|
|
323
|
+
expect(stub.calls).toHaveLength(0);
|
|
324
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
325
|
+
out.setAttribute("data-n", "3");
|
|
326
|
+
await settle();
|
|
327
|
+
expect(out.getAttribute("data-copy")).toBe("3");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("commits the pending paints itself when update never arrives", async () => {
|
|
331
|
+
stub = installViewTransitionStub({ autoUpdate: false });
|
|
332
|
+
const { root } = mount(
|
|
333
|
+
`<p id="out" data-n="1"></p>`,
|
|
334
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
335
|
+
);
|
|
336
|
+
await settle();
|
|
337
|
+
const out = root.querySelector("#out")!;
|
|
338
|
+
out.setAttribute("data-n", "2");
|
|
339
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
340
|
+
expect(out.getAttribute("data-copy")).toBe("1");
|
|
341
|
+
await wait(1050);
|
|
342
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
343
|
+
// a late update finds nothing pending and resolves
|
|
344
|
+
await stub.calls[0].runUpdate();
|
|
345
|
+
expect(stub.calls[0].isUpdated).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("logs a throwing paint, commits the others and never rejects update", async () => {
|
|
349
|
+
stub = installViewTransitionStub();
|
|
350
|
+
const error = vi.spyOn(QuarkLogger, "error").mockImplementation(() => {});
|
|
351
|
+
const bad = (n: string) => (n === "1" ? {} : { "a b": n });
|
|
352
|
+
const { root } = mount(
|
|
353
|
+
`<div id="box" data-n="1"></div>`,
|
|
354
|
+
`#box {
|
|
355
|
+
$n: attr("data-n");
|
|
356
|
+
@view-transition { dataset: bad($n); data-copy: $n; }
|
|
357
|
+
}`,
|
|
358
|
+
{ bad }
|
|
359
|
+
);
|
|
360
|
+
await settle();
|
|
361
|
+
const box = root.querySelector("#box")!;
|
|
362
|
+
box.setAttribute("data-n", "2");
|
|
363
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
364
|
+
await expect(stub.calls[0].updateCallbackDone).resolves.toBeUndefined();
|
|
365
|
+
expect(box.getAttribute("data-copy")).toBe("2");
|
|
366
|
+
expect(error.mock.calls.map(([arg]) => (arg as { message?: string }).message)).toContain(
|
|
367
|
+
"Quark: a paint threw; the other paints still commit"
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("commits plainly when starting the transition throws", async () => {
|
|
372
|
+
stub = installViewTransitionStub();
|
|
373
|
+
const publications: PublicizeMeta[] = [];
|
|
374
|
+
Quark.attachDevtools({
|
|
375
|
+
version: 1,
|
|
376
|
+
inject: () => {},
|
|
377
|
+
publicize: (path, meta) => {
|
|
378
|
+
if (pathMatches(path, ["quark", "transition"])) publications.push(meta);
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
try {
|
|
382
|
+
(document as any).startViewTransition = () => {
|
|
383
|
+
throw new DOMException("not now", "InvalidStateError");
|
|
384
|
+
};
|
|
385
|
+
const { root } = mount(
|
|
386
|
+
`<p id="out" data-n="1"></p>`,
|
|
387
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
388
|
+
);
|
|
389
|
+
await settle();
|
|
390
|
+
const out = root.querySelector("#out")!;
|
|
391
|
+
out.setAttribute("data-n", "2");
|
|
392
|
+
await settle();
|
|
393
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
394
|
+
expect(publications).toEqual([{ phase: "skip", reason: "error", types: [], paints: 1 }]);
|
|
395
|
+
// the next change is not held back by a stuck pending phase
|
|
396
|
+
out.setAttribute("data-n", "3");
|
|
397
|
+
await settle();
|
|
398
|
+
expect(out.getAttribute("data-copy")).toBe("3");
|
|
399
|
+
} finally {
|
|
400
|
+
delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("ignores an element that left the document before update ran", async () => {
|
|
405
|
+
stub = installViewTransitionStub({ autoUpdate: false });
|
|
406
|
+
const { root } = mount(
|
|
407
|
+
`<p id="out" data-n="1"></p>`,
|
|
408
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
409
|
+
);
|
|
410
|
+
await settle();
|
|
411
|
+
const out = root.querySelector("#out")!;
|
|
412
|
+
out.setAttribute("data-n", "2");
|
|
413
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
414
|
+
out.remove();
|
|
415
|
+
await expect(stub.calls[0].runUpdate()).resolves.toBeUndefined();
|
|
416
|
+
expect(stub.calls[0].isUpdated).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("another active transition", () => {
|
|
421
|
+
it("if-active: skip (default) commits unanimated while a transition is active", async () => {
|
|
422
|
+
stub = installViewTransitionStub();
|
|
423
|
+
const { root } = mount(
|
|
424
|
+
`<p id="out" data-n="1"></p>`,
|
|
425
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
426
|
+
);
|
|
427
|
+
await settle();
|
|
428
|
+
(document as any).activeViewTransition = { finished: new Promise(() => {}) };
|
|
429
|
+
const out = root.querySelector("#out")!;
|
|
430
|
+
out.setAttribute("data-n", "2");
|
|
431
|
+
await settle();
|
|
432
|
+
expect(out.getAttribute("data-copy")).toBe("2");
|
|
433
|
+
expect(stub.calls).toHaveLength(0);
|
|
434
|
+
(document as any).activeViewTransition = null;
|
|
435
|
+
out.setAttribute("data-n", "3");
|
|
436
|
+
await settle();
|
|
437
|
+
expect(stub.calls).toHaveLength(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("if-active: replace starts anyway, skipping the running transition", async () => {
|
|
441
|
+
stub = installViewTransitionStub({ animationMs: 200 });
|
|
442
|
+
const { root } = mount(
|
|
443
|
+
`<p id="out" data-n="1"></p>`,
|
|
444
|
+
`#out { @view-transition (if-active: replace) { data-copy: attr("data-n"); } }`
|
|
445
|
+
);
|
|
446
|
+
await settle();
|
|
447
|
+
const out = root.querySelector("#out")!;
|
|
448
|
+
out.setAttribute("data-n", "2");
|
|
449
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
450
|
+
await stub.calls[0].updateCallbackDone;
|
|
451
|
+
// Quark's own transition is still animating
|
|
452
|
+
out.setAttribute("data-n", "3");
|
|
453
|
+
await waitFor(() => stub!.calls.length === 2);
|
|
454
|
+
expect(stub.calls[0].isSkipped).toBe(true);
|
|
455
|
+
await stub.calls[1].updateCallbackDone;
|
|
456
|
+
expect(out.getAttribute("data-copy")).toBe("3");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("skips while its own transition animates, and transitions again after it finished", async () => {
|
|
460
|
+
stub = installViewTransitionStub({ animationMs: 120 });
|
|
461
|
+
const { root } = mount(
|
|
462
|
+
`<p id="out" data-n="1"></p>`,
|
|
463
|
+
`#out { @view-transition { data-copy: attr("data-n"); } }`
|
|
464
|
+
);
|
|
465
|
+
await settle();
|
|
466
|
+
const out = root.querySelector("#out")!;
|
|
467
|
+
out.setAttribute("data-n", "2");
|
|
468
|
+
await waitFor(() => stub!.calls.length === 1);
|
|
469
|
+
await stub.calls[0].updateCallbackDone;
|
|
470
|
+
out.setAttribute("data-n", "3");
|
|
471
|
+
await settle();
|
|
472
|
+
expect(out.getAttribute("data-copy")).toBe("3");
|
|
473
|
+
expect(stub.calls).toHaveLength(1);
|
|
474
|
+
await stub.calls[0].finished;
|
|
475
|
+
out.setAttribute("data-n", "4");
|
|
476
|
+
await settle();
|
|
477
|
+
expect(stub.calls).toHaveLength(2);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("where blocks apply", () => {
|
|
482
|
+
it("inside an @on block, and around one: event writes commit in one transition", async () => {
|
|
483
|
+
stub = installViewTransitionStub();
|
|
484
|
+
const { root } = mount(
|
|
485
|
+
`<div id="box"><button id="btn" type="button">go</button><p id="panel"></p></div>`,
|
|
486
|
+
`#box {
|
|
487
|
+
@on click { @view-transition (types: "open") { data-clicked: ""; #panel { data-open: ""; } } }
|
|
488
|
+
@view-transition (types: "close") { @on dblclick { data-clicked: none; } }
|
|
489
|
+
}`
|
|
490
|
+
);
|
|
491
|
+
await settle();
|
|
492
|
+
const box = root.querySelector("#box")!;
|
|
493
|
+
root.querySelector("#btn")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
494
|
+
await settle();
|
|
495
|
+
expect(stub.calls).toHaveLength(1);
|
|
496
|
+
expect(typesOf(stub)).toEqual(["open"]);
|
|
497
|
+
expect(box.hasAttribute("data-clicked")).toBe(true);
|
|
498
|
+
expect(root.querySelector("#panel")!.hasAttribute("data-open")).toBe(true);
|
|
499
|
+
box.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
|
500
|
+
await settle();
|
|
501
|
+
expect(stub.calls).toHaveLength(2);
|
|
502
|
+
expect(typesOf(stub, 1)).toEqual(["close"]);
|
|
503
|
+
expect(box.hasAttribute("data-clicked")).toBe(false);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("around rules at sheet level, in scoped and global sheets", async () => {
|
|
507
|
+
stub = installViewTransitionStub();
|
|
508
|
+
const scoped = mount(
|
|
509
|
+
`<p id="a" data-n="1"></p>`,
|
|
510
|
+
`@view-transition (types: "scoped") { #a { data-copy: attr("data-n"); } }`
|
|
511
|
+
);
|
|
512
|
+
const global = mount(
|
|
513
|
+
`<p id="b" data-n="1"></p>`,
|
|
514
|
+
`@view-transition (types: "global") { #b { data-copy: attr("data-n"); } }`,
|
|
515
|
+
undefined,
|
|
516
|
+
{ isScoped: false }
|
|
517
|
+
);
|
|
518
|
+
await settle();
|
|
519
|
+
scoped.root.querySelector("#a")!.setAttribute("data-n", "2");
|
|
520
|
+
await settle();
|
|
521
|
+
global.root.querySelector("#b")!.setAttribute("data-n", "2");
|
|
522
|
+
await settle();
|
|
523
|
+
expect(stub.calls.map((_, i) => typesOf(stub!, i))).toEqual([["scoped"], ["global"]]);
|
|
524
|
+
expect(scoped.root.querySelector("#a")!.getAttribute("data-copy")).toBe("2");
|
|
525
|
+
expect(global.root.querySelector("#b")!.getAttribute("data-copy")).toBe("2");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("lets a nested @view-transition override the outer options for its writes", async () => {
|
|
529
|
+
stub = installViewTransitionStub();
|
|
530
|
+
const { root } = mount(
|
|
531
|
+
`<p id="out" data-n="1"></p>`,
|
|
532
|
+
`#out {
|
|
533
|
+
@view-transition (types: "outer") {
|
|
534
|
+
data-fixed: "x";
|
|
535
|
+
@view-transition (types: "inner") { data-copy: attr("data-n"); }
|
|
536
|
+
}
|
|
537
|
+
}`
|
|
538
|
+
);
|
|
539
|
+
await settle();
|
|
540
|
+
root.querySelector("#out")!.setAttribute("data-n", "2");
|
|
541
|
+
await settle();
|
|
542
|
+
expect(stub.calls).toHaveLength(1);
|
|
543
|
+
expect(typesOf(stub)).toEqual(["inner"]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("applies content, class, dataset, ariaset and CSS variables inside a block", async () => {
|
|
547
|
+
stub = installViewTransitionStub();
|
|
548
|
+
const { root } = mount(
|
|
549
|
+
`<div id="box" data-n="1"><span></span></div>`,
|
|
550
|
+
`#box {
|
|
551
|
+
$n: attr("data-n");
|
|
552
|
+
@view-transition {
|
|
553
|
+
class: "n-#{$n}";
|
|
554
|
+
dataset: (count: $n);
|
|
555
|
+
ariaset: (label: "n #{$n}");
|
|
556
|
+
--n: "#{$n}";
|
|
557
|
+
span { content: dangerous-html("<b>#{$n}</b>"); }
|
|
558
|
+
}
|
|
559
|
+
}`
|
|
560
|
+
);
|
|
561
|
+
await settle();
|
|
562
|
+
const box = root.querySelector("#box") as HTMLElement;
|
|
563
|
+
box.setAttribute("data-n", "2");
|
|
564
|
+
await settle();
|
|
565
|
+
expect(stub.calls).toHaveLength(1);
|
|
566
|
+
expect(box.className).toBe("n-2");
|
|
567
|
+
expect(box.getAttribute("data-count")).toBe("2");
|
|
568
|
+
expect(box.getAttribute("aria-label")).toBe("n 2");
|
|
569
|
+
expect(box.style.getPropertyValue("--n")).toBe("2");
|
|
570
|
+
expect(box.querySelector("span")!.innerHTML).toBe("<b>2</b>");
|
|
571
|
+
// re-applying the same values changes nothing for any kind
|
|
572
|
+
box.setAttribute("data-n", "2");
|
|
573
|
+
await settle();
|
|
574
|
+
expect(stub.calls).toHaveLength(1);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("loads @use modules declared inside a block", async () => {
|
|
578
|
+
stub = installViewTransitionStub();
|
|
579
|
+
const original = Quark.moduleLoader;
|
|
580
|
+
Quark.moduleLoader = async () => ({ twice: (n: string) => `${n}${n}` });
|
|
581
|
+
try {
|
|
582
|
+
const { root } = mount(
|
|
583
|
+
`<p id="out" data-n="1"></p>`,
|
|
584
|
+
`#out { @view-transition { @use "/twice.js" as m; data-copy: m.twice(attr("data-n")); } }`
|
|
585
|
+
);
|
|
586
|
+
await settle();
|
|
587
|
+
const out = root.querySelector("#out")!;
|
|
588
|
+
expect(out.getAttribute("data-copy")).toBe("11");
|
|
589
|
+
out.setAttribute("data-n", "2");
|
|
590
|
+
await settle();
|
|
591
|
+
expect(out.getAttribute("data-copy")).toBe("22");
|
|
592
|
+
expect(stub.calls).toHaveLength(1);
|
|
593
|
+
} finally {
|
|
594
|
+
Quark.moduleLoader = original;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("keeps the loop guard's causal depth for paints committed inside update", async () => {
|
|
599
|
+
stub = installViewTransitionStub();
|
|
600
|
+
LoopGuard.reset();
|
|
601
|
+
LoopGuard.configure({ limit: 8, log: () => {} });
|
|
602
|
+
const trips: LoopGuardTrip[] = [];
|
|
603
|
+
const off = LoopGuard.onTrip((trip) => trips.push(trip));
|
|
604
|
+
try {
|
|
605
|
+
const { root } = mount(
|
|
606
|
+
`<div id="d" data-go="0"></div>`,
|
|
607
|
+
`div[data-go="1"] { @view-transition (timeout: 50) { content: dangerous-html("<div data-go=\\"1\\"></div>"); } }`
|
|
608
|
+
);
|
|
609
|
+
await settle();
|
|
610
|
+
root.querySelector("#d")!.setAttribute("data-go", "1");
|
|
611
|
+
for (let i = 0; i < 60 && !trips.length; i++) await flush();
|
|
612
|
+
expect(stub.calls.length).toBeGreaterThanOrEqual(1);
|
|
613
|
+
expect(trips[0]).toMatchObject({ kind: "depth", name: "content" });
|
|
614
|
+
let depth = 0;
|
|
615
|
+
for (let el = root.querySelector("#d"); el; el = el.firstElementChild) depth++;
|
|
616
|
+
expect(depth).toBeLessThanOrEqual(LoopGuard.limit + 2);
|
|
617
|
+
} finally {
|
|
618
|
+
off();
|
|
619
|
+
LoopGuard.reset();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe("change detection", () => {
|
|
625
|
+
type Case = {
|
|
626
|
+
name: string;
|
|
627
|
+
html: string;
|
|
628
|
+
sheet: string;
|
|
629
|
+
/** Each step: prepare the DOM, then whether a transition must start. */
|
|
630
|
+
steps: Array<{ act: (root: HTMLElement) => void; transitions: 0 | 1 }>;
|
|
631
|
+
};
|
|
632
|
+
const rows = (n: string) =>
|
|
633
|
+
(n.startsWith("-") ? [2, 1] : Array.from({ length: Number(n) }, (_, i) => i + 1)).map((id) => ({ id }));
|
|
634
|
+
const set = (selector: string, name: string, value: string) => (root: HTMLElement) =>
|
|
635
|
+
root.querySelector(selector)!.setAttribute(name, value);
|
|
636
|
+
const cases: Case[] = [
|
|
637
|
+
{
|
|
638
|
+
name: "boolean attributes",
|
|
639
|
+
html: `<p id="t" data-v="1"></p>`,
|
|
640
|
+
sheet: `#t { @view-transition { data-on: attr("data-v") != "0"; } }`,
|
|
641
|
+
steps: [
|
|
642
|
+
{ act: set("#t", "data-v", "2"), transitions: 0 },
|
|
643
|
+
{ act: set("#t", "data-v", "0"), transitions: 1 },
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: "a text input whose live value drifted",
|
|
648
|
+
html: `<input id="t" data-v="a">`,
|
|
649
|
+
sheet: `#t { @view-transition { value: attr("data-v"); } }`,
|
|
650
|
+
steps: [
|
|
651
|
+
{ act: set("#t", "data-v", "a"), transitions: 0 },
|
|
652
|
+
{
|
|
653
|
+
act: (root) => {
|
|
654
|
+
(root.querySelector("#t") as HTMLInputElement).value = "typed";
|
|
655
|
+
set("#t", "data-v", "a")(root);
|
|
656
|
+
},
|
|
657
|
+
transitions: 1,
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "a checkbox whose live state drifted",
|
|
663
|
+
html: `<input id="t" type="checkbox" data-v="1">`,
|
|
664
|
+
sheet: `#t { @view-transition { checked: attr("data-v") == "1"; } }`,
|
|
665
|
+
steps: [
|
|
666
|
+
{ act: set("#t", "data-v", "1"), transitions: 0 },
|
|
667
|
+
{
|
|
668
|
+
act: (root) => {
|
|
669
|
+
(root.querySelector("#t") as HTMLInputElement).checked = false;
|
|
670
|
+
set("#t", "data-v", "1")(root);
|
|
671
|
+
},
|
|
672
|
+
transitions: 1,
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: "class lists, maps and wipes",
|
|
678
|
+
html: `<p id="t" data-v="1"></p>`,
|
|
679
|
+
sheet: `#t[data-v="1"], #t[data-v="11"] { @view-transition { class: ("a", "b"); } }
|
|
680
|
+
#t[data-v="2"], #t[data-v="3"] { @view-transition { class: (big: attr("data-v") == "3", c: true); } }
|
|
681
|
+
#t[data-v="0"] { @view-transition { class: none; } }`,
|
|
682
|
+
steps: [
|
|
683
|
+
{ act: set("#t", "data-v", "11"), transitions: 0 },
|
|
684
|
+
{ act: set("#t", "data-v", "2"), transitions: 1 },
|
|
685
|
+
{ act: set("#t", "data-v", "3"), transitions: 1 },
|
|
686
|
+
{ act: set("#t", "data-v", "0"), transitions: 1 },
|
|
687
|
+
],
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: "CSS variables: value, priority and removal",
|
|
691
|
+
html: `<p id="t" data-v="1"></p>`,
|
|
692
|
+
sheet: `#t { @view-transition { --x: if(attr("data-v") == "0": none; attr("data-v") == "2": "1 !important"; else: "1"); } }`,
|
|
693
|
+
steps: [
|
|
694
|
+
{ act: set("#t", "data-v", "3"), transitions: 0 },
|
|
695
|
+
{ act: set("#t", "data-v", "2"), transitions: 1 },
|
|
696
|
+
{ act: set("#t", "data-v", "0"), transitions: 1 },
|
|
697
|
+
{ act: set("#t", "data-v", "4"), transitions: 1 },
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "content wipes with and without a template",
|
|
702
|
+
html: `<p id="t" data-v="1">text</p><ul id="u" data-v="2"><template><li></li></template></ul>`,
|
|
703
|
+
sheet: `#t { @view-transition { content: if(attr("data-v") == "0": none; else: preserve); } }
|
|
704
|
+
#u { @view-transition { content: iterate(if(attr("data-v") == "0": none; else: rows(attr("data-v"))), none, "id"); } }`,
|
|
705
|
+
steps: [
|
|
706
|
+
{ act: set("#t", "data-v", "0"), transitions: 1 },
|
|
707
|
+
{ act: set("#t", "data-v", "0"), transitions: 0 },
|
|
708
|
+
{ act: set("#u", "data-v", "0"), transitions: 1 },
|
|
709
|
+
{ act: set("#u", "data-v", "0"), transitions: 0 },
|
|
710
|
+
],
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
name: "keyed rows: same nodes, added rows, reordered rows",
|
|
714
|
+
html: `<ul id="t" data-v="2"><template><li></li></template></ul>`,
|
|
715
|
+
sheet: `#t { @view-transition { content: iterate(rows(attr("data-v")), none, "id"); } }`,
|
|
716
|
+
steps: [
|
|
717
|
+
{ act: set("#t", "data-v", "2"), transitions: 0 },
|
|
718
|
+
{ act: set("#t", "data-v", "-2"), transitions: 1 },
|
|
719
|
+
{ act: set("#t", "data-v", "3"), transitions: 1 },
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "text into a <template> and innerHTML",
|
|
724
|
+
html: `<template id="t" data-v="1"></template><p id="h" data-v="1"></p>`,
|
|
725
|
+
sheet: `#t { @view-transition { content: attr("data-v"); } }
|
|
726
|
+
#h { @view-transition { content: dangerous-html("<b>#{attr("data-v")}</b>"); } }`,
|
|
727
|
+
steps: [
|
|
728
|
+
{ act: set("#t", "data-v", "1"), transitions: 0 },
|
|
729
|
+
{ act: set("#t", "data-v", "2"), transitions: 1 },
|
|
730
|
+
{ act: set("#h", "data-v", "1"), transitions: 0 },
|
|
731
|
+
{ act: set("#h", "data-v", "2"), transitions: 1 },
|
|
732
|
+
],
|
|
733
|
+
},
|
|
734
|
+
];
|
|
735
|
+
|
|
736
|
+
const node = document.createElement("b");
|
|
737
|
+
node.textContent = "node";
|
|
738
|
+
/** Async content: a promise of text, a Node, a NodeList or a wipe. */
|
|
739
|
+
const later = (v: string) =>
|
|
740
|
+
Promise.resolve(
|
|
741
|
+
v === "text"
|
|
742
|
+
? "later"
|
|
743
|
+
: v === "node"
|
|
744
|
+
? node
|
|
745
|
+
: v === "list"
|
|
746
|
+
? document.createRange().createContextualFragment("<i>a</i><i>b</i>").childNodes
|
|
747
|
+
: null
|
|
748
|
+
);
|
|
749
|
+
cases.push(
|
|
750
|
+
{
|
|
751
|
+
name: "ariaset",
|
|
752
|
+
html: `<p id="t" data-v="1"></p>`,
|
|
753
|
+
sheet: `#t { @view-transition { ariaset: (label: attr("data-v") != "2"); } }`,
|
|
754
|
+
steps: [
|
|
755
|
+
{ act: set("#t", "data-v", "3"), transitions: 0 },
|
|
756
|
+
{ act: set("#t", "data-v", "2"), transitions: 1 },
|
|
757
|
+
],
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "async content: text, a node, a node list and a wipe",
|
|
761
|
+
html: `<p id="t" data-v="none">x</p>`,
|
|
762
|
+
sheet: `#t { @view-transition { content: later(attr("data-v")); } }`,
|
|
763
|
+
steps: [
|
|
764
|
+
{ act: set("#t", "data-v", "text"), transitions: 1 },
|
|
765
|
+
{ act: set("#t", "data-v", "text"), transitions: 0 },
|
|
766
|
+
{ act: set("#t", "data-v", "node"), transitions: 1 },
|
|
767
|
+
{ act: set("#t", "data-v", "node"), transitions: 0 },
|
|
768
|
+
{ act: set("#t", "data-v", "list"), transitions: 1 },
|
|
769
|
+
{ act: set("#t", "data-v", "none"), transitions: 1 },
|
|
770
|
+
{ act: set("#t", "data-v", "none"), transitions: 0 },
|
|
771
|
+
],
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
for (const { name, html, sheet, steps } of cases) {
|
|
776
|
+
it(`detects changes for ${name}`, async () => {
|
|
777
|
+
stub = installViewTransitionStub();
|
|
778
|
+
const { root } = mount(html, sheet, { rows, later });
|
|
779
|
+
await settle();
|
|
780
|
+
for (const [index, { act, transitions }] of steps.entries()) {
|
|
781
|
+
const before = stub.calls.length;
|
|
782
|
+
act(root);
|
|
783
|
+
await settle();
|
|
784
|
+
await Promise.all(stub.calls.map((call) => call.finished));
|
|
785
|
+
expect(stub.calls.length - before, `step ${index}`).toBe(transitions);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
describe("until", () => {
|
|
792
|
+
const loaderSheet = `#loader[is-loading] {
|
|
793
|
+
@view-transition (until: "[is-success], [is-error]", timeout: 800, types: "load") {
|
|
794
|
+
#msg { content: "Loading"; }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
#loader[is-success] #msg { content: "Done"; }`;
|
|
798
|
+
|
|
799
|
+
it("keeps update open until the block's element matches, so the fact's writes land in the cut", async () => {
|
|
800
|
+
stub = installViewTransitionStub();
|
|
801
|
+
const { root } = mount(`<div id="loader"><p id="msg"></p></div>`, loaderSheet);
|
|
802
|
+
await settle();
|
|
803
|
+
const loader = root.querySelector("#loader")!;
|
|
804
|
+
loader.setAttribute("is-loading", "");
|
|
805
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
806
|
+
const started = Date.now();
|
|
807
|
+
await wait(60);
|
|
808
|
+
expect(stub.calls[0].isUpdated).toBe(false);
|
|
809
|
+
loader.removeAttribute("is-loading");
|
|
810
|
+
loader.setAttribute("is-success", "");
|
|
811
|
+
await stub.calls[0].updateCallbackDone;
|
|
812
|
+
const elapsed = Date.now() - started;
|
|
813
|
+
expect(elapsed).toBeGreaterThanOrEqual(50);
|
|
814
|
+
expect(elapsed).toBeLessThan(700);
|
|
815
|
+
expect(root.querySelector("#msg")!.textContent).toBe("Done");
|
|
816
|
+
await settle();
|
|
817
|
+
expect(stub.calls).toHaveLength(1);
|
|
818
|
+
expect(typesOf(stub)).toEqual(["load"]);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("waits until a fact clears (:not) or a descendant appears (:has)", async () => {
|
|
822
|
+
const restore = bypassSelectorCache();
|
|
823
|
+
try {
|
|
824
|
+
stub = installViewTransitionStub();
|
|
825
|
+
const { root } = mount(
|
|
826
|
+
`<div id="a"><p id="a-msg"></p></div><div id="b"><p id="b-msg"></p><ul></ul></div>`,
|
|
827
|
+
`#a[is-open] { @view-transition (until: ":not([is-busy])") { #a-msg { content: "open"; } } }
|
|
828
|
+
#b[is-open] { @view-transition (until: ":has(li)") { #b-msg { content: "open"; } } }`
|
|
829
|
+
);
|
|
830
|
+
await settle();
|
|
831
|
+
const a = root.querySelector("#a")!;
|
|
832
|
+
a.setAttribute("is-busy", "");
|
|
833
|
+
a.setAttribute("is-open", "");
|
|
834
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
835
|
+
await wait(40);
|
|
836
|
+
expect(stub.calls[0].isUpdated).toBe(false);
|
|
837
|
+
a.removeAttribute("is-busy");
|
|
838
|
+
await stub.calls[0].updateCallbackDone;
|
|
839
|
+
await stub.calls[0].finished;
|
|
840
|
+
const b = root.querySelector("#b")!;
|
|
841
|
+
b.setAttribute("is-open", "");
|
|
842
|
+
await waitFor(() => !!stub!.calls[1]?.isUpdating);
|
|
843
|
+
await wait(40);
|
|
844
|
+
expect(stub.calls[1].isUpdated).toBe(false);
|
|
845
|
+
b.querySelector("ul")!.append(document.createElement("li"));
|
|
846
|
+
await stub.calls[1].updateCallbackDone;
|
|
847
|
+
expect(root.querySelector("#b-msg")!.textContent).toBe("open");
|
|
848
|
+
} finally {
|
|
849
|
+
restore();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("checks the block's own element: a block on a descendant waiting on an ancestor fact times out", async () => {
|
|
854
|
+
stub = installViewTransitionStub();
|
|
855
|
+
const warnings = spyWarnings();
|
|
856
|
+
const { root } = mount(
|
|
857
|
+
`<div id="loader"><p id="msg"></p></div>`,
|
|
858
|
+
`#loader[is-loading] #msg { @view-transition (until: "[is-success]", timeout: 60) { content: "Loading"; } }`
|
|
859
|
+
);
|
|
860
|
+
await settle();
|
|
861
|
+
const loader = root.querySelector("#loader")!;
|
|
862
|
+
loader.setAttribute("is-loading", "");
|
|
863
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
864
|
+
loader.setAttribute("is-success", "");
|
|
865
|
+
const started = Date.now();
|
|
866
|
+
await stub.calls[0].updateCallbackDone;
|
|
867
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
|
|
868
|
+
expect(warnings().filter((m) => m.includes("`until` was not met within 60ms"))).toHaveLength(1);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("stops waiting when the element leaves the document", async () => {
|
|
872
|
+
stub = installViewTransitionStub();
|
|
873
|
+
const { root } = mount(`<div id="loader"><p id="msg"></p></div>`, loaderSheet);
|
|
874
|
+
await settle();
|
|
875
|
+
const loader = root.querySelector("#loader")!;
|
|
876
|
+
loader.setAttribute("is-loading", "");
|
|
877
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
878
|
+
const started = Date.now();
|
|
879
|
+
loader.remove();
|
|
880
|
+
await stub.calls[0].updateCallbackDone;
|
|
881
|
+
expect(Date.now() - started).toBeLessThan(400);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("waits on a promise until it settles, either way", async () => {
|
|
885
|
+
stub = installViewTransitionStub();
|
|
886
|
+
const { root } = mount(
|
|
887
|
+
`<div id="loader"><p id="msg"></p></div>`,
|
|
888
|
+
`#loader[is-loading] { @view-transition (until: prop("pending"), timeout: 800) { #msg { content: "Loading"; } } }`
|
|
889
|
+
);
|
|
890
|
+
await settle();
|
|
891
|
+
const loader = root.querySelector("#loader") as HTMLElement & { pending?: Promise<void> };
|
|
892
|
+
let reject!: (reason: unknown) => void;
|
|
893
|
+
loader.pending = new Promise((_, r) => (reject = r));
|
|
894
|
+
loader.pending.catch(() => {});
|
|
895
|
+
loader.setAttribute("is-loading", "");
|
|
896
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
897
|
+
await wait(40);
|
|
898
|
+
expect(stub.calls[0].isUpdated).toBe(false);
|
|
899
|
+
reject(new Error("offline"));
|
|
900
|
+
await stub.calls[0].updateCallbackDone;
|
|
901
|
+
expect(root.querySelector("#msg")!.textContent).toBe("Loading");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("honors one until per transition: a second one is warned and its writes land in the open cut", async () => {
|
|
905
|
+
stub = installViewTransitionStub();
|
|
906
|
+
const warnings = spyWarnings();
|
|
907
|
+
const { root } = mount(
|
|
908
|
+
`<div id="a"><p id="a-msg"></p></div><div id="b"><p id="b-msg"></p></div>`,
|
|
909
|
+
`:scope[data-go] #a { @view-transition (until: "[is-done]", timeout: 800) { #a-msg { content: "a"; } } }
|
|
910
|
+
:scope[data-go] #b { @view-transition (until: "[is-never]", timeout: 800) { #b-msg { content: "b"; } } }`
|
|
911
|
+
);
|
|
912
|
+
await settle();
|
|
913
|
+
root.setAttribute("data-go", "");
|
|
914
|
+
await waitFor(() => !!stub!.calls[0]?.isUpdating);
|
|
915
|
+
expect(root.querySelector("#b-msg")!.textContent).toBe("b");
|
|
916
|
+
root.querySelector("#a")!.setAttribute("is-done", "");
|
|
917
|
+
await stub.calls[0].updateCallbackDone;
|
|
918
|
+
expect(stub.calls).toHaveLength(1);
|
|
919
|
+
expect(warnings().filter((m) => m.includes("one `until` per view transition"))).toHaveLength(1);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("drops an invalid selector with a warning and defaults the cap to 1000ms with until, 300 without", async () => {
|
|
923
|
+
stub = installViewTransitionStub();
|
|
924
|
+
const warnings = spyWarnings();
|
|
925
|
+
const wait$ = vi.spyOn(settleModule, "whenSettled");
|
|
926
|
+
const { root } = mount(
|
|
927
|
+
`<div id="a"></div><div id="b"></div><div id="c"></div>`,
|
|
928
|
+
`#a[data-go] { @view-transition (until: "[is-x") { data-a: ""; } }
|
|
929
|
+
#b[data-go] { @view-transition (until: "[is-x]") { data-b: ""; } }
|
|
930
|
+
#c[data-go] { @view-transition { data-c: ""; } }`
|
|
931
|
+
);
|
|
932
|
+
await settle();
|
|
933
|
+
for (const id of ["a", "b", "c"]) {
|
|
934
|
+
wait$.mockClear();
|
|
935
|
+
const el = root.querySelector(`#${id}`)!;
|
|
936
|
+
if (id === "b") setTimeout(() => el.setAttribute("is-x", ""), 30);
|
|
937
|
+
el.setAttribute("data-go", "");
|
|
938
|
+
await waitFor(() => wait$.mock.calls.length > 0);
|
|
939
|
+
const [{ timeout, until }] = wait$.mock.calls[0] as [settleModule.SettleOptions];
|
|
940
|
+
expect([id, timeout, !!until]).toEqual(
|
|
941
|
+
id === "b" ? ["b", 1000, true] : [id, 300, false]
|
|
942
|
+
);
|
|
943
|
+
await settle();
|
|
944
|
+
await Promise.all(stub.calls.map((call) => call.finished));
|
|
945
|
+
}
|
|
946
|
+
expect(warnings().filter((m) => m.includes('until: "[is-x" is not a valid selector'))).toHaveLength(1);
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe("options", () => {
|
|
951
|
+
it("evaluates options on the block's element, not on the nested element being written", async () => {
|
|
952
|
+
stub = installViewTransitionStub();
|
|
953
|
+
const items = [{ id: 1, kind: "fruit" }, { id: 2, kind: "veg" }];
|
|
954
|
+
const { root } = mount(
|
|
955
|
+
`<section id="box" data-kind="list" data-n="1"><p data-kind="nested"></p>
|
|
956
|
+
<ul id="rows"><template><li><span></span></li></template></ul></section>`,
|
|
957
|
+
`#box {
|
|
958
|
+
$n: attr("data-n");
|
|
959
|
+
@view-transition (types: "box-#{attr("data-kind")}") { p { data-copy: $n; } }
|
|
960
|
+
ul { content: iterate(items(), none, "id"); }
|
|
961
|
+
}
|
|
962
|
+
#box li { @view-transition (types: "row-#{item.kind}") { span { data-copy: $n; } } }`,
|
|
963
|
+
{ items: () => items }
|
|
964
|
+
);
|
|
965
|
+
await settle();
|
|
966
|
+
root.querySelector("#box")!.setAttribute("data-n", "2");
|
|
967
|
+
await settle();
|
|
968
|
+
expect(stub.calls).toHaveLength(1);
|
|
969
|
+
expect(typesOf(stub)).toEqual(["box-list", "row-fruit", "row-veg"]);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("warns once per block about unknown, malformed and reserved options", async () => {
|
|
973
|
+
stub = installViewTransitionStub();
|
|
974
|
+
const warnings = spyWarnings();
|
|
975
|
+
const { root } = mount(
|
|
976
|
+
`<p id="out" data-n="1"></p>`,
|
|
977
|
+
`#out {
|
|
978
|
+
@view-transition (speed: 2, timeout: "soon", delay: -1, first-render: 1, if-active: later, types: 3, scope: element, until: 5) {
|
|
979
|
+
data-copy: attr("data-n");
|
|
980
|
+
}
|
|
981
|
+
}`
|
|
982
|
+
);
|
|
983
|
+
await settle();
|
|
984
|
+
root.querySelector("#out")!.setAttribute("data-n", "2");
|
|
985
|
+
await settle();
|
|
986
|
+
root.querySelector("#out")!.setAttribute("data-n", "3");
|
|
987
|
+
await settle();
|
|
988
|
+
const messages = warnings();
|
|
989
|
+
const count = (text: string) => messages.filter((m) => m.includes(text)).length;
|
|
990
|
+
expect(count('unknown option "speed"')).toBe(1);
|
|
991
|
+
expect(count('option "timeout" needs a positive number')).toBe(1);
|
|
992
|
+
expect(count('option "delay" needs a positive number')).toBe(1);
|
|
993
|
+
expect(count('option "first-render" takes no value')).toBe(1);
|
|
994
|
+
expect(count('option "if-active" must be skip or replace')).toBe(1);
|
|
995
|
+
expect(count('option "types" needs strings')).toBe(1);
|
|
996
|
+
expect(count('option "scope" is not supported yet')).toBe(1);
|
|
997
|
+
expect(count('option "until" needs a selector or a promise')).toBe(1);
|
|
998
|
+
// malformed options fall back to defaults; the writes still transition
|
|
999
|
+
expect(stub.calls.length).toBeGreaterThanOrEqual(1);
|
|
1000
|
+
expect(root.querySelector("#out")!.getAttribute("data-copy")).toBe("3");
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
describe("DevTools", () => {
|
|
1005
|
+
const publications: { path: PublicizePath; meta: PublicizeMeta }[] = [];
|
|
1006
|
+
const renderers: QuarkRenderer[] = [];
|
|
1007
|
+
const installHook = () => {
|
|
1008
|
+
const hook: DevtoolsHook = {
|
|
1009
|
+
version: 1,
|
|
1010
|
+
inject: (renderer) => {
|
|
1011
|
+
renderers.push(renderer as QuarkRenderer);
|
|
1012
|
+
},
|
|
1013
|
+
publicize: (path, meta) => {
|
|
1014
|
+
publications.push({ path, meta });
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
Quark.attachDevtools(hook);
|
|
1018
|
+
};
|
|
1019
|
+
afterEach(() => {
|
|
1020
|
+
delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
|
|
1021
|
+
publications.length = 0;
|
|
1022
|
+
renderers.length = 0;
|
|
1023
|
+
});
|
|
1024
|
+
const transitions = () =>
|
|
1025
|
+
publications.filter((p) => pathMatches(p.path, ["quark", "transition"])).map((p) => p.meta);
|
|
1026
|
+
|
|
1027
|
+
it("publishes quark/transition start, settled and skip records", async () => {
|
|
1028
|
+
installHook();
|
|
1029
|
+
const { root } = mount(
|
|
1030
|
+
`<p id="out" data-n="1"></p>`,
|
|
1031
|
+
`#out { @view-transition (types: "count") { data-copy: attr("data-n"); } }`
|
|
1032
|
+
);
|
|
1033
|
+
await settle();
|
|
1034
|
+
const out = root.querySelector("#out")!;
|
|
1035
|
+
out.setAttribute("data-n", "2");
|
|
1036
|
+
await settle();
|
|
1037
|
+
expect(transitions()).toEqual([{ phase: "skip", reason: "unsupported", types: ["count"], paints: 1 }]);
|
|
1038
|
+
stub = installViewTransitionStub();
|
|
1039
|
+
out.setAttribute("data-n", "3");
|
|
1040
|
+
await settle();
|
|
1041
|
+
await stub.calls[0].updateCallbackDone;
|
|
1042
|
+
expect(transitions().slice(1)).toEqual([
|
|
1043
|
+
{ phase: "start", types: ["count"], paints: 1 },
|
|
1044
|
+
{ phase: "settled", result: "settled", types: ["count"], paints: 1 },
|
|
1045
|
+
]);
|
|
1046
|
+
publications.length = 0;
|
|
1047
|
+
// the same value again: the rule re-runs, nothing to animate
|
|
1048
|
+
out.setAttribute("data-n", "3");
|
|
1049
|
+
await settle();
|
|
1050
|
+
expect(transitions()).toEqual([{ phase: "skip", reason: "unchanged", types: ["count"], paints: 1 }]);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("sheets() names the block each write sits in; a block adds no rule", async () => {
|
|
1054
|
+
installHook();
|
|
1055
|
+
const { quark, register } = createSheet(
|
|
1056
|
+
`<p id="out"></p>`,
|
|
1057
|
+
`#out { data-a: 1; @view-transition (types: "t") { data-b: 2; span { data-c: 3; } } }`
|
|
1058
|
+
);
|
|
1059
|
+
register();
|
|
1060
|
+
await flush();
|
|
1061
|
+
const sheet = renderers.find((r) => r.kind === "quark")!.sheets().find((s) => s.sheetId === quark.id)!;
|
|
1062
|
+
expect(sheet.rules.map((r) => [r.selector, r.declarations])).toEqual([
|
|
1063
|
+
["#out", [{ key: "data-a", value: "1" }, { key: "data-b", value: "2", transition: '@view-transition (types: "t")' }]],
|
|
1064
|
+
["#out span", [{ key: "data-c", value: "3", transition: '@view-transition (types: "t")' }]],
|
|
1065
|
+
]);
|
|
1066
|
+
expect("transition" in sheet.rules[0].declarations[0]).toBe(false);
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
});
|