@buoy-gg/zustand 7.0.30 → 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
@@ -44,7 +44,8 @@ function ZustandActionButton({
44
44
  onPress,
45
45
  text,
46
46
  type,
47
- disabled = false
47
+ disabled = false,
48
+ testID
48
49
  }) {
49
50
  const config = buttonConfigs[type];
50
51
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
@@ -56,6 +57,7 @@ function ZustandActionButton({
56
57
  opacity: disabled ? 0.5 : 1
57
58
  }],
58
59
  activeOpacity: 0.7,
60
+ testID: testID,
59
61
  accessibilityRole: "button",
60
62
  accessibilityLabel: text,
61
63
  accessibilityState: {
@@ -53,6 +53,13 @@ const ZustandDetailViewToggle = exports.ZustandDetailViewToggle = /*#__PURE__*/(
53
53
  onPress: () => !isDisabled && onViewChange(config.key),
54
54
  activeOpacity: isDisabled ? 1 : 0.8,
55
55
  disabled: isDisabled,
56
+ testID: `buoy-zustand-view-${config.key}`,
57
+ accessibilityRole: "button",
58
+ accessibilityLabel: config.label,
59
+ accessibilityState: {
60
+ disabled: isDisabled,
61
+ selected: isActive
62
+ },
56
63
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
57
64
  style: styles.cardContent,
58
65
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(IconComponent, {
@@ -8,6 +8,7 @@ var _react = require("react");
8
8
  var _reactNative = require("react-native");
9
9
  var _sharedUi = require("@buoy-gg/shared-ui");
10
10
  var _zustandStateStore = require("../utils/zustandStateStore");
11
+ var _testIDs = require("../utils/testIDs");
11
12
  var _jsxRuntime = require("react/jsx-runtime");
12
13
  /** Returns the store color for a given pattern if it exactly matches a registered store name */
13
14
  function getStoreColorForPattern(pattern, stores) {
@@ -39,6 +40,7 @@ function ZustandEventFilterView({
39
40
  style: styles.container,
40
41
  contentContainerStyle: styles.scrollContent,
41
42
  showsVerticalScrollIndicator: false,
43
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-scroll`,
42
44
  children: [stores.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
43
45
  style: styles.section,
44
46
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.SectionHeader, {
@@ -46,7 +48,8 @@ function ZustandEventFilterView({
46
48
  children: "REGISTERED STORES"
47
49
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SectionHeader.Badge, {
48
50
  count: stores.length,
49
- color: _sharedUi.night.color.accent
51
+ color: _sharedUi.night.color.accent,
52
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-store-count`
50
53
  })]
51
54
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
52
55
  style: styles.storeList,
@@ -60,6 +63,12 @@ function ZustandEventFilterView({
60
63
  style: [styles.storeRow, filtered && styles.storeRowFiltered],
61
64
  onPress: () => onAddPattern(store.name),
62
65
  disabled: filtered,
66
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-store-${(0, _testIDs.slugForTestID)(store.name)}`,
67
+ accessibilityRole: "button",
68
+ accessibilityLabel: `Hide ${store.name}`,
69
+ accessibilityState: {
70
+ disabled: filtered
71
+ },
63
72
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
64
73
  style: [styles.storeDot, {
65
74
  backgroundColor: filtered ? _sharedUi.night.color.textTertiary : storeColor
@@ -93,14 +102,16 @@ function ZustandEventFilterView({
93
102
  children: "STORE NAME FILTERS"
94
103
  }), ignoredPatterns.size > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SectionHeader.Badge, {
95
104
  count: ignoredPatterns.size,
96
- color: _sharedUi.night.color.accent
105
+ color: _sharedUi.night.color.accent,
106
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-count`
97
107
  })]
98
108
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
99
109
  style: styles.addInputWrapper,
100
110
  children: !showAddInput ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.AddFilterButton, {
101
111
  onPress: () => setShowAddInput(true),
102
112
  color: _sharedUi.night.color.accent,
103
- label: "Add store name pattern"
113
+ label: "Add store name pattern",
114
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-add-open`
104
115
  }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.AddFilterInput, {
105
116
  value: inputValue,
106
117
  onChange: setInputValue,
@@ -110,7 +121,8 @@ function ZustandEventFilterView({
110
121
  setInputValue("");
111
122
  },
112
123
  placeholder: "Enter store name pattern to hide...",
113
- color: _sharedUi.night.color.accent
124
+ color: _sharedUi.night.color.accent,
125
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-add-input`
114
126
  })
115
127
  }), ignoredPatterns.size > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
116
128
  style: styles.patternList,
@@ -120,6 +132,9 @@ function ZustandEventFilterView({
120
132
  style: styles.patternRow,
121
133
  onPress: () => onTogglePattern(pattern),
122
134
  activeOpacity: 0.8,
135
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-chip-${(0, _testIDs.slugForTestID)(pattern)}`,
136
+ accessibilityRole: "button",
137
+ accessibilityLabel: `Remove filter ${pattern}`,
123
138
  children: [storeColor ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
124
139
  style: [styles.storeDot, {
125
140
  backgroundColor: storeColor
@@ -13,7 +13,9 @@ var _useZustandStateChanges = require("../hooks/useZustandStateChanges");
13
13
  var _ZustandStateChangeItem = require("./ZustandStateChangeItem");
14
14
  var _ZustandStateDetailContent = require("./ZustandStateDetailContent");
15
15
  var _ZustandStoreBrowser = require("./ZustandStoreBrowser");
16
+ var _ZustandStoreDetailContent = require("./ZustandStoreDetailContent");
16
17
  var _ZustandEventFilterView = require("./ZustandEventFilterView");
18
+ var _testIDs = require("../utils/testIDs");
17
19
  var _jsxRuntime = require("react/jsx-runtime");
18
20
  /**
19
21
  * Main Zustand DevTools modal
@@ -23,19 +25,32 @@ var _jsxRuntime = require("react/jsx-runtime");
23
25
  * - Events tab: Live state change monitoring with diffs
24
26
  */
25
27
 
28
+ /**
29
+ * Cell clipping, off in e2e bundles only.
30
+ *
31
+ * `removeClippedSubviews` unmounts the views of rows scrolled out of the
32
+ * window, which is what makes a long change list cheap — and which makes
33
+ * Detox report a perfectly rendered row as un-hittable. The env var is
34
+ * inlined at bundle time, so a bundler that does NOT inline it leaves the
35
+ * production behaviour exactly as it was.
36
+ */
37
+ const CLIP_OFFSCREEN_CELLS = !(typeof process !== "undefined" && process.env?.EXPO_PUBLIC_E2E);
26
38
  function EventsEmptyState({
27
39
  isEnabled
28
40
  }) {
29
41
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
30
42
  style: styles.emptyState,
43
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-empty`,
31
44
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
32
45
  size: 32,
33
46
  color: _sharedUi.night.color.textTertiary
34
47
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
35
48
  style: styles.emptyTitle,
49
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-empty-title`,
36
50
  children: "No state changes"
37
51
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
38
52
  style: styles.emptyText,
53
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-empty-text`,
39
54
  children: isEnabled ? "State changes will appear here as stores update." : "Enable capture to start recording state changes"
40
55
  })]
41
56
  });
@@ -59,6 +74,11 @@ function ZustandModal({
59
74
  const [showProModal, setShowProModal] = (0, _react.useState)(false);
60
75
  const [activeTab, setActiveTab] = (0, _react.useState)(initialTab);
61
76
  const [selectedHistoryStore, setSelectedHistoryStore] = (0, _react.useState)(null);
77
+ /**
78
+ * Which store's DETAIL screen is open (live editor + details + tree —
79
+ * the React Query interaction). Null means the browser list.
80
+ */
81
+ const [selectedStoreName, setSelectedStoreName] = (0, _react.useState)(null);
62
82
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
63
83
  const [ignoredPatterns, setIgnoredPatterns] = (0, _react.useState)(new Set());
64
84
  const {
@@ -84,19 +104,26 @@ function ZustandModal({
84
104
  return stores.filter(s => !Array.from(ignoredPatterns).some(p => s.name.toLowerCase().includes(p.toLowerCase())));
85
105
  }, [stores, ignoredPatterns]);
86
106
 
107
+ // Scope to a store BEFORE the tier cap, not after.
108
+ //
109
+ // The other way round, the cap was applied to the whole log and a store's
110
+ // history was whatever survived it — so a store with one change, made a
111
+ // while ago, opened an EMPTY history from a card that said "Changes: 1" and
112
+ // offered a "view history" link. The cap is a limit on how much of a list
113
+ // you can see; which list you are looking at is decided first.
114
+ const scopedChanges = (0, _react.useMemo)(() => {
115
+ if (!selectedHistoryStore) return displayedChanges;
116
+ return displayedChanges.filter(c => c.storeName === selectedHistoryStore);
117
+ }, [displayedChanges, selectedHistoryStore]);
118
+
87
119
  // Pro is Infinity here, so slice() returns everything and the locked count
88
120
  // floors at 0 — no tier branching needed at the call site.
89
- const visibleChanges = (0, _react.useMemo)(() => displayedChanges.slice(0, changeLimit), [displayedChanges, changeLimit]);
90
- const lockedChangeCount = (0, _react.useMemo)(() => Math.max(0, displayedChanges.length - changeLimit), [displayedChanges.length, changeLimit]);
121
+ const visibleChanges = (0, _react.useMemo)(() => scopedChanges.slice(0, changeLimit), [scopedChanges, changeLimit]);
122
+ const lockedChangeCount = (0, _react.useMemo)(() => Math.max(0, scopedChanges.length - changeLimit), [scopedChanges.length, changeLimit]);
91
123
  const hasLockedChanges = lockedChangeCount > 0;
92
124
 
93
- // When viewing a single store's history, scope the change list to that store
94
- const activeChanges = (0, _react.useMemo)(() => {
95
- if (selectedHistoryStore) {
96
- return visibleChanges.filter(c => c.storeName === selectedHistoryStore);
97
- }
98
- return visibleChanges;
99
- }, [visibleChanges, selectedHistoryStore]);
125
+ /** The list the detail stepper walks: already scoped, already capped. */
126
+ const activeChanges = visibleChanges;
100
127
 
101
128
  // Selection state — track by ID so new events don't shift position
102
129
  const [selectedChangeId, setSelectedChangeId] = (0, _react.useState)(null);
@@ -146,6 +173,16 @@ function ZustandModal({
146
173
  }));
147
174
  }
148
175
  };
176
+
177
+ /** Drop the query AND put the header back. The X, and only the X. */
178
+ const closeSearch = (0, _react.useCallback)(() => {
179
+ setSearchText("");
180
+ setFilter(prev => ({
181
+ ...prev,
182
+ searchText: ""
183
+ }));
184
+ setIsSearchActive(false);
185
+ }, [setFilter]);
149
186
  const handleChangePress = (0, _react.useCallback)(change => {
150
187
  setSelectedChangeId(change.id);
151
188
  }, []);
@@ -169,6 +206,16 @@ function ZustandModal({
169
206
  setSelectedHistoryStore(null);
170
207
  setSelectedChangeId(null);
171
208
  }, []);
209
+ const handleOpenStore = (0, _react.useCallback)(storeName => {
210
+ setSelectedStoreName(storeName);
211
+ // The desktop mirror hydrates a store's full state on demand — this is
212
+ // the moment it becomes needed (was fired on card-expand before the
213
+ // detail screen replaced expansion).
214
+ onStoreExpand?.(storeName);
215
+ }, [onStoreExpand]);
216
+ const handleBackFromStore = (0, _react.useCallback)(() => {
217
+ setSelectedStoreName(null);
218
+ }, []);
172
219
  const getStoresSnapshot = (0, _react.useCallback)(() => {
173
220
  return displayedStores.reduce((acc, store) => {
174
221
  try {
@@ -223,6 +270,7 @@ function ZustandModal({
223
270
  const handleTabChange = (0, _react.useCallback)(tab => {
224
271
  setActiveTab(tab);
225
272
  setSelectedHistoryStore(null);
273
+ setSelectedStoreName(null);
226
274
  setSelectedChangeId(null);
227
275
  setShowFilters(false);
228
276
  if (searchText) {
@@ -235,14 +283,20 @@ function ZustandModal({
235
283
  setIsSearchActive(false);
236
284
  }, [searchText, setFilter]);
237
285
  const keyExtractor = (0, _react.useCallback)(item => item.id, []);
286
+
287
+ // Built for the whole list rather than per row: the ordinal a row carries is
288
+ // a fact about the list around it. See ../utils/testIDs.ts for why a store
289
+ // name alone cannot address a change row.
290
+ const rowTestIDs = (0, _react.useMemo)(() => (0, _testIDs.buildChangeRowTestIDs)(activeChanges), [activeChanges]);
238
291
  const renderItem = (0, _react.useCallback)(({
239
292
  item
240
293
  }) => {
241
294
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ZustandStateChangeItem.ZustandStateChangeItem, {
242
295
  change: item,
243
- onPress: handleChangePress
296
+ onPress: handleChangePress,
297
+ testID: rowTestIDs.get(item.id)
244
298
  });
245
- }, [handleChangePress]);
299
+ }, [handleChangePress, rowTestIDs]);
246
300
 
247
301
  // ---- Header rendering ----
248
302
 
@@ -255,7 +309,8 @@ function ZustandModal({
255
309
  onBack: () => setShowFilters(false)
256
310
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
257
311
  title: "Filters",
258
- centered: true
312
+ centered: true,
313
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-title`
259
314
  })]
260
315
  });
261
316
  }
@@ -267,7 +322,8 @@ function ZustandModal({
267
322
  onBack: handleBack
268
323
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
269
324
  title: `${selectedChange.storeName}/setState`,
270
- centered: true
325
+ centered: true,
326
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-detail-title`
271
327
  })]
272
328
  });
273
329
  }
@@ -279,17 +335,34 @@ function ZustandModal({
279
335
  onBack: handleBackFromHistory
280
336
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
281
337
  title: `${selectedHistoryStore} History`,
282
- centered: true
338
+ centered: true,
339
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-history-title`
283
340
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Actions, {
284
341
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
285
342
  value: getHistorySnapshot,
286
343
  disabled: activeChanges.length === 0,
287
- buttonStyle: styles.headerActionButton
344
+ buttonStyle: styles.headerActionButton,
345
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-copy`
288
346
  })
289
347
  })]
290
348
  });
291
349
  }
292
350
 
351
+ // Store detail header — the live editor screen. AFTER history: history is
352
+ // reached FROM the detail screen and stacks over it, so when both are set
353
+ // history owns the header, and its back lands you back on the detail.
354
+ if (selectedStoreName) {
355
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
356
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
357
+ onBack: handleBackFromStore
358
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
359
+ title: selectedStoreName,
360
+ centered: true,
361
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-store-detail-title`
362
+ })]
363
+ });
364
+ }
365
+
293
366
  // Main view with tabs
294
367
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
295
368
  children: [onBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
@@ -298,27 +371,42 @@ function ZustandModal({
298
371
  title: "",
299
372
  children: isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
300
373
  style: styles.headerSearchContainer,
374
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-search`,
301
375
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
302
376
  size: 14,
303
377
  color: _sharedUi.night.color.textSecondary
304
378
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
305
379
  ref: searchInputRef,
380
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-search-field`,
306
381
  style: styles.headerSearchInput,
307
382
  placeholder: activeTab === "stores" ? "Search stores..." : "Search events...",
308
383
  placeholderTextColor: _sharedUi.night.color.placeholder,
309
384
  value: searchText,
310
385
  onChangeText: handleSearch,
311
- onSubmitEditing: () => setIsSearchActive(false),
312
- onBlur: () => setIsSearchActive(false),
386
+ onSubmitEditing: () => searchInputRef.current?.blur()
387
+ // Stay open while the query is still narrowing the list.
388
+ //
389
+ // Collapsing on blur hid the reason rows were missing: the
390
+ // header went back to its TABS — whose counts are of the
391
+ // WHOLE capture on the stores side — while the list stayed
392
+ // filtered, so the tool showed "Stores (4)" above a single
393
+ // row and offered nothing to explain the gap. The only way
394
+ // back was to reopen a field the user had no reason to think
395
+ // was still armed.
396
+ ,
397
+ onBlur: () => {
398
+ if (searchText.length === 0) setIsSearchActive(false);
399
+ },
400
+ accessibilityLabel: "Search Zustand stores and events",
313
401
  autoCapitalize: "none",
314
402
  autoCorrect: false,
315
403
  returnKeyType: "search"
316
- }), searchText.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
317
- onPress: () => {
318
- handleSearch("");
319
- setIsSearchActive(false);
320
- },
404
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
405
+ onPress: closeSearch,
321
406
  style: styles.headerSearchClear,
407
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-search-clear`,
408
+ accessibilityRole: "button",
409
+ accessibilityLabel: "Close search",
322
410
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.X, {
323
411
  size: 14,
324
412
  color: _sharedUi.night.color.textSecondary
@@ -333,12 +421,16 @@ function ZustandModal({
333
421
  label: `Events${displayedChanges.length > 0 ? ` (${displayedChanges.length})` : ""}`
334
422
  }],
335
423
  activeTab: activeTab,
336
- onTabChange: handleTabChange
424
+ onTabChange: handleTabChange,
425
+ testIDPrefix: `${_testIDs.ZUSTAND_TEST_ID}-tab`
337
426
  })
338
427
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
339
428
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
340
429
  onPress: () => setIsSearchActive(true),
341
430
  style: styles.headerActionButton,
431
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-search-open`,
432
+ accessibilityRole: "button",
433
+ accessibilityLabel: "Search",
342
434
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
343
435
  size: 14,
344
436
  color: _sharedUi.night.color.textSecondary
@@ -346,6 +438,9 @@ function ZustandModal({
346
438
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
347
439
  onPress: () => setShowFilters(true),
348
440
  style: [styles.headerActionButton, hasActiveFilters && styles.headerActionButtonActive],
441
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-filters-open`,
442
+ accessibilityRole: "button",
443
+ accessibilityLabel: "Filters",
349
444
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Filter, {
350
445
  size: 14,
351
446
  color: hasActiveFilters ? _sharedUi.night.color.accent : _sharedUi.night.color.textSecondary
@@ -353,20 +448,24 @@ function ZustandModal({
353
448
  }), activeTab === "stores" && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
354
449
  value: getStoresSnapshot,
355
450
  disabled: displayedStores.length === 0,
356
- buttonStyle: styles.headerActionButton
451
+ buttonStyle: styles.headerActionButton,
452
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-copy`
357
453
  }), activeTab === "events" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
358
454
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
359
455
  value: getEventsSnapshot,
360
456
  disabled: displayedChanges.length === 0,
361
- buttonStyle: styles.headerActionButton
457
+ buttonStyle: styles.headerActionButton,
458
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-copy`
362
459
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
363
460
  isEnabled: isEnabled,
364
461
  onToggle: toggleCapture,
365
- accessibilityLabel: "Toggle Zustand state capture"
462
+ accessibilityLabel: "Toggle Zustand state capture",
463
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-power`
366
464
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarClearButton, {
367
465
  onPress: clearChanges,
368
466
  disabled: displayedChanges.length === 0,
369
- buttonStyle: styles.headerActionButton
467
+ buttonStyle: styles.headerActionButton,
468
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-clear`
370
469
  })]
371
470
  })]
372
471
  })]
@@ -401,18 +500,20 @@ function ZustandModal({
401
500
  // Store history view — filtered to one store
402
501
  if (selectedHistoryStore) {
403
502
  return activeChanges.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
503
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-history-list`,
404
504
  data: activeChanges,
405
505
  renderItem: renderItem,
406
506
  keyExtractor: keyExtractor,
407
507
  contentContainerStyle: styles.listContent,
408
508
  showsVerticalScrollIndicator: true,
409
- removeClippedSubviews: true,
509
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
410
510
  initialNumToRender: 12,
411
511
  maxToRenderPerBatch: 8,
412
512
  windowSize: 5,
413
513
  updateCellsBatchingPeriod: 50
414
514
  }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
415
515
  style: styles.emptyState,
516
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-history-empty`,
416
517
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
417
518
  size: 32,
418
519
  color: _sharedUi.night.color.textTertiary
@@ -426,14 +527,41 @@ function ZustandModal({
426
527
  });
427
528
  }
428
529
 
530
+ // Store detail — live editor + details + read-only tree. After history
531
+ // in this chain for the same reason as the header: history stacks OVER
532
+ // the detail screen it was opened from.
533
+ if (selectedStoreName) {
534
+ const selectedStore = stores.find(s => s.name === selectedStoreName) ?? null;
535
+ return selectedStore ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_ZustandStoreDetailContent.ZustandStoreDetailContent, {
536
+ store: selectedStore,
537
+ onViewHistory: handleViewHistory
538
+ }) :
539
+ /*#__PURE__*/
540
+ // The store fell out of the registry mid-look (a remote mirror
541
+ // rebuilding, a dev reload). Say so rather than rendering nothing.
542
+ (0, _jsxRuntime.jsxs)(_reactNative.View, {
543
+ style: styles.emptyState,
544
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-store-detail-gone`,
545
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
546
+ size: 32,
547
+ color: _sharedUi.night.color.textTertiary
548
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
549
+ style: styles.emptyTitle,
550
+ children: "Store no longer registered"
551
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
552
+ style: styles.emptyText,
553
+ children: [selectedStoreName, " is not in the registry any more."]
554
+ })]
555
+ });
556
+ }
557
+
429
558
  // Stores browse tab
430
559
  if (activeTab === "stores") {
431
560
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ZustandStoreBrowser.ZustandStoreBrowser, {
432
561
  stores: displayedStores,
433
562
  searchQuery: searchText,
434
- onViewHistory: handleViewHistory,
435
563
  isLoading: storesLoading,
436
- onStoreExpand: onStoreExpand
564
+ onOpenStore: handleOpenStore
437
565
  });
438
566
  }
439
567
 
@@ -441,6 +569,7 @@ function ZustandModal({
441
569
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
442
570
  children: [!isEnabled && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
443
571
  style: styles.disabledBanner,
572
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-banner-disabled`,
444
573
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Power, {
445
574
  size: 14,
446
575
  color: _sharedUi.night.color.warning
@@ -452,11 +581,14 @@ function ZustandModal({
452
581
  style: styles.lockedBanner,
453
582
  onPress: handleUpgradePress,
454
583
  activeOpacity: 0.8,
584
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-locked`,
585
+ accessibilityRole: "button",
455
586
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Lock, {
456
587
  size: 14,
457
588
  color: _sharedUi.night.color.accent
458
589
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
459
590
  style: styles.lockedText,
591
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-locked-text`,
460
592
  children: [lockedChangeCount, " older", " ", lockedChangeCount === 1 ? "change" : "changes", " locked"]
461
593
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
462
594
  style: styles.upgradeBadge,
@@ -467,12 +599,13 @@ function ZustandModal({
467
599
  })]
468
600
  }), visibleChanges.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
469
601
  ref: flatListRef,
602
+ testID: `${_testIDs.ZUSTAND_TEST_ID}-list`,
470
603
  data: visibleChanges,
471
604
  renderItem: renderItem,
472
605
  keyExtractor: keyExtractor,
473
606
  contentContainerStyle: styles.listContent,
474
607
  showsVerticalScrollIndicator: true,
475
- removeClippedSubviews: true,
608
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
476
609
  initialNumToRender: 12,
477
610
  maxToRenderPerBatch: 8,
478
611
  windowSize: 5,
@@ -496,7 +629,15 @@ function ZustandModal({
496
629
  // (inside JsModal's ScrollView wrapper every row mounts — the Network
497
630
  // tool measured 17 JS FPS / 133% CPU at max load from this exact
498
631
  // pattern; see tool-bench batches 2026-07-06).
499
- const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryStore != null || activeTab === "events");
632
+ //
633
+ // The STORES tab owns its scrolling too, for a different reason: the
634
+ // browser hosts the store editor as an absolute-fill overlay over a
635
+ // flex:1 root, and inside JsModal's ScrollView wrapper that root
636
+ // collapses to zero height — the list still paints (iOS overflow), but
637
+ // the editor opens into a 0pt box and never appears. The nested-flex:1-
638
+ // in-JsModal trap; storage's browser disables the wrapper for the same
639
+ // reason.
640
+ const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryStore != null || activeTab === "events" || activeTab === "stores");
500
641
  const body = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
501
642
  style: styles.container,
502
643
  children: renderContent()
@@ -95,47 +95,43 @@ function getPrimaryText(change) {
95
95
  // ({change, onPress} — onPress is a stable useCallback in the modal).
96
96
  const ZustandStateChangeItem = exports.ZustandStateChangeItem = /*#__PURE__*/(0, _react.memo)(function ZustandStateChangeItem({
97
97
  change,
98
- onPress
98
+ onPress,
99
+ testID
99
100
  }) {
100
101
  const statusColor = getCategoryColor(change.category, change.storeName);
101
102
  const statusLabel = getStatusLabel(change.category, change.storeName);
102
103
  const sublabel = getSublabel(change);
103
104
  const primaryText = getPrimaryText(change);
104
105
  const badgeText = getBadgeText(change);
105
- let customBadge = undefined;
106
- if (change.isSlowUpdate) {
107
- customBadge = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
108
- style: styles.badgeContainer,
109
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
110
- style: [styles.badgeText, {
111
- color: statusColor
112
- }],
113
- children: badgeText
114
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
115
- style: styles.slowBadge,
116
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Clock, {
117
- size: 12,
118
- color: _sharedUi.buoyColors.error
119
- })
120
- })]
121
- });
122
- } else if (change.hasStateChange) {
123
- customBadge = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
124
- style: styles.badgeContainer,
125
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
126
- style: [styles.badgeText, {
127
- color: statusColor
128
- }],
129
- children: badgeText
130
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
131
- style: styles.changeBadge,
132
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Zap, {
133
- size: 12,
134
- color: _sharedUi.buoyColors.warning
135
- })
136
- })]
137
- });
138
- }
106
+
107
+ // Always a custom badge, even when there is no icon to hang off it. The
108
+ // alternative — letting CompactRow draw `badgeText` itself — leaves the one
109
+ // row whose badge matters most (a set that changed NOTHING) as the only row
110
+ // in the list with no handle on its badge.
111
+ const customBadge = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
112
+ style: styles.badgeContainer,
113
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
114
+ style: [styles.badgeText, {
115
+ color: statusColor
116
+ }],
117
+ testID: testID ? `${testID}-badge` : undefined,
118
+ children: badgeText
119
+ }), change.isSlowUpdate ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
120
+ style: styles.slowBadge,
121
+ testID: testID ? `${testID}-slow` : undefined,
122
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Clock, {
123
+ size: 12,
124
+ color: _sharedUi.buoyColors.error
125
+ })
126
+ }) : change.hasStateChange ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
127
+ style: styles.changeBadge,
128
+ testID: testID ? `${testID}-change` : undefined,
129
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Zap, {
130
+ size: 12,
131
+ color: _sharedUi.buoyColors.warning
132
+ })
133
+ }) : null]
134
+ });
139
135
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompactRow, {
140
136
  statusDotColor: statusColor,
141
137
  statusLabel: statusLabel,
@@ -145,10 +141,13 @@ const ZustandStateChangeItem = exports.ZustandStateChangeItem = /*#__PURE__*/(0,
145
141
  timestamp: change.timestamp
146
142
  }),
147
143
  customBadge: customBadge,
148
- badgeText: customBadge ? undefined : badgeText,
149
144
  badgeColor: statusColor,
150
145
  showChevron: true,
151
- onPress: () => onPress(change)
146
+ onPress: () => onPress(change),
147
+ testID: testID,
148
+ statusTestID: testID ? `${testID}-status` : undefined,
149
+ statusSublabelTestID: testID ? `${testID}-sublabel` : undefined,
150
+ primaryTestID: testID ? `${testID}-keys` : undefined
152
151
  });
153
152
  });
154
153
  const styles = _reactNative.StyleSheet.create({