Skip to content

feat(react-navigation-plugin): inspect navigation action dispatch origin#282

Open
burczu wants to merge 5 commits into
callstackincubator:mainfrom
burczu:feat/react-navigation-plugin-stack-trace-inspection
Open

feat(react-navigation-plugin): inspect navigation action dispatch origin#282
burczu wants to merge 5 commits into
callstackincubator:mainfrom
burczu:feat/react-navigation-plugin-stack-trace-inspection

Conversation

@burczu
Copy link
Copy Markdown
Contributor

@burczu burczu commented May 19, 2026

Summary

  • Captures the stack React Navigation provides on every __unsafe_action__ event, parses it, and source-maps it via Metro's /symbolicate endpoint on the React Native side — both the DevTools UI and the agent's list-actions tool see the same enriched origin payload (no UI-vs-agent asymmetry).
  • DevTools detail panel grows a new Dispatch Origin section (confidence-aware headline, optional code-frame snippet, collapsible full stack with library frames muted, Copy raw button). The sidebar action list shows a compact ↳ filename.tsx:line:col preview per action.
  • Streaming emission with cache-hit fast-path: cache miss emits action with status: 'pending' synchronously and a follow-up action-symbolicated event when Metro resolves; cache hit emits a single complete event. 5-second Metro timeout, 256-entry LRU keyed on raw stack string, no retries.
  • Closes #253.

Wire shape change worth flagging: stack: string on ReactNavigationPluginActionMessage and NavigationActionHistoryEntry is replaced by origin?: ActionOrigin (raw stack now lives at origin.rawStack). The agent contract update is noted in the changeset.

Test plan

  • pnpm --filter @rozenite/react-navigation-plugin typecheck clean
  • pnpm --filter @rozenite/react-navigation-plugin lint clean
  • pnpm --filter @rozenite/react-navigation-plugin test — 59/59 passing (38 unit + 3 capture-pipeline integration + 16 UI + 2 added during smoke)
  • Prettier check clean on touched files
  • Manual smoke in Expo playground (New Architecture): dispatch origin resolves to source-mapped paths, sidebar preview shows basename + line:col, full-stack toggle distinguishes app vs library frames, code-frame snippet renders when Metro returns one matching the chosen origin
  • Cache hit verified — repeat dispatch from same callsite emits a single complete action event with no follow-up
  • Reviewer smoke against a non-Expo bare RN app (TurboModule discovery path covered by unit test; would be nice to confirm in the wild)
  • Reviewer eyeball on release-build behavior — should show Stack trace symbolication is unavailable … with raw stack still accessible

burczu added 5 commits May 19, 2026 14:57
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>` /
  `anonymous` / `<unknown>` 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 `<origin>/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.
…navigation actions

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`.
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+).
Render the captured action origin in two places:

- ActionDetailPanel grows a new "Dispatch Origin" section above the
  Action Payload — confidence-aware headline ("Dispatched from <fn> in
  <file:line>" / 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.
@burczu burczu force-pushed the feat/react-navigation-plugin-stack-trace-inspection branch from 607c597 to 5110d9a Compare May 19, 2026 12:57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it easier to symbolicate on the DevTools UI side where the URL is well-known? 👀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main motivation was symmetric consumers — both the DevTools UI and agent tools (list-actions) should get the same pre-symbolicated ActionOrigin. UI-side symbolication means agents get raw bundle URLs while the UI sees source-mapped frames. Metro URL discovery is a secondary constraint: on Android the bundle URL is 10.0.2.2:8081, not localhost, so the DevTools browser can't reliably derive it. Happy to discuss if you see a way to keep both consumers symmetric from the UI side!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add stack trace inspection to the React Navigation plugin

2 participants