From a90c982cfd82615c1b7730fa86bc78747ac33ed6 Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Tue, 9 Jun 2026 12:58:41 +0200 Subject: [PATCH 1/2] feat(redux-devtools-plugin): enable trace symbolication --- apps/playground/src/app/store.ts | 1 + packages/redux-devtools-plugin/README.md | 11 + .../__tests__/redux-devtools-agent.test.ts | 1 + .../src/redux-devtools-agent.ts | 29 ++ .../src/redux-devtools-registry.ts | 2 + packages/redux-devtools-plugin/src/runtime.ts | 123 +++++++- .../src/shared/agent-tools.ts | 2 + .../redux-devtools-plugin/src/shared/trace.ts | 36 +++ .../src/symbolication/__tests__/metro.test.ts | 98 ++++++ .../src/symbolication/__tests__/parse.test.ts | 34 ++ .../src/symbolication/metro.ts | 238 ++++++++++++++ .../src/symbolication/parse.ts | 84 +++++ .../src/symbolication/trace.ts | 74 +++++ .../src/ui/trace-tab.tsx | 297 ++++++++++++++++++ packages/redux-devtools-plugin/vite.config.ts | 8 + 15 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 packages/redux-devtools-plugin/src/shared/trace.ts create mode 100644 packages/redux-devtools-plugin/src/symbolication/__tests__/metro.test.ts create mode 100644 packages/redux-devtools-plugin/src/symbolication/__tests__/parse.test.ts create mode 100644 packages/redux-devtools-plugin/src/symbolication/metro.ts create mode 100644 packages/redux-devtools-plugin/src/symbolication/parse.ts create mode 100644 packages/redux-devtools-plugin/src/symbolication/trace.ts create mode 100644 packages/redux-devtools-plugin/src/ui/trace-tab.tsx diff --git a/apps/playground/src/app/store.ts b/apps/playground/src/app/store.ts index 83310a73..c23bfe96 100644 --- a/apps/playground/src/app/store.ts +++ b/apps/playground/src/app/store.ts @@ -14,6 +14,7 @@ const createCounterStore = (name: string) => rozeniteDevToolsEnhancer({ name, maxAge: 150, + trace: true, }), ), }); diff --git a/packages/redux-devtools-plugin/README.md b/packages/redux-devtools-plugin/README.md index 97c35dd3..a957e293 100644 --- a/packages/redux-devtools-plugin/README.md +++ b/packages/redux-devtools-plugin/README.md @@ -87,6 +87,17 @@ rozeniteDevToolsEnhancer({ }) ``` +`trace` captures the dispatch stack for each action and enables the Trace tab. Rozenite will also try to symbolicate the stack through Metro so React Native traces point to source files instead of generated bundle locations. + +```ts +rozeniteDevToolsEnhancer({ + trace: true, + traceLimit: 25, +}) +``` + +Set `traceSymbolication: false` to keep the raw stack without calling Metro's `/symbolicate` endpoint. + ### 3. Access DevTools Start your development server and open React Native DevTools. You'll find the "Redux DevTools" panel in the DevTools interface. diff --git a/packages/redux-devtools-plugin/src/__tests__/redux-devtools-agent.test.ts b/packages/redux-devtools-plugin/src/__tests__/redux-devtools-agent.test.ts index f373a100..fb5509d9 100644 --- a/packages/redux-devtools-plugin/src/__tests__/redux-devtools-agent.test.ts +++ b/packages/redux-devtools-plugin/src/__tests__/redux-devtools-agent.test.ts @@ -240,6 +240,7 @@ describe('redux devtools agent helpers', () => { }, state: { count: 1 }, error: null, + trace: null, }); }); diff --git a/packages/redux-devtools-plugin/src/redux-devtools-agent.ts b/packages/redux-devtools-plugin/src/redux-devtools-agent.ts index 029ae4b0..80d653cf 100644 --- a/packages/redux-devtools-plugin/src/redux-devtools-agent.ts +++ b/packages/redux-devtools-plugin/src/redux-devtools-agent.ts @@ -23,6 +23,8 @@ import { type ReduxDevToolsStoreInput as StoreInput, type ReduxDevToolsStoreSummary, } from './shared/agent-tools'; +import type { ReduxActionTrace, ReduxActionWithTrace } from './shared/trace'; +import { parseStack } from './symbolication/parse'; type AnyAction = Action & Record; const DEFAULT_PAGE_LIMIT = 50; @@ -152,6 +154,32 @@ const getStoreAndLiftedState = (instanceId?: string) => { return { store, enhancedStore, liftedState }; }; +const getActionTraceForAgent = ( + store: ReduxDevToolsStoreRegistration, + actionId: number, + liftedAction: unknown +): ReduxActionTrace | null => { + const registeredTrace = store.getActionTrace?.(actionId); + if (registeredTrace) { + return serializeForAgent(registeredTrace); + } + + const actionWithTrace = liftedAction as ReduxActionWithTrace | undefined; + if (actionWithTrace?.rozeniteTrace) { + return serializeForAgent(actionWithTrace.rozeniteTrace); + } + + if (typeof actionWithTrace?.stack === 'string') { + return { + rawStack: actionWithTrace.stack, + frames: parseStack(actionWithTrace.stack), + status: 'unavailable', + }; + } + + return null; +}; + const assertKnownAction = ( store: ReduxDevToolsStoreRegistration, liftedState: ReduxDevToolsLiftedState, @@ -275,6 +303,7 @@ export const getReduxActionDetailsResult = ({ liftedAction: serializeForAgent(liftedAction), state: serializeForAgent(computedState?.state), error: computedState?.error ?? null, + trace: getActionTraceForAgent(store, actionId, liftedAction), }; }; diff --git a/packages/redux-devtools-plugin/src/redux-devtools-registry.ts b/packages/redux-devtools-plugin/src/redux-devtools-registry.ts index 2f392dc4..c1ce0c00 100644 --- a/packages/redux-devtools-plugin/src/redux-devtools-registry.ts +++ b/packages/redux-devtools-plugin/src/redux-devtools-registry.ts @@ -1,5 +1,6 @@ import type { Action } from 'redux'; import type { EnhancedStore, LiftedState } from '@redux-devtools/instrument'; +import type { ReduxActionTrace } from './shared/trace'; type AnyAction = Action & Record; @@ -12,6 +13,7 @@ export type ReduxDevToolsStoreRegistration = { maxAge: number; getStore: () => ReduxDevToolsEnhancedStore | null; getLiftedState: () => ReduxDevToolsLiftedState | null; + getActionTrace?: (actionId: number) => ReduxActionTrace | null; }; const registry = new Map(); diff --git a/packages/redux-devtools-plugin/src/runtime.ts b/packages/redux-devtools-plugin/src/runtime.ts index e55173cc..a8e3d37b 100644 --- a/packages/redux-devtools-plugin/src/runtime.ts +++ b/packages/redux-devtools-plugin/src/runtime.ts @@ -18,10 +18,12 @@ import { subscribeToPanelCommands, } from './runtime-bridge'; import { registerReduxDevToolsStore } from './redux-devtools-registry'; +import type { ReduxActionTrace, ReduxActionWithTrace } from './shared/trace'; import type { ReduxDevToolsPanelCommand, ReduxDevToolsRequest, } from './shared/protocol'; +import { resolveReduxTrace } from './symbolication/trace'; const getRandomId = () => Math.random().toString(36).slice(2); const MAX_QUEUED_COMMANDS = 50; @@ -47,6 +49,31 @@ export interface RozeniteDevToolsOptions { * @default 50 */ maxAge?: number; + + /** + * Capture a JavaScript stack trace for each dispatched action. + * + * When enabled, Rozenite stores the raw Redux DevTools stack and attempts + * to symbolicate it through Metro so the Trace tab can show source files + * instead of generated bundle locations. + * + * @default false + */ + trace?: boolean | ((action: AnyAction) => string | undefined); + + /** + * Maximum number of stack frames captured for each action. + * + * @default 25 when trace is enabled + */ + traceLimit?: number; + + /** + * Attempt to source-map captured stacks through Metro's /symbolicate endpoint. + * + * @default true + */ + traceSymbolication?: boolean; } type AnyAction = Action; @@ -98,11 +125,16 @@ const createRuntimeController = ( const appInstanceId = getRandomId(); const instanceName = options.name?.trim() || 'Redux Store'; const maxAge = options.maxAge ?? 50; + const trace = options.trace; + const traceLimit = trace ? (options.traceLimit ?? 25) : options.traceLimit; + const traceSymbolication = options.traceSymbolication ?? true; let store: EnhancedStore | null = null; let monitored = false; let lastAction: string | undefined; const pendingCommands: ReduxDevToolsPanelCommand[] = []; + const tracesByActionId = new Map(); + const pendingTraceKeys = new Set(); const enqueueCommand = (command: ReduxDevToolsPanelCommand) => { if (pendingCommands.length >= MAX_QUEUED_COMMANDS) { @@ -135,6 +167,90 @@ const createRuntimeController = ( }); }; + const resolveTraceForAction = ( + actionId: number, + rawStack: string + ): ReduxActionTrace => { + const existingTrace = tracesByActionId.get(actionId); + if (existingTrace?.rawStack === rawStack) { + return existingTrace; + } + + const { initialTrace, pendingTrace } = resolveReduxTrace(rawStack, { + symbolicate: traceSymbolication, + }); + + tracesByActionId.set(actionId, initialTrace); + + const pendingTraceKey = `${actionId}:${rawStack}`; + + if (pendingTrace && !pendingTraceKeys.has(pendingTraceKey)) { + pendingTraceKeys.add(pendingTraceKey); + pendingTrace + .then((resolvedTrace) => { + const currentTrace = tracesByActionId.get(actionId); + if (currentTrace?.rawStack !== rawStack) { + return; + } + + tracesByActionId.set(actionId, resolvedTrace); + if (monitored) { + sendStateSnapshot(); + } + }) + .finally(() => { + pendingTraceKeys.delete(pendingTraceKey); + }); + } + + return initialTrace; + }; + + const decorateLiftedAction = ( + actionId: number, + liftedAction: PerformAction + ) => { + const rawStack = (liftedAction as ReduxActionWithTrace).stack; + + if (typeof rawStack !== 'string' || rawStack.length === 0) { + return liftedAction; + } + + return { + ...liftedAction, + rozeniteTrace: resolveTraceForAction(actionId, rawStack), + }; + }; + + const decorateLiftedState = ( + liftedState: LiftedState + ): LiftedState => { + let didChange = false; + const actionsById = Object.fromEntries( + Object.entries(liftedState.actionsById).map(([actionId, liftedAction]) => { + const decoratedAction = decorateLiftedAction( + Number(actionId), + liftedAction + ); + + if (decoratedAction !== liftedAction) { + didChange = true; + } + + return [actionId, decoratedAction]; + }) + ) as LiftedState['actionsById']; + + if (!didChange) { + return liftedState; + } + + return { + ...liftedState, + actionsById, + }; + }; + const sendStateSnapshot = (): void => { const liftedState = getLiftedStateRaw(); @@ -146,7 +262,7 @@ const createRuntimeController = ( type: 'STATE', name: instanceName, instanceId: appInstanceId, - payload: stringify(liftedState), + payload: stringify(decorateLiftedState(liftedState)), }); }; @@ -175,7 +291,7 @@ const createRuntimeController = ( name: instanceName, instanceId: appInstanceId, payload: stringify(store.getState()), - action: stringify(liftedAction), + action: stringify(decorateLiftedAction(nextActionId - 1, liftedAction)), nextActionId, maxAge, isExcess: liftedState.stagedActionIds.length >= maxAge, @@ -314,6 +430,8 @@ const createRuntimeController = ( ) => { store = instrument(monitorReducer, { maxAge, + trace, + traceLimit, shouldHotReload: true, shouldRecordChanges: true, pauseActionType: '@@PAUSED', @@ -339,6 +457,7 @@ const createRuntimeController = ( maxAge, getStore: () => store, getLiftedState: () => getLiftedStateRaw(), + getActionTrace: (actionId) => tracesByActionId.get(actionId) ?? null, }); store.subscribe(() => { diff --git a/packages/redux-devtools-plugin/src/shared/agent-tools.ts b/packages/redux-devtools-plugin/src/shared/agent-tools.ts index 42112cb0..0121d91e 100644 --- a/packages/redux-devtools-plugin/src/shared/agent-tools.ts +++ b/packages/redux-devtools-plugin/src/shared/agent-tools.ts @@ -2,6 +2,7 @@ import { defineAgentToolContract, type AgentToolContract, } from '@rozenite/agent-shared'; +import type { ReduxActionTrace } from './trace'; export const REDUX_DEVTOOLS_AGENT_PLUGIN_ID = '@rozenite/redux-devtools-plugin'; @@ -85,6 +86,7 @@ export type ReduxDevToolsGetActionDetailsResult = { liftedAction: unknown; state: unknown; error: unknown; + trace: ReduxActionTrace | null; }; export type ReduxDevToolsDispatchActionArgs = ReduxDevToolsDispatchActionInput; diff --git a/packages/redux-devtools-plugin/src/shared/trace.ts b/packages/redux-devtools-plugin/src/shared/trace.ts new file mode 100644 index 00000000..113dc7e6 --- /dev/null +++ b/packages/redux-devtools-plugin/src/shared/trace.ts @@ -0,0 +1,36 @@ +export type ReduxTraceFrame = { + functionName?: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + generatedUrl?: string; + generatedLineNumber?: number; + generatedColumnNumber?: number; + isCollapsed?: boolean; +}; + +export type ReduxTraceCodeFrame = { + fileName: string; + content: string; + line: number; + column: number; +}; + +export type ReduxTraceStatus = + | 'pending' + | 'complete' + | 'failed' + | 'unavailable'; + +export type ReduxActionTrace = { + rawStack: string; + frames: ReduxTraceFrame[]; + status: ReduxTraceStatus; + error?: string; + codeFrame?: ReduxTraceCodeFrame; +}; + +export type ReduxActionWithTrace = { + stack?: string; + rozeniteTrace?: ReduxActionTrace; +}; diff --git a/packages/redux-devtools-plugin/src/symbolication/__tests__/metro.test.ts b/packages/redux-devtools-plugin/src/symbolication/__tests__/metro.test.ts new file mode 100644 index 00000000..0b753095 --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/__tests__/metro.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NativeModules } from 'react-native'; +import { __resetMetroOriginCache, resolveMetroOrigin, symbolicateFrames } from '../metro'; + +vi.mock('react-native', () => ({ + NativeModules: { + SourceCode: { + scriptURL: undefined, + }, + }, +})); + +describe('redux trace Metro symbolication', () => { + beforeEach(() => { + __resetMetroOriginCache(); + NativeModules.SourceCode.scriptURL = undefined; + }); + + it('resolves the Metro origin from SourceCode.scriptURL', () => { + NativeModules.SourceCode.scriptURL = + 'http://10.0.2.2:8081/index.bundle?platform=android'; + + expect(resolveMetroOrigin()).toBe('http://10.0.2.2:8081'); + }); + + it('posts generated frames to Metro symbolicate and preserves original indexes', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + stack: [ + { + methodName: 'dispatchAction', + file: '/app/src/store.ts', + lineNumber: 12, + column: 3, + }, + ], + codeFrame: { + fileName: '/app/src/store.ts', + content: '\u001b[31mconst action = makeAction()\u001b[39m', + location: { row: 12, column: 3 }, + }, + }), + }); + + await expect( + symbolicateFrames( + [ + { generatedUrl: 'native', generatedLineNumber: 1, generatedColumnNumber: 1 }, + { + functionName: 'dispatchAction', + generatedUrl: 'http://localhost:8081/index.bundle', + generatedLineNumber: 100, + generatedColumnNumber: 20, + }, + ], + { origin: 'http://localhost:8081', fetch }, + ), + ).resolves.toEqual({ + status: 'complete', + frames: [ + { generatedUrl: 'native', generatedLineNumber: 1, generatedColumnNumber: 1 }, + { + functionName: 'dispatchAction', + url: '/app/src/store.ts', + lineNumber: 12, + columnNumber: 3, + generatedUrl: 'http://localhost:8081/index.bundle', + generatedLineNumber: 100, + generatedColumnNumber: 20, + isCollapsed: undefined, + }, + ], + codeFrame: { + fileName: '/app/src/store.ts', + content: 'const action = makeAction()', + line: 12, + column: 3, + }, + }); + + expect(fetch).toHaveBeenCalledWith('http://localhost:8081/symbolicate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stack: [ + { + methodName: 'dispatchAction', + file: 'http://localhost:8081/index.bundle', + lineNumber: 100, + column: 20, + }, + ], + }), + signal: expect.any(AbortSignal), + }); + }); +}); diff --git a/packages/redux-devtools-plugin/src/symbolication/__tests__/parse.test.ts b/packages/redux-devtools-plugin/src/symbolication/__tests__/parse.test.ts new file mode 100644 index 00000000..c205b655 --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/__tests__/parse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parseStack } from '../parse'; + +describe('parseStack', () => { + it('parses V8 function frames', () => { + expect( + parseStack( + 'Error\n at dispatchAction (http://localhost:8081/index.bundle:123:45)', + ), + ).toEqual([ + { + functionName: 'dispatchAction', + generatedUrl: 'http://localhost:8081/index.bundle', + generatedLineNumber: 123, + generatedColumnNumber: 45, + }, + ]); + }); + + it('parses JavaScriptCore frames', () => { + expect( + parseStack( + 'dispatchAction@http://localhost:8081/index.bundle?platform=ios:123:45', + ), + ).toEqual([ + { + functionName: 'dispatchAction', + generatedUrl: 'http://localhost:8081/index.bundle?platform=ios', + generatedLineNumber: 123, + generatedColumnNumber: 45, + }, + ]); + }); +}); diff --git a/packages/redux-devtools-plugin/src/symbolication/metro.ts b/packages/redux-devtools-plugin/src/symbolication/metro.ts new file mode 100644 index 00000000..2f0cba78 --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/metro.ts @@ -0,0 +1,238 @@ +import { NativeModules } from 'react-native'; +import type { ReduxTraceCodeFrame, ReduxTraceFrame } from '../shared/trace'; + +let cachedMetroOrigin: string | null | undefined; + +export const resolveMetroOrigin = (): string | null => { + if (cachedMetroOrigin !== undefined) { + return cachedMetroOrigin; + } + + const sourceCode = NativeModules?.SourceCode as + | { + scriptURL?: string; + getConstants?: () => { scriptURL?: string }; + } + | undefined; + const scriptURL = + sourceCode?.scriptURL ?? sourceCode?.getConstants?.().scriptURL; + + if (!scriptURL) { + cachedMetroOrigin = null; + return null; + } + + try { + const url = new URL(scriptURL); + 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; +}; + +type MetroSymbolicatedFrame = { + methodName: string; + file: string | null | undefined; + lineNumber: number | null | undefined; + column: number | null | undefined; + collapse?: boolean; +}; + +type MetroCodeFrame = { + fileName: string; + content: string; + line?: number; + column?: number; + location?: { row: number; column: number }; +}; + +type MetroSymbolicateResponse = { + stack: MetroSymbolicatedFrame[]; + codeFrame?: MetroCodeFrame; +}; + +export type SymbolicationOutcome = + | { + status: 'complete'; + frames: ReduxTraceFrame[]; + codeFrame?: ReduxTraceCodeFrame; + } + | { status: 'failed'; frames: ReduxTraceFrame[]; error: string } + | { status: 'unavailable'; frames: ReduxTraceFrame[] }; + +export type SymbolicateOptions = { + fetch?: typeof globalThis.fetch; + origin?: string | null; + timeoutMs?: number; +}; + +const DEFAULT_TIMEOUT_MS = 5000; + +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, ''); + +const isGeneratedBundleUrl = (url: string | undefined): boolean => + !!url && /[^/]+\.bundle(?:[/?#]|$)/.test(url); + +const isSymbolicatableUrl = (url: string | undefined): boolean => + url?.startsWith('http://') || url?.startsWith('https://') || false; + +const toMetroFrame = (frame: ReduxTraceFrame): MetroSymbolicatedFrame | null => { + if (!isSymbolicatableUrl(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: ReduxTraceFrame, +): ReduxTraceFrame => { + const sourceUrl = + metroFrame.file && + metroFrame.file !== original.generatedUrl && + !isGeneratedBundleUrl(metroFrame.file) + ? metroFrame.file + : undefined; + + const functionName = + metroFrame.methodName && !ANONYMOUS_METRO_METHODS.has(metroFrame.methodName) + ? metroFrame.methodName + : original.functionName; + + return { + functionName, + 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 = ( + codeFrame: MetroCodeFrame | undefined, +): ReduxTraceCodeFrame | undefined => { + if (!codeFrame) { + return undefined; + } + + const line = codeFrame.location?.row ?? codeFrame.line; + const column = codeFrame.location?.column ?? codeFrame.column; + if (line === undefined || column === undefined) { + return undefined; + } + + return { + fileName: codeFrame.fileName, + content: stripAnsi(codeFrame.content), + line, + column, + }; +}; + +export const symbolicateFrames = async ( + frames: ReduxTraceFrame[], + options: SymbolicateOptions = {}, +): Promise => { + const origin = + options.origin !== undefined ? options.origin : resolveMetroOrigin(); + if (!origin) { + return { status: 'unavailable', frames }; + } + + const entries = frames.flatMap((frame, originalIndex) => { + const metroFrame = toMetroFrame(frame); + return metroFrame ? [{ frame: metroFrame, originalIndex }] : []; + }); + + if (entries.length === 0) { + return { status: 'unavailable', frames }; + } + + const controller = new AbortController(); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await (options.fetch ?? globalThis.fetch)( + `${origin}/symbolicate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stack: entries.map((entry) => entry.frame) }), + 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 mappedFrames = [...frames]; + + data.stack.forEach((metroFrame, index) => { + const entry = entries[index]; + if (!entry) { + return; + } + + mappedFrames[entry.originalIndex] = fromMetroFrame( + metroFrame, + frames[entry.originalIndex], + ); + }); + + return { + status: 'complete', + frames: mappedFrames, + 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/redux-devtools-plugin/src/symbolication/parse.ts b/packages/redux-devtools-plugin/src/symbolication/parse.ts new file mode 100644 index 00000000..babf4cbc --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/parse.ts @@ -0,0 +1,84 @@ +import type { ReduxTraceFrame } from '../shared/trace'; + +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 = (functionName?: string) => { + const trimmed = functionName?.trim(); + + return trimmed && !ANONYMOUS_FUNCTION_NAMES.has(trimmed) + ? trimmed + : undefined; +}; + +const parseLocation = (location: string) => { + 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): ReduxTraceFrame | null => { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'Error') { + 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; + } + + return { + functionName: normalizeFunctionName(functionName), + generatedUrl: parsed.url, + generatedLineNumber: parsed.lineNumber, + generatedColumnNumber: parsed.columnNumber, + }; +}; + +export const parseStack = (rawStack: string): ReduxTraceFrame[] => + rawStack + .split('\n') + .map(parseLine) + .filter((frame): frame is ReduxTraceFrame => frame !== null) + .slice(0, STACK_FRAME_LIMIT); diff --git a/packages/redux-devtools-plugin/src/symbolication/trace.ts b/packages/redux-devtools-plugin/src/symbolication/trace.ts new file mode 100644 index 00000000..ef1ad1de --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/trace.ts @@ -0,0 +1,74 @@ +import type { ReduxActionTrace } from '../shared/trace'; +import { parseStack } from './parse'; +import { symbolicateFrames } from './metro'; + +export type ResolveReduxTraceOptions = { + symbolicate?: boolean; +}; + +export type ResolveReduxTraceResult = { + initialTrace: ReduxActionTrace; + pendingTrace?: Promise; +}; + +const traceCache = new Map(); + +export const resolveReduxTrace = ( + rawStack: string, + options: ResolveReduxTraceOptions = {}, +): ResolveReduxTraceResult => { + const cached = traceCache.get(rawStack); + if (cached) { + return { initialTrace: cached }; + } + + const frames = parseStack(rawStack); + const shouldSymbolicate = options.symbolicate ?? true; + + if (!shouldSymbolicate || frames.length === 0) { + return { + initialTrace: { + rawStack, + frames, + status: 'unavailable', + }, + }; + } + + const initialTrace: ReduxActionTrace = { + rawStack, + frames, + status: 'pending', + }; + + const pendingTrace = symbolicateFrames(frames).then((outcome) => { + const trace: ReduxActionTrace = + outcome.status === 'complete' + ? { + rawStack, + frames: outcome.frames, + status: 'complete', + codeFrame: outcome.codeFrame, + } + : outcome.status === 'failed' + ? { + rawStack, + frames: outcome.frames, + status: 'failed', + error: outcome.error, + } + : { + rawStack, + frames: outcome.frames, + status: 'unavailable', + }; + + if (trace.status === 'complete') { + traceCache.set(rawStack, trace); + } + + return trace; + }); + + return { initialTrace, pendingTrace }; +}; diff --git a/packages/redux-devtools-plugin/src/ui/trace-tab.tsx b/packages/redux-devtools-plugin/src/ui/trace-tab.tsx new file mode 100644 index 00000000..ce9dc66d --- /dev/null +++ b/packages/redux-devtools-plugin/src/ui/trace-tab.tsx @@ -0,0 +1,297 @@ +import type { CSSProperties } from 'react'; +import type { Action } from 'redux'; +import type { TabComponentProps } from '@redux-devtools/inspector-monitor'; +import type { + ReduxActionTrace, + ReduxActionWithTrace, + ReduxTraceFrame, +} from '../shared/trace'; + +type LiftedActionWithTrace> = { + action?: A; +} & ReduxActionWithTrace; + +const rootStyle: CSSProperties = { + padding: '8px 12px', + fontFamily: 'Consolas, Menlo, monospace', + fontSize: 12, + lineHeight: 1.45, + overflow: 'auto', + height: '100%', + boxSizing: 'border-box', +}; + +const mutedStyle: CSSProperties = { + color: '#8f9aa8', +}; + +const statusStyle: CSSProperties = { + marginBottom: 10, + color: '#8f9aa8', +}; + +const frameStyle: CSSProperties = { + padding: '5px 0', + borderBottom: '1px solid rgba(148, 163, 184, 0.18)', +}; + +const functionStyle: CSSProperties = { + color: '#d7dde7', +}; + +const appLocationStyle: CSSProperties = { + color: '#8bd5ff', + wordBreak: 'break-all', +}; + +const libraryLocationStyle: CSSProperties = { + color: '#9ca3af', + wordBreak: 'break-all', +}; + +const codeFrameStyle: CSSProperties = { + margin: '10px 0 12px', + padding: 10, + border: '1px solid rgba(148, 163, 184, 0.25)', + borderRadius: 6, + overflow: 'auto', + color: '#d7dde7', + background: 'rgba(15, 23, 42, 0.55)', +}; + +const detailsStyle: CSSProperties = { + marginTop: 12, +}; + +const summaryStyle: CSSProperties = { + cursor: 'pointer', + color: '#cbd5e1', +}; + +const rawStackStyle: CSSProperties = { + marginTop: 8, + whiteSpace: 'pre-wrap', + color: '#9ca3af', +}; + +const NODE_MODULES_PATTERN = /(?:^|\/)node_modules\//; + +const getLiftedAction = >({ + actions, + selectedActionId, + action, +}: TabComponentProps): LiftedActionWithTrace | undefined => { + if (selectedActionId != null) { + return actions[selectedActionId] as LiftedActionWithTrace | undefined; + } + + return Object.values(actions).find( + (liftedAction) => liftedAction.action === action, + ) as LiftedActionWithTrace | undefined; +}; + +const parseRawStackFallback = (rawStack: string): ReduxTraceFrame[] => + rawStack + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && line !== 'Error') + .slice(0, 50) + .map((line) => ({ + generatedUrl: line, + })); + +const getTrace = >( + liftedAction: LiftedActionWithTrace | undefined, +): ReduxActionTrace | undefined => { + if (liftedAction?.rozeniteTrace) { + return liftedAction.rozeniteTrace; + } + + if (typeof liftedAction?.stack === 'string') { + return { + rawStack: liftedAction.stack, + frames: parseRawStackFallback(liftedAction.stack), + status: 'unavailable', + }; + } + + return undefined; +}; + +const formatSourcePath = (url: string): string => { + const withoutQueryAndHash = url.split(/[?#]/)[0]; + const decoded = safeDecodeURIComponent(withoutQueryAndHash).replace( + /^file:\/\//, + '', + ); + const workspaceMatch = decoded.match(/(?:^|\/)((?:apps|packages|src)\/.+)$/); + + if (workspaceMatch) { + return workspaceMatch[1]; + } + + const bundleMatch = decoded.match(/([^/]+\.bundle)(?:\/|$)/); + if (bundleMatch) { + return bundleMatch[1]; + } + + try { + const parsed = new URL(url); + return parsed.pathname.split('/').filter(Boolean).pop() || parsed.hostname; + } catch { + const segments = decoded.split('/').filter(Boolean); + return segments.slice(-3).join('/') || decoded || url; + } +}; + +const safeDecodeURIComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const formatLocation = (frame: ReduxTraceFrame): string => { + const url = frame.url ?? frame.generatedUrl; + if (!url) { + return '(unknown source)'; + } + + const line = frame.url ? frame.lineNumber : frame.generatedLineNumber; + const column = frame.url ? frame.columnNumber : frame.generatedColumnNumber; + const parts = [formatSourcePath(url)]; + + if (line !== undefined) { + parts.push(String(line)); + } + + if (column !== undefined) { + parts.push(String(column)); + } + + return parts.join(':'); +}; + +const isLibraryFrame = (frame: ReduxTraceFrame): boolean => { + const source = frame.url ?? frame.generatedUrl; + return source ? NODE_MODULES_PATTERN.test(source) : true; +}; + +const getFunctionName = (frame: ReduxTraceFrame): string => + frame.functionName ?? '(anonymous function)'; + +const FrameList = ({ + frames, + dimLibraries, +}: { + frames: ReduxTraceFrame[]; + dimLibraries?: boolean; +}) => { + if (frames.length === 0) { + return
No stack frames could be parsed.
; + } + + return ( +
+ {frames.map((frame, index) => { + const isLibrary = isLibraryFrame(frame); + return ( +
+
{getFunctionName(frame)}
+
+ {formatLocation(frame)} +
+
+ ); + })} +
+ ); +}; + +const CodeFrame = ({ trace }: { trace: ReduxActionTrace }) => { + if (!trace.codeFrame) { + return null; + } + + return ( +
+      {trace.codeFrame.fileName}:{trace.codeFrame.line}:{trace.codeFrame.column}
+      {'\n\n'}
+      {trace.codeFrame.content}
+    
+ ); +}; + +const Status = ({ trace }: { trace: ReduxActionTrace }) => { + if (trace.status === 'pending') { + return
Symbolicating stack trace with Metro...
; + } + + if (trace.status === 'failed') { + return ( +
+ Could not source-map the stack via Metro: {trace.error} +
+ ); + } + + if (trace.status === 'unavailable') { + return ( +
+ Stack trace symbolication is unavailable for this action. +
+ ); + } + + return
Symbolicated stack trace
; +}; + +export const TraceTab = >( + props: TabComponentProps, +) => { + const liftedAction = getLiftedAction( + props as unknown as TabComponentProps, + ); + const trace = getTrace(liftedAction); + + if (!trace) { + return ( +
+ To enable tracing action calls, set the `trace` option to `true` for + `rozeniteDevToolsEnhancer`. +
+ ); + } + + const appFrames = trace.frames.filter((frame) => !isLibraryFrame(frame)); + const preferredFrames = appFrames.length > 0 ? appFrames : trace.frames; + + return ( +
+ + + + {preferredFrames.length !== trace.frames.length && ( +
+ + Full stack ({trace.frames.length} frames) + + +
+ )} +
+ Raw stack +
{trace.rawStack}
+
+
+ ); +}; + +export default TraceTab; diff --git a/packages/redux-devtools-plugin/vite.config.ts b/packages/redux-devtools-plugin/vite.config.ts index 896ec1e4..12c2140d 100644 --- a/packages/redux-devtools-plugin/vite.config.ts +++ b/packages/redux-devtools-plugin/vite.config.ts @@ -6,6 +6,14 @@ import { rozenitePlugin } from '@rozenite/vite-plugin'; export default defineConfig({ root: __dirname, plugins: [rozenitePlugin()], + resolve: { + alias: { + '@redux-devtools/inspector-monitor-trace-tab': resolve( + __dirname, + './src/ui/trace-tab.tsx', + ), + }, + }, test: { passWithNoTests: true, alias: { From c9ee5decad0686a2147f8262b74325bd011fef5b Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Tue, 9 Jun 2026 13:15:52 +0200 Subject: [PATCH 2/2] fix(redux-devtools-plugin): improve trace caching --- .changeset/calm-walls-trace.md | 5 ++ .../src/__tests__/runtime.test.ts | 87 +++++++++++++++++++ packages/redux-devtools-plugin/src/runtime.ts | 40 ++++++++- .../src/symbolication/trace.ts | 79 ++++++++++++----- 4 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 .changeset/calm-walls-trace.md create mode 100644 packages/redux-devtools-plugin/src/__tests__/runtime.test.ts diff --git a/.changeset/calm-walls-trace.md b/.changeset/calm-walls-trace.md new file mode 100644 index 00000000..9791f7f0 --- /dev/null +++ b/.changeset/calm-walls-trace.md @@ -0,0 +1,5 @@ +--- +"@rozenite/redux-devtools-plugin": minor +--- + +Add symbolicated Redux action traces in the Redux DevTools panel. diff --git a/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts b/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts new file mode 100644 index 00000000..f247ada6 --- /dev/null +++ b/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionCreators } from '@redux-devtools/instrument'; +import { createStore, type Action } from 'redux'; +import { rozeniteDevToolsEnhancer } from '../runtime'; +import { getReduxActionDetailsResult } from '../redux-devtools-agent'; +import { clearReduxDevToolsStoreRegistryForTests } from '../redux-devtools-registry'; +import type { ReduxDevToolsPanelCommand } from '../shared/protocol'; + +const bridge = vi.hoisted(() => ({ + panelCommandListener: undefined as + | ((command: ReduxDevToolsPanelCommand) => void) + | undefined, +})); + +vi.mock('../runtime-bridge', () => ({ + getRuntimeConnectionId: () => 'test-connection', + sendRuntimeMessage: vi.fn(), + subscribeToPanelCommands: vi.fn( + (listener: (command: ReduxDevToolsPanelCommand) => void) => { + bridge.panelCommandListener = listener; + + return vi.fn(); + } + ), +})); + +vi.mock('react-native', () => ({ + NativeModules: { + SourceCode: { + scriptURL: undefined, + }, + }, +})); + +type CounterAction = Action; + +const reducer = (state = 0, action: CounterAction) => { + switch (action.type) { + case 'counter/increment': + return state + 1; + case 'counter/decrement': + return state - 1; + default: + return state; + } +}; + +afterEach(() => { + clearReduxDevToolsStoreRegistryForTests(); + bridge.panelCommandListener = undefined; +}); + +describe('redux devtools runtime tracing', () => { + it('drops traces for actions removed from lifted history', () => { + const store = createStore( + reducer, + rozeniteDevToolsEnhancer({ + maxAge: 2, + trace: (action) => + `Error\n at ${action.type} (http://localhost:8081/index.bundle:1:1)`, + traceSymbolication: false, + }) + ); + + bridge.panelCommandListener?.({ type: 'start' }); + + store.dispatch({ type: 'counter/increment' }); + expect(getReduxActionDetailsResult({ actionId: 1 }).trace).toEqual( + expect.objectContaining({ + rawStack: expect.stringContaining('counter/increment'), + }) + ); + + ( + store as typeof store & { + liftedStore: { dispatch: (action: unknown) => unknown }; + } + ).liftedStore.dispatch(ActionCreators.commit()); + store.dispatch({ type: 'counter/decrement' }); + + expect(getReduxActionDetailsResult({ actionId: 1 }).trace).toEqual( + expect.objectContaining({ + rawStack: expect.stringContaining('counter/decrement'), + }) + ); + }); +}); diff --git a/packages/redux-devtools-plugin/src/runtime.ts b/packages/redux-devtools-plugin/src/runtime.ts index a8e3d37b..b2f501b6 100644 --- a/packages/redux-devtools-plugin/src/runtime.ts +++ b/packages/redux-devtools-plugin/src/runtime.ts @@ -27,6 +27,7 @@ import { resolveReduxTrace } from './symbolication/trace'; const getRandomId = () => Math.random().toString(36).slice(2); const MAX_QUEUED_COMMANDS = 50; +const TRACE_SNAPSHOT_DEBOUNCE_MS = 50; const STORE_SENTINEL = Symbol.for('rozenite.redux-devtools.store-sentinel'); /** @@ -135,6 +136,7 @@ const createRuntimeController = ( const pendingCommands: ReduxDevToolsPanelCommand[] = []; const tracesByActionId = new Map(); const pendingTraceKeys = new Set(); + let traceSnapshotTimer: ReturnType | null = null; const enqueueCommand = (command: ReduxDevToolsPanelCommand) => { if (pendingCommands.length >= MAX_QUEUED_COMMANDS) { @@ -167,6 +169,36 @@ const createRuntimeController = ( }); }; + const scheduleTraceSnapshot = (): void => { + if (!monitored || traceSnapshotTimer) { + return; + } + + traceSnapshotTimer = setTimeout(() => { + traceSnapshotTimer = null; + + if (monitored) { + sendStateSnapshot(); + } + }, TRACE_SNAPSHOT_DEBOUNCE_MS); + }; + + const pruneActionTraces = ( + liftedState: LiftedState + ): void => { + const actionIds = new Set(liftedState.stagedActionIds); + + tracesByActionId.forEach((trace, actionId) => { + const liftedAction = liftedState.actionsById[ + actionId + ] as ReduxActionWithTrace | undefined; + + if (!actionIds.has(actionId) || liftedAction?.stack !== trace.rawStack) { + tracesByActionId.delete(actionId); + } + }); + }; + const resolveTraceForAction = ( actionId: number, rawStack: string @@ -194,9 +226,7 @@ const createRuntimeController = ( } tracesByActionId.set(actionId, resolvedTrace); - if (monitored) { - sendStateSnapshot(); - } + scheduleTraceSnapshot(); }) .finally(() => { pendingTraceKeys.delete(pendingTraceKey); @@ -225,6 +255,8 @@ const createRuntimeController = ( const decorateLiftedState = ( liftedState: LiftedState ): LiftedState => { + pruneActionTraces(liftedState); + let didChange = false; const actionsById = Object.fromEntries( Object.entries(liftedState.actionsById).map(([actionId, liftedAction]) => { @@ -276,6 +308,8 @@ const createRuntimeController = ( return; } + pruneActionTraces(liftedState); + const nextActionId = liftedState.nextActionId; const liftedAction = liftedState.actionsById[ nextActionId - 1 diff --git a/packages/redux-devtools-plugin/src/symbolication/trace.ts b/packages/redux-devtools-plugin/src/symbolication/trace.ts index ef1ad1de..106ceec0 100644 --- a/packages/redux-devtools-plugin/src/symbolication/trace.ts +++ b/packages/redux-devtools-plugin/src/symbolication/trace.ts @@ -12,6 +12,21 @@ export type ResolveReduxTraceResult = { }; const traceCache = new Map(); +const pendingTraceCache = new Map>(); +const MAX_TRACE_CACHE_SIZE = 500; + +const setCachedTrace = (rawStack: string, trace: ReduxActionTrace): void => { + traceCache.set(rawStack, trace); + + if (traceCache.size <= MAX_TRACE_CACHE_SIZE) { + return; + } + + const oldestKey = traceCache.keys().next().value; + if (oldestKey) { + traceCache.delete(oldestKey); + } +}; export const resolveReduxTrace = ( rawStack: string, @@ -35,40 +50,58 @@ export const resolveReduxTrace = ( }; } + const pendingTrace = pendingTraceCache.get(rawStack); + if (pendingTrace) { + return { + initialTrace: { + rawStack, + frames, + status: 'pending', + }, + pendingTrace, + }; + } + const initialTrace: ReduxActionTrace = { rawStack, frames, status: 'pending', }; - const pendingTrace = symbolicateFrames(frames).then((outcome) => { - const trace: ReduxActionTrace = - outcome.status === 'complete' - ? { - rawStack, - frames: outcome.frames, - status: 'complete', - codeFrame: outcome.codeFrame, - } - : outcome.status === 'failed' + const nextPendingTrace = symbolicateFrames(frames) + .then((outcome) => { + const trace: ReduxActionTrace = + outcome.status === 'complete' ? { rawStack, frames: outcome.frames, - status: 'failed', - error: outcome.error, + status: 'complete', + codeFrame: outcome.codeFrame, } - : { - rawStack, - frames: outcome.frames, - status: 'unavailable', - }; + : outcome.status === 'failed' + ? { + rawStack, + frames: outcome.frames, + status: 'failed', + error: outcome.error, + } + : { + rawStack, + frames: outcome.frames, + status: 'unavailable', + }; + + if (trace.status === 'complete') { + setCachedTrace(rawStack, trace); + } - if (trace.status === 'complete') { - traceCache.set(rawStack, trace); - } + return trace; + }) + .finally(() => { + pendingTraceCache.delete(rawStack); + }); - return trace; - }); + pendingTraceCache.set(rawStack, nextPendingTrace); - return { initialTrace, pendingTrace }; + return { initialTrace, pendingTrace: nextPendingTrace }; };