@praxisjs/decorators 0.5.0 → 0.6.1

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 (71) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/__tests__/functions.test.js +63 -0
  3. package/dist/__tests__/functions.test.js.map +1 -1
  4. package/dist/__tests__/memo.test.js +53 -0
  5. package/dist/__tests__/memo.test.js.map +1 -1
  6. package/dist/__tests__/properties.test.js +193 -0
  7. package/dist/__tests__/properties.test.js.map +1 -1
  8. package/dist/__tests__/resource.test.js +24 -0
  9. package/dist/__tests__/resource.test.js.map +1 -1
  10. package/dist/__tests__/until.test.d.ts +2 -0
  11. package/dist/__tests__/until.test.d.ts.map +1 -0
  12. package/dist/__tests__/until.test.js +88 -0
  13. package/dist/__tests__/until.test.js.map +1 -0
  14. package/dist/__tests__/virtual.test.js +108 -0
  15. package/dist/__tests__/virtual.test.js.map +1 -1
  16. package/dist/component/virtual.d.ts.map +1 -1
  17. package/dist/component/virtual.js +3 -0
  18. package/dist/component/virtual.js.map +1 -1
  19. package/dist/create-method-decorator.d.ts +2 -2
  20. package/dist/create-method-decorator.d.ts.map +1 -1
  21. package/dist/create-method-decorator.js +3 -1
  22. package/dist/create-method-decorator.js.map +1 -1
  23. package/dist/events/emit.d.ts +1 -1
  24. package/dist/events/emit.d.ts.map +1 -1
  25. package/dist/functions/bind.d.ts +1 -1
  26. package/dist/functions/bind.d.ts.map +1 -1
  27. package/dist/functions/debounce.d.ts +2 -1
  28. package/dist/functions/debounce.d.ts.map +1 -1
  29. package/dist/functions/debounce.js +10 -1
  30. package/dist/functions/debounce.js.map +1 -1
  31. package/dist/functions/index.d.ts +1 -0
  32. package/dist/functions/index.d.ts.map +1 -1
  33. package/dist/functions/index.js +1 -0
  34. package/dist/functions/index.js.map +1 -1
  35. package/dist/functions/log.d.ts +1 -1
  36. package/dist/functions/log.d.ts.map +1 -1
  37. package/dist/functions/memo.d.ts +1 -1
  38. package/dist/functions/memo.d.ts.map +1 -1
  39. package/dist/functions/memo.js +16 -2
  40. package/dist/functions/memo.js.map +1 -1
  41. package/dist/functions/once.d.ts +1 -1
  42. package/dist/functions/once.d.ts.map +1 -1
  43. package/dist/functions/retry.d.ts +1 -1
  44. package/dist/functions/retry.d.ts.map +1 -1
  45. package/dist/functions/throttle.d.ts +1 -1
  46. package/dist/functions/throttle.d.ts.map +1 -1
  47. package/dist/functions/throttle.js +2 -1
  48. package/dist/functions/throttle.js.map +1 -1
  49. package/dist/functions/until.d.ts +2 -0
  50. package/dist/functions/until.d.ts.map +1 -0
  51. package/dist/functions/until.js +17 -0
  52. package/dist/functions/until.js.map +1 -0
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -1
  56. package/dist/index.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/functions.test.ts +80 -0
  59. package/src/__tests__/memo.test.ts +67 -0
  60. package/src/__tests__/properties.test.ts +233 -0
  61. package/src/__tests__/resource.test.ts +28 -0
  62. package/src/__tests__/until.test.ts +113 -0
  63. package/src/__tests__/virtual.test.ts +109 -0
  64. package/src/component/virtual.tsx +5 -0
  65. package/src/create-method-decorator.ts +6 -3
  66. package/src/functions/debounce.ts +14 -1
  67. package/src/functions/index.ts +1 -0
  68. package/src/functions/memo.ts +17 -1
  69. package/src/functions/throttle.ts +2 -1
  70. package/src/functions/until.ts +19 -0
  71. package/src/index.ts +1 -0
@@ -110,4 +110,32 @@ describe("@Resource", () => {
110
110
  const r = instance.r as { data: () => string };
111
111
  expect(r.data()).toBe("default");
112
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
+ });
113
141
  });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { signal, computed } from "@praxisjs/core/internal";
4
+
5
+ import { Until } from "../functions/until";
6
+
7
+ // Simulates TC39 decorator context for a method decorator
8
+ function mockMethodContext(name: string) {
9
+ const initializers: Array<(this: object) => void> = [];
10
+ const ctx = {
11
+ name,
12
+ kind: "method" as const,
13
+ addInitializer(fn: (this: object) => void) {
14
+ initializers.push(fn);
15
+ },
16
+ runInitializers(instance: object) {
17
+ initializers.forEach((fn) => { fn.call(instance); });
18
+ },
19
+ };
20
+ return ctx;
21
+ }
22
+
23
+ // ── @Until ────────────────────────────────────────────────────────────────────
24
+
25
+ describe("Until", () => {
26
+ it("resolves immediately when the signal is already truthy", async () => {
27
+ const s = signal(42);
28
+ const obj: Record<string, unknown> = { count: s };
29
+
30
+ const ctx = mockMethodContext("wait");
31
+ Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
32
+ ctx.runInitializers(obj);
33
+
34
+ const result = await (obj.wait as () => Promise<number>)();
35
+ expect(result).toBe(42);
36
+ });
37
+
38
+ it("resolves when the signal becomes truthy", async () => {
39
+ const s = signal<number>(0);
40
+ const obj: Record<string, unknown> = { count: s };
41
+
42
+ const ctx = mockMethodContext("wait");
43
+ Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
44
+ ctx.runInitializers(obj);
45
+
46
+ const promise = (obj.wait as () => Promise<number>)();
47
+ s.set(5);
48
+ expect(await promise).toBe(5);
49
+ });
50
+
51
+ it("resolves with a computed signal", async () => {
52
+ const s = signal(0);
53
+ const doubled = computed(() => s() * 2);
54
+ const obj: Record<string, unknown> = { doubled };
55
+
56
+ const ctx = mockMethodContext("waitDoubled");
57
+ Until("doubled")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
58
+ ctx.runInitializers(obj);
59
+
60
+ const promise = (obj.waitDoubled as () => Promise<number>)();
61
+ s.set(3); // doubled becomes 6 (truthy)
62
+ expect(await promise).toBe(6);
63
+ });
64
+
65
+ it("each call returns a new promise", async () => {
66
+ const s = signal<string>("");
67
+ const obj: Record<string, unknown> = { name: s };
68
+
69
+ const ctx = mockMethodContext("waitName");
70
+ Until("name")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
71
+ ctx.runInitializers(obj);
72
+
73
+ const wait = obj.waitName as () => Promise<string>;
74
+
75
+ const p1 = wait();
76
+ const p2 = wait();
77
+ expect(p1).not.toBe(p2);
78
+
79
+ s.set("alice");
80
+ expect(await p1).toBe("alice");
81
+ expect(await p2).toBe("alice");
82
+ });
83
+
84
+ it("does not resolve when signal stays falsy", async () => {
85
+ const s = signal<number>(0);
86
+ const obj: Record<string, unknown> = { count: s };
87
+
88
+ const ctx = mockMethodContext("wait");
89
+ Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
90
+ ctx.runInitializers(obj);
91
+
92
+ let resolved = false;
93
+ (obj.wait as () => Promise<number>)().then(() => { resolved = true; });
94
+
95
+ await new Promise((res) => { setTimeout(res, 10); });
96
+ expect(resolved).toBe(false);
97
+ });
98
+
99
+ it("ignores the original method body", async () => {
100
+ const s = signal("hello");
101
+ const obj: Record<string, unknown> = { value: s };
102
+
103
+ let bodyCalled = false;
104
+ const original = () => { bodyCalled = true; };
105
+
106
+ const ctx = mockMethodContext("wait");
107
+ Until("value")(original, ctx as unknown as ClassMethodDecoratorContext);
108
+ ctx.runInitializers(obj);
109
+
110
+ await (obj.wait as () => Promise<string>)();
111
+ expect(bodyCalled).toBe(false);
112
+ });
113
+ });
@@ -257,4 +257,113 @@ describe("Virtual decorator", () => {
257
257
  expect(spacerTop.getAttribute("style")).toContain("50px");
258
258
  document.body.removeChild(container);
259
259
  });
260
+
261
+ it("itemHeight = 0 throws a descriptive error on onMount", () => {
262
+ class ZeroHeightList extends StatefulComponent {
263
+ items = ["a", "b"];
264
+ renderItem(item: unknown) {
265
+ const el = document.createElement("div");
266
+ el.textContent = String(item);
267
+ return el;
268
+ }
269
+ render() { return null; }
270
+ }
271
+
272
+ const Wrapped = applyVirtual(ZeroHeightList as AnyConstructor, 0);
273
+ const instance = new Wrapped();
274
+
275
+ const container = document.createElement("div");
276
+ document.body.appendChild(container);
277
+ const anchor = document.createComment("end");
278
+ container.appendChild(anchor);
279
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
280
+
281
+ expect(() => instance.onMount?.()).toThrow(/itemHeight must be a positive number/);
282
+ document.body.removeChild(container);
283
+ });
284
+
285
+ it("itemHeight = 0 in render() does not divide by zero (no container)", () => {
286
+ class ZeroHeightList2 extends StatefulComponent {
287
+ items = ["a", "b"];
288
+ renderItem(item: unknown) {
289
+ return document.createElement("div");
290
+ }
291
+ render() { return null; }
292
+ }
293
+ // render() is called before onMount, so no throw yet — but it still computes total height
294
+ const Wrapped = applyVirtual(ZeroHeightList2 as AnyConstructor, 0);
295
+ const instance = new Wrapped();
296
+ // Render without mounting — no onMount guard triggered, but division happens in computed
297
+ // The guard is in onMount, so render still runs. Verify it doesn't produce NaN/Infinity
298
+ expect(() => instance.render()).not.toThrow();
299
+ });
300
+
301
+ it("renderItem() that throws — does not crash the entire component", () => {
302
+ class ThrowingList extends StatefulComponent {
303
+ items = ["x"];
304
+ renderItem(): Node {
305
+ throw new Error("render item failed");
306
+ }
307
+ render() { return null; }
308
+ }
309
+
310
+ const Wrapped = applyVirtual(ThrowingList as AnyConstructor, 50, 0);
311
+ const instance = new Wrapped();
312
+ // Render triggers the effect which calls renderItem — it should propagate the error
313
+ expect(() => instance.render()).toThrow("render item failed");
314
+ });
315
+
316
+ it("scrollTop > totalHeight renders last visible items without crash", () => {
317
+ class ShortList extends StatefulComponent {
318
+ items = [1, 2, 3]; // only 3 items × 50px = 150px total
319
+ renderItem(item: unknown) {
320
+ const el = document.createElement("div");
321
+ el.textContent = String(item);
322
+ return el;
323
+ }
324
+ render() { return null; }
325
+ }
326
+
327
+ const Wrapped = applyVirtual(ShortList as AnyConstructor, 50, 0);
328
+ const instance = new Wrapped();
329
+
330
+ const container = document.createElement("div");
331
+ document.body.appendChild(container);
332
+ const anchor = document.createComment("end");
333
+ container.appendChild(anchor);
334
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
335
+
336
+ instance.onMount?.();
337
+ const outer = instance.render() as HTMLElement;
338
+
339
+ // Simulate scrollTop way past end of list (e.g., 9999px for a 150px list)
340
+ Object.defineProperty(container, "scrollTop", { configurable: true, value: 9999 });
341
+ expect(() => container.dispatchEvent(new Event("scroll"))).not.toThrow();
342
+
343
+ // The outer container should still be present
344
+ expect(outer).toBeInstanceOf(HTMLElement);
345
+ document.body.removeChild(container);
346
+ });
347
+
348
+ it("buffer applied correctly — renders extra items beyond visible area", () => {
349
+ class BufferList extends StatefulComponent {
350
+ items = Array.from({ length: 20 }, (_, i) => i);
351
+ renderItem(item: unknown) {
352
+ const el = document.createElement("div");
353
+ el.textContent = String(item);
354
+ return el;
355
+ }
356
+ render() { return null; }
357
+ }
358
+
359
+ // itemHeight=100, buffer=2, viewHeight defaults to 600 → visible=6 items + 2 buffer each side
360
+ const Wrapped = applyVirtual(BufferList as AnyConstructor, 100, 2);
361
+ const instance = new Wrapped();
362
+ const outer = instance.render() as HTMLElement;
363
+
364
+ // With scrollTop=0, buffer=2: startIdx=max(0, 0-2)=0, endIdx=min(19, ceil(600/100)+2)=8
365
+ // So 9 items should be rendered (indices 0..8)
366
+ const itemsSlot = outer.children[1] as HTMLElement;
367
+ expect(itemsSlot.children.length).toBe(9);
368
+ });
260
369
  });
@@ -27,6 +27,11 @@ class VirtualBehavior extends ClassBehavior {
27
27
 
28
28
  return {
29
29
  onMount() {
30
+ if (itemHeight <= 0) {
31
+ throw new Error(
32
+ `[Virtual] itemHeight must be a positive number, got ${String(itemHeight)}`,
33
+ );
34
+ }
30
35
  container = host._anchor?.parentElement ?? undefined;
31
36
  if (!container) return;
32
37
 
@@ -2,15 +2,18 @@ import type { StatefulComponent } from "@praxisjs/core";
2
2
 
3
3
  export interface MethodBehavior {
4
4
  wrap(
5
- original: (...args: unknown[]) => unknown,
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ original: (...args: any[]) => any,
6
7
  instance: object,
7
8
  name: string,
8
- ): (...args: unknown[]) => unknown;
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ ): (...args: any[]) => any;
9
11
  }
10
12
 
11
13
  export function createMethodDecorator(behavior: MethodBehavior) {
12
14
  return function (
13
- value: (this: object, ...args: unknown[]) => unknown,
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ value: (this: object, ...args: any[]) => any,
14
17
  context: ClassMethodDecoratorContext<StatefulComponent>,
15
18
  ): void {
16
19
  context.addInitializer(function (this: unknown) {
@@ -1,9 +1,22 @@
1
+ import type { StatefulComponent } from "@praxisjs/core";
2
+
1
3
  import { createMethodDecorator } from "../create-method-decorator";
2
4
 
3
5
  export function Debounce(ms: number) {
4
6
  return createMethodDecorator({
5
- wrap(original, instance) {
7
+ wrap(original, instance, _name) {
6
8
  let timer: ReturnType<typeof setTimeout> | undefined;
9
+
10
+ const comp = instance as StatefulComponent;
11
+ const prevUnmount = comp.onUnmount?.bind(comp);
12
+ comp.onUnmount = function (this: StatefulComponent) {
13
+ prevUnmount?.call(this);
14
+ if (timer !== undefined) {
15
+ clearTimeout(timer);
16
+ timer = undefined;
17
+ }
18
+ };
19
+
7
20
  return (...args: unknown[]) => {
8
21
  if (timer !== undefined) clearTimeout(timer);
9
22
  timer = setTimeout(() => {
@@ -6,4 +6,5 @@ export { Retry } from "./retry";
6
6
  export { Debounce } from "./debounce";
7
7
  export { Throttle } from "./throttle";
8
8
  export { When } from "./when";
9
+ export { Until } from "./until";
9
10
  export { Watch, type WatchVal, type WatchVals } from "./watch";
@@ -3,11 +3,27 @@ import type { Computed } from "@praxisjs/shared";
3
3
 
4
4
  import { createMethodDecorator } from "../create-method-decorator";
5
5
 
6
+ const objectIdMap = new WeakMap<object, number>();
7
+ let objectIdCounter = 0;
8
+
9
+ function objectIdentityKey(obj: object): string {
10
+ if (!objectIdMap.has(obj)) {
11
+ objectIdMap.set(obj, ++objectIdCounter);
12
+ }
13
+ return `__obj_${String(objectIdMap.get(obj))}__`;
14
+ }
15
+
6
16
  function serializeArgs(args: unknown[]) {
7
17
  if (args.length === 0) return "__no_args__";
8
18
  return args
9
19
  .map((a) => {
10
- if (a === null || typeof a === "object") return JSON.stringify(a);
20
+ if (a === null || typeof a === "object") {
21
+ try {
22
+ return JSON.stringify(a);
23
+ } catch {
24
+ return a === null ? "null" : objectIdentityKey(a);
25
+ }
26
+ }
11
27
  if (typeof a === "symbol") return a.toString();
12
28
  return String(a as string | number | boolean | bigint | undefined);
13
29
  })
@@ -3,10 +3,11 @@ import { createMethodDecorator } from "../create-method-decorator";
3
3
  export function Throttle(ms: number) {
4
4
  return createMethodDecorator({
5
5
  wrap(original, instance) {
6
+ const clampedMs = Math.max(0, ms);
6
7
  let last = 0;
7
8
  return (...args: unknown[]) => {
8
9
  const now = Date.now();
9
- if (now - last < ms) return;
10
+ if (now - last < clampedMs) return;
10
11
  last = now;
11
12
  return original.apply(instance, args);
12
13
  };
@@ -0,0 +1,19 @@
1
+ import { until } from "@praxisjs/core/internal";
2
+ import type { Computed, Signal } from "@praxisjs/shared";
3
+ import { isComputed } from "@praxisjs/shared/internal";
4
+
5
+ import { createMethodDecorator } from "../create-method-decorator";
6
+
7
+ export function Until(propName: string) {
8
+ return createMethodDecorator({
9
+ wrap(_original, instance) {
10
+ return () => {
11
+ const raw = (instance as Record<string, unknown>)[propName];
12
+ const source = isComputed(raw)
13
+ ? (raw as Computed<unknown>)
14
+ : (raw as Signal<unknown>);
15
+ return until(source);
16
+ };
17
+ },
18
+ });
19
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  Debounce,
9
9
  Throttle,
10
10
  When,
11
+ Until,
11
12
  Watch,
12
13
  type WatchVal,
13
14
  type WatchVals,