@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/__tests__/deep-state.test.d.ts +2 -0
  3. package/dist/__tests__/deep-state.test.d.ts.map +1 -0
  4. package/dist/__tests__/deep-state.test.js +150 -0
  5. package/dist/__tests__/deep-state.test.js.map +1 -0
  6. package/dist/__tests__/properties.test.js +57 -4
  7. package/dist/__tests__/properties.test.js.map +1 -1
  8. package/dist/__tests__/synced.test.d.ts +2 -0
  9. package/dist/__tests__/synced.test.d.ts.map +1 -0
  10. package/dist/__tests__/synced.test.js +142 -0
  11. package/dist/__tests__/synced.test.js.map +1 -0
  12. package/dist/__tests__/virtual.test.js +4 -2
  13. package/dist/__tests__/virtual.test.js.map +1 -1
  14. package/dist/component/virtual.d.ts.map +1 -1
  15. package/dist/component/virtual.js +12 -6
  16. package/dist/component/virtual.js.map +1 -1
  17. package/dist/create-lifecycle-method-decorator.d.ts +1 -1
  18. package/dist/create-lifecycle-method-decorator.d.ts.map +1 -1
  19. package/dist/create-lifecycle-method-decorator.js +3 -1
  20. package/dist/create-lifecycle-method-decorator.js.map +1 -1
  21. package/dist/events/on-command.d.ts +1 -1
  22. package/dist/events/on-command.d.ts.map +1 -1
  23. package/dist/functions/watch.d.ts +1 -1
  24. package/dist/functions/watch.d.ts.map +1 -1
  25. package/dist/functions/watch.js +15 -3
  26. package/dist/functions/watch.js.map +1 -1
  27. package/dist/functions/when.d.ts +1 -1
  28. package/dist/functions/when.d.ts.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/properties/deep-state.d.ts +2 -0
  34. package/dist/properties/deep-state.d.ts.map +1 -0
  35. package/dist/properties/deep-state.js +55 -0
  36. package/dist/properties/deep-state.js.map +1 -0
  37. package/dist/properties/index.d.ts +2 -0
  38. package/dist/properties/index.d.ts.map +1 -1
  39. package/dist/properties/index.js +2 -0
  40. package/dist/properties/index.js.map +1 -1
  41. package/dist/properties/synced.d.ts +2 -0
  42. package/dist/properties/synced.d.ts.map +1 -0
  43. package/dist/properties/synced.js +26 -0
  44. package/dist/properties/synced.js.map +1 -0
  45. package/package.json +2 -2
  46. package/src/__tests__/deep-state.test.ts +185 -0
  47. package/src/__tests__/properties.test.ts +65 -4
  48. package/src/__tests__/synced.test.ts +172 -0
  49. package/src/__tests__/virtual.test.ts +4 -2
  50. package/src/component/virtual.tsx +36 -12
  51. package/src/create-lifecycle-method-decorator.ts +2 -1
  52. package/src/functions/watch.ts +15 -3
  53. package/src/index.ts +2 -0
  54. package/src/properties/deep-state.ts +60 -0
  55. package/src/properties/index.ts +2 -0
  56. 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 { computed, effect, type RootComponent, signal } from "@praxisjs/core/internal";
2
-
3
- import { ClassBehavior, createClassDecorator, type ClassEnhancement } from "../create-class-decorator";
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
- if (container) scrollTop.set(container.scrollTop);
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) => { 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(() => (total - 1 - endIdx()) * itemHeight);
99
+ const offsetBottom = computed(
100
+ () => (total - 1 - endIdx()) * itemHeight,
101
+ );
88
102
 
89
103
  const outer = document.createElement("div");
90
- outer.setAttribute("style", `height:${String(totalH)}px; position:relative;`);
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) itemsSlot.removeChild(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) ? rendered.flat() : [rendered]).forEach(
112
- (n) => { wrapper.appendChild(n); },
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("style", `height:${String(offsetBottom())}px;`);
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
- value: (this: T, ...args: unknown[]) => void,
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) {
@@ -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
- callback(newVals, oldVals);
83
- oldVals = newVals;
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
  },
package/src/index.ts CHANGED
@@ -26,6 +26,8 @@ export {
26
26
  Resource,
27
27
  type ResourceInstance,
28
28
  type ResourceOptions,
29
+ Synced,
30
+ DeepState,
29
31
  } from "./properties";
30
32
  export { type Command, createCommand, Emit, OnCommand } from "./events";
31
33
  export {