@buoy-gg/route-events 7.0.35 → 7.0.38

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@buoy-gg/route-events?style=flat-square&labelColor=1c1c1c&color=10B981)](https://www.npmjs.com/package/@buoy-gg/route-events) [![npm downloads](https://img.shields.io/npm/dm/@buoy-gg/route-events?style=flat-square&labelColor=1c1c1c&color=10B981)](https://www.npmjs.com/package/@buoy-gg/route-events)
4
4
 
5
- **See every route in your app, watch navigation happen in real time, and jump to any screen from a full sitemap.**
5
+ Inspect observed navigation events, browse supported routes, and navigate using the parameters your app requires.
6
6
 
7
7
  Part of [Buoy](https://github.com/Buoy-gg/buoy) — devtools that live inside your React Native app. Install it and it auto-appears in the floating menu from [`@buoy-gg/core`](https://www.npmjs.com/package/@buoy-gg/core).
8
8
 
@@ -14,6 +14,24 @@ Part of [Buoy](https://github.com/Buoy-gg/buoy) — devtools that live inside yo
14
14
  npm install @buoy-gg/core @buoy-gg/route-events
15
15
  ```
16
16
 
17
+ ## Before you start
18
+
19
+ Use a development build with `@buoy-gg/core` and a Free or Pro Buoy account key. From your app’s directory, sign in:
20
+
21
+ ```bash
22
+ npx --package=@buoy-gg/core buoy login
23
+ ```
24
+
25
+ For Expo, initialize Buoy before rendering the menu:
26
+
27
+ ```tsx
28
+ import { Buoy } from "@buoy-gg/core";
29
+
30
+ Buoy.init({ licenseKey: process.env.EXPO_PUBLIC_BUOY_KEY });
31
+ ```
32
+
33
+ The login command writes the Expo key to `.env.local`. For React Native CLI, pass the key from your app’s environment configuration; React Native does not load `.env.local` automatically. Mount `FloatingDevTools` inside the same providers as your screens and restart the development server after installation. The [Quick Start](https://buoy.gg/buoy/latest/docs/quick-start) shows the complete root component setup.
34
+
17
35
  ## Quick start
18
36
 
19
37
  ```tsx
@@ -31,25 +49,25 @@ export default function RootLayout() {
31
49
  }
32
50
  ```
33
51
 
34
- That's it. `FloatingDevTools` detects the package, mounts the route tracker for you, and the ROUTES tool shows up in the menu. It auto-detects whether you're on Expo Router or React Navigation and adapts.
52
+ After account setup, open Routes and navigate between two known screens. Check their paths and parameters. Keep your existing navigator and providers; supported data differs between Expo Router and React Navigation. The event duration is time since the previous navigation, not screen render time.
35
53
 
36
54
  ## What you get
37
55
 
38
- - **Full sitemap** — browse every route in your app, searchable, with route types (static, dynamic, layout) labeled
39
- - **Jump to any screen** — tap a route in the sitemap and navigate straight to it, params and all
56
+ - **Sitemap** — browse discovered routes, searchable, with route types (static, dynamic, layout) labeled
57
+ - **Navigate** — select a supported route and supply its required parameters. App navigation guards still apply
40
58
  - **Live navigation stack** — watch the stack push, pop, and replace as you move through the app
41
59
  - **Navigation event timeline** — every navigation logged with path, params, timestamp, and time since the previous one
42
60
  - **Filtering & search** — filter events by pathname pattern, search across routes
43
- - **Zero setup** — no `<RouteTracker />` to place, no config; the core menu wires it automatically
61
+ - **Tracker integration** — the core menu mounts the package’s route tracker
44
62
  - **Hooks for custom UIs** — `useRouteEvents`, `useRouteSitemap`, and `useNavigationStack` if you want the data without the modal
45
63
 
46
64
  ## Desktop & AI
47
65
 
48
- The same live session streams to [Buoy Desktop](https://github.com/Buoy-gg/Buoy-Desktop) (free, macOS/Windows/Linux) and to Claude Code or Cursor via the [Buoy MCP server](https://buoy.gg/buoy/latest/docs/mcp).
66
+ To connect a React Native app to [Buoy Desktop](https://github.com/Buoy-gg/Buoy-Desktop) or MCP, install `@buoy-gg/external-sync` and follow the [Desktop connection guide](https://buoy.gg/buoy/latest/docs/desktop). Sign in to Desktop separately. [MCP setup](https://buoy.gg/buoy/latest/docs/mcp) also requires a process account and Pro access. Available remote actions depend on the tool and app integration.
49
67
 
50
- ## Free vs Pro
68
+ ## Account and plan requirements
51
69
 
52
- Every tool is free. [Pro](https://buoy.gg/pricing) unlocks production builds, the MCP server, and unlimited capture. Every weekend, Pro features unlock free for anyone holding a key — including a free one (`npx buoy login`).
70
+ Use a Free or Pro Buoy account. History limits and paid features vary by tool; see [pricing](https://buoy.gg/pricing). Production access requires Pro where supported. Development-only hooks and actions remain unavailable in release builds.
53
71
 
54
72
  ---
55
73
 
@@ -10,6 +10,7 @@ var _useRouteObserver = require("./useRouteObserver");
10
10
  var _useRouteObserverReactNavigation = require("./useRouteObserverReactNavigation");
11
11
  var _useNavigationStack = require("./useNavigationStack");
12
12
  var _navigationStackStore = require("./stores/navigationStackStore");
13
+ var _routeEventStore = require("./stores/routeEventStore");
13
14
  var _jsxRuntime = require("react/jsx-runtime");
14
15
  /**
15
16
  * RouteTracker - A component to place inside your navigation tree
@@ -52,11 +53,20 @@ var _jsxRuntime = require("react/jsx-runtime");
52
53
  * ```
53
54
  */
54
55
 
56
+ // Mounted at boot by FloatingDevTools, so this is where capture starts —
57
+ // routeEventStore.hydrateBootCapture explains why the store needs it.
58
+ function useBootCapture() {
59
+ (0, _react.useEffect)(() => {
60
+ _routeEventStore.routeEventStore.hydrateBootCapture();
61
+ }, []);
62
+ }
55
63
  function ExpoRouteTracker() {
64
+ useBootCapture();
56
65
  (0, _useRouteObserver.useRouteObserver)();
57
66
  return null;
58
67
  }
59
68
  function ReactNavigationRouteTracker() {
69
+ useBootCapture();
60
70
  (0, _useRouteObserverReactNavigation.useRouteObserverReactNavigation)();
61
71
  return null;
62
72
  }
@@ -131,43 +131,30 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
131
131
  });
132
132
  }, [isExpanded, event, visitNumber, routeTemplate]);
133
133
 
134
- // Custom badge shows Go button (and badge text if present) when onNavigate is provided
135
- const customBadge = (0, _react.useMemo)(() => {
134
+ // The Go button lives beside the row, not inside its touchable (see
135
+ // CompactRow.trailingAction), so a press never opens the row as well.
136
+ const goButton = (0, _react.useMemo)(() => {
136
137
  if (!onNavigate) return undefined;
137
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
138
- style: styles.badgeContainer,
139
- children: [badgeText && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
140
- style: [styles.badgeText, {
141
- color: statusColor
142
- }],
143
- testID: `${testID}-badge`,
144
- children: badgeText
145
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
146
- style: styles.goButton,
147
- onPress: e => {
148
- e.stopPropagation();
149
- onNavigate(event.pathname);
150
- },
151
- testID: `${testID}-go`,
152
- accessibilityRole: "button",
153
- accessibilityLabel: `Go to ${event.pathname}`,
154
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
155
- style: styles.goButtonText,
156
- children: "Go"
157
- })
158
- })]
138
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
139
+ style: styles.goButton,
140
+ onPress: () => onNavigate(event.pathname),
141
+ testID: `${testID}-go`,
142
+ accessibilityRole: "button",
143
+ accessibilityLabel: `Go to ${event.pathname}`,
144
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
145
+ style: styles.goButtonText,
146
+ children: "Go"
147
+ })
159
148
  });
160
- }, [onNavigate, event.pathname, badgeText, statusColor, testID]);
149
+ }, [onNavigate, event.pathname, testID]);
161
150
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompactRow, {
162
151
  statusDotColor: statusColor,
163
152
  statusLabel: statusLabel,
164
153
  statusSublabel: statusSublabel,
165
- primaryText: event.pathname
166
- // Use badgeText when no customBadge, otherwise badge is inside customBadge
167
- ,
168
- badgeText: onNavigate ? undefined : badgeText,
154
+ primaryText: event.pathname,
155
+ badgeText: badgeText,
169
156
  badgeColor: statusColor,
170
- customBadge: customBadge,
157
+ trailingAction: goButton,
171
158
  bottomRightText: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.RelativeTime, {
172
159
  timestamp: event.timestamp
173
160
  }),
@@ -183,11 +170,6 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
183
170
  });
184
171
  });
185
172
  const styles = _reactNative.StyleSheet.create({
186
- badgeContainer: {
187
- flexDirection: "row",
188
- alignItems: "center",
189
- gap: 6
190
- },
191
173
  badgeText: {
192
174
  fontSize: 11,
193
175
  fontWeight: "600",
@@ -11,6 +11,7 @@ var _safeReactNavigation = require("../utils/safeReactNavigation");
11
11
  var _sharedUi = require("@buoy-gg/shared-ui");
12
12
  var _license = require("@buoy-gg/license");
13
13
  var _useRouteEvents = require("../hooks/useRouteEvents");
14
+ var _routeEventStore = require("../stores/routeEventStore");
14
15
  var _RouteEventDetail = require("./RouteEventDetail");
15
16
  var _RouteFilterViewV = require("./RouteFilterViewV2");
16
17
  var _RoutesSitemap = require("./RoutesSitemap");
@@ -138,6 +139,13 @@ function RouteEventsModalWithTabs({
138
139
 
139
140
  // isListening = enabled and store is listening
140
141
  const isListening = isListeningEnabled && isStoreListening;
142
+
143
+ // The switch above only governs THIS component's subscription; the store
144
+ // also holds one of its own from boot (routeEventStore.setBootCapture), so
145
+ // pausing here has to release that too or capture would never stop.
146
+ (0, _react.useEffect)(() => {
147
+ _routeEventStore.routeEventStore.setBootCapture(isListeningEnabled);
148
+ }, [isListeningEnabled]);
141
149
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
142
150
  // Selected event drives the full-screen detail view (parity with the Events
143
151
  // tool). null = list view. visitNumber feeds the "Visited N times" section.
@@ -40,6 +40,45 @@ var _RouteObserver = require("../RouteObserver");
40
40
  class RouteEventStore extends _sharedUi.BaseEventStore {
41
41
  // Unsubscribe function for routeObserver
42
42
  routeObserverUnsubscribe = null;
43
+ // The store's own subscription — see setBootCapture.
44
+ bootCaptureRelease = null;
45
+ bootCaptureHydrated = false;
46
+
47
+ /**
48
+ * Capture from app launch, not from the first open of the tool.
49
+ *
50
+ * A BaseEventStore only listens while something subscribes to it, and for
51
+ * routes that was the open modal or a watching desktop — so every
52
+ * navigation before the tool was first opened was simply never recorded,
53
+ * and the Events tab opened on "No route events yet" after a session of
54
+ * navigating. (It looked fine on any machine with Buoy Desktop running,
55
+ * because the desktop's watch subscribed at boot.) Network and storage both
56
+ * capture from launch; this holds a subscription of the store's own so
57
+ * routes does too.
58
+ *
59
+ * The hold follows the tool's persisted monitoring switch: the modal calls
60
+ * this on every toggle, and `hydrateBootCapture` reads the persisted value
61
+ * at boot so a paused tool stays paused across a relaunch.
62
+ */
63
+ setBootCapture(enabled) {
64
+ if (enabled && !this.bootCaptureRelease) {
65
+ this.bootCaptureRelease = this.subscribeToEvents(() => {});
66
+ } else if (!enabled && this.bootCaptureRelease) {
67
+ this.bootCaptureRelease();
68
+ this.bootCaptureRelease = null;
69
+ }
70
+ }
71
+
72
+ /** Arm boot capture from the persisted monitoring switch (default on). */
73
+ hydrateBootCapture() {
74
+ if (this.bootCaptureHydrated) return;
75
+ this.bootCaptureHydrated = true;
76
+ void _sharedUi.persistentStorage.getItem(_sharedUi.devToolsStorageKeys.routeEvents.isMonitoring()).then(raw => {
77
+ // The modal may already have decided while the read was in flight.
78
+ if (this.bootCaptureRelease) return;
79
+ this.setBootCapture(raw !== "false");
80
+ }).catch(() => this.setBootCapture(true));
81
+ }
43
82
  constructor() {
44
83
  super({
45
84
  storeName: "route-events",
@@ -131,7 +131,21 @@ const routeEventsSyncAdapter = exports.routeEventsSyncAdapter = {
131
131
  // BaseEventStore is newest-FIRST, and the store only captures while
132
132
  // something is subscribed.
133
133
  const latest = _routeEventStore.routeEventStore.getEvents()[0];
134
- if (latest) {
134
+
135
+ /**
136
+ * A RECORDED event only wins while the recorder is running.
137
+ *
138
+ * The store starts capturing when its first subscriber arrives and stops
139
+ * with the last one, so an app with Ask Buoy on the phone and no
140
+ * dashboard attached has NO route events at all — the stack fallback
141
+ * below is its normal path, not the fresh-launch edge case this code was
142
+ * written for. Worse, events left over from a closed capture window are
143
+ * stale the moment the user navigates again: a live QA session read
144
+ * `/(tabs)/menu` from an event six scenarios after the app had moved to
145
+ * `/cart`. The live stack cannot be stale, so it wins whenever nothing
146
+ * is recording.
147
+ */
148
+ if (latest && _routeEventStore.routeEventStore.isCapturing()) {
135
149
  return {
136
150
  path: latest.pathname,
137
151
  params: toWireParams(latest.params),
@@ -139,13 +153,12 @@ const routeEventsSyncAdapter = exports.routeEventsSyncAdapter = {
139
153
  source: "event"
140
154
  };
141
155
  }
142
- // Nothing has navigated yet — which is exactly the state a freshly
143
- // launched app is in, and exactly when a macro recorder needs to know
144
- // where it is starting from. The live stack knows even when the history
145
- // is empty, so falling back to it is the difference between a macro that
146
- // can run from anywhere and one that only works if you happen to be on
147
- // the screen it was recorded from.
148
- const focused = _navigationStackStore.navigationStackStore.getStack().find(item => item.isFocused) ?? _navigationStackStore.navigationStackStore.getStack().slice(-1)[0];
156
+
157
+ // The live stack knows even when the history is empty, which is the
158
+ // difference between a macro that can run from anywhere and one that
159
+ // only works if you happen to be on the screen it was recorded from.
160
+ const stack = _navigationStackStore.navigationStackStore.getStack();
161
+ const focused = stack.find(item => item.isFocused) ?? stack[stack.length - 1];
149
162
  if (focused?.pathname) {
150
163
  return {
151
164
  path: focused.pathname,
@@ -154,6 +167,19 @@ const routeEventsSyncAdapter = exports.routeEventsSyncAdapter = {
154
167
  source: "stack"
155
168
  };
156
169
  }
170
+
171
+ // No live stack (no <RouteTracker/>) and the recorder is off: an old
172
+ // event is all there is. Say that it may be stale rather than passing it
173
+ // off as where the app is.
174
+ if (latest) {
175
+ return {
176
+ path: latest.pathname,
177
+ params: toWireParams(latest.params),
178
+ at: latest.timestamp,
179
+ source: "event",
180
+ stale: true
181
+ };
182
+ }
157
183
  return {
158
184
  path: null,
159
185
  params: {},
@@ -3,6 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ exports.buildStack = buildStack;
6
7
  exports.useNavigationStack = useNavigationStack;
7
8
  var _react = require("react");
8
9
  var _safeReactNavigation = require("./utils/safeReactNavigation");
@@ -85,14 +86,39 @@ function buildNestedNavigateAction(path) {
85
86
  // ============================================================================
86
87
 
87
88
  /**
88
- * Recursively collect all routes from the navigation state tree
89
+ * Recursively collect the routes from the navigation state tree.
90
+ *
91
+ * FOCUS COMES FROM `state.index`, NEVER FROM POSITION. React Navigation names
92
+ * the focused route by index within `routes`; the array's order is declaration
93
+ * order. `buildStack` used to equate the two — "the last route in the collected
94
+ * list is the focused one" — and a hidden tab declared last (`href: null` on an
95
+ * expo-router `Tabs.Screen`) then became the reported current screen for the
96
+ * whole session. Measured on a real app: Home and Rewards both reported as a
97
+ * hidden Menu tab, in every turn of a 55-scenario QA run, because `menu` was
98
+ * declared last.
99
+ *
100
+ * TAB AND DRAWER SIBLINGS ARE NOT A STACK. They are alternative destinations
101
+ * that happen to be mounted at the same time; `canPop` and `popToIndex` never
102
+ * meant anything for them. Only the focused branch of a tab/drawer is
103
+ * collected, so what comes back is the stack you could actually pop.
89
104
  */
90
- function collectRoutesFromState(state, depth = 0, parentPath = "") {
91
- if (!state || !state.routes) return [];
105
+ function collectRoutesFromState(state, depth = 0, parentPath = "", onFocusedChain = true) {
106
+ if (!state || !Array.isArray(state.routes) || state.routes.length === 0) {
107
+ return [];
108
+ }
92
109
  const routes = [];
110
+
111
+ // An absent or out-of-range `index` is incomplete state (mid-rehydration, a
112
+ // partially built child). Fall back to the last route — a stack's top — and
113
+ // never to an arbitrary sibling.
114
+ const declaredIndex = typeof state.index === "number" ? state.index : NaN;
115
+ const focusedIndex = Number.isInteger(declaredIndex) && declaredIndex >= 0 && declaredIndex < state.routes.length ? declaredIndex : state.routes.length - 1;
116
+ const navigatorType = typeof state.type === "string" ? state.type : undefined;
117
+ const siblingsAreStack = navigatorType !== "tab" && navigatorType !== "drawer";
93
118
  for (let i = 0; i < state.routes.length; i++) {
119
+ if (!siblingsAreStack && i !== focusedIndex) continue;
94
120
  const route = state.routes[i];
95
- const isFocused = i === state.index;
121
+ const isFocused = i === focusedIndex;
96
122
 
97
123
  // Build the path
98
124
  let pathname = route.name;
@@ -116,25 +142,30 @@ function collectRoutesFromState(state, depth = 0, parentPath = "") {
116
142
  });
117
143
  }
118
144
  }
145
+
146
+ // Descend the focused branch first: whatever it yields owns the focus, and
147
+ // this route only keeps it when nothing deeper claimed it.
148
+ const onChain = onFocusedChain && isFocused;
149
+ const nestedRoutes = onChain && route.state ? collectRoutesFromState(route.state, depth + 1, pathname, true) : [];
119
150
  routes.push({
120
151
  key: route.key,
121
152
  name: route.name,
122
153
  path: pathname,
123
154
  params: route.params || {},
124
- state: route.state
155
+ state: route.state,
156
+ isFocused: onChain && nestedRoutes.length === 0
125
157
  });
126
-
127
- // If this route has nested state and is focused, recurse
128
- if (isFocused && route.state) {
129
- const nestedRoutes = collectRoutesFromState(route.state, depth + 1, pathname);
130
- routes.push(...nestedRoutes);
131
- }
158
+ routes.push(...nestedRoutes);
132
159
  }
133
160
  return routes;
134
161
  }
135
162
 
136
163
  /**
137
- * Transform raw navigation state into display items
164
+ * Transform raw navigation state into display items.
165
+ *
166
+ * Exported for tests: the focus rule below is the one thing every route answer
167
+ * in the product rests on, and it was previously only reachable by extracting
168
+ * the function from source with the TypeScript AST.
138
169
  */
139
170
  function buildStack(navigationState, isExpo) {
140
171
  if (!navigationState) return [];
@@ -153,7 +184,14 @@ function buildStack(navigationState, isExpo) {
153
184
  // If we filtered everything out, just keep the last route
154
185
  const finalRoutes = filteredRoutes.length > 0 ? filteredRoutes : routes.slice(-1);
155
186
 
156
- // The last route in the collected list is the focused one
187
+ /**
188
+ * Focus survives filtering. It is decided in `collectRoutesFromState` from
189
+ * `state.index`; the only case that falls back to the last item is one
190
+ * where the filter dropped the focused route itself (an internal expo
191
+ * layout), which is the one situation the old rule was ever right for.
192
+ */
193
+ const collectedFocus = finalRoutes.findIndex(route => route.isFocused);
194
+ const focusIndex = collectedFocus >= 0 ? collectedFocus : finalRoutes.length - 1;
157
195
  return finalRoutes.map((route, index) => {
158
196
  let cleanPath = route.path || `/${route.name}`;
159
197
  // Clean up Expo Router internal /__root prefix
@@ -169,7 +207,7 @@ function buildStack(navigationState, isExpo) {
169
207
  name: route.name,
170
208
  pathname: cleanPath,
171
209
  params: route.params || {},
172
- isFocused: index === finalRoutes.length - 1,
210
+ isFocused: index === focusIndex,
173
211
  index,
174
212
  canPop: index > 0
175
213
  };
@@ -47,12 +47,23 @@ import { useRouteObserver } from "./useRouteObserver";
47
47
  import { useRouteObserverReactNavigation } from "./useRouteObserverReactNavigation";
48
48
  import { useNavigationStack } from "./useNavigationStack";
49
49
  import { navigationStackStore } from "./stores/navigationStackStore";
50
+ import { routeEventStore } from "./stores/routeEventStore";
51
+
52
+ // Mounted at boot by FloatingDevTools, so this is where capture starts —
53
+ // routeEventStore.hydrateBootCapture explains why the store needs it.
50
54
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
55
+ function useBootCapture() {
56
+ useEffect(() => {
57
+ routeEventStore.hydrateBootCapture();
58
+ }, []);
59
+ }
51
60
  function ExpoRouteTracker() {
61
+ useBootCapture();
52
62
  useRouteObserver();
53
63
  return null;
54
64
  }
55
65
  function ReactNavigationRouteTracker() {
66
+ useBootCapture();
56
67
  useRouteObserverReactNavigation();
57
68
  return null;
58
69
  }
@@ -7,14 +7,14 @@
7
7
  */
8
8
 
9
9
  import { memo, useMemo } from "react";
10
- import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
10
+ import { Text, TouchableOpacity, StyleSheet } from "react-native";
11
11
  import { CompactRow, RelativeTime, buoyColors, night, monoFont } from "@buoy-gg/shared-ui";
12
12
  import { RouteEventExpandedContent } from "./RouteEventExpandedContent";
13
13
  import { getRouteTemplate } from "../utils/routeTemplate";
14
14
  import { eventRowTestID } from "../utils/testIDs";
15
15
 
16
16
  // Route type for color coding
17
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
+ import { jsx as _jsx } from "react/jsx-runtime";
18
18
  // Get route type for color coding
19
19
  function getRouteType(event) {
20
20
  if (event.pathname === "/") {
@@ -127,43 +127,30 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
127
127
  });
128
128
  }, [isExpanded, event, visitNumber, routeTemplate]);
129
129
 
130
- // Custom badge shows Go button (and badge text if present) when onNavigate is provided
131
- const customBadge = useMemo(() => {
130
+ // The Go button lives beside the row, not inside its touchable (see
131
+ // CompactRow.trailingAction), so a press never opens the row as well.
132
+ const goButton = useMemo(() => {
132
133
  if (!onNavigate) return undefined;
133
- return /*#__PURE__*/_jsxs(View, {
134
- style: styles.badgeContainer,
135
- children: [badgeText && /*#__PURE__*/_jsx(Text, {
136
- style: [styles.badgeText, {
137
- color: statusColor
138
- }],
139
- testID: `${testID}-badge`,
140
- children: badgeText
141
- }), /*#__PURE__*/_jsx(TouchableOpacity, {
142
- style: styles.goButton,
143
- onPress: e => {
144
- e.stopPropagation();
145
- onNavigate(event.pathname);
146
- },
147
- testID: `${testID}-go`,
148
- accessibilityRole: "button",
149
- accessibilityLabel: `Go to ${event.pathname}`,
150
- children: /*#__PURE__*/_jsx(Text, {
151
- style: styles.goButtonText,
152
- children: "Go"
153
- })
154
- })]
134
+ return /*#__PURE__*/_jsx(TouchableOpacity, {
135
+ style: styles.goButton,
136
+ onPress: () => onNavigate(event.pathname),
137
+ testID: `${testID}-go`,
138
+ accessibilityRole: "button",
139
+ accessibilityLabel: `Go to ${event.pathname}`,
140
+ children: /*#__PURE__*/_jsx(Text, {
141
+ style: styles.goButtonText,
142
+ children: "Go"
143
+ })
155
144
  });
156
- }, [onNavigate, event.pathname, badgeText, statusColor, testID]);
145
+ }, [onNavigate, event.pathname, testID]);
157
146
  return /*#__PURE__*/_jsx(CompactRow, {
158
147
  statusDotColor: statusColor,
159
148
  statusLabel: statusLabel,
160
149
  statusSublabel: statusSublabel,
161
- primaryText: event.pathname
162
- // Use badgeText when no customBadge, otherwise badge is inside customBadge
163
- ,
164
- badgeText: onNavigate ? undefined : badgeText,
150
+ primaryText: event.pathname,
151
+ badgeText: badgeText,
165
152
  badgeColor: statusColor,
166
- customBadge: customBadge,
153
+ trailingAction: goButton,
167
154
  bottomRightText: /*#__PURE__*/_jsx(RelativeTime, {
168
155
  timestamp: event.timestamp
169
156
  }),
@@ -179,11 +166,6 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
179
166
  });
180
167
  });
181
168
  const styles = StyleSheet.create({
182
- badgeContainer: {
183
- flexDirection: "row",
184
- alignItems: "center",
185
- gap: 6
186
- },
187
169
  badgeText: {
188
170
  fontSize: 11,
189
171
  fontWeight: "600",
@@ -15,6 +15,7 @@ import { useIsPro } from "@buoy-gg/license";
15
15
  // Free tier limit for route events
16
16
 
17
17
  import { useRouteEvents } from "../hooks/useRouteEvents";
18
+ import { routeEventStore } from "../stores/routeEventStore";
18
19
  import { RouteEventDetail } from "./RouteEventDetail";
19
20
  import { RouteFilterViewV2 } from "./RouteFilterViewV2";
20
21
  import { RoutesSitemap } from "./RoutesSitemap";
@@ -135,6 +136,13 @@ export function RouteEventsModalWithTabs({
135
136
 
136
137
  // isListening = enabled and store is listening
137
138
  const isListening = isListeningEnabled && isStoreListening;
139
+
140
+ // The switch above only governs THIS component's subscription; the store
141
+ // also holds one of its own from boot (routeEventStore.setBootCapture), so
142
+ // pausing here has to release that too or capture would never stop.
143
+ useEffect(() => {
144
+ routeEventStore.setBootCapture(isListeningEnabled);
145
+ }, [isListeningEnabled]);
138
146
  const [showFilters, setShowFilters] = useState(false);
139
147
  // Selected event drives the full-screen detail view (parity with the Events
140
148
  // tool). null = list view. visitNumber feeds the "Visited N times" section.
@@ -21,7 +21,7 @@
21
21
  * ```
22
22
  */
23
23
 
24
- import { BaseEventStore } from "@buoy-gg/shared-ui";
24
+ import { BaseEventStore, devToolsStorageKeys, persistentStorage } from "@buoy-gg/shared-ui";
25
25
  import { routeObserver } from "../RouteObserver";
26
26
 
27
27
  // Re-export types for convenience
@@ -37,6 +37,45 @@ import { routeObserver } from "../RouteObserver";
37
37
  class RouteEventStore extends BaseEventStore {
38
38
  // Unsubscribe function for routeObserver
39
39
  routeObserverUnsubscribe = null;
40
+ // The store's own subscription — see setBootCapture.
41
+ bootCaptureRelease = null;
42
+ bootCaptureHydrated = false;
43
+
44
+ /**
45
+ * Capture from app launch, not from the first open of the tool.
46
+ *
47
+ * A BaseEventStore only listens while something subscribes to it, and for
48
+ * routes that was the open modal or a watching desktop — so every
49
+ * navigation before the tool was first opened was simply never recorded,
50
+ * and the Events tab opened on "No route events yet" after a session of
51
+ * navigating. (It looked fine on any machine with Buoy Desktop running,
52
+ * because the desktop's watch subscribed at boot.) Network and storage both
53
+ * capture from launch; this holds a subscription of the store's own so
54
+ * routes does too.
55
+ *
56
+ * The hold follows the tool's persisted monitoring switch: the modal calls
57
+ * this on every toggle, and `hydrateBootCapture` reads the persisted value
58
+ * at boot so a paused tool stays paused across a relaunch.
59
+ */
60
+ setBootCapture(enabled) {
61
+ if (enabled && !this.bootCaptureRelease) {
62
+ this.bootCaptureRelease = this.subscribeToEvents(() => {});
63
+ } else if (!enabled && this.bootCaptureRelease) {
64
+ this.bootCaptureRelease();
65
+ this.bootCaptureRelease = null;
66
+ }
67
+ }
68
+
69
+ /** Arm boot capture from the persisted monitoring switch (default on). */
70
+ hydrateBootCapture() {
71
+ if (this.bootCaptureHydrated) return;
72
+ this.bootCaptureHydrated = true;
73
+ void persistentStorage.getItem(devToolsStorageKeys.routeEvents.isMonitoring()).then(raw => {
74
+ // The modal may already have decided while the read was in flight.
75
+ if (this.bootCaptureRelease) return;
76
+ this.setBootCapture(raw !== "false");
77
+ }).catch(() => this.setBootCapture(true));
78
+ }
40
79
  constructor() {
41
80
  super({
42
81
  storeName: "route-events",
@@ -129,7 +129,21 @@ export const routeEventsSyncAdapter = {
129
129
  // BaseEventStore is newest-FIRST, and the store only captures while
130
130
  // something is subscribed.
131
131
  const latest = routeEventStore.getEvents()[0];
132
- if (latest) {
132
+
133
+ /**
134
+ * A RECORDED event only wins while the recorder is running.
135
+ *
136
+ * The store starts capturing when its first subscriber arrives and stops
137
+ * with the last one, so an app with Ask Buoy on the phone and no
138
+ * dashboard attached has NO route events at all — the stack fallback
139
+ * below is its normal path, not the fresh-launch edge case this code was
140
+ * written for. Worse, events left over from a closed capture window are
141
+ * stale the moment the user navigates again: a live QA session read
142
+ * `/(tabs)/menu` from an event six scenarios after the app had moved to
143
+ * `/cart`. The live stack cannot be stale, so it wins whenever nothing
144
+ * is recording.
145
+ */
146
+ if (latest && routeEventStore.isCapturing()) {
133
147
  return {
134
148
  path: latest.pathname,
135
149
  params: toWireParams(latest.params),
@@ -137,13 +151,12 @@ export const routeEventsSyncAdapter = {
137
151
  source: "event"
138
152
  };
139
153
  }
140
- // Nothing has navigated yet — which is exactly the state a freshly
141
- // launched app is in, and exactly when a macro recorder needs to know
142
- // where it is starting from. The live stack knows even when the history
143
- // is empty, so falling back to it is the difference between a macro that
144
- // can run from anywhere and one that only works if you happen to be on
145
- // the screen it was recorded from.
146
- const focused = navigationStackStore.getStack().find(item => item.isFocused) ?? navigationStackStore.getStack().slice(-1)[0];
154
+
155
+ // The live stack knows even when the history is empty, which is the
156
+ // difference between a macro that can run from anywhere and one that
157
+ // only works if you happen to be on the screen it was recorded from.
158
+ const stack = navigationStackStore.getStack();
159
+ const focused = stack.find(item => item.isFocused) ?? stack[stack.length - 1];
147
160
  if (focused?.pathname) {
148
161
  return {
149
162
  path: focused.pathname,
@@ -152,6 +165,19 @@ export const routeEventsSyncAdapter = {
152
165
  source: "stack"
153
166
  };
154
167
  }
168
+
169
+ // No live stack (no <RouteTracker/>) and the recorder is off: an old
170
+ // event is all there is. Say that it may be stale rather than passing it
171
+ // off as where the app is.
172
+ if (latest) {
173
+ return {
174
+ path: latest.pathname,
175
+ params: toWireParams(latest.params),
176
+ at: latest.timestamp,
177
+ source: "event",
178
+ stale: true
179
+ };
180
+ }
155
181
  return {
156
182
  path: null,
157
183
  params: {},
@@ -82,14 +82,39 @@ function buildNestedNavigateAction(path) {
82
82
  // ============================================================================
83
83
 
84
84
  /**
85
- * Recursively collect all routes from the navigation state tree
85
+ * Recursively collect the routes from the navigation state tree.
86
+ *
87
+ * FOCUS COMES FROM `state.index`, NEVER FROM POSITION. React Navigation names
88
+ * the focused route by index within `routes`; the array's order is declaration
89
+ * order. `buildStack` used to equate the two — "the last route in the collected
90
+ * list is the focused one" — and a hidden tab declared last (`href: null` on an
91
+ * expo-router `Tabs.Screen`) then became the reported current screen for the
92
+ * whole session. Measured on a real app: Home and Rewards both reported as a
93
+ * hidden Menu tab, in every turn of a 55-scenario QA run, because `menu` was
94
+ * declared last.
95
+ *
96
+ * TAB AND DRAWER SIBLINGS ARE NOT A STACK. They are alternative destinations
97
+ * that happen to be mounted at the same time; `canPop` and `popToIndex` never
98
+ * meant anything for them. Only the focused branch of a tab/drawer is
99
+ * collected, so what comes back is the stack you could actually pop.
86
100
  */
87
- function collectRoutesFromState(state, depth = 0, parentPath = "") {
88
- if (!state || !state.routes) return [];
101
+ function collectRoutesFromState(state, depth = 0, parentPath = "", onFocusedChain = true) {
102
+ if (!state || !Array.isArray(state.routes) || state.routes.length === 0) {
103
+ return [];
104
+ }
89
105
  const routes = [];
106
+
107
+ // An absent or out-of-range `index` is incomplete state (mid-rehydration, a
108
+ // partially built child). Fall back to the last route — a stack's top — and
109
+ // never to an arbitrary sibling.
110
+ const declaredIndex = typeof state.index === "number" ? state.index : NaN;
111
+ const focusedIndex = Number.isInteger(declaredIndex) && declaredIndex >= 0 && declaredIndex < state.routes.length ? declaredIndex : state.routes.length - 1;
112
+ const navigatorType = typeof state.type === "string" ? state.type : undefined;
113
+ const siblingsAreStack = navigatorType !== "tab" && navigatorType !== "drawer";
90
114
  for (let i = 0; i < state.routes.length; i++) {
115
+ if (!siblingsAreStack && i !== focusedIndex) continue;
91
116
  const route = state.routes[i];
92
- const isFocused = i === state.index;
117
+ const isFocused = i === focusedIndex;
93
118
 
94
119
  // Build the path
95
120
  let pathname = route.name;
@@ -113,27 +138,32 @@ function collectRoutesFromState(state, depth = 0, parentPath = "") {
113
138
  });
114
139
  }
115
140
  }
141
+
142
+ // Descend the focused branch first: whatever it yields owns the focus, and
143
+ // this route only keeps it when nothing deeper claimed it.
144
+ const onChain = onFocusedChain && isFocused;
145
+ const nestedRoutes = onChain && route.state ? collectRoutesFromState(route.state, depth + 1, pathname, true) : [];
116
146
  routes.push({
117
147
  key: route.key,
118
148
  name: route.name,
119
149
  path: pathname,
120
150
  params: route.params || {},
121
- state: route.state
151
+ state: route.state,
152
+ isFocused: onChain && nestedRoutes.length === 0
122
153
  });
123
-
124
- // If this route has nested state and is focused, recurse
125
- if (isFocused && route.state) {
126
- const nestedRoutes = collectRoutesFromState(route.state, depth + 1, pathname);
127
- routes.push(...nestedRoutes);
128
- }
154
+ routes.push(...nestedRoutes);
129
155
  }
130
156
  return routes;
131
157
  }
132
158
 
133
159
  /**
134
- * Transform raw navigation state into display items
160
+ * Transform raw navigation state into display items.
161
+ *
162
+ * Exported for tests: the focus rule below is the one thing every route answer
163
+ * in the product rests on, and it was previously only reachable by extracting
164
+ * the function from source with the TypeScript AST.
135
165
  */
136
- function buildStack(navigationState, isExpo) {
166
+ export function buildStack(navigationState, isExpo) {
137
167
  if (!navigationState) return [];
138
168
  try {
139
169
  const routes = collectRoutesFromState(navigationState);
@@ -150,7 +180,14 @@ function buildStack(navigationState, isExpo) {
150
180
  // If we filtered everything out, just keep the last route
151
181
  const finalRoutes = filteredRoutes.length > 0 ? filteredRoutes : routes.slice(-1);
152
182
 
153
- // The last route in the collected list is the focused one
183
+ /**
184
+ * Focus survives filtering. It is decided in `collectRoutesFromState` from
185
+ * `state.index`; the only case that falls back to the last item is one
186
+ * where the filter dropped the focused route itself (an internal expo
187
+ * layout), which is the one situation the old rule was ever right for.
188
+ */
189
+ const collectedFocus = finalRoutes.findIndex(route => route.isFocused);
190
+ const focusIndex = collectedFocus >= 0 ? collectedFocus : finalRoutes.length - 1;
154
191
  return finalRoutes.map((route, index) => {
155
192
  let cleanPath = route.path || `/${route.name}`;
156
193
  // Clean up Expo Router internal /__root prefix
@@ -166,7 +203,7 @@ function buildStack(navigationState, isExpo) {
166
203
  name: route.name,
167
204
  pathname: cleanPath,
168
205
  params: route.params || {},
169
- isFocused: index === finalRoutes.length - 1,
206
+ isFocused: index === focusIndex,
170
207
  index,
171
208
  canPop: index > 0
172
209
  };
@@ -1 +1 @@
1
- {"version":3,"file":"RouteTracker.d.ts","sourceRoot":"","sources":["../../src/RouteTracker.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAiDH,wBAAgB,YAAY,gCAQ3B"}
1
+ {"version":3,"file":"RouteTracker.d.ts","sourceRoot":"","sources":["../../src/RouteTracker.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AA4DH,wBAAgB,YAAY,gCAQ3B"}
@@ -1 +1 @@
1
- {"version":3,"file":"RouteEventItemCompact.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventItemCompact.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAKzD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB;0DACsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AA6FD,eAAO,MAAM,qBAAqB,kEA0FhC,CAAC"}
1
+ {"version":3,"file":"RouteEventItemCompact.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventItemCompact.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAKzD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB;0DACsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AA6FD,eAAO,MAAM,qBAAqB,kEA6EhC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"RouteEventsModalWithTabs.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventsModalWithTabs.tsx"],"names":[],"mappings":"AA0DA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAGnE,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,GAAG,KAAK,IAAI,CAAC;IACvC,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE;YACR,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;YACrB,MAAM,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;YAChC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SAC/B,CAAC;QACF,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;QAC3B;;;;WAIG;QACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;QAC7C;;;;;WAKG;QACH,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC5C,2EAA2E;QAC3E,aAAa,CAAC,EAAE,CACd,MAAM,EAAE,yBAAyB,EACjC,OAAO,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,KACzB,IAAI,CAAC;KACX,CAAC;CACH;AAgED,wBAAgB,wBAAwB,CAAC,EACvC,OAAO,EACP,OAAO,EACP,MAAM,EACN,UAAU,EACV,2BAAmC,EACnC,MAAc,EACd,MAAM,GACP,EAAE,6BAA6B,sCAqmB/B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,GAAE,IAAI,CAAC,6BAA6B,EAAE,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAM,+BAKlF"}
1
+ {"version":3,"file":"RouteEventsModalWithTabs.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventsModalWithTabs.tsx"],"names":[],"mappings":"AA0DA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,mBAAmB,CAAC;AAGnE,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,GAAG,KAAK,IAAI,CAAC;IACvC,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE;YACR,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;YACrB,MAAM,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;YAChC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SAC/B,CAAC;QACF,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;QAC3B;;;;WAIG;QACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;QAC7C;;;;;WAKG;QACH,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC5C,2EAA2E;QAC3E,aAAa,CAAC,EAAE,CACd,MAAM,EAAE,yBAAyB,EACjC,OAAO,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,KACzB,IAAI,CAAC;KACX,CAAC;CACH;AAgED,wBAAgB,wBAAwB,CAAC,EACvC,OAAO,EACP,OAAO,EACP,MAAM,EACN,UAAU,EACV,2BAAmC,EACnC,MAAc,EACd,MAAM,GACP,EAAE,6BAA6B,sCA4mB/B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,GAAE,IAAI,CAAC,6BAA6B,EAAE,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAM,+BAKlF"}
@@ -31,6 +31,27 @@ export type RouteEventListener = (events: RouteChangeEvent[]) => void;
31
31
  export type RouteEventCallback = (event: RouteChangeEvent) => void;
32
32
  declare class RouteEventStore extends BaseEventStore<RouteChangeEvent> {
33
33
  private routeObserverUnsubscribe;
34
+ private bootCaptureRelease;
35
+ private bootCaptureHydrated;
36
+ /**
37
+ * Capture from app launch, not from the first open of the tool.
38
+ *
39
+ * A BaseEventStore only listens while something subscribes to it, and for
40
+ * routes that was the open modal or a watching desktop — so every
41
+ * navigation before the tool was first opened was simply never recorded,
42
+ * and the Events tab opened on "No route events yet" after a session of
43
+ * navigating. (It looked fine on any machine with Buoy Desktop running,
44
+ * because the desktop's watch subscribed at boot.) Network and storage both
45
+ * capture from launch; this holds a subscription of the store's own so
46
+ * routes does too.
47
+ *
48
+ * The hold follows the tool's persisted monitoring switch: the modal calls
49
+ * this on every toggle, and `hydrateBootCapture` reads the persisted value
50
+ * at boot so a paused tool stays paused across a relaunch.
51
+ */
52
+ setBootCapture(enabled: boolean): void;
53
+ /** Arm boot capture from the persisted monitoring switch (default on). */
54
+ hydrateBootCapture(): void;
34
55
  constructor();
35
56
  /**
36
57
  * Start listening to route changes from routeObserver
@@ -1 +1 @@
1
- {"version":3,"file":"routeEventStore.d.ts","sourceRoot":"","sources":["../../../src/stores/routeEventStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGxE,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAC;AAEtE;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;AAEnE,cAAM,eAAgB,SAAQ,cAAc,CAAC,gBAAgB,CAAC;IAE5D,OAAO,CAAC,wBAAwB,CAA6B;;IAS7D;;OAEG;IACH,SAAS,CAAC,cAAc,IAAI,IAAI;IAQhC;;OAEG;IACH,SAAS,CAAC,aAAa,IAAI,IAAI;IAO/B;;OAEG;IACH,WAAW,IAAI,OAAO;IAKtB,WAAW,IAAI,OAAO;CAGvB;AAGD,eAAO,MAAM,eAAe,iBAAwB,CAAC;AAGrD,eAAO,MAAM,sBAAsB,GAAI,UAAU,kBAAkB,eACtB,CAAC;AAC9C,eAAO,MAAM,YAAY,GAAI,UAAU,kBAAkB,eACtB,CAAC;AACpC,eAAO,MAAM,cAAc,0BAAoC,CAAC;AAChE,eAAO,MAAM,gBAAgB,YAAsC,CAAC;AACpE,eAAO,MAAM,gBAAgB,eAAsC,CAAC"}
1
+ {"version":3,"file":"routeEventStore.d.ts","sourceRoot":"","sources":["../../../src/stores/routeEventStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EACL,cAAc,EAGf,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGxE,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAC;AAEtE;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;AAEnE,cAAM,eAAgB,SAAQ,cAAc,CAAC,gBAAgB,CAAC;IAE5D,OAAO,CAAC,wBAAwB,CAA6B;IAE7D,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,mBAAmB,CAAS;IAEpC;;;;;;;;;;;;;;;OAeG;IACH,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAStC,0EAA0E;IAC1E,kBAAkB,IAAI,IAAI;;IAoB1B;;OAEG;IACH,SAAS,CAAC,cAAc,IAAI,IAAI;IAQhC;;OAEG;IACH,SAAS,CAAC,aAAa,IAAI,IAAI;IAO/B;;OAEG;IACH,WAAW,IAAI,OAAO;IAKtB,WAAW,IAAI,OAAO;CAGvB;AAGD,eAAO,MAAM,eAAe,iBAAwB,CAAC;AAGrD,eAAO,MAAM,sBAAsB,GAAI,UAAU,kBAAkB,eACtB,CAAC;AAC9C,eAAO,MAAM,YAAY,GAAI,UAAU,kBAAkB,eACtB,CAAC;AACpC,eAAO,MAAM,cAAc,0BAAoC,CAAC;AAChE,eAAO,MAAM,gBAAgB,YAAsC,CAAC;AACpE,eAAO,MAAM,gBAAgB,eAAsC,CAAC"}
@@ -66,16 +66,25 @@ export declare const routeEventsSyncAdapter: {
66
66
  params: Record<string, string | string[]>;
67
67
  at: number;
68
68
  source: "event";
69
+ stale?: undefined;
69
70
  } | {
70
71
  path: string;
71
72
  params: Record<string, any>;
72
73
  at: null;
73
74
  source: "stack";
75
+ stale?: undefined;
76
+ } | {
77
+ path: string;
78
+ params: Record<string, string | string[]>;
79
+ at: number;
80
+ source: "event";
81
+ stale: true;
74
82
  } | {
75
83
  path: null;
76
84
  params: {};
77
85
  at: null;
78
86
  source: null;
87
+ stale?: undefined;
79
88
  };
80
89
  /**
81
90
  * Navigate the device to a concrete path. The dashboard resolves any
@@ -1 +1 @@
1
- {"version":3,"file":"routeEventsSyncAdapter.d.ts","sourceRoot":"","sources":["../../../src/sync/routeEventsSyncAdapter.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG9D;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,QAAY,CAAC;AA4BrD;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,mEAAmE;IACnE,MAAM,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;IAC/B,6EAA6E;IAC7E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,OAAO,EAAE,oBAAoB,CAAC;IAC9B,8EAA8E;IAC9E,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAWD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB;;uBAEhB,sBAAsB;0BAKjB,MAAM,IAAI;;;QAkC9B;;;;;;;;WAQG;;;;;;;;;;;;;;;;;QAgCH;;;WAGG;4BACiB,OAAO;;;;;;;;;wCAsCK,OAAO;;;mCAUZ,OAAO;;;;;;;;;;CAuBrC,CAAC"}
1
+ {"version":3,"file":"routeEventsSyncAdapter.d.ts","sourceRoot":"","sources":["../../../src/sync/routeEventsSyncAdapter.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG9D;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,QAAY,CAAC;AA4BrD;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,mEAAmE;IACnE,MAAM,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;IAC/B,6EAA6E;IAC7E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,OAAO,EAAE,oBAAoB,CAAC;IAC9B,8EAA8E;IAC9E,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAWD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB;;uBAEhB,sBAAsB;0BAKjB,MAAM,IAAI;;;QAkC9B;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;QAyDH;;;WAGG;4BACiB,OAAO;;;;;;;;;wCAsCK,OAAO;;;mCAUZ,OAAO;;;;;;;;;;CAuBrC,CAAC"}
@@ -38,6 +38,14 @@ export interface UseNavigationStackResult {
38
38
  goBack: () => void;
39
39
  popToTop: () => void;
40
40
  }
41
+ /**
42
+ * Transform raw navigation state into display items.
43
+ *
44
+ * Exported for tests: the focus rule below is the one thing every route answer
45
+ * in the product rests on, and it was previously only reachable by extracting
46
+ * the function from source with the TypeScript AST.
47
+ */
48
+ export declare function buildStack(navigationState: any, isExpo: boolean): StackDisplayItem[];
41
49
  /**
42
50
  * Access the current navigation stack.
43
51
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useNavigationStack.d.ts","sourceRoot":"","sources":["../../src/useNavigationStack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA4BH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IAEvC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,YAAY,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAGlB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAGpB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC;;;;;OAKG;IACH,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5D,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAuKD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,IAAI,wBAAwB,CAiM7D"}
1
+ {"version":3,"file":"useNavigationStack.d.ts","sourceRoot":"","sources":["../../src/useNavigationStack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAiCH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IAEvC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,YAAY,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAGlB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAGpB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC;;;;;OAKG;IACH,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5D,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAqJD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,eAAe,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,GAAG,gBAAgB,EAAE,CAwDpF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,IAAI,wBAAwB,CAiM7D"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@buoy-gg/route-events",
3
- "version": "7.0.35",
4
- "description": "Navigation devtool for React Native — live stack, full sitemap, jump to any screen. Expo Router & React Navigation. Part of Buoy devtools.",
3
+ "version": "7.0.38",
4
+ "description": "Inspect reported navigation state and supported route actions in React Native apps using Expo Router or React Navigation.",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
7
7
  "types": "lib/typescript/index.d.ts",
@@ -26,12 +26,11 @@
26
26
  ],
27
27
  "sideEffects": false,
28
28
  "dependencies": {
29
- "@buoy-gg/floating-tools-core": "^7.0.35",
30
- "@buoy-gg/shared-ui": "^7.0.35"
29
+ "@buoy-gg/floating-tools-core": "^7.0.38",
30
+ "@buoy-gg/shared-ui": "^7.0.38"
31
31
  },
32
32
  "peerDependencies": {
33
- "@buoy-gg/license": "7.0.35",
34
- "@react-native-async-storage/async-storage": "^2.0.0 || ^3.1.0",
33
+ "@buoy-gg/license": "7.0.38",
35
34
  "@react-navigation/native": "*",
36
35
  "expo-router": "*",
37
36
  "react": "*",
@@ -52,7 +51,7 @@
52
51
  "@types/react-native": "^0.73.0",
53
52
  "expo-router": "~5.0.7",
54
53
  "typescript": "~5.8.3",
55
- "@buoy-gg/license": "^7.0.35"
54
+ "@buoy-gg/license": "^7.0.38"
56
55
  },
57
56
  "react-native-builder-bob": {
58
57
  "source": "src",