@praxisjs/core 0.4.2 → 1.1.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 (69) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/__tests__/batch.test.js +47 -0
  3. package/dist/__tests__/batch.test.js.map +1 -1
  4. package/dist/__tests__/computed.test.js +48 -0
  5. package/dist/__tests__/computed.test.js.map +1 -1
  6. package/dist/__tests__/effect.test.js +39 -1
  7. package/dist/__tests__/effect.test.js.map +1 -1
  8. package/dist/__tests__/persisted-signal.test.js +34 -0
  9. package/dist/__tests__/persisted-signal.test.js.map +1 -1
  10. package/dist/__tests__/reactive.test.js +51 -0
  11. package/dist/__tests__/reactive.test.js.map +1 -1
  12. package/dist/__tests__/resource.test.js +53 -0
  13. package/dist/__tests__/resource.test.js.map +1 -1
  14. package/dist/__tests__/signal.test.js +29 -0
  15. package/dist/__tests__/signal.test.js.map +1 -1
  16. package/dist/async/resource.d.ts.map +1 -1
  17. package/dist/async/resource.js +15 -2
  18. package/dist/async/resource.js.map +1 -1
  19. package/dist/component/base.js +4 -6
  20. package/dist/component/base.js.map +1 -1
  21. package/dist/component/composable.d.ts +6 -0
  22. package/dist/component/composable.d.ts.map +1 -0
  23. package/dist/component/composable.js +3 -0
  24. package/dist/component/composable.js.map +1 -0
  25. package/dist/component/index.d.ts +1 -0
  26. package/dist/component/index.d.ts.map +1 -1
  27. package/dist/component/index.js +1 -0
  28. package/dist/component/index.js.map +1 -1
  29. package/dist/component/stateful.js +7 -4
  30. package/dist/component/stateful.js.map +1 -1
  31. package/dist/index.d.ts +1 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal.d.ts +1 -0
  36. package/dist/internal.d.ts.map +1 -1
  37. package/dist/internal.js +1 -0
  38. package/dist/internal.js.map +1 -1
  39. package/dist/reactive/reactive.d.ts +3 -1
  40. package/dist/reactive/reactive.d.ts.map +1 -1
  41. package/dist/reactive/reactive.js +10 -2
  42. package/dist/reactive/reactive.js.map +1 -1
  43. package/dist/signal/batch.d.ts +3 -0
  44. package/dist/signal/batch.d.ts.map +1 -1
  45. package/dist/signal/batch.js +15 -4
  46. package/dist/signal/batch.js.map +1 -1
  47. package/dist/signal/computed.d.ts.map +1 -1
  48. package/dist/signal/computed.js +7 -3
  49. package/dist/signal/computed.js.map +1 -1
  50. package/dist/signal/signal.d.ts.map +1 -1
  51. package/dist/signal/signal.js +14 -1
  52. package/dist/signal/signal.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/__tests__/batch.test.ts +59 -1
  55. package/src/__tests__/computed.test.ts +50 -0
  56. package/src/__tests__/effect.test.ts +43 -1
  57. package/src/__tests__/persisted-signal.test.ts +43 -0
  58. package/src/__tests__/reactive.test.ts +59 -0
  59. package/src/__tests__/resource.test.ts +68 -0
  60. package/src/__tests__/signal.test.ts +31 -0
  61. package/src/async/resource.ts +13 -2
  62. package/src/component/composable.ts +5 -0
  63. package/src/component/index.ts +1 -0
  64. package/src/index.ts +1 -8
  65. package/src/internal.ts +7 -0
  66. package/src/reactive/reactive.ts +11 -2
  67. package/src/signal/batch.ts +17 -4
  68. package/src/signal/computed.ts +6 -3
  69. package/src/signal/signal.ts +12 -1
@@ -148,4 +148,47 @@ describe("persistedSignal", () => {
148
148
  s.set(undefined);
149
149
  expect(localStorage.getItem("key15")).toBeNull();
150
150
  });
151
+
152
+ it("localStorage.setItem throws QuotaExceededError — signal state is updated but no crash", () => {
153
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
154
+ const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
155
+ const err = new DOMException("QuotaExceededError", "QuotaExceededError");
156
+ throw err;
157
+ });
158
+
159
+ const s = persistedSignal("key16", 0);
160
+ expect(() => s.set(42)).not.toThrow();
161
+ // The in-memory signal value is still updated despite storage failure
162
+ expect(s()).toBe(42);
163
+
164
+ setItemSpy.mockRestore();
165
+ warn.mockRestore();
166
+ });
167
+
168
+ it("syncTabs=true — storage event with empty string newValue is handled", () => {
169
+ const s = persistedSignal("key17", 99);
170
+ window.dispatchEvent(
171
+ new StorageEvent("storage", {
172
+ key: "key17",
173
+ newValue: "",
174
+ storageArea: localStorage,
175
+ }),
176
+ );
177
+ // Empty string is falsy, so falls back to initialValue
178
+ expect(s()).toBe(99);
179
+ });
180
+
181
+ it("syncTabs=true — storage event from sessionStorage is ignored", () => {
182
+ const s = persistedSignal("key18", 10);
183
+ s.set(20);
184
+ window.dispatchEvent(
185
+ new StorageEvent("storage", {
186
+ key: "key18",
187
+ newValue: "999",
188
+ storageArea: sessionStorage,
189
+ }),
190
+ );
191
+ // Should stay at 20 since the event is from sessionStorage, not localStorage
192
+ expect(s()).toBe(20);
193
+ });
151
194
  });
@@ -53,6 +53,13 @@ describe("when", () => {
53
53
  s.set(0);
54
54
  expect(fn).not.toHaveBeenCalled();
55
55
  });
56
+
57
+ it("with source that fires immediately on subscription does not crash", () => {
58
+ const s = signal(1); // immediately truthy
59
+ const fn = vi.fn();
60
+ expect(() => when(s, fn)).not.toThrow();
61
+ expect(fn).toHaveBeenCalledWith(1);
62
+ });
56
63
  });
57
64
 
58
65
  // ---------- until ----------
@@ -70,6 +77,15 @@ describe("until", () => {
70
77
  s.set(7);
71
78
  expect(await promise).toBe(7);
72
79
  });
80
+
81
+ it("the promise remains pending if source is always falsy (no-timeout behavior)", async () => {
82
+ const s = signal<number>(0);
83
+ let resolved = false;
84
+ until(s).then(() => { resolved = true; });
85
+ // Never set a truthy value
86
+ await new Promise((res) => setTimeout(res, 10));
87
+ expect(resolved).toBe(false);
88
+ });
73
89
  });
74
90
 
75
91
  // ---------- debounced ----------
@@ -125,6 +141,22 @@ describe("debounced", () => {
125
141
  expect(d()).toBe("b");
126
142
  vi.useRealTimers();
127
143
  });
144
+
145
+ it("the inner effect is cleaned up when stop is called (no leak)", () => {
146
+ vi.useFakeTimers();
147
+ const s = signal(0);
148
+ const d = debounced(s, 100);
149
+
150
+ s.set(1);
151
+ // Stop the debounced effect before the timer fires
152
+ d.stop();
153
+
154
+ // Advance time — the timer should not fire and update the signal
155
+ vi.advanceTimersByTime(200);
156
+ expect(d()).toBe(0); // still initial value, effect was stopped
157
+
158
+ vi.useRealTimers();
159
+ });
128
160
  });
129
161
 
130
162
  // ---------- history ----------
@@ -242,4 +274,31 @@ describe("history", () => {
242
274
  expect(h.canUndo()).toBe(false);
243
275
  expect(h.canRedo()).toBe(false);
244
276
  });
277
+
278
+ it("undo() called more times than history entries — state stays consistent", () => {
279
+ const s = signal(0);
280
+ const h = history(s);
281
+ s.set(1);
282
+ h.undo();
283
+ // No more history — undo is a no-op
284
+ h.undo();
285
+ h.undo();
286
+ expect(h.current()).toBe(0);
287
+ expect(h.canUndo()).toBe(false);
288
+ });
289
+
290
+ it("values() returns a snapshot — each call after source change yields a fresh array", () => {
291
+ const s = signal("a");
292
+ const h = history(s);
293
+ s.set("b");
294
+ s.set("c");
295
+ const snapshot1 = h.values();
296
+ expect(snapshot1).toEqual(["a", "b", "c"]);
297
+ // Trigger a change so the computed re-evaluates on next read
298
+ s.set("d");
299
+ const snapshot2 = h.values();
300
+ expect(snapshot2).toEqual(["a", "b", "c", "d"]);
301
+ // snapshot1 and snapshot2 are different array instances
302
+ expect(snapshot1).not.toBe(snapshot2);
303
+ });
245
304
  });
@@ -73,6 +73,19 @@ describe("resource", () => {
73
73
  expect(r.status()).toBe("idle");
74
74
  });
75
75
 
76
+ it("cancel() before rejection ignores the stale error (catch stale path)", async () => {
77
+ let reject!: (e: Error) => void;
78
+ const r = resource(
79
+ () => new Promise<number>((_res, rej) => { reject = rej; }),
80
+ );
81
+ r.cancel();
82
+ expect(r.status()).toBe("idle");
83
+ reject(new Error("cancelled error"));
84
+ await new Promise((res) => setTimeout(res, 10));
85
+ expect(r.status()).toBe("idle");
86
+ expect(r.error()).toBeNull();
87
+ });
88
+
76
89
  it("keepPreviousData=true preserves old data during refetch", async () => {
77
90
  let call = 0;
78
91
  const r = resource(() => Promise.resolve(++call), { keepPreviousData: true });
@@ -95,3 +108,58 @@ describe("createResource", () => {
95
108
  expect(fetcher).toHaveBeenCalledTimes(2);
96
109
  });
97
110
  });
111
+
112
+ describe("resource — additional cases", () => {
113
+ it("two concurrent refetch() calls — last result wins, stale result is discarded", async () => {
114
+ let resolveFirst!: (v: string) => void;
115
+ let resolveSecond!: (v: string) => void;
116
+
117
+ let call = 0;
118
+ const r = resource(
119
+ () => {
120
+ call++;
121
+ if (call === 1) return new Promise<string>((res) => { resolveFirst = res; });
122
+ return new Promise<string>((res) => { resolveSecond = res; });
123
+ },
124
+ { immediate: false },
125
+ );
126
+
127
+ r.refetch(); // call 1
128
+ r.refetch(); // call 2 — supersedes call 1
129
+
130
+ resolveSecond("second");
131
+ resolveFirst("first"); // stale
132
+
133
+ await vi.waitFor(() => r.status() === "success");
134
+ expect(r.data()).toBe("second");
135
+ });
136
+
137
+ it("fetcher() throws synchronously — error is captured, does not crash", async () => {
138
+ const r = resource(() => {
139
+ throw new Error("sync throw");
140
+ });
141
+ await vi.waitFor(() => r.status() === "error");
142
+ expect((r.error() as Error).message).toBe("sync throw");
143
+ });
144
+
145
+ it("keepPreviousData: true — data is preserved while refetching", async () => {
146
+ let call = 0;
147
+ let resolve!: (v: number) => void;
148
+ const r = resource(
149
+ () => {
150
+ call++;
151
+ if (call === 1) return Promise.resolve(1);
152
+ return new Promise<number>((res) => { resolve = res; });
153
+ },
154
+ { keepPreviousData: true },
155
+ );
156
+
157
+ await vi.waitFor(() => r.data() === 1);
158
+ r.refetch();
159
+ // During refetch with keepPreviousData, old data is preserved
160
+ expect(r.data()).toBe(1);
161
+ expect(r.status()).toBe("pending");
162
+ resolve(2);
163
+ await vi.waitFor(() => r.data() === 2);
164
+ });
165
+ });
@@ -106,4 +106,35 @@ describe("signal", () => {
106
106
  expect(a).toContain(7);
107
107
  expect(b).toContain(7);
108
108
  });
109
+
110
+ it("subscriber B still fires when subscriber A throws", () => {
111
+ const s = signal(0);
112
+ const received: number[] = [];
113
+ s.subscribe((v) => { if (v !== 0) throw new Error("sub A throws"); });
114
+ s.subscribe((v) => received.push(v));
115
+ expect(() => s.set(1)).toThrow("sub A throws");
116
+ expect(received).toContain(1);
117
+ });
118
+
119
+ it("unsubscribe during notification does not crash", () => {
120
+ const s = signal(0);
121
+ let unsub: (() => void) | undefined;
122
+ unsub = s.subscribe(() => {
123
+ unsub?.();
124
+ });
125
+ expect(() => s.set(1)).not.toThrow();
126
+ });
127
+
128
+ it("set() with mutated object reference (same ref) does NOT notify — Object.is semantics", () => {
129
+ const obj = { count: 0 };
130
+ const s = signal(obj);
131
+ const calls: unknown[] = [];
132
+ s.subscribe((v) => calls.push(v));
133
+ const before = calls.length;
134
+ // Mutate the object in-place and set the same reference
135
+ obj.count = 99;
136
+ s.set(obj);
137
+ // Object.is(obj, obj) === true, so no notification
138
+ expect(calls.length).toBe(before);
139
+ });
109
140
  });
@@ -59,12 +59,23 @@ export function resource<T>(
59
59
  }
60
60
 
61
61
  function execute() {
62
- _execute(fetcher());
62
+ try {
63
+ _execute(fetcher());
64
+ } catch (err: unknown) {
65
+ _runId++;
66
+ _error.set(err instanceof Error ? err : new Error(String(err)));
67
+ _status.set("error");
68
+ }
63
69
  }
64
70
 
65
71
  if (immediate) {
66
72
  effect(() => {
67
- _execute(fetcher());
73
+ try {
74
+ _execute(fetcher());
75
+ } catch (err: unknown) {
76
+ _error.set(err instanceof Error ? err : new Error(String(err)));
77
+ _status.set("error");
78
+ }
68
79
  });
69
80
  }
70
81
 
@@ -0,0 +1,5 @@
1
+ export abstract class Composable {
2
+ abstract setup(): Record<string, unknown>;
3
+ onMount?(): void;
4
+ onUnmount?(): void;
5
+ }
@@ -1,3 +1,4 @@
1
1
  export { StatefulComponent } from "./stateful";
2
2
  export { StatelessComponent } from "./stateless";
3
3
  export { RootComponent } from "./base";
4
+ export { Composable } from "./composable";
package/src/index.ts CHANGED
@@ -1,8 +1 @@
1
- export { StatefulComponent, StatelessComponent } from "./component";
2
- export {
3
- resource,
4
- createResource,
5
- type ResourceStatus,
6
- type Resource,
7
- type ResourceOptions,
8
- } from "./async/resource";
1
+ export { StatefulComponent, StatelessComponent, Composable } from "./component";
package/src/internal.ts CHANGED
@@ -15,3 +15,10 @@ export {
15
15
  type PersistedSignalOptions,
16
16
  } from "./signal";
17
17
  export { RootComponent } from "./component";
18
+ export {
19
+ resource,
20
+ createResource,
21
+ type ResourceStatus,
22
+ type Resource,
23
+ type ResourceOptions,
24
+ } from "./async/resource";
@@ -43,14 +43,23 @@ export function debounced<T>(source: Signal<T> | Computed<T>, ms: number) {
43
43
  const current = signal<T>(source());
44
44
  let timeout: ReturnType<typeof setTimeout> | undefined;
45
45
 
46
- effect(() => {
46
+ const stop = effect(() => {
47
47
  const value = source();
48
48
  if (timeout) clearTimeout(timeout);
49
49
  timeout = setTimeout(() => {
50
50
  current.set(value);
51
51
  timeout = undefined;
52
52
  }, ms);
53
+ return () => {
54
+ if (timeout) {
55
+ clearTimeout(timeout);
56
+ timeout = undefined;
57
+ }
58
+ };
53
59
  });
54
60
 
55
- return current;
61
+ const debouncedSignal = current as Signal<T> & { stop: () => void };
62
+ debouncedSignal.stop = stop;
63
+
64
+ return debouncedSignal;
56
65
  }
@@ -2,13 +2,26 @@ import type { Effect } from "./effect";
2
2
 
3
3
  let batchQueue: Set<Effect> | null = null;
4
4
 
5
+ export function isBatching(): boolean {
6
+ return batchQueue !== null;
7
+ }
8
+
9
+ export function enqueueEffect(effect: Effect): void {
10
+ batchQueue?.add(effect);
11
+ }
12
+
5
13
  export function batch(fn: () => void) {
6
- batchQueue = new Set();
14
+ const isOuter = batchQueue === null;
15
+ if (isOuter) {
16
+ batchQueue = new Set();
17
+ }
7
18
  try {
8
19
  fn();
9
20
  } finally {
10
- const effectsToRun = batchQueue;
11
- batchQueue = null;
12
- effectsToRun.forEach((eff) => { eff(); });
21
+ if (isOuter) {
22
+ const effectsToRun = batchQueue ?? new Set<Effect>();
23
+ batchQueue = null;
24
+ effectsToRun.forEach((eff) => { eff(); });
25
+ }
13
26
  }
14
27
  }
@@ -22,9 +22,12 @@ export function computed<T>(computeFn: () => T): Computed<T> {
22
22
  if (dirty) {
23
23
  const prevEffect = activeEffect;
24
24
  runEffect(recompute);
25
- cachedValue = computeFn();
26
- dirty = false;
27
- runEffect(prevEffect);
25
+ try {
26
+ cachedValue = computeFn();
27
+ dirty = false;
28
+ } finally {
29
+ runEffect(prevEffect);
30
+ }
28
31
  }
29
32
 
30
33
  return cachedValue;
@@ -1,5 +1,6 @@
1
1
  import type { Signal } from "@praxisjs/shared";
2
2
 
3
+ import { isBatching, enqueueEffect } from "./batch";
3
4
  import { activeEffect, type Effect } from "./effect";
4
5
 
5
6
  export function signal<T>(initialValue: T): Signal<T> {
@@ -16,9 +17,19 @@ export function signal<T>(initialValue: T): Signal<T> {
16
17
  function set(newValue: T) {
17
18
  if (Object.is(value, newValue)) return;
18
19
  value = newValue;
20
+ if (isBatching()) {
21
+ [...subscribers].forEach((sub) => { enqueueEffect(sub); });
22
+ return;
23
+ }
24
+ const errors: unknown[] = [];
19
25
  [...subscribers].forEach((sub) => {
20
- sub();
26
+ try {
27
+ sub();
28
+ } catch (e) {
29
+ errors.push(e);
30
+ }
21
31
  });
32
+ if (errors.length > 0) throw errors[errors.length - 1];
22
33
  }
23
34
 
24
35
  function update(fn: (prev: T) => T) {