als-browser 1.0.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.
@@ -0,0 +1,594 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { AsyncLocalStorage } from "../async-local-storage";
3
+ import { AsyncContextFrame } from "../async-context-frame";
4
+ import { patchObservers } from "./observers";
5
+
6
+ describe("Observers patch", () => {
7
+ let als: AsyncLocalStorage<number>;
8
+
9
+ beforeEach(() => {
10
+ AsyncContextFrame.set(undefined);
11
+ als = new AsyncLocalStorage<number>();
12
+ patchObservers();
13
+ });
14
+
15
+ describe("MutationObserver", () => {
16
+ it("should preserve context through MutationObserver", async () => {
17
+ if (typeof MutationObserver === "undefined") {
18
+ return; // Skip if not available
19
+ }
20
+
21
+ const target = document.createElement("div");
22
+ document.body.appendChild(target);
23
+
24
+ const promise = new Promise<number | undefined>((resolve) => {
25
+ als.run(100, () => {
26
+ const observer = new MutationObserver(() => {
27
+ resolve(als.getStore());
28
+ observer.disconnect();
29
+ });
30
+
31
+ observer.observe(target, { childList: true });
32
+ target.appendChild(document.createElement("span"));
33
+ });
34
+ });
35
+
36
+ const result = await promise;
37
+ expect(result).toBe(100);
38
+
39
+ document.body.removeChild(target);
40
+ });
41
+
42
+ it("should preserve context through multiple mutations", async () => {
43
+ if (typeof MutationObserver === "undefined") {
44
+ return; // Skip if not available
45
+ }
46
+
47
+ const target = document.createElement("div");
48
+ document.body.appendChild(target);
49
+ const stores: (number | undefined)[] = [];
50
+
51
+ const promise = new Promise<void>((resolve) => {
52
+ als.run(200, () => {
53
+ let count = 0;
54
+ const observer = new MutationObserver(() => {
55
+ stores.push(als.getStore());
56
+ count++;
57
+ if (count >= 3) {
58
+ observer.disconnect();
59
+ resolve();
60
+ }
61
+ });
62
+
63
+ observer.observe(target, { childList: true });
64
+ target.appendChild(document.createElement("span"));
65
+ target.appendChild(document.createElement("div"));
66
+ target.appendChild(document.createElement("p"));
67
+ });
68
+ });
69
+
70
+ await promise;
71
+ expect(stores.length).toBeGreaterThanOrEqual(1);
72
+ stores.forEach((store) => {
73
+ expect(store).toBe(200);
74
+ });
75
+
76
+ document.body.removeChild(target);
77
+ });
78
+
79
+ it("should preserve context when observer is created in one context and fires in another", async () => {
80
+ if (typeof MutationObserver === "undefined") {
81
+ return; // Skip if not available
82
+ }
83
+
84
+ const target = document.createElement("div");
85
+ document.body.appendChild(target);
86
+
87
+ const promise = new Promise<number | undefined>((resolve) => {
88
+ als.run(300, () => {
89
+ const observer = new MutationObserver(() => {
90
+ resolve(als.getStore());
91
+ observer.disconnect();
92
+ });
93
+
94
+ observer.observe(target, { childList: true });
95
+ });
96
+
97
+ // Trigger mutation outside of run context
98
+ setTimeout(() => {
99
+ target.appendChild(document.createElement("span"));
100
+ }, 10);
101
+ });
102
+
103
+ const result = await promise;
104
+ expect(result).toBe(300);
105
+
106
+ document.body.removeChild(target);
107
+ });
108
+
109
+ it("should receive mutation records", async () => {
110
+ if (typeof MutationObserver === "undefined") {
111
+ return; // Skip if not available
112
+ }
113
+
114
+ const target = document.createElement("div");
115
+ document.body.appendChild(target);
116
+
117
+ const promise = new Promise<{
118
+ recordsCount: number;
119
+ store: number | undefined;
120
+ }>((resolve) => {
121
+ als.run(400, () => {
122
+ const observer = new MutationObserver((mutations) => {
123
+ resolve({
124
+ recordsCount: mutations.length,
125
+ store: als.getStore(),
126
+ });
127
+ observer.disconnect();
128
+ });
129
+
130
+ observer.observe(target, { childList: true });
131
+ target.appendChild(document.createElement("span"));
132
+ });
133
+ });
134
+
135
+ const result = await promise;
136
+ expect(result.recordsCount).toBeGreaterThan(0);
137
+ expect(result.store).toBe(400);
138
+
139
+ document.body.removeChild(target);
140
+ });
141
+
142
+ it("should preserve class name as MutationObserver", () => {
143
+ if (typeof MutationObserver === "undefined") {
144
+ return; // Skip if not available
145
+ }
146
+
147
+ expect(MutationObserver.name).toBe("MutationObserver");
148
+ });
149
+ });
150
+
151
+ describe("ResizeObserver", () => {
152
+ it("should preserve context through ResizeObserver", async () => {
153
+ if (typeof ResizeObserver === "undefined") {
154
+ return; // Skip if not available
155
+ }
156
+
157
+ const target = document.createElement("div");
158
+ document.body.appendChild(target);
159
+
160
+ const promise = new Promise<number | undefined>((resolve) => {
161
+ als.run(500, () => {
162
+ const observer = new ResizeObserver(() => {
163
+ resolve(als.getStore());
164
+ observer.disconnect();
165
+ });
166
+
167
+ observer.observe(target);
168
+ // Trigger a resize by changing size
169
+ target.style.width = "100px";
170
+ target.style.height = "100px";
171
+ });
172
+ });
173
+
174
+ // Give the observer time to trigger
175
+ await new Promise((r) => setTimeout(r, 100));
176
+ const result = await Promise.race([
177
+ promise,
178
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
179
+ ]);
180
+
181
+ // ResizeObserver might not be fully functional in test environment
182
+ if (result !== undefined) {
183
+ expect(result).toBe(500);
184
+ }
185
+
186
+ document.body.removeChild(target);
187
+ });
188
+
189
+ it("should receive resize entries", async () => {
190
+ if (typeof ResizeObserver === "undefined") {
191
+ return; // Skip if not available
192
+ }
193
+
194
+ const target = document.createElement("div");
195
+ document.body.appendChild(target);
196
+
197
+ const promise = new Promise<{
198
+ hasEntries: boolean;
199
+ store: number | undefined;
200
+ }>((resolve) => {
201
+ als.run(600, () => {
202
+ const observer = new ResizeObserver((entries) => {
203
+ resolve({
204
+ hasEntries: entries.length > 0,
205
+ store: als.getStore(),
206
+ });
207
+ observer.disconnect();
208
+ });
209
+
210
+ observer.observe(target);
211
+ target.style.width = "200px";
212
+ target.style.height = "200px";
213
+ });
214
+ });
215
+
216
+ await new Promise((r) => setTimeout(r, 100));
217
+ const result = await Promise.race([
218
+ promise,
219
+ new Promise<{ hasEntries: boolean; store: undefined }>((r) =>
220
+ setTimeout(() => r({ hasEntries: false, store: undefined }), 200)
221
+ ),
222
+ ]);
223
+
224
+ if (result.store !== undefined) {
225
+ expect(result.hasEntries).toBe(true);
226
+ expect(result.store).toBe(600);
227
+ }
228
+
229
+ document.body.removeChild(target);
230
+ });
231
+
232
+ it("should preserve class name as ResizeObserver", () => {
233
+ if (typeof ResizeObserver === "undefined") {
234
+ return; // Skip if not available
235
+ }
236
+
237
+ expect(ResizeObserver.name).toBe("ResizeObserver");
238
+ });
239
+ });
240
+
241
+ describe("IntersectionObserver", () => {
242
+ it("should preserve context through IntersectionObserver", async () => {
243
+ if (typeof IntersectionObserver === "undefined") {
244
+ return; // Skip if not available
245
+ }
246
+
247
+ const target = document.createElement("div");
248
+ document.body.appendChild(target);
249
+
250
+ const promise = new Promise<number | undefined>((resolve) => {
251
+ als.run(700, () => {
252
+ const observer = new IntersectionObserver(() => {
253
+ resolve(als.getStore());
254
+ observer.disconnect();
255
+ });
256
+
257
+ observer.observe(target);
258
+ });
259
+ });
260
+
261
+ // Give the observer time to trigger
262
+ await new Promise((r) => setTimeout(r, 100));
263
+ const result = await Promise.race([
264
+ promise,
265
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
266
+ ]);
267
+
268
+ // IntersectionObserver might not trigger in test environment
269
+ if (result !== undefined) {
270
+ expect(result).toBe(700);
271
+ }
272
+
273
+ document.body.removeChild(target);
274
+ });
275
+
276
+ it("should preserve context with options", async () => {
277
+ if (typeof IntersectionObserver === "undefined") {
278
+ return; // Skip if not available
279
+ }
280
+
281
+ const target = document.createElement("div");
282
+ document.body.appendChild(target);
283
+
284
+ const promise = new Promise<number | undefined>((resolve) => {
285
+ als.run(800, () => {
286
+ const observer = new IntersectionObserver(
287
+ () => {
288
+ resolve(als.getStore());
289
+ observer.disconnect();
290
+ },
291
+ {
292
+ threshold: 0.5,
293
+ }
294
+ );
295
+
296
+ observer.observe(target);
297
+ });
298
+ });
299
+
300
+ await new Promise((r) => setTimeout(r, 100));
301
+ const result = await Promise.race([
302
+ promise,
303
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
304
+ ]);
305
+
306
+ if (result !== undefined) {
307
+ expect(result).toBe(800);
308
+ }
309
+
310
+ document.body.removeChild(target);
311
+ });
312
+
313
+ it("should receive intersection entries", async () => {
314
+ if (typeof IntersectionObserver === "undefined") {
315
+ return; // Skip if not available
316
+ }
317
+
318
+ const target = document.createElement("div");
319
+ document.body.appendChild(target);
320
+
321
+ const promise = new Promise<{
322
+ hasEntries: boolean;
323
+ store: number | undefined;
324
+ }>((resolve) => {
325
+ als.run(900, () => {
326
+ const observer = new IntersectionObserver((entries) => {
327
+ resolve({
328
+ hasEntries: entries.length > 0,
329
+ store: als.getStore(),
330
+ });
331
+ observer.disconnect();
332
+ });
333
+
334
+ observer.observe(target);
335
+ });
336
+ });
337
+
338
+ await new Promise((r) => setTimeout(r, 100));
339
+ const result = await Promise.race([
340
+ promise,
341
+ new Promise<{ hasEntries: boolean; store: undefined }>((r) =>
342
+ setTimeout(() => r({ hasEntries: false, store: undefined }), 200)
343
+ ),
344
+ ]);
345
+
346
+ if (result.store !== undefined) {
347
+ expect(result.hasEntries).toBe(true);
348
+ expect(result.store).toBe(900);
349
+ }
350
+
351
+ document.body.removeChild(target);
352
+ });
353
+
354
+ it("should preserve class name as IntersectionObserver", () => {
355
+ if (typeof IntersectionObserver === "undefined") {
356
+ return; // Skip if not available
357
+ }
358
+
359
+ expect(IntersectionObserver.name).toBe("IntersectionObserver");
360
+ });
361
+ });
362
+
363
+ describe("PerformanceObserver", () => {
364
+ it("should preserve context through PerformanceObserver", async () => {
365
+ if (typeof PerformanceObserver === "undefined") {
366
+ return; // Skip if not available
367
+ }
368
+
369
+ const promise = new Promise<number | undefined>((resolve) => {
370
+ als.run(1000, () => {
371
+ try {
372
+ const observer = new PerformanceObserver(() => {
373
+ resolve(als.getStore());
374
+ observer.disconnect();
375
+ });
376
+
377
+ observer.observe({ entryTypes: ["measure"] });
378
+
379
+ // Create a performance measure to trigger the observer
380
+ performance.mark("test-start");
381
+ performance.mark("test-end");
382
+ performance.measure("test-measure", "test-start", "test-end");
383
+ } catch (e) {
384
+ // PerformanceObserver might not be fully supported
385
+ resolve(undefined);
386
+ }
387
+ });
388
+ });
389
+
390
+ // Give the observer time to trigger
391
+ await new Promise((r) => setTimeout(r, 100));
392
+ const result = await Promise.race([
393
+ promise,
394
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
395
+ ]);
396
+
397
+ // PerformanceObserver might not be fully functional in test environment
398
+ if (result !== undefined) {
399
+ expect(result).toBe(1000);
400
+ }
401
+ });
402
+
403
+ it("should receive performance entries", async () => {
404
+ if (typeof PerformanceObserver === "undefined") {
405
+ return; // Skip if not available
406
+ }
407
+
408
+ const promise = new Promise<{
409
+ hasEntries: boolean;
410
+ store: number | undefined;
411
+ }>((resolve) => {
412
+ als.run(1100, () => {
413
+ try {
414
+ const observer = new PerformanceObserver((list) => {
415
+ resolve({
416
+ hasEntries: list.getEntries().length > 0,
417
+ store: als.getStore(),
418
+ });
419
+ observer.disconnect();
420
+ });
421
+
422
+ observer.observe({ entryTypes: ["measure"] });
423
+
424
+ performance.mark("start-1100");
425
+ performance.mark("end-1100");
426
+ performance.measure("measure-1100", "start-1100", "end-1100");
427
+ } catch (e) {
428
+ resolve({ hasEntries: false, store: undefined });
429
+ }
430
+ });
431
+ });
432
+
433
+ await new Promise((r) => setTimeout(r, 100));
434
+ const result = await Promise.race([
435
+ promise,
436
+ new Promise<{ hasEntries: boolean; store: undefined }>((r) =>
437
+ setTimeout(() => r({ hasEntries: false, store: undefined }), 200)
438
+ ),
439
+ ]);
440
+
441
+ if (result.store !== undefined) {
442
+ expect(result.hasEntries).toBe(true);
443
+ expect(result.store).toBe(1100);
444
+ }
445
+ });
446
+
447
+ it("should preserve context with different entry types", async () => {
448
+ if (typeof PerformanceObserver === "undefined") {
449
+ return; // Skip if not available
450
+ }
451
+
452
+ const promise = new Promise<number | undefined>((resolve) => {
453
+ als.run(1200, () => {
454
+ try {
455
+ const observer = new PerformanceObserver(() => {
456
+ resolve(als.getStore());
457
+ observer.disconnect();
458
+ });
459
+
460
+ observer.observe({ entryTypes: ["mark", "measure"] });
461
+
462
+ performance.mark("test-mark-1200");
463
+ } catch (e) {
464
+ resolve(undefined);
465
+ }
466
+ });
467
+ });
468
+
469
+ await new Promise((r) => setTimeout(r, 100));
470
+ const result = await Promise.race([
471
+ promise,
472
+ new Promise<undefined>((r) => setTimeout(() => r(undefined), 200)),
473
+ ]);
474
+
475
+ if (result !== undefined) {
476
+ expect(result).toBe(1200);
477
+ }
478
+ });
479
+
480
+ it("should preserve class name as PerformanceObserver", () => {
481
+ if (typeof PerformanceObserver === "undefined") {
482
+ return; // Skip if not available
483
+ }
484
+
485
+ expect(PerformanceObserver.name).toBe("PerformanceObserver");
486
+ });
487
+ });
488
+
489
+ describe("Multiple observers", () => {
490
+ it("should preserve context for multiple different observers", async () => {
491
+ if (
492
+ typeof MutationObserver === "undefined" ||
493
+ typeof PerformanceObserver === "undefined"
494
+ ) {
495
+ return; // Skip if not available
496
+ }
497
+
498
+ const target = document.createElement("div");
499
+ document.body.appendChild(target);
500
+
501
+ const results: Array<{ type: string; store: number | undefined }> = [];
502
+
503
+ const promise = new Promise<void>((resolve) => {
504
+ als.run(1300, () => {
505
+ const mutationObserver = new MutationObserver(() => {
506
+ results.push({ type: "mutation", store: als.getStore() });
507
+ mutationObserver.disconnect();
508
+ if (results.length >= 2) resolve();
509
+ });
510
+
511
+ mutationObserver.observe(target, { childList: true });
512
+
513
+ try {
514
+ const performanceObserver = new PerformanceObserver(() => {
515
+ results.push({ type: "performance", store: als.getStore() });
516
+ performanceObserver.disconnect();
517
+ if (results.length >= 2) resolve();
518
+ });
519
+
520
+ performanceObserver.observe({ entryTypes: ["measure"] });
521
+
522
+ performance.mark("multi-start");
523
+ performance.mark("multi-end");
524
+ performance.measure("multi-measure", "multi-start", "multi-end");
525
+ } catch (e) {
526
+ // PerformanceObserver might not work, just resolve
527
+ resolve();
528
+ }
529
+
530
+ target.appendChild(document.createElement("span"));
531
+ });
532
+ });
533
+
534
+ await new Promise((r) => setTimeout(r, 100));
535
+ await Promise.race([
536
+ promise,
537
+ new Promise<void>((r) => setTimeout(() => r(), 200)),
538
+ ]);
539
+
540
+ results.forEach((result) => {
541
+ expect(result.store).toBe(1300);
542
+ });
543
+
544
+ document.body.removeChild(target);
545
+ });
546
+
547
+ it("should isolate contexts for observers in different runs", async () => {
548
+ if (typeof MutationObserver === "undefined") {
549
+ return; // Skip if not available
550
+ }
551
+
552
+ const target1 = document.createElement("div");
553
+ const target2 = document.createElement("div");
554
+ document.body.appendChild(target1);
555
+ document.body.appendChild(target2);
556
+
557
+ const results: number[] = [];
558
+
559
+ const promise = new Promise<void>((resolve) => {
560
+ let count = 0;
561
+
562
+ als.run(1400, () => {
563
+ const observer = new MutationObserver(() => {
564
+ results.push(als.getStore()!);
565
+ observer.disconnect();
566
+ count++;
567
+ if (count >= 2) resolve();
568
+ });
569
+
570
+ observer.observe(target1, { childList: true });
571
+ target1.appendChild(document.createElement("span"));
572
+ });
573
+
574
+ als.run(1500, () => {
575
+ const observer = new MutationObserver(() => {
576
+ results.push(als.getStore()!);
577
+ observer.disconnect();
578
+ count++;
579
+ if (count >= 2) resolve();
580
+ });
581
+
582
+ observer.observe(target2, { childList: true });
583
+ target2.appendChild(document.createElement("span"));
584
+ });
585
+ });
586
+
587
+ await promise;
588
+ expect(results.sort()).toEqual([1400, 1500]);
589
+
590
+ document.body.removeChild(target1);
591
+ document.body.removeChild(target2);
592
+ });
593
+ });
594
+ });
@@ -0,0 +1,112 @@
1
+ import { AsyncLocalStorage } from "../async-local-storage";
2
+ import { patch, unpatch } from "./patch-helper";
3
+
4
+ /**
5
+ * Patch Observer APIs to preserve async context.
6
+ * This ensures that observer callbacks maintain their async context when they execute.
7
+ *
8
+ * Patched observers:
9
+ * - MutationObserver
10
+ * - ResizeObserver
11
+ * - IntersectionObserver
12
+ * - PerformanceObserver
13
+ */
14
+ export function patchObservers(): void {
15
+ // Patch MutationObserver
16
+ if (typeof MutationObserver !== "undefined") {
17
+ patch(
18
+ globalThis as any,
19
+ "MutationObserver",
20
+ (OriginalMutationObserver: typeof MutationObserver) => {
21
+ const PatchedClass = class extends OriginalMutationObserver {
22
+ constructor(callback: MutationCallback) {
23
+ super(AsyncLocalStorage.bind(callback));
24
+ }
25
+ };
26
+ // Preserve the original class name
27
+ Object.defineProperty(PatchedClass, "name", {
28
+ value: "MutationObserver",
29
+ });
30
+ return PatchedClass as any;
31
+ }
32
+ );
33
+ }
34
+
35
+ // Patch ResizeObserver
36
+ if (typeof ResizeObserver !== "undefined") {
37
+ patch(
38
+ globalThis as any,
39
+ "ResizeObserver",
40
+ (OriginalResizeObserver: typeof ResizeObserver) => {
41
+ const PatchedClass = class extends OriginalResizeObserver {
42
+ constructor(callback: ResizeObserverCallback) {
43
+ super(AsyncLocalStorage.bind(callback));
44
+ }
45
+ };
46
+ Object.defineProperty(PatchedClass, "name", {
47
+ value: "ResizeObserver",
48
+ });
49
+ return PatchedClass as any;
50
+ }
51
+ );
52
+ }
53
+
54
+ // Patch IntersectionObserver
55
+ if (typeof IntersectionObserver !== "undefined") {
56
+ patch(
57
+ globalThis as any,
58
+ "IntersectionObserver",
59
+ (OriginalIntersectionObserver: typeof IntersectionObserver) => {
60
+ const PatchedClass = class extends OriginalIntersectionObserver {
61
+ constructor(
62
+ callback: IntersectionObserverCallback,
63
+ options?: IntersectionObserverInit
64
+ ) {
65
+ super(AsyncLocalStorage.bind(callback), options);
66
+ }
67
+ };
68
+ Object.defineProperty(PatchedClass, "name", {
69
+ value: "IntersectionObserver",
70
+ });
71
+ return PatchedClass as any;
72
+ }
73
+ );
74
+ }
75
+
76
+ // Patch PerformanceObserver
77
+ if (typeof PerformanceObserver !== "undefined") {
78
+ patch(
79
+ globalThis as any,
80
+ "PerformanceObserver",
81
+ (OriginalPerformanceObserver: typeof PerformanceObserver) => {
82
+ const PatchedClass = class extends OriginalPerformanceObserver {
83
+ constructor(callback: PerformanceObserverCallback) {
84
+ super(AsyncLocalStorage.bind(callback));
85
+ }
86
+ };
87
+ Object.defineProperty(PatchedClass, "name", {
88
+ value: "PerformanceObserver",
89
+ });
90
+ return PatchedClass as any;
91
+ }
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Remove all Observer patches, restoring original behavior.
98
+ */
99
+ export function unpatchObservers(): void {
100
+ if (typeof MutationObserver !== "undefined") {
101
+ unpatch(globalThis as any, "MutationObserver");
102
+ }
103
+ if (typeof ResizeObserver !== "undefined") {
104
+ unpatch(globalThis as any, "ResizeObserver");
105
+ }
106
+ if (typeof IntersectionObserver !== "undefined") {
107
+ unpatch(globalThis as any, "IntersectionObserver");
108
+ }
109
+ if (typeof PerformanceObserver !== "undefined") {
110
+ unpatch(globalThis as any, "PerformanceObserver");
111
+ }
112
+ }