From ff32792faaf502b5ad6b2015e1f75a75a13e7161 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 18 May 2026 16:02:10 +0200 Subject: [PATCH 1/5] feat(react-navigation-plugin): add stack-trace symbolication utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `react-native/symbolication/` module preparing the ground for dispatch-origin inspection on navigation actions. Pure utility code, not wired into the action capture pipeline yet — that integration lands in a follow-up commit so the data plumbing and the bridge contract can be reviewed in isolation. Module layout: - `types.ts` — `ActionStackFrame`, `ActionOrigin`, `SymbolicationStatus`, `OriginConfidence`. A frame carries both `url/lineNumber/columnNumber` (source-mapped, optional) and `generatedUrl/lineNumber/columnNumber` (bundle, always set after parse). The symmetric shape lets the renderer show either layer and lets Metro fill in the source one asynchronously. - `parse.ts` — `parseStack(rawStack)` splits a raw stack string and parses each line via V8 (`at fn (file:line:col)`, `at file:line:col`) and JSC (`fn@file:line:col`) regex matchers. Drops `` / `anonymous` / `` function-name markers, ignores blank or malformed lines, caps the result at 50 frames as a safety bound against pathological stacks. Each parsed frame starts with only the generated location set — source-mapped fields fill in later. - `rank.ts` — `classifyFrame(url)` returns `'app' | 'library' | 'unknown'` by checking whether the path contains `node_modules/` (anchored or slash-prefixed). `pickOriginFrame(frames)` returns the best origin frame with a three-state confidence: - `'high'` — at least one app frame found - `'low'` — no app frame; falls back to the first source-mapped one - `'none'` — no source-mapped frames at all (Metro symbolicated nothing, or symbolication hasn't run yet) - `cache.ts` — `createSymbolicationCache(capacity)` returns a small LRU keyed on the raw stack string. Apps dispatch from a small set of callsites; capacity 256 holds the fingerprints of a wildly diverse app. Built on `Map`'s insertion-order semantics — delete + reinsert on hit to bump recency, no separate linked list needed. - `metro.ts` — `resolveMetroOrigin()` reads `NativeModules.SourceCode.scriptURL` and returns the http(s) origin, or `null` for `file://` schemes (release builds) and missing values. The resolution is cached at module load; tests reset via `__resetMetroOriginCache`. `symbolicateFrames(frames, options)` POSTs the frames' generated locations to `/symbolicate`, parses the Metro response, maps the result back onto our frames preserving the generated location alongside the new source one, and strips ANSI escape sequences from the returned code-frame content (Metro formats it for terminals). Accepts injected `fetch` and `timeoutMs` for testability; uses `AbortController` for the timeout. Handles both the legacy top-level `line/column` and the newer `location: { row, column }` shapes of Metro's `codeFrame` response. - `format.ts` — `formatSourcePath(url)` extracts the meaningful suffix for display (workspace roots `apps/`, `packages/`, `src/`; Metro bundle filename; URL last-segment fallback). `formatFrameLocation(frame)` produces a `path:line:column` string, preferring the source-mapped location and falling back to the generated one. - `index.ts` — public re-exports of the surface the integration layer will consume. Test infrastructure (the plugin had none): - Adds `vitest`-driven testing via `vite.config.ts`'s `test` block: `setupFiles`, an `@rozenite/agent-shared` alias (same as the network plugin's setup), and `passWithNoTests`. - New `vitest.setup.ts` registers RTL cleanup and `jest-dom` matchers for future UI tests. - Adds `@testing-library/dom`, `@testing-library/jest-dom`, `@testing-library/react` devDependencies. `vitest` and `jsdom` are hoisted at the workspace root, no per-package install needed. - Adds `test` script (`vitest --run --passWithNoTests`). 38 tests covering parse / rank / cache / metro / format. The Metro test mocks `react-native` via `vi.hoisted` so `NativeModules.SourceCode.scriptURL` can be varied per test; the symbolicate function is exercised through its `origin` and `fetch` options so the network seam stays explicit. --- packages/react-navigation-plugin/package.json | 6 +- .../symbolication/__tests__/cache.test.ts | 39 ++++ .../symbolication/__tests__/format.test.ts | 61 +++++ .../symbolication/__tests__/metro.test.ts | 210 +++++++++++++++++ .../symbolication/__tests__/parse.test.ts | 96 ++++++++ .../symbolication/__tests__/rank.test.ts | 86 +++++++ .../src/react-native/symbolication/cache.ts | 48 ++++ .../src/react-native/symbolication/format.ts | 53 +++++ .../src/react-native/symbolication/index.ts | 23 ++ .../src/react-native/symbolication/metro.ts | 214 ++++++++++++++++++ .../src/react-native/symbolication/parse.ts | 87 +++++++ .../src/react-native/symbolication/rank.ts | 33 +++ .../src/react-native/symbolication/types.ts | 35 +++ .../react-navigation-plugin/vite.config.ts | 8 + .../react-navigation-plugin/vitest.setup.ts | 7 + pnpm-lock.yaml | 9 + 16 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/__tests__/cache.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/__tests__/format.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/__tests__/metro.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/__tests__/parse.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/__tests__/rank.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/cache.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/format.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/index.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/metro.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/parse.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/rank.ts create mode 100644 packages/react-navigation-plugin/src/react-native/symbolication/types.ts create mode 100644 packages/react-navigation-plugin/vitest.setup.ts 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/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..c0184d49 --- /dev/null +++ b/packages/react-navigation-plugin/src/react-native/symbolication/__tests__/metro.test.ts @@ -0,0 +1,210 @@ +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, +})); + +vi.mock('react-native', () => ({ + NativeModules: { + get SourceCode() { + return { scriptURL: mockScriptURL.value }; + }, + }, +})); + +import { + __resetMetroOriginCache, + resolveMetroOrigin, + symbolicateFrames, +} from '../metro'; +import type { ActionStackFrame } from '../types'; + +beforeEach(() => { + __resetMetroOriginCache(); + mockScriptURL.value = 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); + }); +}); + +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: ' 41 | foo();', + 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..77e3bbb2 --- /dev/null +++ b/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts @@ -0,0 +1,214 @@ +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; + const scriptURL = NativeModules?.SourceCode?.scriptURL as string | undefined; + 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/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 From 7c53229f4bf48a3ea74ff4d8c8518056e360ec8f Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 19 May 2026 13:30:30 +0200 Subject: [PATCH 2/5] feat(react-navigation-plugin): emit symbolicated dispatch origin for navigation actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the symbolication utilities into the capture pipeline. Each captured action now carries an `origin` payload (raw stack, parsed frames, chosen origin frame with a confidence level, symbolication status). Cache hits emit a single `action` event with `status: 'complete'`; cache misses emit `action` with `status: 'pending'` synchronously, then a follow-up `action-symbolicated` event once Metro resolves (or times out / fails / is unavailable). The `stack: string` field is removed from the wire and agent shapes — its content lives at `origin.rawStack`. --- .../useReactNavigationEvents.test.ts | 239 ++++++++++++++++++ .../src/react-native/dispatchOrigin.ts | 107 ++++++++ .../src/react-native/index.ts | 40 ++- .../react-native/useReactNavigationEvents.ts | 104 +++++--- .../src/shared/agent-tools.ts | 16 +- .../src/shared/index.ts | 11 +- 6 files changed, 464 insertions(+), 53 deletions(-) create mode 100644 packages/react-navigation-plugin/src/react-native/__tests__/useReactNavigationEvents.test.ts create mode 100644 packages/react-navigation-plugin/src/react-native/dispatchOrigin.ts 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/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; }; From 37ad5b74c7e0f3872b764d97ed54bf3bd62c4d66 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 19 May 2026 14:46:06 +0200 Subject: [PATCH 3/5] fix(react-navigation-plugin): resolve Metro origin under TurboModules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the New Architecture, `NativeModules.SourceCode` is a lazy TurboModule whose constants don't materialize as direct properties on the module object — `scriptURL` reads `undefined`. Fall back to `getConstants().scriptURL` when the direct read returns nothing, preserving the legacy property-access path for older runtimes. Without this, every captured navigation action ended up with `symbolicationStatus: 'unavailable'` on New-Arch apps (e.g. Expo SDK 50+). --- .../symbolication/__tests__/metro.test.ts | 13 ++++++++++++- .../src/react-native/symbolication/metro.ts | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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 index c0184d49..320c98cc 100644 --- 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 @@ -5,12 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // `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 }; + return { + scriptURL: mockScriptURL.value, + getConstants: () => ({ scriptURL: mockScriptURL.getConstantsValue }), + }; }, }, })); @@ -25,6 +29,7 @@ import type { ActionStackFrame } from '../types'; beforeEach(() => { __resetMetroOriginCache(); mockScriptURL.value = undefined; + mockScriptURL.getConstantsValue = undefined; }); describe('resolveMetroOrigin', () => { @@ -51,6 +56,12 @@ describe('resolveMetroOrigin', () => { 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 = ( diff --git a/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts b/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts index 77e3bbb2..e4881e3f 100644 --- a/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts +++ b/packages/react-navigation-plugin/src/react-native/symbolication/metro.ts @@ -8,7 +8,18 @@ let cachedMetroOrigin: string | null | undefined; // via `__resetMetroOriginCache` between cases. export const resolveMetroOrigin = (): string | null => { if (cachedMetroOrigin !== undefined) return cachedMetroOrigin; - const scriptURL = NativeModules?.SourceCode?.scriptURL as string | undefined; + // 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; From 7d5aa04e71f55b77364d0bab2c2dae36c164f3b3 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 19 May 2026 14:46:38 +0200 Subject: [PATCH 4/5] feat(react-navigation-plugin): show dispatch origin in DevTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the captured action origin in two places: - ActionDetailPanel grows a new "Dispatch Origin" section above the Action Payload — confidence-aware headline ("Dispatched from in " / unresolved / pending / failed / unavailable), an optional Metro code-frame snippet, the full parsed stack collapsed behind a toggle (library frames muted, the chosen origin frame highlighted), and a Copy raw button. - ActionItem shows a compact preview line in the sidebar: `↳ filename.tsx:line:col` for resolved origins (italic on low confidence), `↳ Resolving…` while symbolication is in-flight, and nothing for unresolved / failed / unavailable. The full path is available as a hover tooltip and in the detail panel. The devtools-ui state grew an `id` and `origin` field per entry, plus a new bridge listener for `action-symbolicated` that merges the resolved origin into the matching history entry by id. ActionDetailPanel is keyed by selected-action so per-action UI state resets cleanly when switching between actions. --- .../components/ActionDetailPanel.tsx | 6 + .../src/devtools-ui/components/ActionItem.tsx | 38 +++- .../src/devtools-ui/components/ActionList.tsx | 6 +- .../devtools-ui/components/ActionTimeline.tsx | 2 + .../components/DispatchOriginSection.tsx | 213 ++++++++++++++++++ .../components/__tests__/ActionItem.test.tsx | 121 ++++++++++ .../__tests__/DispatchOriginSection.test.tsx | 199 ++++++++++++++++ .../src/devtools-ui/index.tsx | 11 +- 8 files changed, 591 insertions(+), 5 deletions(-) create mode 100644 packages/react-navigation-plugin/src/devtools-ui/components/DispatchOriginSection.tsx create mode 100644 packages/react-navigation-plugin/src/devtools-ui/components/__tests__/ActionItem.test.tsx create mode 100644 packages/react-navigation-plugin/src/devtools-ui/components/__tests__/DispatchOriginSection.test.tsx 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)), + ); }), ]; From 5110d9a880712836e7c0bddd801977050069b4a1 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 19 May 2026 14:49:15 +0200 Subject: [PATCH 5/5] chore: changeset and docs for navigation stack-trace inspection --- .../react-navigation-stack-trace-inspection.md | 7 +++++++ .../src/docs/official-plugins/react-navigation.mdx | 14 ++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .changeset/react-navigation-stack-trace-inspection.md 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/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.