@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.
- package/CHANGELOG.md +18 -0
- package/dist/__tests__/component.test.d.ts +2 -0
- package/dist/__tests__/component.test.d.ts.map +1 -0
- package/dist/__tests__/component.test.js +137 -0
- package/dist/__tests__/component.test.js.map +1 -0
- package/dist/__tests__/events.test.d.ts +2 -0
- package/dist/__tests__/events.test.d.ts.map +1 -0
- package/dist/__tests__/events.test.js +190 -0
- package/dist/__tests__/events.test.js.map +1 -0
- package/dist/__tests__/functions.test.js +157 -0
- package/dist/__tests__/functions.test.js.map +1 -1
- package/dist/__tests__/lazy.test.d.ts +2 -0
- package/dist/__tests__/lazy.test.d.ts.map +1 -0
- package/dist/__tests__/lazy.test.js +165 -0
- package/dist/__tests__/lazy.test.js.map +1 -0
- package/dist/__tests__/memo.test.js +13 -0
- package/dist/__tests__/memo.test.js.map +1 -1
- package/dist/__tests__/properties.test.js +191 -1
- package/dist/__tests__/properties.test.js.map +1 -1
- package/dist/__tests__/slot.test.d.ts +2 -0
- package/dist/__tests__/slot.test.d.ts.map +1 -0
- package/dist/__tests__/slot.test.js +115 -0
- package/dist/__tests__/slot.test.js.map +1 -0
- package/dist/__tests__/virtual.test.d.ts +2 -0
- package/dist/__tests__/virtual.test.d.ts.map +1 -0
- package/dist/__tests__/virtual.test.js +201 -0
- package/dist/__tests__/virtual.test.js.map +1 -0
- package/dist/component/lazy.d.ts +1 -1
- package/dist/component/lazy.d.ts.map +1 -1
- package/dist/component/lazy.js +4 -1
- package/dist/component/lazy.js.map +1 -1
- package/dist/component/virtual.d.ts +1 -1
- package/dist/component/virtual.d.ts.map +1 -1
- package/dist/functions/watch.d.ts.map +1 -1
- package/dist/functions/watch.js +10 -2
- package/dist/functions/watch.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/component.test.ts +156 -0
- package/src/__tests__/events.test.ts +236 -0
- package/src/__tests__/functions.test.ts +207 -0
- package/src/__tests__/lazy.test.ts +220 -0
- package/src/__tests__/memo.test.ts +16 -0
- package/src/__tests__/properties.test.ts +249 -1
- package/src/__tests__/slot.test.ts +141 -0
- package/src/__tests__/virtual.test.ts +241 -0
- package/src/component/lazy.ts +4 -2
- package/src/component/virtual.tsx +1 -1
- package/src/functions/watch.ts +11 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { signal } from "@praxisjs/core/internal";
|
|
3
4
|
import { Memo } from "../functions/memo";
|
|
4
5
|
|
|
5
6
|
function mockCtx(name: string) {
|
|
@@ -62,6 +63,21 @@ describe("Memo", () => {
|
|
|
62
63
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
it("re-evaluates when a reactive signal read inside the method changes", () => {
|
|
67
|
+
const s = signal(1);
|
|
68
|
+
const fn = vi.fn(() => s() * 10);
|
|
69
|
+
const wrapped = Memo()(fn, mockCtx("reactive"));
|
|
70
|
+
const obj = {};
|
|
71
|
+
|
|
72
|
+
expect(wrapped.call(obj)).toBe(10);
|
|
73
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
74
|
+
|
|
75
|
+
s.set(5);
|
|
76
|
+
// The computed tracks s() — next read triggers recomputation
|
|
77
|
+
expect(wrapped.call(obj)).toBe(50);
|
|
78
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
65
81
|
it("serializes symbol args via toString()", () => {
|
|
66
82
|
const fn = vi.fn((s: unknown) => String(s));
|
|
67
83
|
const wrapped = Memo()(fn, mockCtx("sym"));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { StatefulComponent } from "@praxisjs/core";
|
|
4
|
-
import { signal } from "@praxisjs/core/internal";
|
|
4
|
+
import { signal, computed } from "@praxisjs/core/internal";
|
|
5
5
|
|
|
6
6
|
import { Watch } from "../functions/watch";
|
|
7
7
|
import { When } from "../functions/when";
|
|
@@ -76,6 +76,36 @@ describe("State decorator", () => {
|
|
|
76
76
|
(instance as unknown as Record<string, unknown>).value = "world";
|
|
77
77
|
expect(instance._stateDirty).toBe(true);
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it("accepts null as initial value", () => {
|
|
81
|
+
const { ctx, run } = fieldCtx("nullable");
|
|
82
|
+
State()(undefined, ctx);
|
|
83
|
+
|
|
84
|
+
const instance = new TestComponent();
|
|
85
|
+
(instance as unknown as Record<string, unknown>).nullable = null;
|
|
86
|
+
run(instance);
|
|
87
|
+
|
|
88
|
+
expect((instance as unknown as Record<string, unknown>).nullable).toBeNull();
|
|
89
|
+
(instance as unknown as Record<string, unknown>).nullable = "set";
|
|
90
|
+
expect((instance as unknown as Record<string, unknown>).nullable).toBe("set");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("multiple State fields on same class are independent", () => {
|
|
94
|
+
const { ctx: ctxA, run: runA } = fieldCtx("a");
|
|
95
|
+
const { ctx: ctxB, run: runB } = fieldCtx("b");
|
|
96
|
+
State()(undefined, ctxA);
|
|
97
|
+
State()(undefined, ctxB);
|
|
98
|
+
|
|
99
|
+
const instance = new TestComponent();
|
|
100
|
+
(instance as unknown as Record<string, unknown>).a = 1;
|
|
101
|
+
(instance as unknown as Record<string, unknown>).b = 2;
|
|
102
|
+
runA(instance);
|
|
103
|
+
runB(instance);
|
|
104
|
+
|
|
105
|
+
(instance as unknown as Record<string, unknown>).a = 10;
|
|
106
|
+
expect((instance as unknown as Record<string, unknown>).a).toBe(10);
|
|
107
|
+
expect((instance as unknown as Record<string, unknown>).b).toBe(2);
|
|
108
|
+
});
|
|
79
109
|
});
|
|
80
110
|
|
|
81
111
|
// ── Prop ──────────────────────────────────────────────────────────────────────
|
|
@@ -474,4 +504,222 @@ describe("When decorator", () => {
|
|
|
474
504
|
expect(mountSpy).toHaveBeenCalled();
|
|
475
505
|
expect(unmountSpy).toHaveBeenCalled();
|
|
476
506
|
});
|
|
507
|
+
|
|
508
|
+
it("works when the watched prop is a computed value", () => {
|
|
509
|
+
const { ctx, run } = methodCtxFor("onComputed");
|
|
510
|
+
const handler = vi.fn();
|
|
511
|
+
When("doubled")(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
512
|
+
|
|
513
|
+
const s = signal(0);
|
|
514
|
+
const c = computed(() => s() * 2);
|
|
515
|
+
const instance = new TestComponent();
|
|
516
|
+
// Store the computed itself (not the result) so When can call isComputed on it
|
|
517
|
+
(instance as unknown as Record<string, unknown>).doubled = c;
|
|
518
|
+
run(instance);
|
|
519
|
+
|
|
520
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
521
|
+
|
|
522
|
+
s.set(5); // doubled becomes 10 (truthy)
|
|
523
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── Prop (additional branches) ────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("Prop decorator — additional branches", () => {
|
|
530
|
+
it("set() updates the default value (used when no _rawProps entry)", () => {
|
|
531
|
+
const { ctx, run } = fieldCtx("size");
|
|
532
|
+
Prop()(undefined, ctx);
|
|
533
|
+
|
|
534
|
+
const instance = new TestComponent();
|
|
535
|
+
(instance as unknown as Record<string, unknown>).size = "sm";
|
|
536
|
+
run(instance);
|
|
537
|
+
|
|
538
|
+
// Setter path: updates _defaults
|
|
539
|
+
(instance as unknown as Record<string, unknown>).size = "lg";
|
|
540
|
+
expect((instance as unknown as Record<string, unknown>).size).toBe("lg");
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ── History (additional branches) ────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
describe("History decorator — undo with no previous value", () => {
|
|
547
|
+
it("undo() does nothing when there is no previous value", () => {
|
|
548
|
+
const { ctx, run } = fieldCtx("pts");
|
|
549
|
+
History()(undefined, ctx);
|
|
550
|
+
|
|
551
|
+
const s = signal(0);
|
|
552
|
+
const instance = new TestComponent();
|
|
553
|
+
Object.defineProperty(instance, "pts", {
|
|
554
|
+
get: () => s(),
|
|
555
|
+
set: (v: number) => { s.set(v); },
|
|
556
|
+
configurable: true,
|
|
557
|
+
});
|
|
558
|
+
run(instance);
|
|
559
|
+
|
|
560
|
+
const h = (instance as unknown as Record<string, unknown>).ptsHistory as {
|
|
561
|
+
undo: () => void;
|
|
562
|
+
canUndo: () => boolean;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// No changes yet — undo should be a no-op
|
|
566
|
+
expect(h.canUndo()).toBe(false);
|
|
567
|
+
expect(() => h.undo()).not.toThrow();
|
|
568
|
+
expect(s()).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// ── History decorator — multiple fields ───────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
describe("History decorator — multiple History properties on same instance", () => {
|
|
575
|
+
it("two History fields are independent", () => {
|
|
576
|
+
const { ctx: ctxX, run: runX } = fieldCtx("x");
|
|
577
|
+
const { ctx: ctxY, run: runY } = fieldCtx("y");
|
|
578
|
+
History()(undefined, ctxX);
|
|
579
|
+
History()(undefined, ctxY);
|
|
580
|
+
|
|
581
|
+
const sx = signal(0);
|
|
582
|
+
const sy = signal(0);
|
|
583
|
+
const instance = new TestComponent();
|
|
584
|
+
Object.defineProperty(instance, "x", {
|
|
585
|
+
get: () => sx(), set: (v: number) => { sx.set(v); }, configurable: true,
|
|
586
|
+
});
|
|
587
|
+
Object.defineProperty(instance, "y", {
|
|
588
|
+
get: () => sy(), set: (v: number) => { sy.set(v); }, configurable: true,
|
|
589
|
+
});
|
|
590
|
+
runX(instance);
|
|
591
|
+
runY(instance);
|
|
592
|
+
|
|
593
|
+
// Access history first to trigger lazy creation and subscribe to signals
|
|
594
|
+
const hx = (instance as unknown as Record<string, unknown>).xHistory as {
|
|
595
|
+
canUndo: () => boolean; current: () => number;
|
|
596
|
+
};
|
|
597
|
+
const hy = (instance as unknown as Record<string, unknown>).yHistory as {
|
|
598
|
+
canUndo: () => boolean; current: () => number;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
sx.set(5);
|
|
602
|
+
sy.set(10);
|
|
603
|
+
|
|
604
|
+
expect(hx.canUndo()).toBe(true);
|
|
605
|
+
expect(hy.canUndo()).toBe(true);
|
|
606
|
+
expect(hx.current()).toBe(5);
|
|
607
|
+
expect(hy.current()).toBe(10);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("clear() after undo removes redo entries", () => {
|
|
611
|
+
const { ctx, run } = fieldCtx("val");
|
|
612
|
+
History()(undefined, ctx);
|
|
613
|
+
|
|
614
|
+
const s = signal(0);
|
|
615
|
+
const instance = new TestComponent();
|
|
616
|
+
Object.defineProperty(instance, "val", {
|
|
617
|
+
get: () => s(), set: (v: number) => { s.set(v); }, configurable: true,
|
|
618
|
+
});
|
|
619
|
+
run(instance);
|
|
620
|
+
|
|
621
|
+
const h = (instance as unknown as Record<string, unknown>).valHistory as {
|
|
622
|
+
undo: () => void; clear: () => void;
|
|
623
|
+
canUndo: () => boolean; canRedo: () => boolean;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
s.set(1);
|
|
627
|
+
s.set(2);
|
|
628
|
+
h.undo();
|
|
629
|
+
expect(h.canRedo()).toBe(true);
|
|
630
|
+
h.clear();
|
|
631
|
+
expect(h.canRedo()).toBe(false);
|
|
632
|
+
expect(h.canUndo()).toBe(false);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// ── Watch (cleanup + multi-prop non-watched) ──────────────────────────────────
|
|
637
|
+
|
|
638
|
+
describe("Watch decorator — onUnmount cleanup", () => {
|
|
639
|
+
it("stops firing after onUnmount is called", () => {
|
|
640
|
+
const { ctx, run } = methodCtx("onCount");
|
|
641
|
+
const handler = vi.fn();
|
|
642
|
+
Watch("count" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
643
|
+
|
|
644
|
+
const s = signal(0);
|
|
645
|
+
const instance = new TestComponent();
|
|
646
|
+
Object.defineProperty(instance, "count", { get: () => s(), configurable: true });
|
|
647
|
+
run(instance);
|
|
648
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
649
|
+
|
|
650
|
+
s.set(1);
|
|
651
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
652
|
+
|
|
653
|
+
(instance as unknown as { onUnmount: () => void }).onUnmount?.();
|
|
654
|
+
s.set(2);
|
|
655
|
+
|
|
656
|
+
// After unmount the effect is stopped — handler must NOT fire again
|
|
657
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("preserves existing onUnmount when adding cleanup", () => {
|
|
661
|
+
const { ctx, run } = methodCtx("onVal");
|
|
662
|
+
const handler = vi.fn();
|
|
663
|
+
Watch("val" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
664
|
+
|
|
665
|
+
const s = signal(0);
|
|
666
|
+
const instance = new TestComponent();
|
|
667
|
+
Object.defineProperty(instance, "val", { get: () => s(), configurable: true });
|
|
668
|
+
run(instance);
|
|
669
|
+
|
|
670
|
+
const unmountSpy = vi.fn();
|
|
671
|
+
const prevUnmount = instance.onUnmount?.bind(instance);
|
|
672
|
+
instance.onUnmount = function () { unmountSpy(); prevUnmount?.(); };
|
|
673
|
+
|
|
674
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
675
|
+
instance.onUnmount?.();
|
|
676
|
+
expect(unmountSpy).toHaveBeenCalled();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("multi-prop watch does NOT fire when an unwatched prop changes", () => {
|
|
680
|
+
const { ctx, run } = methodCtx("onAB");
|
|
681
|
+
const handler = vi.fn();
|
|
682
|
+
Watch("a" as never, "b" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
683
|
+
|
|
684
|
+
const sa = signal(0);
|
|
685
|
+
const sb = signal(0);
|
|
686
|
+
const sc = signal(0); // not watched
|
|
687
|
+
|
|
688
|
+
const instance = new TestComponent();
|
|
689
|
+
Object.defineProperty(instance, "a", { get: () => sa(), configurable: true });
|
|
690
|
+
Object.defineProperty(instance, "b", { get: () => sb(), configurable: true });
|
|
691
|
+
Object.defineProperty(instance, "c", { get: () => sc(), configurable: true });
|
|
692
|
+
run(instance);
|
|
693
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
694
|
+
|
|
695
|
+
sc.set(99); // unwatched prop — handler must NOT fire
|
|
696
|
+
expect(handler).not.toHaveBeenCalled();
|
|
697
|
+
|
|
698
|
+
sa.set(1); // watched — handler fires
|
|
699
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// ── Watch (additional branches — computed readValue) ──────────────────────────
|
|
704
|
+
|
|
705
|
+
describe("Watch decorator — reading computed values", () => {
|
|
706
|
+
it("reads the value of a computed field (not the computed object)", () => {
|
|
707
|
+
const { ctx, run } = methodCtx("onDoubled");
|
|
708
|
+
const handler = vi.fn();
|
|
709
|
+
Watch("doubled" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
710
|
+
|
|
711
|
+
const s = signal(1);
|
|
712
|
+
const c = computed(() => s() * 2);
|
|
713
|
+
const instance = new TestComponent();
|
|
714
|
+
// Watch reads instance["doubled"] — if it's a computed, it calls it
|
|
715
|
+
Object.defineProperty(instance, "doubled", {
|
|
716
|
+
get: () => c,
|
|
717
|
+
configurable: true,
|
|
718
|
+
});
|
|
719
|
+
run(instance);
|
|
720
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
721
|
+
|
|
722
|
+
s.set(5); // c() goes from 2 to 10 → triggers handler
|
|
723
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
724
|
+
});
|
|
477
725
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { initSlots, getSlot, Slot } from "../properties/slot";
|
|
5
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
6
|
+
|
|
7
|
+
// ── initSlots / getSlot ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe("initSlots / getSlot", () => {
|
|
10
|
+
it("returns empty default slot when children is null", () => {
|
|
11
|
+
const instance = {};
|
|
12
|
+
initSlots(instance, null);
|
|
13
|
+
expect(getSlot(instance, "default")).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty array for unknown slot", () => {
|
|
17
|
+
const instance = {};
|
|
18
|
+
initSlots(instance, null);
|
|
19
|
+
expect(getSlot(instance, "unknown")).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("puts plain children into the default slot", () => {
|
|
23
|
+
const instance = {};
|
|
24
|
+
const child = document.createElement("span");
|
|
25
|
+
initSlots(instance, child);
|
|
26
|
+
expect(getSlot(instance, "default")).toEqual([child]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("flattens array children into the default slot", () => {
|
|
30
|
+
const instance = {};
|
|
31
|
+
const a = document.createElement("p");
|
|
32
|
+
const b = document.createElement("div");
|
|
33
|
+
initSlots(instance, [a, b]);
|
|
34
|
+
expect(getSlot(instance, "default")).toEqual([a, b]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("routes element with slot attribute to named slot", () => {
|
|
38
|
+
const instance = {};
|
|
39
|
+
const el = document.createElement("div");
|
|
40
|
+
el.setAttribute("slot", "header");
|
|
41
|
+
initSlots(instance, el);
|
|
42
|
+
expect(getSlot(instance, "header")).toEqual([el]);
|
|
43
|
+
expect(getSlot(instance, "default")).toEqual([]);
|
|
44
|
+
// slot attribute should be removed
|
|
45
|
+
expect(el.hasAttribute("slot")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles mixed named and default children", () => {
|
|
49
|
+
const instance = {};
|
|
50
|
+
const named = document.createElement("aside");
|
|
51
|
+
named.setAttribute("slot", "sidebar");
|
|
52
|
+
const plain = document.createElement("main");
|
|
53
|
+
initSlots(instance, [named, plain]);
|
|
54
|
+
expect(getSlot(instance, "sidebar")).toEqual([named]);
|
|
55
|
+
expect(getSlot(instance, "default")).toEqual([plain]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("skips null children in array", () => {
|
|
59
|
+
const instance = {};
|
|
60
|
+
const el = document.createElement("span");
|
|
61
|
+
initSlots(instance, [null, el, undefined]);
|
|
62
|
+
expect(getSlot(instance, "default")).toEqual([el]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Slot decorator ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("Slot decorator", () => {
|
|
69
|
+
it("provides the default slot via field decorator", () => {
|
|
70
|
+
class MyComp extends StatefulComponent {
|
|
71
|
+
render() { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
75
|
+
const ctx = {
|
|
76
|
+
name: "default",
|
|
77
|
+
kind: "field" as const,
|
|
78
|
+
addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
|
|
79
|
+
} as ClassFieldDecoratorContext<StatefulComponent>;
|
|
80
|
+
|
|
81
|
+
Slot()(undefined, ctx);
|
|
82
|
+
|
|
83
|
+
const instance = new MyComp() as unknown as Record<string, unknown>;
|
|
84
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
85
|
+
|
|
86
|
+
// No children initialized — slot should be empty
|
|
87
|
+
const children = document.createElement("p");
|
|
88
|
+
initSlots(instance as object, children);
|
|
89
|
+
|
|
90
|
+
expect(Array.isArray((instance as Record<string, unknown>).default)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses custom slot name when provided", () => {
|
|
94
|
+
class MyComp extends StatefulComponent {
|
|
95
|
+
render() { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
99
|
+
const ctx = {
|
|
100
|
+
name: "content",
|
|
101
|
+
kind: "field" as const,
|
|
102
|
+
addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
|
|
103
|
+
} as ClassFieldDecoratorContext<StatefulComponent>;
|
|
104
|
+
|
|
105
|
+
Slot("header")(undefined, ctx);
|
|
106
|
+
|
|
107
|
+
const instance = new MyComp() as unknown as Record<string, unknown>;
|
|
108
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
109
|
+
|
|
110
|
+
const el = document.createElement("nav");
|
|
111
|
+
el.setAttribute("slot", "header");
|
|
112
|
+
initSlots(instance as object, el);
|
|
113
|
+
|
|
114
|
+
const slot = (instance as Record<string, unknown>).content as unknown[];
|
|
115
|
+
expect(slot).toHaveLength(1);
|
|
116
|
+
expect(slot[0]).toBe(el);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("warns on direct assignment in non-production", () => {
|
|
120
|
+
class MyComp extends StatefulComponent {
|
|
121
|
+
render() { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
125
|
+
const ctx = {
|
|
126
|
+
name: "default",
|
|
127
|
+
kind: "field" as const,
|
|
128
|
+
addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
|
|
129
|
+
} as ClassFieldDecoratorContext<StatefulComponent>;
|
|
130
|
+
|
|
131
|
+
Slot()(undefined, ctx);
|
|
132
|
+
|
|
133
|
+
const instance = new MyComp() as unknown as Record<string, unknown>;
|
|
134
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
135
|
+
|
|
136
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
137
|
+
(instance as Record<string, unknown>).default = "bad";
|
|
138
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Slot]"));
|
|
139
|
+
warn.mockRestore();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
5
|
+
import { Virtual } from "../component/virtual";
|
|
6
|
+
|
|
7
|
+
type AnyConstructor = new (...args: unknown[]) => StatefulComponent;
|
|
8
|
+
|
|
9
|
+
function applyVirtual<T extends AnyConstructor>(cls: T, itemHeight: number, buffer?: number): T {
|
|
10
|
+
return buffer !== undefined
|
|
11
|
+
? Virtual(itemHeight, buffer)(cls, {} as ClassDecoratorContext)
|
|
12
|
+
: Virtual(itemHeight)(cls, {} as ClassDecoratorContext);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A base component with items and renderItem for virtual scrolling
|
|
16
|
+
class ListComp extends StatefulComponent {
|
|
17
|
+
items: unknown[] = [];
|
|
18
|
+
renderItem(item: unknown, _index: number) {
|
|
19
|
+
const div = document.createElement("div");
|
|
20
|
+
(div as HTMLElement).textContent = String(item);
|
|
21
|
+
return div;
|
|
22
|
+
}
|
|
23
|
+
render() { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("Virtual decorator", () => {
|
|
27
|
+
it("warns and returns null when renderItem is not defined", () => {
|
|
28
|
+
class NoRenderItem extends StatefulComponent {
|
|
29
|
+
items = ["a", "b"];
|
|
30
|
+
render() { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Wrapped = applyVirtual(NoRenderItem as AnyConstructor, 50);
|
|
34
|
+
const instance = new Wrapped() as unknown as { render: () => unknown };
|
|
35
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
36
|
+
const result = instance.render();
|
|
37
|
+
expect(result).toBeNull();
|
|
38
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Virtual]"));
|
|
39
|
+
warn.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders a container div with the total height", () => {
|
|
43
|
+
class MyList extends StatefulComponent {
|
|
44
|
+
items = ["a", "b", "c"];
|
|
45
|
+
renderItem(item: unknown) {
|
|
46
|
+
const el = document.createElement("div");
|
|
47
|
+
el.textContent = String(item);
|
|
48
|
+
return el;
|
|
49
|
+
}
|
|
50
|
+
render() { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const Wrapped = applyVirtual(MyList as AnyConstructor, 50);
|
|
54
|
+
const instance = new Wrapped();
|
|
55
|
+
|
|
56
|
+
const result = instance.render() as HTMLElement;
|
|
57
|
+
expect(result).toBeInstanceOf(HTMLElement);
|
|
58
|
+
expect(result.tagName.toLowerCase()).toBe("div");
|
|
59
|
+
// Total height = 3 items * 50px
|
|
60
|
+
expect(result.getAttribute("style")).toContain("150px");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders visible items within the viewport", () => {
|
|
64
|
+
class MyList extends StatefulComponent {
|
|
65
|
+
items = [1, 2, 3, 4, 5];
|
|
66
|
+
renderItem(item: unknown) {
|
|
67
|
+
const el = document.createElement("div");
|
|
68
|
+
el.textContent = String(item);
|
|
69
|
+
return el;
|
|
70
|
+
}
|
|
71
|
+
render() { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const Wrapped = applyVirtual(MyList as AnyConstructor, 100, 0);
|
|
75
|
+
const instance = new Wrapped();
|
|
76
|
+
|
|
77
|
+
const outer = instance.render() as HTMLElement;
|
|
78
|
+
// Should render some content
|
|
79
|
+
expect(outer).not.toBeNull();
|
|
80
|
+
expect(outer.children.length).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles empty items list without errors", () => {
|
|
84
|
+
class EmptyList extends StatefulComponent {
|
|
85
|
+
items: unknown[] = [];
|
|
86
|
+
renderItem(_item: unknown) {
|
|
87
|
+
return document.createElement("div");
|
|
88
|
+
}
|
|
89
|
+
render() { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const Wrapped = applyVirtual(EmptyList as AnyConstructor, 50);
|
|
93
|
+
const instance = new Wrapped();
|
|
94
|
+
expect(() => instance.render()).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("onMount sets container styles when _anchor has a parent", () => {
|
|
98
|
+
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
99
|
+
const instance = new Wrapped();
|
|
100
|
+
(instance as unknown as { items: unknown[] }).items = ["a", "b"];
|
|
101
|
+
|
|
102
|
+
const container = document.createElement("div");
|
|
103
|
+
document.body.appendChild(container);
|
|
104
|
+
const anchor = document.createComment("end");
|
|
105
|
+
container.appendChild(anchor);
|
|
106
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
107
|
+
|
|
108
|
+
instance.onMount?.();
|
|
109
|
+
expect(container.style.overflowY).toBe("auto");
|
|
110
|
+
expect(container.style.position).toBe("relative");
|
|
111
|
+
document.body.removeChild(container);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("onMount does nothing when _anchor has no parent", () => {
|
|
115
|
+
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
116
|
+
const instance = new Wrapped();
|
|
117
|
+
|
|
118
|
+
const anchor = document.createComment("end");
|
|
119
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
120
|
+
|
|
121
|
+
expect(() => instance.onMount?.()).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("onUnmount disconnects scroll listener and cleans up effects", () => {
|
|
125
|
+
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
126
|
+
const instance = new Wrapped();
|
|
127
|
+
|
|
128
|
+
const container = document.createElement("div");
|
|
129
|
+
document.body.appendChild(container);
|
|
130
|
+
const anchor = document.createComment("end");
|
|
131
|
+
container.appendChild(anchor);
|
|
132
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
133
|
+
|
|
134
|
+
instance.onMount?.();
|
|
135
|
+
// Should not throw
|
|
136
|
+
expect(() => instance.onUnmount?.()).not.toThrow();
|
|
137
|
+
document.body.removeChild(container);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("re-renders visible items when scrollTop changes (covers while loop cleanup)", () => {
|
|
141
|
+
class ScrollList extends StatefulComponent {
|
|
142
|
+
items = Array.from({ length: 20 }, (_, i) => i);
|
|
143
|
+
renderItem(item: unknown) {
|
|
144
|
+
const el = document.createElement("div");
|
|
145
|
+
el.textContent = String(item);
|
|
146
|
+
return el;
|
|
147
|
+
}
|
|
148
|
+
render() { return null; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const Wrapped = applyVirtual(ScrollList as AnyConstructor, 50, 0);
|
|
152
|
+
const instance = new Wrapped();
|
|
153
|
+
|
|
154
|
+
const container = document.createElement("div");
|
|
155
|
+
document.body.appendChild(container);
|
|
156
|
+
const anchor = document.createComment("end");
|
|
157
|
+
container.appendChild(anchor);
|
|
158
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
159
|
+
|
|
160
|
+
instance.onMount?.();
|
|
161
|
+
// First render - populates the itemsSlot
|
|
162
|
+
instance.render();
|
|
163
|
+
|
|
164
|
+
// Change scrollTop to trigger effect re-run (covers the while loop removing old nodes)
|
|
165
|
+
Object.defineProperty(container, "scrollTop", { configurable: true, value: 500 });
|
|
166
|
+
container.dispatchEvent(new Event("scroll"));
|
|
167
|
+
|
|
168
|
+
// Verify scrollTop was updated (effect should have re-run)
|
|
169
|
+
const scrollTop = (instance as unknown as { _scrollTop: () => number })._scrollTop;
|
|
170
|
+
expect(scrollTop()).toBe(500);
|
|
171
|
+
|
|
172
|
+
document.body.removeChild(container);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("renderItem returning an array appends all child nodes", () => {
|
|
176
|
+
class ArrayItemList extends StatefulComponent {
|
|
177
|
+
items = ["item"];
|
|
178
|
+
renderItem(item: unknown) {
|
|
179
|
+
const a = document.createElement("span");
|
|
180
|
+
a.textContent = String(item) + "-1";
|
|
181
|
+
const b = document.createElement("span");
|
|
182
|
+
b.textContent = String(item) + "-2";
|
|
183
|
+
return [a, b];
|
|
184
|
+
}
|
|
185
|
+
render() { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const Wrapped = applyVirtual(ArrayItemList as AnyConstructor, 50, 0);
|
|
189
|
+
const instance = new Wrapped();
|
|
190
|
+
const outer = instance.render() as HTMLElement;
|
|
191
|
+
// outer structure: spacerTop, itemsSlot, spacerBottom
|
|
192
|
+
const itemsSlot = outer.children[1] as HTMLElement;
|
|
193
|
+
const wrapper = itemsSlot.children[0] as HTMLElement;
|
|
194
|
+
expect(wrapper.children.length).toBe(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("renderItem returning null produces an empty wrapper div", () => {
|
|
198
|
+
class NullItemList extends StatefulComponent {
|
|
199
|
+
items = ["x"];
|
|
200
|
+
renderItem() { return null; }
|
|
201
|
+
render() { return null; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const Wrapped = applyVirtual(NullItemList as AnyConstructor, 50, 0);
|
|
205
|
+
const instance = new Wrapped();
|
|
206
|
+
expect(() => instance.render()).not.toThrow();
|
|
207
|
+
const outer = instance.render() as HTMLElement;
|
|
208
|
+
const itemsSlot = outer.children[1] as HTMLElement;
|
|
209
|
+
const wrapper = itemsSlot.children[0] as HTMLElement;
|
|
210
|
+
// rendered is null — no child nodes appended
|
|
211
|
+
expect(wrapper.children.length).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("onUnmount without prior onMount does not throw", () => {
|
|
215
|
+
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
216
|
+
const instance = new Wrapped();
|
|
217
|
+
// _container is undefined — onUnmount should be a no-op
|
|
218
|
+
expect(() => instance.onUnmount?.()).not.toThrow();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("updates scrollTop on scroll event", () => {
|
|
222
|
+
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
223
|
+
const instance = new Wrapped();
|
|
224
|
+
(instance as unknown as { items: unknown[] }).items = Array.from({ length: 20 }, (_, i) => i);
|
|
225
|
+
|
|
226
|
+
const container = document.createElement("div");
|
|
227
|
+
document.body.appendChild(container);
|
|
228
|
+
const anchor = document.createComment("end");
|
|
229
|
+
container.appendChild(anchor);
|
|
230
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
231
|
+
|
|
232
|
+
instance.onMount?.();
|
|
233
|
+
|
|
234
|
+
Object.defineProperty(container, "scrollTop", { configurable: true, value: 200 });
|
|
235
|
+
container.dispatchEvent(new Event("scroll"));
|
|
236
|
+
|
|
237
|
+
const scrollTop = (instance as unknown as { _scrollTop: () => number })._scrollTop;
|
|
238
|
+
expect(scrollTop()).toBe(200);
|
|
239
|
+
document.body.removeChild(container);
|
|
240
|
+
});
|
|
241
|
+
});
|
package/src/component/lazy.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type RootComponent, signal } from "@praxisjs/core/internal";
|
|
|
2
2
|
|
|
3
3
|
export function Lazy(placeholder = 200) {
|
|
4
4
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
-
return function <T extends new (...args: any[]) => RootComponent
|
|
5
|
+
return function <T extends new (...args: any[]) => RootComponent<Record<string, any>>>(
|
|
6
6
|
constructor: T,
|
|
7
7
|
_context: ClassDecoratorContext,
|
|
8
8
|
): T {
|
|
@@ -10,7 +10,9 @@ export function Lazy(placeholder = 200) {
|
|
|
10
10
|
private readonly _lazyVisible = signal(false);
|
|
11
11
|
private _observer?: IntersectionObserver;
|
|
12
12
|
private readonly _originalRender: () => Node | Node[] | null =
|
|
13
|
-
|
|
13
|
+
// Capture the *parent* class render, not LazyWrapper's own override,
|
|
14
|
+
// to avoid infinite recursion when render() delegates here.
|
|
15
|
+
(constructor.prototype as { render(): Node | Node[] | null }).render.bind(this);
|
|
14
16
|
|
|
15
17
|
onMount() {
|
|
16
18
|
super.onMount?.();
|