steddy 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ focusRevalidate(defaultCoordinator);
45
45
  clear("user");
46
46
  ```
47
47
 
48
- Need an isolated cache (tests, multiple trees, SSR): wrap with `SteddyProvider` and pass `createStore()` + `createCoordinator(store)`, or `createRuntime()`. Pass `cache={dump(store)}` across an RSC boundary. `{ suspense: true }` throws the in-flight waiter. `useSteddyInfinite` keeps one cache entry per page. `ttlEvict` drops unused keys.
48
+ Need an isolated cache (tests, multiple trees, SSR): wrap with `SteddyProvider` and pass `createStore()` + `createCoordinator(store)`, or `createRuntime()`. Pass `cache={dump(store)}` across an RSC boundary. `{ suspense: true }` throws the in-flight waiter. `{ keepPreviousData: true }` keeps the last value on screen while a new key loads. `useSteddyInfinite` keeps one cache entry per page; `mutate` writes every page, and `getKey` stops when a later page would reuse an earlier key. `ttlEvict` drops unused keys. `measurePerf(coordinator)` subscribes to fetch events and returns `{ starts, aborts, dedups, writes, errors, totalMs }`. Run `npm run bench` for coordinator microbenchmarks.
49
49
 
50
50
  ## Architecture
51
51
 
@@ -55,7 +55,7 @@ plugins → coordinator → store
55
55
  hooks
56
56
  ```
57
57
 
58
- A new request for the same key **aborts** the in-flight one. Aborted requests do not write to the store. Optimistic `mutate` rolls back on error by default.
58
+ A new request for the same key **aborts** the in-flight one. Aborted requests do not write to the store. The last subscriber’s unmount delays that abort by a tick so a remount can reuse the waiter. Optimistic `mutate` rolls back on error by default.
59
59
 
60
60
  ## License
61
61
 
package/dist/index.cjs CHANGED
@@ -7,18 +7,45 @@ var jsxRuntime = require('react/jsx-runtime');
7
7
 
8
8
  // src/coordinator.ts
9
9
  var DEDUP_WINDOW_MS = 2e3;
10
+ var UNSUBSCRIBE_GRACE_MS = 0;
11
+ function now() {
12
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
13
+ }
10
14
  function isAbortError(error) {
11
15
  return typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
12
16
  }
13
17
  function createCoordinator(store) {
14
18
  const registered = /* @__PURE__ */ new Map();
15
19
  const inflight = /* @__PURE__ */ new Map();
20
+ const releaseTimers = /* @__PURE__ */ new Map();
21
+ const listeners = /* @__PURE__ */ new Set();
16
22
  let generation = 0;
23
+ function emit(event) {
24
+ if (listeners.size === 0) {
25
+ return;
26
+ }
27
+ for (const listener of listeners) {
28
+ listener(event);
29
+ }
30
+ }
31
+ function cancelRelease(serializedKey) {
32
+ const timer = releaseTimers.get(serializedKey);
33
+ if (timer == null) {
34
+ return;
35
+ }
36
+ clearTimeout(timer);
37
+ releaseTimers.delete(serializedKey);
38
+ }
17
39
  function abortInternal(serializedKey, markIdle) {
18
40
  const current = inflight.get(serializedKey);
19
41
  if (!current) {
20
42
  return;
21
43
  }
44
+ emit({
45
+ type: "abort",
46
+ key: serializedKey,
47
+ ms: now() - current.startedAt
48
+ });
22
49
  current.controller.abort();
23
50
  current.settle();
24
51
  inflight.delete(serializedKey);
@@ -31,15 +58,30 @@ function createCoordinator(store) {
31
58
  }
32
59
  const coordinator = {
33
60
  register(serializedKey, key, fetcher) {
61
+ cancelRelease(serializedKey);
34
62
  registered.set(serializedKey, { key, fetcher });
35
63
  },
36
64
  unregister(serializedKey) {
65
+ cancelRelease(serializedKey);
37
66
  registered.delete(serializedKey);
38
67
  },
68
+ scheduleRelease(serializedKey) {
69
+ cancelRelease(serializedKey);
70
+ const timer = setTimeout(() => {
71
+ releaseTimers.delete(serializedKey);
72
+ if (store.subscriberCount(serializedKey) > 0) {
73
+ return;
74
+ }
75
+ abortInternal(serializedKey, true);
76
+ registered.delete(serializedKey);
77
+ }, UNSUBSCRIBE_GRACE_MS);
78
+ releaseTimers.set(serializedKey, timer);
79
+ },
39
80
  async revalidate(serializedKey, fetcher, options) {
40
81
  if (!options?.force) {
41
82
  const entry = store.get(serializedKey);
42
83
  if (entry && !entry.isValidating && entry.error == null && entry.data !== void 0 && Date.now() - entry.timestamp < DEDUP_WINDOW_MS) {
84
+ emit({ type: "dedup", key: serializedKey });
43
85
  return;
44
86
  }
45
87
  }
@@ -63,12 +105,15 @@ function createCoordinator(store) {
63
105
  settleWaiter();
64
106
  }
65
107
  };
108
+ const startedAt = now();
66
109
  inflight.set(serializedKey, {
67
110
  controller,
68
111
  generation: currentGeneration,
69
112
  waiter,
70
- settle
113
+ settle,
114
+ startedAt
71
115
  });
116
+ emit({ type: "start", key: serializedKey });
72
117
  const previous = store.get(serializedKey);
73
118
  store.set(serializedKey, {
74
119
  data: previous?.data,
@@ -89,6 +134,11 @@ function createCoordinator(store) {
89
134
  timestamp: Date.now(),
90
135
  isValidating: false
91
136
  });
137
+ emit({
138
+ type: "write",
139
+ key: serializedKey,
140
+ ms: now() - startedAt
141
+ });
92
142
  } catch (error) {
93
143
  const stillCurrent = inflight.get(serializedKey)?.generation === currentGeneration;
94
144
  if (!stillCurrent || controller.signal.aborted || isAbortError(error)) {
@@ -102,6 +152,11 @@ function createCoordinator(store) {
102
152
  timestamp: current?.timestamp ?? 0,
103
153
  isValidating: false
104
154
  });
155
+ emit({
156
+ type: "error",
157
+ key: serializedKey,
158
+ ms: now() - startedAt
159
+ });
105
160
  throw error;
106
161
  } finally {
107
162
  settle();
@@ -117,10 +172,11 @@ function createCoordinator(store) {
117
172
  return [...registered.keys()];
118
173
  },
119
174
  abort(serializedKey) {
175
+ cancelRelease(serializedKey);
120
176
  abortInternal(serializedKey, true);
121
177
  },
122
178
  evict(options) {
123
- const now = Date.now();
179
+ const now2 = Date.now();
124
180
  const removable = [];
125
181
  for (const key of store.keys()) {
126
182
  if (inflight.has(key) || store.subscriberCount(key) > 0) {
@@ -135,7 +191,7 @@ function createCoordinator(store) {
135
191
  const victims = /* @__PURE__ */ new Set();
136
192
  if (options.maxAge != null) {
137
193
  for (const item of removable) {
138
- if (now - item.timestamp >= options.maxAge) {
194
+ if (now2 - item.timestamp >= options.maxAge) {
139
195
  victims.add(item.key);
140
196
  }
141
197
  }
@@ -155,6 +211,7 @@ function createCoordinator(store) {
155
211
  }
156
212
  }
157
213
  for (const key of victims) {
214
+ cancelRelease(key);
158
215
  abortInternal(key, false);
159
216
  registered.delete(key);
160
217
  store.delete(key);
@@ -162,10 +219,19 @@ function createCoordinator(store) {
162
219
  return [...victims];
163
220
  },
164
221
  reset() {
222
+ for (const key of [...releaseTimers.keys()]) {
223
+ cancelRelease(key);
224
+ }
165
225
  for (const key of [...inflight.keys()]) {
166
226
  abortInternal(key, false);
167
227
  }
168
228
  registered.clear();
229
+ },
230
+ subscribe(listener) {
231
+ listeners.add(listener);
232
+ return () => {
233
+ listeners.delete(listener);
234
+ };
169
235
  }
170
236
  };
171
237
  return coordinator;
@@ -466,8 +532,7 @@ function useSteddy(key, fetcher, options) {
466
532
  return () => {
467
533
  unsubscribe();
468
534
  if (store.subscriberCount(serialized) === 0) {
469
- coordinator.abort(serialized);
470
- coordinator.unregister(serialized);
535
+ coordinator.scheduleRelease(serialized);
471
536
  }
472
537
  };
473
538
  },
@@ -489,26 +554,38 @@ function useSteddy(key, fetcher, options) {
489
554
  },
490
555
  [runtimeMutate]
491
556
  );
557
+ const keepPreviousData = options?.keepPreviousData === true;
558
+ const previousRef = react.useRef(
559
+ void 0
560
+ );
561
+ if (serialized != null && snapshot.data !== void 0) {
562
+ previousRef.current = { serialized, data: snapshot.data };
563
+ }
564
+ const data = keepPreviousData && serialized != null && snapshot.data === void 0 && snapshot.error == null && previousRef.current != null && previousRef.current.serialized !== serialized ? previousRef.current.data : snapshot.data;
492
565
  if (options?.suspense && serialized != null) {
493
566
  if (snapshot.error != null) {
494
567
  throw snapshot.error;
495
568
  }
496
- if (snapshot.data === void 0) {
569
+ if (snapshot.data === void 0 && data === void 0) {
497
570
  const waiter = coordinator.getInFlightPromise(serialized) ?? coordinator.revalidate(serialized).catch(() => {
498
571
  });
499
572
  throw waiter;
500
573
  }
501
574
  }
502
575
  return {
503
- data: snapshot.data,
576
+ data,
504
577
  error: snapshot.error,
505
- isLoading: serialized != null && snapshot.data === void 0 && snapshot.error == null,
578
+ isLoading: serialized != null && data === void 0 && snapshot.error == null,
506
579
  isValidating: snapshot.isValidating,
507
580
  mutate: boundMutate
508
581
  };
509
582
  }
583
+ function isThenable2(value) {
584
+ return typeof value === "object" && value !== null && typeof value.then === "function";
585
+ }
510
586
  function collectPages(getKey, getData, size) {
511
587
  const pages = [];
588
+ const seen = /* @__PURE__ */ new Set();
512
589
  let previous;
513
590
  for (let index = 0; index < size; index++) {
514
591
  const key = getKey(index, previous);
@@ -516,13 +593,17 @@ function collectPages(getKey, getData, size) {
516
593
  break;
517
594
  }
518
595
  const serialized = serializeKey(key);
596
+ if (seen.has(serialized)) {
597
+ break;
598
+ }
599
+ seen.add(serialized);
519
600
  pages.push({ key, serialized });
520
601
  previous = getData(serialized);
521
602
  }
522
603
  return pages;
523
604
  }
524
605
  function useSteddyInfinite(getKey, fetcher, options) {
525
- const { store, coordinator, mutate: runtimeMutate } = useSteddyRuntime();
606
+ const { store, coordinator } = useSteddyRuntime();
526
607
  const [size, setSizeState] = react.useState(options?.initialSize ?? 1);
527
608
  const fetcherRef = react.useRef(fetcher);
528
609
  fetcherRef.current = fetcher;
@@ -564,8 +645,7 @@ function useSteddyInfinite(getKey, fetcher, options) {
564
645
  for (const [index, page] of currentPages.entries()) {
565
646
  unsubscribes[index]?.();
566
647
  if (store.subscriberCount(page.serialized) === 0) {
567
- coordinator.abort(page.serialized);
568
- coordinator.unregister(page.serialized);
648
+ coordinator.scheduleRelease(page.serialized);
569
649
  }
570
650
  }
571
651
  };
@@ -628,32 +708,71 @@ function useSteddyInfinite(getKey, fetcher, options) {
628
708
  );
629
709
  const boundMutate = react.useCallback(
630
710
  async (updater, mutateOptions) => {
711
+ const revalidate = mutateOptions?.revalidate ?? true;
712
+ const rollbackOnError = mutateOptions?.rollbackOnError ?? true;
631
713
  const currentPages = collectPages(
632
714
  getKeyRef.current,
633
715
  (serialized) => store.get(serialized)?.data,
634
716
  size
635
717
  );
636
- const first2 = currentPages[0];
637
- if (!first2) {
718
+ if (currentPages.length === 0) {
638
719
  return void 0;
639
720
  }
640
- if (updater !== void 0 && typeof updater !== "function") {
641
- const pagesValue = updater;
642
- const firstPage = pagesValue[0];
643
- if (firstPage !== void 0) {
644
- await runtimeMutate(first2.key, firstPage, mutateOptions);
721
+ const previous = currentPages.map((page) => ({
722
+ serialized: page.serialized,
723
+ entry: store.get(page.serialized)
724
+ }));
725
+ const restore = () => {
726
+ for (const item of previous) {
727
+ if (item.entry === void 0) {
728
+ store.delete(item.serialized);
729
+ } else {
730
+ store.set(item.serialized, item.entry);
731
+ }
645
732
  }
646
- return pagesValue;
733
+ };
734
+ try {
735
+ const loaded = [];
736
+ for (const page of currentPages) {
737
+ const pageData = store.get(page.serialized)?.data;
738
+ if (pageData === void 0) {
739
+ break;
740
+ }
741
+ loaded.push(pageData);
742
+ }
743
+ const current = loaded.length === 0 ? void 0 : loaded;
744
+ const next = typeof updater === "function" ? updater(current) : updater;
745
+ const resolved = isThenable2(next) ? await next : next;
746
+ const pagesValue = resolved;
747
+ for (const [index, page] of currentPages.entries()) {
748
+ if (index >= pagesValue.length) {
749
+ break;
750
+ }
751
+ store.set(page.serialized, {
752
+ data: pagesValue[index],
753
+ error: void 0,
754
+ timestamp: Date.now(),
755
+ isValidating: revalidate
756
+ });
757
+ }
758
+ if (revalidate) {
759
+ await Promise.all(
760
+ currentPages.map(
761
+ (page) => coordinator.revalidate(page.serialized, void 0, {
762
+ force: true
763
+ })
764
+ )
765
+ );
766
+ }
767
+ return currentPages.map((page) => store.get(page.serialized)?.data).filter((page) => page !== void 0);
768
+ } catch (error) {
769
+ if (rollbackOnError) {
770
+ restore();
771
+ }
772
+ throw error;
647
773
  }
648
- await Promise.all(
649
- currentPages.map(
650
- (page) => coordinator.revalidate(page.serialized, void 0, { force: true }).catch(() => {
651
- })
652
- )
653
- );
654
- return currentPages.map((page) => store.get(page.serialized)?.data).filter((page) => page !== void 0);
655
774
  },
656
- [coordinator, runtimeMutate, size, store]
775
+ [coordinator, size, store]
657
776
  );
658
777
  const first = pages[0];
659
778
  const firstEntry = first ? store.getSnapshot(first.serialized) : void 0;
@@ -761,8 +880,56 @@ function ttlEvict(coordinator, options) {
761
880
  };
762
881
  }
763
882
 
883
+ // src/plugins/measure.ts
884
+ function measurePerf(coordinator, onEvent) {
885
+ const snap = {
886
+ starts: 0,
887
+ aborts: 0,
888
+ dedups: 0,
889
+ writes: 0,
890
+ errors: 0,
891
+ totalMs: 0
892
+ };
893
+ const stop = coordinator.subscribe((event) => {
894
+ switch (event.type) {
895
+ case "start":
896
+ snap.starts += 1;
897
+ break;
898
+ case "abort":
899
+ snap.aborts += 1;
900
+ snap.totalMs += event.ms;
901
+ break;
902
+ case "dedup":
903
+ snap.dedups += 1;
904
+ break;
905
+ case "write":
906
+ snap.writes += 1;
907
+ snap.totalMs += event.ms;
908
+ break;
909
+ case "error":
910
+ snap.errors += 1;
911
+ snap.totalMs += event.ms;
912
+ break;
913
+ }
914
+ onEvent?.(event);
915
+ });
916
+ return {
917
+ snapshot: () => ({ ...snap }),
918
+ reset() {
919
+ snap.starts = 0;
920
+ snap.aborts = 0;
921
+ snap.dedups = 0;
922
+ snap.writes = 0;
923
+ snap.errors = 0;
924
+ snap.totalMs = 0;
925
+ },
926
+ stop
927
+ };
928
+ }
929
+
764
930
  exports.DEDUP_WINDOW_MS = DEDUP_WINDOW_MS;
765
931
  exports.SteddyProvider = SteddyProvider;
932
+ exports.UNSUBSCRIBE_GRACE_MS = UNSUBSCRIBE_GRACE_MS;
766
933
  exports.clear = clear;
767
934
  exports.createCoordinator = createCoordinator;
768
935
  exports.createMutate = createMutate;
@@ -774,6 +941,7 @@ exports.dump = dump;
774
941
  exports.focusRevalidate = focusRevalidate;
775
942
  exports.hydrate = hydrate;
776
943
  exports.hydrateAll = hydrateAll;
944
+ exports.measurePerf = measurePerf;
777
945
  exports.mutate = mutate;
778
946
  exports.pollingRevalidate = pollingRevalidate;
779
947
  exports.reconnectRevalidate = reconnectRevalidate;