@praxisjs/core 1.6.0 → 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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/__tests__/computed.test.js +23 -0
  3. package/dist/__tests__/computed.test.js.map +1 -1
  4. package/dist/__tests__/resource-focus.test.d.ts +2 -0
  5. package/dist/__tests__/resource-focus.test.d.ts.map +1 -0
  6. package/dist/__tests__/resource-focus.test.js +50 -0
  7. package/dist/__tests__/resource-focus.test.js.map +1 -0
  8. package/dist/__tests__/resource.test.js +137 -1
  9. package/dist/__tests__/resource.test.js.map +1 -1
  10. package/dist/__tests__/signal.test.js +50 -1
  11. package/dist/__tests__/signal.test.js.map +1 -1
  12. package/dist/async/resource-cache.d.ts +21 -0
  13. package/dist/async/resource-cache.d.ts.map +1 -0
  14. package/dist/async/resource-cache.js +48 -0
  15. package/dist/async/resource-cache.js.map +1 -0
  16. package/dist/async/resource.d.ts +4 -0
  17. package/dist/async/resource.d.ts.map +1 -1
  18. package/dist/async/resource.js +74 -9
  19. package/dist/async/resource.js.map +1 -1
  20. package/dist/internal.d.ts +1 -0
  21. package/dist/internal.d.ts.map +1 -1
  22. package/dist/internal.js +1 -0
  23. package/dist/internal.js.map +1 -1
  24. package/dist/reactive/reactive.d.ts.map +1 -1
  25. package/dist/reactive/reactive.js +0 -2
  26. package/dist/reactive/reactive.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/computed.test.ts +29 -0
  29. package/src/__tests__/resource-focus.test.ts +61 -0
  30. package/src/__tests__/resource.test.ts +178 -1
  31. package/src/__tests__/signal.test.ts +55 -1
  32. package/src/async/resource-cache.ts +62 -0
  33. package/src/async/resource.ts +92 -10
  34. package/src/internal.ts +1 -0
  35. package/src/reactive/reactive.ts +0 -1
@@ -1,14 +1,19 @@
1
1
  import { computed, signal } from "../signal";
2
- import { effect } from "../signal/effect";
2
+ import { getCacheEntry, setCacheEntry, deleteInflight, getInflight, setInflight, registerResource, unregisterResource, } from "./resource-cache";
3
+ import { effect, untrack } from "../signal/effect";
3
4
  export function resource(fetcher, options = {}) {
4
- const { initialData = null, immediate = true, keepPreviousData = false, } = options;
5
- const _data = signal(initialData);
5
+ const { initialData = null, immediate = true, keepPreviousData = false, key, staleTime = 0, refetchOnFocus = false, } = options;
6
+ const cached = key ? getCacheEntry(key) : undefined;
7
+ const isCacheFresh = cached !== undefined && Date.now() - cached.timestamp < staleTime;
8
+ const _data = signal(cached ? cached.data : initialData);
6
9
  const _error = signal(null);
7
- const _status = signal("idle");
10
+ const _status = signal(cached ? "success" : "idle");
8
11
  let _runId = 0;
9
12
  function _execute(fn) {
10
13
  const currentRunId = ++_runId;
11
- if (!keepPreviousData) {
14
+ // untrack: _data.set(result) in the resolve handler must not re-trigger the enclosing effect.
15
+ const hasCachedData = key !== undefined && untrack(() => _data()) !== null;
16
+ if (!keepPreviousData && !hasCachedData) {
12
17
  _data.set(null);
13
18
  }
14
19
  _error.set(null);
@@ -19,16 +24,37 @@ export function resource(fetcher, options = {}) {
19
24
  _data.set(result);
20
25
  _error.set(null);
21
26
  _status.set("success");
27
+ if (key) {
28
+ setCacheEntry(key, result);
29
+ deleteInflight(key);
30
+ }
22
31
  }).catch((err) => {
23
32
  if (currentRunId !== _runId)
24
33
  return;
25
34
  _error.set(err instanceof Error ? err : new Error(String(err)));
26
35
  _status.set("error");
36
+ if (key)
37
+ deleteInflight(key);
27
38
  });
28
39
  }
40
+ function _startFetch() {
41
+ if (key) {
42
+ const existing = getInflight(key);
43
+ if (existing) {
44
+ _execute(existing);
45
+ return;
46
+ }
47
+ const promise = fetcher();
48
+ setInflight(key, promise);
49
+ _execute(promise);
50
+ }
51
+ else {
52
+ _execute(fetcher());
53
+ }
54
+ }
29
55
  function execute() {
30
56
  try {
31
- _execute(fetcher());
57
+ _startFetch();
32
58
  }
33
59
  catch (err) {
34
60
  _runId++;
@@ -36,17 +62,55 @@ export function resource(fetcher, options = {}) {
36
62
  _status.set("error");
37
63
  }
38
64
  }
39
- if (immediate) {
40
- effect(() => {
65
+ if (key) {
66
+ registerResource(key, execute);
67
+ }
68
+ let stopEffect;
69
+ if (immediate && !isCacheFresh) {
70
+ stopEffect = effect(() => {
41
71
  try {
42
- _execute(fetcher());
72
+ if (key) {
73
+ const existing = getInflight(key);
74
+ if (existing) {
75
+ _execute(existing);
76
+ }
77
+ else {
78
+ const promise = fetcher();
79
+ setInflight(key, promise);
80
+ _execute(promise);
81
+ }
82
+ }
83
+ else {
84
+ _execute(fetcher());
85
+ }
43
86
  }
44
87
  catch (err) {
88
+ _runId++;
45
89
  _error.set(err instanceof Error ? err : new Error(String(err)));
46
90
  _status.set("error");
47
91
  }
48
92
  });
49
93
  }
94
+ let _removeFocusListener;
95
+ if (refetchOnFocus && typeof document !== "undefined") {
96
+ const onVisibility = () => {
97
+ if (document.visibilityState === "visible") {
98
+ execute();
99
+ }
100
+ };
101
+ document.addEventListener("visibilitychange", onVisibility);
102
+ _removeFocusListener = () => {
103
+ document.removeEventListener("visibilitychange", onVisibility);
104
+ };
105
+ }
106
+ function destroy() {
107
+ stopEffect?.();
108
+ stopEffect = undefined;
109
+ _removeFocusListener?.();
110
+ _removeFocusListener = undefined;
111
+ if (key)
112
+ unregisterResource(key, execute);
113
+ }
50
114
  return {
51
115
  data: computed(() => _data()),
52
116
  pending: computed(() => _status() === "pending"),
@@ -65,6 +129,7 @@ export function resource(fetcher, options = {}) {
65
129
  _error.set(null);
66
130
  _status.set("success");
67
131
  },
132
+ destroy,
68
133
  };
69
134
  }
70
135
  export function createResource(param, fetcher, options = {}) {
@@ -1 +1 @@
1
- {"version":3,"file":"resource.js","sourceRoot":"","sources":["../../src/async/resource.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"resource.js","sourceRoot":"","sources":["../../src/async/resource.ts"],"names":[],"mappings":"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"}
@@ -2,4 +2,5 @@ export { debounced, until, when, history, type HistoryElement, } from "./reactiv
2
2
  export { signal, persistedSignal, syncedSignal, peek, computed, batch, effect, untrack, type PersistedSignalOptions, type SyncedSignal, } from "./signal";
3
3
  export { RootComponent } from "./component";
4
4
  export { resource, createResource, type ResourceStatus, type Resource, type ResourceOptions, } from "./async/resource";
5
+ export { invalidateResource, _clearCache } from "./async/resource-cache";
5
6
  //# sourceMappingURL=internal.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACJ,OAAO,EACP,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,MAAM,EACN,eAAe,EACf,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,EACP,KAAK,sBAAsB,EAC3B,KAAK,YAAY,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EACL,QAAQ,EACR,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,GACrB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACJ,OAAO,EACP,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,MAAM,EACN,eAAe,EACf,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,EACP,KAAK,sBAAsB,EAC3B,KAAK,YAAY,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EACL,QAAQ,EACR,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/internal.js CHANGED
@@ -2,4 +2,5 @@ export { debounced, until, when, history, } from "./reactive";
2
2
  export { signal, persistedSignal, syncedSignal, peek, computed, batch, effect, untrack, } from "./signal";
3
3
  export { RootComponent } from "./component";
4
4
  export { resource, createResource, } from "./async/resource";
5
+ export { invalidateResource, _clearCache } from "./async/resource-cache";
5
6
  //# sourceMappingURL=internal.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"internal.js","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACJ,OAAO,GAER,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,MAAM,EACN,eAAe,EACf,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,GAGR,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EACL,QAAQ,EACR,cAAc,GAIf,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"internal.js","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACJ,OAAO,GAER,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,MAAM,EACN,eAAe,EACf,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,OAAO,GAGR,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EACL,QAAQ,EACR,cAAc,GAIf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"reactive.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAKzD,wBAAgB,IAAI,CAAC,CAAC,EACpB,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EAC/B,EAAE,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,IAAI,cAsBpC;AAED,wBAAgB,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,2BAQvD;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM;UAmBf,MAAM,IAAI;EAIlE"}
1
+ {"version":3,"file":"reactive.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAKzD,wBAAgB,IAAI,CAAC,CAAC,EACpB,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EAC/B,EAAE,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,IAAI,cAsBpC;AAED,wBAAgB,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,2BAQvD;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM;UAkBf,MAAM,IAAI;EAIlE"}
@@ -32,8 +32,6 @@ export function debounced(source, ms) {
32
32
  let timeout;
33
33
  const stop = effect(() => {
34
34
  const value = source();
35
- if (timeout)
36
- clearTimeout(timeout);
37
35
  timeout = setTimeout(() => {
38
36
  current.set(value);
39
37
  timeout = undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"reactive.js","sourceRoot":"","sources":["../../src/reactive/reactive.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACnC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,MAAM,UAAU,IAAI,CAClB,MAA+B,EAC/B,EAAmC;IAEnC,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,MAAM,GAAG,GAAG,EAAE,MAAM,EAAE,SAAqC,EAAE,CAAC;IAE9D,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC;QACvB,IAAI,CAAC,KAAK,IAAI,QAAQ;YAAE,OAAO;QAE/B,QAAQ,GAAG,IAAI,CAAC;QAChB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;QACf,EAAE,CAAC,KAAK,CAAC,CAAC;IACZ,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;IAElB,0EAA0E;IAC1E,0EAA0E;IAC1E,uEAAuE;IACvE,IAAI,QAAQ;QAAE,IAAI,EAAE,CAAC;IAErB,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,KAAK,CAAI,MAA+B;IACtD,OAAO,IAAI,OAAO,CAAiB,CAAC,OAAO,EAAE,EAAE;QAC7C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YAClC,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC,CAAC,CAAC;QAEH,KAAK,IAAI,CAAC;IACZ,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,SAAS,CAAI,MAA+B,EAAE,EAAU;IACtE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACjC,IAAI,OAAkD,CAAC;IAEvD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC;QACvB,IAAI,OAAO;YAAE,YAAY,CAAC,OAAO,CAAC,CAAC;QACnC,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACnB,OAAO,GAAG,SAAS,CAAC;QACtB,CAAC,EAAE,EAAE,CAAC,CAAC;QACP,OAAO,GAAG,EAAE;YACV,IAAI,OAAO,EAAE,CAAC;gBACZ,YAAY,CAAC,OAAO,CAAC,CAAC;gBACtB,OAAO,GAAG,SAAS,CAAC;YACtB,CAAC;QACH,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,MAAM,eAAe,GAAG,OAA2C,CAAC;IACpE,eAAe,CAAC,IAAI,GAAG,IAAI,CAAC;IAE5B,OAAO,eAAe,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"reactive.js","sourceRoot":"","sources":["../../src/reactive/reactive.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACnC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,MAAM,UAAU,IAAI,CAClB,MAA+B,EAC/B,EAAmC;IAEnC,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,MAAM,GAAG,GAAG,EAAE,MAAM,EAAE,SAAqC,EAAE,CAAC;IAE9D,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC;QACvB,IAAI,CAAC,KAAK,IAAI,QAAQ;YAAE,OAAO;QAE/B,QAAQ,GAAG,IAAI,CAAC;QAChB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;QACf,EAAE,CAAC,KAAK,CAAC,CAAC;IACZ,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;IAElB,0EAA0E;IAC1E,0EAA0E;IAC1E,uEAAuE;IACvE,IAAI,QAAQ;QAAE,IAAI,EAAE,CAAC;IAErB,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,KAAK,CAAI,MAA+B;IACtD,OAAO,IAAI,OAAO,CAAiB,CAAC,OAAO,EAAE,EAAE;QAC7C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YAClC,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC,CAAC,CAAC;QAEH,KAAK,IAAI,CAAC;IACZ,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,SAAS,CAAI,MAA+B,EAAE,EAAU;IACtE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACjC,IAAI,OAAkD,CAAC;IAEvD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC;QACvB,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACnB,OAAO,GAAG,SAAS,CAAC;QACtB,CAAC,EAAE,EAAE,CAAC,CAAC;QACP,OAAO,GAAG,EAAE;YACV,IAAI,OAAO,EAAE,CAAC;gBACZ,YAAY,CAAC,OAAO,CAAC,CAAC;gBACtB,OAAO,GAAG,SAAS,CAAC;YACtB,CAAC;QACH,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,MAAM,eAAe,GAAG,OAA2C,CAAC;IACpE,eAAe,CAAC,IAAI,GAAG,IAAI,CAAC;IAE5B,OAAO,eAAe,CAAC;AACzB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisjs/core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -186,6 +186,35 @@ describe("computed", () => {
186
186
  expect(bVal).toBeGreaterThanOrEqual(1);
187
187
  });
188
188
 
189
+ it("microtask guard: skips notifySubs when leaf subscriber unsubscribes before microtask fires", async () => {
190
+ const s = signal(0);
191
+ const c = computed(() => s() * 2);
192
+ const results: number[] = [];
193
+ const unsub = c.subscribe((v) => results.push(v));
194
+ // results = [0]
195
+
196
+ s.set(1); // markDirty: queues microtask (scheduled = true)
197
+ unsub(); // removes leaf subscriber BEFORE microtask fires → leafHolder.subs = null
198
+
199
+ await Promise.resolve(); // microtask: if (leafHolder.subs !== null) → FALSE → no notify
200
+ expect(results).toEqual([0]); // no update received after unsubscribe
201
+ });
202
+
203
+ it("skips re-scheduling leaf notification when already scheduled (!scheduled false branch)", async () => {
204
+ const s = signal(0);
205
+ const c = computed(() => s() * 2);
206
+ const results: number[] = [];
207
+ c.subscribe((v) => results.push(v));
208
+ // results = [0] after initial subscribe
209
+
210
+ s.set(1); // markDirty: scheduled = true, microtask queued
211
+ c(); // re-computes: dirty = false (scheduled still true)
212
+ s.set(2); // markDirty: dirty=false → proceeds, but !scheduled is false → no second microtask
213
+
214
+ await Promise.resolve(); // microtask runs, subscriber notified with current value
215
+ expect(results).toContain(4); // 2 * 2
216
+ });
217
+
189
218
  it("double unsubscribe on computed does not crash", () => {
190
219
  const s = signal(1);
191
220
  const c = computed(() => s() + 1);
@@ -0,0 +1,61 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+
4
+ import { resource } from "../async/resource";
5
+ import { _clearCache } from "../async/resource-cache";
6
+
7
+ describe("resource — refetchOnFocus", () => {
8
+ beforeEach(() => { _clearCache(); });
9
+
10
+ it("refetches when document becomes visible", async () => {
11
+ let call = 0;
12
+ const fetcher = vi.fn(() => Promise.resolve(++call));
13
+ const r = resource(fetcher, { refetchOnFocus: true });
14
+ await vi.waitFor(() => r.status() === "success");
15
+ expect(fetcher).toHaveBeenCalledTimes(1);
16
+
17
+ Object.defineProperty(document, "visibilityState", {
18
+ configurable: true,
19
+ get: () => "visible",
20
+ });
21
+ document.dispatchEvent(new Event("visibilitychange"));
22
+
23
+ await vi.waitFor(() => r.data() === 2);
24
+ expect(fetcher).toHaveBeenCalledTimes(2);
25
+ r.destroy();
26
+ });
27
+
28
+ it("does not refetch when document becomes hidden", async () => {
29
+ const fetcher = vi.fn(() => Promise.resolve(1));
30
+ const r = resource(fetcher, { refetchOnFocus: true });
31
+ await vi.waitFor(() => r.status() === "success");
32
+
33
+ Object.defineProperty(document, "visibilityState", {
34
+ configurable: true,
35
+ get: () => "hidden",
36
+ });
37
+ document.dispatchEvent(new Event("visibilitychange"));
38
+ await new Promise((res) => setTimeout(res, 10));
39
+
40
+ expect(fetcher).toHaveBeenCalledTimes(1);
41
+ r.destroy();
42
+ });
43
+
44
+ it("destroy() removes the visibilitychange listener", async () => {
45
+ let call = 0;
46
+ const fetcher = vi.fn(() => Promise.resolve(++call));
47
+ const r = resource(fetcher, { refetchOnFocus: true });
48
+ await vi.waitFor(() => r.status() === "success");
49
+
50
+ r.destroy();
51
+
52
+ Object.defineProperty(document, "visibilityState", {
53
+ configurable: true,
54
+ get: () => "visible",
55
+ });
56
+ document.dispatchEvent(new Event("visibilitychange"));
57
+ await new Promise((res) => setTimeout(res, 10));
58
+
59
+ expect(fetcher).toHaveBeenCalledTimes(1); // no refetch after destroy
60
+ });
61
+ });
@@ -1,6 +1,7 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
3
  import { resource, createResource } from "../async/resource";
4
+ import { invalidateResource, _clearCache } from "../async/resource-cache";
4
5
  import { signal } from "../signal/signal";
5
6
 
6
7
  describe("resource", () => {
@@ -151,6 +152,182 @@ describe("resource — execute() sync throw via refetch()", () => {
151
152
  });
152
153
  });
153
154
 
155
+ describe("resource — destroy()", () => {
156
+ it("destroy() stops the reactive effect — signal changes no longer trigger refetch", async () => {
157
+ const id = signal(1);
158
+ const fetcher = vi.fn((n: number) => Promise.resolve(n * 10));
159
+ const r = resource(() => fetcher(id()), { immediate: true });
160
+ await vi.waitFor(() => r.status() === "success");
161
+ expect(fetcher).toHaveBeenCalledTimes(1);
162
+
163
+ r.destroy();
164
+ id.set(2);
165
+ await new Promise((res) => setTimeout(res, 10));
166
+ expect(fetcher).toHaveBeenCalledTimes(1); // no re-fetch after destroy
167
+ });
168
+
169
+ it("destroy() is idempotent", async () => {
170
+ const r = resource(() => Promise.resolve(1));
171
+ await vi.waitFor(() => r.status() === "success");
172
+ expect(() => { r.destroy(); r.destroy(); }).not.toThrow();
173
+ });
174
+ });
175
+
176
+ describe("resource — cache + SWR (key, staleTime)", () => {
177
+ beforeEach(() => { _clearCache(); });
178
+
179
+ it("key: populates data from cache on creation (SWR)", async () => {
180
+ // First resource fills the cache
181
+ const r1 = resource(() => Promise.resolve("cached-value"), { key: "swr-test" });
182
+ await vi.waitFor(() => r1.status() === "success");
183
+
184
+ // Second resource with same key starts with cached data immediately
185
+ const r2 = resource(() => Promise.resolve("new-value"), { key: "swr-test" });
186
+ expect(r2.data()).toBe("cached-value"); // stale data shown immediately
187
+ expect(r2.status()).toBe("pending"); // fetching fresh data in background
188
+ await vi.waitFor(() => r2.data() === "new-value");
189
+ r1.destroy();
190
+ r2.destroy();
191
+ });
192
+
193
+ it("staleTime: skips fetch when cache is fresh", async () => {
194
+ const fetcher = vi.fn(() => Promise.resolve("fresh"));
195
+ const r1 = resource(fetcher, { key: "fresh-test", staleTime: 60_000 });
196
+ await vi.waitFor(() => r1.status() === "success");
197
+ expect(fetcher).toHaveBeenCalledTimes(1);
198
+
199
+ // Second resource within staleTime — no fetch
200
+ const r2 = resource(fetcher, { key: "fresh-test", staleTime: 60_000 });
201
+ expect(r2.data()).toBe("fresh");
202
+ expect(r2.status()).toBe("success");
203
+ await new Promise((res) => setTimeout(res, 10));
204
+ expect(fetcher).toHaveBeenCalledTimes(1); // no second fetch
205
+ r1.destroy();
206
+ r2.destroy();
207
+ });
208
+
209
+ it("staleTime: 0 (default) always refetches even with cached data", async () => {
210
+ const fetcher = vi.fn(() => Promise.resolve("v1"));
211
+ const r1 = resource(fetcher, { key: "always-stale" });
212
+ await vi.waitFor(() => r1.status() === "success");
213
+
214
+ const r2 = resource(fetcher, { key: "always-stale", staleTime: 0 });
215
+ expect(r2.data()).toBe("v1"); // shows stale immediately
216
+ await vi.waitFor(() => r2.status() === "success");
217
+ expect(fetcher).toHaveBeenCalledTimes(2); // did refetch
218
+ r1.destroy();
219
+ r2.destroy();
220
+ });
221
+ });
222
+
223
+ describe("resource — deduplication", () => {
224
+ beforeEach(() => { _clearCache(); });
225
+
226
+ it("two resources with the same key share a single in-flight request", async () => {
227
+ let resolveShared!: (v: string) => void;
228
+ const fetcher = vi.fn(
229
+ () => new Promise<string>((res) => { resolveShared = res; }),
230
+ );
231
+
232
+ const r1 = resource(fetcher, { key: "dedup-test" });
233
+ const r2 = resource(fetcher, { key: "dedup-test" });
234
+
235
+ expect(fetcher).toHaveBeenCalledTimes(1); // only one fetch started
236
+
237
+ resolveShared("shared-result");
238
+ await vi.waitFor(() => r1.status() === "success");
239
+ await vi.waitFor(() => r2.status() === "success");
240
+
241
+ expect(r1.data()).toBe("shared-result");
242
+ expect(r2.data()).toBe("shared-result");
243
+ r1.destroy();
244
+ r2.destroy();
245
+ });
246
+ });
247
+
248
+ describe("resource — key + non-Error sync throw in effect", () => {
249
+ beforeEach(() => { _clearCache(); });
250
+
251
+ it("key: wraps non-Error sync throw from fetcher inside effect in Error", async () => {
252
+ const r = resource(
253
+ () => {
254
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
255
+ throw "raw string error in keyed effect";
256
+ },
257
+ { key: "non-error-keyed" },
258
+ );
259
+ await vi.waitFor(() => r.status() === "error");
260
+ expect(r.error()).toBeInstanceOf(Error);
261
+ expect((r.error() as Error).message).toBe("raw string error in keyed effect");
262
+ r.destroy();
263
+ });
264
+ });
265
+
266
+ describe("resource — key + rejection / sync-throw", () => {
267
+ beforeEach(() => { _clearCache(); });
268
+
269
+ it("key: clears in-flight entry on rejection (catch path)", async () => {
270
+ const r = resource(
271
+ () => Promise.reject(new Error("keyed failure")),
272
+ { key: "reject-test" },
273
+ );
274
+ await new Promise((res) => setTimeout(res, 0));
275
+ expect(r.status()).toBe("error");
276
+ expect((r.error() as Error).message).toBe("keyed failure");
277
+ r.destroy();
278
+ });
279
+
280
+ it("key: sync throw inside effect is captured as error", async () => {
281
+ const r = resource(
282
+ () => { throw new Error("sync in keyed effect"); },
283
+ { key: "sync-throw-keyed" },
284
+ );
285
+ await vi.waitFor(() => r.status() === "error");
286
+ expect((r.error() as Error).message).toBe("sync in keyed effect");
287
+ r.destroy();
288
+ });
289
+ });
290
+
291
+ describe("resource — invalidateResource()", () => {
292
+ beforeEach(() => { _clearCache(); });
293
+
294
+ it("clears cache and triggers refetch on all registered resources", async () => {
295
+ let call = 0;
296
+ const fetcher = vi.fn(() => Promise.resolve(++call));
297
+
298
+ const r1 = resource(fetcher, { key: "inv-test" });
299
+ const r2 = resource(fetcher, { key: "inv-test" });
300
+ await vi.waitFor(() => r1.status() === "success");
301
+ await vi.waitFor(() => r2.status() === "success");
302
+
303
+ const callsBefore = fetcher.mock.calls.length;
304
+ invalidateResource("inv-test");
305
+
306
+ await vi.waitFor(() => r1.status() === "success" && r1.data() !== callsBefore);
307
+ expect(fetcher.mock.calls.length).toBeGreaterThan(callsBefore);
308
+ r1.destroy();
309
+ r2.destroy();
310
+ });
311
+
312
+ it("no-op when key has no registered resources", () => {
313
+ expect(() => { invalidateResource("nonexistent-key"); }).not.toThrow();
314
+ });
315
+
316
+ it("unregisters resource on destroy — invalidate no longer triggers it", async () => {
317
+ let call = 0;
318
+ const fetcher = vi.fn(() => Promise.resolve(++call));
319
+ const r = resource(fetcher, { key: "unreg-test" });
320
+ await vi.waitFor(() => r.status() === "success");
321
+
322
+ r.destroy();
323
+ const callsBefore = fetcher.mock.calls.length;
324
+
325
+ invalidateResource("unreg-test");
326
+ await new Promise((res) => setTimeout(res, 10));
327
+ expect(fetcher.mock.calls.length).toBe(callsBefore); // no refetch after destroy
328
+ });
329
+ });
330
+
154
331
  describe("resource — additional cases", () => {
155
332
  it("two concurrent refetch() calls — last result wins, stale result is discarded", async () => {
156
333
  let resolveFirst!: (v: string) => void;
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
 
3
- import { signal } from "../signal/signal";
3
+ import { batch } from "../signal/batch";
4
+ import { signal, addSub, removeSub, type SubList } from "../signal/signal";
5
+ import type { Effect } from "../signal/effect";
4
6
 
5
7
  describe("signal", () => {
6
8
  it("returns initial value", () => {
@@ -125,6 +127,58 @@ describe("signal", () => {
125
127
  expect(() => s.set(1)).not.toThrow();
126
128
  });
127
129
 
130
+ it("unsubscribing one of multiple subscribers removes only that subscriber", () => {
131
+ const s = signal(0);
132
+ const a: number[] = [];
133
+ const b: number[] = [];
134
+ const unsubA = s.subscribe((v) => a.push(v));
135
+ s.subscribe((v) => b.push(v));
136
+ // subs is now an array — hits removeSub else branch
137
+ unsubA();
138
+ s.set(1);
139
+ expect(a).toEqual([0]); // A stopped after initial fire
140
+ expect(b).toEqual([0, 1]); // B still receives
141
+ });
142
+
143
+ it("unsubscribing a non-existent subscriber from array subs is a no-op", () => {
144
+ const s = signal(0);
145
+ const a: number[] = [];
146
+ const b: number[] = [];
147
+ s.subscribe((v) => a.push(v));
148
+ const unsubB = s.subscribe((v) => b.push(v));
149
+ unsubB(); // remove B, subs becomes array without B
150
+ unsubB(); // call again — idx will be -1, splice not called
151
+ expect(() => s.set(1)).not.toThrow();
152
+ expect(a).toContain(1);
153
+ });
154
+
155
+ it("batch defers notifications for multiple array subscribers", () => {
156
+ const s = signal(0);
157
+ const a: number[] = [];
158
+ const b: number[] = [];
159
+ s.subscribe((v) => a.push(v));
160
+ s.subscribe((v) => b.push(v));
161
+ // subs is an array; inside batch, notifySubs hits the batching=true array path
162
+ batch(() => {
163
+ s.set(1);
164
+ expect(a).toEqual([0]); // not yet fired
165
+ expect(b).toEqual([0]);
166
+ });
167
+ expect(a).toContain(1);
168
+ expect(b).toContain(1);
169
+ });
170
+
171
+ it("removeSub with non-matching single-function subscriber is a no-op", () => {
172
+ // When subs is a single function and we try to remove a different function,
173
+ // the holder must remain unchanged (subs !== eff → false branch).
174
+ const holder: { subs: SubList } = { subs: null };
175
+ const fn1: Effect = () => {};
176
+ const fn2: Effect = () => {};
177
+ addSub(holder, fn1); // subs = fn1 (single function)
178
+ removeSub(holder, fn2); // fn2 !== fn1 → no-op
179
+ expect(holder.subs).toBe(fn1);
180
+ });
181
+
128
182
  it("set() with mutated object reference (same ref) does NOT notify — Object.is semantics", () => {
129
183
  const obj = { count: 0 };
130
184
  const s = signal(obj);
@@ -0,0 +1,62 @@
1
+ interface CacheEntry {
2
+ data: unknown;
3
+ timestamp: number;
4
+ }
5
+
6
+ const _cache = new Map<string, CacheEntry>();
7
+ const _inflight = new Map<string, Promise<unknown>>();
8
+ const _registry = new Map<string, Set<() => void>>();
9
+
10
+ export function getCacheEntry(key: string): CacheEntry | undefined {
11
+ return _cache.get(key);
12
+ }
13
+
14
+ export function setCacheEntry(key: string, data: unknown): void {
15
+ _cache.set(key, { data, timestamp: Date.now() });
16
+ }
17
+
18
+ export function deleteCacheEntry(key: string): void {
19
+ _cache.delete(key);
20
+ }
21
+
22
+ export function getInflight(key: string): Promise<unknown> | undefined {
23
+ return _inflight.get(key);
24
+ }
25
+
26
+ export function setInflight(key: string, promise: Promise<unknown>): void {
27
+ _inflight.set(key, promise);
28
+ }
29
+
30
+ export function deleteInflight(key: string): void {
31
+ _inflight.delete(key);
32
+ }
33
+
34
+ export function registerResource(key: string, refetch: () => void): void {
35
+ let set = _registry.get(key);
36
+ if (!set) {
37
+ set = new Set();
38
+ _registry.set(key, set);
39
+ }
40
+ set.add(refetch);
41
+ }
42
+
43
+ export function unregisterResource(key: string, refetch: () => void): void {
44
+ _registry.get(key)?.delete(refetch);
45
+ }
46
+
47
+ /**
48
+ * Clears the cache entry and in-flight request for `key`, then triggers
49
+ * every active resource registered under that key to refetch.
50
+ */
51
+ export function invalidateResource(key: string): void {
52
+ deleteCacheEntry(key);
53
+ deleteInflight(key);
54
+ _registry.get(key)?.forEach((fn) => { fn(); });
55
+ }
56
+
57
+ /** Resets all cache state. Intended for use in tests only. */
58
+ export function _clearCache(): void {
59
+ _cache.clear();
60
+ _inflight.clear();
61
+ _registry.clear();
62
+ }