@praxisjs/decorators 0.4.2 → 0.5.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 (190) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/__tests__/component.test.d.ts +2 -0
  3. package/dist/__tests__/component.test.d.ts.map +1 -0
  4. package/dist/__tests__/component.test.js +146 -0
  5. package/dist/__tests__/component.test.js.map +1 -0
  6. package/dist/__tests__/compose.test.d.ts +2 -0
  7. package/dist/__tests__/compose.test.d.ts.map +1 -0
  8. package/dist/__tests__/compose.test.js +171 -0
  9. package/dist/__tests__/compose.test.js.map +1 -0
  10. package/dist/__tests__/events.test.d.ts +2 -0
  11. package/dist/__tests__/events.test.d.ts.map +1 -0
  12. package/dist/__tests__/events.test.js +216 -0
  13. package/dist/__tests__/events.test.js.map +1 -0
  14. package/dist/__tests__/functions.test.js +283 -31
  15. package/dist/__tests__/functions.test.js.map +1 -1
  16. package/dist/__tests__/lazy.test.d.ts +2 -0
  17. package/dist/__tests__/lazy.test.d.ts.map +1 -0
  18. package/dist/__tests__/lazy.test.js +163 -0
  19. package/dist/__tests__/lazy.test.js.map +1 -0
  20. package/dist/__tests__/memo.test.js +71 -22
  21. package/dist/__tests__/memo.test.js.map +1 -1
  22. package/dist/__tests__/properties.test.js +191 -1
  23. package/dist/__tests__/properties.test.js.map +1 -1
  24. package/dist/__tests__/resource.test.d.ts +2 -0
  25. package/dist/__tests__/resource.test.d.ts.map +1 -0
  26. package/dist/__tests__/resource.test.js +103 -0
  27. package/dist/__tests__/resource.test.js.map +1 -0
  28. package/dist/__tests__/slot.test.d.ts +2 -0
  29. package/dist/__tests__/slot.test.d.ts.map +1 -0
  30. package/dist/__tests__/slot.test.js +147 -0
  31. package/dist/__tests__/slot.test.js.map +1 -0
  32. package/dist/__tests__/virtual.test.d.ts +2 -0
  33. package/dist/__tests__/virtual.test.d.ts.map +1 -0
  34. package/dist/__tests__/virtual.test.js +242 -0
  35. package/dist/__tests__/virtual.test.js.map +1 -0
  36. package/dist/component/component.d.ts +1 -1
  37. package/dist/component/component.d.ts.map +1 -1
  38. package/dist/component/component.js +15 -10
  39. package/dist/component/component.js.map +1 -1
  40. package/dist/component/lazy.d.ts +1 -1
  41. package/dist/component/lazy.d.ts.map +1 -1
  42. package/dist/component/lazy.js +29 -27
  43. package/dist/component/lazy.js.map +1 -1
  44. package/dist/component/virtual.d.ts +1 -1
  45. package/dist/component/virtual.d.ts.map +1 -1
  46. package/dist/component/virtual.js +46 -50
  47. package/dist/component/virtual.js.map +1 -1
  48. package/dist/create-class-decorator.d.ts +16 -0
  49. package/dist/create-class-decorator.d.ts.map +1 -0
  50. package/dist/create-class-decorator.js +35 -0
  51. package/dist/create-class-decorator.js.map +1 -0
  52. package/dist/create-field-decorator.d.ts +14 -0
  53. package/dist/create-field-decorator.d.ts.map +1 -0
  54. package/dist/create-field-decorator.js +37 -0
  55. package/dist/create-field-decorator.js.map +1 -0
  56. package/dist/create-getter-decorator.d.ts +18 -0
  57. package/dist/create-getter-decorator.d.ts.map +1 -0
  58. package/dist/create-getter-decorator.js +15 -0
  59. package/dist/create-getter-decorator.js.map +1 -0
  60. package/dist/create-lifecycle-method-decorator.d.ts +6 -0
  61. package/dist/create-lifecycle-method-decorator.d.ts.map +1 -0
  62. package/dist/create-lifecycle-method-decorator.js +22 -0
  63. package/dist/create-lifecycle-method-decorator.js.map +1 -0
  64. package/dist/create-method-decorator.d.ts +6 -0
  65. package/dist/create-method-decorator.d.ts.map +1 -0
  66. package/dist/create-method-decorator.js +14 -0
  67. package/dist/create-method-decorator.js.map +1 -0
  68. package/dist/events/emit.d.ts +1 -1
  69. package/dist/events/emit.d.ts.map +1 -1
  70. package/dist/events/emit.js +24 -29
  71. package/dist/events/emit.js.map +1 -1
  72. package/dist/events/on-command.d.ts +1 -2
  73. package/dist/events/on-command.d.ts.map +1 -1
  74. package/dist/events/on-command.js +15 -32
  75. package/dist/events/on-command.js.map +1 -1
  76. package/dist/functions/bind.d.ts +1 -2
  77. package/dist/functions/bind.d.ts.map +1 -1
  78. package/dist/functions/bind.js +4 -11
  79. package/dist/functions/bind.js.map +1 -1
  80. package/dist/functions/debounce.d.ts +1 -2
  81. package/dist/functions/debounce.d.ts.map +1 -1
  82. package/dist/functions/debounce.js +12 -20
  83. package/dist/functions/debounce.js.map +1 -1
  84. package/dist/functions/log.d.ts +1 -2
  85. package/dist/functions/log.d.ts.map +1 -1
  86. package/dist/functions/log.js +40 -38
  87. package/dist/functions/log.js.map +1 -1
  88. package/dist/functions/memo.d.ts +1 -2
  89. package/dist/functions/memo.d.ts.map +1 -1
  90. package/dist/functions/memo.js +15 -27
  91. package/dist/functions/memo.js.map +1 -1
  92. package/dist/functions/once.d.ts +1 -2
  93. package/dist/functions/once.d.ts.map +1 -1
  94. package/dist/functions/once.js +14 -12
  95. package/dist/functions/once.js.map +1 -1
  96. package/dist/functions/retry.d.ts +1 -2
  97. package/dist/functions/retry.d.ts.map +1 -1
  98. package/dist/functions/retry.js +23 -20
  99. package/dist/functions/retry.js.map +1 -1
  100. package/dist/functions/throttle.d.ts +1 -2
  101. package/dist/functions/throttle.d.ts.map +1 -1
  102. package/dist/functions/throttle.js +9 -16
  103. package/dist/functions/throttle.js.map +1 -1
  104. package/dist/functions/watch.d.ts +1 -1
  105. package/dist/functions/watch.d.ts.map +1 -1
  106. package/dist/functions/watch.js +25 -31
  107. package/dist/functions/watch.js.map +1 -1
  108. package/dist/functions/when.d.ts +1 -2
  109. package/dist/functions/when.d.ts.map +1 -1
  110. package/dist/functions/when.js +10 -26
  111. package/dist/functions/when.js.map +1 -1
  112. package/dist/index.d.ts +6 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +6 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/properties/compose.d.ts +3 -0
  117. package/dist/properties/compose.d.ts.map +1 -0
  118. package/dist/properties/compose.js +40 -0
  119. package/dist/properties/compose.js.map +1 -0
  120. package/dist/properties/computed.d.ts +1 -2
  121. package/dist/properties/computed.d.ts.map +1 -1
  122. package/dist/properties/computed.js +10 -9
  123. package/dist/properties/computed.js.map +1 -1
  124. package/dist/properties/history.d.ts.map +1 -1
  125. package/dist/properties/history.js +37 -32
  126. package/dist/properties/history.js.map +1 -1
  127. package/dist/properties/index.d.ts +2 -0
  128. package/dist/properties/index.d.ts.map +1 -1
  129. package/dist/properties/index.js +2 -0
  130. package/dist/properties/index.js.map +1 -1
  131. package/dist/properties/persisted.d.ts +1 -2
  132. package/dist/properties/persisted.d.ts.map +1 -1
  133. package/dist/properties/persisted.js +13 -29
  134. package/dist/properties/persisted.js.map +1 -1
  135. package/dist/properties/prop.d.ts +1 -2
  136. package/dist/properties/prop.d.ts.map +1 -1
  137. package/dist/properties/prop.js +21 -25
  138. package/dist/properties/prop.js.map +1 -1
  139. package/dist/properties/resource.d.ts +14 -0
  140. package/dist/properties/resource.d.ts.map +1 -0
  141. package/dist/properties/resource.js +27 -0
  142. package/dist/properties/resource.js.map +1 -0
  143. package/dist/properties/slot.d.ts +1 -2
  144. package/dist/properties/slot.d.ts.map +1 -1
  145. package/dist/properties/slot.js +15 -20
  146. package/dist/properties/slot.js.map +1 -1
  147. package/dist/properties/state.d.ts +1 -2
  148. package/dist/properties/state.d.ts.map +1 -1
  149. package/dist/properties/state.js +13 -18
  150. package/dist/properties/state.js.map +1 -1
  151. package/package.json +2 -2
  152. package/src/__tests__/component.test.ts +166 -0
  153. package/src/__tests__/compose.test.ts +202 -0
  154. package/src/__tests__/events.test.ts +268 -0
  155. package/src/__tests__/functions.test.ts +368 -30
  156. package/src/__tests__/lazy.test.ts +218 -0
  157. package/src/__tests__/memo.test.ts +89 -22
  158. package/src/__tests__/properties.test.ts +249 -1
  159. package/src/__tests__/resource.test.ts +113 -0
  160. package/src/__tests__/slot.test.ts +179 -0
  161. package/src/__tests__/virtual.test.ts +260 -0
  162. package/src/component/component.ts +27 -12
  163. package/src/component/lazy.ts +33 -35
  164. package/src/component/virtual.tsx +62 -75
  165. package/src/create-class-decorator.ts +61 -0
  166. package/src/create-field-decorator.ts +66 -0
  167. package/src/create-getter-decorator.ts +52 -0
  168. package/src/create-lifecycle-method-decorator.ts +38 -0
  169. package/src/create-method-decorator.ts +26 -0
  170. package/src/events/emit.ts +22 -33
  171. package/src/events/on-command.ts +22 -47
  172. package/src/functions/bind.ts +4 -15
  173. package/src/functions/debounce.ts +11 -26
  174. package/src/functions/log.ts +40 -43
  175. package/src/functions/memo.ts +15 -45
  176. package/src/functions/once.ts +13 -16
  177. package/src/functions/retry.ts +21 -22
  178. package/src/functions/throttle.ts +9 -23
  179. package/src/functions/watch.ts +27 -34
  180. package/src/functions/when.ts +11 -36
  181. package/src/index.ts +28 -0
  182. package/src/properties/compose.ts +53 -0
  183. package/src/properties/computed.ts +11 -13
  184. package/src/properties/history.ts +42 -41
  185. package/src/properties/index.ts +2 -0
  186. package/src/properties/persisted.ts +17 -58
  187. package/src/properties/prop.ts +21 -31
  188. package/src/properties/resource.ts +34 -0
  189. package/src/properties/slot.ts +17 -30
  190. package/src/properties/state.ts +14 -24
@@ -0,0 +1,218 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
3
+
4
+ import { StatefulComponent } from "@praxisjs/core";
5
+ import { Lazy } from "../component/lazy";
6
+
7
+ type AnyConstructor = new (...args: unknown[]) => StatefulComponent;
8
+
9
+ function applyLazy<T extends AnyConstructor>(cls: T, placeholder?: number): T {
10
+ return placeholder !== undefined
11
+ ? Lazy(placeholder)(cls, {} as ClassDecoratorContext)
12
+ : Lazy()(cls, {} as ClassDecoratorContext);
13
+ }
14
+
15
+ class BaseComp extends StatefulComponent {
16
+ render() {
17
+ return document.createTextNode("content");
18
+ }
19
+ }
20
+
21
+ describe("Lazy decorator", () => {
22
+ let intersectionCallback: IntersectionObserverCallback = () => {};
23
+ let mockObserve: ReturnType<typeof vi.fn>;
24
+ let mockDisconnect: ReturnType<typeof vi.fn>;
25
+
26
+ beforeEach(() => {
27
+ intersectionCallback = () => {};
28
+ mockObserve = vi.fn();
29
+ mockDisconnect = vi.fn();
30
+
31
+ globalThis.IntersectionObserver = class {
32
+ constructor(cb: IntersectionObserverCallback) {
33
+ intersectionCallback = cb;
34
+ }
35
+ observe = mockObserve;
36
+ disconnect = mockDisconnect;
37
+ } as unknown as typeof IntersectionObserver;
38
+ });
39
+
40
+ afterEach(() => {
41
+ // @ts-expect-error – removing test stub
42
+ delete globalThis.IntersectionObserver;
43
+ });
44
+
45
+ it("returns null from render before visibility", () => {
46
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
47
+ const instance = new Wrapped();
48
+ expect(instance.render()).toBeNull();
49
+ });
50
+
51
+ it("sets _lazyVisible to true when IntersectionObserver is not supported", () => {
52
+ // Remove IntersectionObserver to simulate unsupported environment
53
+ // @ts-expect-error – removing test stub
54
+ delete globalThis.IntersectionObserver;
55
+
56
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
57
+ const instance = new Wrapped();
58
+
59
+ // Simulate anchor with parent element
60
+ const parent = document.createElement("div");
61
+ document.body.appendChild(parent);
62
+ const anchor = document.createComment("end");
63
+ parent.appendChild(anchor);
64
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
65
+
66
+ instance.onMount?.();
67
+ // Without IntersectionObserver, component should immediately become visible
68
+ expect(instance.render()).not.toBeNull();
69
+ document.body.removeChild(parent);
70
+ });
71
+
72
+ it("sets minHeight placeholder on mount", () => {
73
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 300);
74
+ const instance = new Wrapped();
75
+
76
+ const parent = document.createElement("div");
77
+ document.body.appendChild(parent);
78
+ const anchor = document.createComment("end");
79
+ parent.appendChild(anchor);
80
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
81
+
82
+ instance.onMount?.();
83
+ expect(parent.style.minHeight).toBe("300px");
84
+ document.body.removeChild(parent);
85
+ });
86
+
87
+ it("sets _lazyVisible and clears minHeight when intersecting", () => {
88
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
89
+ const instance = new Wrapped();
90
+
91
+ const parent = document.createElement("div");
92
+ document.body.appendChild(parent);
93
+ const anchor = document.createComment("end");
94
+ parent.appendChild(anchor);
95
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
96
+
97
+ instance.onMount?.();
98
+ expect(instance.render()).toBeNull();
99
+
100
+ intersectionCallback(
101
+ [{ isIntersecting: true } as IntersectionObserverEntry],
102
+ null as unknown as IntersectionObserver,
103
+ );
104
+
105
+ expect(parent.style.minHeight).toBe("");
106
+ expect(instance.render()).not.toBeNull(); // component became visible after intersection
107
+ expect(mockDisconnect).toHaveBeenCalled();
108
+ document.body.removeChild(parent);
109
+ });
110
+
111
+ it("does nothing when _anchor has no parent", () => {
112
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
113
+ const instance = new Wrapped();
114
+
115
+ const anchor = document.createComment("end");
116
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
117
+
118
+ // Should not throw even though anchor has no parent
119
+ expect(() => instance.onMount?.()).not.toThrow();
120
+ });
121
+
122
+ it("disconnects observer on unmount", () => {
123
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
124
+ const instance = new Wrapped();
125
+
126
+ const parent = document.createElement("div");
127
+ document.body.appendChild(parent);
128
+ const anchor = document.createComment("end");
129
+ parent.appendChild(anchor);
130
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
131
+
132
+ instance.onMount?.();
133
+ instance.onUnmount?.();
134
+ expect(mockDisconnect).toHaveBeenCalled();
135
+ document.body.removeChild(parent);
136
+ });
137
+
138
+ it("onMount does nothing when _anchor is undefined", () => {
139
+ const Wrapped = applyLazy(BaseComp as AnyConstructor);
140
+ const instance = new Wrapped();
141
+ // _anchor is undefined by default
142
+ expect(() => instance.onMount?.()).not.toThrow();
143
+ });
144
+
145
+ it("render() returns original content after becoming visible", () => {
146
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
147
+ const instance = new Wrapped();
148
+
149
+ const parent = document.createElement("div");
150
+ document.body.appendChild(parent);
151
+ const anchor = document.createComment("end");
152
+ parent.appendChild(anchor);
153
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
154
+
155
+ instance.onMount?.();
156
+ expect(instance.render()).toBeNull(); // not yet visible
157
+
158
+ intersectionCallback(
159
+ [{ isIntersecting: true } as IntersectionObserverEntry],
160
+ null as unknown as IntersectionObserver,
161
+ );
162
+
163
+ // Now visible: render() must delegate to the original render()
164
+ const result = instance.render();
165
+ expect(result).not.toBeNull();
166
+ document.body.removeChild(parent);
167
+ });
168
+
169
+ it("observer stays active and minHeight is preserved when entry is not intersecting", () => {
170
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 200);
171
+ const instance = new Wrapped();
172
+
173
+ const parent = document.createElement("div");
174
+ document.body.appendChild(parent);
175
+ const anchor = document.createComment("end");
176
+ parent.appendChild(anchor);
177
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
178
+
179
+ instance.onMount?.();
180
+
181
+ // Fire callback with isIntersecting=false — should be a no-op
182
+ intersectionCallback(
183
+ [{ isIntersecting: false } as IntersectionObserverEntry],
184
+ null as unknown as IntersectionObserver,
185
+ );
186
+
187
+ expect(instance.render()).toBeNull();
188
+ expect(mockDisconnect).not.toHaveBeenCalled();
189
+ expect(parent.style.minHeight).toBe("200px");
190
+ document.body.removeChild(parent);
191
+ });
192
+
193
+ it("does not re-apply minHeight on remount when already visible", () => {
194
+ const Wrapped = applyLazy(BaseComp as AnyConstructor, 300);
195
+ const instance = new Wrapped();
196
+
197
+ const parent = document.createElement("div");
198
+ document.body.appendChild(parent);
199
+ const anchor = document.createComment("end");
200
+ parent.appendChild(anchor);
201
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
202
+
203
+ // First mount — become visible
204
+ instance.onMount?.();
205
+ intersectionCallback(
206
+ [{ isIntersecting: true } as IntersectionObserverEntry],
207
+ null as unknown as IntersectionObserver,
208
+ );
209
+ instance.onUnmount?.();
210
+
211
+ // Second mount — _lazyVisible is already true
212
+ instance.onMount?.();
213
+
214
+ // minHeight must NOT be re-applied because _lazyVisible() is true
215
+ expect(parent.style.minHeight).toBe("");
216
+ document.body.removeChild(parent);
217
+ });
218
+ });
@@ -1,74 +1,141 @@
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) {
6
- return { name, kind: "method" as const } as ClassMethodDecoratorContext;
7
+ const initializers: Array<(this: object) => void> = [];
8
+ return {
9
+ name,
10
+ kind: "method" as const,
11
+ addInitializer(fn: (this: object) => void) {
12
+ initializers.push(fn);
13
+ },
14
+ runInitializers(instance: object) {
15
+ initializers.forEach((fn) => { fn.call(instance); });
16
+ },
17
+ } as unknown as ClassMethodDecoratorContext & { runInitializers(instance: object): void };
7
18
  }
8
19
 
9
20
  describe("Memo", () => {
10
21
  it("returns the computed value", () => {
11
22
  const fn = vi.fn((x: unknown) => (x as number) * 2);
12
- const wrapped = Memo()(fn, mockCtx("double"));
23
+ const ctx = mockCtx("double");
24
+ Memo()(fn, ctx);
25
+
13
26
  const obj = {};
14
- expect(wrapped.call(obj, 5)).toBe(10);
27
+ ctx.runInitializers(obj);
28
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).double;
29
+
30
+ expect(method(5)).toBe(10);
15
31
  });
16
32
 
17
33
  it("caches result for same args — calls original only once", () => {
18
34
  const fn = vi.fn((x: unknown) => (x as number) + 1);
19
- const wrapped = Memo()(fn, mockCtx("inc"));
35
+ const ctx = mockCtx("inc");
36
+ Memo()(fn, ctx);
37
+
20
38
  const obj = {};
21
- wrapped.call(obj, 3);
22
- wrapped.call(obj, 3);
23
- wrapped.call(obj, 3);
39
+ ctx.runInitializers(obj);
40
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).inc;
41
+
42
+ method(3);
43
+ method(3);
44
+ method(3);
24
45
  expect(fn).toHaveBeenCalledTimes(1);
25
46
  });
26
47
 
27
48
  it("computes separately for different args", () => {
28
49
  const fn = vi.fn((x: unknown) => (x as number) * 10);
29
- const wrapped = Memo()(fn, mockCtx("mul"));
50
+ const ctx = mockCtx("mul");
51
+ Memo()(fn, ctx);
52
+
30
53
  const obj = {};
31
- expect(wrapped.call(obj, 2)).toBe(20);
32
- expect(wrapped.call(obj, 3)).toBe(30);
54
+ ctx.runInitializers(obj);
55
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).mul;
56
+
57
+ expect(method(2)).toBe(20);
58
+ expect(method(3)).toBe(30);
33
59
  expect(fn).toHaveBeenCalledTimes(2);
34
60
  });
35
61
 
36
62
  it("is per-instance — different instances have separate caches", () => {
37
63
  const fn = vi.fn(() => Math.random());
38
- const wrapped = Memo()(fn, mockCtx("rand"));
64
+ const ctx = mockCtx("rand");
65
+ Memo()(fn, ctx);
66
+
39
67
  const a = {};
40
68
  const b = {};
41
- const v1 = wrapped.call(a);
42
- const v2 = wrapped.call(b);
69
+ ctx.runInitializers(a);
70
+ ctx.runInitializers(b);
71
+
72
+ const methodA = (a as Record<string, () => unknown>).rand;
73
+ const methodB = (b as Record<string, () => unknown>).rand;
74
+ const v1 = methodA();
75
+ const v2 = methodB();
43
76
  expect(fn).toHaveBeenCalledTimes(2);
44
77
  expect(v1).not.toBe(v2); // different random values per instance
45
78
  });
46
79
 
47
80
  it("serializes no-args calls with __no_args__ key", () => {
48
81
  const fn = vi.fn(() => 42);
49
- const wrapped = Memo()(fn, mockCtx("noop"));
82
+ const ctx = mockCtx("noop");
83
+ Memo()(fn, ctx);
84
+
50
85
  const obj = {};
51
- wrapped.call(obj);
52
- wrapped.call(obj);
86
+ ctx.runInitializers(obj);
87
+ const method = (obj as Record<string, () => unknown>).noop;
88
+
89
+ method();
90
+ method();
53
91
  expect(fn).toHaveBeenCalledTimes(1);
54
92
  });
55
93
 
56
94
  it("serializes object args via JSON.stringify", () => {
57
95
  const fn = vi.fn((o: unknown) => JSON.stringify(o));
58
- const wrapped = Memo()(fn, mockCtx("obj"));
96
+ const ctx = mockCtx("obj");
97
+ Memo()(fn, ctx);
98
+
59
99
  const obj = {};
60
- wrapped.call(obj, { a: 1 });
61
- wrapped.call(obj, { a: 1 }); // same key
100
+ ctx.runInitializers(obj);
101
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).obj;
102
+
103
+ method({ a: 1 });
104
+ method({ a: 1 }); // same key
62
105
  expect(fn).toHaveBeenCalledTimes(1);
63
106
  });
64
107
 
108
+ it("re-evaluates when a reactive signal read inside the method changes", () => {
109
+ const s = signal(1);
110
+ const fn = vi.fn(() => s() * 10);
111
+ const ctx = mockCtx("reactive");
112
+ Memo()(fn, ctx);
113
+
114
+ const obj = {};
115
+ ctx.runInitializers(obj);
116
+ const method = (obj as Record<string, () => unknown>).reactive;
117
+
118
+ expect(method()).toBe(10);
119
+ expect(fn).toHaveBeenCalledTimes(1);
120
+
121
+ s.set(5);
122
+ // The computed tracks s() — next read triggers recomputation
123
+ expect(method()).toBe(50);
124
+ expect(fn).toHaveBeenCalledTimes(2);
125
+ });
126
+
65
127
  it("serializes symbol args via toString()", () => {
66
128
  const fn = vi.fn((s: unknown) => String(s));
67
- const wrapped = Memo()(fn, mockCtx("sym"));
129
+ const ctx = mockCtx("sym");
130
+ Memo()(fn, ctx);
131
+
68
132
  const obj = {};
133
+ ctx.runInitializers(obj);
134
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).sym;
135
+
69
136
  const sym = Symbol("test");
70
- wrapped.call(obj, sym);
71
- wrapped.call(obj, sym);
137
+ method(sym);
138
+ method(sym);
72
139
  expect(fn).toHaveBeenCalledTimes(1);
73
140
  });
74
141
  });
@@ -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
  });