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