@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
@@ -14,7 +14,7 @@
14
14
  import { useState, useCallback, useMemo, useEffect } from "react";
15
15
  import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from "react-native";
16
16
  import { useSafeRouter } from "@buoy-gg/shared-ui";
17
- import { Search, ChevronDown, ChevronRight, InlineCopyButton, ToolbarCopyButton, RefreshCw, formatRelativeTime, ProUpgradeModal, buoyColors } from "@buoy-gg/shared-ui";
17
+ import { Search, ChevronDown, ChevronRight, Home, InlineCopyButton, ToolbarCopyButton, RefreshCw, formatRelativeTime, ProUpgradeModal, buoyColors } from "@buoy-gg/shared-ui";
18
18
  import { useIsPro } from "@buoy-gg/license";
19
19
  import { useRouteSitemap } from "../useRouteSitemap";
20
20
 
@@ -22,12 +22,30 @@ import { useRouteSitemap } from "../useRouteSitemap";
22
22
  // Types
23
23
  // ============================================================================
24
24
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
+ // Synthetic route representing the app's home/index ("/"). Used by the
26
+ // "Home" toolbar shortcut so navigation works the same as tapping any route.
27
+ const HOME_ROUTE = {
28
+ path: "/",
29
+ name: "index",
30
+ type: "index",
31
+ params: [],
32
+ nodeType: "route",
33
+ contextKey: "/",
34
+ isInternal: false,
35
+ children: [],
36
+ depth: 0
37
+ };
38
+
25
39
  // ============================================================================
26
40
  // Main Component
27
41
  // ============================================================================
28
42
 
29
43
  export function RoutesSitemap({
30
- style
44
+ style,
45
+ injectedRoutes,
46
+ injectedSource,
47
+ injectedLastUpdatedAt,
48
+ onNavigateRoute
31
49
  }) {
32
50
  const [searchQuery, setSearchQuery] = useState("");
33
51
  const [isSearching, setIsSearching] = useState(false);
@@ -49,6 +67,7 @@ export function RoutesSitemap({
49
67
  groups,
50
68
  stats,
51
69
  isLoaded,
70
+ isSupported,
52
71
  filteredRoutes,
53
72
  routes,
54
73
  refresh,
@@ -56,7 +75,10 @@ export function RoutesSitemap({
56
75
  source
57
76
  } = useRouteSitemap({
58
77
  searchQuery,
59
- sortBy: "path"
78
+ sortBy: "path",
79
+ injectedRoutes,
80
+ injectedSource,
81
+ injectedLastUpdatedAt
60
82
  });
61
83
 
62
84
  // Prepare copy data - memoized so it only rebuilds when dependencies change
@@ -144,6 +166,13 @@ export function RoutesSitemap({
144
166
  }], "plain-text");
145
167
  }, [router]);
146
168
  const handleNavigate = useCallback(route => {
169
+ // When a navigation delegate is supplied (dashboard → device), hand off
170
+ // entirely: the delegate owns Pro gating and param resolution.
171
+ if (onNavigateRoute) {
172
+ onNavigateRoute(route);
173
+ return;
174
+ }
175
+
147
176
  // Gate behind Pro
148
177
  if (!isPro) {
149
178
  setShowUpgradeModal(true);
@@ -179,7 +208,10 @@ export function RoutesSitemap({
179
208
  } catch (error) {
180
209
  Alert.alert("Navigation Error", String(error));
181
210
  }
182
- }, [router, promptForParams, isPro]);
211
+ }, [router, promptForParams, isPro, onNavigateRoute]);
212
+ const handleGoHome = useCallback(() => {
213
+ handleNavigate(HOME_ROUTE);
214
+ }, [handleNavigate]);
183
215
  const handleManualRefresh = useCallback(() => {
184
216
  if (isRefreshing) return;
185
217
  setIsRefreshing(true);
@@ -214,7 +246,7 @@ export function RoutesSitemap({
214
246
  style: styles.loadingContainer,
215
247
  children: /*#__PURE__*/_jsx(Text, {
216
248
  style: styles.loadingText,
217
- children: "Loading routes..."
249
+ children: isSupported ? "Loading routes..." : "Route sitemap isn't available on the dashboard — the route tree lives on the device. Use the Events tab to see navigation here."
218
250
  })
219
251
  })
220
252
  });
@@ -226,6 +258,20 @@ export function RoutesSitemap({
226
258
  children: [/*#__PURE__*/_jsxs(View, {
227
259
  style: styles.actionsRow,
228
260
  children: [/*#__PURE__*/_jsxs(View, {
261
+ style: styles.actionWrapper,
262
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
263
+ style: styles.iconButton,
264
+ onPress: handleGoHome,
265
+ accessibilityLabel: "Go to home route",
266
+ children: /*#__PURE__*/_jsx(Home, {
267
+ size: 16,
268
+ color: buoyColors.textSecondary
269
+ })
270
+ }), /*#__PURE__*/_jsx(Text, {
271
+ style: styles.actionLabel,
272
+ children: "Home"
273
+ })]
274
+ }), /*#__PURE__*/_jsxs(View, {
229
275
  style: styles.actionWrapper,
230
276
  children: [/*#__PURE__*/_jsx(ToolbarCopyButton, {
231
277
  value: copyAllData,
@@ -429,82 +475,92 @@ function RouteItemView({
429
475
  const hasParams = route.params.length > 0;
430
476
  const typeColor = getRouteTypeColor(route.type);
431
477
  const canNavigate = route.type !== "layout" && route.type !== "group";
432
- return /*#__PURE__*/_jsxs(View, {
478
+
479
+ // Leaf routes (no children) have nothing to drill into — expanding would only
480
+ // reveal the Copy/Go actions, so show those inline instead of behind a toggle.
481
+ const isExpandable = hasChildren;
482
+ const showDetails = isExpandable ? isExpanded : true;
483
+ return /*#__PURE__*/_jsx(View, {
433
484
  style: [styles.routeItem, {
434
485
  marginLeft: depth * 12
435
486
  }],
436
- children: [/*#__PURE__*/_jsxs(View, {
487
+ children: /*#__PURE__*/_jsxs(View, {
437
488
  style: styles.routeCard,
438
- children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
439
- style: styles.routeHeaderLeft,
440
- onPress: () => setIsExpanded(!isExpanded),
441
- activeOpacity: 0.7,
442
- children: [/*#__PURE__*/_jsx(View, {
443
- style: styles.expandIndicator,
444
- children: isExpanded ? /*#__PURE__*/_jsx(ChevronDown, {
445
- size: 14,
446
- color: buoyColors.textSecondary
447
- }) : /*#__PURE__*/_jsx(ChevronRight, {
448
- size: 14,
449
- color: buoyColors.textSecondary
450
- })
451
- }), /*#__PURE__*/_jsx(Text, {
452
- style: styles.routePath,
453
- numberOfLines: 1,
454
- children: route.path
455
- })]
456
- }), /*#__PURE__*/_jsxs(View, {
457
- style: styles.routeHeaderActions,
458
- children: [hasChildren && /*#__PURE__*/_jsx(View, {
459
- style: styles.childCountBadge,
460
- children: /*#__PURE__*/_jsx(Text, {
461
- style: styles.childCountText,
462
- children: route.children.length
463
- })
464
- }), /*#__PURE__*/_jsx(View, {
465
- style: [styles.typeTag, {
466
- backgroundColor: `${typeColor}15`,
467
- borderColor: `${typeColor}40`
468
- }],
469
- children: /*#__PURE__*/_jsx(Text, {
470
- style: [styles.typeText, {
471
- color: typeColor
472
- }],
473
- children: route.type.toUpperCase()
474
- })
475
- })]
476
- })]
477
- }), isExpanded && /*#__PURE__*/_jsxs(View, {
478
- style: styles.routeDetails,
479
489
  children: [/*#__PURE__*/_jsxs(View, {
480
- style: styles.routeButtons,
481
- children: [/*#__PURE__*/_jsx(InlineCopyButton, {
482
- value: route.path,
483
- buttonStyle: styles.actionButton
484
- }), canNavigate && /*#__PURE__*/_jsx(TouchableOpacity, {
485
- style: [styles.actionButton, styles.navigateButton],
486
- onPress: () => onNavigate(route),
487
- children: /*#__PURE__*/_jsx(Text, {
488
- style: styles.navigateButtonText,
489
- children: "Go"
490
- })
491
- })]
492
- }), hasParams && /*#__PURE__*/_jsxs(View, {
493
- style: styles.paramsContainer,
494
- children: [/*#__PURE__*/_jsx(Text, {
495
- style: styles.paramsLabel,
496
- children: "Parameters:"
497
- }), /*#__PURE__*/_jsx(View, {
498
- style: styles.paramsRow,
499
- children: route.params.map(param => /*#__PURE__*/_jsx(View, {
500
- style: styles.paramTag,
490
+ style: styles.routeHeader,
491
+ children: [isExpandable ? /*#__PURE__*/_jsxs(TouchableOpacity, {
492
+ style: styles.routeHeaderLeft,
493
+ onPress: () => setIsExpanded(!isExpanded),
494
+ activeOpacity: 0.7,
495
+ children: [/*#__PURE__*/_jsx(View, {
496
+ style: styles.expandIndicator,
497
+ children: isExpanded ? /*#__PURE__*/_jsx(ChevronDown, {
498
+ size: 14,
499
+ color: buoyColors.textSecondary
500
+ }) : /*#__PURE__*/_jsx(ChevronRight, {
501
+ size: 14,
502
+ color: buoyColors.textSecondary
503
+ })
504
+ }), /*#__PURE__*/_jsx(Text, {
505
+ style: styles.routePath,
506
+ numberOfLines: 3,
507
+ children: route.path
508
+ })]
509
+ }) :
510
+ /*#__PURE__*/
511
+ // Non-toggling header for leaf routes (keeps the chevron column as a
512
+ // spacer so paths stay aligned with expandable siblings).
513
+ _jsxs(View, {
514
+ style: styles.routeHeaderLeft,
515
+ children: [/*#__PURE__*/_jsx(View, {
516
+ style: styles.expandIndicator
517
+ }), /*#__PURE__*/_jsx(Text, {
518
+ style: styles.routePath,
519
+ numberOfLines: 3,
520
+ children: route.path
521
+ })]
522
+ }), /*#__PURE__*/_jsxs(View, {
523
+ style: styles.routeHeaderActions,
524
+ children: [hasChildren && /*#__PURE__*/_jsx(View, {
525
+ style: styles.childCountBadge,
501
526
  children: /*#__PURE__*/_jsx(Text, {
502
- style: styles.paramText,
503
- children: param
527
+ style: styles.childCountText,
528
+ children: route.children.length
504
529
  })
505
- }, param))
530
+ }), /*#__PURE__*/_jsx(View, {
531
+ style: [styles.typeTag, {
532
+ backgroundColor: `${typeColor}15`,
533
+ borderColor: `${typeColor}40`
534
+ }],
535
+ children: /*#__PURE__*/_jsx(Text, {
536
+ style: [styles.typeText, {
537
+ color: typeColor
538
+ }],
539
+ children: route.type.toUpperCase()
540
+ })
541
+ }), /*#__PURE__*/_jsx(InlineCopyButton, {
542
+ value: route.path,
543
+ buttonStyle: styles.iconAction
544
+ }), canNavigate && /*#__PURE__*/_jsx(TouchableOpacity, {
545
+ style: styles.goButton,
546
+ onPress: () => onNavigate(route),
547
+ activeOpacity: 0.7,
548
+ children: /*#__PURE__*/_jsx(Text, {
549
+ style: styles.goButtonText,
550
+ children: "Go"
551
+ })
552
+ })]
506
553
  })]
507
- }), hasChildren && /*#__PURE__*/_jsx(View, {
554
+ }), showDetails && hasParams && /*#__PURE__*/_jsx(View, {
555
+ style: styles.paramsRow,
556
+ children: route.params.map(param => /*#__PURE__*/_jsx(View, {
557
+ style: styles.paramTag,
558
+ children: /*#__PURE__*/_jsx(Text, {
559
+ style: styles.paramText,
560
+ children: param
561
+ })
562
+ }, param))
563
+ }), showDetails && hasChildren && /*#__PURE__*/_jsx(View, {
508
564
  style: styles.childrenContainer,
509
565
  children: route.children.map((child, index) => /*#__PURE__*/_jsx(RouteItemView, {
510
566
  route: child,
@@ -512,7 +568,7 @@ function RouteItemView({
512
568
  onNavigate: onNavigate
513
569
  }, `${child.path}-${index}`))
514
570
  })]
515
- })]
571
+ })
516
572
  });
517
573
  }
518
574
 
@@ -566,7 +622,11 @@ const styles = StyleSheet.create({
566
622
  loadingText: {
567
623
  color: buoyColors.textSecondary,
568
624
  fontSize: 14,
569
- fontFamily: "monospace"
625
+ fontFamily: "monospace",
626
+ textAlign: "center",
627
+ lineHeight: 20,
628
+ paddingHorizontal: 32,
629
+ maxWidth: 420
570
630
  },
571
631
  header: {
572
632
  flexDirection: "column",
@@ -578,7 +638,7 @@ const styles = StyleSheet.create({
578
638
  actionsRow: {
579
639
  flexDirection: "row",
580
640
  alignItems: "center",
581
- justifyContent: "flex-end",
641
+ justifyContent: "space-between",
582
642
  gap: 12,
583
643
  paddingBottom: 4
584
644
  },
@@ -613,6 +673,7 @@ const styles = StyleSheet.create({
613
673
  letterSpacing: 0.5
614
674
  },
615
675
  actionWrapper: {
676
+ flex: 1,
616
677
  alignItems: "center",
617
678
  justifyContent: "center",
618
679
  gap: 4,
@@ -780,15 +841,11 @@ const styles = StyleSheet.create({
780
841
  marginBottom: 6
781
842
  },
782
843
  routeCard: {
783
- flexDirection: "row",
784
- alignItems: "center",
785
- justifyContent: "space-between",
786
- paddingVertical: 10,
787
- paddingHorizontal: 12,
788
844
  backgroundColor: buoyColors.card,
789
845
  borderRadius: 6,
790
846
  borderWidth: 1,
791
847
  borderColor: buoyColors.border,
848
+ overflow: "hidden",
792
849
  shadowColor: "#000",
793
850
  shadowOffset: {
794
851
  width: 0,
@@ -798,8 +855,16 @@ const styles = StyleSheet.create({
798
855
  shadowRadius: 2,
799
856
  elevation: 1
800
857
  },
858
+ routeHeader: {
859
+ flexDirection: "row",
860
+ alignItems: "center",
861
+ justifyContent: "space-between",
862
+ paddingVertical: 7,
863
+ paddingHorizontal: 10,
864
+ gap: 8
865
+ },
801
866
  expandIndicator: {
802
- width: 20,
867
+ width: 18,
803
868
  alignItems: "center"
804
869
  },
805
870
  routeHeaderLeft: {
@@ -848,34 +913,41 @@ const styles = StyleSheet.create({
848
913
  fontWeight: "600",
849
914
  fontFamily: "monospace"
850
915
  },
851
- routeDetails: {
852
- paddingHorizontal: 12,
853
- paddingTop: 8,
854
- paddingBottom: 12,
855
- gap: 12,
856
- borderTopWidth: 1,
857
- borderTopColor: buoyColors.border,
858
- backgroundColor: buoyColors.card,
859
- borderRadius: 6,
916
+ // Compact icon-sized Copy button in the header action cluster.
917
+ iconAction: {
918
+ width: 28,
919
+ height: 24,
920
+ borderRadius: 4,
921
+ backgroundColor: buoyColors.input,
860
922
  borderWidth: 1,
861
923
  borderColor: buoyColors.border,
862
- borderTopLeftRadius: 0,
863
- borderTopRightRadius: 0,
864
- marginTop: -6
924
+ alignItems: "center",
925
+ justifyContent: "center"
865
926
  },
866
- paramsContainer: {
867
- gap: 6
927
+ // Small "Go" pill in the header action cluster.
928
+ goButton: {
929
+ height: 24,
930
+ paddingHorizontal: 10,
931
+ borderRadius: 4,
932
+ alignItems: "center",
933
+ justifyContent: "center",
934
+ backgroundColor: buoyColors.primary + "15",
935
+ borderWidth: 1,
936
+ borderColor: buoyColors.primary + "40"
868
937
  },
869
- paramsLabel: {
870
- fontSize: 10,
871
- color: buoyColors.textSecondary,
938
+ goButtonText: {
939
+ fontSize: 12,
940
+ color: buoyColors.primary,
872
941
  fontFamily: "monospace",
873
- marginBottom: 4
942
+ fontWeight: "600"
874
943
  },
875
944
  paramsRow: {
876
945
  flexDirection: "row",
877
946
  flexWrap: "wrap",
878
- gap: 6
947
+ gap: 6,
948
+ paddingHorizontal: 10,
949
+ paddingTop: 2,
950
+ paddingBottom: 8
879
951
  },
880
952
  paramTag: {
881
953
  backgroundColor: "#F59E0B15",
@@ -891,41 +963,12 @@ const styles = StyleSheet.create({
891
963
  fontFamily: "monospace",
892
964
  fontWeight: "600"
893
965
  },
894
- routeButtons: {
895
- flexDirection: "row",
896
- gap: 6,
897
- marginBottom: 12
898
- },
899
- actionButton: {
900
- flexDirection: "row",
901
- alignItems: "center",
902
- gap: 4,
903
- backgroundColor: buoyColors.input,
904
- borderRadius: 4,
905
- paddingHorizontal: 12,
906
- paddingVertical: 6,
907
- borderWidth: 1,
908
- borderColor: buoyColors.border
909
- },
910
- actionButtonText: {
911
- fontSize: 12,
912
- color: buoyColors.textSecondary,
913
- fontFamily: "monospace",
914
- fontWeight: "600"
915
- },
916
- navigateButton: {
917
- backgroundColor: buoyColors.primary + "15",
918
- borderColor: buoyColors.primary + "40"
919
- },
920
- navigateButtonText: {
921
- fontSize: 12,
922
- color: buoyColors.primary,
923
- fontFamily: "monospace",
924
- fontWeight: "600"
925
- },
926
966
  childrenContainer: {
927
967
  borderLeftWidth: 2,
928
968
  borderLeftColor: buoyColors.border,
929
- marginLeft: 16
969
+ marginLeft: 16,
970
+ marginRight: 8,
971
+ marginTop: 2,
972
+ marginBottom: 8
930
973
  }
931
974
  });
@@ -24,10 +24,26 @@ function logOnce(message, error) {
24
24
  * The storeRef gets populated when Expo Router's useStore() hook runs.
25
25
  * So we cache the store reference but its property values update over time.
26
26
  */
27
+ /**
28
+ * Whether the expo-router store can be loaded in this runtime. The store is
29
+ * pulled in via CommonJS `require`, which only exists under Metro/Node. On the
30
+ * web (e.g. the desktop dashboard rendering these RN tools via react-native-web)
31
+ * `require` is undefined, the route tree lives on the device — not here — and
32
+ * there is nothing to load. Callers use this to skip polling/logging.
33
+ */
34
+ export function isExpoRouterStoreSupported() {
35
+ return typeof require !== "undefined";
36
+ }
27
37
  export function getExpoRouterStore() {
28
38
  if (cachedStore) {
29
39
  return cachedStore;
30
40
  }
41
+
42
+ // No `require` (web): bail quietly rather than throwing ReferenceError and
43
+ // logging a misleading "install expo-router" message on the dashboard.
44
+ if (!isExpoRouterStoreSupported()) {
45
+ return null;
46
+ }
31
47
  const loadFromBuild = () => {
32
48
  try {
33
49
  const module = require("expo-router/build/global-state/router-store");
@@ -38,6 +38,10 @@ export { useRouteObserverReactNavigation } from "./useRouteObserverReactNavigati
38
38
  export { useRouteEvents } from "./hooks/useRouteEvents";
39
39
  export { useRouteSitemap, useRoute, useParentRoutes } from "./useRouteSitemap";
40
40
  export { useNavigationStack } from "./useNavigationStack";
41
+ // =============================================================================
42
+ // EXTERNAL SYNC (Adapter for @buoy-gg/external-sync's useExternalSync)
43
+ // =============================================================================
44
+ export { routeEventsSyncAdapter } from "./sync/routeEventsSyncAdapter";
41
45
 
42
46
  // =============================================================================
43
47
  // TYPES
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Navigation Stack Store
5
+ *
6
+ * Holds the latest serializable navigation stack plus references to the live
7
+ * navigation action functions. The stack is captured inside the navigation tree
8
+ * by <RouteTracker /> (which runs useNavigationStack), and read by the
9
+ * route-events sync adapter so the dashboard can render the Stack tab and drive
10
+ * navigation on the device remotely.
11
+ *
12
+ * The stack itself (StackDisplayItem[]) is JSON-serializable and synced to the
13
+ * dashboard. The action functions are NOT serialized — they stay on the device
14
+ * and are invoked when the dashboard sends a remote action.
15
+ */
16
+
17
+ class NavigationStackStore {
18
+ stack = [];
19
+ actions = null;
20
+ listeners = new Set();
21
+ getStack() {
22
+ return this.stack;
23
+ }
24
+ setStack(stack) {
25
+ this.stack = stack;
26
+ this.listeners.forEach(listener => listener());
27
+ }
28
+ getActions() {
29
+ return this.actions;
30
+ }
31
+ setActions(actions) {
32
+ this.actions = actions;
33
+ }
34
+ subscribe(listener) {
35
+ this.listeners.add(listener);
36
+ return () => {
37
+ this.listeners.delete(listener);
38
+ };
39
+ }
40
+ }
41
+ export const navigationStackStore = new NavigationStackStore();
@@ -0,0 +1,145 @@
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
+
9
+ /**
10
+ * Serializable snapshot of the device's route tree, sent to the dashboard so
11
+ * the Routes tab can render a sitemap (the raw expo-router RouteNode itself is
12
+ * not JSON-serializable — it holds components/functions — so we send the parsed
13
+ * RouteInfo[] instead).
14
+ */
15
+
16
+ /**
17
+ * Payload shape for the route-events tool (adapter version 3). Consumers must
18
+ * tolerate older shapes: v1 sends a bare RouteChangeEvent[]; v2 omits `stack`.
19
+ */
20
+
21
+ function getSitemapSnapshot() {
22
+ const metadata = getRouteNodeMetadata();
23
+ return {
24
+ routes: RouteParser.parseRouteTree(loadRouteNode()),
25
+ source: metadata.source,
26
+ lastUpdatedAt: metadata.lastLoadedAt
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Sync adapter for the route-events tool, consumed by @buoy-gg/external-sync's
32
+ * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
33
+ * this package doesn't need a dependency on it).
34
+ *
35
+ * Subscribing attaches the routeObserver listener, so route changes are only
36
+ * recorded while a dashboard is watching. (Route events still require a
37
+ * <RouteTracker /> inside the navigation tree to be emitted at all.)
38
+ *
39
+ * The snapshot carries both the route-change events AND the parsed route
40
+ * sitemap, so the dashboard — which has no local expo-router store — can render
41
+ * the route tree. The `navigate` action lets the dashboard drive navigation on
42
+ * the device.
43
+ */
44
+ export const routeEventsSyncAdapter = {
45
+ version: 3,
46
+ getSnapshot: () => ({
47
+ events: routeEventStore.getEvents(),
48
+ sitemap: getSitemapSnapshot(),
49
+ stack: navigationStackStore.getStack()
50
+ }),
51
+ subscribe: onChange => {
52
+ const unsubscribeEvents = routeEventStore.subscribeToEvents(onChange);
53
+ const unsubscribeStack = navigationStackStore.subscribe(onChange);
54
+
55
+ // The expo-router route tree can still be populating when a dashboard
56
+ // starts watching. Poll briefly until it's available and push a snapshot
57
+ // the moment it loads, so the Routes tab fills in without needing the user
58
+ // to navigate first. Stops as soon as the tree is found.
59
+ let pollTimer = null;
60
+ if (!loadRouteNode()) {
61
+ let retries = 0;
62
+ const maxRetries = 100; // ~10s
63
+ const poll = () => {
64
+ retries += 1;
65
+ if (loadRouteNode()) {
66
+ pollTimer = null;
67
+ onChange();
68
+ return;
69
+ }
70
+ pollTimer = retries < maxRetries ? setTimeout(poll, 100) : null;
71
+ };
72
+ pollTimer = setTimeout(poll, 100);
73
+ }
74
+ return () => {
75
+ unsubscribeEvents();
76
+ unsubscribeStack();
77
+ if (pollTimer) clearTimeout(pollTimer);
78
+ };
79
+ },
80
+ actions: {
81
+ clearEvents: () => {
82
+ routeEventStore.clearEvents();
83
+ },
84
+ /**
85
+ * Navigate the device to a concrete path. The dashboard resolves any
86
+ * dynamic params into a concrete path before invoking this.
87
+ */
88
+ navigate: params => {
89
+ const path = params?.path;
90
+ if (!path) {
91
+ throw new Error("navigate requires a 'path' param");
92
+ }
93
+ const router = getSafeRouter();
94
+ if (!router) {
95
+ throw new Error("expo-router is not available on this device");
96
+ }
97
+ router.navigate(path);
98
+ return {
99
+ navigated: path
100
+ };
101
+ },
102
+ // ── Stack actions: delegate to the live navigation actions captured by
103
+ // <RouteTracker /> (they hold the React Navigation container ref). ──
104
+ stackNavigateToIndex: params => {
105
+ const index = params?.index;
106
+ if (typeof index !== "number") {
107
+ throw new Error("stackNavigateToIndex requires a numeric 'index'");
108
+ }
109
+ const actions = navigationStackStore.getActions();
110
+ if (!actions) throw new Error("navigation stack is not available");
111
+ actions.navigateToIndex(index);
112
+ return {
113
+ navigatedToIndex: index
114
+ };
115
+ },
116
+ stackPopToIndex: params => {
117
+ const index = params?.index;
118
+ if (typeof index !== "number") {
119
+ throw new Error("stackPopToIndex requires a numeric 'index'");
120
+ }
121
+ const actions = navigationStackStore.getActions();
122
+ if (!actions) throw new Error("navigation stack is not available");
123
+ actions.popToIndex(index);
124
+ return {
125
+ poppedToIndex: index
126
+ };
127
+ },
128
+ stackGoBack: () => {
129
+ const actions = navigationStackStore.getActions();
130
+ if (!actions) throw new Error("navigation stack is not available");
131
+ actions.goBack();
132
+ return {
133
+ wentBack: true
134
+ };
135
+ },
136
+ stackPopToTop: () => {
137
+ const actions = navigationStackStore.getActions();
138
+ if (!actions) throw new Error("navigation stack is not available");
139
+ actions.popToTop();
140
+ return {
141
+ poppedToTop: true
142
+ };
143
+ }
144
+ }
145
+ };