@praxisjs/decorators 1.6.1 → 1.7.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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/__tests__/deep-state.test.js +71 -0
  3. package/dist/__tests__/deep-state.test.js.map +1 -1
  4. package/dist/__tests__/memo.test.js +82 -1
  5. package/dist/__tests__/memo.test.js.map +1 -1
  6. package/dist/__tests__/properties-browser.test.js +49 -0
  7. package/dist/__tests__/properties-browser.test.js.map +1 -1
  8. package/dist/__tests__/properties.test.js +27 -0
  9. package/dist/__tests__/properties.test.js.map +1 -1
  10. package/dist/functions/index.d.ts +1 -1
  11. package/dist/functions/index.d.ts.map +1 -1
  12. package/dist/functions/index.js.map +1 -1
  13. package/dist/functions/memo.d.ts +8 -1
  14. package/dist/functions/memo.d.ts.map +1 -1
  15. package/dist/functions/memo.js +15 -2
  16. package/dist/functions/memo.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/properties/deep-state.d.ts.map +1 -1
  21. package/dist/properties/deep-state.js +22 -11
  22. package/dist/properties/deep-state.js.map +1 -1
  23. package/dist/properties/history.d.ts.map +1 -1
  24. package/dist/properties/history.js +2 -0
  25. package/dist/properties/history.js.map +1 -1
  26. package/dist/properties/persisted.d.ts.map +1 -1
  27. package/dist/properties/persisted.js +16 -11
  28. package/dist/properties/persisted.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/__tests__/deep-state.test.ts +80 -0
  31. package/src/__tests__/memo.test.ts +86 -1
  32. package/src/__tests__/properties-browser.test.ts +54 -0
  33. package/src/__tests__/properties.test.ts +30 -0
  34. package/src/functions/index.ts +1 -1
  35. package/src/functions/memo.ts +26 -2
  36. package/src/index.ts +1 -0
  37. package/src/properties/deep-state.ts +24 -11
  38. package/src/properties/history.ts +2 -0
  39. package/src/properties/persisted.ts +22 -18
@@ -183,3 +183,83 @@ describe("@DeepState decorator", () => {
183
183
  expect(log).toEqual([0, 42]);
184
184
  });
185
185
  });
186
+
187
+ describe("@DeepState — proxy identity and notifications", () => {
188
+ function setup(initial: unknown) {
189
+ const { ctx, run } = fieldCtx("data");
190
+ DeepState()(undefined, ctx);
191
+ const instance = new TestComponent();
192
+ (instance as unknown as Record<string, unknown>).data = initial;
193
+ run(instance);
194
+ const inst = instance as unknown as { data: any };
195
+ let runs = 0;
196
+ effect(() => {
197
+ void inst.data;
198
+ runs++;
199
+ });
200
+ runs = 0;
201
+ return { inst, runs: () => runs };
202
+ }
203
+
204
+ it("returns the same proxy for the same nested object", () => {
205
+ const { inst } = setup({ nested: { a: 1 } });
206
+ expect(inst.data.nested).toBe(inst.data.nested);
207
+ });
208
+
209
+ it("keeps identity across reads after an unrelated mutation", () => {
210
+ const { inst } = setup({ nested: { a: 1 }, other: 0 });
211
+ const before = inst.data.nested;
212
+ inst.data.other = 1;
213
+ expect(inst.data.nested).toBe(before);
214
+ });
215
+
216
+ it("returns a fresh proxy once the root is replaced", () => {
217
+ const { inst } = setup({ nested: { a: 1 } });
218
+ const before = inst.data;
219
+ inst.data = { nested: { a: 1 } };
220
+ expect(inst.data).not.toBe(before);
221
+ });
222
+
223
+ it("does not notify when a property is set to the value it already has", () => {
224
+ const { inst, runs } = setup({ a: 1 });
225
+ inst.data.a = 1;
226
+ expect(runs()).toBe(0);
227
+ });
228
+
229
+ it("notifies when a property changes or a new key is added", () => {
230
+ const { inst, runs } = setup({ a: 1 });
231
+ inst.data.a = 2;
232
+ expect(runs()).toBe(1);
233
+ inst.data.b = 3;
234
+ expect(runs()).toBe(2);
235
+ });
236
+
237
+ it("notifies once for an array push", () => {
238
+ const { inst, runs } = setup({ list: [1] });
239
+ inst.data.list.push(2);
240
+ expect(runs()).toBe(1);
241
+ expect(inst.data.list.length).toBe(2);
242
+ });
243
+
244
+ it("notifies on delete only when the key existed", () => {
245
+ const { inst, runs } = setup({ a: 1 });
246
+ delete inst.data.missing;
247
+ expect(runs()).toBe(0);
248
+ delete inst.data.a;
249
+ expect(runs()).toBe(1);
250
+ });
251
+
252
+ it("assigning a primitive root and null roots still works", () => {
253
+ const { ctx, run } = fieldCtx("n");
254
+ DeepState()(undefined, ctx);
255
+ const instance = new TestComponent();
256
+ (instance as unknown as Record<string, unknown>).n = null;
257
+ run(instance);
258
+ const inst = instance as unknown as { n: unknown };
259
+ expect(inst.n).toBeNull();
260
+ inst.n = 5;
261
+ expect(inst.n).toBe(5);
262
+ inst.n = { x: 1 };
263
+ expect((inst.n as { x: number }).x).toBe(1);
264
+ });
265
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
 
3
- import { signal } from "@praxisjs/core/internal";
3
+ import { effect, signal } from "@praxisjs/core/internal";
4
4
  import { Memo } from "../functions/memo";
5
5
 
6
6
  function mockCtx(name: string) {
@@ -206,3 +206,88 @@ describe("Memo", () => {
206
206
  expect(fn).toHaveBeenCalledTimes(1);
207
207
  });
208
208
  });
209
+
210
+ describe("Memo({ max })", () => {
211
+ function setup(max: number) {
212
+ const fn = vi.fn((x: unknown) => (x as number) * 2);
213
+ const ctx = mockCtx("dbl");
214
+ Memo({ max })(fn, ctx);
215
+ const obj = {};
216
+ ctx.runInitializers(obj);
217
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).dbl;
218
+ return { fn, method };
219
+ }
220
+
221
+ it("keeps at most max entries, evicting the oldest first", () => {
222
+ const { fn, method } = setup(2);
223
+ method(1);
224
+ method(2);
225
+ method(3); // evicts 1
226
+ expect(fn).toHaveBeenCalledTimes(3);
227
+ method(2);
228
+ method(3);
229
+ expect(fn).toHaveBeenCalledTimes(3);
230
+ method(1); // recomputed
231
+ expect(fn).toHaveBeenCalledTimes(4);
232
+ });
233
+
234
+ it("a cache hit refreshes recency so the used entry survives", () => {
235
+ const { fn, method } = setup(2);
236
+ method(1);
237
+ method(2);
238
+ method(1); // 1 is now the most recent
239
+ method(3); // evicts 2
240
+ fn.mockClear();
241
+ method(1);
242
+ expect(fn).not.toHaveBeenCalled();
243
+ method(2);
244
+ expect(fn).toHaveBeenCalledTimes(1);
245
+ });
246
+
247
+ it("keeps 100 combinations by default and drops the oldest beyond that", () => {
248
+ const fn = vi.fn((x: unknown) => x);
249
+ const ctx = mockCtx("id");
250
+ Memo()(fn, ctx);
251
+ const obj = {};
252
+ ctx.runInitializers(obj);
253
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).id;
254
+ for (let i = 0; i < 101; i++) method(i);
255
+ fn.mockClear();
256
+ method(100);
257
+ expect(fn).not.toHaveBeenCalled();
258
+ method(0);
259
+ expect(fn).toHaveBeenCalledTimes(1);
260
+ });
261
+
262
+ it("max: Infinity keeps every combination", () => {
263
+ const fn = vi.fn((x: unknown) => x);
264
+ const ctx = mockCtx("id");
265
+ Memo({ max: Infinity })(fn, ctx);
266
+ const obj = {};
267
+ ctx.runInitializers(obj);
268
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).id;
269
+ for (let i = 0; i < 200; i++) method(i);
270
+ fn.mockClear();
271
+ for (let i = 0; i < 200; i++) method(i);
272
+ expect(fn).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("an evicted entry that a binding still uses keeps updating", async () => {
276
+ const s = signal(1);
277
+ const fn = vi.fn((x: unknown) => (x as number) * s());
278
+ const ctx = mockCtx("scaled");
279
+ Memo({ max: 1 })(fn, ctx);
280
+ const obj = {};
281
+ ctx.runInitializers(obj);
282
+ const method = (obj as Record<string, (...a: unknown[]) => unknown>).scaled;
283
+
284
+ let seen: unknown;
285
+ effect(() => {
286
+ seen = method(10);
287
+ });
288
+ method(20); // evicts the entry the effect reads
289
+ s.set(2);
290
+ await Promise.resolve();
291
+ expect(seen).toBe(20);
292
+ });
293
+ });
@@ -232,3 +232,57 @@ describe("initSlots / getSlot", () => {
232
232
  expect(getSlot(instance, "nonexistent")).toEqual([]);
233
233
  });
234
234
  });
235
+
236
+ describe("Persisted decorator — unmount cleanup", () => {
237
+ function mountPersisted(name: string, key: string) {
238
+ const { ctx, run } = fieldCtx(name);
239
+ Persisted<string>(key)(undefined, ctx);
240
+ const instance = new TestComponent();
241
+ (instance as unknown as Record<string, unknown>)[name] = "initial";
242
+ run(instance);
243
+ return instance as unknown as Record<string, unknown> & { onUnmount?: () => void };
244
+ }
245
+
246
+ function storageEvent(key: string, newValue: string) {
247
+ window.dispatchEvent(new StorageEvent("storage", { key, newValue, storageArea: localStorage }));
248
+ }
249
+
250
+ it("keeps syncing other tabs while mounted", () => {
251
+ const instance = mountPersisted("a", "sync-key");
252
+ storageEvent("sync-key", '"remote"');
253
+ expect(instance.a).toBe("remote");
254
+ });
255
+
256
+ it("stops listening for storage events after unmount", () => {
257
+ const instance = mountPersisted("a", "gone-key");
258
+ instance.onUnmount?.();
259
+ storageEvent("gone-key", '"remote"');
260
+ expect(instance.a).toBe("initial");
261
+ });
262
+
263
+ it("repeated mount/unmount cycles do not accumulate window listeners", () => {
264
+ const add = vi.spyOn(window, "addEventListener");
265
+ const remove = vi.spyOn(window, "removeEventListener");
266
+ for (let i = 0; i < 5; i++) mountPersisted("a", "cycle-key").onUnmount?.();
267
+ const added = add.mock.calls.filter(([type]) => type === "storage").length;
268
+ const removed = remove.mock.calls.filter(([type]) => type === "storage").length;
269
+ expect(added).toBe(5);
270
+ expect(removed).toBe(5);
271
+ add.mockRestore();
272
+ remove.mockRestore();
273
+ });
274
+
275
+ it("two fields sharing a key on one instance share one signal", () => {
276
+ const first = fieldCtx("a");
277
+ const second = fieldCtx("b");
278
+ Persisted<string>("shared-key")(undefined, first.ctx);
279
+ Persisted<string>("shared-key")(undefined, second.ctx);
280
+ const instance = new TestComponent() as unknown as Record<string, unknown>;
281
+ instance.a = "x";
282
+ instance.b = "y";
283
+ first.run(instance);
284
+ second.run(instance);
285
+ instance.a = "changed";
286
+ expect(instance.b).toBe("changed");
287
+ });
288
+ });
@@ -1524,3 +1524,33 @@ describe("createAccessorDecorator — init", () => {
1524
1524
  expect(result.init!.call(instance, "hello")).toBe("hello");
1525
1525
  });
1526
1526
  });
1527
+
1528
+ describe("History decorator — unmount cleanup", () => {
1529
+ function mountHistory() {
1530
+ const { ctx, run } = fieldCtx("scoreHistory");
1531
+ History("score")(undefined, ctx);
1532
+ const s = signal(0);
1533
+ const instance = new TestComponent();
1534
+ Object.defineProperty(instance, "score", {
1535
+ get: () => s(),
1536
+ set: (v: number) => { s.set(v); },
1537
+ configurable: true,
1538
+ });
1539
+ run(instance);
1540
+ return { s, instance: instance as unknown as { scoreHistory: { values: () => number[] }; onUnmount?: () => void } };
1541
+ }
1542
+
1543
+ it("stops recording once the component unmounts", () => {
1544
+ const { s, instance } = mountHistory();
1545
+ expect(instance.scoreHistory.values()).toEqual([0]);
1546
+ s.set(1);
1547
+ instance.onUnmount?.();
1548
+ s.set(2);
1549
+ expect(instance.scoreHistory.values()).toEqual([0, 1]);
1550
+ });
1551
+
1552
+ it("unmounting before the history was ever read is a no-op", () => {
1553
+ const { instance } = mountHistory();
1554
+ expect(() => { instance.onUnmount?.(); }).not.toThrow();
1555
+ });
1556
+ });
@@ -1,4 +1,4 @@
1
- export { Memo } from "./memo";
1
+ export { Memo, type MemoOptions } from "./memo";
2
2
  export { Bind } from "./bind";
3
3
  export { Log } from "./log";
4
4
  export { Once } from "./once";
@@ -30,16 +30,40 @@ function serializeArgs(args: unknown[]) {
30
30
  .join("|");
31
31
  }
32
32
 
33
- export function Memo() {
33
+ export interface MemoOptions {
34
+ /**
35
+ * Maximum number of cached argument combinations. When exceeded, the least recently used entry
36
+ * is dropped. Defaults to 100; pass `Infinity` to keep every combination.
37
+ */
38
+ max?: number;
39
+ }
40
+
41
+ const DEFAULT_MAX = 100;
42
+
43
+ type Disposable = Computed<unknown> & { dispose(): void };
44
+
45
+ export function Memo(options: MemoOptions = {}) {
46
+ const { max = DEFAULT_MAX } = options;
47
+
34
48
  return createMethodDecorator({
35
49
  wrap(original, instance, _name) {
36
50
  const cache = new Map<string, Computed<unknown>>();
37
51
  return (...args: unknown[]) => {
38
52
  const key = serializeArgs(args);
39
53
  let memoized = cache.get(key);
40
- if (!memoized) {
54
+ if (memoized) {
55
+ // Re-insert so Map order tracks recency.
56
+ cache.delete(key);
57
+ cache.set(key, memoized);
58
+ } else {
41
59
  memoized = computed(() => original.apply(instance, args) as unknown);
42
60
  cache.set(key, memoized);
61
+ if (cache.size > max) {
62
+ const [oldestKey, oldest] = cache.entries().next().value as [string, Computed<unknown>];
63
+ cache.delete(oldestKey);
64
+ // Releases the entry's hold on the signals it read; a no-op if a binding still uses it.
65
+ (oldest as Disposable).dispose();
66
+ }
43
67
  }
44
68
  return memoized();
45
69
  };
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Component, Lazy } from "./component";
2
2
  export {
3
3
  Memo,
4
+ type MemoOptions,
4
5
  Bind,
5
6
  Log,
6
7
  Once,
@@ -4,37 +4,51 @@ import { createFieldDecorator } from "../create-field-decorator";
4
4
 
5
5
  import type { ReactiveHost } from "../reactive-host";
6
6
 
7
- function deepProxy<T extends object>(target: T, notify: () => void): T {
8
- return new Proxy(target, {
7
+ // One proxy per raw object: repeated reads return the same reference (so `a.b === a.b` and
8
+ // identity comparisons in effects hold) and don't allocate a new Proxy on every access.
9
+ function deepProxy<T extends object>(
10
+ target: T,
11
+ notify: () => void,
12
+ cache: WeakMap<object, object>,
13
+ ): T {
14
+ const existing = cache.get(target);
15
+ if (existing) return existing as T;
16
+
17
+ const proxy = new Proxy(target, {
9
18
  get(obj, key, receiver) {
10
19
  const val: unknown = Reflect.get(obj, key, receiver);
11
20
  if (val !== null && typeof val === "object") {
12
- return deepProxy(val, notify);
21
+ return deepProxy(val, notify, cache);
13
22
  }
14
23
  return val;
15
24
  },
16
25
  set(obj, key, value) {
26
+ const had = Object.hasOwn(obj, key);
27
+ const prev: unknown = Reflect.get(obj, key);
17
28
  const result = Reflect.set(obj, key, value);
18
- notify();
29
+ if (!had || !Object.is(prev, value)) notify();
19
30
  return result;
20
31
  },
21
32
  deleteProperty(obj, key) {
33
+ const had = Object.hasOwn(obj, key);
22
34
  const result = Reflect.deleteProperty(obj, key);
23
- notify();
35
+ if (had) notify();
24
36
  return result;
25
37
  },
26
38
  });
39
+ cache.set(target, proxy);
40
+ return proxy;
27
41
  }
28
42
 
29
43
  export function DeepState() {
30
44
  return createFieldDecorator<ReactiveHost>({
31
45
  bind(instance, _name, initialValue) {
32
46
  const version = signal(0);
33
- let current = initialValue;
47
+ const cache = new WeakMap<object, object>();
34
48
  let proxy =
35
- current !== null && typeof current === "object"
36
- ? deepProxy(current, notify)
37
- : current;
49
+ initialValue !== null && typeof initialValue === "object"
50
+ ? deepProxy(initialValue, notify, cache)
51
+ : initialValue;
38
52
 
39
53
  function notify() {
40
54
  markStateDirty(instance);
@@ -48,10 +62,9 @@ export function DeepState() {
48
62
  return proxy;
49
63
  },
50
64
  set(value: unknown) {
51
- current = value;
52
65
  proxy =
53
66
  value !== null && typeof value === "object"
54
- ? deepProxy(value, notify)
67
+ ? deepProxy(value, notify, cache)
55
68
  : value;
56
69
  notify();
57
70
  },
@@ -37,6 +37,8 @@ export function History(fieldName: string, limit = 50) {
37
37
  },
38
38
  enumerable: false,
39
39
  },
40
+ // The tracking effect would otherwise keep recording a source that outlives the component.
41
+ onUnmount() { h?.destroy(); },
40
42
  };
41
43
  },
42
44
  });
@@ -1,25 +1,30 @@
1
- import { persistedSignal, type PersistedSignalOptions } from "@praxisjs/core/internal";
2
- import type { Signal } from "@praxisjs/shared";
1
+ import {
2
+ persistedSignal,
3
+ type PersistedSignal,
4
+ type PersistedSignalOptions,
5
+ } from "@praxisjs/core/internal";
3
6
 
4
7
  import { createFieldDecorator } from "../create-field-decorator";
5
8
 
6
- const signalMap = new WeakMap<object, Map<string, Signal<unknown>>>();
9
+ const signalMap = new WeakMap<object, Map<string, PersistedSignal<unknown>>>();
7
10
 
8
11
  function getOrCreateSignal<T>(
9
12
  instance: object,
10
13
  storageKey: string,
11
14
  initialValue: T,
12
15
  options: PersistedSignalOptions<T>,
13
- ): Signal<T> {
14
- if (!signalMap.has(instance)) signalMap.set(instance, new Map());
15
- const map = signalMap.get(instance) as Map<string, Signal<unknown>>;
16
- if (!map.has(storageKey)) {
17
- map.set(
18
- storageKey,
19
- persistedSignal(storageKey, initialValue, options) as Signal<unknown>,
20
- );
16
+ ): PersistedSignal<T> {
17
+ let map = signalMap.get(instance);
18
+ if (!map) {
19
+ map = new Map();
20
+ signalMap.set(instance, map);
21
21
  }
22
- return map.get(storageKey) as Signal<T>;
22
+ let sig = map.get(storageKey);
23
+ if (!sig) {
24
+ sig = persistedSignal(storageKey, initialValue, options) as PersistedSignal<unknown>;
25
+ map.set(storageKey, sig);
26
+ }
27
+ return sig as PersistedSignal<T>;
23
28
  }
24
29
 
25
30
  export function Persisted<T>(
@@ -28,15 +33,14 @@ export function Persisted<T>(
28
33
  ) {
29
34
  return createFieldDecorator({
30
35
  bind(instance, name, initialValue) {
31
- const storageKey = key ?? name;
32
- getOrCreateSignal(instance, storageKey, initialValue as T, options);
36
+ const sig = getOrCreateSignal(instance, key ?? name, initialValue as T, options);
33
37
  return {
34
38
  descriptor: {
35
- get: () =>
36
- getOrCreateSignal(instance, storageKey, undefined as T, options)(),
37
- set: (value: T) =>
38
- { getOrCreateSignal(instance, storageKey, undefined as T, options).set(value); },
39
+ get: () => sig(),
40
+ set: (value: T) => { sig.set(value); },
39
41
  },
42
+ // Without this every mounted instance would leave a window "storage" listener behind.
43
+ onUnmount() { sig.close(); },
40
44
  };
41
45
  },
42
46
  });