@praxisjs/decorators 0.4.2 → 0.4.3

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 (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/__tests__/component.test.d.ts +2 -0
  3. package/dist/__tests__/component.test.d.ts.map +1 -0
  4. package/dist/__tests__/component.test.js +137 -0
  5. package/dist/__tests__/component.test.js.map +1 -0
  6. package/dist/__tests__/events.test.d.ts +2 -0
  7. package/dist/__tests__/events.test.d.ts.map +1 -0
  8. package/dist/__tests__/events.test.js +190 -0
  9. package/dist/__tests__/events.test.js.map +1 -0
  10. package/dist/__tests__/functions.test.js +157 -0
  11. package/dist/__tests__/functions.test.js.map +1 -1
  12. package/dist/__tests__/lazy.test.d.ts +2 -0
  13. package/dist/__tests__/lazy.test.d.ts.map +1 -0
  14. package/dist/__tests__/lazy.test.js +165 -0
  15. package/dist/__tests__/lazy.test.js.map +1 -0
  16. package/dist/__tests__/memo.test.js +13 -0
  17. package/dist/__tests__/memo.test.js.map +1 -1
  18. package/dist/__tests__/properties.test.js +191 -1
  19. package/dist/__tests__/properties.test.js.map +1 -1
  20. package/dist/__tests__/slot.test.d.ts +2 -0
  21. package/dist/__tests__/slot.test.d.ts.map +1 -0
  22. package/dist/__tests__/slot.test.js +115 -0
  23. package/dist/__tests__/slot.test.js.map +1 -0
  24. package/dist/__tests__/virtual.test.d.ts +2 -0
  25. package/dist/__tests__/virtual.test.d.ts.map +1 -0
  26. package/dist/__tests__/virtual.test.js +201 -0
  27. package/dist/__tests__/virtual.test.js.map +1 -0
  28. package/dist/component/lazy.d.ts +1 -1
  29. package/dist/component/lazy.d.ts.map +1 -1
  30. package/dist/component/lazy.js +4 -1
  31. package/dist/component/lazy.js.map +1 -1
  32. package/dist/component/virtual.d.ts +1 -1
  33. package/dist/component/virtual.d.ts.map +1 -1
  34. package/dist/functions/watch.d.ts.map +1 -1
  35. package/dist/functions/watch.js +10 -2
  36. package/dist/functions/watch.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/__tests__/component.test.ts +156 -0
  39. package/src/__tests__/events.test.ts +236 -0
  40. package/src/__tests__/functions.test.ts +207 -0
  41. package/src/__tests__/lazy.test.ts +220 -0
  42. package/src/__tests__/memo.test.ts +16 -0
  43. package/src/__tests__/properties.test.ts +249 -1
  44. package/src/__tests__/slot.test.ts +141 -0
  45. package/src/__tests__/virtual.test.ts +241 -0
  46. package/src/component/lazy.ts +4 -2
  47. package/src/component/virtual.tsx +1 -1
  48. package/src/functions/watch.ts +11 -2
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { StatefulComponent } from "@praxisjs/core";
4
+
5
+ import { createCommand } from "../events/command";
6
+ import { Emit } from "../events/emit";
7
+ import { readProp } from "../events/helper";
8
+ import { OnCommand } from "../events/on-command";
9
+
10
+ // ── Helpers ───────────────────────────────────────────────────────────────────
11
+
12
+ class TestComponent extends StatefulComponent {
13
+ render() { return null; }
14
+ }
15
+
16
+ function methodCtx(name: string) {
17
+ const initializers: Array<(this: unknown) => void> = [];
18
+ return {
19
+ ctx: {
20
+ name,
21
+ kind: "method" as const,
22
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
23
+ } as ClassMethodDecoratorContext,
24
+ run(instance: unknown) { initializers.forEach((fn) => { fn.call(instance); }); },
25
+ };
26
+ }
27
+
28
+ // ── createCommand ─────────────────────────────────────────────────────────────
29
+
30
+ describe("createCommand", () => {
31
+ it("trigger calls all subscribed handlers with the argument", () => {
32
+ const cmd = createCommand<number>();
33
+ const h1 = vi.fn();
34
+ const h2 = vi.fn();
35
+ cmd.subscribe(h1);
36
+ cmd.subscribe(h2);
37
+ cmd.trigger(42);
38
+ expect(h1).toHaveBeenCalledWith(42);
39
+ expect(h2).toHaveBeenCalledWith(42);
40
+ });
41
+
42
+ it("trigger does nothing when no handlers are subscribed", () => {
43
+ const cmd = createCommand<string>();
44
+ expect(() => cmd.trigger("hello")).not.toThrow();
45
+ });
46
+
47
+ it("subscribe returns an unsubscribe function", () => {
48
+ const cmd = createCommand<void>();
49
+ const handler = vi.fn();
50
+ const unsub = cmd.subscribe(handler);
51
+ unsub();
52
+ cmd.trigger();
53
+ expect(handler).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("void command can be triggered without args", () => {
57
+ const cmd = createCommand();
58
+ const handler = vi.fn();
59
+ cmd.subscribe(handler);
60
+ cmd.trigger(undefined as unknown as void);
61
+ expect(handler).toHaveBeenCalledOnce();
62
+ });
63
+ });
64
+
65
+ // ── readProp ──────────────────────────────────────────────────────────────────
66
+
67
+ describe("readProp", () => {
68
+ it("returns value from _rawProps when defined", () => {
69
+ const instance = new TestComponent({ myProp: "fromParent" });
70
+ expect(readProp(instance, "myProp")).toBe("fromParent");
71
+ });
72
+
73
+ it("falls back to _defaults when _rawProps does not define the prop", () => {
74
+ const instance = new TestComponent();
75
+ instance._defaults.myProp = "default";
76
+ expect(readProp(instance, "myProp")).toBe("default");
77
+ });
78
+
79
+ it("returns undefined when prop is not in either", () => {
80
+ const instance = new TestComponent();
81
+ expect(readProp(instance, "missing")).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ // ── Emit ─────────────────────────────────────────────────────────────────────
86
+
87
+ describe("Emit decorator", () => {
88
+ it("calls the prop callback with the return value of the method", () => {
89
+ const { ctx, run } = methodCtx("handleInput");
90
+ const original = function (this: object, value: string) { return value.toUpperCase(); };
91
+ Emit("onChange")(original as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
92
+
93
+ const callback = vi.fn();
94
+ const instance = new TestComponent({ onChange: callback });
95
+ run(instance);
96
+
97
+ const method = (instance as unknown as Record<string, (v: string) => void>).handleInput;
98
+ method.call(instance, "hello");
99
+ expect(callback).toHaveBeenCalledWith("HELLO");
100
+ });
101
+
102
+ it("calls the prop callback with the method args when return value is undefined", () => {
103
+ const { ctx, run } = methodCtx("doSomething");
104
+ const original = function (this: object, a: number, b: number) { return undefined; };
105
+ Emit("onDo")(original as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
106
+
107
+ const callback = vi.fn();
108
+ const instance = new TestComponent({ onDo: callback });
109
+ run(instance);
110
+
111
+ const method = (instance as unknown as Record<string, (a: number, b: number) => void>).doSomething;
112
+ method.call(instance, 1, 2);
113
+ expect(callback).toHaveBeenCalledWith(1, 2);
114
+ });
115
+
116
+ it("calls the prop callback with no args when return is undefined and no args", () => {
117
+ const { ctx, run } = methodCtx("fire");
118
+ const original = function (this: object) { return undefined; };
119
+ Emit("onFire")(original as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
120
+
121
+ const callback = vi.fn();
122
+ const instance = new TestComponent({ onFire: callback });
123
+ run(instance);
124
+
125
+ const method = (instance as unknown as Record<string, () => void>).fire;
126
+ method.call(instance);
127
+ expect(callback).toHaveBeenCalledOnce();
128
+ expect(callback).toHaveBeenCalledWith();
129
+ });
130
+
131
+ it("does not throw when the prop callback is not a function", () => {
132
+ const { ctx, run } = methodCtx("submit");
133
+ const original = function (this: object) { return "result"; };
134
+ Emit("onSubmit")(original as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
135
+
136
+ const instance = new TestComponent(); // no onSubmit prop
137
+ run(instance);
138
+
139
+ const method = (instance as unknown as Record<string, () => void>).submit;
140
+ expect(() => method.call(instance)).not.toThrow();
141
+ });
142
+
143
+ it("still returns the original method's return value", () => {
144
+ const { ctx, run } = methodCtx("compute");
145
+ const original = function (this: object) { return 99; };
146
+ Emit("onResult")(original as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
147
+
148
+ const callback = vi.fn();
149
+ const instance = new TestComponent({ onResult: callback });
150
+ run(instance);
151
+
152
+ const method = (instance as unknown as Record<string, () => number>).compute;
153
+ const result = method.call(instance);
154
+ expect(result).toBe(99);
155
+ });
156
+ });
157
+
158
+ // ── OnCommand ─────────────────────────────────────────────────────────────────
159
+
160
+ describe("OnCommand decorator", () => {
161
+ it("subscribes to the command on mount and calls the method when triggered", () => {
162
+ const { ctx, run } = methodCtx("handleCmd");
163
+ const handler = vi.fn();
164
+ OnCommand("myCommand")(handler as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
165
+
166
+ const cmd = createCommand<string>();
167
+ const instance = new TestComponent({ myCommand: cmd });
168
+ run(instance);
169
+
170
+ instance.onMount?.();
171
+ cmd.trigger("payload");
172
+ expect(handler).toHaveBeenCalledWith("payload");
173
+ });
174
+
175
+ it("unsubscribes from the command on unmount", () => {
176
+ const { ctx, run } = methodCtx("onMsg");
177
+ const handler = vi.fn();
178
+ OnCommand("msg")(handler as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
179
+
180
+ const cmd = createCommand<string>();
181
+ const instance = new TestComponent({ msg: cmd });
182
+ run(instance);
183
+
184
+ instance.onMount?.();
185
+ instance.onUnmount?.();
186
+ cmd.trigger("after-unmount");
187
+ expect(handler).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it("warns when the command prop is not provided", () => {
191
+ const { ctx, run } = methodCtx("onMissing");
192
+ OnCommand("missingProp")(vi.fn() as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
193
+
194
+ const instance = new TestComponent(); // no missingProp
195
+ run(instance);
196
+
197
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
198
+ instance.onMount?.();
199
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[OnCommand]"));
200
+ warn.mockRestore();
201
+ });
202
+
203
+ it("warns when the command prop is not a valid Command", () => {
204
+ const { ctx, run } = methodCtx("onBadCmd");
205
+ OnCommand("badProp")(vi.fn() as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
206
+
207
+ const instance = new TestComponent({ badProp: "not-a-command" });
208
+ run(instance);
209
+
210
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
211
+ instance.onMount?.();
212
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[OnCommand]"));
213
+ warn.mockRestore();
214
+ });
215
+
216
+ it("preserves the original onMount and onUnmount implementations", () => {
217
+ const { ctx, run } = methodCtx("onEvt");
218
+ OnCommand("evt")(vi.fn() as never, ctx as unknown as ClassMethodDecoratorContext<StatefulComponent>);
219
+
220
+ const cmd = createCommand<void>();
221
+ const instance = new TestComponent({ evt: cmd });
222
+ run(instance);
223
+
224
+ const mountSpy = vi.fn();
225
+ const unmountSpy = vi.fn();
226
+ const prevMount = instance.onMount?.bind(instance);
227
+ const prevUnmount = instance.onUnmount?.bind(instance);
228
+ instance.onMount = function () { mountSpy(); prevMount?.(); };
229
+ instance.onUnmount = function () { unmountSpy(); prevUnmount?.(); };
230
+
231
+ instance.onMount();
232
+ instance.onUnmount();
233
+ expect(mountSpy).toHaveBeenCalled();
234
+ expect(unmountSpy).toHaveBeenCalled();
235
+ });
236
+ });
@@ -129,6 +129,73 @@ describe("Retry", () => {
129
129
  await expect(wrapped.call({})).rejects.toThrow();
130
130
  expect(onRetry).toHaveBeenCalledTimes(2); // attempts 1 and 2, not the last
131
131
  });
132
+
133
+ it("maxAttempts=1 fails immediately without retrying", async () => {
134
+ const original = vi.fn(async () => { throw new Error("instant fail"); });
135
+ const ctx = mockMethodContext("once");
136
+ const wrapped = Retry(1)(original, ctx as unknown as ClassMethodDecoratorContext);
137
+
138
+ await expect(wrapped.call({})).rejects.toThrow("instant fail");
139
+ expect(original).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("non-Error throw is wrapped in an Error", async () => {
143
+ const original = vi.fn(async () => {
144
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
145
+ throw "string error";
146
+ });
147
+ const ctx = mockMethodContext("strThrow");
148
+ const wrapped = Retry(1)(original, ctx as unknown as ClassMethodDecoratorContext);
149
+
150
+ await expect(wrapped.call({})).rejects.toThrow("string error");
151
+ });
152
+
153
+ it("maxAttempts=0 throws immediately without calling the function", async () => {
154
+ const original = vi.fn(async () => "ok");
155
+ const ctx = mockMethodContext("never");
156
+ const wrapped = Retry(0)(original, ctx as unknown as ClassMethodDecoratorContext);
157
+
158
+ await expect(wrapped.call({})).rejects.toThrow("Unknown error");
159
+ expect(original).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it("onRetry receives the correct attempt number", async () => {
163
+ const attempts: number[] = [];
164
+ const original = vi.fn(async () => { throw new Error("fail"); });
165
+ const ctx = mockMethodContext("numbered");
166
+ const wrapped = Retry(3, {
167
+ onRetry: (_err, attempt) => { attempts.push(attempt); },
168
+ })(original, ctx as unknown as ClassMethodDecoratorContext);
169
+
170
+ await expect(wrapped.call({})).rejects.toThrow();
171
+ expect(attempts).toEqual([1, 2]); // attempt 1 failed → onRetry(1), attempt 2 failed → onRetry(2)
172
+ });
173
+
174
+ it("backoff multiplies delay correctly", async () => {
175
+ vi.useFakeTimers();
176
+ const callTimes: number[] = [];
177
+ const original = vi.fn(async () => {
178
+ callTimes.push(Date.now());
179
+ throw new Error("fail");
180
+ });
181
+ const ctx = mockMethodContext("backoffTest");
182
+ const wrapped = Retry(3, { delay: 100, backoff: 2 })(
183
+ original,
184
+ ctx as unknown as ClassMethodDecoratorContext,
185
+ );
186
+
187
+ const p = wrapped.call({});
188
+ const rejection = expect(p).rejects.toThrow("fail");
189
+ await vi.runAllTimersAsync();
190
+ await rejection;
191
+
192
+ expect(callTimes.length).toBe(3);
193
+ // Attempt 2 runs 100ms after attempt 1
194
+ expect(callTimes[1] - callTimes[0]).toBeGreaterThanOrEqual(100);
195
+ // Attempt 3 runs 200ms after attempt 2 (backoff × 2)
196
+ expect(callTimes[2] - callTimes[1]).toBeGreaterThanOrEqual(200);
197
+ vi.useRealTimers();
198
+ });
132
199
  });
133
200
 
134
201
  // ── Log ──────────────────────────────────────────────────────────────────────
@@ -221,6 +288,66 @@ describe("Log", () => {
221
288
  });
222
289
  });
223
290
 
291
+ // ── Log (additional branches) ─────────────────────────────────────────────────
292
+
293
+ describe("Log — time option", () => {
294
+ it("includes elapsed time in sync result log when time=true", () => {
295
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
296
+ const original = vi.fn(() => "value");
297
+ const ctx = mockMethodContext("timed");
298
+ const wrapped = Log({ args: false, result: true, time: true })(
299
+ original,
300
+ ctx as unknown as ClassMethodDecoratorContext,
301
+ );
302
+
303
+ class Svc {}
304
+ wrapped.call(new Svc());
305
+ // Third argument should be a string containing "ms"
306
+ expect(spy).toHaveBeenCalledWith(
307
+ expect.stringContaining("returned:"),
308
+ "value",
309
+ expect.stringContaining("ms"),
310
+ );
311
+ spy.mockRestore();
312
+ });
313
+
314
+ it("includes elapsed time in async result log when time=true", async () => {
315
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
316
+ const original = vi.fn(async () => "async-val");
317
+ const ctx = mockMethodContext("asyncTimed");
318
+ const wrapped = Log({ args: false, result: true, time: true })(
319
+ original,
320
+ ctx as unknown as ClassMethodDecoratorContext,
321
+ );
322
+
323
+ class Svc {}
324
+ await wrapped.call(new Svc());
325
+ expect(spy).toHaveBeenCalledWith(
326
+ expect.stringContaining("resolved:"),
327
+ "async-val",
328
+ expect.stringContaining("ms"),
329
+ );
330
+ spy.mockRestore();
331
+ });
332
+
333
+ it("skips result log when result=false", () => {
334
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
335
+ const original = vi.fn(() => 42);
336
+ const ctx = mockMethodContext("noResult");
337
+ const wrapped = Log({ args: false, result: false })(
338
+ original,
339
+ ctx as unknown as ClassMethodDecoratorContext,
340
+ );
341
+
342
+ class Svc {}
343
+ wrapped.call(new Svc());
344
+ // Should not log a "returned:" line
345
+ const calls = spy.mock.calls.flat().join(" ");
346
+ expect(calls).not.toContain("returned:");
347
+ spy.mockRestore();
348
+ });
349
+ });
350
+
224
351
  // ── Bind ─────────────────────────────────────────────────────────────────────
225
352
 
226
353
  describe("Bind", () => {
@@ -286,6 +413,49 @@ describe("Debounce", () => {
286
413
  expect(fn).toHaveBeenCalledOnce();
287
414
  vi.useRealTimers();
288
415
  });
416
+
417
+ it("multiple instances have independent timers", () => {
418
+ vi.useFakeTimers();
419
+ const fn = vi.fn();
420
+ const ctx = mockMethodContext("act");
421
+ Debounce(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
422
+
423
+ const obj1 = {};
424
+ const obj2 = {};
425
+ ctx.runInitializers(obj1);
426
+ ctx.runInitializers(obj2);
427
+
428
+ const m1 = (obj1 as Record<string, (...a: unknown[]) => void>).act;
429
+ const m2 = (obj2 as Record<string, (...a: unknown[]) => void>).act;
430
+
431
+ m1("from-1");
432
+ m2("from-2");
433
+
434
+ vi.advanceTimersByTime(100);
435
+ // Both timers fire independently
436
+ expect(fn).toHaveBeenCalledTimes(2);
437
+ expect(fn).toHaveBeenCalledWith("from-1");
438
+ expect(fn).toHaveBeenCalledWith("from-2");
439
+ vi.useRealTimers();
440
+ });
441
+
442
+ it("delay=0 fires after the next macrotask", () => {
443
+ vi.useFakeTimers();
444
+ const fn = vi.fn();
445
+ const ctx = mockMethodContext("instant");
446
+ Debounce(0)(fn, ctx as unknown as ClassMethodDecoratorContext);
447
+
448
+ const obj = {};
449
+ ctx.runInitializers(obj);
450
+ const method = (obj as Record<string, (...a: unknown[]) => void>).instant;
451
+
452
+ method("a");
453
+ expect(fn).not.toHaveBeenCalled();
454
+ vi.advanceTimersByTime(0);
455
+ expect(fn).toHaveBeenCalledTimes(1);
456
+ expect(fn).toHaveBeenCalledWith("a");
457
+ vi.useRealTimers();
458
+ });
289
459
  });
290
460
 
291
461
  // ── Throttle ─────────────────────────────────────────────────────────────────
@@ -339,4 +509,41 @@ describe("Throttle", () => {
339
509
  expect(fn).toHaveBeenCalledTimes(2);
340
510
  vi.useRealTimers();
341
511
  });
512
+
513
+ it("passes the first call's arguments to the underlying function", () => {
514
+ vi.useFakeTimers();
515
+ const fn = vi.fn();
516
+ const ctx = mockMethodContext("click");
517
+ Throttle(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
518
+
519
+ const obj = {};
520
+ ctx.runInitializers(obj);
521
+ const method = (obj as Record<string, (...a: unknown[]) => void>).click;
522
+
523
+ method("first-arg");
524
+ method("second-arg");
525
+ expect(fn).toHaveBeenCalledWith("first-arg");
526
+ vi.useRealTimers();
527
+ });
528
+
529
+ it("multiple instances throttle independently", () => {
530
+ vi.useFakeTimers();
531
+ const fn = vi.fn();
532
+ const ctx = mockMethodContext("ping");
533
+ Throttle(100)(fn, ctx as unknown as ClassMethodDecoratorContext);
534
+
535
+ const obj1 = {};
536
+ const obj2 = {};
537
+ ctx.runInitializers(obj1);
538
+ ctx.runInitializers(obj2);
539
+
540
+ const m1 = (obj1 as Record<string, (...a: unknown[]) => void>).ping;
541
+ const m2 = (obj2 as Record<string, (...a: unknown[]) => void>).ping;
542
+
543
+ m1("a");
544
+ m2("b");
545
+ // Both first calls go through
546
+ expect(fn).toHaveBeenCalledTimes(2);
547
+ vi.useRealTimers();
548
+ });
342
549
  });
@@ -0,0 +1,220 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
3
+
4
+ import { StatefulComponent } from "@praxisjs/core";
5
+ import { Lazy } from "../component/lazy";
6
+
7
+ type AnyConstructor = new (...args: unknown[]) => StatefulComponent;
8
+
9
+ function applyLazy<T extends AnyConstructor>(cls: T, placeholder?: number): T {
10
+ return placeholder !== undefined
11
+ ? Lazy(placeholder)(cls, {} as ClassDecoratorContext)
12
+ : Lazy()(cls, {} as ClassDecoratorContext);
13
+ }
14
+
15
+ class BaseComp extends StatefulComponent {
16
+ render() {
17
+ return document.createTextNode("content");
18
+ }
19
+ }
20
+
21
+ describe("Lazy decorator", () => {
22
+ let intersectionCallback: IntersectionObserverCallback = () => {};
23
+ let mockObserve: ReturnType<typeof vi.fn>;
24
+ let mockDisconnect: ReturnType<typeof vi.fn>;
25
+
26
+ beforeEach(() => {
27
+ intersectionCallback = () => {};
28
+ mockObserve = vi.fn();
29
+ mockDisconnect = vi.fn();
30
+
31
+ globalThis.IntersectionObserver = class {
32
+ constructor(cb: IntersectionObserverCallback) {
33
+ intersectionCallback = cb;
34
+ }
35
+ observe = mockObserve;
36
+ disconnect = mockDisconnect;
37
+ } as unknown as typeof IntersectionObserver;
38
+ });
39
+
40
+ afterEach(() => {
41
+ // @ts-expect-error – removing test stub
42
+ delete globalThis.IntersectionObserver;
43
+ });
44
+
45
+ it("returns null from render before visibility", () => {
46
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
47
+ const instance = new Wrapped();
48
+ expect(instance.render()).toBeNull();
49
+ });
50
+
51
+ it("sets _lazyVisible to true when IntersectionObserver is not supported", () => {
52
+ // Remove IntersectionObserver to simulate unsupported environment
53
+ // @ts-expect-error – removing test stub
54
+ delete globalThis.IntersectionObserver;
55
+
56
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
57
+ const instance = new Wrapped();
58
+
59
+ // Simulate anchor with parent element
60
+ const parent = document.createElement("div");
61
+ document.body.appendChild(parent);
62
+ const anchor = document.createComment("end");
63
+ parent.appendChild(anchor);
64
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
65
+
66
+ instance.onMount?.();
67
+ // Without IntersectionObserver, _lazyVisible should be set to true
68
+ const lazyVisible = (instance as unknown as { _lazyVisible: () => boolean })._lazyVisible;
69
+ expect(lazyVisible()).toBe(true);
70
+ document.body.removeChild(parent);
71
+ });
72
+
73
+ it("sets minHeight placeholder on mount", () => {
74
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 300);
75
+ const instance = new Wrapped();
76
+
77
+ const parent = document.createElement("div");
78
+ document.body.appendChild(parent);
79
+ const anchor = document.createComment("end");
80
+ parent.appendChild(anchor);
81
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
82
+
83
+ instance.onMount?.();
84
+ expect(parent.style.minHeight).toBe("300px");
85
+ document.body.removeChild(parent);
86
+ });
87
+
88
+ it("sets _lazyVisible and clears minHeight when intersecting", () => {
89
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
90
+ const instance = new Wrapped();
91
+
92
+ const parent = document.createElement("div");
93
+ document.body.appendChild(parent);
94
+ const anchor = document.createComment("end");
95
+ parent.appendChild(anchor);
96
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
97
+
98
+ instance.onMount?.();
99
+ expect(instance.render()).toBeNull();
100
+
101
+ intersectionCallback(
102
+ [{ isIntersecting: true } as IntersectionObserverEntry],
103
+ null as unknown as IntersectionObserver,
104
+ );
105
+
106
+ expect(parent.style.minHeight).toBe("");
107
+ const lazyVisible = (instance as unknown as { _lazyVisible: () => boolean })._lazyVisible;
108
+ expect(lazyVisible()).toBe(true);
109
+ expect(mockDisconnect).toHaveBeenCalled();
110
+ document.body.removeChild(parent);
111
+ });
112
+
113
+ it("does nothing when _anchor has no parent", () => {
114
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
115
+ const instance = new Wrapped();
116
+
117
+ const anchor = document.createComment("end");
118
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
119
+
120
+ // Should not throw even though anchor has no parent
121
+ expect(() => instance.onMount?.()).not.toThrow();
122
+ });
123
+
124
+ it("disconnects observer on unmount", () => {
125
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
126
+ const instance = new Wrapped();
127
+
128
+ const parent = document.createElement("div");
129
+ document.body.appendChild(parent);
130
+ const anchor = document.createComment("end");
131
+ parent.appendChild(anchor);
132
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
133
+
134
+ instance.onMount?.();
135
+ instance.onUnmount?.();
136
+ expect(mockDisconnect).toHaveBeenCalled();
137
+ document.body.removeChild(parent);
138
+ });
139
+
140
+ it("onMount does nothing when _anchor is undefined", () => {
141
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
142
+ const instance = new Wrapped();
143
+ // _anchor is undefined by default
144
+ expect(() => instance.onMount?.()).not.toThrow();
145
+ });
146
+
147
+ it("render() returns original content after becoming visible", () => {
148
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
149
+ const instance = new Wrapped();
150
+
151
+ const parent = document.createElement("div");
152
+ document.body.appendChild(parent);
153
+ const anchor = document.createComment("end");
154
+ parent.appendChild(anchor);
155
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
156
+
157
+ instance.onMount?.();
158
+ expect(instance.render()).toBeNull(); // not yet visible
159
+
160
+ intersectionCallback(
161
+ [{ isIntersecting: true } as IntersectionObserverEntry],
162
+ null as unknown as IntersectionObserver,
163
+ );
164
+
165
+ // Now visible: render() must delegate to the original render()
166
+ const result = instance.render();
167
+ expect(result).not.toBeNull();
168
+ document.body.removeChild(parent);
169
+ });
170
+
171
+ it("observer stays active and minHeight is preserved when entry is not intersecting", () => {
172
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
173
+ const instance = new Wrapped();
174
+
175
+ const parent = document.createElement("div");
176
+ document.body.appendChild(parent);
177
+ const anchor = document.createComment("end");
178
+ parent.appendChild(anchor);
179
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
180
+
181
+ instance.onMount?.();
182
+
183
+ // Fire callback with isIntersecting=false — should be a no-op
184
+ intersectionCallback(
185
+ [{ isIntersecting: false } as IntersectionObserverEntry],
186
+ null as unknown as IntersectionObserver,
187
+ );
188
+
189
+ expect(instance.render()).toBeNull();
190
+ expect(mockDisconnect).not.toHaveBeenCalled();
191
+ expect(parent.style.minHeight).toBe("200px");
192
+ document.body.removeChild(parent);
193
+ });
194
+
195
+ it("does not re-apply minHeight on remount when already visible", () => {
196
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 300);
197
+ const instance = new Wrapped();
198
+
199
+ const parent = document.createElement("div");
200
+ document.body.appendChild(parent);
201
+ const anchor = document.createComment("end");
202
+ parent.appendChild(anchor);
203
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
204
+
205
+ // First mount — become visible
206
+ instance.onMount?.();
207
+ intersectionCallback(
208
+ [{ isIntersecting: true } as IntersectionObserverEntry],
209
+ null as unknown as IntersectionObserver,
210
+ );
211
+ instance.onUnmount?.();
212
+
213
+ // Second mount — _lazyVisible is already true
214
+ instance.onMount?.();
215
+
216
+ // minHeight must NOT be re-applied because _lazyVisible() is true
217
+ expect(parent.style.minHeight).toBe("");
218
+ document.body.removeChild(parent);
219
+ });
220
+ });