@praxisjs/decorators 0.4.3 → 0.6.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 (192) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/__tests__/component.test.js +10 -1
  3. package/dist/__tests__/component.test.js.map +1 -1
  4. package/dist/__tests__/compose.test.d.ts +2 -0
  5. package/dist/__tests__/compose.test.d.ts.map +1 -0
  6. package/dist/__tests__/compose.test.js +171 -0
  7. package/dist/__tests__/compose.test.js.map +1 -0
  8. package/dist/__tests__/events.test.js +26 -0
  9. package/dist/__tests__/events.test.js.map +1 -1
  10. package/dist/__tests__/functions.test.js +205 -47
  11. package/dist/__tests__/functions.test.js.map +1 -1
  12. package/dist/__tests__/lazy.test.js +5 -7
  13. package/dist/__tests__/lazy.test.js.map +1 -1
  14. package/dist/__tests__/memo.test.js +114 -25
  15. package/dist/__tests__/memo.test.js.map +1 -1
  16. package/dist/__tests__/properties.test.js +193 -0
  17. package/dist/__tests__/properties.test.js.map +1 -1
  18. package/dist/__tests__/resource.test.d.ts +2 -0
  19. package/dist/__tests__/resource.test.d.ts.map +1 -0
  20. package/dist/__tests__/resource.test.js +127 -0
  21. package/dist/__tests__/resource.test.js.map +1 -0
  22. package/dist/__tests__/slot.test.js +32 -0
  23. package/dist/__tests__/slot.test.js.map +1 -1
  24. package/dist/__tests__/until.test.d.ts +2 -0
  25. package/dist/__tests__/until.test.d.ts.map +1 -0
  26. package/dist/__tests__/until.test.js +88 -0
  27. package/dist/__tests__/until.test.js.map +1 -0
  28. package/dist/__tests__/virtual.test.js +164 -15
  29. package/dist/__tests__/virtual.test.js.map +1 -1
  30. package/dist/component/component.d.ts +1 -1
  31. package/dist/component/component.d.ts.map +1 -1
  32. package/dist/component/component.js +15 -10
  33. package/dist/component/component.js.map +1 -1
  34. package/dist/component/lazy.d.ts.map +1 -1
  35. package/dist/component/lazy.js +29 -30
  36. package/dist/component/lazy.js.map +1 -1
  37. package/dist/component/virtual.d.ts.map +1 -1
  38. package/dist/component/virtual.js +49 -50
  39. package/dist/component/virtual.js.map +1 -1
  40. package/dist/create-class-decorator.d.ts +16 -0
  41. package/dist/create-class-decorator.d.ts.map +1 -0
  42. package/dist/create-class-decorator.js +35 -0
  43. package/dist/create-class-decorator.js.map +1 -0
  44. package/dist/create-field-decorator.d.ts +14 -0
  45. package/dist/create-field-decorator.d.ts.map +1 -0
  46. package/dist/create-field-decorator.js +37 -0
  47. package/dist/create-field-decorator.js.map +1 -0
  48. package/dist/create-getter-decorator.d.ts +18 -0
  49. package/dist/create-getter-decorator.d.ts.map +1 -0
  50. package/dist/create-getter-decorator.js +15 -0
  51. package/dist/create-getter-decorator.js.map +1 -0
  52. package/dist/create-lifecycle-method-decorator.d.ts +6 -0
  53. package/dist/create-lifecycle-method-decorator.d.ts.map +1 -0
  54. package/dist/create-lifecycle-method-decorator.js +22 -0
  55. package/dist/create-lifecycle-method-decorator.js.map +1 -0
  56. package/dist/create-method-decorator.d.ts +6 -0
  57. package/dist/create-method-decorator.d.ts.map +1 -0
  58. package/dist/create-method-decorator.js +14 -0
  59. package/dist/create-method-decorator.js.map +1 -0
  60. package/dist/events/emit.d.ts +1 -1
  61. package/dist/events/emit.d.ts.map +1 -1
  62. package/dist/events/emit.js +24 -29
  63. package/dist/events/emit.js.map +1 -1
  64. package/dist/events/on-command.d.ts +1 -2
  65. package/dist/events/on-command.d.ts.map +1 -1
  66. package/dist/events/on-command.js +15 -32
  67. package/dist/events/on-command.js.map +1 -1
  68. package/dist/functions/bind.d.ts +1 -2
  69. package/dist/functions/bind.d.ts.map +1 -1
  70. package/dist/functions/bind.js +4 -11
  71. package/dist/functions/bind.js.map +1 -1
  72. package/dist/functions/debounce.d.ts.map +1 -1
  73. package/dist/functions/debounce.js +21 -20
  74. package/dist/functions/debounce.js.map +1 -1
  75. package/dist/functions/index.d.ts +1 -0
  76. package/dist/functions/index.d.ts.map +1 -1
  77. package/dist/functions/index.js +1 -0
  78. package/dist/functions/index.js.map +1 -1
  79. package/dist/functions/log.d.ts +1 -2
  80. package/dist/functions/log.d.ts.map +1 -1
  81. package/dist/functions/log.js +40 -38
  82. package/dist/functions/log.js.map +1 -1
  83. package/dist/functions/memo.d.ts +1 -2
  84. package/dist/functions/memo.d.ts.map +1 -1
  85. package/dist/functions/memo.js +29 -27
  86. package/dist/functions/memo.js.map +1 -1
  87. package/dist/functions/once.d.ts +1 -2
  88. package/dist/functions/once.d.ts.map +1 -1
  89. package/dist/functions/once.js +14 -12
  90. package/dist/functions/once.js.map +1 -1
  91. package/dist/functions/retry.d.ts +1 -2
  92. package/dist/functions/retry.d.ts.map +1 -1
  93. package/dist/functions/retry.js +23 -20
  94. package/dist/functions/retry.js.map +1 -1
  95. package/dist/functions/throttle.d.ts +1 -2
  96. package/dist/functions/throttle.d.ts.map +1 -1
  97. package/dist/functions/throttle.js +11 -17
  98. package/dist/functions/throttle.js.map +1 -1
  99. package/dist/functions/until.d.ts +2 -0
  100. package/dist/functions/until.d.ts.map +1 -0
  101. package/dist/functions/until.js +17 -0
  102. package/dist/functions/until.js.map +1 -0
  103. package/dist/functions/watch.d.ts +1 -1
  104. package/dist/functions/watch.d.ts.map +1 -1
  105. package/dist/functions/watch.js +25 -39
  106. package/dist/functions/watch.js.map +1 -1
  107. package/dist/functions/when.d.ts +1 -2
  108. package/dist/functions/when.d.ts.map +1 -1
  109. package/dist/functions/when.js +10 -26
  110. package/dist/functions/when.js.map +1 -1
  111. package/dist/index.d.ts +7 -2
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +7 -2
  114. package/dist/index.js.map +1 -1
  115. package/dist/properties/compose.d.ts +3 -0
  116. package/dist/properties/compose.d.ts.map +1 -0
  117. package/dist/properties/compose.js +40 -0
  118. package/dist/properties/compose.js.map +1 -0
  119. package/dist/properties/computed.d.ts +1 -2
  120. package/dist/properties/computed.d.ts.map +1 -1
  121. package/dist/properties/computed.js +10 -9
  122. package/dist/properties/computed.js.map +1 -1
  123. package/dist/properties/history.d.ts.map +1 -1
  124. package/dist/properties/history.js +37 -32
  125. package/dist/properties/history.js.map +1 -1
  126. package/dist/properties/index.d.ts +2 -0
  127. package/dist/properties/index.d.ts.map +1 -1
  128. package/dist/properties/index.js +2 -0
  129. package/dist/properties/index.js.map +1 -1
  130. package/dist/properties/persisted.d.ts +1 -2
  131. package/dist/properties/persisted.d.ts.map +1 -1
  132. package/dist/properties/persisted.js +13 -29
  133. package/dist/properties/persisted.js.map +1 -1
  134. package/dist/properties/prop.d.ts +1 -2
  135. package/dist/properties/prop.d.ts.map +1 -1
  136. package/dist/properties/prop.js +21 -25
  137. package/dist/properties/prop.js.map +1 -1
  138. package/dist/properties/resource.d.ts +14 -0
  139. package/dist/properties/resource.d.ts.map +1 -0
  140. package/dist/properties/resource.js +27 -0
  141. package/dist/properties/resource.js.map +1 -0
  142. package/dist/properties/slot.d.ts +1 -2
  143. package/dist/properties/slot.d.ts.map +1 -1
  144. package/dist/properties/slot.js +15 -20
  145. package/dist/properties/slot.js.map +1 -1
  146. package/dist/properties/state.d.ts +1 -2
  147. package/dist/properties/state.d.ts.map +1 -1
  148. package/dist/properties/state.js +13 -18
  149. package/dist/properties/state.js.map +1 -1
  150. package/package.json +2 -2
  151. package/src/__tests__/component.test.ts +10 -0
  152. package/src/__tests__/compose.test.ts +202 -0
  153. package/src/__tests__/events.test.ts +32 -0
  154. package/src/__tests__/functions.test.ts +257 -46
  155. package/src/__tests__/lazy.test.ts +3 -5
  156. package/src/__tests__/memo.test.ts +143 -25
  157. package/src/__tests__/properties.test.ts +233 -0
  158. package/src/__tests__/resource.test.ts +141 -0
  159. package/src/__tests__/slot.test.ts +38 -0
  160. package/src/__tests__/until.test.ts +113 -0
  161. package/src/__tests__/virtual.test.ts +135 -7
  162. package/src/component/component.ts +27 -12
  163. package/src/component/lazy.ts +33 -37
  164. package/src/component/virtual.tsx +67 -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 +21 -23
  174. package/src/functions/index.ts +1 -0
  175. package/src/functions/log.ts +40 -43
  176. package/src/functions/memo.ts +28 -42
  177. package/src/functions/once.ts +13 -16
  178. package/src/functions/retry.ts +21 -22
  179. package/src/functions/throttle.ts +11 -24
  180. package/src/functions/until.ts +19 -0
  181. package/src/functions/watch.ts +27 -43
  182. package/src/functions/when.ts +11 -36
  183. package/src/index.ts +29 -0
  184. package/src/properties/compose.ts +53 -0
  185. package/src/properties/computed.ts +11 -13
  186. package/src/properties/history.ts +42 -41
  187. package/src/properties/index.ts +2 -0
  188. package/src/properties/persisted.ts +17 -58
  189. package/src/properties/prop.ts +21 -31
  190. package/src/properties/resource.ts +34 -0
  191. package/src/properties/slot.ts +17 -30
  192. package/src/properties/state.ts +14 -24
@@ -4,87 +4,205 @@ import { signal } from "@praxisjs/core/internal";
4
4
  import { Memo } from "../functions/memo";
5
5
 
6
6
  function mockCtx(name: string) {
7
- 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 };
8
18
  }
9
19
 
10
20
  describe("Memo", () => {
11
21
  it("returns the computed value", () => {
12
22
  const fn = vi.fn((x: unknown) => (x as number) * 2);
13
- const wrapped = Memo()(fn, mockCtx("double"));
23
+ const ctx = mockCtx("double");
24
+ Memo()(fn, ctx);
25
+
14
26
  const obj = {};
15
- 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);
16
31
  });
17
32
 
18
33
  it("caches result for same args — calls original only once", () => {
19
34
  const fn = vi.fn((x: unknown) => (x as number) + 1);
20
- const wrapped = Memo()(fn, mockCtx("inc"));
35
+ const ctx = mockCtx("inc");
36
+ Memo()(fn, ctx);
37
+
21
38
  const obj = {};
22
- wrapped.call(obj, 3);
23
- wrapped.call(obj, 3);
24
- 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);
25
45
  expect(fn).toHaveBeenCalledTimes(1);
26
46
  });
27
47
 
28
48
  it("computes separately for different args", () => {
29
49
  const fn = vi.fn((x: unknown) => (x as number) * 10);
30
- const wrapped = Memo()(fn, mockCtx("mul"));
50
+ const ctx = mockCtx("mul");
51
+ Memo()(fn, ctx);
52
+
31
53
  const obj = {};
32
- expect(wrapped.call(obj, 2)).toBe(20);
33
- 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);
34
59
  expect(fn).toHaveBeenCalledTimes(2);
35
60
  });
36
61
 
37
62
  it("is per-instance — different instances have separate caches", () => {
38
63
  const fn = vi.fn(() => Math.random());
39
- const wrapped = Memo()(fn, mockCtx("rand"));
64
+ const ctx = mockCtx("rand");
65
+ Memo()(fn, ctx);
66
+
40
67
  const a = {};
41
68
  const b = {};
42
- const v1 = wrapped.call(a);
43
- 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();
44
76
  expect(fn).toHaveBeenCalledTimes(2);
45
77
  expect(v1).not.toBe(v2); // different random values per instance
46
78
  });
47
79
 
48
80
  it("serializes no-args calls with __no_args__ key", () => {
49
81
  const fn = vi.fn(() => 42);
50
- const wrapped = Memo()(fn, mockCtx("noop"));
82
+ const ctx = mockCtx("noop");
83
+ Memo()(fn, ctx);
84
+
51
85
  const obj = {};
52
- wrapped.call(obj);
53
- wrapped.call(obj);
86
+ ctx.runInitializers(obj);
87
+ const method = (obj as Record<string, () => unknown>).noop;
88
+
89
+ method();
90
+ method();
54
91
  expect(fn).toHaveBeenCalledTimes(1);
55
92
  });
56
93
 
57
94
  it("serializes object args via JSON.stringify", () => {
58
95
  const fn = vi.fn((o: unknown) => JSON.stringify(o));
59
- const wrapped = Memo()(fn, mockCtx("obj"));
96
+ const ctx = mockCtx("obj");
97
+ Memo()(fn, ctx);
98
+
60
99
  const obj = {};
61
- wrapped.call(obj, { a: 1 });
62
- 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
63
105
  expect(fn).toHaveBeenCalledTimes(1);
64
106
  });
65
107
 
66
108
  it("re-evaluates when a reactive signal read inside the method changes", () => {
67
109
  const s = signal(1);
68
110
  const fn = vi.fn(() => s() * 10);
69
- const wrapped = Memo()(fn, mockCtx("reactive"));
111
+ const ctx = mockCtx("reactive");
112
+ Memo()(fn, ctx);
113
+
70
114
  const obj = {};
115
+ ctx.runInitializers(obj);
116
+ const method = (obj as Record<string, () => unknown>).reactive;
71
117
 
72
- expect(wrapped.call(obj)).toBe(10);
118
+ expect(method()).toBe(10);
73
119
  expect(fn).toHaveBeenCalledTimes(1);
74
120
 
75
121
  s.set(5);
76
122
  // The computed tracks s() — next read triggers recomputation
77
- expect(wrapped.call(obj)).toBe(50);
123
+ expect(method()).toBe(50);
78
124
  expect(fn).toHaveBeenCalledTimes(2);
79
125
  });
80
126
 
81
127
  it("serializes symbol args via toString()", () => {
82
128
  const fn = vi.fn((s: unknown) => String(s));
83
- const wrapped = Memo()(fn, mockCtx("sym"));
129
+ const ctx = mockCtx("sym");
130
+ Memo()(fn, ctx);
131
+
84
132
  const obj = {};
133
+ ctx.runInitializers(obj);
134
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).sym;
135
+
85
136
  const sym = Symbol("test");
86
- wrapped.call(obj, sym);
87
- wrapped.call(obj, sym);
137
+ method(sym);
138
+ method(sym);
139
+ expect(fn).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("circular object argument does not crash — falls back to stable identity key", () => {
143
+ const fn = vi.fn((o: unknown) => o);
144
+ const ctx = mockCtx("circular");
145
+ Memo()(fn, ctx);
146
+
147
+ const obj = {};
148
+ ctx.runInitializers(obj);
149
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).circular;
150
+
151
+ // Create a circular reference
152
+ const circ: Record<string, unknown> = {};
153
+ circ.self = circ;
154
+
155
+ expect(() => method(circ)).not.toThrow();
156
+ // Calling again with the same object should hit cache (not call fn again)
157
+ method(circ);
158
+ expect(fn).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ it("two circular objects produce different cache keys", () => {
162
+ const fn = vi.fn((o: unknown) => o);
163
+ const ctx = mockCtx("circular2");
164
+ Memo()(fn, ctx);
165
+
166
+ const obj = {};
167
+ ctx.runInitializers(obj);
168
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).circular2;
169
+
170
+ const a: Record<string, unknown> = {};
171
+ a.self = a;
172
+ const b: Record<string, unknown> = {};
173
+ b.self = b;
174
+
175
+ method(a);
176
+ method(b);
177
+ expect(fn).toHaveBeenCalledTimes(2);
178
+ });
179
+
180
+ it("undefined vs null as arguments produce different cache keys", () => {
181
+ const fn = vi.fn((x: unknown) => x);
182
+ const ctx = mockCtx("nullUndef");
183
+ Memo()(fn, ctx);
184
+
185
+ const obj = {};
186
+ ctx.runInitializers(obj);
187
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).nullUndef;
188
+
189
+ method(undefined);
190
+ method(null);
191
+ expect(fn).toHaveBeenCalledTimes(2);
192
+ });
193
+
194
+ it("BigInt argument does not crash JSON.stringify path", () => {
195
+ const fn = vi.fn((x: unknown) => x);
196
+ const ctx = mockCtx("bigint");
197
+ Memo()(fn, ctx);
198
+
199
+ const obj = {};
200
+ ctx.runInitializers(obj);
201
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).bigint;
202
+
203
+ // BigInt is a non-object, non-symbol primitive — goes through String() path
204
+ expect(() => method(BigInt(42))).not.toThrow();
205
+ method(BigInt(42));
88
206
  expect(fn).toHaveBeenCalledTimes(1);
89
207
  });
90
208
  });
@@ -5,6 +5,7 @@ import { signal, computed } from "@praxisjs/core/internal";
5
5
 
6
6
  import { Watch } from "../functions/watch";
7
7
  import { When } from "../functions/when";
8
+ import { Compose } from "../properties/compose";
8
9
  import { Computed } from "../properties/computed";
9
10
  import { History } from "../properties/history";
10
11
  import { Prop } from "../properties/prop";
@@ -723,3 +724,235 @@ describe("Watch decorator — reading computed values", () => {
723
724
  expect(handler).toHaveBeenCalledTimes(1);
724
725
  });
725
726
  });
727
+
728
+ // ── Watch — additional new tests ──────────────────────────────────────────────
729
+
730
+ describe("Watch decorator — new cases", () => {
731
+ it("NaN → NaN does NOT fire (Object.is semantics)", () => {
732
+ const { ctx, run } = methodCtx("onNaN");
733
+ const handler = vi.fn();
734
+ Watch("nanProp" as never)(handler, ctx as unknown as ClassMethodDecoratorContext);
735
+
736
+ const s = signal<number>(NaN);
737
+ const instance = new TestComponent();
738
+ Object.defineProperty(instance, "nanProp", { get: () => s(), configurable: true });
739
+ run(instance);
740
+ (instance as unknown as { onMount: () => void }).onMount?.();
741
+
742
+ s.set(NaN); // Object.is(NaN, NaN) === true → no change
743
+ expect(handler).not.toHaveBeenCalled();
744
+ });
745
+
746
+ it("watch callback that throws does not crash the effect system", () => {
747
+ const { ctx, run } = methodCtx("onThrow");
748
+ const throwingHandler = vi.fn(() => { throw new Error("handler error"); });
749
+ Watch("tProp" as never)(throwingHandler, ctx as unknown as ClassMethodDecoratorContext);
750
+
751
+ const s = signal(0);
752
+ const instance = new TestComponent();
753
+ Object.defineProperty(instance, "tProp", { get: () => s(), configurable: true });
754
+ run(instance);
755
+ (instance as unknown as { onMount: () => void }).onMount?.();
756
+
757
+ // Even if the handler throws, the signal change shouldn't crash the effect framework
758
+ // The throw propagates from the effect, so we catch it here
759
+ expect(() => { s.set(1); }).toThrow("handler error");
760
+ // The system should still be intact — further calls are not tested for recovery
761
+ expect(throwingHandler).toHaveBeenCalledTimes(1);
762
+ });
763
+
764
+ it("multiple @Watch decorators on different methods both fire independently", () => {
765
+ const { ctx: ctxA, run: runA } = methodCtx("onA");
766
+ const { ctx: ctxB, run: runB } = methodCtx("onB");
767
+ const handlerA = vi.fn();
768
+ const handlerB = vi.fn();
769
+ Watch("propA" as never)(handlerA, ctxA as unknown as ClassMethodDecoratorContext);
770
+ Watch("propB" as never)(handlerB, ctxB as unknown as ClassMethodDecoratorContext);
771
+
772
+ const sa = signal(0);
773
+ const sb = signal(0);
774
+ const instance = new TestComponent();
775
+ Object.defineProperty(instance, "propA", { get: () => sa(), configurable: true });
776
+ Object.defineProperty(instance, "propB", { get: () => sb(), configurable: true });
777
+ runA(instance);
778
+ runB(instance);
779
+ (instance as unknown as { onMount: () => void }).onMount?.();
780
+
781
+ sa.set(1);
782
+ expect(handlerA).toHaveBeenCalledTimes(1);
783
+ expect(handlerB).not.toHaveBeenCalled();
784
+
785
+ sb.set(1);
786
+ expect(handlerA).toHaveBeenCalledTimes(1);
787
+ expect(handlerB).toHaveBeenCalledTimes(1);
788
+ });
789
+ });
790
+
791
+ // ── When — additional new tests ───────────────────────────────────────────────
792
+
793
+ describe("When decorator — new cases", () => {
794
+ function methodCtxFor(name: string) {
795
+ const initializers: Array<(this: unknown) => void> = [];
796
+ return {
797
+ ctx: {
798
+ name,
799
+ kind: "method" as const,
800
+ addInitializer(fn: (this: unknown) => void) {
801
+ initializers.push(fn);
802
+ },
803
+ } as ClassMethodDecoratorContext,
804
+ run(instance: unknown) {
805
+ initializers.forEach((fn) => { fn.call(instance); });
806
+ },
807
+ };
808
+ }
809
+
810
+ it("@When fires only once — first truthy value; subsequent truthy values are ignored", () => {
811
+ const { ctx, run } = methodCtxFor("onToggle");
812
+ const handler = vi.fn();
813
+ When("toggle")(handler, ctx as unknown as ClassMethodDecoratorContext);
814
+
815
+ const s = signal<number | null>(null);
816
+ const instance = new TestComponent();
817
+ (instance as unknown as Record<string, unknown>).toggle = s;
818
+ run(instance);
819
+ (instance as unknown as { onMount: () => void }).onMount?.();
820
+
821
+ s.set(1); // truthy → fires (first and only time)
822
+ s.set(null); // falsy
823
+ s.set(2); // truthy again — but when() has already disposed itself
824
+ expect(handler).toHaveBeenCalledTimes(1);
825
+ expect(handler).toHaveBeenCalledWith(1);
826
+ });
827
+
828
+ it("@When callback that throws — onUnmount cleanup still works", () => {
829
+ const { ctx, run } = methodCtxFor("onThrowWhen");
830
+ const throwingHandler = vi.fn(() => { throw new Error("when error"); });
831
+ When("whenProp")(throwingHandler, ctx as unknown as ClassMethodDecoratorContext);
832
+
833
+ const s = signal<number | null>(null);
834
+ const instance = new TestComponent();
835
+ (instance as unknown as Record<string, unknown>).whenProp = s;
836
+ run(instance);
837
+ (instance as unknown as { onMount: () => void }).onMount?.();
838
+
839
+ // Triggering truthy fires handler which throws
840
+ expect(() => { s.set(1); }).toThrow("when error");
841
+
842
+ // onUnmount should still work without crashing
843
+ expect(() => {
844
+ (instance as unknown as { onUnmount: () => void }).onUnmount?.();
845
+ }).not.toThrow();
846
+ });
847
+ });
848
+
849
+ // ── State — additional new tests ──────────────────────────────────────────────
850
+
851
+ describe("State decorator — new cases", () => {
852
+ it("two @State fields can be set independently without cross-contamination", () => {
853
+ const { ctx: ctxX, run: runX } = fieldCtx("x");
854
+ const { ctx: ctxY, run: runY } = fieldCtx("y");
855
+ State()(undefined, ctxX);
856
+ State()(undefined, ctxY);
857
+
858
+ const instance = new TestComponent();
859
+ (instance as unknown as Record<string, unknown>).x = 10;
860
+ (instance as unknown as Record<string, unknown>).y = 20;
861
+ runX(instance);
862
+ runY(instance);
863
+
864
+ (instance as unknown as Record<string, unknown>).x = 99;
865
+ expect((instance as unknown as Record<string, unknown>).x).toBe(99);
866
+ expect((instance as unknown as Record<string, unknown>).y).toBe(20);
867
+
868
+ (instance as unknown as Record<string, unknown>).y = 77;
869
+ expect((instance as unknown as Record<string, unknown>).x).toBe(99);
870
+ expect((instance as unknown as Record<string, unknown>).y).toBe(77);
871
+ });
872
+
873
+ it("setting @State to undefined works (signal stores undefined)", () => {
874
+ const { ctx, run } = fieldCtx("maybeVal");
875
+ State()(undefined, ctx);
876
+
877
+ const instance = new TestComponent();
878
+ (instance as unknown as Record<string, unknown>).maybeVal = "initial";
879
+ run(instance);
880
+
881
+ (instance as unknown as Record<string, unknown>).maybeVal = undefined;
882
+ expect((instance as unknown as Record<string, unknown>).maybeVal).toBeUndefined();
883
+ });
884
+ });
885
+
886
+ // ── Prop — additional new tests ───────────────────────────────────────────────
887
+
888
+ describe("Prop decorator — new cases", () => {
889
+ it("function prop that throws — getter propagates the error", () => {
890
+ const { ctx, run } = fieldCtx("dynProp");
891
+ Prop()(undefined, ctx);
892
+
893
+ const throwingFn = () => { throw new Error("prop error"); };
894
+ const instance = new TestComponent({ dynProp: throwingFn });
895
+ run(instance);
896
+
897
+ expect(() => (instance as unknown as Record<string, unknown>).dynProp).toThrow("prop error");
898
+ });
899
+
900
+ it("undefined passed explicitly vs key not present — both fall back to default", () => {
901
+ const { ctx: ctx1, run: run1 } = fieldCtx("optA");
902
+ const { ctx: ctx2, run: run2 } = fieldCtx("optB");
903
+ Prop()(undefined, ctx1);
904
+ Prop()(undefined, ctx2);
905
+
906
+ // Case 1: key explicitly set to undefined in rawProps
907
+ const instance1 = new TestComponent({ optA: undefined });
908
+ (instance1 as unknown as Record<string, unknown>).optA = "default-a";
909
+ run1(instance1);
910
+ // undefined from rawProps — Prop checks `!== undefined`, so falls back to default
911
+ expect((instance1 as unknown as Record<string, unknown>).optA).toBe("default-a");
912
+
913
+ // Case 2: key not present in rawProps at all
914
+ const instance2 = new TestComponent({});
915
+ (instance2 as unknown as Record<string, unknown>).optB = "default-b";
916
+ run2(instance2);
917
+ expect((instance2 as unknown as Record<string, unknown>).optB).toBe("default-b");
918
+ });
919
+ });
920
+
921
+ // ── Compose — additional new tests ────────────────────────────────────────────
922
+
923
+ describe("@Compose decorator — new cases", () => {
924
+ it("setup() that throws — field access on the instance throws", () => {
925
+ class ThrowingComposable {
926
+ setup(): Record<string, unknown> {
927
+ throw new Error("setup failed");
928
+ }
929
+ }
930
+
931
+ const { ctx, run } = fieldCtx("throwing");
932
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
933
+ expect(() => {
934
+ const instance = new TestComponent();
935
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
936
+ Compose(ThrowingComposable as any)(undefined, ctx);
937
+ run(instance);
938
+ }).toThrow("setup failed");
939
+ });
940
+
941
+ it("string argument resolution where the instance property is undefined", () => {
942
+ class ArgComposable {
943
+ constructor(private val: unknown) {}
944
+ setup() { return { resolved: this.val }; }
945
+ }
946
+
947
+ const { ctx, run } = fieldCtx("strArgUndef");
948
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
949
+ Compose(ArgComposable as any, "missingProp")(undefined, ctx);
950
+
951
+ const instance = new TestComponent();
952
+ // missingProp is not set on instance — resolves to undefined
953
+ run(instance);
954
+
955
+ const view = (instance as unknown as Record<string, unknown>).strArgUndef as Record<string, unknown>;
956
+ expect(view.resolved).toBeUndefined();
957
+ });
958
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { Resource } from "../properties/resource";
4
+
5
+ function makeFieldCtx(name: string) {
6
+ const initializers: Array<(this: unknown) => void> = [];
7
+ return {
8
+ ctx: {
9
+ name,
10
+ kind: "field" as const,
11
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
12
+ } as ClassFieldDecoratorContext,
13
+ run(instance: unknown) { initializers.forEach((fn) => { fn.call(instance); }); },
14
+ };
15
+ }
16
+
17
+ describe("@Resource", () => {
18
+ it("binds a Resource object to the field", () => {
19
+ const { ctx, run } = makeFieldCtx("users");
20
+ Resource(() => Promise.resolve([]))(undefined, ctx);
21
+ const instance: Record<string, unknown> = {};
22
+ run(instance);
23
+ const r = instance.users as Record<string, unknown>;
24
+ expect(typeof r.data).toBe("function");
25
+ expect(typeof r.pending).toBe("function");
26
+ expect(typeof r.error).toBe("function");
27
+ expect(typeof r.status).toBe("function");
28
+ expect(typeof r.refetch).toBe("function");
29
+ expect(typeof r.cancel).toBe("function");
30
+ expect(typeof r.mutate).toBe("function");
31
+ });
32
+
33
+ it("starts in pending state when immediate=true (default)", () => {
34
+ const { ctx, run } = makeFieldCtx("data");
35
+ Resource(() => new Promise(() => {}))(undefined, ctx);
36
+ const instance: Record<string, unknown> = {};
37
+ run(instance);
38
+ const r = instance.data as { pending: () => boolean; status: () => string };
39
+ expect(r.pending()).toBe(true);
40
+ expect(r.status()).toBe("pending");
41
+ });
42
+
43
+ it("starts idle when immediate=false", () => {
44
+ const { ctx, run } = makeFieldCtx("data");
45
+ Resource(() => Promise.resolve(42), { immediate: false })(undefined, ctx);
46
+ const instance: Record<string, unknown> = {};
47
+ run(instance);
48
+ const r = instance.data as { status: () => string; pending: () => boolean };
49
+ expect(r.status()).toBe("idle");
50
+ expect(r.pending()).toBe(false);
51
+ });
52
+
53
+ it("resolves data asynchronously", async () => {
54
+ const { ctx, run } = makeFieldCtx("items");
55
+ Resource(() => Promise.resolve([1, 2, 3]))(undefined, ctx);
56
+ const instance: Record<string, unknown> = {};
57
+ run(instance);
58
+ const r = instance.items as { data: () => unknown; status: () => string };
59
+ await vi.waitFor(() => r.status() === "success");
60
+ expect(r.data()).toEqual([1, 2, 3]);
61
+ });
62
+
63
+ it("sets error state on rejection", async () => {
64
+ const { ctx, run } = makeFieldCtx("items");
65
+ Resource(() => Promise.reject(new Error("fetch failed")))(undefined, ctx);
66
+ const instance: Record<string, unknown> = {};
67
+ run(instance);
68
+ const r = instance.items as { error: () => Error | null; status: () => string };
69
+ await new Promise((res) => setTimeout(res, 0));
70
+ expect(r.status()).toBe("error");
71
+ expect((r.error() as Error).message).toBe("fetch failed");
72
+ });
73
+
74
+ it("each instance gets its own resource", () => {
75
+ const { ctx, run } = makeFieldCtx("r");
76
+ Resource(() => Promise.resolve(0))(undefined, ctx);
77
+ const a: Record<string, unknown> = {};
78
+ const b: Record<string, unknown> = {};
79
+ run(a);
80
+ run(b);
81
+ expect(a.r).not.toBe(b.r);
82
+ });
83
+
84
+ it("mutate() updates data immediately", async () => {
85
+ const { ctx, run } = makeFieldCtx("r");
86
+ Resource(() => Promise.resolve(1))(undefined, ctx);
87
+ const instance: Record<string, unknown> = {};
88
+ run(instance);
89
+ const r = instance.r as { mutate: (v: number) => void; data: () => number; status: () => string };
90
+ r.mutate(99);
91
+ expect(r.data()).toBe(99);
92
+ expect(r.status()).toBe("success");
93
+ });
94
+
95
+ it("cancel() stops a pending fetch", async () => {
96
+ const { ctx, run } = makeFieldCtx("r");
97
+ Resource(() => new Promise(() => {}))(undefined, ctx);
98
+ const instance: Record<string, unknown> = {};
99
+ run(instance);
100
+ const r = instance.r as { cancel: () => void; status: () => string };
101
+ r.cancel();
102
+ expect(r.status()).toBe("idle");
103
+ });
104
+
105
+ it("uses initialData option", () => {
106
+ const { ctx, run } = makeFieldCtx("r");
107
+ Resource(() => Promise.resolve("new"), { initialData: "default", immediate: false })(undefined, ctx);
108
+ const instance: Record<string, unknown> = {};
109
+ run(instance);
110
+ const r = instance.r as { data: () => string };
111
+ expect(r.data()).toBe("default");
112
+ });
113
+
114
+ it("cancel() called when not pending — no-op, no crash", () => {
115
+ const { ctx, run } = makeFieldCtx("r");
116
+ Resource(() => Promise.resolve(42), { immediate: false })(undefined, ctx);
117
+ const instance: Record<string, unknown> = {};
118
+ run(instance);
119
+ const r = instance.r as { cancel: () => void; status: () => string };
120
+ // Not pending (immediate=false → idle)
121
+ expect(r.status()).toBe("idle");
122
+ expect(() => r.cancel()).not.toThrow();
123
+ expect(r.status()).toBe("idle");
124
+ });
125
+
126
+ it("refetch() called after successful load — re-fetches", async () => {
127
+ let callCount = 0;
128
+ const { ctx, run } = makeFieldCtx("r");
129
+ Resource(() => Promise.resolve(++callCount))(undefined, ctx);
130
+ const instance: Record<string, unknown> = {};
131
+ run(instance);
132
+ const r = instance.r as { refetch: () => void; data: () => number; status: () => string };
133
+
134
+ await vi.waitFor(() => r.status() === "success");
135
+ expect(r.data()).toBe(1);
136
+
137
+ r.refetch();
138
+ await vi.waitFor(() => r.status() === "success" && r.data() === 2);
139
+ expect(callCount).toBe(2);
140
+ });
141
+ });
@@ -61,6 +61,19 @@ describe("initSlots / getSlot", () => {
61
61
  initSlots(instance, [null, el, undefined]);
62
62
  expect(getSlot(instance, "default")).toEqual([el]);
63
63
  });
64
+
65
+ it("appends to existing named slot when same slot name appears twice", () => {
66
+ const instance = {};
67
+ const a = document.createElement("div");
68
+ a.setAttribute("slot", "header");
69
+ const b = document.createElement("div");
70
+ b.setAttribute("slot", "header");
71
+ initSlots(instance, [a, b]);
72
+ const headerSlot = getSlot(instance, "header");
73
+ expect(headerSlot).toHaveLength(2);
74
+ expect(headerSlot[0]).toBe(a);
75
+ expect(headerSlot[1]).toBe(b);
76
+ });
64
77
  });
65
78
 
66
79
  // ── Slot decorator ───────────────────────────────────────────────────────────
@@ -116,6 +129,31 @@ describe("Slot decorator", () => {
116
129
  expect(slot[0]).toBe(el);
117
130
  });
118
131
 
132
+ it("skips warn on direct assignment in production mode", () => {
133
+ class MyComp extends StatefulComponent {
134
+ render() { return null; }
135
+ }
136
+
137
+ const initializers: Array<(this: unknown) => void> = [];
138
+ const ctx = {
139
+ name: "default",
140
+ kind: "field" as const,
141
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
142
+ } as ClassFieldDecoratorContext<StatefulComponent>;
143
+
144
+ Slot()(undefined, ctx);
145
+
146
+ const instance = new MyComp() as unknown as Record<string, unknown>;
147
+ initializers.forEach((fn) => { fn.call(instance); });
148
+
149
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
150
+ vi.stubEnv("NODE_ENV", "production");
151
+ (instance as Record<string, unknown>).default = "bad";
152
+ expect(warn).not.toHaveBeenCalled();
153
+ vi.unstubAllEnvs();
154
+ warn.mockRestore();
155
+ });
156
+
119
157
  it("warns on direct assignment in non-production", () => {
120
158
  class MyComp extends StatefulComponent {
121
159
  render() { return null; }