Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/react-navigation-stack-trace-inspection.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 5 additions & 1 deletion packages/react-navigation-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -28,10 +31,13 @@ const jsonTreeTheme = {
export const ActionDetailPanel = ({
action,
state,
origin,
}: ActionDetailPanelProps) => {
return (
<div className="flex-1 overflow-auto bg-gray-900">
<div className="p-4">
<DispatchOriginSection origin={origin} />

<div className="mb-6">
<h3 className="mb-3 text-base font-bold text-gray-100">
Action Payload
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<div className="mt-1 text-xs italic text-gray-500">↳ Resolving…</div>
);
}
if (origin.symbolicationStatus !== 'complete') return null;
if (origin.confidence === 'none') return null;
const location = formatFrameLocation(origin.originFrame);
if (!location) return null;
return (
<div
className={`mt-1 truncate font-mono text-xs text-gray-500 ${
origin.confidence === 'low' ? 'italic' : ''
}`}
title={location}
>
↳ {shortenForSidebar(location)}
</div>
);
};

export const ActionItem = ({
action,
origin,
index,
isSelected,
onSelect,
Expand All @@ -39,7 +73,7 @@ export const ActionItem = ({

return (
<div
className={`m-1 p-3 rounded cursor-pointer transition-all duration-200 border ${
className={`m-1 p-3 rounded cursor-pointer transition-all duration-200 border overflow-hidden ${
isSelected
? 'bg-blue-900/30 border-blue-500'
: 'bg-gray-800 border-gray-700 hover:bg-gray-700'
Expand Down Expand Up @@ -69,6 +103,8 @@ export const ActionItem = ({
{actionName && (
<div className="text-xs text-gray-300">→ {actionName}</div>
)}

<OriginPreview origin={origin} />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -20,7 +23,7 @@ export const ActionList = ({
onGoToAction,
}: ActionListProps) => {
return (
<div className="flex-1 overflow-auto">
<div className="min-w-0 flex-1 overflow-auto">
{actionHistory.length === 0 ? (
<div className="p-4 text-center text-gray-400">
No actions recorded yet
Expand All @@ -31,6 +34,7 @@ export const ActionList = ({
<ActionItem
key={index}
action={entry.action}
origin={entry.origin}
index={index}
isSelected={selectedActionIndex === index}
onSelect={() => onActionSelect(index)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export const ActionTimeline = ({

{selectedEntry ? (
<ActionDetailPanel
key={selectedActionIndex}
action={selectedEntry.action}
state={selectedEntry.state}
origin={selectedEntry.origin}
/>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 bg-gray-900">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => (
<svg
className="h-4 w-4 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
className="opacity-25"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
strokeWidth="3"
className="opacity-75"
/>
</svg>
);

const Headline = ({ origin }: { origin: ActionOrigin }) => {
if (origin.symbolicationStatus === 'pending') {
return (
<div className="flex items-center gap-2 text-gray-300">
<Spinner />
<span>Resolving origin from Metro…</span>
</div>
);
}
if (origin.symbolicationStatus === 'unavailable') {
return (
<div className="text-gray-400">
Stack trace symbolication is unavailable (production build or Metro
disconnected).
</div>
);
}
if (origin.symbolicationStatus === 'failed') {
return (
<div className="text-gray-300">
<div>Could not source-map the stack via Metro.</div>
{origin.symbolicationError && (
<div className="mt-1 text-xs text-gray-500">
{origin.symbolicationError}
</div>
)}
</div>
);
}
if (origin.confidence === 'none') {
return (
<div className="text-orange-400">Could not resolve dispatch origin.</div>
);
}
const location = formatFrameLocation(origin.originFrame);
const fn = origin.originFrame?.functionName ?? '<anonymous>';
return (
<div className="flex flex-wrap items-center gap-2">
<div className="text-gray-100">
Dispatched from <code className="font-mono text-blue-300">{fn}</code> in{' '}
<code className="font-mono text-blue-300">
{location ?? 'unknown location'}
</code>
</div>
{origin.confidence === 'low' && (
<span className="rounded border border-yellow-700 bg-yellow-900/40 px-2 py-0.5 text-xs text-yellow-300">
low confidence
</span>
)}
</div>
);
};

const CodeFrame = ({ origin }: { origin: ActionOrigin }) => {
if (!origin.codeFrame) return null;
return (
<pre
className="mt-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-gray-300"
data-testid="dispatch-origin-code-frame"
>
{origin.codeFrame.content}
</pre>
);
};

const StackFrame = ({
frame,
isOrigin,
}: {
frame: ActionStackFrame;
isOrigin: boolean;
}) => {
const cls = classifyFrame(frame.url);
const location = formatFrameLocation(frame);
const fn = frame.functionName ?? '<anonymous>';
return (
<li
className={`flex flex-wrap items-baseline gap-x-2 rounded-sm px-2 py-0.5 ${
isOrigin ? 'border-l-2 border-blue-500 bg-gray-900' : ''
} ${cls === 'library' ? 'text-gray-500' : 'text-gray-300'}`}
>
<span className="font-mono">
{location ?? frame.generatedUrl ?? '(no location)'}
</span>
<span className="text-gray-500">—</span>
<span className="font-mono">{fn}</span>
{cls === 'library' && (
<span className="rounded border border-gray-700 px-1 text-[10px] text-gray-500">
library
</span>
)}
</li>
);
};

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 (
<section className="mb-6">
<h3 className="mb-3 text-base font-bold text-gray-100">
Dispatch Origin
</h3>
<div className="rounded border border-gray-700 bg-gray-800 p-3 text-sm text-gray-400">
No stack trace captured for this action.
</div>
</section>
);
}

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 (
<section className="mb-6">
<header className="mb-3 flex items-center justify-between">
<h3 className="text-base font-bold text-gray-100">Dispatch Origin</h3>
<button
type="button"
onClick={copyRaw}
className="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-700"
title="Copy raw stack"
>
{copied ? 'Copied' : 'Copy raw'}
</button>
</header>

<div className="rounded border border-gray-700 bg-gray-800 p-3 text-sm">
<Headline origin={origin} />
{origin.symbolicationStatus === 'complete' && (
<CodeFrame origin={origin} />
)}

{origin.frames.length > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => setIsStackExpanded((prev) => !prev)}
className="flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-gray-200"
aria-expanded={isStackExpanded}
>
<span>{isStackExpanded ? '▾' : '▸'}</span>
<span>
Full stack ({origin.frames.length}{' '}
{origin.frames.length === 1 ? 'frame' : 'frames'})
</span>
</button>
{isStackExpanded && (
<ul className="mt-2 space-y-0.5 text-xs">
{origin.frames.map((frame, idx) => (
<StackFrame
key={idx}
frame={frame}
isOrigin={origin.originFrame === frame}
/>
))}
</ul>
)}
</div>
)}
</div>
</section>
);
};
Loading
Loading