steddy 0.1.2 → 0.1.3

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
@@ -10,6 +10,34 @@ Explainer: [https://cincinnatus101010.github.io/steddyweb/](https://cincinnatus1
10
10
  npm install steddy
11
11
  ```
12
12
 
13
+ ## App root
14
+
15
+ Wire an isolated runtime once. On the server, create a **new** runtime per request — do not rely on the module singleton.
16
+
17
+ ```tsx
18
+ import { useEffect } from "react";
19
+ import {
20
+ attachDefaults,
21
+ createRuntime,
22
+ SteddyProvider,
23
+ } from "steddy";
24
+
25
+ const runtime = createRuntime();
26
+
27
+ export function AppProviders({ children }: { children: React.ReactNode }) {
28
+ useEffect(() => attachDefaults(runtime.coordinator, runtime.store), []);
29
+ return (
30
+ <SteddyProvider store={runtime.store} coordinator={runtime.coordinator}>
31
+ {children}
32
+ </SteddyProvider>
33
+ );
34
+ }
35
+ ```
36
+
37
+ For Next.js / RSC: fetch on the server, `dump(runtime.store)`, pass `cache={payload}` into `SteddyProvider` on the client. `hydrateAll` preserves timestamps from `dump` so dedup can skip an immediate refetch.
38
+
39
+ ## Hook
40
+
13
41
  ```ts
14
42
  import { useSteddy } from "steddy";
15
43
 
@@ -21,6 +49,7 @@ function Profile({ id }: { id: string }) {
21
49
  if (!response.ok) throw new Error("failed");
22
50
  return response.json();
23
51
  },
52
+ { keepPreviousData: true, staleTime: 60_000 },
24
53
  );
25
54
 
26
55
  if (error) return <p>Failed to load</p>;
@@ -35,17 +64,39 @@ function Profile({ id }: { id: string }) {
35
64
  }
36
65
  ```
37
66
 
38
- `key === null` skips fetching. Plugins (`focusRevalidate`, `reconnectRevalidate`, `pollingRevalidate`, `retryOnError`) are opt-in named exports.
67
+ `key === null` skips fetching. Options: `{ suspense: true }`, `{ keepPreviousData: true }`, `{ staleTime }` (default 2000ms dedup window).
68
+
69
+ ## Helpers
39
70
 
40
71
  ```ts
41
- import { defaultCoordinator, focusRevalidate, hydrate, clear } from "steddy";
72
+ import {
73
+ createRuntime,
74
+ dump,
75
+ hydrateAll,
76
+ prefetch,
77
+ clear,
78
+ } from "steddy";
79
+
80
+ // Route loader / link hover — no mounted hook required
81
+ await prefetch(["user", id], fetchUser, runtime);
82
+
83
+ clear("user", runtime);
84
+ ```
85
+
86
+ ## Plugins (opt-in)
42
87
 
43
- hydrate("user", { name: "Ada" });
44
- focusRevalidate(defaultCoordinator);
45
- clear("user");
88
+ ```ts
89
+ import { attachDefaults, focusRevalidate, measurePerf } from "steddy";
90
+
91
+ // Or wire individually — pass store so focus/reconnect only hit subscribed keys
92
+ focusRevalidate(coordinator, store);
93
+
94
+ const perf = measurePerf(coordinator);
46
95
  ```
47
96
 
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.
97
+ Also: `reconnectRevalidate`, `pollingRevalidate`, `retryOnError` (fetcher wrapper), `ttlEvict`, `useSteddyInfinite`.
98
+
99
+ `measurePerf(coordinator)` returns `{ starts, aborts, dedups, writes, errors, totalMs }`. Run `npm run bench` for coordinator microbenchmarks.
49
100
 
50
101
  ## Architecture
51
102
 
package/dist/index.cjs CHANGED
@@ -57,9 +57,13 @@ function createCoordinator(store) {
57
57
  }
58
58
  }
59
59
  const coordinator = {
60
- register(serializedKey, key, fetcher) {
60
+ register(serializedKey, key, fetcher, options) {
61
61
  cancelRelease(serializedKey);
62
- registered.set(serializedKey, { key, fetcher });
62
+ registered.set(serializedKey, {
63
+ key,
64
+ fetcher,
65
+ staleTime: options?.staleTime ?? DEDUP_WINDOW_MS
66
+ });
63
67
  },
64
68
  unregister(serializedKey) {
65
69
  cancelRelease(serializedKey);
@@ -80,7 +84,8 @@ function createCoordinator(store) {
80
84
  async revalidate(serializedKey, fetcher, options) {
81
85
  if (!options?.force) {
82
86
  const entry = store.get(serializedKey);
83
- if (entry && !entry.isValidating && entry.error == null && entry.data !== void 0 && Date.now() - entry.timestamp < DEDUP_WINDOW_MS) {
87
+ const staleTime = registered.get(serializedKey)?.staleTime ?? DEDUP_WINDOW_MS;
88
+ if (entry && !entry.isValidating && entry.error == null && entry.hasData && Date.now() - entry.timestamp < staleTime) {
84
89
  emit({ type: "dedup", key: serializedKey });
85
90
  return;
86
91
  }
@@ -117,6 +122,7 @@ function createCoordinator(store) {
117
122
  const previous = store.get(serializedKey);
118
123
  store.set(serializedKey, {
119
124
  data: previous?.data,
125
+ hasData: previous?.hasData ?? false,
120
126
  error: previous?.error,
121
127
  timestamp: previous?.timestamp ?? 0,
122
128
  isValidating: true
@@ -130,6 +136,7 @@ function createCoordinator(store) {
130
136
  inflight.delete(serializedKey);
131
137
  store.set(serializedKey, {
132
138
  data,
139
+ hasData: true,
133
140
  error: void 0,
134
141
  timestamp: Date.now(),
135
142
  isValidating: false
@@ -148,6 +155,7 @@ function createCoordinator(store) {
148
155
  const current = store.get(serializedKey);
149
156
  store.set(serializedKey, {
150
157
  data: current?.data,
158
+ hasData: current?.hasData ?? false,
151
159
  error,
152
160
  timestamp: current?.timestamp ?? 0,
153
161
  isValidating: false
@@ -240,6 +248,7 @@ function createCoordinator(store) {
240
248
  // src/store.ts
241
249
  var EMPTY_SNAPSHOT = Object.freeze({
242
250
  data: void 0,
251
+ hasData: false,
243
252
  error: void 0,
244
253
  timestamp: 0,
245
254
  isValidating: false
@@ -261,6 +270,10 @@ function createStore() {
261
270
  return entries.get(key);
262
271
  },
263
272
  set(key, entry) {
273
+ const existing = entries.get(key);
274
+ if (existing && Object.is(existing.data, entry.data) && existing.hasData === entry.hasData && existing.error === entry.error && existing.timestamp === entry.timestamp && existing.isValidating === entry.isValidating) {
275
+ return;
276
+ }
264
277
  entries.set(key, entry);
265
278
  emit(key);
266
279
  },
@@ -362,6 +375,7 @@ function createMutate(store, coordinator) {
362
375
  if (isThenable(next)) {
363
376
  store.set(serialized, {
364
377
  data: currentData,
378
+ hasData: previous?.hasData ?? false,
365
379
  error: previous?.error,
366
380
  timestamp: previous?.timestamp ?? 0,
367
381
  isValidating: true
@@ -369,6 +383,7 @@ function createMutate(store, coordinator) {
369
383
  const resolved = await next;
370
384
  store.set(serialized, {
371
385
  data: resolved,
386
+ hasData: true,
372
387
  error: void 0,
373
388
  timestamp: Date.now(),
374
389
  isValidating: revalidate
@@ -376,6 +391,7 @@ function createMutate(store, coordinator) {
376
391
  } else {
377
392
  store.set(serialized, {
378
393
  data: next,
394
+ hasData: true,
379
395
  error: void 0,
380
396
  timestamp: Date.now(),
381
397
  isValidating: revalidate
@@ -392,6 +408,7 @@ function createMutate(store, coordinator) {
392
408
  const current = store.get(serialized);
393
409
  store.set(serialized, {
394
410
  data: current?.data,
411
+ hasData: current?.hasData ?? false,
395
412
  error,
396
413
  timestamp: current?.timestamp ?? Date.now(),
397
414
  isValidating: false
@@ -423,24 +440,44 @@ function SteddyProvider({
423
440
  cache,
424
441
  children
425
442
  }) {
426
- const value = react.useMemo(() => {
427
- if (cache) {
428
- hydrateAll(cache, store);
443
+ const hydratedFingerprint = react.useRef(null);
444
+ react.useEffect(() => {
445
+ if (!cache) {
446
+ hydratedFingerprint.current = null;
447
+ return;
429
448
  }
430
- return {
449
+ const fingerprint = JSON.stringify(cache);
450
+ if (hydratedFingerprint.current === fingerprint) {
451
+ return;
452
+ }
453
+ hydratedFingerprint.current = fingerprint;
454
+ hydrateAll(cache, store);
455
+ }, [cache, store]);
456
+ const value = react.useMemo(
457
+ () => ({
431
458
  store,
432
459
  coordinator,
433
460
  mutate: createMutate(store, coordinator)
434
- };
435
- }, [store, coordinator, cache]);
461
+ }),
462
+ [store, coordinator]
463
+ );
436
464
  return /* @__PURE__ */ jsxRuntime.jsx(SteddyContext.Provider, { value, children });
437
465
  }
466
+ var warnedDefaultOnServer = false;
438
467
  function useSteddyRuntime() {
439
- return react.useContext(SteddyContext);
468
+ const runtime = react.useContext(SteddyContext);
469
+ if (process.env.NODE_ENV !== "production" && typeof window === "undefined" && runtime === defaultRuntime && !warnedDefaultOnServer) {
470
+ warnedDefaultOnServer = true;
471
+ console.warn(
472
+ "[steddy] useSteddy on the server without SteddyProvider shares one cache across requests. Use createRuntime() per request."
473
+ );
474
+ }
475
+ return runtime;
440
476
  }
441
477
  function hydrate(key, data, store = defaultStore) {
442
478
  store.set(serializeKey(key), {
443
479
  data,
480
+ hasData: true,
444
481
  error: void 0,
445
482
  timestamp: 0,
446
483
  isValidating: false
@@ -450,7 +487,7 @@ function dump(store = defaultStore) {
450
487
  const snapshot = {};
451
488
  for (const key of store.keys()) {
452
489
  const entry = store.get(key);
453
- if (!entry || entry.data === void 0) {
490
+ if (!entry?.hasData) {
454
491
  continue;
455
492
  }
456
493
  snapshot[key] = { data: entry.data, timestamp: entry.timestamp };
@@ -461,12 +498,23 @@ function hydrateAll(snapshot, store = defaultStore) {
461
498
  for (const [key, payload] of Object.entries(snapshot)) {
462
499
  store.set(key, {
463
500
  data: payload.data,
501
+ hasData: true,
464
502
  error: void 0,
465
- timestamp: 0,
503
+ timestamp: payload.timestamp,
466
504
  isValidating: false
467
505
  });
468
506
  }
469
507
  }
508
+ async function prefetch(key, fetcher, runtime = defaultRuntime) {
509
+ const serialized = serializeKey(key);
510
+ runtime.coordinator.register(serialized, key, fetcher);
511
+ try {
512
+ await runtime.coordinator.revalidate(serialized);
513
+ } catch {
514
+ } finally {
515
+ runtime.coordinator.unregister(serialized);
516
+ }
517
+ }
470
518
  function clear(key, runtime = defaultRuntime) {
471
519
  if (key === void 0) {
472
520
  for (const active of runtime.coordinator.getRegisteredKeys()) {
@@ -505,11 +553,15 @@ function useSteddy(key, fetcher, options) {
505
553
  fetcherRef.current = fetcher;
506
554
  const keyRef = react.useRef(key);
507
555
  keyRef.current = key;
556
+ const staleTime = options?.staleTime ?? DEDUP_WINDOW_MS;
557
+ const staleTimeRef = react.useRef(staleTime);
558
+ staleTimeRef.current = staleTime;
508
559
  if (serialized != null && key != null) {
509
560
  coordinator.register(
510
561
  serialized,
511
562
  key,
512
- (k, ctx) => fetcherRef.current(k, ctx)
563
+ (k, ctx) => fetcherRef.current(k, ctx),
564
+ { staleTime: staleTimeRef.current }
513
565
  );
514
566
  }
515
567
  const subscribe = react.useCallback(
@@ -522,7 +574,8 @@ function useSteddy(key, fetcher, options) {
522
574
  coordinator.register(
523
575
  serialized,
524
576
  originalKey,
525
- (k, ctx) => fetcherRef.current(k, ctx)
577
+ (k, ctx) => fetcherRef.current(k, ctx),
578
+ { staleTime: staleTimeRef.current }
526
579
  );
527
580
  const unsubscribe = store.subscribe(serialized, onStoreChange);
528
581
  if (!coordinator.isInFlight(serialized)) {
@@ -558,15 +611,15 @@ function useSteddy(key, fetcher, options) {
558
611
  const previousRef = react.useRef(
559
612
  void 0
560
613
  );
561
- if (serialized != null && snapshot.data !== void 0) {
614
+ if (serialized != null && snapshot.hasData) {
562
615
  previousRef.current = { serialized, data: snapshot.data };
563
616
  }
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;
617
+ const data = keepPreviousData && serialized != null && !snapshot.hasData && snapshot.error == null && previousRef.current != null && previousRef.current.serialized !== serialized ? previousRef.current.data : snapshot.data;
565
618
  if (options?.suspense && serialized != null) {
566
619
  if (snapshot.error != null) {
567
620
  throw snapshot.error;
568
621
  }
569
- if (snapshot.data === void 0 && data === void 0) {
622
+ if (!snapshot.hasData && data === void 0) {
570
623
  const waiter = coordinator.getInFlightPromise(serialized) ?? coordinator.revalidate(serialized).catch(() => {
571
624
  });
572
625
  throw waiter;
@@ -575,7 +628,7 @@ function useSteddy(key, fetcher, options) {
575
628
  return {
576
629
  data,
577
630
  error: snapshot.error,
578
- isLoading: serialized != null && data === void 0 && snapshot.error == null,
631
+ isLoading: serialized != null && !snapshot.hasData && snapshot.error == null && data === void 0,
579
632
  isValidating: snapshot.isValidating,
580
633
  mutate: boundMutate
581
634
  };
@@ -666,7 +719,7 @@ function useSteddyInfinite(getKey, fetcher, options) {
666
719
  );
667
720
  const fingerprint = currentPages.map((page) => {
668
721
  const entry = store.getSnapshot(page.serialized);
669
- return `${page.serialized}:${entry.timestamp}:${entry.isValidating ? 1 : 0}:${entry.error == null ? 0 : 1}:${entry.data === void 0 ? 0 : 1}`;
722
+ return `${page.serialized}:${entry.timestamp}:${entry.isValidating ? 1 : 0}:${entry.error == null ? 0 : 1}:${entry.hasData ? 1 : 0}`;
670
723
  }).join("|");
671
724
  if (snapshotRef.current.fingerprint === fingerprint) {
672
725
  return snapshotRef.current;
@@ -683,7 +736,7 @@ function useSteddyInfinite(getKey, fetcher, options) {
683
736
  if (entry.error != null && error === void 0) {
684
737
  error = entry.error;
685
738
  }
686
- if (entry.data === void 0) {
739
+ if (!entry.hasData) {
687
740
  missing = true;
688
741
  break;
689
742
  }
@@ -750,6 +803,7 @@ function useSteddyInfinite(getKey, fetcher, options) {
750
803
  }
751
804
  store.set(page.serialized, {
752
805
  data: pagesValue[index],
806
+ hasData: true,
753
807
  error: void 0,
754
808
  timestamp: Date.now(),
755
809
  isValidating: revalidate
@@ -779,7 +833,7 @@ function useSteddyInfinite(getKey, fetcher, options) {
779
833
  return {
780
834
  data: snapshot.data,
781
835
  error: snapshot.error,
782
- isLoading: first != null && firstEntry?.data === void 0 && firstEntry?.error == null,
836
+ isLoading: first != null && !firstEntry?.hasData && firstEntry?.error == null,
783
837
  isValidating: snapshot.isValidating,
784
838
  size,
785
839
  setSize,
@@ -787,23 +841,37 @@ function useSteddyInfinite(getKey, fetcher, options) {
787
841
  };
788
842
  }
789
843
 
844
+ // src/plugins/subscribedKeys.ts
845
+ function keysWithSubscribers(keys, store) {
846
+ return keys.filter((key) => store.subscriberCount(key) > 0);
847
+ }
848
+
790
849
  // src/plugins/focus.ts
791
- function focusRevalidate(coordinator) {
792
- const onFocus = () => {
793
- for (const key of coordinator.getRegisteredKeys()) {
850
+ function focusRevalidate(coordinator, store) {
851
+ const run = () => {
852
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") {
853
+ return;
854
+ }
855
+ const keys = store ? keysWithSubscribers(coordinator.getRegisteredKeys(), store) : coordinator.getRegisteredKeys();
856
+ for (const key of keys) {
794
857
  void coordinator.revalidate(key);
795
858
  }
796
859
  };
797
- window.addEventListener("focus", onFocus);
860
+ if (typeof document === "undefined") {
861
+ return () => {
862
+ };
863
+ }
864
+ document.addEventListener("visibilitychange", run);
798
865
  return () => {
799
- window.removeEventListener("focus", onFocus);
866
+ document.removeEventListener("visibilitychange", run);
800
867
  };
801
868
  }
802
869
 
803
870
  // src/plugins/reconnect.ts
804
- function reconnectRevalidate(coordinator) {
871
+ function reconnectRevalidate(coordinator, store) {
805
872
  const onOnline = () => {
806
- for (const key of coordinator.getRegisteredKeys()) {
873
+ const keys = store ? keysWithSubscribers(coordinator.getRegisteredKeys(), store) : coordinator.getRegisteredKeys();
874
+ for (const key of keys) {
807
875
  void coordinator.revalidate(key);
808
876
  }
809
877
  };
@@ -927,9 +995,24 @@ function measurePerf(coordinator, onEvent) {
927
995
  };
928
996
  }
929
997
 
998
+ // src/plugins/attachDefaults.ts
999
+ function attachDefaults(coordinator, store, options) {
1000
+ const stops = [
1001
+ focusRevalidate(coordinator, store),
1002
+ reconnectRevalidate(coordinator, store),
1003
+ ttlEvict(coordinator, options?.ttl ?? { maxAge: 3e5 })
1004
+ ];
1005
+ return () => {
1006
+ for (const stop of stops) {
1007
+ stop();
1008
+ }
1009
+ };
1010
+ }
1011
+
930
1012
  exports.DEDUP_WINDOW_MS = DEDUP_WINDOW_MS;
931
1013
  exports.SteddyProvider = SteddyProvider;
932
1014
  exports.UNSUBSCRIBE_GRACE_MS = UNSUBSCRIBE_GRACE_MS;
1015
+ exports.attachDefaults = attachDefaults;
933
1016
  exports.clear = clear;
934
1017
  exports.createCoordinator = createCoordinator;
935
1018
  exports.createMutate = createMutate;
@@ -944,6 +1027,7 @@ exports.hydrateAll = hydrateAll;
944
1027
  exports.measurePerf = measurePerf;
945
1028
  exports.mutate = mutate;
946
1029
  exports.pollingRevalidate = pollingRevalidate;
1030
+ exports.prefetch = prefetch;
947
1031
  exports.reconnectRevalidate = reconnectRevalidate;
948
1032
  exports.retryOnError = retryOnError;
949
1033
  exports.serializeKey = serializeKey;