@excom/neutron 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 (63) hide show
  1. package/.rush/temp/chunked-rush-logs/neutron.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/neutron.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 +9 -0
  12. package/package.json +45 -0
  13. package/rush-logs/neutron.apply-exports.cache.log +1 -0
  14. package/rush-logs/neutron.apply-exports.log +1 -0
  15. package/rush-logs/neutron.build_package-metas.cache.log +1 -0
  16. package/rush-logs/neutron.build_package-metas.log +1 -0
  17. package/src/command.ts +102 -0
  18. package/src/common-element.ts +377 -0
  19. package/src/constants.ts +101 -0
  20. package/src/devtools-hook.ts +93 -0
  21. package/src/lifecycle-configs.ts +304 -0
  22. package/src/neutron-element.ts +72 -0
  23. package/src/neutron-error.ts +6 -0
  24. package/src/neutron-internal.ts +550 -0
  25. package/src/neutron.ts +36 -0
  26. package/src/types/effect.types.ts +104 -0
  27. package/src/types/element.types.ts +263 -0
  28. package/src/types/index.ts +4 -0
  29. package/src/types/new.types.ts +159 -0
  30. package/src/types/shared.types.ts +25 -0
  31. package/src/utils/effect.ts +357 -0
  32. package/src/utils/element.ts +382 -0
  33. package/src/utils/index.ts +2 -0
  34. package/support/docs/COMMANDS.md +58 -0
  35. package/support/docs/COMPOSE.md +32 -0
  36. package/support/docs/DEBUG.md +11 -0
  37. package/support/docs/DEFINE.md +20 -0
  38. package/support/docs/EFFECTS.md +49 -0
  39. package/support/docs/EVENTS.md +57 -0
  40. package/support/docs/LIFECYCLES.md +69 -0
  41. package/support/docs/METHODS.md +49 -0
  42. package/support/docs/PROMISE_PROPS.md +29 -0
  43. package/support/docs/PROPS.md +64 -0
  44. package/support/docs/PROP_REACTIONS.md +35 -0
  45. package/support/docs/PROVISION.md +31 -0
  46. package/support/docs/README.md +118 -0
  47. package/support/docs/RECOMPOSE.md +70 -0
  48. package/support/docs/TYPESCRIPT.md +51 -0
  49. package/support/docs-sections.json +42 -0
  50. package/support/package-meta.json +129 -0
  51. package/support/tests/commands.test.ts +330 -0
  52. package/support/tests/common-element.test.ts +342 -0
  53. package/support/tests/devtools-hook.test.ts +209 -0
  54. package/support/tests/devtools-renderer.test.ts +125 -0
  55. package/support/tests/effects.test.ts +253 -0
  56. package/support/tests/element-config.test.ts +331 -0
  57. package/support/tests/entry.test.ts +68 -0
  58. package/support/tests/lifecycles.test.ts +489 -0
  59. package/support/tests/loop-guard.test.ts +162 -0
  60. package/support/tests/neutron.test.ts +1286 -0
  61. package/support/tests/recompose.test.ts +129 -0
  62. package/support/tests/utils.test.ts +75 -0
  63. package/tsconfig.json +5 -0
@@ -0,0 +1,489 @@
1
+ import { Neutron } from "../../src/neutron";
2
+ import { NeutronError } from "../../src/neutron-error";
3
+ import {
4
+ afterEach,
5
+ describe,
6
+ expect,
7
+ fixture,
8
+ it,
9
+ vi,
10
+ wait,
11
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
12
+ import { KitLogger } from "@excom/kit-logger";
13
+ import { TokenList } from "@excom/kit-utils";
14
+
15
+ const mountTag = async <T = any>(html: string): Promise<T> => {
16
+ const el = fixture<T>(html);
17
+ await wait(0);
18
+ return el;
19
+ };
20
+
21
+ describe("Lifecycles: onEffect", () => {
22
+ const effectFn = vi.fn();
23
+ Neutron({
24
+ tag: "effect-host",
25
+ props: { aValue: String, bValue: String, cValue: String },
26
+ })
27
+ .onEffect(["aValue", "bValue"], effectFn)
28
+ .define();
29
+
30
+ afterEach(() => {
31
+ document.body.innerHTML = "";
32
+ effectFn.mockClear();
33
+ });
34
+
35
+ it("runs once per batch with the previous values, only after mount", async () => {
36
+ const el = document.createElement("effect-host") as any;
37
+ // before mount: kept and flushed on first connect
38
+ el.aValue = "pre";
39
+ expect(effectFn).not.toHaveBeenCalled();
40
+ document.body.append(el);
41
+ expect(effectFn).toHaveBeenCalledTimes(1);
42
+ expect(effectFn.mock.calls[0][0]).toBe(el);
43
+ expect(effectFn.mock.calls[0][1]).toMatchObject({ aValue: null });
44
+ expect("bValue" in effectFn.mock.calls[0][1]).toBe(false);
45
+
46
+ effectFn.mockClear();
47
+ el.cValue = "unwatched";
48
+ expect(effectFn).not.toHaveBeenCalled();
49
+ el.bValue = "y";
50
+ expect(effectFn).toHaveBeenCalledTimes(1);
51
+ expect(effectFn.mock.calls[0][1]).toEqual({ bValue: null });
52
+
53
+ effectFn.mockClear();
54
+ el._n_.batch(() => {
55
+ el.aValue = "a2";
56
+ el.bValue = "b2";
57
+ });
58
+ expect(effectFn).toHaveBeenCalledTimes(1);
59
+ expect(effectFn.mock.calls[0][1]).toEqual({ aValue: "pre", bValue: "y" });
60
+
61
+ // mounting without any watched change does not run the handler
62
+ effectFn.mockClear();
63
+ await mountTag(`<effect-host c-value="only"></effect-host>`);
64
+ expect(effectFn).not.toHaveBeenCalled();
65
+
66
+ // reactions keep running on a detached element that was mounted before
67
+ el.remove();
68
+ await wait(0);
69
+ el.aValue = "after";
70
+ expect(effectFn).toHaveBeenCalledTimes(1);
71
+ expect(effectFn.mock.calls[0][1]).toEqual({ aValue: "a2" });
72
+ });
73
+ });
74
+
75
+ describe("Lifecycles: prop reactions guard their own prop", () => {
76
+ Neutron({
77
+ tag: "guard-host",
78
+ props: {
79
+ sourceText: String,
80
+ mirrorText: String,
81
+ loopText: String,
82
+ loopSet: String,
83
+ flagText: String,
84
+ resetText: String,
85
+ childEl: { type: HTMLElement, store: "weak" },
86
+ },
87
+ })
88
+ .onPropChanged("sourceText", ({ sourceText }) => ({
89
+ mirrorText: sourceText,
90
+ }))
91
+ .onPropChanged("loopText", () => ({ loopText: "loop" }))
92
+ .onPropChanged("childEl", () => ({
93
+ childEl: { title: "from-child-effect" },
94
+ }))
95
+ .onPropSet("loopSet", () => ({ loopSet: "again" }))
96
+ .onPropUnset("flagText", () => ({ flagText: null }))
97
+ .onPropUnset("resetText", () => ({ resetText: "restored" }))
98
+ .define();
99
+
100
+ afterEach(() => {
101
+ document.body.innerHTML = "";
102
+ });
103
+
104
+ it("allows effects on other props and child effects; throws on the reacted prop", async () => {
105
+ const el = await mountTag(`<guard-host></guard-host>`);
106
+ el.sourceText = "s";
107
+ expect(el.mirrorText).toBe("s");
108
+ // onPropChanged also fires on unset
109
+ el.sourceText = null;
110
+ expect(el.mirrorText).toBe(null);
111
+
112
+ expect(() => {
113
+ el.loopText = "x";
114
+ }).toThrow(NeutronError);
115
+ expect(() => {
116
+ el.loopText = "y";
117
+ }).toThrow("Cannot change prop loopText in its own onPropChanged handler.");
118
+ expect(() => {
119
+ el.loopSet = "x";
120
+ }).toThrow("Cannot set prop loopSet in its own onPropSet handler.");
121
+
122
+ el.flagText = "on";
123
+ expect(() => {
124
+ el.flagText = null;
125
+ }).toThrow("Cannot unset prop flagText in its own onPropUnset handler.");
126
+
127
+ // an unset handler may set its prop back to a truthy value
128
+ el.resetText = "on";
129
+ el.resetText = null;
130
+ expect(el.resetText).toBe("restored");
131
+
132
+ const child = document.createElement("span");
133
+ el.childEl = child;
134
+ expect(child.title).toBe("from-child-effect");
135
+
136
+ // reactions still run on a disconnected element that was mounted before
137
+ el.remove();
138
+ await wait(0);
139
+ el.resetText = null;
140
+ expect(el.resetText).toBe("restored");
141
+ });
142
+ });
143
+
144
+ describe("Lifecycles: onError", () => {
145
+ const errorFn = vi.fn();
146
+ Neutron({
147
+ tag: "error-host",
148
+ props: { isBroken: Boolean, errorText: String },
149
+ })
150
+ .onPropSet("isBroken", () => {
151
+ throw new Error("broken");
152
+ })
153
+ .onError(errorFn)
154
+ .define();
155
+
156
+ afterEach(() => {
157
+ document.body.innerHTML = "";
158
+ errorFn.mockReset();
159
+ });
160
+
161
+ it("routes handler errors to onError and applies its effect", async () => {
162
+ const el = await mountTag(`<error-host></error-host>`);
163
+ errorFn.mockImplementation((_el, error) => ({ errorText: error.message }));
164
+ expect(() => {
165
+ el.isBroken = true;
166
+ }).not.toThrow();
167
+ expect(errorFn).toHaveBeenCalledTimes(1);
168
+ expect(errorFn.mock.calls[0][0]).toBe(el);
169
+ expect(errorFn.mock.calls[0][1]).toBeInstanceOf(Error);
170
+ expect(el.errorText).toBe("broken");
171
+ });
172
+ });
173
+
174
+ describe("Lifecycles: adoption and moves", () => {
175
+ const adoptedFn = vi.fn();
176
+ const connectedFn = vi.fn();
177
+ const disconnectedFn = vi.fn();
178
+ Neutron({ tag: "move-host", props: {} })
179
+ .onAdopted(adoptedFn)
180
+ .onConnected((el) => {
181
+ connectedFn(el.isMoving);
182
+ })
183
+ .onDisconnected((el) => {
184
+ disconnectedFn(el.isMoving);
185
+ })
186
+ .define();
187
+
188
+ afterEach(() => {
189
+ document.body.innerHTML = "";
190
+ adoptedFn.mockClear();
191
+ connectedFn.mockClear();
192
+ disconnectedFn.mockClear();
193
+ });
194
+
195
+ it("adoptedCallback flags isAdopted until the next disconnect", async () => {
196
+ const el = await mountTag(`<move-host></move-host>`);
197
+ expect(el.isAdopted).toBe(false);
198
+ el.adoptedCallback();
199
+ expect(el.isAdopted).toBe(true);
200
+ expect(adoptedFn).toHaveBeenCalledTimes(1);
201
+ expect(adoptedFn.mock.calls[0][0]).toBe(el);
202
+ el.remove();
203
+ await wait(0);
204
+ expect(el.isAdopted).toBe(false);
205
+ expect(adoptedFn).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it("a synchronous remove + append is a move: both lifecycles run with isMoving", async () => {
209
+ const el = await mountTag(`<move-host></move-host>`);
210
+ expect(connectedFn).toHaveBeenCalledWith(false);
211
+ connectedFn.mockClear();
212
+
213
+ el.remove();
214
+ document.body.append(el);
215
+ expect(disconnectedFn).toHaveBeenCalledTimes(1);
216
+ expect(disconnectedFn).toHaveBeenCalledWith(true);
217
+ expect(connectedFn).toHaveBeenCalledTimes(1);
218
+ expect(connectedFn).toHaveBeenCalledWith(true);
219
+ expect(el.isMoving).toBe(false);
220
+ expect(el.isMounted).toBe(true);
221
+ expect(el.wasMounted).toBe(true);
222
+ // the queued microtask disconnect was consumed by the move
223
+ await wait(0);
224
+ expect(disconnectedFn).toHaveBeenCalledTimes(1);
225
+
226
+ // connectedMoveCallback delegates to the same disconnect + connect pair
227
+ el.connectedMoveCallback();
228
+ expect(disconnectedFn).toHaveBeenCalledTimes(2);
229
+ expect(disconnectedFn.mock.calls[1][0]).toBe(true);
230
+ expect(connectedFn).toHaveBeenCalledTimes(2);
231
+ expect(connectedFn.mock.calls[1][0]).toBe(true);
232
+ expect(el.isMounted).toBe(true);
233
+ });
234
+ });
235
+
236
+ describe("Lifecycles: registry", () => {
237
+ it("unregisters every lifecycle by handler identity, trimming names from shared entries", () => {
238
+ const B = Neutron({
239
+ tag: "off-host",
240
+ props: { aValue: String, bValue: String, loadPromise: Promise },
241
+ });
242
+ const f = vi.fn();
243
+ const g = vi.fn();
244
+ const L = () => B.builtConfig.lifecycles;
245
+
246
+ B.onConstructed(f).onConstructed(g).offConstructed(f);
247
+ expect(L().constructed).toEqual([[[], g]]);
248
+ B.onAdopted(f).offAdopted(f);
249
+ expect(L().adopted).toEqual([]);
250
+ B.onError(f).offError(f);
251
+ expect(L().error).toEqual([]);
252
+ B.onDisconnected(f).offDisconnected(f);
253
+ expect(L().disconnected).toEqual([]);
254
+
255
+ B.onEffect(["aValue", "bValue"], f).offEffect("aValue", f);
256
+ expect(L().effect).toEqual([[["bValue"], f]]);
257
+ B.offEffect("bValue", f);
258
+ expect(L().effect).toEqual([]);
259
+
260
+ B.onPropUnset("aValue", f).offPropUnset("aValue", f);
261
+ expect(L().propUnset).toEqual([]);
262
+ B.onPropChanged("aValue", f).offPropChanged(["aValue"], f);
263
+ expect(L().propChanged).toEqual([]);
264
+ B.onPromiseResolved("loadPromise", f).offPromiseResolved("loadPromise", f);
265
+ expect(L().promiseResolved).toEqual([]);
266
+ B.onPromiseRejected("loadPromise", f).offPromiseRejected("loadPromise", f);
267
+ expect(L().promiseRejected).toEqual([]);
268
+ B.onBroadcast("hum", f).offBroadcast("hum", f);
269
+ expect(L().broadcast).toEqual([]);
270
+
271
+ // a different handler leaves the entry untouched
272
+ B.onEvent("off-host-hit", f).offEvent("off-host-hit", g);
273
+ expect(L().event).toEqual([[["off-host-hit"], f]]);
274
+ B.offEvent("off-host-hit", f);
275
+ expect(L().event).toEqual([]);
276
+ B.onEventDefault("off-host-hit", f).offEventDefault("off-host-hit", f);
277
+ expect(L().eventDefault).toEqual([]);
278
+
279
+ // names are de-duplicated on registration and mirrored in the debug signature
280
+ B.onPropSet(["aValue", "aValue", "bValue"], f);
281
+ expect(L().propSet).toEqual([[["aValue", "bValue"], f]]);
282
+ expect((f as any)._logSignature).toBe('onPropSet(["aValue", "bValue"])');
283
+ B.onPropSet("aValue", g);
284
+ expect((g as any)._logSignature).toBe('onPropSet("aValue")');
285
+ });
286
+
287
+ it("defineMethods rejects protected names and non-functions", () => {
288
+ const B = Neutron({ tag: "methods-host", props: {} });
289
+ expect(() => B.defineMethods({ emit: () => ({}) })).toThrow(NeutronError);
290
+ expect(() => B.defineMethods({ emit: () => ({}) })).toThrow(
291
+ 'Cannot use protected name: "emit"'
292
+ );
293
+ expect(() => B.defineMethods({ connectedCallback: () => ({}) })).toThrow(
294
+ NeutronError
295
+ );
296
+ expect(() => B.defineMethods({ notCallable: {} as any })).toThrow(
297
+ "Method notCallable is not a function"
298
+ );
299
+ expect(B.builtConfig.methods).toEqual([]);
300
+ });
301
+
302
+ it("define() warns for an already-registered tag and forwards definition options", () => {
303
+ const warn = vi.spyOn(KitLogger, "warn").mockImplementation(() => {});
304
+ const B = Neutron({ tag: "define-host", props: {} });
305
+ B.define();
306
+ B.define();
307
+ expect(warn).toHaveBeenCalledTimes(1);
308
+ expect(warn.mock.calls[0][0]).toContain(
309
+ '"define-host", but it\'s already defined'
310
+ );
311
+
312
+ const defineSpy = vi.spyOn(customElements, "define");
313
+ const definitionOpts = {};
314
+ Neutron({ tag: "define-host-opts", props: {}, definitionOpts }).define();
315
+ expect(defineSpy).toHaveBeenLastCalledWith(
316
+ "define-host-opts",
317
+ expect.any(Function),
318
+ definitionOpts
319
+ );
320
+ vi.restoreAllMocks();
321
+ });
322
+ });
323
+
324
+ describe("Lifecycles: promise props", () => {
325
+ const resolvedA = vi.fn();
326
+ const resolvedB = vi.fn();
327
+ const rejected = vi.fn();
328
+ Neutron({
329
+ tag: "promise-host",
330
+ props: { loadPromise: Promise, resultText: String },
331
+ })
332
+ .onPromiseResolved("loadPromise", resolvedA)
333
+ .onPromiseResolved("loadPromise", resolvedB)
334
+ .onPromiseRejected("loadPromise", rejected)
335
+ .define();
336
+
337
+ afterEach(() => {
338
+ document.body.innerHTML = "";
339
+ });
340
+
341
+ it("notifies every handler with { prop: value } and drops stale settlements", async () => {
342
+ const el = await mountTag(`<promise-host></promise-host>`);
343
+ resolvedA.mockImplementation((_el, result) => ({
344
+ resultText: result.loadPromise,
345
+ }));
346
+
347
+ let resolveStale!: (v: string) => void;
348
+ const stale = new Promise<string>((r) => {
349
+ resolveStale = r;
350
+ });
351
+ let resolveFresh!: (v: string) => void;
352
+ const fresh = new Promise<string>((r) => {
353
+ resolveFresh = r;
354
+ });
355
+ el.loadPromise = stale;
356
+ // replacing a pending promise cancels it
357
+ el.loadPromise = fresh;
358
+ resolveStale("stale");
359
+ await wait(0);
360
+ expect(resolvedA).not.toHaveBeenCalled();
361
+
362
+ resolveFresh("fresh");
363
+ await wait(0);
364
+ expect(resolvedA).toHaveBeenCalledTimes(1);
365
+ expect(resolvedB).toHaveBeenCalledTimes(1);
366
+ expect(resolvedA.mock.calls[0][0]).toBe(el);
367
+ expect(resolvedA.mock.calls[0][1]).toEqual({ loadPromise: "fresh" });
368
+ expect(el.resultText).toBe("fresh");
369
+
370
+ el.loadPromise = Promise.reject(new Error("nope"));
371
+ await wait(0);
372
+ expect(rejected).toHaveBeenCalledTimes(1);
373
+ expect(rejected.mock.calls[0][1].loadPromise).toBeInstanceOf(Error);
374
+
375
+ // clearing resets the queue without settling anything
376
+ el.loadPromise = null;
377
+ await wait(0);
378
+ expect(resolvedA).toHaveBeenCalledTimes(1);
379
+ expect(rejected).toHaveBeenCalledTimes(1);
380
+ });
381
+ });
382
+
383
+ describe("Lifecycles: compose", () => {
384
+ it("merges config maps (later wins) and concatenates methods / lifecycles", () => {
385
+ const fa = vi.fn();
386
+ const fb = vi.fn();
387
+ const fc = vi.fn();
388
+ const A = Neutron({
389
+ tag: "compose-a",
390
+ props: { aValue: String, sharedText: String },
391
+ events: { ping: { prefixWithTag: true } },
392
+ broadcasts: { hum: {} },
393
+ })
394
+ .onConstructed(fa)
395
+ .onConnected(fa)
396
+ .onPropSet("aValue", fa)
397
+ .defineMethods({ fromA: fa });
398
+ const isValid = (v: unknown) => v !== "bad";
399
+ const B = Neutron({
400
+ tag: "compose-b",
401
+ props: { bValue: Number, sharedText: { type: String, isValid } },
402
+ events: { pong: {} },
403
+ broadcasts: { buzz: { prefixWithTag: true } },
404
+ })
405
+ .onConstructed(fb)
406
+ .onDisconnected(fb)
407
+ .onPropUnset("bValue", fb)
408
+ .onEvent("pong", fb)
409
+ .defineMethods({ fromB: fb });
410
+ const C = Neutron({ tag: "compose-c", props: {} })
411
+ .onConstructed(fc)
412
+ .onEffect("aValue", fc);
413
+
414
+ const conf = Neutron.compose([A, B, C]).builtConfig;
415
+ expect(conf.tag).toBe("compose-c");
416
+ expect(Object.keys(conf.props)).toEqual(["aValue", "sharedText", "bValue"]);
417
+ expect(conf.props.sharedText.isValid).toBe(isValid);
418
+ expect(conf.events).toEqual({ ping: { prefixWithTag: true }, pong: {} });
419
+ expect(conf.broadcasts).toEqual({ hum: {}, buzz: { prefixWithTag: true } });
420
+ expect(conf.methods).toEqual([
421
+ ["fromA", fa],
422
+ ["fromB", fb],
423
+ ]);
424
+ expect(conf.lifecycles.constructed).toEqual([
425
+ [[], fa],
426
+ [[], fb],
427
+ [[], fc],
428
+ ]);
429
+ expect(conf.lifecycles.connected).toEqual([[[], fa]]);
430
+ expect(conf.lifecycles.disconnected).toEqual([[[], fb]]);
431
+ expect(conf.lifecycles.propSet).toEqual([[["aValue"], fa]]);
432
+ expect(conf.lifecycles.propUnset).toEqual([[["bValue"], fb]]);
433
+ expect(conf.lifecycles.event).toEqual([[["pong"], fb]]);
434
+ expect(conf.lifecycles.effect).toEqual([[["aValue"], fc]]);
435
+ // sources are deep-cloned, never mutated
436
+ expect(A.builtConfig.props.bValue).toBeUndefined();
437
+ expect(conf.props.aValue).not.toBe(A.builtConfig.props.aValue);
438
+ expect(conf.props.aValue).toEqual(A.builtConfig.props.aValue);
439
+ });
440
+ });
441
+
442
+ describe("Lifecycles: attribute changes and default-prop reflection", () => {
443
+ const changedFn = vi.fn();
444
+ Neutron({
445
+ tag: "attr-host",
446
+ props: { tagNames: TokenList, countValue: Number },
447
+ })
448
+ .onPropChanged(["tagNames", "countValue"], changedFn)
449
+ .define();
450
+ Neutron({
451
+ tag: "reflect-host",
452
+ props: {},
453
+ reflectDefaultProps: ["isMounted", "wasMounted"],
454
+ })
455
+ .onConnected(vi.fn())
456
+ .define();
457
+
458
+ afterEach(() => {
459
+ document.body.innerHTML = "";
460
+ changedFn.mockClear();
461
+ });
462
+
463
+ it("ignores unknown attributes and attribute rewrites that parse to the same value", async () => {
464
+ const el = await mountTag(`<attr-host tag-names="a b"></attr-host>`);
465
+ changedFn.mockClear();
466
+ el.attributeChangedCallback("unknown-attr", null, "x");
467
+ el.setAttribute("tag-names", "a b ");
468
+ expect(changedFn).not.toHaveBeenCalled();
469
+ el.setAttribute("tag-names", "a b c");
470
+ expect(changedFn).toHaveBeenCalledTimes(1);
471
+ expect(el.tagNames).toEqual(["a", "b", "c"]);
472
+ el.setAttribute("count-value", "12");
473
+ expect(el.countValue).toBe(12);
474
+ el.setAttribute("count-value", "12.0");
475
+ expect(changedFn).toHaveBeenCalledTimes(2);
476
+ });
477
+
478
+ it("reflects the chosen default props as attributes", async () => {
479
+ const el = await mountTag(`<reflect-host></reflect-host>`);
480
+ expect(el.hasAttribute("is-mounted")).toBe(true);
481
+ expect(el.hasAttribute("was-mounted")).toBe(false);
482
+ el.remove();
483
+ await wait(0);
484
+ expect(el.hasAttribute("is-mounted")).toBe(false);
485
+ expect(el.hasAttribute("was-mounted")).toBe(true);
486
+ expect(el.isMounted).toBe(false);
487
+ expect(el.wasMounted).toBe(true);
488
+ });
489
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Runaway effect loops are cut by the shared loop guard (kit-utils):
3
+ * two reactions feeding each other inside one element (Neutron already
4
+ * rejects a handler writing its *own* prop), two elements feeding each
5
+ * other, and an attribute chain that arrives already deep.
6
+ */
7
+ import { Neutron } from "../../src/neutron";
8
+ import {
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ fixture,
14
+ it,
15
+ wait,
16
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
17
+ import { LoopGuard, type LoopGuardTrip } from "@excom/kit-utils";
18
+
19
+ Neutron({
20
+ tag: "loop-counter",
21
+ props: {
22
+ count: Number,
23
+ other: Number,
24
+ payload: Object,
25
+ echo: Object,
26
+ source: Number,
27
+ mirror: Number,
28
+ },
29
+ })
30
+ // attribute-backed ping-pong: never settles
31
+ .onPropChanged("count", ({ count }) => ({ other: count + 1 }))
32
+ .onPropChanged("other", ({ other }) => ({ count: other + 1 }))
33
+ // same through rich (prop-backed) properties
34
+ .onPropChanged("payload", ({ payload }) => payload && { echo: { n: payload.n + 1 } })
35
+ .onPropChanged("echo", ({ echo }) => echo && { payload: { n: echo.n + 1 } })
36
+ // legitimate reaction: one dependent write
37
+ .onPropChanged("source", ({ source }) => ({ mirror: source * 2 }))
38
+ .define();
39
+
40
+ Neutron({
41
+ tag: "loop-ping",
42
+ props: { ping: Number, partner: { type: HTMLElement, store: "weak" } },
43
+ })
44
+ .onPropChanged("ping", ({ ping, partner }) =>
45
+ partner ? { partner: { pong: ping + 1 } } : undefined
46
+ )
47
+ .define();
48
+
49
+ Neutron({
50
+ tag: "loop-pong",
51
+ props: { pong: Number, partner: { type: HTMLElement, store: "weak" } },
52
+ })
53
+ .onPropChanged("pong", ({ pong, partner }) =>
54
+ partner ? { partner: { ping: pong + 1 } } : undefined
55
+ )
56
+ .define();
57
+
58
+ describe("Neutron: loop guard", () => {
59
+ let trips: LoopGuardTrip[];
60
+ let off: () => void;
61
+
62
+ beforeEach(() => {
63
+ LoopGuard.reset();
64
+ LoopGuard.configure({ limit: 5, log: () => {} });
65
+ trips = [];
66
+ off = LoopGuard.onTrip((trip) => trips.push(trip));
67
+ });
68
+
69
+ afterEach(() => {
70
+ off();
71
+ LoopGuard.reset();
72
+ document.body.innerHTML = "";
73
+ });
74
+
75
+ const mount = async <T,>(html: string) => {
76
+ const el = fixture<T>(html);
77
+ await wait(0);
78
+ return el;
79
+ };
80
+
81
+ it("cuts two reactions feeding each other through attribute-backed props", async () => {
82
+ const el = await mount<any>(`<loop-counter></loop-counter>`);
83
+ expect(() => {
84
+ el.count = 1;
85
+ }).not.toThrow();
86
+ expect(trips.length).toBeGreaterThanOrEqual(1);
87
+ expect(trips[0].target).toBe(el);
88
+ expect(el.count + el.other).toBeLessThanOrEqual(LoopGuard.limit * 6);
89
+ // attributes and props agree: no write was half-applied
90
+ expect(el.getAttribute("count")).toBe(String(el.count));
91
+ expect(el.getAttribute("other")).toBe(String(el.other));
92
+ // the element keeps working afterwards
93
+ await wait(0);
94
+ const settled = el.count;
95
+ await wait(0);
96
+ expect(el.count).toBe(settled);
97
+ });
98
+
99
+ it("cuts two reactions feeding each other through rich props", async () => {
100
+ const el = await mount<any>(`<loop-counter></loop-counter>`);
101
+ expect(() => {
102
+ el.payload = { n: 0 };
103
+ }).not.toThrow();
104
+ expect(trips.length).toBeGreaterThanOrEqual(1);
105
+ expect(trips[0]).toMatchObject({ kind: "batch", target: el });
106
+ // the handler's dependency list (plus Neutron' mount marker)
107
+ expect(trips[0].name).toMatch(/^(payload|echo)\b/);
108
+ expect(el.payload.n + el.echo.n).toBeLessThanOrEqual(LoopGuard.limit * 6);
109
+ });
110
+
111
+ it("cuts two elements feeding each other through child effects", async () => {
112
+ const root = await mount<HTMLElement>(
113
+ `<div><loop-ping></loop-ping><loop-pong></loop-pong></div>`
114
+ );
115
+ const ping = root.querySelector("loop-ping") as any;
116
+ const pong = root.querySelector("loop-pong") as any;
117
+ ping.partner = pong;
118
+ pong.partner = ping;
119
+ expect(() => {
120
+ ping.ping = 0;
121
+ }).not.toThrow();
122
+ expect(trips.length).toBeGreaterThanOrEqual(1);
123
+ expect(ping.ping + pong.pong).toBeLessThanOrEqual(LoopGuard.limit * 4);
124
+ expect(pong.getAttribute("pong")).toBe(String(pong.pong));
125
+ expect(ping.getAttribute("ping")).toBe(String(ping.ping));
126
+ });
127
+
128
+ it("does not trip a legitimate one-step reaction, however often it fires", async () => {
129
+ const el = await mount<any>(`<loop-counter></loop-counter>`);
130
+ for (let i = 1; i <= 30; i++) {
131
+ el.source = i;
132
+ }
133
+ expect(el.mirror).toBe(60);
134
+ expect(el.getAttribute("mirror")).toBe("60");
135
+ expect(trips).toEqual([]);
136
+ });
137
+
138
+ it("attributeChangedCallback inherits the chain that wrote the attribute", async () => {
139
+ const el = await mount<any>(`<loop-counter></loop-counter>`);
140
+ // a guarded writer (e.g. a Quark rule) stamps the attribute at the
141
+ // limit: the element's own reaction is the hop that crosses it
142
+ LoopGuard.run(LoopGuard.limit - 1, () =>
143
+ LoopGuard.write(el, "count", () => el.setAttribute("count", "5"))
144
+ );
145
+ expect(el.count).toBe(5);
146
+ expect(el.hasAttribute("other")).toBe(false);
147
+ expect(trips).toHaveLength(1);
148
+ expect(trips[0]).toMatchObject({
149
+ kind: "depth",
150
+ target: el,
151
+ name: "other",
152
+ depth: LoopGuard.limit + 1,
153
+ });
154
+ // a fresh (external) write in a later task starts a new chain, which
155
+ // runs its full length before being cut again
156
+ await wait(0);
157
+ el.setAttribute("count", "7");
158
+ expect(el.other).toBeGreaterThanOrEqual(8);
159
+ expect(el.count + el.other).toBeLessThanOrEqual(LoopGuard.limit * 4 + 16);
160
+ expect(trips.length).toBeGreaterThanOrEqual(2);
161
+ });
162
+ });