@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.
- package/lib/commonjs/zustand/components/ZustandActionButton.js +3 -1
- package/lib/commonjs/zustand/components/ZustandDetailViewToggle.js +7 -0
- package/lib/commonjs/zustand/components/ZustandEventFilterView.js +19 -4
- package/lib/commonjs/zustand/components/ZustandModal.js +173 -32
- package/lib/commonjs/zustand/components/ZustandStateChangeItem.js +36 -37
- package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +22 -5
- package/lib/commonjs/zustand/components/ZustandStateInfoView.js +30 -7
- package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +23 -126
- package/lib/commonjs/zustand/components/ZustandStoreDetailContent.js +471 -0
- package/lib/commonjs/zustand/components/ZustandStoreEditorScreen.js +393 -0
- package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +54 -0
- package/lib/commonjs/zustand/utils/buoyZustandMiddleware.js +71 -7
- package/lib/commonjs/zustand/utils/editableState.js +139 -0
- package/lib/commonjs/zustand/utils/snapshotProvider.js +10 -37
- package/lib/commonjs/zustand/utils/testIDs.js +82 -0
- package/lib/commonjs/zustand/utils/zustandStateStore.js +16 -3
- package/lib/module/zustand/components/ZustandActionButton.js +3 -1
- package/lib/module/zustand/components/ZustandDetailViewToggle.js +7 -0
- package/lib/module/zustand/components/ZustandEventFilterView.js +19 -4
- package/lib/module/zustand/components/ZustandModal.js +173 -32
- package/lib/module/zustand/components/ZustandStateChangeItem.js +36 -37
- package/lib/module/zustand/components/ZustandStateDetailContent.js +22 -5
- package/lib/module/zustand/components/ZustandStateInfoView.js +30 -7
- package/lib/module/zustand/components/ZustandStoreBrowser.js +26 -129
- package/lib/module/zustand/components/ZustandStoreDetailContent.js +469 -0
- package/lib/module/zustand/components/ZustandStoreEditorScreen.js +389 -0
- package/lib/module/zustand/sync/zustandSyncAdapter.js +54 -0
- package/lib/module/zustand/utils/buoyZustandMiddleware.js +71 -7
- package/lib/module/zustand/utils/editableState.js +131 -0
- package/lib/module/zustand/utils/snapshotProvider.js +4 -30
- package/lib/module/zustand/utils/testIDs.js +74 -0
- package/lib/module/zustand/utils/zustandStateStore.js +16 -3
- package/lib/typescript/zustand/components/ZustandActionButton.d.ts +2 -1
- package/lib/typescript/zustand/components/ZustandActionButton.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandDetailViewToggle.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandEventFilterView.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts +6 -0
- package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStateInfoView.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +7 -9
- package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
- package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts +40 -0
- package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts.map +1 -0
- package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts +42 -0
- package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts.map +1 -0
- package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +35 -0
- package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/zustand/types/index.d.ts +13 -0
- package/lib/typescript/zustand/types/index.d.ts.map +1 -1
- package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts +31 -3
- package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts.map +1 -1
- package/lib/typescript/zustand/utils/editableState.d.ts +65 -0
- package/lib/typescript/zustand/utils/editableState.d.ts.map +1 -0
- package/lib/typescript/zustand/utils/testIDs.d.ts +54 -0
- package/lib/typescript/zustand/utils/testIDs.d.ts.map +1 -0
- package/lib/typescript/zustand/utils/zustandStateStore.d.ts +1 -0
- package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
- 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
|
|
211
|
+
return rawSet(partial, replace);
|
|
164
212
|
}
|
|
165
213
|
const startTime = performance.now();
|
|
166
214
|
const prevState = get();
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|