wenay-react2 1.0.31 → 1.0.33

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.
@@ -2,6 +2,7 @@ import { ObserveAll2, Replay } from "wenay-common2";
2
2
  type StorePatch = ObserveAll2.StorePatch;
3
3
  type ReplayEvent<Z extends any[]> = Replay.ReplayEvent<Z>;
4
4
  type ReplayRemote<Z extends any[]> = Replay.ReplayRemote<Z>;
5
+ type StaleInfo = Replay.StaleInfo;
5
6
  export type UseReplaySubscribeOptions = {
6
7
  /** Start position for the first subscription: journal tail after this seq; omit = keyframe. */
7
8
  since?: number;
@@ -11,13 +12,30 @@ export type UseReplaySubscribeOptions = {
11
12
  enabled?: boolean;
12
13
  onSeq?: (seq: number) => void;
13
14
  onError?: (e: unknown) => void;
15
+ /**
16
+ * Producer-freshness watchdog (detection lives in wenay-common2): no event with a fresh
17
+ * producer ts for staleMs -> `stale` flips true, flips back on resume. Omitted = zero cost
18
+ * (no watchdog, no extra state updates). Changing staleMs resubscribes (reconnects by tail
19
+ * under keepSeq, so it is cheap).
20
+ */
21
+ staleMs?: number;
22
+ /** Edge-triggered mirror of common2's onStale (fresh<->stale transitions only). Goes through a ref — a new identity does not resubscribe. */
23
+ onStale?: (info: StaleInfo) => void;
14
24
  };
15
25
  export type ReplaySubscribeController = {
16
26
  /** Keyframe/tail catch-up finished, events are live from here. */
17
27
  readonly ready: boolean;
18
28
  readonly error: unknown;
29
+ /**
30
+ * Line is stale by the staleMs watchdog. React state, but it updates ONLY on fresh<->stale
31
+ * transitions — a high-frequency line causes zero extra renders while fresh.
32
+ * Always false without staleMs.
33
+ */
34
+ readonly stale: boolean;
19
35
  /** Last seen seq (reconnect point). Getter — reading it does not re-render. */
20
36
  seq(): number;
37
+ /** Producer ts of the last delivered event (0 before the first delivery). Getter — reading it does not re-render. */
38
+ lastTs(): number;
21
39
  /** Drop and re-create the subscription. since omitted = continue by keepSeq rules; a number = explicit position. */
22
40
  restart(since?: number): void;
23
41
  };
@@ -11,7 +11,9 @@ import { ObserveAll2, Replay } from "wenay-common2";
11
11
  * (ref/store/canvas). If the consumer state resets on resubscribe, pass {keepSeq: false};
12
12
  * - a FULL unmount loses the hook's refs: to reconnect by tail after a remount, keep the position
13
13
  * outside via onSeq and pass it back as `since` (see the QA card for the pattern);
14
- * - a different `remote` identity always starts from scratch (seq from another line is meaningless).
14
+ * - a different `remote` identity always starts from scratch (seq from another line is meaningless);
15
+ * - freshness (staleMs/onStale, controller.stale/lastTs()) is detected by wenay-common2;
16
+ * the hooks only mirror the fresh<->stale edges into React state.
15
17
  *
16
18
  * Server-side parts of the stack (conflateReplay, archiveReplay) are per-connection/per-process
17
19
  * and intentionally have no hooks here.
@@ -28,14 +30,15 @@ function useLatestRef(value) {
28
30
  * cb goes through a ref — a new cb identity does not resubscribe.
29
31
  */
30
32
  export function useReplaySubscribe(remote, cb, options = {}) {
31
- const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
33
+ const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale } = options;
32
34
  const cbRef = useLatestRef(cb);
33
- const hooksRef = useLatestRef({ onSeq, onError });
35
+ const hooksRef = useLatestRef({ onSeq, onError, onStale });
34
36
  const seqRef = useRef(since);
35
37
  const subRef = useRef(null);
36
38
  const lastRemoteRef = useRef(undefined);
37
39
  const [ready, setReady] = useState(false);
38
40
  const [error, setError] = useState(null);
41
+ const [stale, setStale] = useState(false);
39
42
  const [epoch, setEpoch] = useState(0);
40
43
  useEffect(() => {
41
44
  if (!remote || !enabled)
@@ -46,6 +49,10 @@ export function useReplaySubscribe(remote, cb, options = {}) {
46
49
  let alive = true;
47
50
  setReady(false);
48
51
  setError(null);
52
+ // stale is NOT reset here: it re-syncs from common2 after the first delivery (a stale
53
+ // keyframe must show stale from the start, not flicker through false on resubscribe)
54
+ if (staleMs === undefined)
55
+ setStale(false);
49
56
  const off = Replay.replaySubscribe(remote, (...event) => cbRef.current(...event), {
50
57
  since: seqRef.current,
51
58
  onSeq: seq => {
@@ -57,10 +64,23 @@ export function useReplaySubscribe(remote, cb, options = {}) {
57
64
  setError(e);
58
65
  hooksRef.current.onError?.(e);
59
66
  },
67
+ ...(staleMs !== undefined ? {
68
+ staleMs,
69
+ onStale: (info) => {
70
+ if (alive)
71
+ setStale(info.stale);
72
+ hooksRef.current.onStale?.(info);
73
+ },
74
+ } : null),
60
75
  });
61
76
  subRef.current = off;
62
- off.ready.then(() => { if (alive)
63
- setReady(true); }, e => { if (alive)
77
+ off.ready.then(() => {
78
+ if (!alive)
79
+ return;
80
+ setReady(true);
81
+ if (staleMs !== undefined)
82
+ setStale(off.isStale()); // fresh line after a stale one: no edge from common2, sync by hand
83
+ }, e => { if (alive)
64
84
  setError(e); });
65
85
  return () => {
66
86
  alive = false;
@@ -69,16 +89,18 @@ export function useReplaySubscribe(remote, cb, options = {}) {
69
89
  if (!keepSeq)
70
90
  seqRef.current = since;
71
91
  };
72
- // keepSeq/since are start-position config, not subscription identity — no resubscribe on change
92
+ // keepSeq/since are start-position config, not subscription identity — no resubscribe on change;
93
+ // staleMs is subscribe-time config in common2, so changing it resubscribes
73
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
74
- }, [remote, enabled, epoch]);
95
+ }, [remote, enabled, epoch, staleMs]);
75
96
  const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
97
+ const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
76
98
  const restart = useCallback((at) => {
77
99
  if (at !== undefined)
78
100
  seqRef.current = at;
79
101
  setEpoch(v => v + 1);
80
102
  }, []);
81
- return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
103
+ return useMemo(() => ({ ready, error, stale, seq, lastTs, restart }), [ready, error, stale, seq, lastTs, restart]);
82
104
  }
83
105
  /**
84
106
  * Sync an existing mirror store from a store replay line (`ObserveAll2.exposeStoreReplay(...).api.replay`).
@@ -87,13 +109,14 @@ export function useReplaySubscribe(remote, cb, options = {}) {
87
109
  * (useStoreNode/useStoreSelect).
88
110
  */
89
111
  export function useStoreReplaySync(store, remote, options = {}) {
90
- const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
91
- const hooksRef = useLatestRef({ onSeq, onError });
112
+ const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale } = options;
113
+ const hooksRef = useLatestRef({ onSeq, onError, onStale });
92
114
  const seqRef = useRef(since);
93
115
  const subRef = useRef(null);
94
116
  const lastRemoteRef = useRef(undefined);
95
117
  const [ready, setReady] = useState(false);
96
118
  const [error, setError] = useState(null);
119
+ const [stale, setStale] = useState(false);
97
120
  const [epoch, setEpoch] = useState(0);
98
121
  useEffect(() => {
99
122
  if (!store || !remote || !enabled)
@@ -104,6 +127,8 @@ export function useStoreReplaySync(store, remote, options = {}) {
104
127
  let alive = true;
105
128
  setReady(false);
106
129
  setError(null);
130
+ if (staleMs === undefined)
131
+ setStale(false); // see useReplaySubscribe: stale re-syncs from common2, no reset-to-false flicker
107
132
  const off = ObserveAll2.syncStoreReplay(store, remote, {
108
133
  since: seqRef.current,
109
134
  onSeq: seq => {
@@ -115,10 +140,23 @@ export function useStoreReplaySync(store, remote, options = {}) {
115
140
  setError(e);
116
141
  hooksRef.current.onError?.(e);
117
142
  },
143
+ ...(staleMs !== undefined ? {
144
+ staleMs,
145
+ onStale: (info) => {
146
+ if (alive)
147
+ setStale(info.stale);
148
+ hooksRef.current.onStale?.(info);
149
+ },
150
+ } : null),
118
151
  });
119
152
  subRef.current = off;
120
- off.ready.then(() => { if (alive)
121
- setReady(true); }, e => { if (alive)
153
+ off.ready.then(() => {
154
+ if (!alive)
155
+ return;
156
+ setReady(true);
157
+ if (staleMs !== undefined)
158
+ setStale(off.isStale());
159
+ }, e => { if (alive)
122
160
  setError(e); });
123
161
  return () => {
124
162
  alive = false;
@@ -128,14 +166,15 @@ export function useStoreReplaySync(store, remote, options = {}) {
128
166
  seqRef.current = since;
129
167
  };
130
168
  // eslint-disable-next-line react-hooks/exhaustive-deps
131
- }, [store, remote, enabled, epoch]);
169
+ }, [store, remote, enabled, epoch, staleMs]);
132
170
  const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
171
+ const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
133
172
  const restart = useCallback((at) => {
134
173
  if (at !== undefined)
135
174
  seqRef.current = at;
136
175
  setEpoch(v => v + 1);
137
176
  }, []);
138
- return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
177
+ return useMemo(() => ({ ready, error, stale, seq, lastTs, restart }), [ready, error, stale, seq, lastTs, restart]);
139
178
  }
140
179
  /**
141
180
  * Convenience: create a local mirror store and keep it synced from a store replay line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-react2",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "Common react",
5
5
  "strict": true,
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "react": "^19.2.0",
24
24
  "react-dom": "^19.2.0",
25
25
  "react-rnd": "^10.5.3",
26
- "wenay-common2": "^1.0.56"
26
+ "wenay-common2": "^1.0.57"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^19.2.0",