@buoy-gg/zustand 7.0.17 → 7.0.19

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 (25) hide show
  1. package/lib/commonjs/zustand/components/ZustandModal.js +35 -4
  2. package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +3 -2
  3. package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +11 -3
  4. package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +141 -6
  5. package/lib/commonjs/zustand/utils/snapshotProvider.js +110 -2
  6. package/lib/commonjs/zustand/utils/zustandStateStore.js +105 -14
  7. package/lib/module/zustand/components/ZustandModal.js +36 -5
  8. package/lib/module/zustand/components/ZustandStateDetailContent.js +3 -2
  9. package/lib/module/zustand/components/ZustandStoreBrowser.js +11 -3
  10. package/lib/module/zustand/sync/zustandSyncAdapter.js +141 -5
  11. package/lib/module/zustand/utils/snapshotProvider.js +110 -2
  12. package/lib/module/zustand/utils/zustandStateStore.js +105 -14
  13. package/lib/typescript/zustand/components/ZustandModal.d.ts +1 -1
  14. package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
  15. package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts +4 -1
  16. package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
  17. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +3 -1
  18. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
  19. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +72 -4
  20. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
  21. package/lib/typescript/zustand/types/index.d.ts +26 -0
  22. package/lib/typescript/zustand/types/index.d.ts.map +1 -1
  23. package/lib/typescript/zustand/utils/zustandStateStore.d.ts +18 -0
  24. package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
  25. package/package.json +9 -4
@@ -49,11 +49,16 @@ function ZustandModal({
49
49
  onMinimize,
50
50
  enableSharedModalDimensions = false,
51
51
  inline = false,
52
- storesLoading = false
52
+ storesLoading = false,
53
+ initialTab = "stores",
54
+ initialSelectedChangeId,
55
+ initialDetailView,
56
+ onChangeSelect,
57
+ onStoreExpand
53
58
  }) {
54
59
  const isPro = (0, _license.useIsPro)();
55
60
  const [showProModal, setShowProModal] = (0, _react.useState)(false);
56
- const [activeTab, setActiveTab] = (0, _react.useState)("stores");
61
+ const [activeTab, setActiveTab] = (0, _react.useState)(initialTab);
57
62
  const [selectedHistoryStore, setSelectedHistoryStore] = (0, _react.useState)(null);
58
63
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
59
64
  const [ignoredPatterns, setIgnoredPatterns] = (0, _react.useState)(new Set());
@@ -108,6 +113,30 @@ function ZustandModal({
108
113
  }, [selectedChangeId, activeChanges]);
109
114
  const selectedChange = selectedChangeIndex !== null ? activeChanges[selectedChangeIndex] : null;
110
115
 
116
+ // Notify the host on selection (keyed by change id so re-renders with the
117
+ // same selection don't re-fire; remote mirrors fetch state trees here).
118
+ const lastNotifiedSelectionRef = (0, _react.useRef)(null);
119
+ (0, _react.useEffect)(() => {
120
+ if (!onChangeSelect) return;
121
+ const id = selectedChange?.id ?? null;
122
+ if (id === null || id === lastNotifiedSelectionRef.current) return;
123
+ lastNotifiedSelectionRef.current = id;
124
+ onChangeSelect(selectedChange);
125
+ }, [selectedChange, onChangeSelect]);
126
+
127
+ // Deep link into a change's detail (see initialSelectedChangeId). Applied
128
+ // at most once per id, so the list re-rendering — or the user pressing Back
129
+ // — never re-opens it.
130
+ const appliedInitialSelectionRef = (0, _react.useRef)(null);
131
+ (0, _react.useEffect)(() => {
132
+ if (!initialSelectedChangeId) return;
133
+ if (appliedInitialSelectionRef.current === initialSelectedChangeId) return;
134
+ const exists = activeChanges.some(c => c.id === initialSelectedChangeId);
135
+ if (!exists) return;
136
+ appliedInitialSelectionRef.current = initialSelectedChangeId;
137
+ setSelectedChangeId(initialSelectedChangeId);
138
+ }, [initialSelectedChangeId, activeChanges]);
139
+
111
140
  // Search state — shared across tabs
112
141
  const [searchText, setSearchText] = (0, _react.useState)("");
113
142
  const [isSearchActive, setIsSearchActive] = (0, _react.useState)(false);
@@ -370,7 +399,8 @@ function ZustandModal({
370
399
  changes: activeChanges,
371
400
  selectedIndex: selectedChangeIndex,
372
401
  onIndexChange: handleIndexChange,
373
- disableInternalFooter: true
402
+ disableInternalFooter: true,
403
+ initialView: initialDetailView
374
404
  });
375
405
  }
376
406
 
@@ -408,7 +438,8 @@ function ZustandModal({
408
438
  stores: displayedStores,
409
439
  searchQuery: searchText,
410
440
  onViewHistory: handleViewHistory,
411
- isLoading: storesLoading
441
+ isLoading: storesLoading,
442
+ onStoreExpand: onStoreExpand
412
443
  });
413
444
  }
414
445
 
@@ -66,9 +66,10 @@ function ZustandStateDetailContent({
66
66
  changes,
67
67
  selectedIndex,
68
68
  onIndexChange,
69
- disableInternalFooter = false
69
+ disableInternalFooter = false,
70
+ initialView = "change"
70
71
  }) {
71
- const [activeView, setActiveView] = (0, _react.useState)("change");
72
+ const [activeView, setActiveView] = (0, _react.useState)(initialView);
72
73
  const [diffMode, setDiffMode] = (0, _react.useState)("tree");
73
74
  const totalChanges = changes.length;
74
75
  const changeColor = getChangeColor(change);
@@ -26,6 +26,9 @@ var _jsxRuntime = require("react/jsx-runtime");
26
26
  function getKeysPreview(store) {
27
27
  try {
28
28
  const state = store.api.getState();
29
+ if (state && typeof state === "object" && state.__buoyStateOnDevice === true) {
30
+ return "on device";
31
+ }
29
32
  if (state && typeof state === "object") {
30
33
  const keys = Object.keys(state);
31
34
  const dataKeys = keys.filter(k => {
@@ -146,7 +149,8 @@ function ZustandStoreBrowser({
146
149
  stores,
147
150
  searchQuery,
148
151
  onViewHistory,
149
- isLoading = false
152
+ isLoading = false,
153
+ onStoreExpand
150
154
  }) {
151
155
  const [expandedStore, setExpandedStore] = (0, _react.useState)(null);
152
156
 
@@ -157,8 +161,12 @@ function ZustandStoreBrowser({
157
161
  return stores.filter(s => s.name.toLowerCase().includes(search) || s.persistName && s.persistName.toLowerCase().includes(search));
158
162
  }, [stores, searchQuery]);
159
163
  const handleStorePress = (0, _react.useCallback)(store => {
160
- setExpandedStore(prev => prev === store.name ? null : store.name);
161
- }, []);
164
+ setExpandedStore(prev => {
165
+ const next = prev === store.name ? null : store.name;
166
+ if (next) onStoreExpand?.(store.name);
167
+ return next;
168
+ });
169
+ }, [onStoreExpand]);
162
170
  if (filteredStores.length === 0 && !searchQuery) {
163
171
  return isLoading ? /*#__PURE__*/(0, _jsxRuntime.jsx)(LoadingBrowserState, {}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(EmptyBrowserState, {});
164
172
  }
@@ -3,13 +3,57 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.zustandSyncAdapter = void 0;
6
+ exports.zustandSyncAdapter = exports.STATE_ON_DEVICE = exports.PAYLOAD_ON_DEVICE = void 0;
7
7
  var _zustandStateStore = require("../utils/zustandStateStore");
8
+ var _sharedUi = require("@buoy-gg/shared-ui");
9
+ /**
10
+ * Sentinel used in WIRE-FORM snapshots in place of raw prevState/nextState.
11
+ * Same hazard as redux: up to 200 changes × 2 full trees on every snapshot
12
+ * freeze/OOM-killed large apps. Dashboards fetch trees via `getChangeDetail`.
13
+ */
14
+ const STATE_ON_DEVICE = exports.STATE_ON_DEVICE = Object.freeze({
15
+ __buoyStateOnDevice: true,
16
+ note: "State trees stay on the device — fetched on demand via getChangeDetail / getStoreState."
17
+ });
18
+
19
+ /** Wire-form sentinel for an oversized `partial` (replace can carry a whole tree). */
20
+ const PAYLOAD_ON_DEVICE = exports.PAYLOAD_ON_DEVICE = Object.freeze({
21
+ __buoyPayloadOnDevice: true,
22
+ note: "Partial stays on the device — fetched on demand via getChangeDetail."
23
+ });
24
+ const MAX_CHANGE_DETAIL_BYTES = 8 * 1024 * 1024;
25
+ const MAX_WIRE_PAYLOAD_BYTES = 16 * 1024;
8
26
  /** Top-level keys of an object state, for the compact (no-values) view. */
9
27
  function topLevelKeys(state) {
10
28
  return state && typeof state === "object" && !Array.isArray(state) ? Object.keys(state) : undefined;
11
29
  }
12
30
 
31
+ /**
32
+ * Per-change wire cache. The store prepends and never mutates a recorded
33
+ * change, so keying on identity is correct and self-invalidating — without it
34
+ * every `partial` in the list is re-measured on all ~5 snapshots per second.
35
+ */
36
+ const changeCache = new WeakMap();
37
+ function toWireChange(change) {
38
+ const cached = changeCache.get(change);
39
+ if (cached) return cached;
40
+ const wire = {
41
+ ...change,
42
+ prevState: STATE_ON_DEVICE,
43
+ nextState: STATE_ON_DEVICE,
44
+ partial: toWirePartial(change.partial)
45
+ };
46
+ changeCache.set(change, wire);
47
+ return wire;
48
+ }
49
+ function toWirePartial(value) {
50
+ if (value === undefined) return value;
51
+ return (0, _sharedUi.isOverWireBudget)(value, MAX_WIRE_PAYLOAD_BYTES) ? PAYLOAD_ON_DEVICE : value;
52
+ }
53
+ function toWireState(value) {
54
+ return (0, _sharedUi.isOverWireBudget)(value, MAX_WIRE_PAYLOAD_BYTES) ? STATE_ON_DEVICE : value;
55
+ }
56
+
13
57
  /**
14
58
  * Sync adapter for the zustand tool, consumed by @buoy-gg/external-sync's
15
59
  * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
@@ -21,13 +65,21 @@ function topLevelKeys(state) {
21
65
  * change timeline and the store registry (with each store's current state)
22
66
  * so the dashboard can render the store browser.
23
67
  *
68
+ * v2: change-log prevState/nextState are replaced with on-device markers
69
+ * (see STATE_ON_DEVICE). Dashboards fetch trees via `getChangeDetail`.
70
+ * Oversized / unserializable `stores[].currentState` uses the same marker
71
+ * and is fetched via `getStoreState`.
72
+ *
24
73
  * The `setState` action powers remote time-travel/reset from the dashboard.
25
74
  */
26
75
  const zustandSyncAdapter = exports.zustandSyncAdapter = {
27
- version: 1,
76
+ version: 2,
28
77
  getSnapshot: () => ({
29
- changes: _zustandStateStore.zustandStateStore.getStateChanges(),
30
- stores: _zustandStateStore.zustandStateStore.getStoreSnapshots()
78
+ changes: _zustandStateStore.zustandStateStore.getStateChanges().map(toWireChange),
79
+ stores: _zustandStateStore.zustandStateStore.getStoreSnapshots().map(store => ({
80
+ ...store,
81
+ currentState: toWireState(store.currentState)
82
+ }))
31
83
  }),
32
84
  subscribe: onChange => {
33
85
  const unsubscribeChanges = _zustandStateStore.zustandStateStore.subscribe(onChange);
@@ -41,6 +93,77 @@ const zustandSyncAdapter = exports.zustandSyncAdapter = {
41
93
  clearEvents: () => {
42
94
  _zustandStateStore.zustandStateStore.clearStateChanges();
43
95
  },
96
+ /**
97
+ * On-demand detail: one change's real prevState/nextState/partial.
98
+ * The per-snapshot stream stays tree-free; this is the explicit,
99
+ * size-guarded channel for the detail pane.
100
+ */
101
+ getChangeDetail: params => {
102
+ const {
103
+ id
104
+ } = params ?? {};
105
+ if (!id) return {
106
+ found: false,
107
+ reason: "missing id"
108
+ };
109
+ const change = _zustandStateStore.zustandStateStore.getStateChangeById(id);
110
+ if (!change) return {
111
+ found: false,
112
+ reason: "unknown id"
113
+ };
114
+ const cappedNote = {
115
+ __buoyTruncated: true,
116
+ note: `Exceeds the ${MAX_CHANGE_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
117
+ };
118
+ const cap = value => (0, _sharedUi.isOverWireBudget)(value, MAX_CHANGE_DETAIL_BYTES) ? cappedNote : value;
119
+ return {
120
+ found: true,
121
+ id,
122
+ prevState: cap(change.prevState),
123
+ nextState: cap(change.nextState),
124
+ partial: cap(change.partial)
125
+ };
126
+ },
127
+ /**
128
+ * On-demand current state for one store. Wire-form snapshots replace
129
+ * oversized / unserializable `currentState` with STATE_ON_DEVICE.
130
+ */
131
+ getStoreState: params => {
132
+ const {
133
+ storeName
134
+ } = params ?? {};
135
+ if (!storeName) return {
136
+ found: false,
137
+ reason: "missing storeName"
138
+ };
139
+ const store = _zustandStateStore.zustandStateStore.getStore(storeName);
140
+ if (!store) return {
141
+ found: false,
142
+ reason: "unknown storeName"
143
+ };
144
+ let currentState;
145
+ try {
146
+ currentState = store.api.getState();
147
+ } catch {
148
+ currentState = undefined;
149
+ }
150
+ const cappedNote = {
151
+ __buoyTruncated: true,
152
+ note: `Exceeds the ${MAX_CHANGE_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
153
+ };
154
+ if (!(0, _sharedUi.isOverWireBudget)(currentState, MAX_CHANGE_DETAIL_BYTES)) {
155
+ return {
156
+ found: true,
157
+ storeName,
158
+ currentState
159
+ };
160
+ }
161
+ return {
162
+ found: true,
163
+ storeName,
164
+ currentState: (0, _sharedUi.isJsonSerializable)(currentState) ? cappedNote : STATE_ON_DEVICE
165
+ };
166
+ },
44
167
  /**
45
168
  * Compact current-state reader for a remote driver (MCP/LLM): the registered
46
169
  * stores with light metadata; each store's full `currentState` object stays
@@ -77,8 +200,20 @@ const zustandSyncAdapter = exports.zustandSyncAdapter = {
77
200
  storeName,
78
201
  state,
79
202
  replace
80
- } = params;
81
- _zustandStateStore.zustandStateStore.getStore(storeName)?.api.setState(state, replace ?? true);
203
+ } = params ?? {};
204
+ if (!storeName) return {
205
+ ok: false,
206
+ error: "Missing storeName."
207
+ };
208
+ const store = _zustandStateStore.zustandStateStore.getStore(storeName);
209
+ if (!store) return {
210
+ ok: false,
211
+ error: `No store named "${storeName}".`
212
+ };
213
+ store.api.setState(state, replace ?? true);
214
+ return {
215
+ ok: true
216
+ };
82
217
  }
83
218
  }
84
219
  };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  var _sharedUi = require("@buoy-gg/shared-ui");
4
+ var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
4
5
  var _zustandStateStore = require("./zustandStateStore");
5
6
  /**
6
7
  * Time Machine snapshot provider for Zustand.
@@ -117,8 +118,12 @@ async function capture() {
117
118
  warnings
118
119
  };
119
120
  }
120
- async function restore(data) {
121
+ async function restore(data, options) {
121
122
  const snapshot = data;
123
+ const excludedSet = new Set(options?.excludeKeys ?? []);
124
+ const includedSet = options?.includeKeys ? new Set(options.includeKeys) : null;
125
+ const touch = key => !excludedSet.has(key) && (!includedSet || includedSet.has(key));
126
+ let excludedHits = 0;
122
127
  const result = {
123
128
  ok: true,
124
129
  applied: 0,
@@ -134,6 +139,10 @@ async function restore(data) {
134
139
  };
135
140
  }
136
141
  for (const [name, envelope] of Object.entries(snapshot.stores)) {
142
+ if (!touch(name)) {
143
+ excludedHits += 1;
144
+ continue;
145
+ }
137
146
  const info = _zustandStateStore.zustandStateStore.getStore(name);
138
147
  if (!info) {
139
148
  result.skipped.push({
@@ -160,12 +169,111 @@ async function restore(data) {
160
169
  result.ok = false;
161
170
  }
162
171
  }
172
+ if (excludedHits > 0) {
173
+ result.warnings.push(`${excludedHits} store(s) excluded by you`);
174
+ }
163
175
  return result;
164
176
  }
177
+ const ITEM_RENDER_BYTE_LIMIT = 256 * 1024;
178
+
179
+ /**
180
+ * Per-store restore preview. Both sides are function-stripped through the
181
+ * SAME depth-8 normalizer as capture — diffing snapshot-vs-RAW-live would
182
+ * report every stripped function and every beyond-depth subtree as removed.
183
+ */
184
+ async function previewItems(target, base) {
185
+ const targetStores = target?.stores ?? {};
186
+ const liveMode = base === undefined;
187
+ const baseStores = {};
188
+ if (liveMode) {
189
+ for (const info of _zustandStateStore.zustandStateStore.getStores()) {
190
+ const fnPaths = [];
191
+ try {
192
+ baseStores[info.name] = {
193
+ state: stripFunctions(info.api.getState(), "", fnPaths, new WeakSet(), 0),
194
+ fnPaths
195
+ };
196
+ } catch {
197
+ // unreadable live store — treated as absent
198
+ }
199
+ }
200
+ } else {
201
+ Object.assign(baseStores, base?.stores ?? {});
202
+ }
203
+ const items = [];
204
+ const notes = [];
205
+ let preservedFns = 0;
206
+ const names = new Set([...Object.keys(baseStores), ...Object.keys(targetStores)]);
207
+ for (const name of names) {
208
+ const inTarget = name in targetStores;
209
+ const inBase = name in baseStores;
210
+ if (!inTarget) {
211
+ // Restore never touches stores the snapshot doesn't know about.
212
+ if (liveMode) notes.push(`store "${name}" not in snapshot — untouched`);else items.push({
213
+ key: name,
214
+ label: name,
215
+ verdict: "removed",
216
+ oldValue: baseStores[name].state
217
+ });
218
+ continue;
219
+ }
220
+ preservedFns += targetStores[name].fnPaths?.length ?? 0;
221
+ if (liveMode && !_zustandStateStore.zustandStateStore.getStore(name)) {
222
+ items.push({
223
+ key: name,
224
+ label: name,
225
+ verdict: "wont-apply",
226
+ reason: "store not registered (renamed, or created after snapshot?)"
227
+ });
228
+ continue;
229
+ }
230
+ if (!inBase) {
231
+ items.push({
232
+ key: name,
233
+ label: name,
234
+ verdict: "added",
235
+ newValue: targetStores[name].state
236
+ });
237
+ continue;
238
+ }
239
+ const oldState = baseStores[name].state;
240
+ const newState = targetStores[name].state;
241
+ if ((0, _dataViewer.boundedDeepEqual)(oldState, newState)) {
242
+ items.push({
243
+ key: name,
244
+ label: name,
245
+ verdict: "unchanged"
246
+ });
247
+ } else if ((0, _dataViewer.jsonLikelyLargerThan)(oldState, ITEM_RENDER_BYTE_LIMIT) || (0, _dataViewer.jsonLikelyLargerThan)(newState, ITEM_RENDER_BYTE_LIMIT)) {
248
+ items.push({
249
+ key: name,
250
+ label: name,
251
+ verdict: "too-large",
252
+ reason: "store too large to render a diff — restore still applies it"
253
+ });
254
+ } else {
255
+ items.push({
256
+ key: name,
257
+ label: name,
258
+ verdict: "changed",
259
+ oldValue: oldState,
260
+ newValue: newState
261
+ });
262
+ }
263
+ }
264
+ if (preservedFns > 0) {
265
+ notes.push(`${preservedFns} store action function${preservedFns === 1 ? "" : "s"} preserved on restore (not diffed)`);
266
+ }
267
+ return {
268
+ items,
269
+ notes
270
+ };
271
+ }
165
272
  (0, _sharedUi.registerStateSnapshotProvider)({
166
273
  id: "zustand",
167
274
  label: "Zustand",
168
275
  status,
169
276
  capture,
170
- restore
277
+ restore,
278
+ previewItems
171
279
  });
@@ -276,6 +276,27 @@ class ZustandStateStore {
276
276
 
277
277
  // ---- Remote Mirror Mode ----
278
278
 
279
+ /**
280
+ * Replace the change log without touching the store registry. Used when a
281
+ * host keeps real registered stores (JUMP / RESET stay wired) but needs to
282
+ * reseed the event list — the docs live tour, or any mirror that already
283
+ * called `registerStore`. Does not fire `onClear`. Respects the enabled
284
+ * flag so the UI's pause button freezes the reseed.
285
+ */
286
+ replaceStateChanges(changes) {
287
+ if (!this.isEnabled) return;
288
+ this.stateChanges = changes.slice(0, this.maxChanges);
289
+ for (const store of this.stores.values()) {
290
+ store.stateChangeCount = 0;
291
+ }
292
+ for (const change of this.stateChanges) {
293
+ const store = this.stores.get(change.storeName);
294
+ if (store) store.stateChangeCount++;
295
+ }
296
+ this.notifyListeners();
297
+ this.notifyStoreListeners();
298
+ }
299
+
279
300
  /**
280
301
  * Permanently suppress local capture. Use when this store acts as a mirror
281
302
  * of a remote device's state changes (e.g. the desktop dashboard): data
@@ -330,22 +351,92 @@ class ZustandStateStore {
330
351
  */
331
352
  replaceFromSnapshot(changes, storeSnapshots) {
332
353
  if (!this.isEnabled) return;
333
- this.stateChanges = changes.slice(0, this.maxChanges);
334
- this.stores = new Map(storeSnapshots.map(snapshot => [snapshot.name, {
335
- name: snapshot.name,
336
- api: {
337
- getState: () => snapshot.currentState,
338
- setState: (partial, replace) => {
339
- this.remoteSetStateHandler?.(snapshot.name, partial, replace);
354
+
355
+ // Preserve trees a remote mirror already hydrated — wire-form snapshots
356
+ // always carry markers, and a later snapshot must not blank the detail pane.
357
+ const hydrated = new Map();
358
+ for (const existing of this.stateChanges) {
359
+ const prev = existing.prevState;
360
+ if (!prev || prev.__buoyStateOnDevice !== true) {
361
+ hydrated.set(existing.id, existing);
362
+ }
363
+ }
364
+ this.stateChanges = changes.slice(0, this.maxChanges).map(change => {
365
+ const kept = hydrated.get(change.id);
366
+ const incoming = change.prevState;
367
+ return kept && incoming?.__buoyStateOnDevice === true ? {
368
+ ...change,
369
+ prevState: kept.prevState,
370
+ nextState: kept.nextState,
371
+ partial: kept.partial
372
+ } : change;
373
+ });
374
+
375
+ // Preserve currentState a remote mirror already hydrated — oversized
376
+ // stores arrive as STATE_ON_DEVICE markers on every snapshot.
377
+ const hydratedStores = new Map();
378
+ for (const [name, store] of this.stores) {
379
+ try {
380
+ const state = store.api.getState();
381
+ const marker = state;
382
+ if (!marker || marker.__buoyStateOnDevice !== true) {
383
+ hydratedStores.set(name, state);
384
+ }
385
+ } catch {
386
+ // ignore
387
+ }
388
+ }
389
+ this.stores = new Map(storeSnapshots.map(snapshot => {
390
+ const incoming = snapshot.currentState;
391
+ const kept = hydratedStores.get(snapshot.name);
392
+ const currentState = kept !== undefined && incoming?.__buoyStateOnDevice === true ? kept : snapshot.currentState;
393
+ return [snapshot.name, {
394
+ name: snapshot.name,
395
+ api: {
396
+ getState: () => currentState,
397
+ setState: (partial, replace) => {
398
+ this.remoteSetStateHandler?.(snapshot.name, partial, replace);
399
+ },
400
+ subscribe: () => () => {}
340
401
  },
341
- subscribe: () => () => {}
342
- },
343
- stateChangeCount: snapshot.stateChangeCount,
344
- isPersisted: snapshot.isPersisted,
345
- persistName: snapshot.persistName,
346
- color: snapshot.color
347
- }]));
402
+ stateChangeCount: snapshot.stateChangeCount,
403
+ isPersisted: snapshot.isPersisted,
404
+ persistName: snapshot.persistName,
405
+ color: snapshot.color
406
+ }];
407
+ }));
408
+ this.notifyListeners();
409
+ this.notifyStoreListeners();
410
+ }
411
+
412
+ /**
413
+ * Hydrate one mirrored change with real trees fetched on demand from the
414
+ * device via the adapter's `getChangeDetail`. Remote-mirror (desktop) only.
415
+ */
416
+ hydrateChange(id, patch) {
417
+ const index = this.stateChanges.findIndex(c => c.id === id);
418
+ if (index === -1) return;
419
+ this.stateChanges = [...this.stateChanges.slice(0, index), {
420
+ ...this.stateChanges[index],
421
+ ...patch
422
+ }, ...this.stateChanges.slice(index + 1)];
348
423
  this.notifyListeners();
424
+ }
425
+
426
+ /**
427
+ * Hydrate one mirrored store's currentState fetched on demand from the
428
+ * device via the adapter's `getStoreState`. Remote-mirror (desktop) only.
429
+ */
430
+ hydrateStoreState(storeName, currentState) {
431
+ const store = this.stores.get(storeName);
432
+ if (!store) return;
433
+ this.stores.set(storeName, {
434
+ ...store,
435
+ api: {
436
+ ...store.api,
437
+ getState: () => currentState
438
+ }
439
+ });
349
440
  this.notifyStoreListeners();
350
441
  }
351
442
 
@@ -8,7 +8,7 @@
8
8
  * - Events tab: Live state change monitoring with diffs
9
9
  */
10
10
 
11
- import { useState, useMemo, useRef, useCallback } from "react";
11
+ import { useState, useMemo, useRef, useCallback, useEffect } from "react";
12
12
  import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity } from "react-native";
13
13
  import { JsModal, ModalHeader, TabSelector, macOSColors, buoyColors, Search, Filter, Power, X, Lock, Box, devToolsStorageKeys, ProUpgradeModal, PowerToggleButton, ToolbarCopyButton, ToolbarClearButton, truncatePayload, TickProvider } from "@buoy-gg/shared-ui";
14
14
  import { useIsPro } from "@buoy-gg/license";
@@ -44,11 +44,16 @@ export function ZustandModal({
44
44
  onMinimize,
45
45
  enableSharedModalDimensions = false,
46
46
  inline = false,
47
- storesLoading = false
47
+ storesLoading = false,
48
+ initialTab = "stores",
49
+ initialSelectedChangeId,
50
+ initialDetailView,
51
+ onChangeSelect,
52
+ onStoreExpand
48
53
  }) {
49
54
  const isPro = useIsPro();
50
55
  const [showProModal, setShowProModal] = useState(false);
51
- const [activeTab, setActiveTab] = useState("stores");
56
+ const [activeTab, setActiveTab] = useState(initialTab);
52
57
  const [selectedHistoryStore, setSelectedHistoryStore] = useState(null);
53
58
  const [showFilters, setShowFilters] = useState(false);
54
59
  const [ignoredPatterns, setIgnoredPatterns] = useState(new Set());
@@ -103,6 +108,30 @@ export function ZustandModal({
103
108
  }, [selectedChangeId, activeChanges]);
104
109
  const selectedChange = selectedChangeIndex !== null ? activeChanges[selectedChangeIndex] : null;
105
110
 
111
+ // Notify the host on selection (keyed by change id so re-renders with the
112
+ // same selection don't re-fire; remote mirrors fetch state trees here).
113
+ const lastNotifiedSelectionRef = useRef(null);
114
+ useEffect(() => {
115
+ if (!onChangeSelect) return;
116
+ const id = selectedChange?.id ?? null;
117
+ if (id === null || id === lastNotifiedSelectionRef.current) return;
118
+ lastNotifiedSelectionRef.current = id;
119
+ onChangeSelect(selectedChange);
120
+ }, [selectedChange, onChangeSelect]);
121
+
122
+ // Deep link into a change's detail (see initialSelectedChangeId). Applied
123
+ // at most once per id, so the list re-rendering — or the user pressing Back
124
+ // — never re-opens it.
125
+ const appliedInitialSelectionRef = useRef(null);
126
+ useEffect(() => {
127
+ if (!initialSelectedChangeId) return;
128
+ if (appliedInitialSelectionRef.current === initialSelectedChangeId) return;
129
+ const exists = activeChanges.some(c => c.id === initialSelectedChangeId);
130
+ if (!exists) return;
131
+ appliedInitialSelectionRef.current = initialSelectedChangeId;
132
+ setSelectedChangeId(initialSelectedChangeId);
133
+ }, [initialSelectedChangeId, activeChanges]);
134
+
106
135
  // Search state — shared across tabs
107
136
  const [searchText, setSearchText] = useState("");
108
137
  const [isSearchActive, setIsSearchActive] = useState(false);
@@ -365,7 +394,8 @@ export function ZustandModal({
365
394
  changes: activeChanges,
366
395
  selectedIndex: selectedChangeIndex,
367
396
  onIndexChange: handleIndexChange,
368
- disableInternalFooter: true
397
+ disableInternalFooter: true,
398
+ initialView: initialDetailView
369
399
  });
370
400
  }
371
401
 
@@ -403,7 +433,8 @@ export function ZustandModal({
403
433
  stores: displayedStores,
404
434
  searchQuery: searchText,
405
435
  onViewHistory: handleViewHistory,
406
- isLoading: storesLoading
436
+ isLoading: storesLoading,
437
+ onStoreExpand: onStoreExpand
407
438
  });
408
439
  }
409
440
 
@@ -62,9 +62,10 @@ export function ZustandStateDetailContent({
62
62
  changes,
63
63
  selectedIndex,
64
64
  onIndexChange,
65
- disableInternalFooter = false
65
+ disableInternalFooter = false,
66
+ initialView = "change"
66
67
  }) {
67
- const [activeView, setActiveView] = useState("change");
68
+ const [activeView, setActiveView] = useState(initialView);
68
69
  const [diffMode, setDiffMode] = useState("tree");
69
70
  const totalChanges = changes.length;
70
71
  const changeColor = getChangeColor(change);