feat(react-navigation-plugin): inspect navigation action dispatch origin#282
Open
burczu wants to merge 5 commits into
Open
Conversation
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.
607c597 to
5110d9a
Compare
V3RON
reviewed
May 19, 2026
Contributor
There was a problem hiding this comment.
Isn't it easier to symbolicate on the DevTools UI side where the URL is well-known? 👀
Contributor
Author
There was a problem hiding this comment.
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!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
__unsafe_action__event, parses it, and source-maps it via Metro's/symbolicateendpoint on the React Native side — both the DevTools UI and the agent'slist-actionstool see the same enrichedoriginpayload (no UI-vs-agent asymmetry).↳ filename.tsx:line:colpreview per action.actionwithstatus: 'pending'synchronously and a follow-upaction-symbolicatedevent when Metro resolves; cache hit emits a single complete event. 5-second Metro timeout, 256-entry LRU keyed on raw stack string, no retries.Wire shape change worth flagging:
stack: stringonReactNavigationPluginActionMessageandNavigationActionHistoryEntryis replaced byorigin?: ActionOrigin(raw stack now lives atorigin.rawStack). The agent contract update is noted in the changeset.Test plan
pnpm --filter @rozenite/react-navigation-plugin typecheckcleanpnpm --filter @rozenite/react-navigation-plugin lintcleanpnpm --filter @rozenite/react-navigation-plugin test— 59/59 passing (38 unit + 3 capture-pipeline integration + 16 UI + 2 added during smoke)actionevent with no follow-upStack trace symbolication is unavailable …with raw stack still accessible