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 +2 -2
- package/dist/index.cjs +195 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -1
- package/dist/index.d.ts +41 -1
- package/dist/index.js +194 -28
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|
|
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 (
|
|
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.
|
|
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
|
|
576
|
+
data,
|
|
504
577
|
error: snapshot.error,
|
|
505
|
-
isLoading: serialized != 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
|
|
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.
|
|
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
|
-
|
|
637
|
-
if (!first2) {
|
|
718
|
+
if (currentPages.length === 0) {
|
|
638
719
|
return void 0;
|
|
639
720
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|