@buoy-gg/route-events 7.0.41 → 7.0.43

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 (102) hide show
  1. package/README.md +11 -64
  2. package/lib/commonjs/RouteObserver.js +1 -54
  3. package/lib/commonjs/RouteParser.js +1 -314
  4. package/lib/commonjs/RouteTracker.js +1 -112
  5. package/lib/commonjs/RouteTracker.web.js +1 -12
  6. package/lib/commonjs/browserRouteRegistry.js +1 -30
  7. package/lib/commonjs/browserRoutes.js +1 -96
  8. package/lib/commonjs/components/NavigationStack.js +2 -572
  9. package/lib/commonjs/components/ReactNavigationRoutes.js +1 -836
  10. package/lib/commonjs/components/ReactNavigationRoutes.web.js +1 -12
  11. package/lib/commonjs/components/RouteEventDetail.js +1 -97
  12. package/lib/commonjs/components/RouteEventExpandedContent.js +1 -204
  13. package/lib/commonjs/components/RouteEventItemCompact.js +1 -191
  14. package/lib/commonjs/components/RouteEventsModalWithTabs.js +1 -734
  15. package/lib/commonjs/components/RouteEventsTimeline.js +1 -107
  16. package/lib/commonjs/components/RouteFilterViewV2.js +1 -42
  17. package/lib/commonjs/components/RoutesSitemap.js +2 -1035
  18. package/lib/commonjs/expoRouterStore.js +1 -121
  19. package/lib/commonjs/expoRouterStore.web.js +1 -44
  20. package/lib/commonjs/hooks/useRouteEvents.js +1 -57
  21. package/lib/commonjs/index.js +1 -168
  22. package/lib/commonjs/preset.js +1 -69
  23. package/lib/commonjs/stores/navigationStackStore.js +1 -45
  24. package/lib/commonjs/stores/routeEventStore.js +1 -136
  25. package/lib/commonjs/sync/routeEventsSyncAdapter.js +1 -275
  26. package/lib/commonjs/useNavigationStack.js +1 -408
  27. package/lib/commonjs/useNavigationStack.web.js +1 -32
  28. package/lib/commonjs/useRouteObserver.js +1 -73
  29. package/lib/commonjs/useRouteObserverReactNavigation.js +1 -109
  30. package/lib/commonjs/useRouteSitemap.js +1 -255
  31. package/lib/commonjs/utils/routeTemplate.js +1 -79
  32. package/lib/commonjs/utils/safeReactNavigation.js +1 -194
  33. package/lib/commonjs/utils/safeReactNavigation.web.js +1 -36
  34. package/lib/commonjs/utils/testIDs.js +1 -63
  35. package/lib/module/RouteObserver.js +1 -49
  36. package/lib/module/RouteParser.js +1 -309
  37. package/lib/module/RouteTracker.js +1 -109
  38. package/lib/module/RouteTracker.web.js +1 -8
  39. package/lib/module/browserRouteRegistry.js +1 -24
  40. package/lib/module/browserRoutes.js +1 -92
  41. package/lib/module/components/NavigationStack.js +2 -568
  42. package/lib/module/components/ReactNavigationRoutes.js +1 -832
  43. package/lib/module/components/ReactNavigationRoutes.web.js +1 -3
  44. package/lib/module/components/RouteEventDetail.js +1 -93
  45. package/lib/module/components/RouteEventExpandedContent.js +1 -200
  46. package/lib/module/components/RouteEventItemCompact.js +1 -187
  47. package/lib/module/components/RouteEventsModalWithTabs.js +1 -731
  48. package/lib/module/components/RouteEventsTimeline.js +1 -103
  49. package/lib/module/components/RouteFilterViewV2.js +1 -38
  50. package/lib/module/components/RoutesSitemap.js +2 -1033
  51. package/lib/module/expoRouterStore.js +1 -114
  52. package/lib/module/expoRouterStore.web.js +1 -37
  53. package/lib/module/hooks/useRouteEvents.js +1 -54
  54. package/lib/module/index.js +1 -70
  55. package/lib/module/preset.js +1 -66
  56. package/lib/module/stores/navigationStackStore.js +1 -41
  57. package/lib/module/stores/routeEventStore.js +1 -128
  58. package/lib/module/sync/routeEventsSyncAdapter.js +1 -273
  59. package/lib/module/useNavigationStack.js +1 -404
  60. package/lib/module/useNavigationStack.web.js +1 -28
  61. package/lib/module/useRouteObserver.js +1 -70
  62. package/lib/module/useRouteObserverReactNavigation.js +1 -106
  63. package/lib/module/useRouteSitemap.js +1 -250
  64. package/lib/module/utils/routeTemplate.js +1 -73
  65. package/lib/module/utils/safeReactNavigation.js +1 -185
  66. package/lib/module/utils/safeReactNavigation.web.js +1 -27
  67. package/lib/module/utils/testIDs.js +1 -55
  68. package/lib/web/index.mjs +4 -5
  69. package/package.json +6 -11
  70. package/lib/typescript/RouteObserver.d.ts.map +0 -1
  71. package/lib/typescript/RouteParser.d.ts.map +0 -1
  72. package/lib/typescript/RouteTracker.d.ts.map +0 -1
  73. package/lib/typescript/RouteTracker.web.d.ts.map +0 -1
  74. package/lib/typescript/browserRouteRegistry.d.ts.map +0 -1
  75. package/lib/typescript/browserRoutes.d.ts.map +0 -1
  76. package/lib/typescript/components/NavigationStack.d.ts.map +0 -1
  77. package/lib/typescript/components/ReactNavigationRoutes.d.ts.map +0 -1
  78. package/lib/typescript/components/ReactNavigationRoutes.web.d.ts.map +0 -1
  79. package/lib/typescript/components/RouteEventDetail.d.ts.map +0 -1
  80. package/lib/typescript/components/RouteEventExpandedContent.d.ts.map +0 -1
  81. package/lib/typescript/components/RouteEventItemCompact.d.ts.map +0 -1
  82. package/lib/typescript/components/RouteEventsModalWithTabs.d.ts.map +0 -1
  83. package/lib/typescript/components/RouteEventsTimeline.d.ts.map +0 -1
  84. package/lib/typescript/components/RouteFilterViewV2.d.ts.map +0 -1
  85. package/lib/typescript/components/RoutesSitemap.d.ts.map +0 -1
  86. package/lib/typescript/expoRouterStore.d.ts.map +0 -1
  87. package/lib/typescript/expoRouterStore.web.d.ts.map +0 -1
  88. package/lib/typescript/hooks/useRouteEvents.d.ts.map +0 -1
  89. package/lib/typescript/index.d.ts.map +0 -1
  90. package/lib/typescript/preset.d.ts.map +0 -1
  91. package/lib/typescript/stores/navigationStackStore.d.ts.map +0 -1
  92. package/lib/typescript/stores/routeEventStore.d.ts.map +0 -1
  93. package/lib/typescript/sync/routeEventsSyncAdapter.d.ts.map +0 -1
  94. package/lib/typescript/useNavigationStack.d.ts.map +0 -1
  95. package/lib/typescript/useNavigationStack.web.d.ts.map +0 -1
  96. package/lib/typescript/useRouteObserver.d.ts.map +0 -1
  97. package/lib/typescript/useRouteObserverReactNavigation.d.ts.map +0 -1
  98. package/lib/typescript/useRouteSitemap.d.ts.map +0 -1
  99. package/lib/typescript/utils/routeTemplate.d.ts.map +0 -1
  100. package/lib/typescript/utils/safeReactNavigation.d.ts.map +0 -1
  101. package/lib/typescript/utils/safeReactNavigation.web.d.ts.map +0 -1
  102. package/lib/typescript/utils/testIDs.d.ts.map +0 -1
@@ -1,273 +1 @@
1
- "use strict";
2
-
3
- import { getSafeRouter } from "@buoy-gg/shared-ui";
4
- import { routeEventStore } from "../stores/routeEventStore";
5
- import { navigationStackStore } from "../stores/navigationStackStore";
6
- import { RouteParser } from "../RouteParser";
7
- import { loadRouteNode, getRouteNodeMetadata } from "../expoRouterStore";
8
- import { isOverWireBudget } from "@buoy-gg/shared-ui";
9
-
10
- /**
11
- * Navigation params larger than this are NOT streamed in the snapshot.
12
- * Apps routinely pass whole user objects (or a stringified payload) through
13
- * `navigate(..., { user })`; 500 of those on every route change exceed the
14
- * 2MB emit budget and drop the Routes panel. Pathname/segments stay inline.
15
- */
16
- export const SNAPSHOT_PARAM_INLINE_LIMIT = 16 * 1024;
17
- const PARAMS_ON_DEVICE = Object.freeze({
18
- __buoyOmitted: "route-params",
19
- note: "Navigation params stay on the device."
20
- });
21
- function shouldOmitParams(params) {
22
- if (params == null) return false;
23
- return isOverWireBudget(params, SNAPSHOT_PARAM_INLINE_LIMIT);
24
- }
25
- function toWireParams(params) {
26
- if (!shouldOmitParams(params)) return params;
27
- return PARAMS_ON_DEVICE;
28
- }
29
- function toWireEvent(event) {
30
- if (!shouldOmitParams(event.params)) return event;
31
- return {
32
- ...event,
33
- params: toWireParams(event.params)
34
- };
35
- }
36
- function toWireStackItem(item) {
37
- if (!shouldOmitParams(item.params)) return item;
38
- return {
39
- ...item,
40
- params: toWireParams(item.params)
41
- };
42
- }
43
-
44
- /**
45
- * Serializable snapshot of the device's route tree, sent to the dashboard so
46
- * the Routes tab can render a sitemap (the raw expo-router RouteNode itself is
47
- * not JSON-serializable — it holds components/functions — so we send the parsed
48
- * RouteInfo[] instead).
49
- */
50
-
51
- /**
52
- * Payload shape for the route-events tool (adapter version 3). Consumers must
53
- * tolerate older shapes: v1 sends a bare RouteChangeEvent[]; v2 omits `stack`.
54
- */
55
-
56
- function getSitemapSnapshot() {
57
- const metadata = getRouteNodeMetadata();
58
- return {
59
- routes: RouteParser.parseRouteTree(loadRouteNode()),
60
- source: metadata.source,
61
- lastUpdatedAt: metadata.lastLoadedAt
62
- };
63
- }
64
-
65
- /**
66
- * Sync adapter for the route-events tool, consumed by @buoy-gg/external-sync's
67
- * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
68
- * this package doesn't need a dependency on it).
69
- *
70
- * Subscribing attaches the routeObserver listener, so route changes are only
71
- * recorded while a dashboard is watching. (Route events still require a
72
- * <RouteTracker /> inside the navigation tree to be emitted at all.)
73
- *
74
- * The snapshot carries both the route-change events AND the parsed route
75
- * sitemap, so the dashboard — which has no local expo-router store — can render
76
- * the route tree. The `navigate` action lets the dashboard drive navigation on
77
- * the device.
78
- */
79
- export const routeEventsSyncAdapter = {
80
- version: 3,
81
- getSnapshot: () => ({
82
- events: routeEventStore.getEvents().map(toWireEvent),
83
- sitemap: getSitemapSnapshot(),
84
- stack: navigationStackStore.getStack().map(toWireStackItem)
85
- }),
86
- subscribe: onChange => {
87
- const unsubscribeEvents = routeEventStore.subscribeToEvents(onChange);
88
- const unsubscribeStack = navigationStackStore.subscribe(onChange);
89
-
90
- // The expo-router route tree can still be populating when a dashboard
91
- // starts watching. Poll briefly until it's available and push a snapshot
92
- // the moment it loads, so the Routes tab fills in without needing the user
93
- // to navigate first. Stops as soon as the tree is found.
94
- let pollTimer = null;
95
- if (!loadRouteNode()) {
96
- let retries = 0;
97
- const maxRetries = 100; // ~10s
98
- const poll = () => {
99
- retries += 1;
100
- if (loadRouteNode()) {
101
- pollTimer = null;
102
- onChange();
103
- return;
104
- }
105
- pollTimer = retries < maxRetries ? setTimeout(poll, 100) : null;
106
- };
107
- pollTimer = setTimeout(poll, 100);
108
- }
109
- return () => {
110
- unsubscribeEvents();
111
- unsubscribeStack();
112
- if (pollTimer) clearTimeout(pollTimer);
113
- };
114
- },
115
- actions: {
116
- clearEvents: () => {
117
- routeEventStore.clearEvents();
118
- },
119
- /**
120
- * Just where the app is right now — no sitemap, no history.
121
- *
122
- * The full snapshot carries the parsed route tree, which is the wrong
123
- * thing to hand a caller that polls (the macro recorder reads this several
124
- * times a second to notice a screen change). Reading the last route event
125
- * is also what makes this honest about apps with no <RouteTracker />: they
126
- * get `{ path: null }` rather than a plausible-looking guess.
127
- */
128
- getCurrentRoute: () => {
129
- // BaseEventStore is newest-FIRST, and the store only captures while
130
- // something is subscribed.
131
- const latest = routeEventStore.getEvents()[0];
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()) {
147
- return {
148
- path: latest.pathname,
149
- params: toWireParams(latest.params),
150
- at: latest.timestamp,
151
- source: "event"
152
- };
153
- }
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];
160
- if (focused?.pathname) {
161
- return {
162
- path: focused.pathname,
163
- params: toWireParams(focused.params ?? {}),
164
- at: null,
165
- source: "stack"
166
- };
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
- }
181
- return {
182
- path: null,
183
- params: {},
184
- at: null,
185
- source: null
186
- };
187
- },
188
- /**
189
- * Navigate the device to a concrete path. The dashboard resolves any
190
- * dynamic params into a concrete path before invoking this.
191
- */
192
- navigate: params => {
193
- const p = params;
194
- const path = p?.path;
195
- if (!path) {
196
- throw new Error("navigate requires a 'path' param");
197
- }
198
- const router = getSafeRouter();
199
- if (!router) {
200
- // Bare React Navigation: no paths, but the tracker captured the
201
- // container ref, so navigate by SCREEN NAME — "/Settings" and
202
- // "Settings" both resolve, and nested navigators are walked via the
203
- // root state. This is half the market; "throw off expo-router" made
204
- // navigate a dead verb for every RN CLI app.
205
- const actions = navigationStackStore.getActions();
206
- const name = path.replace(/^\//, "");
207
- if (!actions?.navigateToRoute || !name) {
208
- throw new Error("expo-router is not available here and no navigation container was captured — is <RouteTracker /> mounted inside your NavigationContainer?");
209
- }
210
- const ok = actions.navigateToRoute(name, p?.params);
211
- if (!ok) {
212
- throw new Error(`Could not navigate to "${name}" — no navigation container is live.`);
213
- }
214
- return {
215
- navigated: path,
216
- replaced: false,
217
- via: "react-navigation"
218
- };
219
- }
220
- // Push by default so navigate-then-back flows keep working. Callers that
221
- // mean "leave this screen" (sitemap Go, agents resetting to home) pass
222
- // replace: true — otherwise "Go to home" stacks another `/` on top.
223
- const go = p?.replace === true && typeof router.replace === "function" ? router.replace : router.navigate;
224
- go(path);
225
- return {
226
- navigated: path,
227
- replaced: p?.replace === true
228
- };
229
- },
230
- // ── Stack actions: delegate to the live navigation actions captured by
231
- // <RouteTracker /> (they hold the React Navigation container ref). ──
232
- stackNavigateToIndex: params => {
233
- const index = params?.index;
234
- if (typeof index !== "number") {
235
- throw new Error("stackNavigateToIndex requires a numeric 'index'");
236
- }
237
- const actions = navigationStackStore.getActions();
238
- if (!actions) throw new Error("navigation stack is not available");
239
- actions.navigateToIndex(index);
240
- return {
241
- navigatedToIndex: index
242
- };
243
- },
244
- stackPopToIndex: params => {
245
- const index = params?.index;
246
- if (typeof index !== "number") {
247
- throw new Error("stackPopToIndex requires a numeric 'index'");
248
- }
249
- const actions = navigationStackStore.getActions();
250
- if (!actions) throw new Error("navigation stack is not available");
251
- actions.popToIndex(index);
252
- return {
253
- poppedToIndex: index
254
- };
255
- },
256
- stackGoBack: () => {
257
- const actions = navigationStackStore.getActions();
258
- if (!actions) throw new Error("navigation stack is not available");
259
- actions.goBack();
260
- return {
261
- wentBack: true
262
- };
263
- },
264
- stackPopToTop: () => {
265
- const actions = navigationStackStore.getActions();
266
- if (!actions) throw new Error("navigation stack is not available");
267
- actions.popToTop();
268
- return {
269
- poppedToTop: true
270
- };
271
- }
272
- }
273
- };
1
+ "use strict";import{getSafeRouter as d}from"@buoy-gg/shared-ui";import{routeEventStore as i}from"../stores/routeEventStore";import{navigationStackStore as o}from"../stores/navigationStackStore";import{RouteParser as f}from"../RouteParser";import{loadRouteNode as p,getRouteNodeMetadata as v}from"../expoRouterStore";import{isOverWireBudget as g}from"@buoy-gg/shared-ui";export const SNAPSHOT_PARAM_INLINE_LIMIT=16*1024;const T=Object.freeze({__buoyOmitted:"route-params",note:"Navigation params stay on the device."});function l(t){if(t==null)return false;return g(t,SNAPSHOT_PARAM_INLINE_LIMIT)}function s(t){if(!l(t))return t;return T}function h(t){if(!l(t.params))return t;return{...t,params:s(t.params)}}function k(t){if(!l(t.params))return t;return{...t,params:s(t.params)}}function E(){const t=v();return{routes:f.parseRouteTree(p()),source:t.source,lastUpdatedAt:t.lastLoadedAt}}export const routeEventsSyncAdapter={version:3,getSnapshot:()=>({events:i.getEvents().map(h),sitemap:E(),stack:o.getStack().map(k)}),subscribe:t=>{const e=i.subscribeToEvents(t);const a=o.subscribe(t);let r=null;if(!p()){let c=0;const u=100;const n=()=>{c+=1;if(p()){r=null;t();return}r=c<u?setTimeout(n,100):null};r=setTimeout(n,100)}return()=>{e();a();if(r)clearTimeout(r)}},actions:{clearEvents:()=>{i.clearEvents()},getCurrentRoute:()=>{const t=i.getEvents()[0];if(t&&i.isCapturing()){return{path:t.pathname,params:s(t.params),at:t.timestamp,source:"event"}}const e=o.getStack();const a=e.find(r=>r.isFocused)??e[e.length-1];if(a?.pathname){return{path:a.pathname,params:s(a.params??{}),at:null,source:"stack"}}if(t){return{path:t.pathname,params:s(t.params),at:t.timestamp,source:"event",stale:true}}return{path:null,params:{},at:null,source:null}},navigate:t=>{const e=t;const a=e?.path;if(!a){throw new Error("navigate requires a 'path' param")}const r=d();if(!r){const u=o.getActions();const n=a.replace(/^\//,"");if(!u?.navigateToRoute||!n){throw new Error("expo-router is not available here and no navigation container was captured \u2014 is <RouteTracker /> mounted inside your NavigationContainer?")}const m=u.navigateToRoute(n,e?.params);if(!m){throw new Error(`Could not navigate to "${n}" \u2014 no navigation container is live.`)}return{navigated:a,replaced:false,via:"react-navigation"}}const c=e?.replace===true&&typeof r.replace==="function"?r.replace:r.navigate;c(a);return{navigated:a,replaced:e?.replace===true}},stackNavigateToIndex:t=>{const e=t?.index;if(typeof e!=="number"){throw new Error("stackNavigateToIndex requires a numeric 'index'")}const a=o.getActions();if(!a)throw new Error("navigation stack is not available");a.navigateToIndex(e);return{navigatedToIndex:e}},stackPopToIndex:t=>{const e=t?.index;if(typeof e!=="number"){throw new Error("stackPopToIndex requires a numeric 'index'")}const a=o.getActions();if(!a)throw new Error("navigation stack is not available");a.popToIndex(e);return{poppedToIndex:e}},stackGoBack:()=>{const t=o.getActions();if(!t)throw new Error("navigation stack is not available");t.goBack();return{wentBack:true}},stackPopToTop:()=>{const t=o.getActions();if(!t)throw new Error("navigation stack is not available");t.popToTop();return{poppedToTop:true}}}};
@@ -1,404 +1 @@
1
- "use strict";
2
-
3
- /**
4
- * useNavigationStack - Hook to access current navigation stack state
5
- *
6
- * Provides real-time access to the navigation stack,
7
- * showing what screens are currently mounted in memory and which is visible.
8
- *
9
- * Supports both Expo Router and React Navigation (RN CLI).
10
- *
11
- * - Expo Router: uses useNavigation/useNavigationState (inside navigator context)
12
- * - RN CLI: uses NavigationContainerRefContext (works as sibling of navigators)
13
- */
14
-
15
- import { useState, useEffect, useMemo, useCallback } from "react";
16
- import { getCommonActions, getStackActions, useSafeNavigationContainerRef } from "./utils/safeReactNavigation";
17
- import { getSafeRouter, isExpoRouterAvailable } from "@buoy-gg/shared-ui";
18
-
19
- // Resolved once at module load; null when React Navigation is absent
20
- // (useSafeNavigationContainerRef then yields undefined, so the dispatch
21
- // paths below never execute).
22
- const CommonActions = getCommonActions();
23
- const StackActions = getStackActions();
24
-
25
- // ============================================================================
26
- // Type Definitions
27
- // ============================================================================
28
-
29
- // ============================================================================
30
- // Nested Navigation Helpers
31
- // ============================================================================
32
-
33
- /**
34
- * Find the path from root navigation state to a screen by name.
35
- * Returns array of route names from root to the target, or null if not found.
36
- */
37
- function findScreenPath(state, screenName) {
38
- if (!state || !state.routes) return null;
39
- for (const route of state.routes) {
40
- if (route.name === screenName) {
41
- return [route.name];
42
- }
43
- if (route.state) {
44
- const nested = findScreenPath(route.state, screenName);
45
- if (nested) {
46
- return [route.name, ...nested];
47
- }
48
- }
49
- }
50
- return null;
51
- }
52
-
53
- /**
54
- * Build a nested CommonActions.navigate() from a path of screen names.
55
- * e.g. ["MainApp", "Settings", "SettingScreen"] becomes:
56
- * navigate("MainApp", { screen: "Settings", params: { screen: "SettingScreen" } })
57
- */
58
- function buildNestedNavigateAction(path) {
59
- if (path.length === 0) return null;
60
- if (path.length === 1) {
61
- return CommonActions.navigate({
62
- name: path[0]
63
- });
64
- }
65
- let params = {
66
- screen: path[path.length - 1]
67
- };
68
- for (let i = path.length - 2; i >= 1; i--) {
69
- params = {
70
- screen: path[i],
71
- params
72
- };
73
- }
74
- return CommonActions.navigate({
75
- name: path[0],
76
- params
77
- });
78
- }
79
-
80
- // ============================================================================
81
- // Main Hook
82
- // ============================================================================
83
-
84
- /**
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.
100
- */
101
- function collectRoutesFromState(state, depth = 0, parentPath = "", onFocusedChain = true) {
102
- if (!state || !Array.isArray(state.routes) || state.routes.length === 0) {
103
- return [];
104
- }
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";
114
- for (let i = 0; i < state.routes.length; i++) {
115
- if (!siblingsAreStack && i !== focusedIndex) continue;
116
- const route = state.routes[i];
117
- const isFocused = i === focusedIndex;
118
-
119
- // Build the path
120
- let pathname = route.name;
121
- if (route.name === "index") {
122
- pathname = "/";
123
- } else if (!pathname.startsWith("/")) {
124
- pathname = `/${pathname}`;
125
- }
126
-
127
- // Add parent path
128
- if (parentPath && parentPath !== "/") {
129
- pathname = `${parentPath}${pathname}`;
130
- }
131
-
132
- // Add params to pathname for dynamic routes
133
- if (route.params) {
134
- const paramEntries = Object.entries(route.params);
135
- if (paramEntries.length > 0) {
136
- pathname = pathname.replace(/\[([^\]]+)\]/g, (match, param) => {
137
- return route.params[param] || match;
138
- });
139
- }
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) : [];
146
- routes.push({
147
- key: route.key,
148
- name: route.name,
149
- path: pathname,
150
- params: route.params || {},
151
- state: route.state,
152
- isFocused: onChain && nestedRoutes.length === 0
153
- });
154
- routes.push(...nestedRoutes);
155
- }
156
- return routes;
157
- }
158
-
159
- /**
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.
165
- */
166
- export function buildStack(navigationState, isExpo) {
167
- if (!navigationState) return [];
168
- try {
169
- const routes = collectRoutesFromState(navigationState);
170
-
171
- // Filter out internal Expo Router routes (layouts, __root, etc.)
172
- // These only exist in Expo Router; on RN CLI all routes are user-defined
173
- const filteredRoutes = isExpo ? routes.filter(route => {
174
- if (route.name.startsWith("__")) return false;
175
- if (route.name.includes("_layout")) return false;
176
- if (route.name.startsWith("+not-found")) return false;
177
- return true;
178
- }) : routes;
179
-
180
- // If we filtered everything out, just keep the last route
181
- const finalRoutes = filteredRoutes.length > 0 ? filteredRoutes : routes.slice(-1);
182
-
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;
191
- return finalRoutes.map((route, index) => {
192
- let cleanPath = route.path || `/${route.name}`;
193
- // Clean up Expo Router internal /__root prefix
194
- if (isExpo) {
195
- cleanPath = cleanPath.replace(/^\/__root/, "");
196
- }
197
- // Ensure we always have at least a /
198
- if (!cleanPath || cleanPath === "") {
199
- cleanPath = "/";
200
- }
201
- return {
202
- key: route.key,
203
- name: route.name,
204
- pathname: cleanPath,
205
- params: route.params || {},
206
- isFocused: index === focusIndex,
207
- index,
208
- canPop: index > 0
209
- };
210
- });
211
- } catch (err) {
212
- console.error("Error transforming navigation state:", err);
213
- return [];
214
- }
215
- }
216
-
217
- /**
218
- * Access the current navigation stack.
219
- *
220
- * Works in both Expo Router and RN CLI apps. On RN CLI, uses the
221
- * NavigationContainer ref context so it works even as a sibling of navigators.
222
- *
223
- * @example
224
- * ```tsx
225
- * const { stack, focusedRoute, goBack, popToTop } = useNavigationStack();
226
- *
227
- * // Display stack
228
- * stack.map(item => (
229
- * <View key={item.key}>
230
- * <Text>{item.pathname}</Text>
231
- * {item.isFocused && <Text>VISIBLE</Text>}
232
- * </View>
233
- * ));
234
- * ```
235
- */
236
- export function useNavigationStack() {
237
- const isExpo = isExpoRouterAvailable();
238
- const containerRef = useSafeNavigationContainerRef();
239
- const [navigationState, setNavigationState] = useState(() => containerRef?.getRootState?.() ?? null);
240
- const [isLoaded, setIsLoaded] = useState(false);
241
- const [error, setError] = useState(null);
242
-
243
- // Subscribe to navigation state changes via the container ref
244
- useEffect(() => {
245
- if (!containerRef) return;
246
-
247
- // Get initial state
248
- const initialState = containerRef.getRootState?.();
249
- if (initialState) {
250
- setNavigationState(initialState);
251
- }
252
- setIsLoaded(true);
253
-
254
- // Listen for state changes
255
- const unsubscribe = containerRef.addListener?.("state", () => {
256
- const state = containerRef.getRootState?.();
257
- if (state) {
258
- setNavigationState(state);
259
- }
260
- });
261
- return () => {
262
- unsubscribe?.();
263
- };
264
- }, [containerRef]);
265
-
266
- // Transform navigation state into display items
267
- const stack = useMemo(() => buildStack(navigationState, isExpo), [navigationState, isExpo]);
268
-
269
- // Get focused route
270
- const focusedRoute = useMemo(() => {
271
- return stack.find(item => item.isFocused) || null;
272
- }, [stack]);
273
-
274
- // Helper properties
275
- const stackDepth = stack.length;
276
- const isAtRoot = stackDepth <= 1;
277
-
278
- // Manual refresh
279
- const refresh = useCallback(() => {
280
- if (!containerRef) return;
281
- const state = containerRef.getRootState?.();
282
- if (state) {
283
- setNavigationState(state);
284
- }
285
- }, [containerRef]);
286
-
287
- // Navigation actions - use Expo Router when available, fall back to React Navigation
288
- const navigateToIndex = useCallback(index => {
289
- if (index >= stack.length || index < 0) return;
290
- const targetRoute = stack[index];
291
- if (!targetRoute) return;
292
- try {
293
- const expoRouter = getSafeRouter();
294
- if (expoRouter) {
295
- expoRouter.navigate(targetRoute.pathname);
296
- } else if (containerRef) {
297
- // Walk the state tree to build nested screen/params for deep navigation
298
- const rootState = containerRef.getRootState?.();
299
- if (rootState) {
300
- const path = findScreenPath(rootState, targetRoute.name);
301
- if (path) {
302
- const action = buildNestedNavigateAction(path);
303
- if (action) {
304
- containerRef.dispatch(action);
305
- return;
306
- }
307
- }
308
- }
309
- // Fallback: try direct navigation
310
- containerRef.dispatch(CommonActions.navigate({
311
- name: targetRoute.name,
312
- params: targetRoute.params
313
- }));
314
- }
315
- } catch (err) {
316
- console.error("Failed to navigate:", err);
317
- }
318
- }, [stack, containerRef]);
319
- const navigateToRoute = useCallback((name, params) => {
320
- if (!containerRef) return false;
321
- try {
322
- // Deep path first, so screens inside nested navigators resolve even
323
- // when their navigator isn't mounted yet.
324
- const rootState = containerRef.getRootState?.();
325
- if (!params && rootState) {
326
- const path = findScreenPath(rootState, name);
327
- if (path) {
328
- const action = buildNestedNavigateAction(path);
329
- if (action) {
330
- containerRef.dispatch(action);
331
- return true;
332
- }
333
- }
334
- }
335
- containerRef.dispatch(CommonActions.navigate({
336
- name,
337
- params
338
- }));
339
- return true;
340
- } catch (err) {
341
- console.error("Failed to navigate:", err);
342
- return false;
343
- }
344
- }, [containerRef]);
345
- const popToIndex = useCallback(index => {
346
- if (index >= stack.length || index < 0) return;
347
- const currentIndex = stack.length - 1;
348
- const popCount = currentIndex - index;
349
- if (popCount <= 0) return;
350
- try {
351
- const expoRouter = getSafeRouter();
352
- if (expoRouter) {
353
- for (let i = 0; i < popCount; i++) {
354
- expoRouter.back();
355
- }
356
- } else if (containerRef) {
357
- containerRef.dispatch(StackActions.pop(popCount));
358
- }
359
- } catch (err) {
360
- console.error("Failed to pop:", err);
361
- }
362
- }, [stack, containerRef]);
363
- const goBack = useCallback(() => {
364
- if (isAtRoot) return;
365
- try {
366
- const expoRouter = getSafeRouter();
367
- if (expoRouter) {
368
- expoRouter.back();
369
- } else if (containerRef) {
370
- containerRef.goBack();
371
- }
372
- } catch (err) {
373
- console.error("Failed to go back:", err);
374
- }
375
- }, [isAtRoot, containerRef]);
376
- const popToTop = useCallback(() => {
377
- if (isAtRoot) return;
378
- try {
379
- const expoRouter = getSafeRouter();
380
- if (expoRouter) {
381
- // Pop to index 0 via expo-router
382
- popToIndex(0);
383
- } else if (containerRef) {
384
- containerRef.dispatch(StackActions.popToTop());
385
- }
386
- } catch (err) {
387
- console.error("Failed to pop to top:", err);
388
- }
389
- }, [isAtRoot, containerRef, popToIndex]);
390
- return {
391
- stack,
392
- focusedRoute,
393
- stackDepth,
394
- isAtRoot,
395
- isLoaded,
396
- error,
397
- refresh,
398
- navigateToIndex,
399
- navigateToRoute,
400
- popToIndex,
401
- goBack,
402
- popToTop
403
- };
404
- }
1
+ "use strict";import{useState as I,useEffect as L,useMemo as C,useCallback as S}from"react";import{getCommonActions as W,getStackActions as B,useSafeNavigationContainerRef as j}from"./utils/safeReactNavigation";import{getSafeRouter as b,isExpoRouterAvailable as w}from"@buoy-gg/shared-ui";const F=W();const E=B();function T(e,t){if(!e||!e.routes)return null;for(const o of e.routes){if(o.name===t){return[o.name]}if(o.state){const l=T(o.state,t);if(l){return[o.name,...l]}}}return null}function _(e){if(e.length===0)return null;if(e.length===1){return F.navigate({name:e[0]})}let t={screen:e[e.length-1]};for(let o=e.length-2;o>=1;o--){t={screen:e[o],params:t}}return F.navigate({name:e[0],params:t})}function $(e,t=0,o="",l=true){if(!e||!Array.isArray(e.routes)||e.routes.length===0){return[]}const g=[];const d=typeof e.index==="number"?e.index:NaN;const R=Number.isInteger(d)&&d>=0&&d<e.routes.length?d:e.routes.length-1;const a=typeof e.type==="string"?e.type:void 0;const r=a!=="tab"&&a!=="drawer";for(let i=0;i<e.routes.length;i++){if(!r&&i!==R)continue;const c=e.routes[i];const h=i===R;let u=c.name;if(c.name==="index"){u="/"}else if(!u.startsWith("/")){u=`/${u}`}if(o&&o!=="/"){u=`${o}${u}`}if(c.params){const y=Object.entries(c.params);if(y.length>0){u=u.replace(/\[([^\]]+)\]/g,(x,A)=>{return c.params[A]||x})}}const k=l&&h;const v=k&&c.state?$(c.state,t+1,u,true):[];g.push({key:c.key,name:c.name,path:u,params:c.params||{},state:c.state,isFocused:k&&v.length===0});g.push(...v)}return g}export function buildStack(e,t){if(!e)return[];try{const o=$(e);const l=t?o.filter(a=>{if(a.name.startsWith("__"))return false;if(a.name.includes("_layout"))return false;if(a.name.startsWith("+not-found"))return false;return true}):o;const g=l.length>0?l:o.slice(-1);const d=g.findIndex(a=>a.isFocused);const R=d>=0?d:g.length-1;return g.map((a,r)=>{let i=a.path||`/${a.name}`;if(t){i=i.replace(/^\/__root/,"")}if(!i||i===""){i="/"}return{key:a.key,name:a.name,pathname:i,params:a.params||{},isFocused:r===R,index:r,canPop:r>0}})}catch(o){console.error("Error transforming navigation state:",o);return[]}}export function useNavigationStack(){const e=w();const t=j();const[o,l]=I(()=>t?.getRootState?.()??null);const[g,d]=I(false);const[R,a]=I(null);L(()=>{if(!t)return;const n=t.getRootState?.();if(n){l(n)}d(true);const f=t.addListener?.("state",()=>{const s=t.getRootState?.();if(s){l(s)}});return()=>{f?.()}},[t]);const r=C(()=>buildStack(o,e),[o,e]);const i=C(()=>{return r.find(n=>n.isFocused)||null},[r]);const c=r.length;const h=c<=1;const u=S(()=>{if(!t)return;const n=t.getRootState?.();if(n){l(n)}},[t]);const k=S(n=>{if(n>=r.length||n<0)return;const f=r[n];if(!f)return;try{const s=b();if(s){s.navigate(f.pathname)}else if(t){const p=t.getRootState?.();if(p){const m=T(p,f.name);if(m){const N=_(m);if(N){t.dispatch(N);return}}}t.dispatch(F.navigate({name:f.name,params:f.params}))}}catch(s){console.error("Failed to navigate:",s)}},[r,t]);const v=S((n,f)=>{if(!t)return false;try{const s=t.getRootState?.();if(!f&&s){const p=T(s,n);if(p){const m=_(p);if(m){t.dispatch(m);return true}}}t.dispatch(F.navigate({name:n,params:f}));return true}catch(s){console.error("Failed to navigate:",s);return false}},[t]);const y=S(n=>{if(n>=r.length||n<0)return;const f=r.length-1;const s=f-n;if(s<=0)return;try{const p=b();if(p){for(let m=0;m<s;m++){p.back()}}else if(t){t.dispatch(E.pop(s))}}catch(p){console.error("Failed to pop:",p)}},[r,t]);const x=S(()=>{if(h)return;try{const n=b();if(n){n.back()}else if(t){t.goBack()}}catch(n){console.error("Failed to go back:",n)}},[h,t]);const A=S(()=>{if(h)return;try{const n=b();if(n){y(0)}else if(t){t.dispatch(E.popToTop())}}catch(n){console.error("Failed to pop to top:",n)}},[h,t,y]);return{stack:r,focusedRoute:i,stackDepth:c,isAtRoot:h,isLoaded:g,error:R,refresh:u,navigateToIndex:k,navigateToRoute:v,popToIndex:y,goBack:x,popToTop:A}}