@buoy-gg/zustand 7.0.29 → 7.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/lib/commonjs/zustand/components/ZustandActionButton.js +3 -1
  2. package/lib/commonjs/zustand/components/ZustandDetailViewToggle.js +7 -0
  3. package/lib/commonjs/zustand/components/ZustandEventFilterView.js +19 -4
  4. package/lib/commonjs/zustand/components/ZustandModal.js +173 -32
  5. package/lib/commonjs/zustand/components/ZustandStateChangeItem.js +36 -37
  6. package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +22 -5
  7. package/lib/commonjs/zustand/components/ZustandStateInfoView.js +30 -7
  8. package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +23 -126
  9. package/lib/commonjs/zustand/components/ZustandStoreDetailContent.js +471 -0
  10. package/lib/commonjs/zustand/components/ZustandStoreEditorScreen.js +393 -0
  11. package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +54 -0
  12. package/lib/commonjs/zustand/utils/buoyZustandMiddleware.js +71 -7
  13. package/lib/commonjs/zustand/utils/editableState.js +139 -0
  14. package/lib/commonjs/zustand/utils/snapshotProvider.js +10 -37
  15. package/lib/commonjs/zustand/utils/testIDs.js +82 -0
  16. package/lib/commonjs/zustand/utils/zustandStateStore.js +16 -3
  17. package/lib/module/zustand/components/ZustandActionButton.js +3 -1
  18. package/lib/module/zustand/components/ZustandDetailViewToggle.js +7 -0
  19. package/lib/module/zustand/components/ZustandEventFilterView.js +19 -4
  20. package/lib/module/zustand/components/ZustandModal.js +173 -32
  21. package/lib/module/zustand/components/ZustandStateChangeItem.js +36 -37
  22. package/lib/module/zustand/components/ZustandStateDetailContent.js +22 -5
  23. package/lib/module/zustand/components/ZustandStateInfoView.js +30 -7
  24. package/lib/module/zustand/components/ZustandStoreBrowser.js +26 -129
  25. package/lib/module/zustand/components/ZustandStoreDetailContent.js +469 -0
  26. package/lib/module/zustand/components/ZustandStoreEditorScreen.js +389 -0
  27. package/lib/module/zustand/sync/zustandSyncAdapter.js +54 -0
  28. package/lib/module/zustand/utils/buoyZustandMiddleware.js +71 -7
  29. package/lib/module/zustand/utils/editableState.js +131 -0
  30. package/lib/module/zustand/utils/snapshotProvider.js +4 -30
  31. package/lib/module/zustand/utils/testIDs.js +74 -0
  32. package/lib/module/zustand/utils/zustandStateStore.js +16 -3
  33. package/lib/typescript/zustand/components/ZustandActionButton.d.ts +2 -1
  34. package/lib/typescript/zustand/components/ZustandActionButton.d.ts.map +1 -1
  35. package/lib/typescript/zustand/components/ZustandDetailViewToggle.d.ts.map +1 -1
  36. package/lib/typescript/zustand/components/ZustandEventFilterView.d.ts.map +1 -1
  37. package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
  38. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts +6 -0
  39. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts.map +1 -1
  40. package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
  41. package/lib/typescript/zustand/components/ZustandStateInfoView.d.ts.map +1 -1
  42. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +7 -9
  43. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
  44. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts +40 -0
  45. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts.map +1 -0
  46. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts +42 -0
  47. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts.map +1 -0
  48. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +35 -0
  49. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
  50. package/lib/typescript/zustand/types/index.d.ts +13 -0
  51. package/lib/typescript/zustand/types/index.d.ts.map +1 -1
  52. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts +31 -3
  53. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts.map +1 -1
  54. package/lib/typescript/zustand/utils/editableState.d.ts +65 -0
  55. package/lib/typescript/zustand/utils/editableState.d.ts.map +1 -0
  56. package/lib/typescript/zustand/utils/testIDs.d.ts +54 -0
  57. package/lib/typescript/zustand/utils/testIDs.d.ts.map +1 -0
  58. package/lib/typescript/zustand/utils/zustandStateStore.d.ts +1 -0
  59. package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
  60. package/package.json +3 -3
@@ -16,21 +16,36 @@ import { useZustandStateChanges } from "../hooks/useZustandStateChanges";
16
16
  import { ZustandStateChangeItem } from "./ZustandStateChangeItem";
17
17
  import { ZustandStateDetailContent, ZustandStateDetailFooter } from "./ZustandStateDetailContent";
18
18
  import { ZustandStoreBrowser } from "./ZustandStoreBrowser";
19
+ import { ZustandStoreDetailContent } from "./ZustandStoreDetailContent";
19
20
  import { ZustandEventFilterView } from "./ZustandEventFilterView";
21
+ import { ZUSTAND_TEST_ID, buildChangeRowTestIDs } from "../utils/testIDs";
20
22
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
23
+ /**
24
+ * Cell clipping, off in e2e bundles only.
25
+ *
26
+ * `removeClippedSubviews` unmounts the views of rows scrolled out of the
27
+ * window, which is what makes a long change list cheap — and which makes
28
+ * Detox report a perfectly rendered row as un-hittable. The env var is
29
+ * inlined at bundle time, so a bundler that does NOT inline it leaves the
30
+ * production behaviour exactly as it was.
31
+ */
32
+ const CLIP_OFFSCREEN_CELLS = !(typeof process !== "undefined" && process.env?.EXPO_PUBLIC_E2E);
21
33
  function EventsEmptyState({
22
34
  isEnabled
23
35
  }) {
24
36
  return /*#__PURE__*/_jsxs(View, {
25
37
  style: styles.emptyState,
38
+ testID: `${ZUSTAND_TEST_ID}-empty`,
26
39
  children: [/*#__PURE__*/_jsx(Box, {
27
40
  size: 32,
28
41
  color: night.color.textTertiary
29
42
  }), /*#__PURE__*/_jsx(Text, {
30
43
  style: styles.emptyTitle,
44
+ testID: `${ZUSTAND_TEST_ID}-empty-title`,
31
45
  children: "No state changes"
32
46
  }), /*#__PURE__*/_jsx(Text, {
33
47
  style: styles.emptyText,
48
+ testID: `${ZUSTAND_TEST_ID}-empty-text`,
34
49
  children: isEnabled ? "State changes will appear here as stores update." : "Enable capture to start recording state changes"
35
50
  })]
36
51
  });
@@ -54,6 +69,11 @@ export function ZustandModal({
54
69
  const [showProModal, setShowProModal] = useState(false);
55
70
  const [activeTab, setActiveTab] = useState(initialTab);
56
71
  const [selectedHistoryStore, setSelectedHistoryStore] = useState(null);
72
+ /**
73
+ * Which store's DETAIL screen is open (live editor + details + tree —
74
+ * the React Query interaction). Null means the browser list.
75
+ */
76
+ const [selectedStoreName, setSelectedStoreName] = useState(null);
57
77
  const [showFilters, setShowFilters] = useState(false);
58
78
  const [ignoredPatterns, setIgnoredPatterns] = useState(new Set());
59
79
  const {
@@ -79,19 +99,26 @@ export function ZustandModal({
79
99
  return stores.filter(s => !Array.from(ignoredPatterns).some(p => s.name.toLowerCase().includes(p.toLowerCase())));
80
100
  }, [stores, ignoredPatterns]);
81
101
 
102
+ // Scope to a store BEFORE the tier cap, not after.
103
+ //
104
+ // The other way round, the cap was applied to the whole log and a store's
105
+ // history was whatever survived it — so a store with one change, made a
106
+ // while ago, opened an EMPTY history from a card that said "Changes: 1" and
107
+ // offered a "view history" link. The cap is a limit on how much of a list
108
+ // you can see; which list you are looking at is decided first.
109
+ const scopedChanges = useMemo(() => {
110
+ if (!selectedHistoryStore) return displayedChanges;
111
+ return displayedChanges.filter(c => c.storeName === selectedHistoryStore);
112
+ }, [displayedChanges, selectedHistoryStore]);
113
+
82
114
  // Pro is Infinity here, so slice() returns everything and the locked count
83
115
  // floors at 0 — no tier branching needed at the call site.
84
- const visibleChanges = useMemo(() => displayedChanges.slice(0, changeLimit), [displayedChanges, changeLimit]);
85
- const lockedChangeCount = useMemo(() => Math.max(0, displayedChanges.length - changeLimit), [displayedChanges.length, changeLimit]);
116
+ const visibleChanges = useMemo(() => scopedChanges.slice(0, changeLimit), [scopedChanges, changeLimit]);
117
+ const lockedChangeCount = useMemo(() => Math.max(0, scopedChanges.length - changeLimit), [scopedChanges.length, changeLimit]);
86
118
  const hasLockedChanges = lockedChangeCount > 0;
87
119
 
88
- // When viewing a single store's history, scope the change list to that store
89
- const activeChanges = useMemo(() => {
90
- if (selectedHistoryStore) {
91
- return visibleChanges.filter(c => c.storeName === selectedHistoryStore);
92
- }
93
- return visibleChanges;
94
- }, [visibleChanges, selectedHistoryStore]);
120
+ /** The list the detail stepper walks: already scoped, already capped. */
121
+ const activeChanges = visibleChanges;
95
122
 
96
123
  // Selection state — track by ID so new events don't shift position
97
124
  const [selectedChangeId, setSelectedChangeId] = useState(null);
@@ -141,6 +168,16 @@ export function ZustandModal({
141
168
  }));
142
169
  }
143
170
  };
171
+
172
+ /** Drop the query AND put the header back. The X, and only the X. */
173
+ const closeSearch = useCallback(() => {
174
+ setSearchText("");
175
+ setFilter(prev => ({
176
+ ...prev,
177
+ searchText: ""
178
+ }));
179
+ setIsSearchActive(false);
180
+ }, [setFilter]);
144
181
  const handleChangePress = useCallback(change => {
145
182
  setSelectedChangeId(change.id);
146
183
  }, []);
@@ -164,6 +201,16 @@ export function ZustandModal({
164
201
  setSelectedHistoryStore(null);
165
202
  setSelectedChangeId(null);
166
203
  }, []);
204
+ const handleOpenStore = useCallback(storeName => {
205
+ setSelectedStoreName(storeName);
206
+ // The desktop mirror hydrates a store's full state on demand — this is
207
+ // the moment it becomes needed (was fired on card-expand before the
208
+ // detail screen replaced expansion).
209
+ onStoreExpand?.(storeName);
210
+ }, [onStoreExpand]);
211
+ const handleBackFromStore = useCallback(() => {
212
+ setSelectedStoreName(null);
213
+ }, []);
167
214
  const getStoresSnapshot = useCallback(() => {
168
215
  return displayedStores.reduce((acc, store) => {
169
216
  try {
@@ -218,6 +265,7 @@ export function ZustandModal({
218
265
  const handleTabChange = useCallback(tab => {
219
266
  setActiveTab(tab);
220
267
  setSelectedHistoryStore(null);
268
+ setSelectedStoreName(null);
221
269
  setSelectedChangeId(null);
222
270
  setShowFilters(false);
223
271
  if (searchText) {
@@ -230,14 +278,20 @@ export function ZustandModal({
230
278
  setIsSearchActive(false);
231
279
  }, [searchText, setFilter]);
232
280
  const keyExtractor = useCallback(item => item.id, []);
281
+
282
+ // Built for the whole list rather than per row: the ordinal a row carries is
283
+ // a fact about the list around it. See ../utils/testIDs.ts for why a store
284
+ // name alone cannot address a change row.
285
+ const rowTestIDs = useMemo(() => buildChangeRowTestIDs(activeChanges), [activeChanges]);
233
286
  const renderItem = useCallback(({
234
287
  item
235
288
  }) => {
236
289
  return /*#__PURE__*/_jsx(ZustandStateChangeItem, {
237
290
  change: item,
238
- onPress: handleChangePress
291
+ onPress: handleChangePress,
292
+ testID: rowTestIDs.get(item.id)
239
293
  });
240
- }, [handleChangePress]);
294
+ }, [handleChangePress, rowTestIDs]);
241
295
 
242
296
  // ---- Header rendering ----
243
297
 
@@ -250,7 +304,8 @@ export function ZustandModal({
250
304
  onBack: () => setShowFilters(false)
251
305
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
252
306
  title: "Filters",
253
- centered: true
307
+ centered: true,
308
+ testID: `${ZUSTAND_TEST_ID}-filters-title`
254
309
  })]
255
310
  });
256
311
  }
@@ -262,7 +317,8 @@ export function ZustandModal({
262
317
  onBack: handleBack
263
318
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
264
319
  title: `${selectedChange.storeName}/setState`,
265
- centered: true
320
+ centered: true,
321
+ testID: `${ZUSTAND_TEST_ID}-detail-title`
266
322
  })]
267
323
  });
268
324
  }
@@ -274,17 +330,34 @@ export function ZustandModal({
274
330
  onBack: handleBackFromHistory
275
331
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
276
332
  title: `${selectedHistoryStore} History`,
277
- centered: true
333
+ centered: true,
334
+ testID: `${ZUSTAND_TEST_ID}-history-title`
278
335
  }), /*#__PURE__*/_jsx(ModalHeader.Actions, {
279
336
  children: /*#__PURE__*/_jsx(ToolbarCopyButton, {
280
337
  value: getHistorySnapshot,
281
338
  disabled: activeChanges.length === 0,
282
- buttonStyle: styles.headerActionButton
339
+ buttonStyle: styles.headerActionButton,
340
+ testID: `${ZUSTAND_TEST_ID}-copy`
283
341
  })
284
342
  })]
285
343
  });
286
344
  }
287
345
 
346
+ // Store detail header — the live editor screen. AFTER history: history is
347
+ // reached FROM the detail screen and stacks over it, so when both are set
348
+ // history owns the header, and its back lands you back on the detail.
349
+ if (selectedStoreName) {
350
+ return /*#__PURE__*/_jsxs(ModalHeader, {
351
+ children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
352
+ onBack: handleBackFromStore
353
+ }), /*#__PURE__*/_jsx(ModalHeader.Content, {
354
+ title: selectedStoreName,
355
+ centered: true,
356
+ testID: `${ZUSTAND_TEST_ID}-store-detail-title`
357
+ })]
358
+ });
359
+ }
360
+
288
361
  // Main view with tabs
289
362
  return /*#__PURE__*/_jsxs(ModalHeader, {
290
363
  children: [onBack && /*#__PURE__*/_jsx(ModalHeader.Navigation, {
@@ -293,27 +366,42 @@ export function ZustandModal({
293
366
  title: "",
294
367
  children: isSearchActive ? /*#__PURE__*/_jsxs(View, {
295
368
  style: styles.headerSearchContainer,
369
+ testID: `${ZUSTAND_TEST_ID}-search`,
296
370
  children: [/*#__PURE__*/_jsx(Search, {
297
371
  size: 14,
298
372
  color: night.color.textSecondary
299
373
  }), /*#__PURE__*/_jsx(TextInput, {
300
374
  ref: searchInputRef,
375
+ testID: `${ZUSTAND_TEST_ID}-search-field`,
301
376
  style: styles.headerSearchInput,
302
377
  placeholder: activeTab === "stores" ? "Search stores..." : "Search events...",
303
378
  placeholderTextColor: night.color.placeholder,
304
379
  value: searchText,
305
380
  onChangeText: handleSearch,
306
- onSubmitEditing: () => setIsSearchActive(false),
307
- onBlur: () => setIsSearchActive(false),
381
+ onSubmitEditing: () => searchInputRef.current?.blur()
382
+ // Stay open while the query is still narrowing the list.
383
+ //
384
+ // Collapsing on blur hid the reason rows were missing: the
385
+ // header went back to its TABS — whose counts are of the
386
+ // WHOLE capture on the stores side — while the list stayed
387
+ // filtered, so the tool showed "Stores (4)" above a single
388
+ // row and offered nothing to explain the gap. The only way
389
+ // back was to reopen a field the user had no reason to think
390
+ // was still armed.
391
+ ,
392
+ onBlur: () => {
393
+ if (searchText.length === 0) setIsSearchActive(false);
394
+ },
395
+ accessibilityLabel: "Search Zustand stores and events",
308
396
  autoCapitalize: "none",
309
397
  autoCorrect: false,
310
398
  returnKeyType: "search"
311
- }), searchText.length > 0 && /*#__PURE__*/_jsx(TouchableOpacity, {
312
- onPress: () => {
313
- handleSearch("");
314
- setIsSearchActive(false);
315
- },
399
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
400
+ onPress: closeSearch,
316
401
  style: styles.headerSearchClear,
402
+ testID: `${ZUSTAND_TEST_ID}-search-clear`,
403
+ accessibilityRole: "button",
404
+ accessibilityLabel: "Close search",
317
405
  children: /*#__PURE__*/_jsx(X, {
318
406
  size: 14,
319
407
  color: night.color.textSecondary
@@ -328,12 +416,16 @@ export function ZustandModal({
328
416
  label: `Events${displayedChanges.length > 0 ? ` (${displayedChanges.length})` : ""}`
329
417
  }],
330
418
  activeTab: activeTab,
331
- onTabChange: handleTabChange
419
+ onTabChange: handleTabChange,
420
+ testIDPrefix: `${ZUSTAND_TEST_ID}-tab`
332
421
  })
333
422
  }), /*#__PURE__*/_jsxs(ModalHeader.Actions, {
334
423
  children: [/*#__PURE__*/_jsx(TouchableOpacity, {
335
424
  onPress: () => setIsSearchActive(true),
336
425
  style: styles.headerActionButton,
426
+ testID: `${ZUSTAND_TEST_ID}-search-open`,
427
+ accessibilityRole: "button",
428
+ accessibilityLabel: "Search",
337
429
  children: /*#__PURE__*/_jsx(Search, {
338
430
  size: 14,
339
431
  color: night.color.textSecondary
@@ -341,6 +433,9 @@ export function ZustandModal({
341
433
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
342
434
  onPress: () => setShowFilters(true),
343
435
  style: [styles.headerActionButton, hasActiveFilters && styles.headerActionButtonActive],
436
+ testID: `${ZUSTAND_TEST_ID}-filters-open`,
437
+ accessibilityRole: "button",
438
+ accessibilityLabel: "Filters",
344
439
  children: /*#__PURE__*/_jsx(Filter, {
345
440
  size: 14,
346
441
  color: hasActiveFilters ? night.color.accent : night.color.textSecondary
@@ -348,20 +443,24 @@ export function ZustandModal({
348
443
  }), activeTab === "stores" && /*#__PURE__*/_jsx(ToolbarCopyButton, {
349
444
  value: getStoresSnapshot,
350
445
  disabled: displayedStores.length === 0,
351
- buttonStyle: styles.headerActionButton
446
+ buttonStyle: styles.headerActionButton,
447
+ testID: `${ZUSTAND_TEST_ID}-copy`
352
448
  }), activeTab === "events" && /*#__PURE__*/_jsxs(_Fragment, {
353
449
  children: [/*#__PURE__*/_jsx(ToolbarCopyButton, {
354
450
  value: getEventsSnapshot,
355
451
  disabled: displayedChanges.length === 0,
356
- buttonStyle: styles.headerActionButton
452
+ buttonStyle: styles.headerActionButton,
453
+ testID: `${ZUSTAND_TEST_ID}-copy`
357
454
  }), /*#__PURE__*/_jsx(PowerToggleButton, {
358
455
  isEnabled: isEnabled,
359
456
  onToggle: toggleCapture,
360
- accessibilityLabel: "Toggle Zustand state capture"
457
+ accessibilityLabel: "Toggle Zustand state capture",
458
+ testID: `${ZUSTAND_TEST_ID}-power`
361
459
  }), /*#__PURE__*/_jsx(ToolbarClearButton, {
362
460
  onPress: clearChanges,
363
461
  disabled: displayedChanges.length === 0,
364
- buttonStyle: styles.headerActionButton
462
+ buttonStyle: styles.headerActionButton,
463
+ testID: `${ZUSTAND_TEST_ID}-clear`
365
464
  })]
366
465
  })]
367
466
  })]
@@ -396,18 +495,20 @@ export function ZustandModal({
396
495
  // Store history view — filtered to one store
397
496
  if (selectedHistoryStore) {
398
497
  return activeChanges.length > 0 ? /*#__PURE__*/_jsx(FlatList, {
498
+ testID: `${ZUSTAND_TEST_ID}-history-list`,
399
499
  data: activeChanges,
400
500
  renderItem: renderItem,
401
501
  keyExtractor: keyExtractor,
402
502
  contentContainerStyle: styles.listContent,
403
503
  showsVerticalScrollIndicator: true,
404
- removeClippedSubviews: true,
504
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
405
505
  initialNumToRender: 12,
406
506
  maxToRenderPerBatch: 8,
407
507
  windowSize: 5,
408
508
  updateCellsBatchingPeriod: 50
409
509
  }) : /*#__PURE__*/_jsxs(View, {
410
510
  style: styles.emptyState,
511
+ testID: `${ZUSTAND_TEST_ID}-history-empty`,
411
512
  children: [/*#__PURE__*/_jsx(Box, {
412
513
  size: 32,
413
514
  color: night.color.textTertiary
@@ -421,14 +522,41 @@ export function ZustandModal({
421
522
  });
422
523
  }
423
524
 
525
+ // Store detail — live editor + details + read-only tree. After history
526
+ // in this chain for the same reason as the header: history stacks OVER
527
+ // the detail screen it was opened from.
528
+ if (selectedStoreName) {
529
+ const selectedStore = stores.find(s => s.name === selectedStoreName) ?? null;
530
+ return selectedStore ? /*#__PURE__*/_jsx(ZustandStoreDetailContent, {
531
+ store: selectedStore,
532
+ onViewHistory: handleViewHistory
533
+ }) :
534
+ /*#__PURE__*/
535
+ // The store fell out of the registry mid-look (a remote mirror
536
+ // rebuilding, a dev reload). Say so rather than rendering nothing.
537
+ _jsxs(View, {
538
+ style: styles.emptyState,
539
+ testID: `${ZUSTAND_TEST_ID}-store-detail-gone`,
540
+ children: [/*#__PURE__*/_jsx(Box, {
541
+ size: 32,
542
+ color: night.color.textTertiary
543
+ }), /*#__PURE__*/_jsx(Text, {
544
+ style: styles.emptyTitle,
545
+ children: "Store no longer registered"
546
+ }), /*#__PURE__*/_jsxs(Text, {
547
+ style: styles.emptyText,
548
+ children: [selectedStoreName, " is not in the registry any more."]
549
+ })]
550
+ });
551
+ }
552
+
424
553
  // Stores browse tab
425
554
  if (activeTab === "stores") {
426
555
  return /*#__PURE__*/_jsx(ZustandStoreBrowser, {
427
556
  stores: displayedStores,
428
557
  searchQuery: searchText,
429
- onViewHistory: handleViewHistory,
430
558
  isLoading: storesLoading,
431
- onStoreExpand: onStoreExpand
559
+ onOpenStore: handleOpenStore
432
560
  });
433
561
  }
434
562
 
@@ -436,6 +564,7 @@ export function ZustandModal({
436
564
  return /*#__PURE__*/_jsxs(_Fragment, {
437
565
  children: [!isEnabled && /*#__PURE__*/_jsxs(View, {
438
566
  style: styles.disabledBanner,
567
+ testID: `${ZUSTAND_TEST_ID}-banner-disabled`,
439
568
  children: [/*#__PURE__*/_jsx(Power, {
440
569
  size: 14,
441
570
  color: night.color.warning
@@ -447,11 +576,14 @@ export function ZustandModal({
447
576
  style: styles.lockedBanner,
448
577
  onPress: handleUpgradePress,
449
578
  activeOpacity: 0.8,
579
+ testID: `${ZUSTAND_TEST_ID}-locked`,
580
+ accessibilityRole: "button",
450
581
  children: [/*#__PURE__*/_jsx(Lock, {
451
582
  size: 14,
452
583
  color: night.color.accent
453
584
  }), /*#__PURE__*/_jsxs(Text, {
454
585
  style: styles.lockedText,
586
+ testID: `${ZUSTAND_TEST_ID}-locked-text`,
455
587
  children: [lockedChangeCount, " older", " ", lockedChangeCount === 1 ? "change" : "changes", " locked"]
456
588
  }), /*#__PURE__*/_jsx(View, {
457
589
  style: styles.upgradeBadge,
@@ -462,12 +594,13 @@ export function ZustandModal({
462
594
  })]
463
595
  }), visibleChanges.length > 0 ? /*#__PURE__*/_jsx(FlatList, {
464
596
  ref: flatListRef,
597
+ testID: `${ZUSTAND_TEST_ID}-list`,
465
598
  data: visibleChanges,
466
599
  renderItem: renderItem,
467
600
  keyExtractor: keyExtractor,
468
601
  contentContainerStyle: styles.listContent,
469
602
  showsVerticalScrollIndicator: true,
470
- removeClippedSubviews: true,
603
+ removeClippedSubviews: CLIP_OFFSCREEN_CELLS,
471
604
  initialNumToRender: 12,
472
605
  maxToRenderPerBatch: 8,
473
606
  windowSize: 5,
@@ -491,7 +624,15 @@ export function ZustandModal({
491
624
  // (inside JsModal's ScrollView wrapper every row mounts — the Network
492
625
  // tool measured 17 JS FPS / 133% CPU at max load from this exact
493
626
  // pattern; see tool-bench batches 2026-07-06).
494
- const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryStore != null || activeTab === "events");
627
+ //
628
+ // The STORES tab owns its scrolling too, for a different reason: the
629
+ // browser hosts the store editor as an absolute-fill overlay over a
630
+ // flex:1 root, and inside JsModal's ScrollView wrapper that root
631
+ // collapses to zero height — the list still paints (iOS overflow), but
632
+ // the editor opens into a 0pt box and never appears. The nested-flex:1-
633
+ // in-JsModal trap; storage's browser disables the wrapper for the same
634
+ // reason.
635
+ const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryStore != null || activeTab === "events" || activeTab === "stores");
495
636
  const body = /*#__PURE__*/_jsx(View, {
496
637
  style: styles.container,
497
638
  children: renderContent()
@@ -91,47 +91,43 @@ function getPrimaryText(change) {
91
91
  // ({change, onPress} — onPress is a stable useCallback in the modal).
92
92
  export const ZustandStateChangeItem = /*#__PURE__*/memo(function ZustandStateChangeItem({
93
93
  change,
94
- onPress
94
+ onPress,
95
+ testID
95
96
  }) {
96
97
  const statusColor = getCategoryColor(change.category, change.storeName);
97
98
  const statusLabel = getStatusLabel(change.category, change.storeName);
98
99
  const sublabel = getSublabel(change);
99
100
  const primaryText = getPrimaryText(change);
100
101
  const badgeText = getBadgeText(change);
101
- let customBadge = undefined;
102
- if (change.isSlowUpdate) {
103
- customBadge = /*#__PURE__*/_jsxs(View, {
104
- style: styles.badgeContainer,
105
- children: [/*#__PURE__*/_jsx(Text, {
106
- style: [styles.badgeText, {
107
- color: statusColor
108
- }],
109
- children: badgeText
110
- }), /*#__PURE__*/_jsx(View, {
111
- style: styles.slowBadge,
112
- children: /*#__PURE__*/_jsx(Clock, {
113
- size: 12,
114
- color: buoyColors.error
115
- })
116
- })]
117
- });
118
- } else if (change.hasStateChange) {
119
- customBadge = /*#__PURE__*/_jsxs(View, {
120
- style: styles.badgeContainer,
121
- children: [/*#__PURE__*/_jsx(Text, {
122
- style: [styles.badgeText, {
123
- color: statusColor
124
- }],
125
- children: badgeText
126
- }), /*#__PURE__*/_jsx(View, {
127
- style: styles.changeBadge,
128
- children: /*#__PURE__*/_jsx(Zap, {
129
- size: 12,
130
- color: buoyColors.warning
131
- })
132
- })]
133
- });
134
- }
102
+
103
+ // Always a custom badge, even when there is no icon to hang off it. The
104
+ // alternative — letting CompactRow draw `badgeText` itself — leaves the one
105
+ // row whose badge matters most (a set that changed NOTHING) as the only row
106
+ // in the list with no handle on its badge.
107
+ const customBadge = /*#__PURE__*/_jsxs(View, {
108
+ style: styles.badgeContainer,
109
+ children: [/*#__PURE__*/_jsx(Text, {
110
+ style: [styles.badgeText, {
111
+ color: statusColor
112
+ }],
113
+ testID: testID ? `${testID}-badge` : undefined,
114
+ children: badgeText
115
+ }), change.isSlowUpdate ? /*#__PURE__*/_jsx(View, {
116
+ style: styles.slowBadge,
117
+ testID: testID ? `${testID}-slow` : undefined,
118
+ children: /*#__PURE__*/_jsx(Clock, {
119
+ size: 12,
120
+ color: buoyColors.error
121
+ })
122
+ }) : change.hasStateChange ? /*#__PURE__*/_jsx(View, {
123
+ style: styles.changeBadge,
124
+ testID: testID ? `${testID}-change` : undefined,
125
+ children: /*#__PURE__*/_jsx(Zap, {
126
+ size: 12,
127
+ color: buoyColors.warning
128
+ })
129
+ }) : null]
130
+ });
135
131
  return /*#__PURE__*/_jsx(CompactRow, {
136
132
  statusDotColor: statusColor,
137
133
  statusLabel: statusLabel,
@@ -141,10 +137,13 @@ export const ZustandStateChangeItem = /*#__PURE__*/memo(function ZustandStateCha
141
137
  timestamp: change.timestamp
142
138
  }),
143
139
  customBadge: customBadge,
144
- badgeText: customBadge ? undefined : badgeText,
145
140
  badgeColor: statusColor,
146
141
  showChevron: true,
147
- onPress: () => onPress(change)
142
+ onPress: () => onPress(change),
143
+ testID: testID,
144
+ statusTestID: testID ? `${testID}-status` : undefined,
145
+ statusSublabelTestID: testID ? `${testID}-sublabel` : undefined,
146
+ primaryTestID: testID ? `${testID}-keys` : undefined
148
147
  });
149
148
  });
150
149
  const styles = StyleSheet.create({
@@ -19,6 +19,7 @@ import { ZustandDetailViewToggle } from "./ZustandDetailViewToggle";
19
19
  import { ZustandStateInfoView } from "./ZustandStateInfoView";
20
20
  import { DiffModeTabs, CompareBar } from "@buoy-gg/shared-ui";
21
21
  import { zustandStateStore } from "../utils/zustandStateStore";
22
+ import { ZUSTAND_TEST_ID } from "../utils/testIDs";
22
23
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
24
  function getChangeColor(change) {
24
25
  return zustandStateStore.getStoreColor(change.storeName);
@@ -117,6 +118,7 @@ export function ZustandStateDetailContent({
117
118
  const parsed = parseValue(change.nextState);
118
119
  return /*#__PURE__*/_jsxs(View, {
119
120
  style: styles.contentCard,
121
+ testID: `${ZUSTAND_TEST_ID}-state-card`,
120
122
  children: [/*#__PURE__*/_jsxs(View, {
121
123
  style: styles.valueHeader,
122
124
  children: [/*#__PURE__*/_jsx(Text, {
@@ -138,6 +140,7 @@ export function ZustandStateDetailContent({
138
140
  style: styles.typeBadge,
139
141
  children: /*#__PURE__*/_jsx(Text, {
140
142
  style: styles.typeText,
143
+ testID: `${ZUSTAND_TEST_ID}-state-type`,
141
144
  children: stateType.toUpperCase()
142
145
  })
143
146
  })]
@@ -189,7 +192,14 @@ export function ZustandStateDetailContent({
189
192
  const handleFooterNext = useCallback(() => {
190
193
  onIndexChange(Math.max(0, selectedIndex - 1));
191
194
  }, [selectedIndex, onIndexChange]);
192
- const diffDisabled = totalChanges <= 1;
195
+
196
+ // The diff compares THIS change's prevState/nextState — both live on the
197
+ // change itself, so it needs no siblings. Gating on `changes.length` greyed
198
+ // DIFF out for every zustand event opened from the Events timeline, which
199
+ // hands this component a one-element array. Only a change with no captured
200
+ // previous state (initial creation) has nothing to compare.
201
+ const diffDisabled = change.prevState === undefined;
202
+ const showInternalFooter = !disableInternalFooter && totalChanges > 1;
193
203
  return /*#__PURE__*/_jsxs(View, {
194
204
  style: styles.container,
195
205
  children: [/*#__PURE__*/_jsx(ZustandDetailViewToggle, {
@@ -197,7 +207,7 @@ export function ZustandStateDetailContent({
197
207
  onViewChange: setActiveView,
198
208
  diffDisabled: diffDisabled
199
209
  }), /*#__PURE__*/_jsxs(View, {
200
- style: styles.contentArea,
210
+ style: [styles.contentArea, showInternalFooter && styles.contentAreaFooterPad],
201
211
  children: [activeView === "change" && /*#__PURE__*/_jsx(ZustandStateInfoView, {
202
212
  change: change
203
213
  }), activeView === "state" && /*#__PURE__*/_jsx(View, {
@@ -205,19 +215,22 @@ export function ZustandStateDetailContent({
205
215
  children: renderStateView()
206
216
  }), activeView === "diff" && /*#__PURE__*/_jsxs(View, {
207
217
  style: styles.diffContainer,
218
+ testID: `${ZUSTAND_TEST_ID}-diff`,
208
219
  children: [/*#__PURE__*/_jsx(DiffModeTabs, {
209
220
  tabs: DIFF_MODE_TABS,
210
221
  activeTab: diffMode,
211
- onTabChange: setDiffMode
222
+ onTabChange: setDiffMode,
223
+ testIDPrefix: `${ZUSTAND_TEST_ID}-diff-tab`
212
224
  }), /*#__PURE__*/_jsx(CompareBar, {
213
225
  leftEvent: leftEvent,
214
226
  rightEvent: rightEvent
215
227
  }), /*#__PURE__*/_jsx(View, {
216
228
  style: styles.diffContent,
229
+ testID: `${ZUSTAND_TEST_ID}-diff-content`,
217
230
  children: renderDiffContent()
218
231
  })]
219
232
  })]
220
- }), !disableInternalFooter && totalChanges > 1 && /*#__PURE__*/_jsx(EventStepperFooter, {
233
+ }), showInternalFooter && /*#__PURE__*/_jsx(EventStepperFooter, {
221
234
  currentIndex: chronologicalNumber - 1,
222
235
  totalItems: totalChanges,
223
236
  onPrevious: handleFooterPrevious,
@@ -257,7 +270,11 @@ const styles = StyleSheet.create({
257
270
  backgroundColor: "transparent"
258
271
  },
259
272
  contentArea: {
260
- flex: 1,
273
+ flex: 1
274
+ },
275
+ // Clearance for the absolute EventStepperFooter — only when it renders,
276
+ // otherwise hosts like the Events tool get a dead band at the sheet bottom.
277
+ contentAreaFooterPad: {
261
278
  paddingBottom: 96
262
279
  },
263
280
  stateContainer: {