diff --git a/.changeset/react-navigation-stack-trace-inspection.md b/.changeset/react-navigation-stack-trace-inspection.md
new file mode 100644
index 00000000..0a5ebdc1
--- /dev/null
+++ b/.changeset/react-navigation-stack-trace-inspection.md
@@ -0,0 +1,7 @@
+---
+'@rozenite/react-navigation-plugin': minor
+---
+
+Add dispatch-origin inspection for navigation actions.
+
+Captured actions now expose where they were dispatched from: a source-mapped origin frame (resolved via Metro on the React Native side), the full parsed stack with library frames distinguished from app frames, an optional code-frame snippet, and a confidence level. The detail panel renders a new "Dispatch Origin" section; the sidebar shows a compact `filename.tsx:line` preview. The `list-actions` agent tool returns the same `origin` payload, replacing the previous raw `stack` string field on `NavigationActionHistoryEntry`.
\ No newline at end of file
diff --git a/packages/react-navigation-plugin/package.json b/packages/react-navigation-plugin/package.json
index 485f32f9..655a17ba 100644
--- a/packages/react-navigation-plugin/package.json
+++ b/packages/react-navigation-plugin/package.json
@@ -18,7 +18,8 @@
"build": "rozenite build",
"dev": "rozenite dev",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "test": "vitest --run --passWithNoTests"
},
"dependencies": {
"@rozenite/agent-shared": "workspace:*",
@@ -29,6 +30,9 @@
"devDependencies": {
"@react-navigation/core": "^7.12.1",
"@rozenite/vite-plugin": "workspace:*",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.21",
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/ActionDetailPanel.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/ActionDetailPanel.tsx
index 2a468689..d85c39c1 100644
--- a/packages/react-navigation-plugin/src/devtools-ui/components/ActionDetailPanel.tsx
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/ActionDetailPanel.tsx
@@ -1,9 +1,12 @@
import { JSONTree } from 'react-json-tree';
+import type { ActionOrigin } from '../../react-native/symbolication/types';
import { NavigationAction, NavigationState } from '../../shared';
+import { DispatchOriginSection } from './DispatchOriginSection';
export type ActionDetailPanelProps = {
action: NavigationAction;
state: NavigationState | undefined;
+ origin: ActionOrigin | undefined;
};
const jsonTreeTheme = {
@@ -28,10 +31,13 @@ const jsonTreeTheme = {
export const ActionDetailPanel = ({
action,
state,
+ origin,
}: ActionDetailPanelProps) => {
return (
+
+
Action Payload
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/ActionItem.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/ActionItem.tsx
index f8ef986f..a0b274e1 100644
--- a/packages/react-navigation-plugin/src/devtools-ui/components/ActionItem.tsx
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/ActionItem.tsx
@@ -1,7 +1,10 @@
+import { formatFrameLocation } from '../../react-native/symbolication/format';
+import type { ActionOrigin } from '../../react-native/symbolication/types';
import { NavigationAction } from '../../shared';
export type ActionItemProps = {
action: NavigationAction;
+ origin: ActionOrigin | undefined;
index: number;
isSelected: boolean;
onSelect: () => void;
@@ -23,8 +26,39 @@ const getActionTypeColor = (type: string): string => {
return colors[type] || 'text-gray-400';
};
+// Show only the file basename in the sidebar — full path lives in the
+// detail panel. Keeps each row to a single line in narrow widths.
+const shortenForSidebar = (location: string): string => {
+ const lastSlash = location.lastIndexOf('/');
+ return lastSlash === -1 ? location : location.slice(lastSlash + 1);
+};
+
+const OriginPreview = ({ origin }: { origin: ActionOrigin | undefined }) => {
+ if (!origin) return null;
+ if (origin.symbolicationStatus === 'pending') {
+ return (
+
↳ Resolving…
+ );
+ }
+ if (origin.symbolicationStatus !== 'complete') return null;
+ if (origin.confidence === 'none') return null;
+ const location = formatFrameLocation(origin.originFrame);
+ if (!location) return null;
+ return (
+
+ ↳ {shortenForSidebar(location)}
+
+ );
+};
+
export const ActionItem = ({
action,
+ origin,
index,
isSelected,
onSelect,
@@ -39,7 +73,7 @@ export const ActionItem = ({
return (
→ {actionName}
)}
+
+
);
};
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/ActionList.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/ActionList.tsx
index a02fb2f5..213905f3 100644
--- a/packages/react-navigation-plugin/src/devtools-ui/components/ActionList.tsx
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/ActionList.tsx
@@ -1,9 +1,12 @@
+import type { ActionOrigin } from '../../react-native/symbolication/types';
import { NavigationAction, NavigationState } from '../../shared';
import { ActionItem } from './ActionItem';
export type ActionWithState = {
+ id?: number;
action: NavigationAction;
state: NavigationState | undefined;
+ origin?: ActionOrigin;
};
export type ActionListProps = {
@@ -20,7 +23,7 @@ export const ActionList = ({
onGoToAction,
}: ActionListProps) => {
return (
-
+
{actionHistory.length === 0 ? (
No actions recorded yet
@@ -31,6 +34,7 @@ export const ActionList = ({
onActionSelect(index)}
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/ActionTimeline.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/ActionTimeline.tsx
index b20bb64c..5eed685f 100644
--- a/packages/react-navigation-plugin/src/devtools-ui/components/ActionTimeline.tsx
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/ActionTimeline.tsx
@@ -32,8 +32,10 @@ export const ActionTimeline = ({
{selectedEntry ? (
) : (
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/DispatchOriginSection.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/DispatchOriginSection.tsx
new file mode 100644
index 00000000..f77755dc
--- /dev/null
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/DispatchOriginSection.tsx
@@ -0,0 +1,213 @@
+import { useState } from 'react';
+import { formatFrameLocation } from '../../react-native/symbolication/format';
+import { classifyFrame } from '../../react-native/symbolication/rank';
+import type {
+ ActionOrigin,
+ ActionStackFrame,
+} from '../../react-native/symbolication/types';
+
+export type DispatchOriginSectionProps = {
+ origin: ActionOrigin | undefined;
+};
+
+const Spinner = () => (
+
+);
+
+const Headline = ({ origin }: { origin: ActionOrigin }) => {
+ if (origin.symbolicationStatus === 'pending') {
+ return (
+
+
+ Resolving origin from Metro…
+
+ );
+ }
+ if (origin.symbolicationStatus === 'unavailable') {
+ return (
+
+ Stack trace symbolication is unavailable (production build or Metro
+ disconnected).
+
+ );
+ }
+ if (origin.symbolicationStatus === 'failed') {
+ return (
+
+
Could not source-map the stack via Metro.
+ {origin.symbolicationError && (
+
+ {origin.symbolicationError}
+
+ )}
+
+ );
+ }
+ if (origin.confidence === 'none') {
+ return (
+
Could not resolve dispatch origin.
+ );
+ }
+ const location = formatFrameLocation(origin.originFrame);
+ const fn = origin.originFrame?.functionName ?? '
';
+ return (
+
+
+ Dispatched from {fn} in{' '}
+
+ {location ?? 'unknown location'}
+
+
+ {origin.confidence === 'low' && (
+
+ low confidence
+
+ )}
+
+ );
+};
+
+const CodeFrame = ({ origin }: { origin: ActionOrigin }) => {
+ if (!origin.codeFrame) return null;
+ return (
+
+ {origin.codeFrame.content}
+
+ );
+};
+
+const StackFrame = ({
+ frame,
+ isOrigin,
+}: {
+ frame: ActionStackFrame;
+ isOrigin: boolean;
+}) => {
+ const cls = classifyFrame(frame.url);
+ const location = formatFrameLocation(frame);
+ const fn = frame.functionName ?? '';
+ return (
+
+
+ {location ?? frame.generatedUrl ?? '(no location)'}
+
+ —
+ {fn}
+ {cls === 'library' && (
+
+ library
+
+ )}
+
+ );
+};
+
+export const DispatchOriginSection = ({
+ origin,
+}: DispatchOriginSectionProps) => {
+ const initiallyExpanded =
+ origin?.symbolicationStatus === 'complete' && origin?.confidence === 'none';
+ const [isStackExpanded, setIsStackExpanded] = useState(initiallyExpanded);
+ const [copied, setCopied] = useState(false);
+
+ if (!origin) {
+ return (
+
+
+ Dispatch Origin
+
+
+ No stack trace captured for this action.
+
+
+ );
+ }
+
+ const copyRaw = async () => {
+ try {
+ await navigator.clipboard.writeText(origin.rawStack);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Clipboard write can be denied in some iframe contexts; ignore.
+ }
+ };
+
+ return (
+
+
+ Dispatch Origin
+
+
+
+
+
+ {origin.symbolicationStatus === 'complete' && (
+
+ )}
+
+ {origin.frames.length > 0 && (
+
+
+ {isStackExpanded && (
+
+ {origin.frames.map((frame, idx) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+};
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/ActionItem.test.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/ActionItem.test.tsx
new file mode 100644
index 00000000..c4d111af
--- /dev/null
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/ActionItem.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment jsdom
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { ActionItem } from '../ActionItem';
+import type {
+ ActionOrigin,
+ ActionStackFrame,
+} from '../../../react-native/symbolication/types';
+import type { NavigationAction } from '../../../shared';
+
+const appFrame: ActionStackFrame = {
+ functionName: 'handlePress',
+ url: 'apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ columnNumber: 5,
+ generatedUrl: 'http://localhost:8081/index.bundle?platform=ios',
+ generatedLineNumber: 12345,
+ generatedColumnNumber: 10,
+};
+
+const baseAction = {
+ type: 'NAVIGATE',
+ payload: { name: 'Home' },
+} as unknown as NavigationAction;
+
+const renderItem = (origin: ActionOrigin | undefined) =>
+ render(
+ ,
+ );
+
+const buildOrigin = (overrides: Partial): ActionOrigin => ({
+ rawStack: 'raw',
+ frames: [appFrame],
+ originFrame: appFrame,
+ confidence: 'high',
+ symbolicationStatus: 'complete',
+ ...overrides,
+});
+
+describe('ActionItem origin preview', () => {
+ it('shows the file basename + line preview for high-confidence complete origins', () => {
+ renderItem(buildOrigin({}));
+ expect(screen.getByText('↳ Screen.tsx:42:5')).toBeInTheDocument();
+ });
+
+ it('exposes the full path as a hover tooltip', () => {
+ renderItem(buildOrigin({}));
+ const preview = screen.getByText('↳ Screen.tsx:42:5');
+ expect(preview).toHaveAttribute(
+ 'title',
+ 'apps/playground/src/Screen.tsx:42:5',
+ );
+ });
+
+ it('renders the preview italicised for low-confidence origins', () => {
+ renderItem(buildOrigin({ confidence: 'low' }));
+ const preview = screen.getByText('↳ Screen.tsx:42:5');
+ expect(preview.className).toMatch(/italic/);
+ });
+
+ it('shows a "Resolving…" indicator while the origin is pending', () => {
+ renderItem(
+ buildOrigin({
+ symbolicationStatus: 'pending',
+ confidence: 'none',
+ originFrame: undefined,
+ }),
+ );
+ expect(screen.getByText('↳ Resolving…')).toBeInTheDocument();
+ });
+
+ it('renders no preview for failed / unavailable / none-confidence states', () => {
+ const { rerender } = renderItem(
+ buildOrigin({ symbolicationStatus: 'failed', originFrame: undefined }),
+ );
+ expect(screen.queryByText(/↳/)).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByText(/↳/)).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByText(/↳/)).not.toBeInTheDocument();
+ });
+
+ it('renders no preview when origin is undefined (no stack captured)', () => {
+ renderItem(undefined);
+ expect(screen.queryByText(/↳/)).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/DispatchOriginSection.test.tsx b/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/DispatchOriginSection.test.tsx
new file mode 100644
index 00000000..57adfeca
--- /dev/null
+++ b/packages/react-navigation-plugin/src/devtools-ui/components/__tests__/DispatchOriginSection.test.tsx
@@ -0,0 +1,199 @@
+// @vitest-environment jsdom
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { DispatchOriginSection } from '../DispatchOriginSection';
+import type {
+ ActionOrigin,
+ ActionStackFrame,
+} from '../../../react-native/symbolication/types';
+
+const appFrame: ActionStackFrame = {
+ functionName: 'handlePress',
+ url: 'apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ columnNumber: 5,
+ generatedUrl: 'http://localhost:8081/index.bundle?platform=ios',
+ generatedLineNumber: 12345,
+ generatedColumnNumber: 10,
+};
+
+const libraryFrame: ActionStackFrame = {
+ functionName: 'dispatch',
+ url: 'node_modules/@react-navigation/core/lib/dispatch.js',
+ lineNumber: 100,
+ columnNumber: 1,
+ generatedUrl: 'http://localhost:8081/index.bundle?platform=ios',
+ generatedLineNumber: 12340,
+ generatedColumnNumber: 1,
+};
+
+const buildOrigin = (overrides: Partial = {}): ActionOrigin => ({
+ rawStack: 'raw\nstack\nstring',
+ frames: [appFrame, libraryFrame],
+ originFrame: appFrame,
+ confidence: 'high',
+ symbolicationStatus: 'complete',
+ ...overrides,
+});
+
+describe('DispatchOriginSection', () => {
+ it('renders the empty state when no origin is captured', () => {
+ render();
+ expect(
+ screen.getByText('No stack trace captured for this action.'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the resolving headline while pending', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText('Resolving origin from Metro…'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the failure copy and error message', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText('Could not source-map the stack via Metro.'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Metro symbolication timed out after 5000ms'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the unavailable copy in production / disconnected mode', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(/Stack trace symbolication is unavailable/),
+ ).toBeInTheDocument();
+ });
+
+ it('renders a high-confidence headline without a confidence chip', () => {
+ render();
+ expect(screen.getByText('handlePress')).toBeInTheDocument();
+ expect(
+ screen.getByText('apps/playground/src/Screen.tsx:42:5'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('low confidence')).not.toBeInTheDocument();
+ });
+
+ it('renders a low-confidence chip alongside the headline', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('low confidence')).toBeInTheDocument();
+ });
+
+ it('renders the unresolved-origin copy when confidence is none', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText('Could not resolve dispatch origin.'),
+ ).toBeInTheDocument();
+ });
+
+ it('toggles the full stack on click and marks library frames', () => {
+ render();
+ const toggle = screen.getByRole('button', {
+ name: /Full stack \(2 frames\)/,
+ });
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('library')).toBeInTheDocument();
+ // The path printer falls back to the last few segments for paths
+ // outside a workspace root — node_modules paths render as their tail.
+ expect(screen.getByText('core/lib/dispatch.js:100:1')).toBeInTheDocument();
+ });
+
+ it('expands the full stack by default when confidence is none', () => {
+ render(
+ ,
+ );
+ const toggle = screen.getByRole('button', { name: /Full stack/ });
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('renders the code-frame snippet when present and omits it when absent', () => {
+ // The producer (resolveDispatchOrigin) drops non-matching codeFrames
+ // before they ever reach the section, so the section just trusts
+ // what it gets: render if present, skip if not.
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByTestId('dispatch-origin-code-frame')).toHaveTextContent(
+ '42 | handlePress();',
+ );
+
+ rerender(
+ ,
+ );
+ expect(
+ screen.queryByTestId('dispatch-origin-code-frame'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('copies the verbatim raw stack and reflects success in the button label', async () => {
+ const writeText = vi.fn(async () => undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render();
+ const button = screen.getByRole('button', { name: 'Copy raw' });
+ fireEvent.click(button);
+
+ expect(writeText).toHaveBeenCalledWith('raw\nstack\nstring');
+ await screen.findByText('Copied');
+ });
+});
diff --git a/packages/react-navigation-plugin/src/devtools-ui/index.tsx b/packages/react-navigation-plugin/src/devtools-ui/index.tsx
index 8317d143..35d127f0 100644
--- a/packages/react-navigation-plugin/src/devtools-ui/index.tsx
+++ b/packages/react-navigation-plugin/src/devtools-ui/index.tsx
@@ -12,7 +12,7 @@ import './globals.css';
export default function ReactNavigationPanel() {
const [actionHistory, setActionHistory] = useState([]);
const [selectedActionIndex, setSelectedActionIndex] = useState(
- null
+ null,
);
const [activeTabId, setActiveTabId] = useState('timeline');
@@ -30,8 +30,13 @@ export default function ReactNavigationPanel() {
setActionHistory([{ action: { type: 'SNAPSHOT' }, state }]);
setSelectedActionIndex(null);
}),
- client.onMessage('action', ({ action, state }) => {
- setActionHistory((prev) => [{ action, state }, ...prev]);
+ client.onMessage('action', ({ id, action, state, origin }) => {
+ setActionHistory((prev) => [{ id, action, state, origin }, ...prev]);
+ }),
+ client.onMessage('action-symbolicated', ({ id, origin }) => {
+ setActionHistory((prev) =>
+ prev.map((entry) => (entry.id === id ? { ...entry, origin } : entry)),
+ );
}),
];
diff --git a/packages/react-navigation-plugin/src/react-native/__tests__/useReactNavigationEvents.test.ts b/packages/react-navigation-plugin/src/react-native/__tests__/useReactNavigationEvents.test.ts
new file mode 100644
index 00000000..299d28e1
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/__tests__/useReactNavigationEvents.test.ts
@@ -0,0 +1,239 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+
+// Mock react-native BEFORE importing modules that read NativeModules so
+// `resolveMetroOrigin` reads the value we set per test.
+const mockScriptURL = vi.hoisted(() => ({
+ value: undefined as string | undefined,
+}));
+
+vi.mock('react-native', () => ({
+ NativeModules: {
+ get SourceCode() {
+ return { scriptURL: mockScriptURL.value };
+ },
+ },
+}));
+
+import {
+ useReactNavigationEvents,
+ type ActionDataEvent,
+} from '../useReactNavigationEvents';
+import { __resetMetroOriginCache } from '../symbolication/metro';
+
+type ListenerMap = Map void>;
+
+type MockNavigation = {
+ __listeners: ListenerMap;
+ addListener: (
+ event: string,
+ listener: (event: unknown) => void,
+ ) => () => void;
+ getRootState: () => undefined;
+ resetRoot: () => void;
+ emit: (event: string, payload: unknown) => void;
+ hasListener: (event: string) => boolean;
+};
+
+const createMockNavigation = (): MockNavigation => {
+ const listeners: ListenerMap = new Map();
+ return {
+ __listeners: listeners,
+ addListener: (event, listener) => {
+ listeners.set(event, listener);
+ return () => listeners.delete(event);
+ },
+ getRootState: () => undefined,
+ resetRoot: () => {},
+ emit: (event, payload) => {
+ const fn = listeners.get(event);
+ fn?.(payload);
+ },
+ hasListener: (event) => listeners.has(event),
+ };
+};
+
+const sampleStack = (suffix = 'A') =>
+ `at handleClick (http://localhost:8081/index.bundle?platform=ios:12345:10)\n` +
+ `at onPress${suffix} (http://localhost:8081/index.bundle?platform=ios:12345:20)`;
+
+const okSymbolicateResponse = () =>
+ new Response(
+ JSON.stringify({
+ stack: [
+ {
+ methodName: 'handleClick',
+ file: 'apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ column: 5,
+ },
+ {
+ methodName: 'onPress',
+ file: 'apps/playground/src/Screen.tsx',
+ lineNumber: 41,
+ column: 3,
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+
+beforeEach(() => {
+ __resetMetroOriginCache();
+ mockScriptURL.value = 'http://localhost:8081/index.bundle';
+});
+
+describe('useReactNavigationEvents', () => {
+ it('emits action with pending origin then a follow-up symbolicated event on a cache miss', async () => {
+ const fetchMock = vi.fn(async () => okSymbolicateResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ const events: ActionDataEvent[] = [];
+ const nav = createMockNavigation();
+ const ref = { current: nav } as unknown as React.RefObject<
+ Parameters[0]['current']
+ >;
+
+ renderHook(() =>
+ useReactNavigationEvents(ref, (event) => events.push(event)),
+ );
+
+ await waitFor(() =>
+ expect(nav.hasListener('__unsafe_action__')).toBe(true),
+ );
+
+ nav.emit('__unsafe_action__', {
+ data: {
+ action: { type: 'NAVIGATE', payload: { name: 'Home' } },
+ stack: sampleStack(),
+ noop: true,
+ },
+ });
+
+ await waitFor(() => expect(events).toHaveLength(2));
+
+ expect(events[0]).toMatchObject({
+ type: 'action',
+ origin: { symbolicationStatus: 'pending' },
+ });
+ expect(events[0].type === 'action' && events[0].id).toBeGreaterThan(0);
+
+ const id = events[0].type === 'action' ? events[0].id : -1;
+ expect(events[1]).toMatchObject({
+ type: 'action-symbolicated',
+ id,
+ origin: {
+ symbolicationStatus: 'complete',
+ confidence: 'high',
+ },
+ });
+ expect(fetchMock).toHaveBeenCalledOnce();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('emits a single complete action when the same callsite dispatches again (cache hit)', async () => {
+ const fetchMock = vi.fn(async () => okSymbolicateResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ const events: ActionDataEvent[] = [];
+ const nav = createMockNavigation();
+ const ref = { current: nav } as unknown as React.RefObject<
+ Parameters[0]['current']
+ >;
+
+ renderHook(() =>
+ useReactNavigationEvents(ref, (event) => events.push(event)),
+ );
+ await waitFor(() =>
+ expect(nav.hasListener('__unsafe_action__')).toBe(true),
+ );
+
+ const stack = sampleStack();
+ nav.emit('__unsafe_action__', {
+ data: {
+ action: { type: 'NAVIGATE', payload: { name: 'A' } },
+ stack,
+ noop: true,
+ },
+ });
+
+ // Wait until the first dispatch has gone through pending → symbolicated.
+ await waitFor(() => expect(events).toHaveLength(2));
+ const cachedEventCount = events.length;
+
+ nav.emit('__unsafe_action__', {
+ data: {
+ action: { type: 'NAVIGATE', payload: { name: 'A again' } },
+ stack,
+ noop: true,
+ },
+ });
+
+ // Second dispatch: a single action event with status: 'complete'
+ // and no follow-up symbolicated event (fetch only called once for
+ // both dispatches).
+ await waitFor(() => expect(events).toHaveLength(cachedEventCount + 1));
+
+ const secondAction = events[cachedEventCount];
+ expect(secondAction).toMatchObject({
+ type: 'action',
+ origin: {
+ symbolicationStatus: 'complete',
+ confidence: 'high',
+ },
+ });
+
+ // Give any spurious second event a tick to land.
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(events).toHaveLength(cachedEventCount + 1);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('emits a single action with no origin when React Navigation does not supply a stack', async () => {
+ const fetchMock = vi.fn(async () => okSymbolicateResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ const events: ActionDataEvent[] = [];
+ const nav = createMockNavigation();
+ const ref = { current: nav } as unknown as React.RefObject<
+ Parameters[0]['current']
+ >;
+
+ renderHook(() =>
+ useReactNavigationEvents(ref, (event) => events.push(event)),
+ );
+ await waitFor(() =>
+ expect(nav.hasListener('__unsafe_action__')).toBe(true),
+ );
+
+ nav.emit('__unsafe_action__', {
+ data: {
+ action: { type: 'NAVIGATE', payload: { name: 'Home' } },
+ stack: undefined,
+ noop: true,
+ },
+ });
+
+ await waitFor(() => expect(events).toHaveLength(1));
+
+ expect(events[0]).toMatchObject({ type: 'action' });
+ expect(events[0].type === 'action' && events[0].origin).toBeUndefined();
+
+ // No symbolication attempt should have been made.
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(events).toHaveLength(1);
+ expect(fetchMock).not.toHaveBeenCalled();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/dispatchOrigin.ts b/packages/react-navigation-plugin/src/react-native/dispatchOrigin.ts
new file mode 100644
index 00000000..0b761371
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/dispatchOrigin.ts
@@ -0,0 +1,107 @@
+import {
+ createSymbolicationCache,
+ parseStack,
+ pickOriginFrame,
+ symbolicateFrames,
+ type ActionOrigin,
+ type ActionOriginCodeFrame,
+ type ActionStackFrame,
+ type SymbolicateOptions,
+ type SymbolicationCache,
+ type SymbolicationStatus,
+} from './symbolication';
+
+export type SymbolicatedCacheEntry = {
+ frames: ActionStackFrame[];
+ codeFrame?: ActionOriginCodeFrame;
+};
+
+export type DispatchOriginCache = SymbolicationCache;
+
+export const createDispatchOriginCache = (): DispatchOriginCache =>
+ createSymbolicationCache();
+
+const buildOrigin = (
+ rawStack: string,
+ frames: ActionStackFrame[],
+ status: SymbolicationStatus,
+ options: { codeFrame?: ActionOriginCodeFrame; error?: string } = {},
+): ActionOrigin => {
+ const { frame: originFrame, confidence } = pickOriginFrame(frames);
+ // The code-frame snippet only makes sense when it belongs to the same
+ // file as the chosen origin frame; otherwise the UI would highlight a
+ // location in one file and show a snippet from another.
+ const codeFrame =
+ options.codeFrame && originFrame?.url === options.codeFrame.fileName
+ ? options.codeFrame
+ : undefined;
+ return {
+ rawStack,
+ frames,
+ originFrame,
+ confidence,
+ symbolicationStatus: status,
+ symbolicationError: options.error,
+ codeFrame,
+ };
+};
+
+export type ResolveDispatchOriginDeps = {
+ cache: DispatchOriginCache;
+ symbolicate?: typeof symbolicateFrames;
+ symbolicateOptions?: SymbolicateOptions;
+};
+
+export type ResolveDispatchOriginResult = {
+ initialOrigin: ActionOrigin;
+ pendingResolution?: Promise;
+};
+
+// Cache-hit fast path: return a `complete` origin synchronously.
+// Cache-miss path: return a `pending` origin plus a promise that
+// resolves to the post-symbolication origin (failed / unavailable /
+// complete). Only successes are cached — failures retry on the next
+// dispatch from the same callsite.
+export const resolveDispatchOrigin = (
+ rawStack: string,
+ {
+ cache,
+ symbolicate = symbolicateFrames,
+ symbolicateOptions,
+ }: ResolveDispatchOriginDeps,
+): ResolveDispatchOriginResult => {
+ const frames = parseStack(rawStack);
+
+ const cached = cache.get(rawStack);
+ if (cached) {
+ return {
+ initialOrigin: buildOrigin(rawStack, cached.frames, 'complete', {
+ codeFrame: cached.codeFrame,
+ }),
+ };
+ }
+
+ const initialOrigin = buildOrigin(rawStack, frames, 'pending');
+
+ const pendingResolution = symbolicate(frames, symbolicateOptions ?? {}).then(
+ (outcome) => {
+ if (outcome.status === 'complete') {
+ cache.set(rawStack, {
+ frames: outcome.frames,
+ codeFrame: outcome.codeFrame,
+ });
+ return buildOrigin(rawStack, outcome.frames, 'complete', {
+ codeFrame: outcome.codeFrame,
+ });
+ }
+ if (outcome.status === 'failed') {
+ return buildOrigin(rawStack, frames, 'failed', {
+ error: outcome.error,
+ });
+ }
+ return buildOrigin(rawStack, frames, 'unavailable');
+ },
+ );
+
+ return { initialOrigin, pendingResolution };
+};
diff --git a/packages/react-navigation-plugin/src/react-native/index.ts b/packages/react-navigation-plugin/src/react-native/index.ts
index 5f3733c1..938f98b3 100644
--- a/packages/react-navigation-plugin/src/react-native/index.ts
+++ b/packages/react-navigation-plugin/src/react-native/index.ts
@@ -12,12 +12,11 @@ import type {
NavigationActionHistoryEntry,
ReactNavigationNavigateArgs,
} from '../shared/agent-tools';
-import {
- useReactNavigationAgentTools,
-} from './useReactNavigationAgentTools';
+import { useReactNavigationAgentTools } from './useReactNavigationAgentTools';
export type ReactNavigationDevToolsConfig<
- TNavigationContainerRef extends NavigationContainerRef = NavigationContainerRef
+ TNavigationContainerRef extends
+ NavigationContainerRef = NavigationContainerRef,
> = {
ref: React.RefObject;
};
@@ -26,7 +25,6 @@ export const useReactNavigationDevTools = ({
ref,
}: ReactNavigationDevToolsConfig): void => {
const actionHistoryRef = useRef([]);
- const nextActionIdRef = useRef(1);
const currentStateRef = useRef(undefined);
const getCurrentState = useCallback(() => {
@@ -45,7 +43,7 @@ export const useReactNavigationDevTools = ({
ref.current.resetRoot(state);
},
- [ref]
+ [ref],
);
const openLink = useCallback(async (href: string) => {
@@ -53,12 +51,7 @@ export const useReactNavigationDevTools = ({
}, []);
const navigate = useCallback(
- ({
- name,
- params,
- path,
- merge,
- }: ReactNavigationNavigateArgs) => {
+ ({ name, params, path, merge }: ReactNavigationNavigateArgs) => {
if (!ref.current) {
throw new Error('Navigation ref is not ready.');
}
@@ -69,10 +62,10 @@ export const useReactNavigationDevTools = ({
params,
path,
merge,
- })
+ }),
);
},
- [ref]
+ [ref],
);
const goBack = useCallback(
@@ -93,7 +86,7 @@ export const useReactNavigationDevTools = ({
return performed;
},
- [ref]
+ [ref],
);
const dispatchAction = useCallback(
@@ -104,7 +97,7 @@ export const useReactNavigationDevTools = ({
ref.current.dispatch(action);
},
- [ref]
+ [ref],
);
useReactNavigationAgentTools({
@@ -126,16 +119,21 @@ export const useReactNavigationDevTools = ({
if (message.type === 'action') {
currentStateRef.current = message.state;
const entry: NavigationActionHistoryEntry = {
- id: nextActionIdRef.current,
+ id: message.id,
timestamp: Date.now(),
action: message.action,
state: message.state,
- stack: message.stack,
+ origin: message.origin,
};
- nextActionIdRef.current += 1;
actionHistoryRef.current = [entry, ...actionHistoryRef.current].slice(
0,
- 100
+ 100,
+ );
+ } else {
+ // 'action-symbolicated' — replace the pending origin on the
+ // matching history entry. Bridge consumers do the same merge.
+ actionHistoryRef.current = actionHistoryRef.current.map((entry) =>
+ entry.id === message.id ? { ...entry, origin: message.origin } : entry,
);
}
@@ -177,7 +175,7 @@ export const useReactNavigationDevTools = ({
void openLink(message.href).catch(() => {
// We don't care about errors here
});
- })
+ }),
);
return () => {
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/cache.test.ts b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/cache.test.ts
new file mode 100644
index 00000000..7690d140
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/cache.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { createSymbolicationCache } from '../cache';
+
+describe('createSymbolicationCache', () => {
+ it('returns undefined on a miss', () => {
+ const cache = createSymbolicationCache(8);
+ expect(cache.get('nope')).toBeUndefined();
+ });
+
+ it('returns the stored value on a hit', () => {
+ const cache = createSymbolicationCache<{ id: number }>(8);
+ cache.set('k', { id: 7 });
+ expect(cache.get('k')).toEqual({ id: 7 });
+ });
+
+ it('evicts the least recently used entry once capacity is exceeded', () => {
+ const cache = createSymbolicationCache(3);
+ cache.set('a', 1);
+ cache.set('b', 2);
+ cache.set('c', 3);
+ // Touch 'a' to mark it as recently used; 'b' becomes the LRU.
+ cache.get('a');
+ cache.set('d', 4);
+ expect(cache.get('b')).toBeUndefined();
+ expect(cache.get('a')).toBe(1);
+ expect(cache.get('c')).toBe(3);
+ expect(cache.get('d')).toBe(4);
+ });
+
+ it('overwrites an existing value without growing past capacity', () => {
+ const cache = createSymbolicationCache(2);
+ cache.set('a', 1);
+ cache.set('b', 2);
+ cache.set('a', 99);
+ expect(cache.size()).toBe(2);
+ expect(cache.get('a')).toBe(99);
+ expect(cache.get('b')).toBe(2);
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/format.test.ts b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/format.test.ts
new file mode 100644
index 00000000..f344c8a9
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/format.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { formatFrameLocation, formatSourcePath } from '../format';
+
+describe('formatSourcePath', () => {
+ it('extracts the apps/ workspace suffix from a long absolute path', () => {
+ expect(
+ formatSourcePath('/Users/me/code/myapp/apps/playground/src/Screen.tsx'),
+ ).toBe('apps/playground/src/Screen.tsx');
+ });
+
+ it('extracts the packages/ workspace suffix', () => {
+ expect(
+ formatSourcePath('/Users/me/code/myapp/packages/shared/src/util.ts'),
+ ).toBe('packages/shared/src/util.ts');
+ });
+
+ it('returns the bundle filename for Metro bundle URLs', () => {
+ expect(
+ formatSourcePath(
+ 'http://localhost:8081/index.bundle?platform=ios&dev=true',
+ ),
+ ).toBe('index.bundle');
+ });
+
+ it('strips query string and hash before matching', () => {
+ expect(formatSourcePath('/abs/apps/foo/src/x.ts?bar=1#frag')).toBe(
+ 'apps/foo/src/x.ts',
+ );
+ });
+
+ it('falls back to the last few segments for non-workspace URLs', () => {
+ expect(formatSourcePath('https://example.com/a/b/c/d.ts')).toBe('d.ts');
+ });
+});
+
+describe('formatFrameLocation', () => {
+ it('returns null when the frame has no url at all', () => {
+ expect(formatFrameLocation({})).toBeNull();
+ expect(formatFrameLocation(undefined)).toBeNull();
+ });
+
+ it('formats source-mapped frames as path:line:col', () => {
+ expect(
+ formatFrameLocation({
+ url: '/abs/apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ columnNumber: 5,
+ }),
+ ).toBe('apps/playground/src/Screen.tsx:42:5');
+ });
+
+ it('falls back to the generated URL when no source-mapped url is present', () => {
+ expect(
+ formatFrameLocation({
+ generatedUrl: 'http://localhost:8081/index.bundle',
+ generatedLineNumber: 1,
+ generatedColumnNumber: 2,
+ }),
+ ).toBe('index.bundle:1:2');
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/metro.test.ts b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/metro.test.ts
new file mode 100644
index 00000000..320c98cc
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/metro.test.ts
@@ -0,0 +1,221 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock react-native BEFORE importing the module under test so the
+// mocked `NativeModules.SourceCode.scriptURL` is what
+// `resolveMetroOrigin` reads.
+const mockScriptURL = vi.hoisted(() => ({
+ value: undefined as string | undefined,
+ getConstantsValue: undefined as string | undefined,
+}));
+
+vi.mock('react-native', () => ({
+ NativeModules: {
+ get SourceCode() {
+ return {
+ scriptURL: mockScriptURL.value,
+ getConstants: () => ({ scriptURL: mockScriptURL.getConstantsValue }),
+ };
+ },
+ },
+}));
+
+import {
+ __resetMetroOriginCache,
+ resolveMetroOrigin,
+ symbolicateFrames,
+} from '../metro';
+import type { ActionStackFrame } from '../types';
+
+beforeEach(() => {
+ __resetMetroOriginCache();
+ mockScriptURL.value = undefined;
+ mockScriptURL.getConstantsValue = undefined;
+});
+
+describe('resolveMetroOrigin', () => {
+ it('returns null when NativeModules.SourceCode.scriptURL is undefined', () => {
+ mockScriptURL.value = undefined;
+ expect(resolveMetroOrigin()).toBeNull();
+ });
+
+ it('returns null for file:// schemes (release builds)', () => {
+ mockScriptURL.value = 'file:///var/containers/Bundle/.../main.jsbundle';
+ expect(resolveMetroOrigin()).toBeNull();
+ });
+
+ it('returns the http origin for a Metro bundle URL', () => {
+ mockScriptURL.value =
+ 'http://10.0.2.2:8081/index.bundle?platform=android&dev=true';
+ expect(resolveMetroOrigin()).toBe('http://10.0.2.2:8081');
+ });
+
+ it('caches the resolution across calls', () => {
+ mockScriptURL.value = 'http://localhost:8081/index.bundle';
+ const first = resolveMetroOrigin();
+ // Subsequent change to scriptURL must not affect the cached value.
+ mockScriptURL.value = 'http://different.host:9999/index.bundle';
+ expect(resolveMetroOrigin()).toBe(first);
+ });
+
+ it('falls back to getConstants() when scriptURL is missing on the module object (New Architecture TurboModule)', () => {
+ mockScriptURL.value = undefined;
+ mockScriptURL.getConstantsValue = 'http://localhost:8081/index.bundle';
+ expect(resolveMetroOrigin()).toBe('http://localhost:8081');
+ });
+});
+
+const sampleFrame = (
+ overrides: Partial = {},
+): ActionStackFrame => ({
+ functionName: 'handleClick',
+ generatedUrl: 'http://localhost:8081/index.bundle?platform=ios&dev=true',
+ generatedLineNumber: 12345,
+ generatedColumnNumber: 10,
+ ...overrides,
+});
+
+describe('symbolicateFrames', () => {
+ it('returns "unavailable" when no Metro origin is reachable', async () => {
+ const result = await symbolicateFrames([sampleFrame()], {
+ origin: null,
+ });
+ expect(result.status).toBe('unavailable');
+ expect(result.frames).toHaveLength(1);
+ });
+
+ it('returns "unavailable" when no frame has a generatedUrl to symbolicate', async () => {
+ const fetchSpy = vi.fn();
+ const result = await symbolicateFrames([{ functionName: 'noUrl' }], {
+ origin: 'http://localhost:8081',
+ fetch: fetchSpy,
+ });
+ expect(result.status).toBe('unavailable');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('posts the right body shape and maps Metro response onto source-mapped frames', async () => {
+ const fetchSpy = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ stack: [
+ {
+ methodName: 'handleClick',
+ file: 'apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ column: 5,
+ collapse: false,
+ },
+ ],
+ }),
+ { status: 200 },
+ ),
+ );
+ const result = await symbolicateFrames([sampleFrame()], {
+ origin: 'http://localhost:8081',
+ fetch: fetchSpy as typeof globalThis.fetch,
+ });
+
+ expect(fetchSpy).toHaveBeenCalledOnce();
+ const [callUrl, callInit] = fetchSpy.mock.calls[0] as unknown as [
+ string,
+ RequestInit,
+ ];
+ expect(callUrl).toBe('http://localhost:8081/symbolicate');
+ expect(callInit.method).toBe('POST');
+ expect(JSON.parse(callInit.body as string)).toEqual({
+ stack: [
+ {
+ methodName: 'handleClick',
+ file: 'http://localhost:8081/index.bundle?platform=ios&dev=true',
+ lineNumber: 12345,
+ column: 10,
+ },
+ ],
+ });
+
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return; // type narrowing
+ expect(result.frames[0]).toMatchObject({
+ functionName: 'handleClick',
+ url: 'apps/playground/src/Screen.tsx',
+ lineNumber: 42,
+ columnNumber: 5,
+ generatedUrl: 'http://localhost:8081/index.bundle?platform=ios&dev=true',
+ generatedLineNumber: 12345,
+ generatedColumnNumber: 10,
+ isCollapsed: false,
+ });
+ });
+
+ it('strips ANSI escape sequences from the codeFrame content', async () => {
+ const fetchSpy = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ stack: [
+ {
+ methodName: 'x',
+ file: 'apps/playground/src/x.ts',
+ lineNumber: 1,
+ column: 1,
+ },
+ ],
+ codeFrame: {
+ fileName: 'apps/playground/src/x.ts',
+ // ESC [31m red ESC [0m
+ content: '[31m 41 | foo();[0m',
+ line: 41,
+ column: 3,
+ },
+ }),
+ { status: 200 },
+ ),
+ );
+ const result = await symbolicateFrames([sampleFrame()], {
+ origin: 'http://localhost:8081',
+ fetch: fetchSpy as typeof globalThis.fetch,
+ });
+ expect(result.status).toBe('complete');
+ if (result.status !== 'complete') return;
+ expect(result.codeFrame?.content).toBe(' 41 | foo();');
+ expect(result.codeFrame?.line).toBe(41);
+ expect(result.codeFrame?.column).toBe(3);
+ });
+
+ it('returns "failed" when Metro responds with a non-200 status', async () => {
+ const fetchSpy = vi.fn(
+ async () => new Response('Server Error', { status: 500 }),
+ );
+ const result = await symbolicateFrames([sampleFrame()], {
+ origin: 'http://localhost:8081',
+ fetch: fetchSpy as typeof globalThis.fetch,
+ });
+ expect(result.status).toBe('failed');
+ if (result.status !== 'failed') return;
+ expect(result.error).toContain('500');
+ // Raw frames preserved so the UI can still show what we had.
+ expect(result.frames).toHaveLength(1);
+ });
+
+ it('returns "failed" with a timeout error when the request exceeds timeoutMs', async () => {
+ const fetchSpy = vi.fn(
+ (_url: string, init: RequestInit | undefined) =>
+ new Promise((_, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ const err = new Error('Aborted');
+ err.name = 'AbortError';
+ reject(err);
+ });
+ }),
+ );
+ const result = await symbolicateFrames([sampleFrame()], {
+ origin: 'http://localhost:8081',
+ fetch: fetchSpy as unknown as typeof globalThis.fetch,
+ timeoutMs: 10,
+ });
+ expect(result.status).toBe('failed');
+ if (result.status !== 'failed') return;
+ expect(result.error).toContain('timed out');
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/parse.test.ts b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/parse.test.ts
new file mode 100644
index 00000000..8fcbffdd
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/parse.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import { parseStack } from '../parse';
+
+describe('parseStack', () => {
+ it('returns an empty array for an empty string', () => {
+ expect(parseStack('')).toEqual([]);
+ });
+
+ it('parses a V8-style function frame "at fn (file:line:col)"', () => {
+ const stack = 'at handleClick (apps/playground/src/Screen.tsx:42:5)';
+ expect(parseStack(stack)).toEqual([
+ {
+ functionName: 'handleClick',
+ generatedUrl: 'apps/playground/src/Screen.tsx',
+ generatedLineNumber: 42,
+ generatedColumnNumber: 5,
+ },
+ ]);
+ });
+
+ it('parses a V8-style anonymous location frame "at file:line:col"', () => {
+ const stack = 'at apps/playground/src/Screen.tsx:42:5';
+ expect(parseStack(stack)).toEqual([
+ {
+ functionName: undefined,
+ generatedUrl: 'apps/playground/src/Screen.tsx',
+ generatedLineNumber: 42,
+ generatedColumnNumber: 5,
+ },
+ ]);
+ });
+
+ it('parses a JSC-style frame "fn@file:line:col"', () => {
+ const stack = 'handleClick@apps/playground/src/Screen.tsx:42:5';
+ expect(parseStack(stack)).toEqual([
+ {
+ functionName: 'handleClick',
+ generatedUrl: 'apps/playground/src/Screen.tsx',
+ generatedLineNumber: 42,
+ generatedColumnNumber: 5,
+ },
+ ]);
+ });
+
+ it('parses multiple frames in input order', () => {
+ const stack = [
+ 'at dispatch (node_modules/@react-navigation/core/dispatch.js:10:1)',
+ 'at navigate (node_modules/@react-navigation/core/navigate.js:20:2)',
+ 'at handleClick (apps/playground/src/Screen.tsx:42:5)',
+ ].join('\n');
+ const frames = parseStack(stack);
+ expect(frames).toHaveLength(3);
+ expect(frames[0].functionName).toBe('dispatch');
+ expect(frames[1].functionName).toBe('navigate');
+ expect(frames[2].functionName).toBe('handleClick');
+ });
+
+ it('skips blank lines and malformed lines that match no frame format', () => {
+ const stack = [
+ '',
+ ' ',
+ 'totally not a stack frame',
+ 'at handleClick (apps/playground/src/Screen.tsx:42:5)',
+ ].join('\n');
+ const frames = parseStack(stack);
+ expect(frames).toHaveLength(1);
+ expect(frames[0].functionName).toBe('handleClick');
+ });
+
+ it('drops "", "anonymous", and "" function-name markers', () => {
+ const stack = [
+ 'at (apps/playground/src/a.ts:1:1)',
+ 'at anonymous (apps/playground/src/b.ts:2:1)',
+ 'at (apps/playground/src/c.ts:3:1)',
+ ].join('\n');
+ const frames = parseStack(stack);
+ expect(frames.map((f) => f.functionName)).toEqual([
+ undefined,
+ undefined,
+ undefined,
+ ]);
+ });
+
+ it('caps the result at 50 frames', () => {
+ const lines = Array.from(
+ { length: 75 },
+ (_, i) => `at frame${i} (apps/playground/src/x.ts:${i}:1)`,
+ );
+ const frames = parseStack(lines.join('\n'));
+ expect(frames).toHaveLength(50);
+ // The first 50 should match input order — verifies it slices the
+ // head, not the tail.
+ expect(frames[0].functionName).toBe('frame0');
+ expect(frames[49].functionName).toBe('frame49');
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/rank.test.ts b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/rank.test.ts
new file mode 100644
index 00000000..bf1f7705
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/rank.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import { classifyFrame, pickOriginFrame } from '../rank';
+import type { ActionStackFrame } from '../types';
+
+const frame = (
+ overrides: Partial = {},
+): ActionStackFrame => ({
+ ...overrides,
+});
+
+describe('classifyFrame', () => {
+ it('returns "unknown" when no url is provided', () => {
+ expect(classifyFrame(undefined)).toBe('unknown');
+ });
+
+ it('returns "app" for paths outside node_modules', () => {
+ expect(classifyFrame('apps/playground/src/Screen.tsx')).toBe('app');
+ expect(classifyFrame('/Users/me/code/myapp/src/index.ts')).toBe('app');
+ expect(classifyFrame('packages/shared/src/util.ts')).toBe('app');
+ });
+
+ it('returns "library" for paths under node_modules', () => {
+ expect(classifyFrame('node_modules/react/index.js')).toBe('library');
+ expect(
+ classifyFrame('/abs/node_modules/@react-navigation/native/lib/foo.js'),
+ ).toBe('library');
+ expect(classifyFrame('apps/x/node_modules/react/index.js')).toBe('library');
+ });
+});
+
+describe('pickOriginFrame', () => {
+ it('prefers the first app frame as high confidence', () => {
+ const frames = [
+ frame({ url: 'node_modules/@react-navigation/core/dispatch.js' }),
+ frame({ url: 'node_modules/react/index.js' }),
+ frame({ url: 'apps/playground/src/Screen.tsx', functionName: 'handle' }),
+ frame({ url: 'apps/playground/src/App.tsx' }),
+ ];
+ const result = pickOriginFrame(frames);
+ expect(result.confidence).toBe('high');
+ expect(result.frame?.functionName).toBe('handle');
+ });
+
+ it('falls back to the first source-mapped library frame as low confidence', () => {
+ const frames = [
+ frame({ url: 'node_modules/@react-navigation/core/dispatch.js' }),
+ frame({ url: 'node_modules/react/index.js' }),
+ ];
+ const result = pickOriginFrame(frames);
+ expect(result.confidence).toBe('low');
+ expect(result.frame?.url).toBe(
+ 'node_modules/@react-navigation/core/dispatch.js',
+ );
+ });
+
+ it('returns the first frame with no source as "none" confidence', () => {
+ // Frames have only generatedUrl (no source-mapped `url`) — Metro
+ // either failed to symbolicate them or symbolication has not run.
+ const frames = [
+ frame({ generatedUrl: 'http://localhost:8081/index.bundle' }),
+ frame({ generatedUrl: 'http://localhost:8081/index.bundle' }),
+ ];
+ const result = pickOriginFrame(frames);
+ expect(result.confidence).toBe('none');
+ expect(result.frame).toBe(frames[0]);
+ });
+
+ it('returns "none" with undefined frame for an empty input', () => {
+ const result = pickOriginFrame([]);
+ expect(result.confidence).toBe('none');
+ expect(result.frame).toBeUndefined();
+ });
+
+ it('does not consider generatedUrl when classifying — only source url decides app vs library', () => {
+ // A frame whose only resolution is the bundle URL is "unknown",
+ // not "app", because we have no source path to inspect. This
+ // matters during the pending state, before Metro symbolicates.
+ const frames = [
+ frame({
+ generatedUrl:
+ 'http://localhost:8081/index.bundle?platform=ios&dev=true',
+ }),
+ ];
+ expect(pickOriginFrame(frames).confidence).toBe('none');
+ });
+});
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/cache.ts b/packages/react-navigation-plugin/src/react-native/symbolication/cache.ts
new file mode 100644
index 00000000..8536a11d
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/cache.ts
@@ -0,0 +1,48 @@
+// A tiny LRU keyed on raw stack strings. Apps typically dispatch
+// navigation from a small set of callsites, so capacity is generous —
+// 256 entries holds the dispatch fingerprints of a wildly diverse app.
+const DEFAULT_CAPACITY = 256;
+
+export type SymbolicationCache = {
+ get(key: string): V | undefined;
+ set(key: string, value: V): void;
+ size(): number;
+ clear(): void;
+};
+
+export const createSymbolicationCache = (
+ capacity: number = DEFAULT_CAPACITY,
+): SymbolicationCache => {
+ // Map preserves insertion order — deleting + re-inserting on hit is
+ // enough to maintain LRU recency without a separate linked list.
+ const entries = new Map();
+
+ return {
+ get(key) {
+ if (!entries.has(key)) return undefined;
+ const value = entries.get(key) as V;
+ // Bump recency by re-inserting.
+ entries.delete(key);
+ entries.set(key, value);
+ return value;
+ },
+ set(key, value) {
+ if (entries.has(key)) {
+ entries.delete(key);
+ } else if (entries.size >= capacity) {
+ // Evict the oldest entry (first key in insertion order).
+ const oldestKey = entries.keys().next().value;
+ if (oldestKey !== undefined) {
+ entries.delete(oldestKey);
+ }
+ }
+ entries.set(key, value);
+ },
+ size() {
+ return entries.size;
+ },
+ clear() {
+ entries.clear();
+ },
+ };
+};
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/format.ts b/packages/react-navigation-plugin/src/react-native/symbolication/format.ts
new file mode 100644
index 00000000..69f02379
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/format.ts
@@ -0,0 +1,53 @@
+import type { ActionStackFrame } from './types';
+
+// Workspace-style path matchers — surface the part of the file path
+// that's meaningful to a developer. Monorepos commonly have `apps/`,
+// `packages/`, and `src/` roots; falling back to the last few segments
+// covers everything else.
+const WORKSPACE_PATH_PATTERN = /(?:^|\/)((?:apps|packages|src)\/.+)$/;
+
+const safeDecodeURIComponent = (value: string): string => {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+};
+
+export const formatSourcePath = (url: string): string => {
+ const withoutQueryAndHash = url.split(/[?#]/)[0];
+ const decoded = safeDecodeURIComponent(withoutQueryAndHash).replace(
+ /^file:\/\//,
+ '',
+ );
+
+ // For Metro bundle URLs, the bundle filename is the meaningful suffix.
+ const bundleMatch = decoded.match(/([^/]+\.bundle)(?:\/|$)/);
+ if (bundleMatch) return bundleMatch[1];
+
+ const workspaceMatch = decoded.match(WORKSPACE_PATH_PATTERN);
+ if (workspaceMatch) return workspaceMatch[1];
+
+ try {
+ const parsed = new URL(url);
+ const fileName = parsed.pathname.split('/').filter(Boolean).pop();
+ return fileName || parsed.hostname || url;
+ } catch {
+ const segments = decoded.split('/').filter(Boolean);
+ return segments.slice(-3).join('/') || decoded || url;
+ }
+};
+
+export const formatFrameLocation = (
+ frame: ActionStackFrame | undefined,
+): string | null => {
+ const url = frame?.url ?? frame?.generatedUrl;
+ if (!url) return null;
+
+ const parts = [formatSourcePath(url)];
+ const line = frame?.url ? frame.lineNumber : frame?.generatedLineNumber;
+ const column = frame?.url ? frame.columnNumber : frame?.generatedColumnNumber;
+ if (line !== undefined) parts.push(String(line));
+ if (column !== undefined) parts.push(String(column));
+ return parts.join(':');
+};
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/index.ts b/packages/react-navigation-plugin/src/react-native/symbolication/index.ts
new file mode 100644
index 00000000..d8adada0
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/index.ts
@@ -0,0 +1,23 @@
+export { parseStack } from './parse';
+export {
+ classifyFrame,
+ pickOriginFrame,
+ type FrameClass,
+ type OriginPick,
+} from './rank';
+export {
+ resolveMetroOrigin,
+ symbolicateFrames,
+ __resetMetroOriginCache,
+ type SymbolicateOptions,
+ type SymbolicationOutcome,
+} from './metro';
+export { createSymbolicationCache, type SymbolicationCache } from './cache';
+export { formatSourcePath, formatFrameLocation } from './format';
+export type {
+ ActionOrigin,
+ ActionOriginCodeFrame,
+ ActionStackFrame,
+ OriginConfidence,
+ SymbolicationStatus,
+} from './types';
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts b/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts
new file mode 100644
index 00000000..e4881e3f
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts
@@ -0,0 +1,225 @@
+import { NativeModules } from 'react-native';
+import type { ActionOriginCodeFrame, ActionStackFrame } from './types';
+
+let cachedMetroOrigin: string | null | undefined;
+
+// Resolved once per process. The bundle URL doesn't change at runtime,
+// so the cache is safe for the lifetime of the app. Tests can reset it
+// via `__resetMetroOriginCache` between cases.
+export const resolveMetroOrigin = (): string | null => {
+ if (cachedMetroOrigin !== undefined) return cachedMetroOrigin;
+ // On the New Architecture, `SourceCode` is a TurboModule whose
+ // constants don't materialize as direct properties on the module
+ // object — `getConstants()` is required to access them. Fall back to
+ // the legacy direct-property access for older runtimes.
+ const sourceCode = NativeModules?.SourceCode as
+ | {
+ scriptURL?: string;
+ getConstants?: () => { scriptURL?: string };
+ }
+ | undefined;
+ const scriptURL =
+ sourceCode?.scriptURL ?? sourceCode?.getConstants?.().scriptURL;
+ if (!scriptURL) {
+ cachedMetroOrigin = null;
+ return null;
+ }
+ try {
+ const url = new URL(scriptURL);
+ // Release builds load from `file://`; Metro isn't reachable.
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ cachedMetroOrigin = null;
+ return null;
+ }
+ cachedMetroOrigin = url.origin;
+ return cachedMetroOrigin;
+ } catch {
+ cachedMetroOrigin = null;
+ return null;
+ }
+};
+
+export const __resetMetroOriginCache = (): void => {
+ cachedMetroOrigin = undefined;
+};
+
+// Metro returns code-frame content formatted for terminals. DevTools
+// renders it as plain text, so escape sequences must be removed.
+const ANSI_SEQUENCE_PATTERN = new RegExp(
+ [
+ '[\\u001b\\u009b][[\\]()#;?]*',
+ '(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)',
+ '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
+ ].join(''),
+ 'g',
+);
+
+const stripAnsi = (value: string): string =>
+ value.replace(ANSI_SEQUENCE_PATTERN, '');
+
+type MetroSymbolicatedFrame = {
+ methodName: string;
+ file: string | null | undefined;
+ lineNumber: number | null | undefined;
+ column: number | null | undefined;
+ collapse?: boolean;
+};
+
+type MetroCodeFrame = {
+ fileName: string;
+ content: string;
+ // Metro versions vary: older responses use top-level line/column,
+ // newer ones nest under `location`. Handle both.
+ line?: number;
+ column?: number;
+ location?: { row: number; column: number };
+};
+
+type MetroSymbolicateResponse = {
+ stack: MetroSymbolicatedFrame[];
+ codeFrame?: MetroCodeFrame;
+};
+
+const isGeneratedBundleUrl = (url: string | undefined): boolean =>
+ !!url && /[^/]+\.bundle(?:[/?#]|$)/.test(url);
+
+const toMetroFrame = (
+ frame: ActionStackFrame,
+): MetroSymbolicatedFrame | null => {
+ if (!frame.generatedUrl) return null;
+ return {
+ methodName: frame.functionName ?? '',
+ file: frame.generatedUrl,
+ lineNumber: frame.generatedLineNumber,
+ column: frame.generatedColumnNumber,
+ };
+};
+
+const ANONYMOUS_METRO_METHODS = new Set(['', 'anonymous']);
+
+const fromMetroFrame = (
+ metroFrame: MetroSymbolicatedFrame,
+ original: ActionStackFrame,
+): ActionStackFrame => {
+ // Metro returns `file: ` for frames it couldn't
+ // source-map. Drop that as the source url so the frame stays marked
+ // "no source available".
+ const sourceUrl =
+ metroFrame.file &&
+ metroFrame.file !== original.generatedUrl &&
+ !isGeneratedBundleUrl(metroFrame.file)
+ ? metroFrame.file
+ : undefined;
+
+ const resolvedFunctionName =
+ metroFrame.methodName && !ANONYMOUS_METRO_METHODS.has(metroFrame.methodName)
+ ? metroFrame.methodName
+ : original.functionName;
+
+ return {
+ functionName: resolvedFunctionName,
+ url: sourceUrl,
+ lineNumber: sourceUrl ? (metroFrame.lineNumber ?? undefined) : undefined,
+ columnNumber: sourceUrl ? (metroFrame.column ?? undefined) : undefined,
+ generatedUrl: original.generatedUrl,
+ generatedLineNumber: original.generatedLineNumber,
+ generatedColumnNumber: original.generatedColumnNumber,
+ isCollapsed: metroFrame.collapse,
+ };
+};
+
+const toCodeFrame = (
+ raw: MetroCodeFrame | undefined,
+): ActionOriginCodeFrame | undefined => {
+ if (!raw) return undefined;
+ const line = raw.location?.row ?? raw.line;
+ const column = raw.location?.column ?? raw.column;
+ if (line === undefined || column === undefined) return undefined;
+ return {
+ fileName: raw.fileName,
+ content: stripAnsi(raw.content),
+ line,
+ column,
+ };
+};
+
+export type SymbolicateOptions = {
+ fetch?: typeof globalThis.fetch;
+ timeoutMs?: number;
+ // Override the auto-resolved origin. Useful in tests; production
+ // code leaves it undefined so `resolveMetroOrigin()` is consulted.
+ origin?: string | null;
+};
+
+export type SymbolicationOutcome =
+ | {
+ status: 'complete';
+ frames: ActionStackFrame[];
+ codeFrame?: ActionOriginCodeFrame;
+ }
+ | { status: 'failed'; frames: ActionStackFrame[]; error: string }
+ | { status: 'unavailable'; frames: ActionStackFrame[] };
+
+const DEFAULT_TIMEOUT_MS = 5000;
+
+export const symbolicateFrames = async (
+ frames: ActionStackFrame[],
+ options: SymbolicateOptions = {},
+): Promise => {
+ const origin =
+ options.origin !== undefined ? options.origin : resolveMetroOrigin();
+ if (!origin) {
+ return { status: 'unavailable', frames };
+ }
+
+ const fetchFn = options.fetch ?? globalThis.fetch;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+
+ const metroFrames = frames
+ .map(toMetroFrame)
+ .filter((f): f is MetroSymbolicatedFrame => f !== null);
+
+ if (metroFrames.length === 0) {
+ return { status: 'unavailable', frames };
+ }
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetchFn(`${origin}/symbolicate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ stack: metroFrames }),
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+
+ if (!response.ok) {
+ return {
+ status: 'failed',
+ frames,
+ error: `Metro responded with HTTP ${response.status}`,
+ };
+ }
+
+ const data = (await response.json()) as MetroSymbolicateResponse;
+ const mapped = data.stack.map((metroFrame, idx) =>
+ fromMetroFrame(metroFrame, frames[idx] ?? {}),
+ );
+ return {
+ status: 'complete',
+ frames: mapped,
+ codeFrame: toCodeFrame(data.codeFrame),
+ };
+ } catch (error) {
+ clearTimeout(timer);
+ const message =
+ error instanceof Error
+ ? error.name === 'AbortError'
+ ? `Metro symbolication timed out after ${timeoutMs}ms`
+ : error.message
+ : 'Metro symbolication failed';
+ return { status: 'failed', frames, error: message };
+ }
+};
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/parse.ts b/packages/react-navigation-plugin/src/react-native/symbolication/parse.ts
new file mode 100644
index 00000000..bdbeb2a7
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/parse.ts
@@ -0,0 +1,87 @@
+import type { ActionStackFrame } from './types';
+
+// Hard cap on parsed frames. The dispatch chain for a typical navigation
+// action is well under 20 frames; 50 is a safety bound against
+// pathological stacks (deep recursion, error-handler chains).
+const STACK_FRAME_LIMIT = 50;
+
+const FRAME_LOCATION_PATTERN = /^(.*):(\d+):(\d+)$/;
+const V8_FUNCTION_FRAME_PATTERN = /^at\s+(.*?)\s+\((.*)\)$/;
+const V8_LOCATION_FRAME_PATTERN = /^at\s+(.*)$/;
+const JSC_FRAME_PATTERN = /^(.*?)@(.*)$/;
+
+const ANONYMOUS_FUNCTION_NAMES = new Set([
+ '',
+ 'anonymous',
+ '',
+]);
+
+const normalizeFunctionName = (fn: string | undefined): string | undefined => {
+ const trimmed = fn?.trim();
+ return trimmed && !ANONYMOUS_FUNCTION_NAMES.has(trimmed)
+ ? trimmed
+ : undefined;
+};
+
+type ParsedLocation = {
+ url: string;
+ lineNumber: number;
+ columnNumber: number;
+};
+
+const parseLocation = (location: string): ParsedLocation | null => {
+ const match = location.match(FRAME_LOCATION_PATTERN);
+ if (!match) return null;
+ return {
+ url: match[1],
+ lineNumber: Number.parseInt(match[2], 10),
+ columnNumber: Number.parseInt(match[3], 10),
+ };
+};
+
+const parseLine = (line: string): ActionStackFrame | null => {
+ const trimmed = line.trim();
+ if (!trimmed) return null;
+
+ let functionName: string | undefined;
+ let location: string | undefined;
+
+ const v8FunctionMatch = trimmed.match(V8_FUNCTION_FRAME_PATTERN);
+ if (v8FunctionMatch) {
+ functionName = v8FunctionMatch[1];
+ location = v8FunctionMatch[2];
+ } else {
+ const v8LocationMatch = trimmed.match(V8_LOCATION_FRAME_PATTERN);
+ if (v8LocationMatch) {
+ location = v8LocationMatch[1];
+ } else {
+ const jscMatch = trimmed.match(JSC_FRAME_PATTERN);
+ if (jscMatch) {
+ functionName = jscMatch[1];
+ location = jscMatch[2];
+ }
+ }
+ }
+
+ if (!location) return null;
+ const parsed = parseLocation(location);
+ if (!parsed) return null;
+
+ // Parsed frames carry the GENERATED (bundle) location only. Source
+ // map data is filled in later by `symbolicateFrames` once Metro
+ // resolves the `/symbolicate` call.
+ return {
+ functionName: normalizeFunctionName(functionName),
+ generatedUrl: parsed.url,
+ generatedLineNumber: parsed.lineNumber,
+ generatedColumnNumber: parsed.columnNumber,
+ };
+};
+
+export const parseStack = (rawStack: string): ActionStackFrame[] => {
+ return rawStack
+ .split('\n')
+ .map(parseLine)
+ .filter((frame): frame is ActionStackFrame => frame !== null)
+ .slice(0, STACK_FRAME_LIMIT);
+};
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/rank.ts b/packages/react-navigation-plugin/src/react-native/symbolication/rank.ts
new file mode 100644
index 00000000..6c677f5e
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/rank.ts
@@ -0,0 +1,33 @@
+import type { ActionStackFrame, OriginConfidence } from './types';
+
+export type FrameClass = 'app' | 'library' | 'unknown';
+
+// Match "node_modules/" at the start of the path OR preceded by a
+// slash. Captures both absolute paths (`/abs/.../node_modules/react/`)
+// and relative ones (`node_modules/react/index.js`).
+const NODE_MODULES_PATTERN = /(?:^|\/)node_modules\//;
+
+export const classifyFrame = (url: string | undefined): FrameClass => {
+ if (!url) return 'unknown';
+ return NODE_MODULES_PATTERN.test(url) ? 'library' : 'app';
+};
+
+export type OriginPick = {
+ frame: ActionStackFrame | undefined;
+ confidence: OriginConfidence;
+};
+
+// Prefers the first source-mapped app frame. Falls back to the first
+// frame with any source-mapped URL (library), then to the first frame
+// at all (which may have only a generated URL). The three confidence
+// states let the UI distinguish "clearly your code" from "best we
+// could do" from "we don't really know".
+export const pickOriginFrame = (frames: ActionStackFrame[]): OriginPick => {
+ const firstApp = frames.find((f) => classifyFrame(f.url) === 'app');
+ if (firstApp) return { frame: firstApp, confidence: 'high' };
+
+ const firstWithSource = frames.find((f) => f.url);
+ if (firstWithSource) return { frame: firstWithSource, confidence: 'low' };
+
+ return { frame: frames[0], confidence: 'none' };
+};
diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/types.ts b/packages/react-navigation-plugin/src/react-native/symbolication/types.ts
new file mode 100644
index 00000000..a30d43ee
--- /dev/null
+++ b/packages/react-navigation-plugin/src/react-native/symbolication/types.ts
@@ -0,0 +1,35 @@
+export type ActionStackFrame = {
+ functionName?: string;
+ url?: string;
+ lineNumber?: number;
+ columnNumber?: number;
+ generatedUrl?: string;
+ generatedLineNumber?: number;
+ generatedColumnNumber?: number;
+ isCollapsed?: boolean;
+};
+
+export type SymbolicationStatus =
+ | 'pending'
+ | 'complete'
+ | 'failed'
+ | 'unavailable';
+
+export type OriginConfidence = 'high' | 'low' | 'none';
+
+export type ActionOriginCodeFrame = {
+ fileName: string;
+ content: string;
+ line: number;
+ column: number;
+};
+
+export type ActionOrigin = {
+ rawStack: string;
+ frames: ActionStackFrame[];
+ originFrame?: ActionStackFrame;
+ confidence: OriginConfidence;
+ symbolicationStatus: SymbolicationStatus;
+ symbolicationError?: string;
+ codeFrame?: ActionOriginCodeFrame;
+};
diff --git a/packages/react-navigation-plugin/src/react-native/useReactNavigationEvents.ts b/packages/react-navigation-plugin/src/react-native/useReactNavigationEvents.ts
index f6b57a44..79fd16ff 100644
--- a/packages/react-navigation-plugin/src/react-native/useReactNavigationEvents.ts
+++ b/packages/react-navigation-plugin/src/react-native/useReactNavigationEvents.ts
@@ -5,18 +5,31 @@ import type {
} from '@react-navigation/core';
import deepEqual from 'fast-deep-equal';
import { useRef, useEffect, useCallback } from 'react';
-
-export type ActionDataEvent = {
- type: 'action';
- action: NavigationAction;
- state: NavigationState | undefined;
- stack: string | undefined;
-};
+import {
+ createDispatchOriginCache,
+ resolveDispatchOrigin,
+ type DispatchOriginCache,
+} from './dispatchOrigin';
+import type { ActionOrigin } from './symbolication';
+
+export type ActionDataEvent =
+ | {
+ type: 'action';
+ id: number;
+ action: NavigationAction;
+ state: NavigationState | undefined;
+ origin?: ActionOrigin;
+ }
+ | {
+ type: 'action-symbolicated';
+ id: number;
+ origin: ActionOrigin;
+ };
// This is a copy of useDevToolsBase from the @react-navigation/devtools package
export function useReactNavigationEvents(
ref: React.RefObject | null>,
- callback: (result: ActionDataEvent) => void
+ callback: (result: ActionDataEvent) => void,
) {
const lastStateRef = useRef(undefined);
const lastActionRef = useRef<
@@ -24,6 +37,11 @@ export function useReactNavigationEvents(
>(undefined);
const callbackRef = useRef(callback);
const lastResetRef = useRef(undefined);
+ const nextIdRef = useRef(1);
+ const cacheRef = useRef(null);
+ if (cacheRef.current === null) {
+ cacheRef.current = createDispatchOriginCache();
+ }
useEffect(() => {
callbackRef.current = callback;
@@ -31,24 +49,54 @@ export function useReactNavigationEvents(
const pendingPromiseRef = useRef>(Promise.resolve());
- const send = useCallback((data: ActionDataEvent) => {
- // We need to make sure that our callbacks executed in the same order
- // So we add check if the last promise is settled before sending the next one
- pendingPromiseRef.current = pendingPromiseRef.current
- .catch(() => {
- // Ignore any errors from the last promise
- })
- .then(async () => {
- if (data.stack) {
- let stack: string | undefined;
- // TODO: Symbolicate the stack again
-
- callbackRef.current({ ...data, stack });
- } else {
- callbackRef.current(data);
- }
- });
- }, []);
+ const send = useCallback(
+ (data: {
+ action: NavigationAction;
+ state: NavigationState | undefined;
+ stack: string | undefined;
+ }) => {
+ // We need to make sure that our callbacks executed in the same order
+ // So we add check if the last promise is settled before sending the next one
+ pendingPromiseRef.current = pendingPromiseRef.current
+ .catch(() => {
+ // Ignore any errors from the last promise
+ })
+ .then(() => {
+ const id = nextIdRef.current++;
+ let initialOrigin: ActionOrigin | undefined;
+ let pendingResolution: Promise | undefined;
+
+ if (data.stack) {
+ const resolved = resolveDispatchOrigin(data.stack, {
+ cache: cacheRef.current!,
+ });
+ initialOrigin = resolved.initialOrigin;
+ pendingResolution = resolved.pendingResolution;
+ }
+
+ callbackRef.current({
+ type: 'action',
+ id,
+ action: data.action,
+ state: data.state,
+ origin: initialOrigin,
+ });
+
+ if (pendingResolution) {
+ // Fire-and-forget: the action event is already delivered;
+ // the symbolicated event arrives whenever Metro responds.
+ pendingResolution.then((resolved) => {
+ callbackRef.current({
+ type: 'action-symbolicated',
+ id,
+ origin: resolved,
+ });
+ });
+ }
+ });
+ },
+ [],
+ );
useEffect(() => {
let timer: any;
@@ -79,7 +127,6 @@ export function useReactNavigationEvents(
if (e.data.noop) {
// Even if the state didn't change, it's useful to show the action
send({
- type: 'action',
action,
state: lastStateRef.current,
stack: e.data.stack,
@@ -112,7 +159,6 @@ export function useReactNavigationEvents(
}
send({
- type: 'action',
action: lastChange ? lastChange.action : { type: '@@UNKNOWN' },
state,
stack: lastChange?.stack,
@@ -136,7 +182,7 @@ export function useReactNavigationEvents(
ref.current.resetRoot(state);
}
},
- [ref]
+ [ref],
);
return { resetRoot };
diff --git a/packages/react-navigation-plugin/src/shared/agent-tools.ts b/packages/react-navigation-plugin/src/shared/agent-tools.ts
index 510ba7de..5ddf741e 100644
--- a/packages/react-navigation-plugin/src/shared/agent-tools.ts
+++ b/packages/react-navigation-plugin/src/shared/agent-tools.ts
@@ -3,6 +3,7 @@ import {
type AgentToolContract,
} from '@rozenite/agent-shared';
import type { NavigationAction, NavigationState } from './index';
+import type { ActionOrigin } from '../react-native/symbolication/types';
export const REACT_NAVIGATION_AGENT_PLUGIN_ID =
'@rozenite/react-navigation-plugin';
@@ -12,7 +13,17 @@ export type NavigationActionHistoryEntry = {
timestamp: number;
action: NavigationAction;
state: NavigationState | undefined;
- stack: string | undefined;
+ /**
+ * Captured dispatch context for this navigation action.
+ *
+ * - `undefined` when React Navigation did not supply a stack.
+ * - When defined, contains the raw stack string, parsed frames, the
+ * selected origin frame with a confidence level, and the
+ * symbolication status. Agents querying "where did this come from?"
+ * should read `origin.originFrame` after confirming
+ * `origin.symbolicationStatus === 'complete'`.
+ */
+ origin?: ActionOrigin;
};
export type ReactNavigationListActionsArgs = {
@@ -125,7 +136,8 @@ export const reactNavigationToolDefinitions = {
ReactNavigationListActionsResult
>({
name: 'list-actions',
- description: 'List recorded navigation actions with states using pagination.',
+ description:
+ 'List recorded navigation actions with states using pagination.',
inputSchema: {
type: 'object',
properties: {
diff --git a/packages/react-navigation-plugin/src/shared/index.ts b/packages/react-navigation-plugin/src/shared/index.ts
index 8b855606..f1bf9ca1 100644
--- a/packages/react-navigation-plugin/src/shared/index.ts
+++ b/packages/react-navigation-plugin/src/shared/index.ts
@@ -1,5 +1,6 @@
import type { NavigationAction, NavigationState } from '@react-navigation/core';
import type { RozeniteDevToolsClient } from '@rozenite/plugin-bridge';
+import type { ActionOrigin } from '../react-native/symbolication/types';
export type ReactNavigationPluginInitMessage = {
type: 'init';
@@ -12,9 +13,16 @@ export type ReactNavigationPluginInitialStateMessage = {
export type ReactNavigationPluginActionMessage = {
type: 'action';
+ id: number;
action: NavigationAction;
state: NavigationState | undefined;
- stack: string | undefined;
+ origin?: ActionOrigin;
+};
+
+export type ReactNavigationPluginActionSymbolicatedMessage = {
+ type: 'action-symbolicated';
+ id: number;
+ origin: ActionOrigin;
};
export type ReactNavigationPluginResetRootMessage = {
@@ -34,6 +42,7 @@ export type ReactNavigationPluginEventMap = {
'reset-root': ReactNavigationPluginResetRootMessage;
'initial-state': ReactNavigationPluginInitialStateMessage;
action: ReactNavigationPluginActionMessage;
+ 'action-symbolicated': ReactNavigationPluginActionSymbolicatedMessage;
'open-link': ReactNavigationPluginOpenLinkMessage;
};
diff --git a/packages/react-navigation-plugin/vite.config.ts b/packages/react-navigation-plugin/vite.config.ts
index 9a54b5f3..8d402f90 100644
--- a/packages/react-navigation-plugin/vite.config.ts
+++ b/packages/react-navigation-plugin/vite.config.ts
@@ -1,4 +1,5 @@
///
+import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { rozenitePlugin } from '@rozenite/vite-plugin';
@@ -7,6 +8,13 @@ export default defineConfig({
plugins: [rozenitePlugin()],
test: {
passWithNoTests: true,
+ setupFiles: ['./vitest.setup.ts'],
+ alias: {
+ '@rozenite/agent-shared': resolve(
+ __dirname,
+ '../agent-shared/src/index.ts',
+ ),
+ },
},
base: './',
build: {
diff --git a/packages/react-navigation-plugin/vitest.setup.ts b/packages/react-navigation-plugin/vitest.setup.ts
new file mode 100644
index 00000000..97650fd9
--- /dev/null
+++ b/packages/react-navigation-plugin/vitest.setup.ts
@@ -0,0 +1,7 @@
+import { afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e2c85589..ba428887 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1037,6 +1037,15 @@ importers:
'@rozenite/vite-plugin':
specifier: workspace:*
version: link:../vite-plugin
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.1
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.1.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.11(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@types/react':
specifier: 'catalog:'
version: 19.2.14
diff --git a/website/src/docs/official-plugins/react-navigation.mdx b/website/src/docs/official-plugins/react-navigation.mdx
index ba94d0cf..9cd968c4 100644
--- a/website/src/docs/official-plugins/react-navigation.mdx
+++ b/website/src/docs/official-plugins/react-navigation.mdx
@@ -11,6 +11,7 @@ The React Navigation plugin provides comprehensive navigation debugging and insp
The React Navigation plugin is a powerful debugging tool that helps you inspect and debug navigation in your React Native application. It provides:
- **Action Timeline**: Track all navigation actions in real-time with detailed history
+- **Dispatch Origin**: See the source-mapped file and line where each action was dispatched from, with the full call stack
- **State Inspection**: View and analyze navigation state at any point in time
- **Time Travel Debugging**: Jump back to any previous navigation state
- **Deep Link Testing**: Test and validate deep links directly from DevTools
@@ -72,6 +73,17 @@ With [Rozenite for Web](/docs/rozenite-for-web), this plugin is available when y
Once configured, the React Navigation plugin will automatically appear in your React Native DevTools sidebar as "React Navigation". Click on it to access two main features:
+## Dispatch Origin
+
+Every captured action is annotated with where it was dispatched from. The detail panel shows a **Dispatch Origin** section above the action payload:
+
+- A headline summarising the dispatch call site, for example `Dispatched from handlePress in apps/playground/src/screens/Home.tsx:42`. A `low confidence` chip appears when the chosen frame is inside `node_modules/`.
+- The Metro code-frame snippet for the call site when available.
+- A collapsible full stack — application frames are bright, library frames (`node_modules/*`) are muted and tagged.
+- A **Copy raw** button copies the unmodified stack string as React Navigation provided it.
+
+The sidebar's action list shows a compact preview (`↳ Home.tsx:42`) so you can spot the source without opening each action. Symbolication runs against the Metro development server, so origins are only resolvable in development builds; release builds show "symbolication unavailable" and the raw stack remains accessible.
+
## Agent Integration
This plugin also exposes Agent tools under the `@rozenite/react-navigation-plugin` domain for LLM workflows.
@@ -89,6 +101,8 @@ Use `navigate` and `go-back` first for routine movement across stacks/tabs. Use
These tools allow read access to the current navigation state and recent action timeline (rolling in-memory history), plus mutation actions for navigation control and deep linking.
+Entries returned by `list-actions` include an `origin` payload (when React Navigation supplied a stack) with the source-mapped frames, the chosen origin frame, a confidence level, and the symbolication status. Agents should read `origin.originFrame` after confirming `origin.symbolicationStatus === 'complete'`.
+
## Contributing
The React Navigation plugin is open source and welcomes contributions! Check out the [Plugin Development Guide](../plugin-development/plugin-development.md) to learn how to contribute or create your own plugins.