@buoy-gg/zustand 7.0.29 → 7.0.31

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 (60) hide show
  1. package/lib/commonjs/zustand/components/ZustandActionButton.js +3 -1
  2. package/lib/commonjs/zustand/components/ZustandDetailViewToggle.js +7 -0
  3. package/lib/commonjs/zustand/components/ZustandEventFilterView.js +19 -4
  4. package/lib/commonjs/zustand/components/ZustandModal.js +173 -32
  5. package/lib/commonjs/zustand/components/ZustandStateChangeItem.js +36 -37
  6. package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +22 -5
  7. package/lib/commonjs/zustand/components/ZustandStateInfoView.js +30 -7
  8. package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +23 -126
  9. package/lib/commonjs/zustand/components/ZustandStoreDetailContent.js +471 -0
  10. package/lib/commonjs/zustand/components/ZustandStoreEditorScreen.js +393 -0
  11. package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +54 -0
  12. package/lib/commonjs/zustand/utils/buoyZustandMiddleware.js +71 -7
  13. package/lib/commonjs/zustand/utils/editableState.js +139 -0
  14. package/lib/commonjs/zustand/utils/snapshotProvider.js +10 -37
  15. package/lib/commonjs/zustand/utils/testIDs.js +82 -0
  16. package/lib/commonjs/zustand/utils/zustandStateStore.js +16 -3
  17. package/lib/module/zustand/components/ZustandActionButton.js +3 -1
  18. package/lib/module/zustand/components/ZustandDetailViewToggle.js +7 -0
  19. package/lib/module/zustand/components/ZustandEventFilterView.js +19 -4
  20. package/lib/module/zustand/components/ZustandModal.js +173 -32
  21. package/lib/module/zustand/components/ZustandStateChangeItem.js +36 -37
  22. package/lib/module/zustand/components/ZustandStateDetailContent.js +22 -5
  23. package/lib/module/zustand/components/ZustandStateInfoView.js +30 -7
  24. package/lib/module/zustand/components/ZustandStoreBrowser.js +26 -129
  25. package/lib/module/zustand/components/ZustandStoreDetailContent.js +469 -0
  26. package/lib/module/zustand/components/ZustandStoreEditorScreen.js +389 -0
  27. package/lib/module/zustand/sync/zustandSyncAdapter.js +54 -0
  28. package/lib/module/zustand/utils/buoyZustandMiddleware.js +71 -7
  29. package/lib/module/zustand/utils/editableState.js +131 -0
  30. package/lib/module/zustand/utils/snapshotProvider.js +4 -30
  31. package/lib/module/zustand/utils/testIDs.js +74 -0
  32. package/lib/module/zustand/utils/zustandStateStore.js +16 -3
  33. package/lib/typescript/zustand/components/ZustandActionButton.d.ts +2 -1
  34. package/lib/typescript/zustand/components/ZustandActionButton.d.ts.map +1 -1
  35. package/lib/typescript/zustand/components/ZustandDetailViewToggle.d.ts.map +1 -1
  36. package/lib/typescript/zustand/components/ZustandEventFilterView.d.ts.map +1 -1
  37. package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
  38. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts +6 -0
  39. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts.map +1 -1
  40. package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
  41. package/lib/typescript/zustand/components/ZustandStateInfoView.d.ts.map +1 -1
  42. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +7 -9
  43. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
  44. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts +40 -0
  45. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts.map +1 -0
  46. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts +42 -0
  47. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts.map +1 -0
  48. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +35 -0
  49. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
  50. package/lib/typescript/zustand/types/index.d.ts +13 -0
  51. package/lib/typescript/zustand/types/index.d.ts.map +1 -1
  52. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts +31 -3
  53. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts.map +1 -1
  54. package/lib/typescript/zustand/utils/editableState.d.ts +65 -0
  55. package/lib/typescript/zustand/utils/editableState.d.ts.map +1 -0
  56. package/lib/typescript/zustand/utils/testIDs.d.ts +54 -0
  57. package/lib/typescript/zustand/utils/testIDs.d.ts.map +1 -0
  58. package/lib/typescript/zustand/utils/zustandStateStore.d.ts +1 -0
  59. package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
  60. package/package.json +3 -3
@@ -0,0 +1,389 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Editing a store, on a screen of its own — the Storage key editor's shape,
5
+ * pointed at a live zustand store.
6
+ *
7
+ * Same reasoning as storage's editor screen: a tree, six structural actions
8
+ * and a keyboard don't fit inside an expanded card, so the card offers ONE
9
+ * edit button and the work happens here with the whole viewport. The tree,
10
+ * dock, draft and value modal are the shared-ui editor — one implementation,
11
+ * two tools.
12
+ *
13
+ * What is zustand-specific is the WRITE. Storage saves by replacing one key's
14
+ * serialized value; a store has no such luxury — its state holds the app's
15
+ * action functions, and a full `setState(draft, true)` replace would delete
16
+ * them (see `editableState.ts`). So Save diffs the draft against the state it
17
+ * was seeded from and writes ONE top-level merge:
18
+ *
19
+ * store.api.setState(changedTopLevelKeys, false)
20
+ *
21
+ * which by construction cannot remove a key or touch a function. The two
22
+ * edits a merge cannot express — removing or renaming a top-level key — are
23
+ * disabled in the dock (`rootEntriesLocked`) and refused at save if they
24
+ * arrive through the raw-JSON escape hatch.
25
+ *
26
+ * On a desktop mirror the same line goes over the wire: the mirrored store's
27
+ * `api.setState` forwards to the device's sync adapter (the time-travel
28
+ * path). That write has no ack, so a remote save resolves optimistically and
29
+ * the next device snapshot is the confirmation.
30
+ */
31
+
32
+ import { useCallback, useMemo, useState } from "react";
33
+ import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
34
+ import { buoyColors, Database, FileJson, night, PillBadge, ToolBackground, useSafeAreaInsets, X } from "@buoy-gg/shared-ui";
35
+ import { DataTreeActionDock, DataViewer, getAtPath, useTreeEditor, ValueEditModal } from "@buoy-gg/shared-ui/dataViewer";
36
+ import { computeMergePartial, editableStateOf } from "../utils/editableState";
37
+ import { ZUSTAND_TEST_ID } from "../utils/testIDs";
38
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
39
+ const EDITOR_ID = `${ZUSTAND_TEST_ID}-editor`;
40
+ export function ZustandStoreEditorScreen({
41
+ store,
42
+ onClose,
43
+ applySafeAreaInset
44
+ }) {
45
+ const insets = useSafeAreaInsets();
46
+ const bottomInset = applySafeAreaInset ? insets.bottom : 0;
47
+
48
+ // Seeded ONCE, when the editor opens — the draft is diffed against this at
49
+ // save, so the partial carries exactly what the user changed and a key the
50
+ // app wrote meanwhile (untouched here) keeps the app's value.
51
+ const [seed] = useState(() => editableStateOf(store.api.getState()));
52
+ const save = useCallback(async draft => {
53
+ if (seed.data === null) throw new Error("Store is not editable.");
54
+ const partial = computeMergePartial(seed.data, draft);
55
+ // A merge, never a replace — the entire reason this editor can exist.
56
+ // Empty partial = the draft circled back to the seed; nothing to write.
57
+ if (Object.keys(partial).length > 0) {
58
+ store.api.setState(partial, false);
59
+ }
60
+ }, [seed, store]);
61
+ const editor = useTreeEditor({
62
+ value: seed.data,
63
+ save,
64
+ onClose,
65
+ confirmDiscard: choices => Alert.alert("Unsaved changes", `"${store.name}" has edits that haven't been written to the store.`, [{
66
+ text: "Keep editing",
67
+ style: "cancel"
68
+ }, {
69
+ text: "Discard",
70
+ style: "destructive",
71
+ onPress: choices.discard
72
+ }, {
73
+ text: "Save",
74
+ onPress: choices.save
75
+ }])
76
+ });
77
+ const {
78
+ draft,
79
+ selection,
80
+ valuePath
81
+ } = editor;
82
+ const keyCount = useMemo(() => {
83
+ const v = draft.value;
84
+ return v !== null && typeof v === "object" ? Object.keys(v).length : 0;
85
+ }, [draft.value]);
86
+
87
+ // The card's gating means this can't normally be reached un-editable, but a
88
+ // remote mirror can theoretically drop hydration between tap and mount.
89
+ if (seed.data === null) {
90
+ return /*#__PURE__*/_jsxs(View, {
91
+ style: styles.screen,
92
+ children: [/*#__PURE__*/_jsx(ToolBackground, {}), /*#__PURE__*/_jsx(Text, {
93
+ style: styles.blockedText,
94
+ children: seed.blockedReason
95
+ })]
96
+ });
97
+ }
98
+ return /*#__PURE__*/_jsxs(View, {
99
+ style: styles.screen,
100
+ children: [/*#__PURE__*/_jsx(ToolBackground, {}), /*#__PURE__*/_jsxs(View, {
101
+ style: styles.header,
102
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
103
+ onPress: editor.requestClose,
104
+ testID: `${EDITOR_ID}-close`,
105
+ accessibilityRole: "button",
106
+ accessibilityLabel: "Close editor",
107
+ style: styles.headerButton,
108
+ hitSlop: {
109
+ top: 8,
110
+ bottom: 8,
111
+ left: 8,
112
+ right: 8
113
+ },
114
+ children: /*#__PURE__*/_jsx(X, {
115
+ size: 16,
116
+ color: night.color.textSecondary
117
+ })
118
+ }), /*#__PURE__*/_jsxs(View, {
119
+ style: styles.headerTitle,
120
+ children: [/*#__PURE__*/_jsx(Text
121
+ // Which store this editor is pointed at — opening it on the wrong
122
+ // one looks exactly like the right one until a write lands
123
+ // somewhere unexpected.
124
+ , {
125
+ testID: `${EDITOR_ID}-store`,
126
+ style: styles.headerKey,
127
+ numberOfLines: 1,
128
+ children: store.name
129
+ }), /*#__PURE__*/_jsx(Text, {
130
+ style: [styles.headerState, draft.isDirty && styles.headerDirty],
131
+ children: draft.isSaving ? "Saving…" : draft.isDirty ? "Draft · unsaved" : "In sync with store"
132
+ })]
133
+ }), /*#__PURE__*/_jsxs(TouchableOpacity, {
134
+ onPress: editor.openRawEditor,
135
+ testID: `${EDITOR_ID}-raw`,
136
+ accessibilityRole: "button",
137
+ accessibilityLabel: "Edit raw JSON",
138
+ style: styles.headerButton,
139
+ hitSlop: {
140
+ top: 8,
141
+ bottom: 8,
142
+ left: 8,
143
+ right: 8
144
+ },
145
+ children: [/*#__PURE__*/_jsx(FileJson, {
146
+ size: 16,
147
+ color: night.color.accent
148
+ }), /*#__PURE__*/_jsx(Text, {
149
+ style: styles.headerRawText,
150
+ children: "Raw"
151
+ })]
152
+ })]
153
+ }), /*#__PURE__*/_jsxs(View, {
154
+ style: styles.metaRow,
155
+ children: [/*#__PURE__*/_jsx(PillBadge, {
156
+ color: buoyColors.info,
157
+ icon: store.isPersisted ? /*#__PURE__*/_jsx(Database, {
158
+ size: 9,
159
+ color: buoyColors.info
160
+ }) : undefined,
161
+ children: store.isPersisted ? store.persistName || "persisted" : "in-memory"
162
+ }), /*#__PURE__*/_jsxs(Text, {
163
+ style: styles.metaText,
164
+ children: [`object · ${keyCount} key${keyCount === 1 ? "" : "s"}`, seed.fnPaths.length > 0 ? ` · ${seed.fnPaths.length} action${seed.fnPaths.length === 1 ? "" : "s"} untouched` : ""]
165
+ })]
166
+ }), /*#__PURE__*/_jsxs(ScrollView, {
167
+ testID: `${EDITOR_ID}-scroll`,
168
+ style: styles.treeScroll,
169
+ contentContainerStyle: styles.treeContent,
170
+ keyboardShouldPersistTaps: "handled",
171
+ children: [/*#__PURE__*/_jsx(View, {
172
+ style: styles.treeCard,
173
+ children: /*#__PURE__*/_jsx(DataViewer, {
174
+ data: draft.value,
175
+ title: "",
176
+ rawMode: true,
177
+ initialExpanded: true
178
+ // Type filtering is a READING aid: the filtered view is a
179
+ // projection whose row paths don't address the real document, so
180
+ // the explorer stops reporting selection while one is active. An
181
+ // editor whose actions silently go dead isn't worth the filter.
182
+ ,
183
+ showTypeFilter: false,
184
+ onSelectionChange: editor.setSelection,
185
+ selectPath: draft.selectPath
186
+ // Its own handle root: the card that opened this editor is still
187
+ // mounted underneath, rendering the same value in its preview tree.
188
+ ,
189
+ testIDPrefix: `${EDITOR_ID}-row`,
190
+ onRequestInlineEdit: sel => editor.openValueEditor(sel.path)
191
+ })
192
+ }), /*#__PURE__*/_jsx(Text, {
193
+ style: styles.treeHint,
194
+ children: "Tap a row to select it \xB7 double-tap to edit its value \xB7 saves merge into the live store, so actions and untouched keys survive"
195
+ })]
196
+ }), draft.error ? /*#__PURE__*/_jsx(Text, {
197
+ style: styles.error,
198
+ numberOfLines: 3,
199
+ testID: `${EDITOR_ID}-error`,
200
+ children: draft.error
201
+ }) : null, /*#__PURE__*/_jsxs(View, {
202
+ style: [styles.footer, {
203
+ paddingBottom: bottomInset
204
+ }],
205
+ children: [/*#__PURE__*/_jsx(DataTreeActionDock, {
206
+ root: draft.value,
207
+ selection: selection,
208
+ onEdit: draft.apply,
209
+ onSelectPath: draft.selectAt
210
+ // Booleans never reach this — the dock applies their flip directly.
211
+ ,
212
+ onEditValue: () => editor.openValueEditor(selection?.path ?? [])
213
+ // A merge write can rewrite a top-level key but never delete one, so
214
+ // Remove is disabled at depth 1 — see `editableState.ts`.
215
+ ,
216
+ rootEntriesLocked: true
217
+ }), draft.isDirty ? /*#__PURE__*/_jsxs(View, {
218
+ style: styles.commitRow,
219
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
220
+ onPress: draft.discard,
221
+ disabled: draft.isSaving,
222
+ testID: `${EDITOR_ID}-discard`,
223
+ accessibilityRole: "button",
224
+ accessibilityLabel: "Discard changes",
225
+ style: styles.discardButton,
226
+ children: /*#__PURE__*/_jsx(Text, {
227
+ style: styles.discardText,
228
+ children: "Discard"
229
+ })
230
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
231
+ onPress: editor.saveAndClose,
232
+ disabled: draft.isSaving,
233
+ testID: `${EDITOR_ID}-save`,
234
+ accessibilityRole: "button",
235
+ accessibilityLabel: "Save changes",
236
+ style: [styles.saveButton, draft.isSaving && styles.saveDisabled],
237
+ children: /*#__PURE__*/_jsx(Text, {
238
+ style: styles.saveText,
239
+ children: draft.isSaving ? "Saving…" : "Save changes"
240
+ })
241
+ })]
242
+ }) : null]
243
+ }), valuePath ? /*#__PURE__*/_jsx(ValueEditModal, {
244
+ path: valuePath,
245
+ value: getAtPath(draft.value, valuePath),
246
+ onSave: editor.applyValue,
247
+ onCancel: editor.closeValueEditor
248
+ // It only reaches the draft here — the single write happens on Save.
249
+ ,
250
+ saveLabel: "Apply",
251
+ testIDPrefix: `${ZUSTAND_TEST_ID}-value`
252
+ }) : null]
253
+ });
254
+ }
255
+ const styles = StyleSheet.create({
256
+ screen: {
257
+ flex: 1,
258
+ // Opaque on purpose: transparency here showed the whole store list
259
+ // through the editor, not just the sheet's backdrop. The screen paints
260
+ // its own ToolBackground instead — see the mount site above.
261
+ backgroundColor: night.color.bg
262
+ },
263
+ blockedText: {
264
+ color: night.color.textTertiary,
265
+ fontSize: 12,
266
+ padding: 20,
267
+ textAlign: "center",
268
+ lineHeight: 18
269
+ },
270
+ header: {
271
+ flexDirection: "row",
272
+ alignItems: "center",
273
+ gap: 8,
274
+ paddingHorizontal: 10,
275
+ paddingVertical: 8,
276
+ borderBottomWidth: 1,
277
+ borderBottomColor: night.color.border
278
+ },
279
+ headerButton: {
280
+ flexDirection: "row",
281
+ alignItems: "center",
282
+ gap: 4,
283
+ paddingVertical: 6,
284
+ paddingHorizontal: 4
285
+ },
286
+ headerRawText: {
287
+ color: night.color.accent,
288
+ fontSize: 12,
289
+ fontWeight: "700"
290
+ },
291
+ headerTitle: {
292
+ flex: 1,
293
+ alignItems: "center",
294
+ gap: 2
295
+ },
296
+ headerKey: {
297
+ color: night.color.text,
298
+ fontSize: 13,
299
+ fontWeight: "600",
300
+ fontFamily: "monospace"
301
+ },
302
+ headerState: {
303
+ color: night.color.textTertiary,
304
+ fontSize: 10
305
+ },
306
+ headerDirty: {
307
+ color: night.color.warning
308
+ },
309
+ metaRow: {
310
+ flexDirection: "row",
311
+ alignItems: "center",
312
+ gap: 8,
313
+ paddingHorizontal: 12,
314
+ paddingVertical: 8
315
+ },
316
+ metaText: {
317
+ color: night.color.textTertiary,
318
+ fontSize: 11,
319
+ fontFamily: "monospace"
320
+ },
321
+ treeScroll: {
322
+ flex: 1
323
+ },
324
+ treeContent: {
325
+ paddingHorizontal: 12,
326
+ paddingBottom: 16
327
+ },
328
+ treeCard: {
329
+ backgroundColor: night.color.surface,
330
+ borderRadius: 10,
331
+ borderWidth: 1,
332
+ borderColor: night.color.border,
333
+ padding: 6
334
+ },
335
+ treeHint: {
336
+ color: night.color.textTertiary,
337
+ fontSize: 10,
338
+ paddingTop: 8,
339
+ paddingHorizontal: 4
340
+ },
341
+ error: {
342
+ color: night.color.danger,
343
+ fontSize: 11,
344
+ fontFamily: "monospace",
345
+ paddingHorizontal: 12,
346
+ paddingBottom: 6
347
+ },
348
+ footer: {
349
+ backgroundColor: night.color.surface
350
+ },
351
+ commitRow: {
352
+ flexDirection: "row",
353
+ gap: 8,
354
+ paddingHorizontal: 12,
355
+ paddingVertical: 10,
356
+ borderTopWidth: 1,
357
+ borderTopColor: night.color.border
358
+ },
359
+ discardButton: {
360
+ flex: 1,
361
+ minHeight: 44,
362
+ alignItems: "center",
363
+ justifyContent: "center",
364
+ borderRadius: 10,
365
+ borderWidth: 1,
366
+ borderColor: night.color.border
367
+ },
368
+ discardText: {
369
+ color: night.color.textSecondary,
370
+ fontSize: 14,
371
+ fontWeight: "600"
372
+ },
373
+ saveButton: {
374
+ flex: 1.4,
375
+ minHeight: 44,
376
+ alignItems: "center",
377
+ justifyContent: "center",
378
+ borderRadius: 10,
379
+ backgroundColor: night.color.accent
380
+ },
381
+ saveDisabled: {
382
+ opacity: 0.5
383
+ },
384
+ saveText: {
385
+ color: night.color.onAccent,
386
+ fontSize: 15,
387
+ fontWeight: "700"
388
+ }
389
+ });
@@ -179,6 +179,7 @@ export const zustandSyncAdapter = {
179
179
  changes: s.stateChangeCount,
180
180
  isPersisted: s.isPersisted,
181
181
  persistName: s.persistName,
182
+ canRehydrate: s.canRehydrate,
182
183
  ...(includeValues ? {
183
184
  currentState: s.currentState
184
185
  } : {
@@ -211,6 +212,59 @@ export const zustandSyncAdapter = {
211
212
  return {
212
213
  ok: true
213
214
  };
215
+ },
216
+ /**
217
+ * Re-read a persisted store's saved value and merge it into the live store.
218
+ *
219
+ * This is what makes editing a store's storage key mean anything. The app
220
+ * reads that key once at startup and then holds the state in memory, so an
221
+ * outside write — the Storage tool, a scenario, an agent — changes the disk
222
+ * and nothing else, and the next time the store saves it writes its own
223
+ * copy straight back over the edit.
224
+ *
225
+ * `persist.rehydrate()` is plain zustand, not a devtools hook, so unlike
226
+ * reloading the app this also works in a release build. It merges the saved
227
+ * value OVER current state (zustand's default merge is
228
+ * `{...current, ...persisted}`), so the store's action functions survive,
229
+ * and it does not write back to storage unless a version migration ran —
230
+ * the edit stays exactly as it was typed.
231
+ *
232
+ * Two honest limits, both inherent to persist rather than to this action:
233
+ * a key the store's `partialize` excludes is not in the saved value and so
234
+ * cannot be applied, and because the merge is shallow, DELETING a top-level
235
+ * key from the saved JSON does not remove it from the live store.
236
+ */
237
+ rehydrate: async params => {
238
+ const {
239
+ storeName
240
+ } = params ?? {};
241
+ if (!storeName) return {
242
+ ok: false,
243
+ error: "Missing storeName."
244
+ };
245
+ const store = zustandStateStore.getStore(storeName);
246
+ if (!store) return {
247
+ ok: false,
248
+ error: `No store named "${storeName}".`
249
+ };
250
+ if (!store.isPersisted) {
251
+ return {
252
+ ok: false,
253
+ error: `"${storeName}" is not a persisted store — it has no saved copy to re-read.`
254
+ };
255
+ }
256
+ if (!store.rehydrate) {
257
+ return {
258
+ ok: false,
259
+ error: `"${storeName}" is persisted but Buoy has no rehydrate handle for it. It was probably registered before the app's persist middleware attached.`
260
+ };
261
+ }
262
+ await store.rehydrate();
263
+ return {
264
+ ok: true,
265
+ storeName,
266
+ persistName: store.persistName
267
+ };
214
268
  }
215
269
  }
216
270
  };
@@ -18,6 +18,8 @@ import { zustandStateStore } from "./zustandStateStore";
18
18
  /** Symbol to mark stores that have been watched/instrumented */
19
19
  const WATCHED_SYMBOL = Symbol.for("@@buoy-zustand/watched");
20
20
 
21
+ /** The slice of zustand's persist API this module uses. */
22
+
21
23
  /**
22
24
  * Detect if a store is using the persist middleware by checking for persist API
23
25
  */
@@ -27,7 +29,8 @@ function detectPersist(store) {
27
29
  const options = persist.getOptions();
28
30
  return {
29
31
  isPersisted: true,
30
- persistName: options?.name
32
+ persistName: options?.name,
33
+ persistApi: persist
31
34
  };
32
35
  }
33
36
  return {
@@ -78,7 +81,8 @@ export function watchStores(stores) {
78
81
  storeAny[WATCHED_SYMBOL] = true;
79
82
  const {
80
83
  isPersisted,
81
- persistName
84
+ persistName,
85
+ persistApi
82
86
  } = detectPersist(store);
83
87
 
84
88
  // Register the store for overview
@@ -89,12 +93,36 @@ export function watchStores(stores) {
89
93
  subscribe: store.subscribe
90
94
  }, {
91
95
  isPersisted,
92
- persistName
96
+ persistName,
97
+ // Was detected and thrown away. Without it nothing can re-read the
98
+ // saved copy after an outside edit — see ZustandStoreInfo.rehydrate.
99
+ rehydrate: typeof persistApi?.rehydrate === "function" ? () => persistApi.rehydrate() : undefined
93
100
  });
94
101
 
102
+ // The rehydration is a state change nobody's code asked for — the store
103
+ // simply becomes what the disk said, seconds into the process. Drawn as an
104
+ // ordinary setState it is the most surprising row in the list and the one
105
+ // that looks most like a bug, so it gets its own category. Recognised
106
+ // rather than announced: `hasHydrated()` is still false while the
107
+ // rehydrating `set` is running, and this listener runs inside it.
108
+ let sawHydration = false;
109
+
95
110
  // Subscribe to state changes — completely external, never touches setState
96
111
  const unsubscribe = store.subscribe((state, prevState) => {
97
112
  try {
113
+ let category;
114
+ if (!sawHydration && isPersisted) {
115
+ let hydrating = false;
116
+ try {
117
+ hydrating = persistApi?.hasHydrated?.() === false;
118
+ } catch {
119
+ // An older persist API without hasHydrated — no worse than before.
120
+ }
121
+ if (hydrating) {
122
+ sawHydration = true;
123
+ category = "persist";
124
+ }
125
+ }
98
126
  zustandStateStore.addStateChange({
99
127
  storeName: name,
100
128
  partial: undefined,
@@ -104,6 +132,7 @@ export function watchStores(stores) {
104
132
  nextState: state,
105
133
  duration: undefined,
106
134
  // Not available in subscribe-only mode
135
+ category,
107
136
  isPersisted
108
137
  });
109
138
  } catch {
@@ -154,17 +183,36 @@ let unnamedCounter = 0;
154
183
  * );
155
184
  * ```
156
185
  */
186
+ /**
187
+ * Zustand's own `set` shape (v5 types): with `replace: true` the argument must
188
+ * be a FULL state, so a single `(partial, replace?: boolean)` signature is
189
+ * incompatible in both directions — zustand's set is not assignable to it,
190
+ * which made `create(buoyDevTools(...))` fail to typecheck for every strict-TS
191
+ * user. Mirroring the overloads keeps buoyDevTools a drop-in for `create()`.
192
+ */
193
+
194
+ /**
195
+ * The store api object zustand hands a middleware, described structurally so
196
+ * this package never has to import zustand's own types (which differ between
197
+ * v4 and v5). `Record<string, unknown>` was wrong here: `StoreApi<T>` has no
198
+ * index signature, so `create(buoyDevTools(...))` failed to typecheck.
199
+ */
200
+
157
201
  export function buoyDevTools(config, options) {
158
202
  return (set, get, api) => {
159
203
  const storeName = options?.name ?? `store-${++unnamedCounter}`;
160
204
  const enabled = options?.enabled !== false;
205
+
206
+ // Forwarding a plain `replace?: boolean` through the overloads needs one
207
+ // widening cast; runtime behavior is unchanged.
208
+ const rawSet = set;
161
209
  const instrumentedSet = (partial, replace) => {
162
210
  if (!enabled) {
163
- return set(partial, replace);
211
+ return rawSet(partial, replace);
164
212
  }
165
213
  const startTime = performance.now();
166
214
  const prevState = get();
167
- set(partial, replace);
215
+ rawSet(partial, replace);
168
216
  const nextState = get();
169
217
  const duration = performance.now() - startTime;
170
218
  const resolvedPartial = typeof partial === "function" ? "(updater fn)" : partial;
@@ -175,7 +223,14 @@ export function buoyDevTools(config, options) {
175
223
  prevState,
176
224
  nextState,
177
225
  duration,
178
- isPersisted: false
226
+ // Read back off the registry rather than hardcoded false. Persistence
227
+ // is a property of the STORE, and this middleware can be wrapped
228
+ // around a persisted one — every change on such a store used to be
229
+ // drawn as un-persisted, which is the opposite of what the badge is
230
+ // for. Registration happens a tick after creation, so a set fired in
231
+ // that first tick still reports false; the alternative is re-sniffing
232
+ // `api.persist` on every set.
233
+ isPersisted: zustandStateStore.getStore(storeName)?.isPersisted ?? false
179
234
  });
180
235
  };
181
236
  const result = config(instrumentedSet, get, api);
@@ -191,7 +246,16 @@ export function buoyDevTools(config, options) {
191
246
  zustandStateStore.registerStore(storeName, {
192
247
  getState: get,
193
248
  getInitialState: api.getInitialState,
194
- setState: set,
249
+ // The INSTRUMENTED set, not the raw one.
250
+ //
251
+ // This handle is what the tool's own JUMP and RESET STORE call, and
252
+ // registering the raw `set` here made both of them invisible: the
253
+ // store changed, the app re-rendered, and nothing appeared in the
254
+ // log — so the newest row's "state after change" no longer described
255
+ // the store, and the next diff compared against a state the device
256
+ // had never been in. The watch path never had this problem, because
257
+ // it observes the store from outside and cannot be gone around.
258
+ setState: instrumentedSet,
195
259
  subscribe: api.subscribe
196
260
  }, {
197
261
  isPersisted,