@praxisjs/core 1.6.0 → 1.8.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 (49) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/__tests__/computed.test.js +60 -1
  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/component/base.d.ts +1 -1
  21. package/dist/component/base.d.ts.map +1 -1
  22. package/dist/component/base.js.map +1 -1
  23. package/dist/internal.d.ts +2 -1
  24. package/dist/internal.d.ts.map +1 -1
  25. package/dist/internal.js +2 -1
  26. package/dist/internal.js.map +1 -1
  27. package/dist/reactive/reactive.d.ts.map +1 -1
  28. package/dist/reactive/reactive.js +0 -2
  29. package/dist/reactive/reactive.js.map +1 -1
  30. package/dist/signal/computed.d.ts +2 -1
  31. package/dist/signal/computed.d.ts.map +1 -1
  32. package/dist/signal/computed.js +7 -0
  33. package/dist/signal/computed.js.map +1 -1
  34. package/dist/signal/index.d.ts +1 -1
  35. package/dist/signal/index.d.ts.map +1 -1
  36. package/dist/signal/index.js +1 -1
  37. package/dist/signal/index.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/computed.test.ts +72 -1
  40. package/src/__tests__/resource-focus.test.ts +61 -0
  41. package/src/__tests__/resource.test.ts +178 -1
  42. package/src/__tests__/signal.test.ts +55 -1
  43. package/src/async/resource-cache.ts +62 -0
  44. package/src/async/resource.ts +92 -10
  45. package/src/component/base.ts +2 -1
  46. package/src/internal.ts +2 -0
  47. package/src/reactive/reactive.ts +0 -1
  48. package/src/signal/computed.ts +9 -1
  49. package/src/signal/index.ts +1 -1
@@ -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
+ }
@@ -1,7 +1,16 @@
1
1
  import type { Computed, Signal } from "@praxisjs/shared";
2
2
 
3
3
  import { computed, signal } from "../signal";
4
- import { effect } from "../signal/effect";
4
+ import {
5
+ getCacheEntry,
6
+ setCacheEntry,
7
+ deleteInflight,
8
+ getInflight,
9
+ setInflight,
10
+ registerResource,
11
+ unregisterResource,
12
+ } from "./resource-cache";
13
+ import { effect, untrack } from "../signal/effect";
5
14
 
6
15
  export type ResourceStatus = "idle" | "pending" | "success" | "error";
7
16
 
@@ -13,12 +22,16 @@ export interface Resource<T> {
13
22
  refetch(): void;
14
23
  cancel(): void;
15
24
  mutate(data: T): void;
25
+ destroy(): void;
16
26
  }
17
27
 
18
28
  export interface ResourceOptions<T> {
19
29
  initialData?: T;
20
30
  immediate?: boolean;
21
31
  keepPreviousData?: boolean;
32
+ key?: string;
33
+ staleTime?: number;
34
+ refetchOnFocus?: boolean;
22
35
  }
23
36
 
24
37
  export function resource<T>(
@@ -29,18 +42,27 @@ export function resource<T>(
29
42
  initialData = null,
30
43
  immediate = true,
31
44
  keepPreviousData = false,
45
+ key,
46
+ staleTime = 0,
47
+ refetchOnFocus = false,
32
48
  } = options;
33
49
 
34
- const _data = signal(initialData);
50
+ const cached = key ? getCacheEntry(key) : undefined;
51
+ const isCacheFresh =
52
+ cached !== undefined && Date.now() - cached.timestamp < staleTime;
53
+
54
+ const _data = signal(cached ? (cached.data as T) : initialData);
35
55
  const _error = signal<Error | null>(null);
36
- const _status = signal<ResourceStatus>("idle");
56
+ const _status = signal<ResourceStatus>(cached ? "success" : "idle");
37
57
 
38
58
  let _runId = 0;
39
59
 
40
- function _execute(fn: Promise<T>) {
60
+ function _execute(fn: Promise<T>): void {
41
61
  const currentRunId = ++_runId;
42
62
 
43
- if (!keepPreviousData) {
63
+ // untrack: _data.set(result) in the resolve handler must not re-trigger the enclosing effect.
64
+ const hasCachedData = key !== undefined && untrack(() => _data()) !== null;
65
+ if (!keepPreviousData && !hasCachedData) {
44
66
  _data.set(null);
45
67
  }
46
68
  _error.set(null);
@@ -51,16 +73,36 @@ export function resource<T>(
51
73
  _data.set(result);
52
74
  _error.set(null);
53
75
  _status.set("success");
76
+ if (key) {
77
+ setCacheEntry(key, result);
78
+ deleteInflight(key);
79
+ }
54
80
  }).catch((err: unknown) => {
55
81
  if (currentRunId !== _runId) return;
56
82
  _error.set(err instanceof Error ? err : new Error(String(err)));
57
83
  _status.set("error");
84
+ if (key) deleteInflight(key);
58
85
  });
59
86
  }
60
87
 
61
- function execute() {
62
- try {
88
+ function _startFetch(): void {
89
+ if (key) {
90
+ const existing = getInflight(key);
91
+ if (existing) {
92
+ _execute(existing as Promise<T>);
93
+ return;
94
+ }
95
+ const promise = fetcher();
96
+ setInflight(key, promise as Promise<unknown>);
97
+ _execute(promise);
98
+ } else {
63
99
  _execute(fetcher());
100
+ }
101
+ }
102
+
103
+ function execute(): void {
104
+ try {
105
+ _startFetch();
64
106
  } catch (err: unknown) {
65
107
  _runId++;
66
108
  _error.set(err instanceof Error ? err : new Error(String(err)));
@@ -68,17 +110,56 @@ export function resource<T>(
68
110
  }
69
111
  }
70
112
 
71
- if (immediate) {
72
- effect(() => {
113
+ if (key) {
114
+ registerResource(key, execute);
115
+ }
116
+
117
+ let stopEffect: (() => void) | undefined;
118
+
119
+ if (immediate && !isCacheFresh) {
120
+ stopEffect = effect(() => {
73
121
  try {
74
- _execute(fetcher());
122
+ if (key) {
123
+ const existing = getInflight(key);
124
+ if (existing) {
125
+ _execute(existing as Promise<T>);
126
+ } else {
127
+ const promise = fetcher();
128
+ setInflight(key, promise as Promise<unknown>);
129
+ _execute(promise);
130
+ }
131
+ } else {
132
+ _execute(fetcher());
133
+ }
75
134
  } catch (err: unknown) {
135
+ _runId++;
76
136
  _error.set(err instanceof Error ? err : new Error(String(err)));
77
137
  _status.set("error");
78
138
  }
79
139
  });
80
140
  }
81
141
 
142
+ let _removeFocusListener: (() => void) | undefined;
143
+ if (refetchOnFocus && typeof document !== "undefined") {
144
+ const onVisibility = (): void => {
145
+ if (document.visibilityState === "visible") {
146
+ execute();
147
+ }
148
+ };
149
+ document.addEventListener("visibilitychange", onVisibility);
150
+ _removeFocusListener = (): void => {
151
+ document.removeEventListener("visibilitychange", onVisibility);
152
+ };
153
+ }
154
+
155
+ function destroy(): void {
156
+ stopEffect?.();
157
+ stopEffect = undefined;
158
+ _removeFocusListener?.();
159
+ _removeFocusListener = undefined;
160
+ if (key) unregisterResource(key, execute);
161
+ }
162
+
82
163
  return {
83
164
  data: computed(() => _data()),
84
165
  pending: computed(() => _status() === "pending"),
@@ -97,6 +178,7 @@ export function resource<T>(
97
178
  _error.set(null);
98
179
  _status.set("success");
99
180
  },
181
+ destroy,
100
182
  };
101
183
  }
102
184
 
@@ -19,7 +19,8 @@ export abstract class RootComponent<T extends object = Record<string, never>> {
19
19
  onBeforeMount?(): void;
20
20
  onMount?(): void;
21
21
  onUnmount?(): void;
22
- onError?(error: Error): void;
22
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
23
+ onError?(error: Error): Node | Node[] | null | undefined | void;
23
24
 
24
25
  abstract render(): Node | Node[] | null;
25
26
  }
package/src/internal.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  syncedSignal,
12
12
  peek,
13
13
  computed,
14
+ writableComputed,
14
15
  batch,
15
16
  effect,
16
17
  untrack,
@@ -25,3 +26,4 @@ export {
25
26
  type Resource,
26
27
  type ResourceOptions,
27
28
  } from "./async/resource";
29
+ export { invalidateResource, _clearCache } from "./async/resource-cache";
@@ -45,7 +45,6 @@ export function debounced<T>(source: Signal<T> | Computed<T>, ms: number) {
45
45
 
46
46
  const stop = effect(() => {
47
47
  const value = source();
48
- if (timeout) clearTimeout(timeout);
49
48
  timeout = setTimeout(() => {
50
49
  current.set(value);
51
50
  timeout = undefined;
@@ -1,4 +1,4 @@
1
- import type { Computed } from "@praxisjs/shared";
1
+ import type { Computed, WritableComputed } from "@praxisjs/shared";
2
2
 
3
3
  import { activeEffect, runEffect, type Effect } from "./effect";
4
4
  import { addSub, removeSub, notifySubs, type SubList } from "./signal";
@@ -70,3 +70,11 @@ export function computed<T>(getter: () => T): Computed<T> {
70
70
  c.__isComputed = true;
71
71
  return c;
72
72
  }
73
+
74
+ export function writableComputed<T>(getter: () => T, setter: (value: T) => void): WritableComputed<T> {
75
+ const c = computed(getter) as WritableComputed<T>;
76
+ c.set = setter;
77
+ c.update = (updater) => { setter(updater(c())); };
78
+ c.__isWritableComputed = true;
79
+ return c;
80
+ }
@@ -1,7 +1,7 @@
1
1
  export { batch } from "./batch";
2
2
  export { peek } from "./peek";
3
3
  export { signal } from "./signal";
4
- export { computed } from "./computed";
4
+ export { computed, writableComputed } from "./computed";
5
5
  export { type PersistedSignalOptions, persistedSignal } from "./persisted";
6
6
  export { type SyncedSignal, syncedSignal } from "./synced";
7
7
  export { effect, untrack } from "./effect";