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