@praxisjs/decorators 0.7.4 → 0.8.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/CHANGELOG.md +34 -0
- package/dist/__tests__/deep-state.test.d.ts +2 -0
- package/dist/__tests__/deep-state.test.d.ts.map +1 -0
- package/dist/__tests__/deep-state.test.js +150 -0
- package/dist/__tests__/deep-state.test.js.map +1 -0
- package/dist/__tests__/properties.test.js +57 -4
- package/dist/__tests__/properties.test.js.map +1 -1
- package/dist/__tests__/synced.test.d.ts +2 -0
- package/dist/__tests__/synced.test.d.ts.map +1 -0
- package/dist/__tests__/synced.test.js +142 -0
- package/dist/__tests__/synced.test.js.map +1 -0
- package/dist/__tests__/virtual.test.js +4 -2
- package/dist/__tests__/virtual.test.js.map +1 -1
- package/dist/component/virtual.d.ts.map +1 -1
- package/dist/component/virtual.js +12 -6
- package/dist/component/virtual.js.map +1 -1
- package/dist/create-lifecycle-method-decorator.d.ts +1 -1
- package/dist/create-lifecycle-method-decorator.d.ts.map +1 -1
- package/dist/create-lifecycle-method-decorator.js +3 -1
- package/dist/create-lifecycle-method-decorator.js.map +1 -1
- package/dist/events/on-command.d.ts +1 -1
- package/dist/events/on-command.d.ts.map +1 -1
- package/dist/functions/watch.d.ts +1 -1
- package/dist/functions/watch.d.ts.map +1 -1
- package/dist/functions/watch.js +15 -3
- package/dist/functions/watch.js.map +1 -1
- package/dist/functions/when.d.ts +1 -1
- package/dist/functions/when.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/properties/deep-state.d.ts +2 -0
- package/dist/properties/deep-state.d.ts.map +1 -0
- package/dist/properties/deep-state.js +55 -0
- package/dist/properties/deep-state.js.map +1 -0
- package/dist/properties/index.d.ts +2 -0
- package/dist/properties/index.d.ts.map +1 -1
- package/dist/properties/index.js +2 -0
- package/dist/properties/index.js.map +1 -1
- package/dist/properties/synced.d.ts +2 -0
- package/dist/properties/synced.d.ts.map +1 -0
- package/dist/properties/synced.js +26 -0
- package/dist/properties/synced.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/deep-state.test.ts +185 -0
- package/src/__tests__/properties.test.ts +65 -4
- package/src/__tests__/synced.test.ts +172 -0
- package/src/__tests__/virtual.test.ts +4 -2
- package/src/component/virtual.tsx +36 -12
- package/src/create-lifecycle-method-decorator.ts +2 -1
- package/src/functions/watch.ts +15 -3
- package/src/index.ts +2 -0
- package/src/properties/deep-state.ts +60 -0
- package/src/properties/index.ts +2 -0
- package/src/properties/synced.ts +28 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
4
|
+
import { effect } from "@praxisjs/core/internal";
|
|
5
|
+
|
|
6
|
+
import { DeepState } from "../properties/deep-state";
|
|
7
|
+
|
|
8
|
+
class TestComponent extends StatefulComponent {
|
|
9
|
+
render() { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fieldCtx(name: string) {
|
|
13
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
14
|
+
return {
|
|
15
|
+
ctx: {
|
|
16
|
+
name,
|
|
17
|
+
kind: "field" as const,
|
|
18
|
+
addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
|
|
19
|
+
} as ClassFieldDecoratorContext,
|
|
20
|
+
run(instance: unknown) {
|
|
21
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("@DeepState decorator", () => {
|
|
27
|
+
it("reads initial value", () => {
|
|
28
|
+
const { ctx, run } = fieldCtx("config");
|
|
29
|
+
DeepState()(undefined, ctx);
|
|
30
|
+
const instance = new TestComponent();
|
|
31
|
+
(instance as unknown as Record<string, unknown>).config = { theme: "light" };
|
|
32
|
+
run(instance);
|
|
33
|
+
|
|
34
|
+
const inst = instance as unknown as { config: { theme: string } };
|
|
35
|
+
expect(inst.config.theme).toBe("light");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("nested object mutation triggers reactive effect", () => {
|
|
39
|
+
const { ctx, run } = fieldCtx("config");
|
|
40
|
+
DeepState()(undefined, ctx);
|
|
41
|
+
const instance = new TestComponent();
|
|
42
|
+
(instance as unknown as Record<string, unknown>).config = { theme: { mode: "light" } };
|
|
43
|
+
run(instance);
|
|
44
|
+
|
|
45
|
+
const inst = instance as unknown as { config: { theme: { mode: string } } };
|
|
46
|
+
const log: string[] = [];
|
|
47
|
+
effect(() => { log.push(inst.config.theme.mode); });
|
|
48
|
+
|
|
49
|
+
inst.config.theme.mode = "dark";
|
|
50
|
+
expect(log).toEqual(["light", "dark"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("array push triggers reactive effect", () => {
|
|
54
|
+
const { ctx, run } = fieldCtx("items");
|
|
55
|
+
DeepState()(undefined, ctx);
|
|
56
|
+
const instance = new TestComponent();
|
|
57
|
+
(instance as unknown as Record<string, unknown>).items = [1, 2];
|
|
58
|
+
run(instance);
|
|
59
|
+
|
|
60
|
+
const inst = instance as unknown as { items: number[] };
|
|
61
|
+
const log: number[][] = [];
|
|
62
|
+
effect(() => { log.push([...inst.items]); });
|
|
63
|
+
|
|
64
|
+
inst.items.push(3);
|
|
65
|
+
expect(log[log.length - 1]).toEqual([1, 2, 3]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("array index assignment triggers reactive effect", () => {
|
|
69
|
+
const { ctx, run } = fieldCtx("list");
|
|
70
|
+
DeepState()(undefined, ctx);
|
|
71
|
+
const instance = new TestComponent();
|
|
72
|
+
(instance as unknown as Record<string, unknown>).list = ["a", "b"];
|
|
73
|
+
run(instance);
|
|
74
|
+
|
|
75
|
+
const inst = instance as unknown as { list: string[] };
|
|
76
|
+
const log: string[][] = [];
|
|
77
|
+
effect(() => { log.push([...inst.list]); });
|
|
78
|
+
|
|
79
|
+
inst.list[0] = "z";
|
|
80
|
+
expect(log[log.length - 1]).toEqual(["z", "b"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("property deletion triggers reactive effect", () => {
|
|
84
|
+
const { ctx, run } = fieldCtx("obj");
|
|
85
|
+
DeepState()(undefined, ctx);
|
|
86
|
+
const instance = new TestComponent();
|
|
87
|
+
(instance as unknown as Record<string, unknown>).obj = { a: 1, b: 2 };
|
|
88
|
+
run(instance);
|
|
89
|
+
|
|
90
|
+
const inst = instance as unknown as { obj: Record<string, number> };
|
|
91
|
+
const log: string[][] = [];
|
|
92
|
+
effect(() => { log.push(Object.keys(inst.obj)); });
|
|
93
|
+
|
|
94
|
+
delete inst.obj.a;
|
|
95
|
+
expect(log[log.length - 1]).not.toContain("a");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("direct assignment replaces root and triggers effect", () => {
|
|
99
|
+
const { ctx, run } = fieldCtx("data");
|
|
100
|
+
DeepState()(undefined, ctx);
|
|
101
|
+
const instance = new TestComponent();
|
|
102
|
+
(instance as unknown as Record<string, unknown>).data = { x: 1 };
|
|
103
|
+
run(instance);
|
|
104
|
+
|
|
105
|
+
const inst = instance as unknown as { data: { x: number } };
|
|
106
|
+
const log: number[] = [];
|
|
107
|
+
effect(() => { log.push(inst.data.x); });
|
|
108
|
+
|
|
109
|
+
inst.data = { x: 99 };
|
|
110
|
+
expect(log).toEqual([1, 99]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("mutations on new root after reassignment are reactive", () => {
|
|
114
|
+
const { ctx, run } = fieldCtx("cfg");
|
|
115
|
+
DeepState()(undefined, ctx);
|
|
116
|
+
const instance = new TestComponent();
|
|
117
|
+
(instance as unknown as Record<string, unknown>).cfg = { v: 0 };
|
|
118
|
+
run(instance);
|
|
119
|
+
|
|
120
|
+
const inst = instance as unknown as { cfg: { v: number } };
|
|
121
|
+
inst.cfg = { v: 10 };
|
|
122
|
+
|
|
123
|
+
const log: number[] = [];
|
|
124
|
+
effect(() => { log.push(inst.cfg.v); });
|
|
125
|
+
|
|
126
|
+
inst.cfg.v = 20;
|
|
127
|
+
expect(log).toEqual([10, 20]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("null initial value is handled gracefully", () => {
|
|
131
|
+
const { ctx, run } = fieldCtx("maybe");
|
|
132
|
+
DeepState()(undefined, ctx);
|
|
133
|
+
const instance = new TestComponent();
|
|
134
|
+
(instance as unknown as Record<string, unknown>).maybe = null;
|
|
135
|
+
run(instance);
|
|
136
|
+
|
|
137
|
+
const inst = instance as unknown as { maybe: null | { x: number } };
|
|
138
|
+
expect(inst.maybe).toBeNull();
|
|
139
|
+
|
|
140
|
+
const log: unknown[] = [];
|
|
141
|
+
effect(() => { log.push(inst.maybe); });
|
|
142
|
+
|
|
143
|
+
inst.maybe = { x: 5 };
|
|
144
|
+
expect(log).toEqual([null, { x: 5 }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("setter with null value — proxy not created, value is null", () => {
|
|
148
|
+
const { ctx, run } = fieldCtx("nullable");
|
|
149
|
+
DeepState()(undefined, ctx);
|
|
150
|
+
const instance = new TestComponent();
|
|
151
|
+
(instance as unknown as Record<string, unknown>).nullable = { x: 1 };
|
|
152
|
+
run(instance);
|
|
153
|
+
|
|
154
|
+
const inst = instance as unknown as { nullable: { x: number } | null };
|
|
155
|
+
inst.nullable = null;
|
|
156
|
+
expect(inst.nullable).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("setter with primitive value — value stored directly without proxy", () => {
|
|
160
|
+
const { ctx, run } = fieldCtx("num");
|
|
161
|
+
DeepState()(undefined, ctx);
|
|
162
|
+
const instance = new TestComponent();
|
|
163
|
+
(instance as unknown as Record<string, unknown>).num = { v: 0 };
|
|
164
|
+
run(instance);
|
|
165
|
+
|
|
166
|
+
const inst = instance as unknown as { num: number | { v: number } };
|
|
167
|
+
inst.num = 99;
|
|
168
|
+
expect(inst.num).toBe(99);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("deeply nested mutation triggers effect", () => {
|
|
172
|
+
const { ctx, run } = fieldCtx("deep");
|
|
173
|
+
DeepState()(undefined, ctx);
|
|
174
|
+
const instance = new TestComponent();
|
|
175
|
+
(instance as unknown as Record<string, unknown>).deep = { a: { b: { c: 0 } } };
|
|
176
|
+
run(instance);
|
|
177
|
+
|
|
178
|
+
const inst = instance as unknown as { deep: { a: { b: { c: number } } } };
|
|
179
|
+
const log: number[] = [];
|
|
180
|
+
effect(() => { log.push(inst.deep.a.b.c); });
|
|
181
|
+
|
|
182
|
+
inst.deep.a.b.c = 42;
|
|
183
|
+
expect(log).toEqual([0, 42]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
3
3
|
import { StatefulComponent } from "@praxisjs/core";
|
|
4
4
|
import { signal, computed } from "@praxisjs/core/internal";
|
|
5
5
|
|
|
6
|
+
import { createFieldDecorator } from "../create-field-decorator";
|
|
6
7
|
import { Watch } from "../functions/watch";
|
|
7
8
|
import { When } from "../functions/when";
|
|
8
9
|
import { Compose } from "../properties/compose";
|
|
@@ -232,7 +233,7 @@ describe("Watch decorator", () => {
|
|
|
232
233
|
expect(handler).not.toHaveBeenCalled();
|
|
233
234
|
});
|
|
234
235
|
|
|
235
|
-
it("multi-prop watch fires when any watched prop changes", () => {
|
|
236
|
+
it("multi-prop watch fires when any watched prop changes", async () => {
|
|
236
237
|
const { ctx, run } = methodCtx("onMulti");
|
|
237
238
|
const handler = vi.fn();
|
|
238
239
|
Watch("a" as never, "b" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
@@ -246,10 +247,34 @@ describe("Watch decorator", () => {
|
|
|
246
247
|
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
247
248
|
|
|
248
249
|
sa.set(1);
|
|
250
|
+
await Promise.resolve();
|
|
249
251
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
250
252
|
sb.set(1);
|
|
253
|
+
await Promise.resolve();
|
|
251
254
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
252
255
|
});
|
|
256
|
+
|
|
257
|
+
it("multi-prop watch coalesces simultaneous changes into a single callback", async () => {
|
|
258
|
+
const { ctx, run } = methodCtx("onMulti");
|
|
259
|
+
const handler = vi.fn();
|
|
260
|
+
Watch("a" as never, "b" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
261
|
+
|
|
262
|
+
const sa = signal(0);
|
|
263
|
+
const sb = signal(0);
|
|
264
|
+
const instance = new TestComponent();
|
|
265
|
+
Object.defineProperty(instance, "a", { get: () => sa(), configurable: true });
|
|
266
|
+
Object.defineProperty(instance, "b", { get: () => sb(), configurable: true });
|
|
267
|
+
run(instance);
|
|
268
|
+
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
269
|
+
|
|
270
|
+
// both change in the same synchronous block
|
|
271
|
+
sa.set(1);
|
|
272
|
+
sb.set(1);
|
|
273
|
+
await Promise.resolve();
|
|
274
|
+
|
|
275
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
276
|
+
expect(handler).toHaveBeenCalledWith({ a: 1, b: 1 }, { a: 0, b: 0 });
|
|
277
|
+
});
|
|
253
278
|
});
|
|
254
279
|
|
|
255
280
|
// ── History ───────────────────────────────────────────────────────────────────
|
|
@@ -506,7 +531,7 @@ describe("When decorator", () => {
|
|
|
506
531
|
expect(unmountSpy).toHaveBeenCalled();
|
|
507
532
|
});
|
|
508
533
|
|
|
509
|
-
it("works when the watched prop is a computed value", () => {
|
|
534
|
+
it("works when the watched prop is a computed value", async () => {
|
|
510
535
|
const { ctx, run } = methodCtxFor("onComputed");
|
|
511
536
|
const handler = vi.fn();
|
|
512
537
|
When("doubled")(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
@@ -521,6 +546,7 @@ describe("When decorator", () => {
|
|
|
521
546
|
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
522
547
|
|
|
523
548
|
s.set(5); // doubled becomes 10 (truthy)
|
|
549
|
+
await Promise.resolve();
|
|
524
550
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
525
551
|
});
|
|
526
552
|
});
|
|
@@ -677,7 +703,7 @@ describe("Watch decorator — onUnmount cleanup", () => {
|
|
|
677
703
|
expect(unmountSpy).toHaveBeenCalled();
|
|
678
704
|
});
|
|
679
705
|
|
|
680
|
-
it("multi-prop watch does NOT fire when an unwatched prop changes", () => {
|
|
706
|
+
it("multi-prop watch does NOT fire when an unwatched prop changes", async () => {
|
|
681
707
|
const { ctx, run } = methodCtx("onAB");
|
|
682
708
|
const handler = vi.fn();
|
|
683
709
|
Watch("a" as never, "b" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
@@ -694,9 +720,11 @@ describe("Watch decorator — onUnmount cleanup", () => {
|
|
|
694
720
|
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
695
721
|
|
|
696
722
|
sc.set(99); // unwatched prop — handler must NOT fire
|
|
723
|
+
await Promise.resolve();
|
|
697
724
|
expect(handler).not.toHaveBeenCalled();
|
|
698
725
|
|
|
699
726
|
sa.set(1); // watched — handler fires
|
|
727
|
+
await Promise.resolve();
|
|
700
728
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
701
729
|
});
|
|
702
730
|
});
|
|
@@ -704,7 +732,7 @@ describe("Watch decorator — onUnmount cleanup", () => {
|
|
|
704
732
|
// ── Watch (additional branches — computed readValue) ──────────────────────────
|
|
705
733
|
|
|
706
734
|
describe("Watch decorator — reading computed values", () => {
|
|
707
|
-
it("reads the value of a computed field (not the computed object)", () => {
|
|
735
|
+
it("reads the value of a computed field (not the computed object)", async () => {
|
|
708
736
|
const { ctx, run } = methodCtx("onDoubled");
|
|
709
737
|
const handler = vi.fn();
|
|
710
738
|
Watch("doubled" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
|
|
@@ -721,6 +749,7 @@ describe("Watch decorator — reading computed values", () => {
|
|
|
721
749
|
(instance as unknown as { onMount: () => void }).onMount?.();
|
|
722
750
|
|
|
723
751
|
s.set(5); // c() goes from 2 to 10 → triggers handler
|
|
752
|
+
await Promise.resolve();
|
|
724
753
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
725
754
|
});
|
|
726
755
|
});
|
|
@@ -956,3 +985,35 @@ describe("@Compose decorator — new cases", () => {
|
|
|
956
985
|
expect(view.resolved).toBeUndefined();
|
|
957
986
|
});
|
|
958
987
|
});
|
|
988
|
+
|
|
989
|
+
// ── createFieldDecorator — additional property ────────────────────────────────
|
|
990
|
+
|
|
991
|
+
describe("createFieldDecorator — additional property", () => {
|
|
992
|
+
it("defines extra properties on the instance when additional is returned", () => {
|
|
993
|
+
const decorator = createFieldDecorator({
|
|
994
|
+
bind(_instance, _name, initialValue) {
|
|
995
|
+
return {
|
|
996
|
+
descriptor: {
|
|
997
|
+
get() { return initialValue; },
|
|
998
|
+
set() { /* no-op */ },
|
|
999
|
+
},
|
|
1000
|
+
additional: {
|
|
1001
|
+
extraProp: {
|
|
1002
|
+
configurable: true,
|
|
1003
|
+
get() { return "extra"; },
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
const { ctx, run } = fieldCtx("myField");
|
|
1011
|
+
decorator(undefined, ctx);
|
|
1012
|
+
const instance = new TestComponent();
|
|
1013
|
+
(instance as unknown as Record<string, unknown>).myField = 42;
|
|
1014
|
+
run(instance);
|
|
1015
|
+
|
|
1016
|
+
expect((instance as unknown as Record<string, unknown>).myField).toBe(42);
|
|
1017
|
+
expect((instance as unknown as Record<string, unknown>).extraProp).toBe("extra");
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
5
|
+
import { effect } from "@praxisjs/core/internal";
|
|
6
|
+
|
|
7
|
+
import { Synced } from "../properties/synced";
|
|
8
|
+
|
|
9
|
+
// BroadcastChannel mock (same as core test)
|
|
10
|
+
class MockBroadcastChannel {
|
|
11
|
+
static instances: MockBroadcastChannel[] = [];
|
|
12
|
+
static byChannel: Map<string, MockBroadcastChannel[]> = new Map();
|
|
13
|
+
|
|
14
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
closed = false;
|
|
17
|
+
|
|
18
|
+
constructor(name: string) {
|
|
19
|
+
this.name = name;
|
|
20
|
+
MockBroadcastChannel.instances.push(this);
|
|
21
|
+
if (!MockBroadcastChannel.byChannel.has(name)) {
|
|
22
|
+
MockBroadcastChannel.byChannel.set(name, []);
|
|
23
|
+
}
|
|
24
|
+
MockBroadcastChannel.byChannel.get(name)!.push(this);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
postMessage(data: unknown) {
|
|
28
|
+
MockBroadcastChannel.byChannel.get(this.name)?.forEach((ch) => {
|
|
29
|
+
if (ch !== this && ch.onmessage) {
|
|
30
|
+
ch.onmessage(new MessageEvent("message", { data }));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
close() {
|
|
36
|
+
this.closed = true;
|
|
37
|
+
const list = MockBroadcastChannel.byChannel.get(this.name);
|
|
38
|
+
if (list) {
|
|
39
|
+
const idx = list.indexOf(this);
|
|
40
|
+
if (idx !== -1) list.splice(idx, 1);
|
|
41
|
+
}
|
|
42
|
+
MockBroadcastChannel.instances.splice(
|
|
43
|
+
MockBroadcastChannel.instances.indexOf(this),
|
|
44
|
+
1,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
MockBroadcastChannel.instances = [];
|
|
51
|
+
MockBroadcastChannel.byChannel = new Map();
|
|
52
|
+
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
class TestComponent extends StatefulComponent {
|
|
56
|
+
render() { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fieldCtx(name: string) {
|
|
60
|
+
const initializers: Array<(this: unknown) => void> = [];
|
|
61
|
+
return {
|
|
62
|
+
ctx: {
|
|
63
|
+
name,
|
|
64
|
+
kind: "field" as const,
|
|
65
|
+
addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
|
|
66
|
+
} as ClassFieldDecoratorContext,
|
|
67
|
+
run(instance: unknown) {
|
|
68
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("@Synced decorator", () => {
|
|
74
|
+
it("reads and writes the field reactively", () => {
|
|
75
|
+
const { ctx, run } = fieldCtx("count");
|
|
76
|
+
Synced()(undefined, ctx);
|
|
77
|
+
const instance = new TestComponent();
|
|
78
|
+
(instance as unknown as Record<string, unknown>).count = 0;
|
|
79
|
+
run(instance);
|
|
80
|
+
|
|
81
|
+
const inst = instance as unknown as { count: number };
|
|
82
|
+
expect(inst.count).toBe(0);
|
|
83
|
+
inst.count = 5;
|
|
84
|
+
expect(inst.count).toBe(5);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses field name as channel name by default", () => {
|
|
88
|
+
const { ctx, run } = fieldCtx("cart");
|
|
89
|
+
Synced()(undefined, ctx);
|
|
90
|
+
const instance = new TestComponent();
|
|
91
|
+
(instance as unknown as Record<string, unknown>).cart = [];
|
|
92
|
+
run(instance);
|
|
93
|
+
|
|
94
|
+
expect(MockBroadcastChannel.instances[0].name).toBe("cart");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses custom channel name when provided", () => {
|
|
98
|
+
const { ctx, run } = fieldCtx("items");
|
|
99
|
+
Synced("my-channel")(undefined, ctx);
|
|
100
|
+
const instance = new TestComponent();
|
|
101
|
+
(instance as unknown as Record<string, unknown>).items = [];
|
|
102
|
+
run(instance);
|
|
103
|
+
|
|
104
|
+
expect(MockBroadcastChannel.instances[0].name).toBe("my-channel");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("triggers reactive effects on write", () => {
|
|
108
|
+
const { ctx, run } = fieldCtx("value");
|
|
109
|
+
Synced()(undefined, ctx);
|
|
110
|
+
const instance = new TestComponent();
|
|
111
|
+
(instance as unknown as Record<string, unknown>).value = 0;
|
|
112
|
+
run(instance);
|
|
113
|
+
|
|
114
|
+
const inst = instance as unknown as { value: number };
|
|
115
|
+
const log: number[] = [];
|
|
116
|
+
effect(() => { log.push(inst.value); });
|
|
117
|
+
|
|
118
|
+
inst.value = 10;
|
|
119
|
+
expect(log).toEqual([0, 10]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("updates field when a remote message arrives", () => {
|
|
123
|
+
const { ctx, run } = fieldCtx("score");
|
|
124
|
+
Synced()(undefined, ctx);
|
|
125
|
+
const instance = new TestComponent();
|
|
126
|
+
(instance as unknown as Record<string, unknown>).score = 0;
|
|
127
|
+
run(instance);
|
|
128
|
+
|
|
129
|
+
const inst = instance as unknown as { score: number };
|
|
130
|
+
const channel = MockBroadcastChannel.instances[0];
|
|
131
|
+
channel.onmessage!(new MessageEvent("message", { data: JSON.stringify(42) }));
|
|
132
|
+
|
|
133
|
+
expect(inst.score).toBe(42);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("two @Synced fields on the same instance share the signalMap entry", () => {
|
|
137
|
+
const { ctx: ctx1, run: run1 } = fieldCtx("a");
|
|
138
|
+
const { ctx: ctx2, run: run2 } = fieldCtx("b");
|
|
139
|
+
Synced()(undefined, ctx1);
|
|
140
|
+
Synced()(undefined, ctx2);
|
|
141
|
+
|
|
142
|
+
const instance = new TestComponent();
|
|
143
|
+
(instance as unknown as Record<string, unknown>).a = 1;
|
|
144
|
+
(instance as unknown as Record<string, unknown>).b = 2;
|
|
145
|
+
run1(instance);
|
|
146
|
+
run2(instance);
|
|
147
|
+
|
|
148
|
+
const inst = instance as unknown as { a: number; b: number };
|
|
149
|
+
inst.a = 10;
|
|
150
|
+
inst.b = 20;
|
|
151
|
+
expect(inst.a).toBe(10);
|
|
152
|
+
expect(inst.b).toBe(20);
|
|
153
|
+
|
|
154
|
+
// Both fields have their own BroadcastChannel
|
|
155
|
+
expect(MockBroadcastChannel.instances.length).toBe(2);
|
|
156
|
+
instance.onUnmount?.();
|
|
157
|
+
expect(MockBroadcastChannel.instances.every((ch) => ch.closed)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("onUnmount closes the BroadcastChannel", () => {
|
|
161
|
+
const { ctx, run } = fieldCtx("data");
|
|
162
|
+
Synced()(undefined, ctx);
|
|
163
|
+
const instance = new TestComponent();
|
|
164
|
+
(instance as unknown as Record<string, unknown>).data = null;
|
|
165
|
+
run(instance);
|
|
166
|
+
|
|
167
|
+
const channel = MockBroadcastChannel.instances[0];
|
|
168
|
+
expect(channel.closed).toBe(false);
|
|
169
|
+
instance.onUnmount?.();
|
|
170
|
+
expect(channel.closed).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -137,7 +137,7 @@ describe("Virtual decorator", () => {
|
|
|
137
137
|
document.body.removeChild(container);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
it("re-renders visible items when scrollTop changes (covers while loop cleanup)", () => {
|
|
140
|
+
it("re-renders visible items when scrollTop changes (covers while loop cleanup)", async () => {
|
|
141
141
|
class ScrollList extends StatefulComponent {
|
|
142
142
|
items = Array.from({ length: 20 }, (_, i) => i);
|
|
143
143
|
renderItem(item: unknown) {
|
|
@@ -164,6 +164,7 @@ describe("Virtual decorator", () => {
|
|
|
164
164
|
// Change scrollTop to trigger effect re-run (covers the while loop removing old nodes)
|
|
165
165
|
Object.defineProperty(container, "scrollTop", { configurable: true, value: 500 });
|
|
166
166
|
container.dispatchEvent(new Event("scroll"));
|
|
167
|
+
await Promise.resolve();
|
|
167
168
|
|
|
168
169
|
// buffer=0, itemHeight=50, scrollTop=500 → startIdx=10 → offsetTop=500px
|
|
169
170
|
const spacerTop = outer.children[0] as HTMLElement;
|
|
@@ -234,7 +235,7 @@ describe("Virtual decorator", () => {
|
|
|
234
235
|
expect(() => instance.onUnmount?.()).not.toThrow();
|
|
235
236
|
});
|
|
236
237
|
|
|
237
|
-
it("updates scrollTop on scroll event", () => {
|
|
238
|
+
it("updates scrollTop on scroll event", async () => {
|
|
238
239
|
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
239
240
|
const instance = new Wrapped();
|
|
240
241
|
(instance as unknown as { items: unknown[] }).items = Array.from({ length: 20 }, (_, i) => i);
|
|
@@ -251,6 +252,7 @@ describe("Virtual decorator", () => {
|
|
|
251
252
|
|
|
252
253
|
Object.defineProperty(container, "scrollTop", { configurable: true, value: 200 });
|
|
253
254
|
container.dispatchEvent(new Event("scroll"));
|
|
255
|
+
await Promise.resolve();
|
|
254
256
|
|
|
255
257
|
// buffer=3, itemHeight=50, scrollTop=200 → startIdx=1 → offsetTop=50px
|
|
256
258
|
const spacerTop = outer.children[0] as HTMLElement;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
effect,
|
|
4
|
+
type RootComponent,
|
|
5
|
+
signal,
|
|
6
|
+
} from "@praxisjs/core/internal";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ClassBehavior,
|
|
10
|
+
createClassDecorator,
|
|
11
|
+
type ClassEnhancement,
|
|
12
|
+
} from "../create-class-decorator";
|
|
4
13
|
|
|
5
14
|
interface VirtualHost {
|
|
6
15
|
_anchor?: Comment;
|
|
@@ -39,8 +48,9 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
39
48
|
container.style.position = "relative";
|
|
40
49
|
viewHeight.set(container.clientHeight || 600);
|
|
41
50
|
|
|
51
|
+
const currentContainer = container;
|
|
42
52
|
scrollHandler = () => {
|
|
43
|
-
|
|
53
|
+
scrollTop.set(currentContainer.scrollTop);
|
|
44
54
|
};
|
|
45
55
|
container.addEventListener("scroll", scrollHandler);
|
|
46
56
|
},
|
|
@@ -49,7 +59,9 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
49
59
|
if (container && scrollHandler) {
|
|
50
60
|
container.removeEventListener("scroll", scrollHandler);
|
|
51
61
|
}
|
|
52
|
-
cleanups.forEach((c) => {
|
|
62
|
+
cleanups.forEach((c) => {
|
|
63
|
+
c();
|
|
64
|
+
});
|
|
53
65
|
cleanups.length = 0;
|
|
54
66
|
},
|
|
55
67
|
|
|
@@ -84,10 +96,15 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
84
96
|
return result;
|
|
85
97
|
});
|
|
86
98
|
const offsetTop = computed(() => startIdx() * itemHeight);
|
|
87
|
-
const offsetBottom = computed(
|
|
99
|
+
const offsetBottom = computed(
|
|
100
|
+
() => (total - 1 - endIdx()) * itemHeight,
|
|
101
|
+
);
|
|
88
102
|
|
|
89
103
|
const outer = document.createElement("div");
|
|
90
|
-
outer.setAttribute(
|
|
104
|
+
outer.setAttribute(
|
|
105
|
+
"style",
|
|
106
|
+
`height:${String(totalH)}px; position:relative;`,
|
|
107
|
+
);
|
|
91
108
|
|
|
92
109
|
const spacerTop = document.createElement("div");
|
|
93
110
|
cleanups.push(
|
|
@@ -99,7 +116,8 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
99
116
|
const itemsSlot = document.createElement("div");
|
|
100
117
|
cleanups.push(
|
|
101
118
|
effect(() => {
|
|
102
|
-
while (itemsSlot.firstChild)
|
|
119
|
+
while (itemsSlot.firstChild)
|
|
120
|
+
itemsSlot.removeChild(itemsSlot.firstChild);
|
|
103
121
|
visibleItems().forEach(({ item, index }) => {
|
|
104
122
|
const wrapper = document.createElement("div");
|
|
105
123
|
wrapper.setAttribute(
|
|
@@ -108,9 +126,12 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
108
126
|
);
|
|
109
127
|
const rendered = renderItem(item, index);
|
|
110
128
|
if (rendered) {
|
|
111
|
-
(Array.isArray(rendered)
|
|
112
|
-
|
|
113
|
-
|
|
129
|
+
(Array.isArray(rendered)
|
|
130
|
+
? rendered.flat()
|
|
131
|
+
: [rendered]
|
|
132
|
+
).forEach((n) => {
|
|
133
|
+
wrapper.appendChild(n);
|
|
134
|
+
});
|
|
114
135
|
}
|
|
115
136
|
itemsSlot.appendChild(wrapper);
|
|
116
137
|
});
|
|
@@ -120,7 +141,10 @@ class VirtualBehavior extends ClassBehavior {
|
|
|
120
141
|
const spacerBottom = document.createElement("div");
|
|
121
142
|
cleanups.push(
|
|
122
143
|
effect(() => {
|
|
123
|
-
spacerBottom.setAttribute(
|
|
144
|
+
spacerBottom.setAttribute(
|
|
145
|
+
"style",
|
|
146
|
+
`height:${String(offsetBottom())}px;`,
|
|
147
|
+
);
|
|
124
148
|
}),
|
|
125
149
|
);
|
|
126
150
|
|
|
@@ -13,7 +13,8 @@ export function createLifecycleMethodDecorator<
|
|
|
13
13
|
T extends StatefulComponent = StatefulComponent,
|
|
14
14
|
>(behavior: LifecycleMethodBehavior<T>) {
|
|
15
15
|
return function (
|
|
16
|
-
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
value: (this: T, ...args: any[]) => void,
|
|
17
18
|
_context: ClassMethodDecoratorContext<T>,
|
|
18
19
|
): void {
|
|
19
20
|
_context.addInitializer(function (this: unknown) {
|
package/src/functions/watch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StatefulComponent } from "@praxisjs/core";
|
|
2
|
-
import { effect } from "@praxisjs/core/internal";
|
|
2
|
+
import { effect, batch } from "@praxisjs/core/internal";
|
|
3
3
|
import type { Computed } from "@praxisjs/shared";
|
|
4
4
|
import { isComputed } from "@praxisjs/shared/internal";
|
|
5
5
|
|
|
@@ -76,11 +76,23 @@ export function Watch<
|
|
|
76
76
|
const read = () =>
|
|
77
77
|
Object.fromEntries(props.map((p) => [p, readValue(inst, p)]));
|
|
78
78
|
let oldVals = read();
|
|
79
|
+
let latestVals = oldVals;
|
|
80
|
+
let scheduled = false;
|
|
79
81
|
return effect(() => {
|
|
80
82
|
const newVals = read();
|
|
81
83
|
if (props.some((p) => !Object.is(newVals[p], oldVals[p]))) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
latestVals = newVals;
|
|
85
|
+
if (!scheduled) {
|
|
86
|
+
scheduled = true;
|
|
87
|
+
const prevVals = oldVals;
|
|
88
|
+
queueMicrotask(() => {
|
|
89
|
+
scheduled = false;
|
|
90
|
+
oldVals = latestVals;
|
|
91
|
+
batch(() => {
|
|
92
|
+
callback(latestVals, prevVals);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
});
|
|
86
98
|
},
|