@excom/kit-utils 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/kit-utils.apply-exports.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/shrinkwrap-deps.json +4 -0
- package/batch-manager.ts +109 -0
- package/common.ts +234 -0
- package/config/rig.json +6 -0
- package/dom.ts +511 -0
- package/fetching.ts +120 -0
- package/form.ts +99 -0
- package/index.ts +9 -0
- package/load-dependency.ts +40 -0
- package/loop-guard.ts +187 -0
- package/package.json +39 -0
- package/property.ts +225 -0
- package/queue-manager.ts +214 -0
- package/rush-logs/kit-utils.apply-exports.cache.log +1 -0
- package/rush-logs/kit-utils.apply-exports.log +1 -0
- package/support/tests/batch-manager.test.ts +381 -0
- package/support/tests/common.test.ts +376 -0
- package/support/tests/dom.test.ts +852 -0
- package/support/tests/fetching.test.ts +230 -0
- package/support/tests/form.test.ts +297 -0
- package/support/tests/index.test.ts +20 -0
- package/support/tests/load-dependency.test.ts +98 -0
- package/support/tests/loop-guard.test.ts +225 -0
- package/support/tests/property.test.ts +355 -0
- package/support/tests/queue-manager.test.ts +471 -0
- package/support/tests/url.test.ts +111 -0
- package/tsconfig.json +5 -0
- package/url.ts +39 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { LoopGuard, type LoopGuardTrip } from "../../loop-guard";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
vi,
|
|
9
|
+
wait,
|
|
10
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
11
|
+
|
|
12
|
+
describe("LoopGuard", () => {
|
|
13
|
+
let trips: LoopGuardTrip[];
|
|
14
|
+
let logs: string[];
|
|
15
|
+
let off: () => void;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
LoopGuard.reset();
|
|
19
|
+
trips = [];
|
|
20
|
+
logs = [];
|
|
21
|
+
LoopGuard.configure({ log: (message) => logs.push(message) });
|
|
22
|
+
off = LoopGuard.onTrip((trip) => trips.push(trip));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
off();
|
|
27
|
+
LoopGuard.reset();
|
|
28
|
+
document.body.innerHTML = "";
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("contexts", () => {
|
|
32
|
+
it("is at depth 0 outside any context", () => {
|
|
33
|
+
expect(LoopGuard.current()).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("opens a context at max(current, inherited) and restores it after", () => {
|
|
37
|
+
const seen: number[] = [];
|
|
38
|
+
LoopGuard.run(3, () => {
|
|
39
|
+
seen.push(LoopGuard.current());
|
|
40
|
+
LoopGuard.run(1, () => seen.push(LoopGuard.current()));
|
|
41
|
+
LoopGuard.run(7, () => seen.push(LoopGuard.current()));
|
|
42
|
+
seen.push(LoopGuard.current());
|
|
43
|
+
});
|
|
44
|
+
expect(seen).toEqual([3, 3, 7, 3]);
|
|
45
|
+
expect(LoopGuard.current()).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("pops the context when fn throws", () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
LoopGuard.run(2, () => {
|
|
51
|
+
throw new Error("boom");
|
|
52
|
+
})
|
|
53
|
+
).toThrow("boom");
|
|
54
|
+
expect(LoopGuard.current()).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns fn's result", () => {
|
|
58
|
+
expect(LoopGuard.run(1, () => "value")).toBe("value");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("stamps", () => {
|
|
63
|
+
it("write() stamps (target, name) one deeper than the current context", () => {
|
|
64
|
+
const target = {};
|
|
65
|
+
LoopGuard.write(target, "x", () => {});
|
|
66
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(1);
|
|
67
|
+
LoopGuard.run(4, () => LoopGuard.write(target, "y", () => {}));
|
|
68
|
+
expect(LoopGuard.depthOf(target, "y")).toBe(5);
|
|
69
|
+
expect(LoopGuard.depthOf(target, "never")).toBe(0);
|
|
70
|
+
expect(LoopGuard.depthOf({}, "x")).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stamp() records a depth without applying the limit", () => {
|
|
74
|
+
LoopGuard.configure({ limit: 2 });
|
|
75
|
+
const target = {};
|
|
76
|
+
LoopGuard.run(10, () => LoopGuard.stamp(target, "x"));
|
|
77
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(11);
|
|
78
|
+
LoopGuard.stamp(target, "y", 99);
|
|
79
|
+
expect(LoopGuard.depthOf(target, "y")).toBe(99);
|
|
80
|
+
expect(trips).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stamps expire on the next macrotask, not within the task's microtasks", async () => {
|
|
84
|
+
const target = {};
|
|
85
|
+
LoopGuard.run(5, () => LoopGuard.write(target, "x", () => {}));
|
|
86
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(6);
|
|
87
|
+
await Promise.resolve();
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(6);
|
|
90
|
+
await wait(0);
|
|
91
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("a write in a later task starts a fresh chain even on a previously stamped address", async () => {
|
|
95
|
+
const target = {};
|
|
96
|
+
LoopGuard.run(20, () => LoopGuard.write(target, "x", () => {}));
|
|
97
|
+
await wait(0);
|
|
98
|
+
// an external writer (no context) does not inherit the old depth
|
|
99
|
+
LoopGuard.write(target, "x", () => {});
|
|
100
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("write() runs fn and returns its result", () => {
|
|
104
|
+
const fn = vi.fn(() => 42);
|
|
105
|
+
expect(LoopGuard.write({}, "x", fn)).toBe(42);
|
|
106
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the limit", () => {
|
|
111
|
+
it("defaults to 50 and is configurable", () => {
|
|
112
|
+
expect(LoopGuard.limit).toBe(50);
|
|
113
|
+
LoopGuard.configure({ limit: 7 });
|
|
114
|
+
expect(LoopGuard.limit).toBe(7);
|
|
115
|
+
LoopGuard.configure({ limit: 0 });
|
|
116
|
+
expect(LoopGuard.limit).toBe(7);
|
|
117
|
+
LoopGuard.configure({ limit: 3.9 });
|
|
118
|
+
expect(LoopGuard.limit).toBe(3);
|
|
119
|
+
LoopGuard.reset();
|
|
120
|
+
expect(LoopGuard.limit).toBe(50);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("drops a write past the limit, reports it and returns false", () => {
|
|
124
|
+
LoopGuard.configure({ limit: 3 });
|
|
125
|
+
const target = document.createElement("div");
|
|
126
|
+
const fn = vi.fn();
|
|
127
|
+
// depth 3 is still allowed …
|
|
128
|
+
expect(LoopGuard.run(2, () => LoopGuard.write(target, "x", fn))).not.toBe(
|
|
129
|
+
false
|
|
130
|
+
);
|
|
131
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
132
|
+
// … depth 4 is not
|
|
133
|
+
expect(LoopGuard.run(3, () => LoopGuard.write(target, "y", fn))).toBe(
|
|
134
|
+
false
|
|
135
|
+
);
|
|
136
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(LoopGuard.depthOf(target, "y")).toBe(0);
|
|
138
|
+
expect(trips).toHaveLength(1);
|
|
139
|
+
expect(trips[0]).toMatchObject({
|
|
140
|
+
kind: "depth",
|
|
141
|
+
target,
|
|
142
|
+
name: "y",
|
|
143
|
+
depth: 4,
|
|
144
|
+
limit: 3,
|
|
145
|
+
});
|
|
146
|
+
expect(trips[0].message).toContain('"y"');
|
|
147
|
+
expect(trips[0].message).toContain("<div>");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("chains climb one hop at a time through inherited stamps", () => {
|
|
151
|
+
LoopGuard.configure({ limit: 5 });
|
|
152
|
+
const target = {};
|
|
153
|
+
// simulate engine hops: each reaction inherits the stamp it reacts to
|
|
154
|
+
let hops = 0;
|
|
155
|
+
const hop = (name: string) =>
|
|
156
|
+
LoopGuard.run(LoopGuard.depthOf(target, name), () => {
|
|
157
|
+
const next = name === "a" ? "b" : "a";
|
|
158
|
+
if (LoopGuard.write(target, next, () => hops++) !== false) hop(next);
|
|
159
|
+
});
|
|
160
|
+
hop("a");
|
|
161
|
+
expect(hops).toBe(5);
|
|
162
|
+
expect(trips).toHaveLength(1);
|
|
163
|
+
expect(trips[0].depth).toBe(6);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("reporting", () => {
|
|
168
|
+
it("logs once per name per task but notifies listeners every time", () => {
|
|
169
|
+
LoopGuard.configure({ limit: 1 });
|
|
170
|
+
const target = {};
|
|
171
|
+
LoopGuard.run(1, () => {
|
|
172
|
+
LoopGuard.write(target, "x", () => {});
|
|
173
|
+
LoopGuard.write(target, "x", () => {});
|
|
174
|
+
LoopGuard.write(target, "z", () => {});
|
|
175
|
+
});
|
|
176
|
+
expect(trips).toHaveLength(3);
|
|
177
|
+
expect(logs).toHaveLength(2);
|
|
178
|
+
expect(logs[0]).toContain("Loop guard");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("logs again in a later task", async () => {
|
|
182
|
+
LoopGuard.configure({ limit: 1 });
|
|
183
|
+
const target = {};
|
|
184
|
+
LoopGuard.run(1, () => LoopGuard.write(target, "x", () => {}));
|
|
185
|
+
await wait(0);
|
|
186
|
+
LoopGuard.run(1, () => LoopGuard.write(target, "x", () => {}));
|
|
187
|
+
expect(logs).toHaveLength(2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("logs to console.error by default", () => {
|
|
191
|
+
LoopGuard.reset();
|
|
192
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
193
|
+
LoopGuard.report({
|
|
194
|
+
kind: "depth",
|
|
195
|
+
target: {},
|
|
196
|
+
name: "x",
|
|
197
|
+
depth: 51,
|
|
198
|
+
limit: 50,
|
|
199
|
+
message: "custom message",
|
|
200
|
+
});
|
|
201
|
+
expect(error).toHaveBeenCalledWith("custom message");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("unsubscribes listeners", () => {
|
|
205
|
+
off();
|
|
206
|
+
LoopGuard.configure({ limit: 1 });
|
|
207
|
+
LoopGuard.run(1, () => LoopGuard.write({}, "x", () => {}));
|
|
208
|
+
expect(trips).toEqual([]);
|
|
209
|
+
off = () => {};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reset() keeps listeners but clears contexts, stamps and configuration", () => {
|
|
213
|
+
const target = {};
|
|
214
|
+
LoopGuard.configure({ limit: 2 });
|
|
215
|
+
LoopGuard.stamp(target, "x", 9);
|
|
216
|
+
LoopGuard.reset();
|
|
217
|
+
expect(LoopGuard.depthOf(target, "x")).toBe(0);
|
|
218
|
+
expect(LoopGuard.current()).toBe(0);
|
|
219
|
+
expect(LoopGuard.limit).toBe(50);
|
|
220
|
+
LoopGuard.configure({ log: () => {} });
|
|
221
|
+
LoopGuard.run(50, () => LoopGuard.write(target, "x", () => {}));
|
|
222
|
+
expect(trips).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineObservableProperty,
|
|
3
|
+
isObservedProperty,
|
|
4
|
+
observeProperty,
|
|
5
|
+
} from "../../property";
|
|
6
|
+
import {
|
|
7
|
+
afterEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
vi,
|
|
12
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
13
|
+
|
|
14
|
+
describe("observeProperty", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("observes an own data property and keeps its value", () => {
|
|
20
|
+
const obj: { n: number } = { n: 1 };
|
|
21
|
+
const seen = vi.fn();
|
|
22
|
+
const stop = observeProperty(obj, "n", seen);
|
|
23
|
+
expect(obj.n).toBe(1);
|
|
24
|
+
obj.n = 2;
|
|
25
|
+
expect(obj.n).toBe(2);
|
|
26
|
+
expect(seen).toHaveBeenCalledWith(2, 1);
|
|
27
|
+
obj.n = 2;
|
|
28
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
29
|
+
stop();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("restores a data property when the last observer leaves", () => {
|
|
33
|
+
const obj: { n: number } = { n: 1 };
|
|
34
|
+
const stop = observeProperty(obj, "n", () => {});
|
|
35
|
+
obj.n = 5;
|
|
36
|
+
expect(Object.getOwnPropertyDescriptor(obj, "n")?.get).toBeTypeOf(
|
|
37
|
+
"function",
|
|
38
|
+
);
|
|
39
|
+
stop();
|
|
40
|
+
const restored = Object.getOwnPropertyDescriptor(obj, "n");
|
|
41
|
+
expect(restored?.value).toBe(5);
|
|
42
|
+
expect(restored?.writable).toBe(true);
|
|
43
|
+
expect(isObservedProperty(obj, "n")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("shadows an inherited accessor and still runs its setter", () => {
|
|
47
|
+
class Box {
|
|
48
|
+
#v = "a";
|
|
49
|
+
calls = 0;
|
|
50
|
+
get v() {
|
|
51
|
+
return this.#v;
|
|
52
|
+
}
|
|
53
|
+
set v(next: string) {
|
|
54
|
+
this.calls++;
|
|
55
|
+
this.#v = next;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const box = new Box();
|
|
59
|
+
const seen = vi.fn();
|
|
60
|
+
const stop = observeProperty(box, "v", seen);
|
|
61
|
+
box.v = "b";
|
|
62
|
+
expect(box.v).toBe("b");
|
|
63
|
+
expect(box.calls).toBe(1);
|
|
64
|
+
expect(seen).toHaveBeenCalledWith("b", "a");
|
|
65
|
+
stop();
|
|
66
|
+
expect(Object.getOwnPropertyDescriptor(box, "v")).toBeUndefined();
|
|
67
|
+
box.v = "c";
|
|
68
|
+
expect(box.calls).toBe(2);
|
|
69
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("observes a native element property set from JS", () => {
|
|
73
|
+
const input = document.createElement("input");
|
|
74
|
+
document.body.append(input);
|
|
75
|
+
const seen = vi.fn();
|
|
76
|
+
const stop = observeProperty(input, "value", seen);
|
|
77
|
+
input.value = "typed";
|
|
78
|
+
expect(input.value).toBe("typed");
|
|
79
|
+
expect(seen).toHaveBeenCalledWith("typed", "");
|
|
80
|
+
// the wrapper delegates to the platform accessor: attribute untouched
|
|
81
|
+
expect(input.getAttribute("value")).toBeNull();
|
|
82
|
+
stop();
|
|
83
|
+
expect(Object.getOwnPropertyDescriptor(input, "value")).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shares one wrapper between observers", () => {
|
|
87
|
+
const obj: { n: number } = { n: 0 };
|
|
88
|
+
const a = vi.fn();
|
|
89
|
+
const b = vi.fn();
|
|
90
|
+
const stopA = observeProperty(obj, "n", a);
|
|
91
|
+
const stopB = observeProperty(obj, "n", b);
|
|
92
|
+
obj.n = 1;
|
|
93
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(b).toHaveBeenCalledTimes(1);
|
|
95
|
+
stopA();
|
|
96
|
+
obj.n = 2;
|
|
97
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(b).toHaveBeenCalledTimes(2);
|
|
99
|
+
expect(isObservedProperty(obj, "n")).toBe(true);
|
|
100
|
+
stopB();
|
|
101
|
+
expect(isObservedProperty(obj, "n")).toBe(false);
|
|
102
|
+
expect(obj.n).toBe(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does nothing for read-only properties", () => {
|
|
106
|
+
const obj = Object.freeze({ n: 1 });
|
|
107
|
+
const stop = observeProperty(obj, "n", () => {});
|
|
108
|
+
expect(obj.n).toBe(1);
|
|
109
|
+
expect(isObservedProperty(obj, "n")).toBe(false);
|
|
110
|
+
stop();
|
|
111
|
+
const getterOnly = {
|
|
112
|
+
get g() {
|
|
113
|
+
return 1;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
expect(isObservedProperty(getterOnly, "g")).toBe(false);
|
|
117
|
+
observeProperty(getterOnly, "g", () => {})();
|
|
118
|
+
expect(getterOnly.g).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("observes a property that does not exist yet", () => {
|
|
122
|
+
const obj: Record<string, unknown> = {};
|
|
123
|
+
const seen = vi.fn();
|
|
124
|
+
const stop = observeProperty(obj, "later", seen);
|
|
125
|
+
expect(obj.later).toBeUndefined();
|
|
126
|
+
obj.later = { a: 1 };
|
|
127
|
+
expect(seen).toHaveBeenCalledWith({ a: 1 }, undefined);
|
|
128
|
+
stop();
|
|
129
|
+
expect(obj.later).toEqual({ a: 1 });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("defineObservableProperty", () => {
|
|
134
|
+
it("defines a storage-owning accessor when nothing observes", () => {
|
|
135
|
+
const obj: { n?: number } = {};
|
|
136
|
+
let store = 0;
|
|
137
|
+
defineObservableProperty(obj, "n", {
|
|
138
|
+
get: () => store,
|
|
139
|
+
set: (v) => {
|
|
140
|
+
store = (v as number) * 10;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
obj.n = 2;
|
|
144
|
+
expect(obj.n).toBe(20);
|
|
145
|
+
expect(Object.getOwnPropertyDescriptor(obj, "n")?.enumerable).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps an installed observer when the base is defined afterwards (upgrade)", () => {
|
|
149
|
+
// pre-upgrade: a plain data property with a value
|
|
150
|
+
const el = document.createElement("div") as HTMLElement & {
|
|
151
|
+
provision?: unknown;
|
|
152
|
+
};
|
|
153
|
+
el.provision = { early: true };
|
|
154
|
+
const seen = vi.fn();
|
|
155
|
+
const stop = observeProperty(el, "provision", seen);
|
|
156
|
+
// upgrade: the element class takes over storage
|
|
157
|
+
let store: unknown = null;
|
|
158
|
+
defineObservableProperty(el, "provision", {
|
|
159
|
+
get: () => store,
|
|
160
|
+
set: (v) => {
|
|
161
|
+
store = v;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
el.provision = { late: true };
|
|
165
|
+
expect(store).toEqual({ late: true });
|
|
166
|
+
expect(el.provision).toEqual({ late: true });
|
|
167
|
+
expect(seen).toHaveBeenLastCalledWith({ late: true }, null);
|
|
168
|
+
stop();
|
|
169
|
+
// the base accessor is what remains
|
|
170
|
+
el.provision = { after: true };
|
|
171
|
+
expect(store).toEqual({ after: true });
|
|
172
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("notifies after the base setter finished", () => {
|
|
176
|
+
const obj: { n?: number } = {};
|
|
177
|
+
const order: string[] = [];
|
|
178
|
+
let store = 0;
|
|
179
|
+
defineObservableProperty(obj, "n", {
|
|
180
|
+
get: () => store,
|
|
181
|
+
set: (v) => {
|
|
182
|
+
order.push("base");
|
|
183
|
+
store = v as number;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
observeProperty(obj, "n", () => order.push("observer"));
|
|
187
|
+
obj.n = 1;
|
|
188
|
+
expect(order).toEqual(["base", "observer"]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("falls through to a plain define when the wrapper was replaced", () => {
|
|
192
|
+
const obj: Record<string, unknown> = { n: 1 };
|
|
193
|
+
const seen = vi.fn();
|
|
194
|
+
const stop = observeProperty(obj, "n", seen);
|
|
195
|
+
// something redefines the property over the wrapper
|
|
196
|
+
Object.defineProperty(obj, "n", {
|
|
197
|
+
value: 5,
|
|
198
|
+
writable: true,
|
|
199
|
+
configurable: true,
|
|
200
|
+
enumerable: true,
|
|
201
|
+
});
|
|
202
|
+
let store = 0;
|
|
203
|
+
defineObservableProperty(obj, "n", {
|
|
204
|
+
get: () => store,
|
|
205
|
+
set: (v) => {
|
|
206
|
+
store = v as number;
|
|
207
|
+
},
|
|
208
|
+
enumerable: true,
|
|
209
|
+
});
|
|
210
|
+
obj.n = 7;
|
|
211
|
+
expect(store).toBe(7);
|
|
212
|
+
expect(obj.n).toBe(7);
|
|
213
|
+
expect(seen).not.toHaveBeenCalled();
|
|
214
|
+
expect(Object.getOwnPropertyDescriptor(obj, "n")?.enumerable).toBe(true);
|
|
215
|
+
stop();
|
|
216
|
+
expect(obj.n).toBe(7);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("installs the observer over an existing own accessor and restores it", () => {
|
|
220
|
+
const obj: Record<string, unknown> = {};
|
|
221
|
+
let store = "a";
|
|
222
|
+
defineObservableProperty(obj, "v", {
|
|
223
|
+
get: () => store,
|
|
224
|
+
set: (next) => {
|
|
225
|
+
store = next as string;
|
|
226
|
+
},
|
|
227
|
+
enumerable: true,
|
|
228
|
+
});
|
|
229
|
+
const seen = vi.fn();
|
|
230
|
+
const stop = observeProperty(obj, "v", seen);
|
|
231
|
+
obj.v = "b";
|
|
232
|
+
expect(seen).toHaveBeenCalledWith("b", "a");
|
|
233
|
+
stop();
|
|
234
|
+
const restored = Object.getOwnPropertyDescriptor(obj, "v");
|
|
235
|
+
expect(restored?.get).toBeTypeOf("function");
|
|
236
|
+
expect(restored?.enumerable).toBe(true);
|
|
237
|
+
obj.v = "c";
|
|
238
|
+
expect(store).toBe("c");
|
|
239
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("observeProperty edge cases", () => {
|
|
244
|
+
it("observes an inherited data property through an own shadow", () => {
|
|
245
|
+
const proto = { n: 1 };
|
|
246
|
+
const obj = Object.create(proto) as { n: number };
|
|
247
|
+
const seen = vi.fn();
|
|
248
|
+
const stop = observeProperty(obj, "n", seen);
|
|
249
|
+
expect(obj.n).toBe(1);
|
|
250
|
+
obj.n = 2;
|
|
251
|
+
expect(seen).toHaveBeenCalledWith(2, 1);
|
|
252
|
+
expect(proto.n).toBe(1);
|
|
253
|
+
stop();
|
|
254
|
+
// the shadow is removed; reads fall back to the prototype
|
|
255
|
+
expect(Object.getOwnPropertyDescriptor(obj, "n")).toBeUndefined();
|
|
256
|
+
expect(obj.n).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("leaves a non-configurable own accessor alone", () => {
|
|
260
|
+
const obj = {} as { x: number };
|
|
261
|
+
let store = 1;
|
|
262
|
+
Object.defineProperty(obj, "x", {
|
|
263
|
+
get: () => store,
|
|
264
|
+
set: (v: number) => {
|
|
265
|
+
store = v;
|
|
266
|
+
},
|
|
267
|
+
configurable: false,
|
|
268
|
+
});
|
|
269
|
+
const seen = vi.fn();
|
|
270
|
+
const stop = observeProperty(obj, "x", seen);
|
|
271
|
+
obj.x = 2;
|
|
272
|
+
expect(store).toBe(2);
|
|
273
|
+
expect(seen).not.toHaveBeenCalled();
|
|
274
|
+
expect(isObservedProperty(obj, "x")).toBe(false);
|
|
275
|
+
stop();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("does not restore when something redefined the property meanwhile", () => {
|
|
279
|
+
const obj: Record<string, unknown> = { n: 1 };
|
|
280
|
+
const stop = observeProperty(obj, "n", () => {});
|
|
281
|
+
Object.defineProperty(obj, "n", {
|
|
282
|
+
value: "redefined",
|
|
283
|
+
writable: false,
|
|
284
|
+
configurable: true,
|
|
285
|
+
});
|
|
286
|
+
stop();
|
|
287
|
+
expect(isObservedProperty(obj, "n")).toBe(false);
|
|
288
|
+
const descriptor = Object.getOwnPropertyDescriptor(obj, "n");
|
|
289
|
+
expect(descriptor?.value).toBe("redefined");
|
|
290
|
+
expect(descriptor?.writable).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("unsubscribing twice is harmless", () => {
|
|
294
|
+
const obj: { n: number } = { n: 1 };
|
|
295
|
+
const a = vi.fn();
|
|
296
|
+
const b = vi.fn();
|
|
297
|
+
const stopA = observeProperty(obj, "n", a);
|
|
298
|
+
const stopB = observeProperty(obj, "n", b);
|
|
299
|
+
stopA();
|
|
300
|
+
stopA();
|
|
301
|
+
expect(isObservedProperty(obj, "n")).toBe(true);
|
|
302
|
+
obj.n = 2;
|
|
303
|
+
expect(a).not.toHaveBeenCalled();
|
|
304
|
+
expect(b).toHaveBeenCalledWith(2, 1);
|
|
305
|
+
stopB();
|
|
306
|
+
expect(isObservedProperty(obj, "n")).toBe(false);
|
|
307
|
+
expect(Object.getOwnPropertyDescriptor(obj, "n")?.value).toBe(2);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("chains a reflected native accessor so the attribute still updates", () => {
|
|
311
|
+
const el = document.createElement("div");
|
|
312
|
+
document.body.append(el);
|
|
313
|
+
const seen = vi.fn();
|
|
314
|
+
const stop = observeProperty(el, "title", seen);
|
|
315
|
+
el.title = "hint";
|
|
316
|
+
expect(el.getAttribute("title")).toBe("hint");
|
|
317
|
+
expect(seen).toHaveBeenCalledWith("hint", "");
|
|
318
|
+
el.setAttribute("title", "changed");
|
|
319
|
+
// attribute writes bypass the setter: no notification, but reads are live
|
|
320
|
+
expect(el.title).toBe("changed");
|
|
321
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
322
|
+
stop();
|
|
323
|
+
expect(Object.getOwnPropertyDescriptor(el, "title")).toBeUndefined();
|
|
324
|
+
el.title = "after";
|
|
325
|
+
expect(el.getAttribute("title")).toBe("after");
|
|
326
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("observes a property set only later on a class instance accessor", () => {
|
|
330
|
+
class Widget {
|
|
331
|
+
#count = 0;
|
|
332
|
+
get count() {
|
|
333
|
+
return this.#count;
|
|
334
|
+
}
|
|
335
|
+
set count(v: number) {
|
|
336
|
+
this.#count = v;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const w = new Widget();
|
|
340
|
+
const seen = vi.fn();
|
|
341
|
+
const stop = observeProperty(w, "count", seen);
|
|
342
|
+
// the wrapper is an own property; the class accessor stays on the prototype
|
|
343
|
+
expect(Object.getOwnPropertyDescriptor(w, "count")?.get).toBeTypeOf(
|
|
344
|
+
"function",
|
|
345
|
+
);
|
|
346
|
+
w.count = 3;
|
|
347
|
+
expect(w.count).toBe(3);
|
|
348
|
+
expect(seen).toHaveBeenCalledWith(3, 0);
|
|
349
|
+
w.count = 3;
|
|
350
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
351
|
+
stop();
|
|
352
|
+
expect(Object.getOwnPropertyDescriptor(w, "count")).toBeUndefined();
|
|
353
|
+
expect(w.count).toBe(3);
|
|
354
|
+
});
|
|
355
|
+
});
|