@buoy-gg/route-events 3.0.1 → 3.0.2

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 (32) hide show
  1. package/lib/commonjs/RouteTracker.js +38 -4
  2. package/lib/commonjs/components/NavigationStack.js +47 -31
  3. package/lib/commonjs/components/RoutesSitemap.js +176 -133
  4. package/lib/commonjs/expoRouterStore.js +17 -0
  5. package/lib/commonjs/index.js +7 -0
  6. package/lib/commonjs/stores/navigationStackStore.js +45 -0
  7. package/lib/commonjs/sync/routeEventsSyncAdapter.js +148 -0
  8. package/lib/commonjs/useRouteSitemap.js +28 -7
  9. package/lib/module/RouteTracker.js +39 -5
  10. package/lib/module/components/NavigationStack.js +47 -31
  11. package/lib/module/components/RoutesSitemap.js +177 -134
  12. package/lib/module/expoRouterStore.js +16 -0
  13. package/lib/module/index.js +4 -0
  14. package/lib/module/stores/navigationStackStore.js +41 -0
  15. package/lib/module/sync/routeEventsSyncAdapter.js +145 -0
  16. package/lib/module/useRouteSitemap.js +29 -8
  17. package/lib/typescript/RouteTracker.d.ts.map +1 -1
  18. package/lib/typescript/components/NavigationStack.d.ts +18 -1
  19. package/lib/typescript/components/NavigationStack.d.ts.map +1 -1
  20. package/lib/typescript/components/RoutesSitemap.d.ts +19 -1
  21. package/lib/typescript/components/RoutesSitemap.d.ts.map +1 -1
  22. package/lib/typescript/expoRouterStore.d.ts +8 -0
  23. package/lib/typescript/expoRouterStore.d.ts.map +1 -1
  24. package/lib/typescript/index.d.ts +3 -1
  25. package/lib/typescript/index.d.ts.map +1 -1
  26. package/lib/typescript/stores/navigationStackStore.d.ts +33 -0
  27. package/lib/typescript/stores/navigationStackStore.d.ts.map +1 -0
  28. package/lib/typescript/sync/routeEventsSyncAdapter.d.ts +69 -0
  29. package/lib/typescript/sync/routeEventsSyncAdapter.d.ts.map +1 -0
  30. package/lib/typescript/useRouteSitemap.d.ts +17 -0
  31. package/lib/typescript/useRouteSitemap.d.ts.map +1 -1
  32. package/package.json +5 -5
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.navigationStackStore = void 0;
7
+ /**
8
+ * Navigation Stack Store
9
+ *
10
+ * Holds the latest serializable navigation stack plus references to the live
11
+ * navigation action functions. The stack is captured inside the navigation tree
12
+ * by <RouteTracker /> (which runs useNavigationStack), and read by the
13
+ * route-events sync adapter so the dashboard can render the Stack tab and drive
14
+ * navigation on the device remotely.
15
+ *
16
+ * The stack itself (StackDisplayItem[]) is JSON-serializable and synced to the
17
+ * dashboard. The action functions are NOT serialized — they stay on the device
18
+ * and are invoked when the dashboard sends a remote action.
19
+ */
20
+
21
+ class NavigationStackStore {
22
+ stack = [];
23
+ actions = null;
24
+ listeners = new Set();
25
+ getStack() {
26
+ return this.stack;
27
+ }
28
+ setStack(stack) {
29
+ this.stack = stack;
30
+ this.listeners.forEach(listener => listener());
31
+ }
32
+ getActions() {
33
+ return this.actions;
34
+ }
35
+ setActions(actions) {
36
+ this.actions = actions;
37
+ }
38
+ subscribe(listener) {
39
+ this.listeners.add(listener);
40
+ return () => {
41
+ this.listeners.delete(listener);
42
+ };
43
+ }
44
+ }
45
+ const navigationStackStore = exports.navigationStackStore = new NavigationStackStore();
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.routeEventsSyncAdapter = void 0;
7
+ var _sharedUi = require("@buoy-gg/shared-ui");
8
+ var _routeEventStore = require("../stores/routeEventStore");
9
+ var _navigationStackStore = require("../stores/navigationStackStore");
10
+ var _RouteParser = require("../RouteParser");
11
+ var _expoRouterStore = require("../expoRouterStore");
12
+ /**
13
+ * Serializable snapshot of the device's route tree, sent to the dashboard so
14
+ * the Routes tab can render a sitemap (the raw expo-router RouteNode itself is
15
+ * not JSON-serializable — it holds components/functions — so we send the parsed
16
+ * RouteInfo[] instead).
17
+ */
18
+
19
+ /**
20
+ * Payload shape for the route-events tool (adapter version 3). Consumers must
21
+ * tolerate older shapes: v1 sends a bare RouteChangeEvent[]; v2 omits `stack`.
22
+ */
23
+
24
+ function getSitemapSnapshot() {
25
+ const metadata = (0, _expoRouterStore.getRouteNodeMetadata)();
26
+ return {
27
+ routes: _RouteParser.RouteParser.parseRouteTree((0, _expoRouterStore.loadRouteNode)()),
28
+ source: metadata.source,
29
+ lastUpdatedAt: metadata.lastLoadedAt
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Sync adapter for the route-events tool, consumed by @buoy-gg/external-sync's
35
+ * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
36
+ * this package doesn't need a dependency on it).
37
+ *
38
+ * Subscribing attaches the routeObserver listener, so route changes are only
39
+ * recorded while a dashboard is watching. (Route events still require a
40
+ * <RouteTracker /> inside the navigation tree to be emitted at all.)
41
+ *
42
+ * The snapshot carries both the route-change events AND the parsed route
43
+ * sitemap, so the dashboard — which has no local expo-router store — can render
44
+ * the route tree. The `navigate` action lets the dashboard drive navigation on
45
+ * the device.
46
+ */
47
+ const routeEventsSyncAdapter = exports.routeEventsSyncAdapter = {
48
+ version: 3,
49
+ getSnapshot: () => ({
50
+ events: _routeEventStore.routeEventStore.getEvents(),
51
+ sitemap: getSitemapSnapshot(),
52
+ stack: _navigationStackStore.navigationStackStore.getStack()
53
+ }),
54
+ subscribe: onChange => {
55
+ const unsubscribeEvents = _routeEventStore.routeEventStore.subscribeToEvents(onChange);
56
+ const unsubscribeStack = _navigationStackStore.navigationStackStore.subscribe(onChange);
57
+
58
+ // The expo-router route tree can still be populating when a dashboard
59
+ // starts watching. Poll briefly until it's available and push a snapshot
60
+ // the moment it loads, so the Routes tab fills in without needing the user
61
+ // to navigate first. Stops as soon as the tree is found.
62
+ let pollTimer = null;
63
+ if (!(0, _expoRouterStore.loadRouteNode)()) {
64
+ let retries = 0;
65
+ const maxRetries = 100; // ~10s
66
+ const poll = () => {
67
+ retries += 1;
68
+ if ((0, _expoRouterStore.loadRouteNode)()) {
69
+ pollTimer = null;
70
+ onChange();
71
+ return;
72
+ }
73
+ pollTimer = retries < maxRetries ? setTimeout(poll, 100) : null;
74
+ };
75
+ pollTimer = setTimeout(poll, 100);
76
+ }
77
+ return () => {
78
+ unsubscribeEvents();
79
+ unsubscribeStack();
80
+ if (pollTimer) clearTimeout(pollTimer);
81
+ };
82
+ },
83
+ actions: {
84
+ clearEvents: () => {
85
+ _routeEventStore.routeEventStore.clearEvents();
86
+ },
87
+ /**
88
+ * Navigate the device to a concrete path. The dashboard resolves any
89
+ * dynamic params into a concrete path before invoking this.
90
+ */
91
+ navigate: params => {
92
+ const path = params?.path;
93
+ if (!path) {
94
+ throw new Error("navigate requires a 'path' param");
95
+ }
96
+ const router = (0, _sharedUi.getSafeRouter)();
97
+ if (!router) {
98
+ throw new Error("expo-router is not available on this device");
99
+ }
100
+ router.navigate(path);
101
+ return {
102
+ navigated: path
103
+ };
104
+ },
105
+ // ── Stack actions: delegate to the live navigation actions captured by
106
+ // <RouteTracker /> (they hold the React Navigation container ref). ──
107
+ stackNavigateToIndex: params => {
108
+ const index = params?.index;
109
+ if (typeof index !== "number") {
110
+ throw new Error("stackNavigateToIndex requires a numeric 'index'");
111
+ }
112
+ const actions = _navigationStackStore.navigationStackStore.getActions();
113
+ if (!actions) throw new Error("navigation stack is not available");
114
+ actions.navigateToIndex(index);
115
+ return {
116
+ navigatedToIndex: index
117
+ };
118
+ },
119
+ stackPopToIndex: params => {
120
+ const index = params?.index;
121
+ if (typeof index !== "number") {
122
+ throw new Error("stackPopToIndex requires a numeric 'index'");
123
+ }
124
+ const actions = _navigationStackStore.navigationStackStore.getActions();
125
+ if (!actions) throw new Error("navigation stack is not available");
126
+ actions.popToIndex(index);
127
+ return {
128
+ poppedToIndex: index
129
+ };
130
+ },
131
+ stackGoBack: () => {
132
+ const actions = _navigationStackStore.navigationStackStore.getActions();
133
+ if (!actions) throw new Error("navigation stack is not available");
134
+ actions.goBack();
135
+ return {
136
+ wentBack: true
137
+ };
138
+ },
139
+ stackPopToTop: () => {
140
+ const actions = _navigationStackStore.navigationStackStore.getActions();
141
+ if (!actions) throw new Error("navigation stack is not available");
142
+ actions.popToTop();
143
+ return {
144
+ poppedToTop: true
145
+ };
146
+ }
147
+ }
148
+ };
@@ -96,8 +96,12 @@ function useRouteSitemap(options = {}) {
96
96
  searchQuery = "",
97
97
  sortBy = "path",
98
98
  autoRefresh = false,
99
- refreshInterval = 1000
99
+ refreshInterval = 1000,
100
+ injectedRoutes,
101
+ injectedSource = null,
102
+ injectedLastUpdatedAt = null
100
103
  } = options;
104
+ const hasInjectedRoutes = injectedRoutes != null;
101
105
  const [routeTreeState, setRouteTreeState] = (0, _react.useState)(() => {
102
106
  const node = (0, _expoRouterStore.loadRouteNode)();
103
107
  const metadata = (0, _expoRouterStore.getRouteNodeMetadata)();
@@ -132,6 +136,16 @@ function useRouteSitemap(options = {}) {
132
136
  if (routeNode) {
133
137
  return;
134
138
  }
139
+
140
+ // Routes injected from outside (dashboard) don't come from the local store.
141
+ if (hasInjectedRoutes) {
142
+ return;
143
+ }
144
+
145
+ // On web (no expo-router) the tree will never load — don't poll forever.
146
+ if (!(0, _expoRouterStore.isExpoRouterStoreSupported)()) {
147
+ return;
148
+ }
135
149
  let retryCount = 0;
136
150
  const maxRetries = 100; // 10 seconds max
137
151
 
@@ -144,7 +158,7 @@ function useRouteSitemap(options = {}) {
144
158
  };
145
159
  let timeoutRef = setTimeout(poll, 100);
146
160
  return () => clearTimeout(timeoutRef);
147
- }, [routeNode, refresh]);
161
+ }, [routeNode, refresh, hasInjectedRoutes]);
148
162
 
149
163
  // Optional auto-refresh hook for callers that want periodic updates
150
164
  (0, _react.useEffect)(() => {
@@ -152,13 +166,19 @@ function useRouteSitemap(options = {}) {
152
166
  const interval = setInterval(refresh, refreshInterval);
153
167
  return () => clearInterval(interval);
154
168
  }, [autoRefresh, refreshInterval, refresh]);
155
- const isLoaded = !!routeNode;
156
169
 
157
- // Parse routes
170
+ // With injected routes we're "supported" regardless of the local runtime, and
171
+ // "loaded" once the device has actually sent a non-empty tree (an empty array
172
+ // means the device is still booting expo-router → keep showing "Loading...").
173
+ const isLoaded = hasInjectedRoutes ? injectedRoutes.length > 0 : !!routeNode;
174
+ const isSupported = hasInjectedRoutes ? true : (0, _expoRouterStore.isExpoRouterStoreSupported)();
175
+
176
+ // Parse routes (or use the pre-parsed routes injected from the dashboard).
158
177
  const routes = (0, _react.useMemo)(() => {
178
+ if (hasInjectedRoutes) return injectedRoutes;
159
179
  if (!routeNode) return [];
160
180
  return _RouteParser.RouteParser.parseRouteTree(routeNode);
161
- }, [routeNode, routeNodeVersion]);
181
+ }, [routeNode, routeNodeVersion, hasInjectedRoutes, injectedRoutes]);
162
182
 
163
183
  // Sort routes
164
184
  const sortedRoutes = (0, _react.useMemo)(() => {
@@ -190,11 +210,12 @@ function useRouteSitemap(options = {}) {
190
210
  stats,
191
211
  filteredRoutes,
192
212
  isLoaded,
213
+ isSupported,
193
214
  refresh,
194
215
  findRoute,
195
216
  getParents,
196
- lastUpdatedAt,
197
- source
217
+ lastUpdatedAt: hasInjectedRoutes ? injectedLastUpdatedAt : lastUpdatedAt,
218
+ source: hasInjectedRoutes ? injectedSource : source
198
219
  };
199
220
  }
200
221
 
@@ -41,10 +41,13 @@
41
41
  * ```
42
42
  */
43
43
 
44
+ import { useEffect } from "react";
44
45
  import { isExpoRouterAvailable } from "@buoy-gg/shared-ui";
45
46
  import { useRouteObserver } from "./useRouteObserver";
46
47
  import { useRouteObserverReactNavigation } from "./useRouteObserverReactNavigation";
47
- import { jsx as _jsx } from "react/jsx-runtime";
48
+ import { useNavigationStack } from "./useNavigationStack";
49
+ import { navigationStackStore } from "./stores/navigationStackStore";
50
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
48
51
  function ExpoRouteTracker() {
49
52
  useRouteObserver();
50
53
  return null;
@@ -53,10 +56,41 @@ function ReactNavigationRouteTracker() {
53
56
  useRouteObserverReactNavigation();
54
57
  return null;
55
58
  }
59
+
60
+ /**
61
+ * Mirrors the live navigation stack (and its action functions) into
62
+ * navigationStackStore so the route-events sync adapter can expose the Stack
63
+ * tab to the dashboard. Runs inside the navigation tree where the container ref
64
+ * context is available.
65
+ */
66
+ function NavigationStackCapture() {
67
+ const {
68
+ stack,
69
+ navigateToIndex,
70
+ popToIndex,
71
+ goBack,
72
+ popToTop
73
+ } = useNavigationStack();
74
+ useEffect(() => {
75
+ navigationStackStore.setStack(stack);
76
+ }, [stack]);
77
+
78
+ // Keep the action references fresh (they change as the stack changes) so a
79
+ // remote action always operates on the current navigation state.
80
+ useEffect(() => {
81
+ navigationStackStore.setActions({
82
+ navigateToIndex,
83
+ popToIndex,
84
+ goBack,
85
+ popToTop
86
+ });
87
+ return () => navigationStackStore.setActions(null);
88
+ });
89
+ return null;
90
+ }
56
91
  export function RouteTracker() {
57
92
  const hasExpo = isExpoRouterAvailable();
58
- if (hasExpo) {
59
- return /*#__PURE__*/_jsx(ExpoRouteTracker, {});
60
- }
61
- return /*#__PURE__*/_jsx(ReactNavigationRouteTracker, {});
93
+ return /*#__PURE__*/_jsxs(_Fragment, {
94
+ children: [hasExpo ? /*#__PURE__*/_jsx(ExpoRouteTracker, {}) : /*#__PURE__*/_jsx(ReactNavigationRouteTracker, {}), /*#__PURE__*/_jsx(NavigationStackCapture, {})]
95
+ });
62
96
  }
@@ -17,25 +17,35 @@ import { useNavigationStack } from "../useNavigationStack";
17
17
  // ============================================================================
18
18
  // Types
19
19
  // ============================================================================
20
+
21
+ /** Stack manipulation a host can be asked to perform on the device. */
20
22
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
23
  // ============================================================================
22
24
  // Main Component
23
25
  // ============================================================================
24
26
 
25
27
  export function NavigationStack({
26
- style
28
+ style,
29
+ injectedStack,
30
+ onAction
27
31
  }) {
28
- const {
29
- stack,
30
- focusedRoute,
31
- stackDepth,
32
- isAtRoot,
33
- isLoaded,
34
- error,
35
- navigateToIndex,
36
- goBack,
37
- popToTop
38
- } = useNavigationStack();
32
+ const hookResult = useNavigationStack();
33
+ const isInjected = injectedStack != null;
34
+
35
+ // Data source: injected (dashboard) or the local navigation container.
36
+ const stack = isInjected ? injectedStack : hookResult.stack;
37
+ const isLoaded = isInjected ? true : hookResult.isLoaded;
38
+ const error = isInjected ? null : hookResult.error;
39
+ const focusedRoute = useMemo(() => stack.find(item => item.isFocused) ?? null, [stack]);
40
+ const stackDepth = stack.length;
41
+ const isAtRoot = stackDepth <= 1;
42
+
43
+ // Action wrappers: delegate to the host when provided, else act locally.
44
+ const navigateToIndex = index => onAction ? onAction("navigateToIndex", {
45
+ index
46
+ }) : hookResult.navigateToIndex(index);
47
+ const goBack = () => onAction ? onAction("goBack") : hookResult.goBack();
48
+ const popToTop = () => onAction ? onAction("popToTop") : hookResult.popToTop();
39
49
  const [expandedIndex, setExpandedIndex] = useState(null);
40
50
  const [showHelp, setShowHelp] = useState(false);
41
51
  const [showUpgradeModal, setShowUpgradeModal] = useState(false);
@@ -124,6 +134,26 @@ export function NavigationStack({
124
134
  }
125
135
 
126
136
  // Handlers
137
+ // Confirm a destructive action. On device we use the native Alert; when an
138
+ // action delegate is set (dashboard, react-native-web) Alert button presses
139
+ // don't fire, so fall back to window.confirm.
140
+ const confirmDestructive = (title, message, confirmLabel, onConfirm) => {
141
+ if (onAction) {
142
+ const confirmFn = globalThis.window?.confirm;
143
+ if (!confirmFn || confirmFn(`${title}\n\n${message}`)) {
144
+ onConfirm();
145
+ }
146
+ return;
147
+ }
148
+ Alert.alert(title, message, [{
149
+ text: "Cancel",
150
+ style: "cancel"
151
+ }, {
152
+ text: confirmLabel,
153
+ style: "destructive",
154
+ onPress: onConfirm
155
+ }]);
156
+ };
127
157
  const handleGoBack = () => {
128
158
  // Gate behind Pro
129
159
  if (!isPro) {
@@ -146,14 +176,7 @@ export function NavigationStack({
146
176
  Alert.alert("Already at Top", "Stack only has one screen");
147
177
  return;
148
178
  }
149
- Alert.alert("Pop to Top", "This will remove all screens except the root screen.", [{
150
- text: "Cancel",
151
- style: "cancel"
152
- }, {
153
- text: "Pop to Top",
154
- style: "destructive",
155
- onPress: popToTop
156
- }]);
179
+ confirmDestructive("Pop to Top", "This will remove all screens except the root screen.", "Pop to Top", popToTop);
157
180
  };
158
181
  const toggleExpand = index => {
159
182
  setExpandedIndex(expandedIndex === index ? null : index);
@@ -195,17 +218,10 @@ export function NavigationStack({
195
218
  return;
196
219
  }
197
220
  const screensToRemove = stackDepth - 1 - selectedRoute.index;
198
- Alert.alert("Pop to Route", `Remove ${screensToRemove} screen${screensToRemove !== 1 ? "s" : ""} above ${selectedRoute.pathname}?`, [{
199
- text: "Cancel",
200
- style: "cancel"
201
- }, {
202
- text: "Pop",
203
- style: "destructive",
204
- onPress: () => {
205
- // Navigate to the selected route, which effectively pops everything above it
206
- navigateToIndex(selectedRoute.index);
207
- }
208
- }]);
221
+ const targetIndex = selectedRoute.index;
222
+ confirmDestructive("Pop to Route", `Remove ${screensToRemove} screen${screensToRemove !== 1 ? "s" : ""} above ${selectedRoute.pathname}?`, "Pop",
223
+ // Navigate to the selected route, which effectively pops everything above it
224
+ () => navigateToIndex(targetIndex));
209
225
  };
210
226
  return /*#__PURE__*/_jsxs(View, {
211
227
  style: [styles.container, style],