@buoy-gg/redux 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 (44) hide show
  1. package/lib/commonjs/index.js +25 -0
  2. package/lib/commonjs/redux/components/ReduxActionButton.js +3 -1
  3. package/lib/commonjs/redux/components/ReduxActionDetailContent.js +60 -6
  4. package/lib/commonjs/redux/components/ReduxActionInfoView.js +61 -12
  5. package/lib/commonjs/redux/components/ReduxActionItem.js +20 -5
  6. package/lib/commonjs/redux/components/ReduxDetailViewToggle.js +7 -0
  7. package/lib/commonjs/redux/components/ReduxModal.js +168 -32
  8. package/lib/commonjs/redux/utils/activeStoreRegistry.js +31 -0
  9. package/lib/commonjs/redux/utils/autoInstrument.js +23 -40
  10. package/lib/commonjs/redux/utils/buoyReduxMiddleware.js +36 -9
  11. package/lib/commonjs/redux/utils/devtoolsHook.js +4 -0
  12. package/lib/commonjs/redux/utils/testIDs.js +75 -0
  13. package/lib/module/index.js +5 -0
  14. package/lib/module/redux/components/ReduxActionButton.js +3 -1
  15. package/lib/module/redux/components/ReduxActionDetailContent.js +60 -6
  16. package/lib/module/redux/components/ReduxActionInfoView.js +63 -14
  17. package/lib/module/redux/components/ReduxActionItem.js +20 -5
  18. package/lib/module/redux/components/ReduxDetailViewToggle.js +7 -0
  19. package/lib/module/redux/components/ReduxModal.js +170 -33
  20. package/lib/module/redux/utils/activeStoreRegistry.js +29 -0
  21. package/lib/module/redux/utils/autoInstrument.js +8 -40
  22. package/lib/module/redux/utils/buoyReduxMiddleware.js +32 -10
  23. package/lib/module/redux/utils/devtoolsHook.js +5 -1
  24. package/lib/module/redux/utils/testIDs.js +68 -0
  25. package/lib/typescript/index.d.ts +1 -0
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/redux/components/ReduxActionButton.d.ts +2 -1
  28. package/lib/typescript/redux/components/ReduxActionButton.d.ts.map +1 -1
  29. package/lib/typescript/redux/components/ReduxActionDetailContent.d.ts.map +1 -1
  30. package/lib/typescript/redux/components/ReduxActionInfoView.d.ts.map +1 -1
  31. package/lib/typescript/redux/components/ReduxActionItem.d.ts +15 -0
  32. package/lib/typescript/redux/components/ReduxActionItem.d.ts.map +1 -1
  33. package/lib/typescript/redux/components/ReduxDetailViewToggle.d.ts.map +1 -1
  34. package/lib/typescript/redux/components/ReduxModal.d.ts.map +1 -1
  35. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts +27 -0
  36. package/lib/typescript/redux/utils/activeStoreRegistry.d.ts.map +1 -1
  37. package/lib/typescript/redux/utils/autoInstrument.d.ts +7 -11
  38. package/lib/typescript/redux/utils/autoInstrument.d.ts.map +1 -1
  39. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts +17 -3
  40. package/lib/typescript/redux/utils/buoyReduxMiddleware.d.ts.map +1 -1
  41. package/lib/typescript/redux/utils/devtoolsHook.d.ts.map +1 -1
  42. package/lib/typescript/redux/utils/testIDs.d.ts +51 -0
  43. package/lib/typescript/redux/utils/testIDs.d.ts.map +1 -0
  44. package/package.json +4 -4
@@ -15,6 +15,7 @@ var _reduxActionStore = require("../utils/reduxActionStore");
15
15
  var _useAutoInstrumentRedux = require("../hooks/useAutoInstrumentRedux");
16
16
  var _ReduxActionItem = require("./ReduxActionItem");
17
17
  var _ReduxActionDetailContent = require("./ReduxActionDetailContent");
18
+ var _testIDs = require("../utils/testIDs");
18
19
  var _jsxRuntime = require("react/jsx-runtime");
19
20
  /**
20
21
  * Main Redux DevTools modal
@@ -29,20 +30,31 @@ var _jsxRuntime = require("react/jsx-runtime");
29
30
  * anonymous tier. Kept because it is part of the package's public exports.
30
31
  */
31
32
  const FREE_TIER_ACTION_LIMIT = exports.FREE_TIER_ACTION_LIMIT = _sharedUi.TIER_LIMITS["redux.actions"].free;
33
+ /**
34
+ * Why this takes the query as well as the enabled flag: "nothing was captured"
35
+ * and "nothing matched what you typed" are different problems, and an empty
36
+ * list under a live search reads as a dead tool. Naming the query back is what
37
+ * makes the difference legible without clearing the field to find out.
38
+ */
32
39
  function EmptyState({
33
- isEnabled
40
+ isEnabled,
41
+ query
34
42
  }) {
43
+ const isFiltered = query.length > 0;
35
44
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
36
45
  style: styles.emptyState,
46
+ testID: `${_testIDs.REDUX_TEST_ID}-empty`,
37
47
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Zap, {
38
48
  size: 32,
39
49
  color: _sharedUi.night.color.textTertiary
40
50
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
41
51
  style: styles.emptyTitle,
42
- children: "No Redux actions"
52
+ testID: `${_testIDs.REDUX_TEST_ID}-empty-title`,
53
+ children: isFiltered ? "No Matches" : "No Redux actions"
43
54
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
44
55
  style: styles.emptyText,
45
- children: isEnabled ? "Actions will appear here as they are dispatched" : "Enable capture to start recording actions"
56
+ testID: `${_testIDs.REDUX_TEST_ID}-empty-text`,
57
+ children: isFiltered ? `No action type contains "${query}"` : isEnabled ? "Actions will appear here as they are dispatched" : "Enable capture to start recording actions"
46
58
  })]
47
59
  });
48
60
  }
@@ -85,14 +97,27 @@ function ReduxModal({
85
97
  const lockedActionCount = (0, _react.useMemo)(() => Math.max(0, filteredActions.length - actionLimit), [filteredActions.length, actionLimit]);
86
98
  const hasLockedActions = lockedActionCount > 0;
87
99
 
88
- // Selection state - track index for navigation
89
- const [selectedActionIndex, setSelectedActionIndex] = (0, _react.useState)(null);
90
- const actionsRef = (0, _react.useRef)([]);
91
-
92
- // Keep actions ref in sync (using visible actions for navigation)
93
- actionsRef.current = visibleActions;
94
-
95
- // Get selected action from index
100
+ // Selection state, held as an action ID rather than as a list position.
101
+ //
102
+ // This list grows at the HEAD: every dispatch prepends a row and pushes
103
+ // everything below it down one. An index therefore names a different action
104
+ // a moment after it is taken — so a detail page opened while the app was
105
+ // still dispatching silently became a detail page for some OTHER action,
106
+ // with no transition and nothing to say it had happened. A devtool that
107
+ // quietly swaps what it is showing you is worse than one that shows nothing.
108
+ //
109
+ // The index is still what the stepper and the diff's "previous action" are
110
+ // expressed in — both are genuinely about position — so it is derived here
111
+ // and re-derived whenever the list moves.
112
+ const [selectedActionId, setSelectedActionId] = (0, _react.useState)(null);
113
+ const selectedActionIndex = (0, _react.useMemo)(() => {
114
+ if (selectedActionId === null) return null;
115
+ const index = visibleActions.findIndex(a => a.id === selectedActionId);
116
+ // Below zero means the action is gone — cleared, or aged out past the tier
117
+ // cap. Falling back to the list is the honest outcome; the alternative is
118
+ // showing whatever moved into its place.
119
+ return index >= 0 ? index : null;
120
+ }, [selectedActionId, visibleActions]);
96
121
  const selectedAction = selectedActionIndex !== null ? visibleActions[selectedActionIndex] : null;
97
122
 
98
123
  // Notify the host on selection (keyed by action id so re-renders with the
@@ -116,7 +141,7 @@ function ReduxModal({
116
141
  const index = visibleActions.findIndex(a => a.id === initialSelectedActionId);
117
142
  if (index < 0) return;
118
143
  appliedInitialSelectionRef.current = initialSelectedActionId;
119
- setSelectedActionIndex(index);
144
+ setSelectedActionId(initialSelectedActionId);
120
145
  }, [initialSelectedActionId, visibleActions]);
121
146
  const [searchText, setSearchText] = (0, _react.useState)("");
122
147
  const [isSearchActive, setIsSearchActive] = (0, _react.useState)(false);
@@ -132,19 +157,33 @@ function ReduxModal({
132
157
  searchText: text
133
158
  }));
134
159
  };
160
+
161
+ /** Drop the query AND put the header back — the ✕ means "stop filtering". */
162
+ const closeSearch = (0, _react.useCallback)(() => {
163
+ setSearchText("");
164
+ setFilter(prev => ({
165
+ ...prev,
166
+ searchText: ""
167
+ }));
168
+ setIsSearchActive(false);
169
+ }, [setFilter]);
135
170
  const handleActionPress = (0, _react.useCallback)(action => {
136
- const index = actionsRef.current.findIndex(a => a.id === action.id);
137
- setSelectedActionIndex(index >= 0 ? index : 0);
171
+ setSelectedActionId(action.id);
138
172
  }, []);
139
173
  const handleUpgradePress = (0, _react.useCallback)(() => {
140
174
  setShowProModal(true);
141
175
  }, []);
142
176
  const handleBack = (0, _react.useCallback)(() => {
143
- setSelectedActionIndex(null);
177
+ setSelectedActionId(null);
144
178
  }, []);
179
+
180
+ // The stepper and the async timeline both navigate by POSITION; the
181
+ // selection is kept by identity, so the position is resolved to an id here,
182
+ // once, at the moment of the tap.
145
183
  const handleIndexChange = (0, _react.useCallback)(newIndex => {
146
- setSelectedActionIndex(newIndex);
147
- }, []);
184
+ const next = visibleActions[newIndex];
185
+ if (next) setSelectedActionId(next.id);
186
+ }, [visibleActions]);
148
187
  const handleCopyAll = async () => {
149
188
  if (!isPro) {
150
189
  setShowProModal(true);
@@ -166,14 +205,49 @@ function ReduxModal({
166
205
 
167
206
  // FlatList optimization
168
207
  const keyExtractor = (0, _react.useCallback)(item => item.id, []);
208
+
209
+ // An action row's handle carries an ordinal among rows of the SAME type, and
210
+ // an ordinal is a fact about the list rather than about the row — see
211
+ // ../utils/testIDs.ts for why a type alone cannot address a redux row.
212
+ const rowTestIDs = (0, _react.useMemo)(() => (0, _testIDs.buildActionRowTestIDs)(visibleActions), [visibleActions]);
213
+
214
+ // How many captured actions share each requestId, i.e. how far along each
215
+ // async operation is.
216
+ //
217
+ // Computed HERE rather than in the row, and passed down, because a row's
218
+ // async badge is a fact about OTHER rows. `pending` is captured and drawn
219
+ // while it is still alone in the world; `fulfilled` arrives a moment later
220
+ // and makes the earlier row's badge wrong. `ReduxActionItem` is memoized on
221
+ // its props and the action object it holds never changes identity, so
222
+ // nothing about that second arrival reached the first row — the pending half
223
+ // of every settled thunk permanently showed no flow badge, which is exactly
224
+ // backwards from what the badge is for.
225
+ //
226
+ // One pass over the store instead of a per-row scan: the previous shape was
227
+ // O(rows x actions) at 200 actions, on a list that re-renders on every
228
+ // dispatch.
229
+ const linkedCounts = (0, _react.useMemo)(() => {
230
+ const counts = new Map();
231
+ for (const action of _reduxActionStore.reduxActionStore.getActions()) {
232
+ const requestId = action.meta?.requestId;
233
+ if (!requestId) continue;
234
+ counts.set(requestId, (counts.get(requestId) ?? 0) + 1);
235
+ }
236
+ return counts;
237
+ // `filteredActions` takes a new identity whenever the store notifies, which
238
+ // is the only moment these counts can change.
239
+ }, [filteredActions]);
169
240
  const renderItem = (0, _react.useCallback)(({
170
241
  item
171
242
  }) => {
243
+ const requestId = item.meta?.requestId;
172
244
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ReduxActionItem.ReduxActionItem, {
173
245
  action: item,
174
- onPress: handleActionPress
246
+ onPress: handleActionPress,
247
+ linkedCount: requestId ? linkedCounts.get(requestId) ?? 0 : 0,
248
+ testID: rowTestIDs.get(item.id)
175
249
  });
176
- }, [handleActionPress]);
250
+ }, [handleActionPress, rowTestIDs, linkedCounts]);
177
251
  const renderHeaderContent = () => {
178
252
  // Detail view header - show action type
179
253
  if (selectedAction) {
@@ -182,7 +256,8 @@ function ReduxModal({
182
256
  onBack: handleBack
183
257
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
184
258
  title: selectedAction.type,
185
- centered: true
259
+ centered: true,
260
+ testID: `${_testIDs.REDUX_TEST_ID}-detail-title`
186
261
  })]
187
262
  });
188
263
  }
@@ -195,27 +270,42 @@ function ReduxModal({
195
270
  title: "",
196
271
  children: isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
197
272
  style: styles.headerSearchContainer,
273
+ testID: `${_testIDs.REDUX_TEST_ID}-search`,
198
274
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
199
275
  size: 14,
200
276
  color: _sharedUi.night.color.textSecondary
201
277
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
202
278
  ref: searchInputRef,
279
+ testID: `${_testIDs.REDUX_TEST_ID}-search-field`,
203
280
  style: styles.headerSearchInput,
204
281
  placeholder: "Search action types...",
205
282
  placeholderTextColor: _sharedUi.night.color.textTertiary,
206
283
  value: searchText,
207
284
  onChangeText: handleSearch,
208
- onSubmitEditing: () => setIsSearchActive(false),
209
- onBlur: () => setIsSearchActive(false),
285
+ onSubmitEditing: () => searchInputRef.current?.blur()
286
+ // Stay open while the query still filters the list.
287
+ //
288
+ // Collapsing it on blur hid the reason rows were missing: the
289
+ // header went back to its chips — which count the WHOLE
290
+ // capture, not the filtered list — while the list stayed
291
+ // narrowed, so the tool showed "4 actions" above a single row
292
+ // and offered nothing to explain the gap. The only way back
293
+ // was to reopen a field the user had no reason to think was
294
+ // still armed.
295
+ ,
296
+ onBlur: () => {
297
+ if (searchText.length === 0) setIsSearchActive(false);
298
+ },
299
+ accessibilityLabel: "Search action types",
210
300
  autoCapitalize: "none",
211
301
  autoCorrect: false,
212
302
  returnKeyType: "search"
213
- }), searchText.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
214
- onPress: () => {
215
- handleSearch("");
216
- setIsSearchActive(false);
217
- },
303
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
304
+ onPress: closeSearch,
218
305
  style: styles.headerSearchClear,
306
+ testID: `${_testIDs.REDUX_TEST_ID}-search-clear`,
307
+ accessibilityRole: "button",
308
+ accessibilityLabel: "Close search",
219
309
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.X, {
220
310
  size: 14,
221
311
  color: _sharedUi.night.color.textSecondary
@@ -229,6 +319,12 @@ function ReduxModal({
229
319
  ...prev,
230
320
  onlyWithChanges: false
231
321
  })),
322
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-all`,
323
+ accessibilityRole: "button",
324
+ accessibilityLabel: `Show all actions, ${stats.totalActions} captured`,
325
+ accessibilityState: {
326
+ selected: !filter.onlyWithChanges
327
+ },
232
328
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
233
329
  style: styles.headerChipLabel,
234
330
  children: "ALL"
@@ -236,6 +332,7 @@ function ReduxModal({
236
332
  style: [styles.headerChipValue, {
237
333
  color: _sharedUi.night.color.text
238
334
  }],
335
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-all-value`,
239
336
  children: stats.totalActions
240
337
  })]
241
338
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
@@ -244,6 +341,12 @@ function ReduxModal({
244
341
  ...prev,
245
342
  onlyWithChanges: true
246
343
  })),
344
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-changed`,
345
+ accessibilityRole: "button",
346
+ accessibilityLabel: `Show only actions that changed state, ${stats.actionsWithChanges} of them`,
347
+ accessibilityState: {
348
+ selected: !!filter.onlyWithChanges
349
+ },
247
350
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Zap, {
248
351
  size: 12,
249
352
  color: _sharedUi.macOSColors.semantic.warning
@@ -251,10 +354,13 @@ function ReduxModal({
251
354
  style: [styles.headerChipValue, {
252
355
  color: _sharedUi.macOSColors.semantic.warning
253
356
  }],
357
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-changed-value`,
254
358
  children: stats.actionsWithChanges
255
359
  })]
256
360
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
257
361
  style: styles.headerChip,
362
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-types`,
363
+ accessibilityLabel: `${stats.uniqueActionTypes} distinct action types`,
258
364
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CheckCircle, {
259
365
  size: 12,
260
366
  color: _sharedUi.macOSColors.semantic.success
@@ -262,22 +368,32 @@ function ReduxModal({
262
368
  style: [styles.headerChipValue, {
263
369
  color: _sharedUi.macOSColors.semantic.success
264
370
  }],
371
+ testID: `${_testIDs.REDUX_TEST_ID}-chip-types-value`,
265
372
  children: stats.uniqueActionTypes
266
373
  })]
267
374
  })]
268
375
  })
269
376
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
270
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
377
+ children: [!isSearchActive && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
271
378
  onPress: () => setIsSearchActive(true),
272
- style: styles.headerActionButton,
379
+ style: [styles.headerActionButton, searchText.length > 0 && styles.headerActionButtonActive],
380
+ testID: `${_testIDs.REDUX_TEST_ID}-search-open`,
381
+ accessibilityRole: "button",
382
+ accessibilityLabel: "Search action types",
273
383
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
274
384
  size: 14,
275
- color: _sharedUi.night.color.textSecondary
385
+ color: searchText.length > 0 ? _sharedUi.night.color.accent : _sharedUi.night.color.textSecondary
276
386
  })
277
387
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
278
388
  onPress: handleCopyAll,
279
389
  style: [styles.headerActionButton, filteredActions.length === 0 && styles.headerActionButtonDisabled],
280
390
  disabled: filteredActions.length === 0,
391
+ testID: `${_testIDs.REDUX_TEST_ID}-copy`,
392
+ accessibilityRole: "button",
393
+ accessibilityLabel: "Copy all actions",
394
+ accessibilityState: {
395
+ disabled: filteredActions.length === 0
396
+ },
281
397
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Copy, {
282
398
  size: 14,
283
399
  color: filteredActions.length > 0 ? _sharedUi.night.color.textSecondary : _sharedUi.night.color.textTertiary
@@ -285,11 +401,18 @@ function ReduxModal({
285
401
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
286
402
  isEnabled: isEnabled,
287
403
  onToggle: toggleCapture,
288
- accessibilityLabel: "Toggle Redux action capture"
404
+ accessibilityLabel: "Toggle Redux action capture",
405
+ testID: `${_testIDs.REDUX_TEST_ID}-power`
289
406
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
290
407
  onPress: clearActions,
291
408
  style: [styles.headerActionButton, filteredActions.length === 0 && styles.headerActionButtonDisabled],
292
409
  disabled: filteredActions.length === 0,
410
+ testID: `${_testIDs.REDUX_TEST_ID}-clear`,
411
+ accessibilityRole: "button",
412
+ accessibilityLabel: "Clear actions",
413
+ accessibilityState: {
414
+ disabled: filteredActions.length === 0
415
+ },
293
416
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Trash2, {
294
417
  size: 14,
295
418
  color: filteredActions.length > 0 ? _sharedUi.night.color.textTertiary : _sharedUi.night.color.textTertiary
@@ -325,6 +448,7 @@ function ReduxModal({
325
448
  }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
326
449
  children: [instrumentError && !isInstrumented && !_reduxActionStore.reduxActionStore.isCaptureSuppressed() && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
327
450
  style: styles.errorBanner,
451
+ testID: `${_testIDs.REDUX_TEST_ID}-banner-error`,
328
452
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.AlertTriangle, {
329
453
  size: 14,
330
454
  color: _sharedUi.night.color.danger
@@ -334,6 +458,7 @@ function ReduxModal({
334
458
  })]
335
459
  }), !isEnabled && isInstrumented && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
336
460
  style: styles.disabledBanner,
461
+ testID: `${_testIDs.REDUX_TEST_ID}-banner-disabled`,
337
462
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Power, {
338
463
  size: 14,
339
464
  color: _sharedUi.night.color.warning
@@ -343,6 +468,7 @@ function ReduxModal({
343
468
  })]
344
469
  }), captureMechanism === "patch" && !_reduxActionStore.reduxActionStore.isCaptureSuppressed() && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
345
470
  style: styles.disabledBanner,
471
+ testID: `${_testIDs.REDUX_TEST_ID}-banner-patch`,
346
472
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.AlertTriangle, {
347
473
  size: 14,
348
474
  color: _sharedUi.night.color.warning
@@ -354,11 +480,15 @@ function ReduxModal({
354
480
  style: styles.lockedBanner,
355
481
  onPress: handleUpgradePress,
356
482
  activeOpacity: 0.8,
483
+ testID: `${_testIDs.REDUX_TEST_ID}-locked`,
484
+ accessibilityRole: "button",
485
+ accessibilityLabel: `${lockedActionCount} older ${lockedActionCount === 1 ? "action" : "actions"} locked. Upgrade to see them.`,
357
486
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Lock, {
358
487
  size: 14,
359
488
  color: _sharedUi.night.color.accent
360
489
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
361
490
  style: styles.lockedText,
491
+ testID: `${_testIDs.REDUX_TEST_ID}-locked-text`,
362
492
  children: [lockedActionCount, " older ", lockedActionCount === 1 ? "action" : "actions", " locked"]
363
493
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
364
494
  style: styles.upgradeBadge,
@@ -369,6 +499,7 @@ function ReduxModal({
369
499
  })]
370
500
  }), visibleActions.length > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
371
501
  ref: flatListRef,
502
+ testID: `${_testIDs.REDUX_TEST_ID}-list`,
372
503
  data: visibleActions,
373
504
  renderItem: renderItem,
374
505
  keyExtractor: keyExtractor,
@@ -380,7 +511,8 @@ function ReduxModal({
380
511
  windowSize: 5,
381
512
  updateCellsBatchingPeriod: 50
382
513
  }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(EmptyState, {
383
- isEnabled: isEnabled
514
+ isEnabled: isEnabled,
515
+ query: searchText
384
516
  })]
385
517
  })
386
518
  });
@@ -526,6 +658,10 @@ const styles = _reactNative.StyleSheet.create({
526
658
  headerActionButtonDisabled: {
527
659
  opacity: 0.55
528
660
  },
661
+ headerActionButtonActive: {
662
+ backgroundColor: _sharedUi.night.color.accentSoft,
663
+ borderColor: _sharedUi.night.color.accentBorderStrong
664
+ },
529
665
  startButton: {
530
666
  backgroundColor: _sharedUi.macOSColors.semantic.successBackground,
531
667
  borderColor: _sharedUi.macOSColors.semantic.success + "40"
@@ -7,6 +7,8 @@ exports.ENHANCER_SYMBOL = void 0;
7
7
  exports.getActiveStore = getActiveStore;
8
8
  exports.getCaptureMechanism = getCaptureMechanism;
9
9
  exports.getInstrumentationStatus = getInstrumentationStatus;
10
+ exports.isTimeTravelAvailable = isTimeTravelAvailable;
11
+ exports.markTimeTravelReducer = markTimeTravelReducer;
10
12
  exports.registerActiveStore = registerActiveStore;
11
13
  exports.setReduxDiagnostic = setReduxDiagnostic;
12
14
  exports.unregisterActiveStore = unregisterActiveStore;
@@ -38,6 +40,7 @@ const ENHANCER_SYMBOL = exports.ENHANCER_SYMBOL = Symbol.for("@@buoy/enhancerIns
38
40
  let activeStore = null;
39
41
  let mechanism = null;
40
42
  let diagnostic = null;
43
+ let timeTravelReducer = false;
41
44
  function registerActiveStore(store, mech) {
42
45
  activeStore = store;
43
46
  mechanism = mech;
@@ -56,6 +59,33 @@ function getCaptureMechanism() {
56
59
  return mechanism;
57
60
  }
58
61
 
62
+ /**
63
+ * Record that a reducer able to serve `BUOY_JUMP_TO_STATE` is in the store.
64
+ *
65
+ * Called by the devtools-hook enhancer and by `withBuoyDevTools` — the only
66
+ * two things that wrap the root reducer. Capture and time travel are separate
67
+ * capabilities and they are wired separately: a store can be fully captured
68
+ * through `buoyReduxMiddleware` and still have no way to restore a state,
69
+ * because middleware sits ABOVE the reducer and cannot replace what it
70
+ * returns.
71
+ */
72
+ function markTimeTravelReducer() {
73
+ timeTravelReducer = true;
74
+ }
75
+
76
+ /**
77
+ * Whether pressing JUMP can actually do anything.
78
+ *
79
+ * Worth a first-class answer rather than a try-and-see, because the failure is
80
+ * invisible: `jumpToState` dispatches into a store that simply does not
81
+ * recognise the action, the reducer returns the state it already had, and the
82
+ * screen does not move. From the outside that is indistinguishable from a
83
+ * devtool that is broken.
84
+ */
85
+ function isTimeTravelAvailable() {
86
+ return timeTravelReducer && activeStore !== null;
87
+ }
88
+
59
89
  /**
60
90
  * Recorded by the auto-instrument hook so non-React surfaces (sync adapter,
61
91
  * MCP) can report WHY there's no store instead of a generic failure.
@@ -68,6 +98,7 @@ function getInstrumentationStatus() {
68
98
  mechanism,
69
99
  capture: mechanism === "enhancer" || mechanism === "middleware" ? "full" : mechanism === "patch" ? "top-level-only" : "none",
70
100
  hasStore: activeStore !== null,
101
+ timeTravel: isTimeTravelAvailable(),
71
102
  diagnostic
72
103
  };
73
104
  }
@@ -13,9 +13,25 @@ Object.defineProperty(exports, "getInstrumentationStatus", {
13
13
  exports.instrumentStore = instrumentStore;
14
14
  exports.isAutoInstrumentActive = isAutoInstrumentActive;
15
15
  exports.isStoreInstrumented = isStoreInstrumented;
16
- exports.jumpToState = jumpToState;
16
+ Object.defineProperty(exports, "isTimeTravelAvailable", {
17
+ enumerable: true,
18
+ get: function () {
19
+ return _buoyReduxMiddleware.isTimeTravelAvailable;
20
+ }
21
+ });
22
+ Object.defineProperty(exports, "jumpToState", {
23
+ enumerable: true,
24
+ get: function () {
25
+ return _buoyReduxMiddleware.jumpToState;
26
+ }
27
+ });
17
28
  exports.registerReduxStore = registerReduxStore;
18
- exports.replayAction = replayAction;
29
+ Object.defineProperty(exports, "replayAction", {
30
+ enumerable: true,
31
+ get: function () {
32
+ return _buoyReduxMiddleware.replayAction;
33
+ }
34
+ });
19
35
  exports.uninstrumentStore = uninstrumentStore;
20
36
  var _reduxActionStore = require("./reduxActionStore");
21
37
  var _buoyReduxMiddleware = require("./buoyReduxMiddleware");
@@ -187,47 +203,14 @@ function registerReduxStore(store, options = {}) {
187
203
  // Time-Travel / Introspection
188
204
  // ============================================
189
205
 
190
- let warnedJumpUnsupported = false;
191
-
192
206
  /**
193
- * Jump to a specific state (time-travel).
207
+ * Time travel lives in buoyReduxMiddleware.ts and is re-exported here.
194
208
  *
195
- * Real on the enhancer path (reducer is wrapped from creation) and on the
196
- * middleware path when the app used `withBuoyDevTools`. On the bare patch
197
- * path there's no reducer wrapper, so the jump action is ignored — we detect
198
- * that and warn once instead of pretending it worked.
209
+ * It used to be reimplemented in this file, and the two copies drifted: this
210
+ * one learned to notice that a jump had been ignored, the one the modal's JUMP
211
+ * button actually calls never did. So the detection existed and no user could
212
+ * ever have seen it. One implementation, one behaviour.
199
213
  */
200
- function jumpToState(state) {
201
- if ((0, _reduxActionStore.isEvictedState)(state)) {
202
- console.warn("[BuoyRedux] Cannot jump - this action's state was evicted (raw state is retained for recent actions only)");
203
- return;
204
- }
205
- const store = (0, _activeStoreRegistry.getActiveStore)();
206
- if (!store) {
207
- console.warn("[BuoyRedux] Cannot jump to state - no store instrumented");
208
- return;
209
- }
210
- store.dispatch({
211
- type: _buoyReduxMiddleware.BUOY_JUMP_TO_STATE,
212
- payload: state
213
- });
214
- if (typeof __DEV__ !== "undefined" && __DEV__ && !warnedJumpUnsupported && store.getState() !== state) {
215
- warnedJumpUnsupported = true;
216
- console.warn("[BuoyRedux] Time-travel jump was ignored by the store. Wrap your root " + "reducer with withBuoyDevTools(), or ensure @buoy-gg/redux is imported " + "before your store is created (enhancer path) for real time travel.");
217
- }
218
- }
219
-
220
- /**
221
- * Replay an action (dispatch it again)
222
- */
223
- function replayAction(action) {
224
- const store = (0, _activeStoreRegistry.getActiveStore)();
225
- if (store) {
226
- store.dispatch(action);
227
- } else {
228
- console.warn("[BuoyRedux] Cannot replay action - no store instrumented");
229
- }
230
- }
231
214
 
232
215
  /**
233
216
  * Get the currently active (instrumented) store.
@@ -6,6 +6,12 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.buoyReduxMiddleware = exports.BUOY_REPLAY_ACTION = exports.BUOY_JUMP_TO_STATE = void 0;
7
7
  exports.createBuoyReduxMiddleware = createBuoyReduxMiddleware;
8
8
  exports.isMiddlewareActive = isMiddlewareActive;
9
+ Object.defineProperty(exports, "isTimeTravelAvailable", {
10
+ enumerable: true,
11
+ get: function () {
12
+ return _activeStoreRegistry.isTimeTravelAvailable;
13
+ }
14
+ });
9
15
  exports.jumpToState = jumpToState;
10
16
  exports.replayAction = replayAction;
11
17
  exports.withBuoyDevTools = withBuoyDevTools;
@@ -51,9 +57,18 @@ const BUOY_REPLAY_ACTION = exports.BUOY_REPLAY_ACTION = "@@buoy/REPLAY_ACTION";
51
57
  /**
52
58
  * Jump to a specific state (time-travel).
53
59
  *
54
- * Registry-backed, so it works on EVERY capture path (enhancer, middleware,
55
- * patch) — previously this only worked when the middleware was installed,
56
- * which silently broke the modal's Jump button on the zero-config path.
60
+ * Registry-backed, so it reaches the store on every capture path — but
61
+ * REACHING the store is not the same as being able to jump, and conflating the
62
+ * two is what made this fail invisibly. A jump is an action like any other: it
63
+ * only does something if a reducer handles it, and the only two things that
64
+ * install such a reducer are the devtools-hook enhancer and `withBuoyDevTools`.
65
+ * An app that captures through `buoyReduxMiddleware` alone dispatches the jump
66
+ * into a store that has never heard of it, gets its own state back, and shows
67
+ * a screen that does not move — with no error, because nothing went wrong.
68
+ *
69
+ * So the capability is checked up front and refused out loud. The UI asks the
70
+ * same question (`isTimeTravelAvailable`) and disables the button, which is the
71
+ * half a warning cannot do.
57
72
  */
58
73
  function jumpToState(state) {
59
74
  if ((0, _reduxActionStore.isEvictedState)(state)) {
@@ -61,16 +76,25 @@ function jumpToState(state) {
61
76
  return;
62
77
  }
63
78
  const store = (0, _activeStoreRegistry.getActiveStore)();
64
- if (store) {
65
- store.dispatch({
66
- type: BUOY_JUMP_TO_STATE,
67
- payload: state
68
- });
69
- } else {
79
+ if (!store) {
70
80
  console.warn("[BuoyRedux] Cannot jump to state - no store instrumented");
81
+ return;
71
82
  }
83
+ if (!(0, _activeStoreRegistry.isTimeTravelAvailable)()) {
84
+ console.warn("[BuoyRedux] Cannot jump to state - the root reducer cannot serve a " + "state jump. Capture and time travel are wired separately: either put " + "`import '@buoy-gg/redux';` first in your app entry (so Buoy is the " + "store enhancer), or wrap your root reducer with withBuoyDevTools().");
85
+ return;
86
+ }
87
+ store.dispatch({
88
+ type: BUOY_JUMP_TO_STATE,
89
+ payload: state
90
+ });
72
91
  }
73
92
 
93
+ /**
94
+ * Whether the JUMP affordance can do anything in this app. Re-exported from
95
+ * the registry so hosts have one import for the whole time-travel surface.
96
+ */
97
+
74
98
  /**
75
99
  * Replay an action (dispatch it again)
76
100
  */
@@ -95,6 +119,9 @@ function replayAction(action) {
95
119
  * ```
96
120
  */
97
121
  function withBuoyDevTools(reducer) {
122
+ // The other half of the time-travel wiring (see jumpToState): this is one of
123
+ // exactly two places that can answer BUOY_JUMP_TO_STATE.
124
+ (0, _activeStoreRegistry.markTimeTravelReducer)();
98
125
  return (state, action) => {
99
126
  // Handle jump to state action
100
127
  if (action.type === BUOY_JUMP_TO_STATE) {
@@ -43,6 +43,10 @@ function composeFns(...funcs) {
43
43
 
44
44
  /** Wrap a reducer so BUOY_JUMP_TO_STATE performs a real state jump. */
45
45
  function wrapReducer(reducer) {
46
+ // Announce the capability, not just perform it: with nothing wrapped, a jump
47
+ // is dispatched, ignored, and silent — so the UI needs to know in advance
48
+ // whether the button it is drawing can keep its promise.
49
+ (0, _activeStoreRegistry.markTimeTravelReducer)();
46
50
  return (state, action) => {
47
51
  if (action && action.type === _buoyReduxMiddleware.BUOY_JUMP_TO_STATE) {
48
52
  return action.payload;