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/apps/playground/src/app/screens/NetworkTestScreen.tsx b/apps/playground/src/app/screens/NetworkTestScreen.tsx index 7eeeb011..aed924f3 100644 --- a/apps/playground/src/app/screens/NetworkTestScreen.tsx +++ b/apps/playground/src/app/screens/NetworkTestScreen.tsx @@ -13,6 +13,10 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import EventSource from 'react-native-sse'; import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { + createSection, + useRozeniteControlsPlugin, +} from '@rozenite/controls-plugin'; import { NitroWebSocket, type WebSocketCloseEvent as NitroWebSocketCloseEvent, @@ -1782,6 +1786,44 @@ export const NetworkTestScreen: React.FC = () => { | 'request-body' >('http'); + const networkControlsSections = React.useMemo( + () => [ + createSection({ + id: 'network-playground', + title: 'Network Playground', + description: + 'Local controls registered from the Network screen so we can verify Rozenite Controls can be mounted more than once.', + items: [ + { + id: 'active-test', + type: 'text' as const, + title: 'Active Test', + value: activeTest, + }, + { + id: 'reset-http', + type: 'button' as const, + title: 'Reset to HTTP', + actionLabel: 'Reset', + onPress: () => setActiveTest('http'), + }, + { + id: 'request-body-test', + type: 'button' as const, + title: 'Open Request Body Test', + actionLabel: 'Open', + onPress: () => navigation.navigate('RequestBodyTest'), + }, + ], + }), + ], + [activeTest, navigation], + ); + + useRozeniteControlsPlugin({ + sections: networkControlsSections, + }); + const renderHeader = () => ( Network Test 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/docs/react-agent-features/01-get-tree.md b/docs/react-agent-features/01-get-tree.md new file mode 100644 index 00000000..8ee43cef --- /dev/null +++ b/docs/react-agent-features/01-get-tree.md @@ -0,0 +1,141 @@ +# Get Tree + +## Summary + +Add a `getTree` tool to the built-in `react` agent domain. It should return a compact component hierarchy for the current React tree, optionally scoped by root/subtree and limited by depth. + +This ports the most useful part of `agent-react-devtools get tree`: one command that lets an agent understand the app structure before choosing a component to inspect. + +## PR Recommendation + +Implement in the same PR as: + +- Component labels +- Get component +- Host filtering + +Reason: `getTree` should expose stable labels, accept labels as roots, and share traversal/filtering logic with `getComponent`. + +## Current Rozenite Behavior + +Rozenite currently exposes: + +- `searchNodes` +- `getNode` +- `getChildren` + +These are accurate but require several calls to reconstruct the tree. The relevant implementation is in: + +- `packages/middleware/src/agent/runtime/react/store.ts` +- `packages/middleware/src/agent/runtime/react/types.ts` +- `packages/middleware/src/agent/local-domains.ts` + +## Proposed Tool + +Tool name: `getTree` + +Input: + +```ts +type ReactGetTreeRequest = { + root?: number | string; + depth?: number; + noHost?: boolean; + limit?: number; + cursor?: string; +}; +``` + +Output: + +```ts +type ReactTreeNode = { + nodeId: number; + label: string; + displayName: string; + elementType: string; + key?: string; + parentId?: number; + parentLabel?: string; + childIds: number[]; + childLabels: string[]; + childCount: number; + depth: number; + errors?: number; + warnings?: number; +}; + +type ReactGetTreeResult = { + roots: Array<{ nodeId: number; label: string }>; + items: ReactTreeNode[]; + totalCount: number; + page: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +}; +``` + +Use `items` instead of `nodes` to match existing paginated Rozenite tool style. + +## Behavior + +- If `root` is omitted, traverse all current React roots. +- If `root` is provided, accept a numeric node ID or a label such as `@c12`. +- `depth` limits descendants relative to the selected traversal root. `0` means only the root node(s). +- `noHost` filters out host nodes that are not significant. See `06-host-filtering.md`. +- Results should be breadth-first or pre-order depth-first, but must be deterministic. Prefer pre-order because it reads like a component tree. +- Include `totalCount` before pagination so agents know whether the response is partial. +- Preserve existing cursor conventions using base64url cursor payloads with `tool: "getTree"` and a filters hash. + +## Implementation Steps + +1. Add request/result types in `packages/middleware/src/agent/runtime/react/types.ts`. +2. Add constants in `store.ts`: + - `GET_TREE_TOOL_NAME = 'getTree'` + - default/max limits can reuse existing search limits unless a tree-specific default is desired. +3. Add an ID resolver helper shared by future label support: + - `resolveNodeId(state, value, fieldName)` + - Accepts `number` and label `string`. + - Initially numeric only if labels are not in the same patch, but the preferred PR includes labels. +4. Add traversal helper: + - Starts from resolved root or `state.rootIds`. + - Computes `depth`. + - Avoids cycles via a visited set. + - Skips missing child IDs defensively. +5. Convert internal `ReactNodeRecord` to `ReactTreeNode`. +6. Add pagination: + - Include `root`, `depth`, and `noHost` in `filtersHash`. + - Reject mismatched cursors with the same error style as existing tools. +7. Expose `getTree` from `createReactTreeStore`. +8. Register the tool in `createReactDomainService` in `packages/middleware/src/agent/local-domains.ts`. +9. Add `getTree` to `STATIC_DOMAIN_TOOL_NAMES.react` in `packages/agent-sdk/src/constants.ts`. +10. Update `packages/cli/skills/rozenite-agent/domains/react.md`. +11. If required by the repository structure, mirror runtime changes under `packages/cli/src/commands/agent/runtime/react/*`. + +## Test Plan + +Add focused tests for the store-level behavior. If there is no existing test file for React store, create one under: + +`packages/middleware/src/agent/runtime/react/__tests__/store.test.ts` + +Cover: + +- Returns all roots and descendants in deterministic order. +- `depth: 0`, `depth: 1`, and omitted depth. +- Scoped root by numeric ID. +- Scoped root by label after label support lands. +- Pagination returns `nextCursor` and rejects mismatched cursor context. +- Missing/stale root throws a clear error. +- `noHost` behavior if implemented in the same PR. + +Also add a domain registration test if local-domain tests are already asserting built-in tools. + +## Acceptance Criteria + +- `rozenite agent react call --tool getTree --args '{}' --session ` returns a compact tree. +- Existing React tools keep their current schemas and behavior. +- New tool appears in `rozenite agent react tools`. +- SDK domain resolution recognizes `getTree`. + diff --git a/docs/react-agent-features/02-component-labels.md b/docs/react-agent-features/02-component-labels.md new file mode 100644 index 00000000..d7ad4369 --- /dev/null +++ b/docs/react-agent-features/02-component-labels.md @@ -0,0 +1,157 @@ +# Component Labels + +## Summary + +Add agent-friendly labels such as `@c1`, `@c2`, and `@c3` to React node summaries, and allow those labels anywhere a React tool currently accepts `nodeId`. + +This ports one of the best usability features from `agent-react-devtools`: agents can inspect by label after reading a tree, without copying long raw numeric React IDs. + +## PR Recommendation + +Implement in the same PR as: + +- Get tree +- Get component +- Host filtering + +Reason: labels are most valuable when `getTree` emits them and `getComponent` accepts them. Adding labels alone would not improve the workflow much. + +## Current Rozenite Behavior + +Rozenite exposes raw numeric React DevTools node IDs: + +- `ReactNodeSummary.nodeId` +- `getNode({ nodeId })` +- `getChildren({ nodeId })` +- `getProps({ nodeId })` +- `getState({ nodeId })` +- `getHooks({ nodeId })` +- `searchNodes({ query })` + +The relevant files are: + +- `packages/middleware/src/agent/runtime/react/types.ts` +- `packages/middleware/src/agent/runtime/react/store.ts` +- `packages/middleware/src/agent/local-domains.ts` + +## Proposed Data Model + +Add `label` to node summaries: + +```ts +type ReactNodeSummary = { + nodeId: number; + label: string; + displayName: string; + elementType: string; + key?: string; + childCount: number; + parentId?: number; + parentLabel?: string; +}; +``` + +Add label maps to `DeviceReactTreeState`: + +```ts +type DeviceReactTreeState = { + labelByNodeId: Map; + nodeIdByLabel: Map; +}; +``` + +Labels should be regenerated whenever a tree sync replaces topology. + +## Label Stability + +Use deterministic traversal order: + +1. Sort roots by numeric ID. +2. Traverse each root in pre-order. +3. Preserve each node's `childIds` order from React DevTools operations. +4. Assign labels incrementally: `@c1`, `@c2`, etc. + +Labels are session-local and tree-snapshot-local. They may change after reloads or large tree changes. That is acceptable as long as every response includes the current label. + +## Label Resolution + +Add helper: + +```ts +const resolveNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number => { ... }; +``` + +Rules: + +- If `value` is an integer, use it as a node ID. +- If `value` is a string matching `/^@c\d+$/`, resolve from `nodeIdByLabel`. +- If `value` is a numeric string, optionally accept it for convenience. +- Throw clear errors: + - `"nodeId" must be an integer or component label like "@c12"` + - `Component label "@c12" no longer exists in the current React tree.` + - `Node "123" no longer exists in the current React tree.` + +## Tool Schema Changes + +Existing tools can remain backward compatible by adding an optional `id` field while keeping `nodeId`. + +Recommended pattern: + +```ts +type ReactNodeIdentifier = number | string; + +type ReactGetNodeRequest = { + nodeId?: number; + id?: ReactNodeIdentifier; +}; +``` + +Resolution priority: + +1. `id` +2. `nodeId` + +For minimal churn, existing tool descriptions can say `nodeId` accepts labels after broadening schema to `oneOf: [{ type: 'integer' }, { type: 'string' }]`. + +## Implementation Steps + +1. Extend React node types with `label` and optional `parentLabel`. +2. Add label maps to device state. +3. In `syncTree`, after `rootIds` and `nodesById` are replaced, rebuild labels. +4. Update `ensureNodeSummary` to include `label` and `parentLabel`. +5. Replace `getNodeId` usage with `resolveNodeId` in: + - `getNode` + - `getChildren` + - `getProps` + - `getState` + - `getHooks` + - `getRenderData` if component/fiber IDs become accepted there later + - new `getTree` + - new `getComponent` +6. Update tool schemas in `createReactDomainService`. +7. Update CLI skill docs with examples using labels. +8. Mirror equivalent CLI runtime files if needed. + +## Test Plan + +Cover: + +- Labels are assigned after tree sync. +- Labels are deterministic for the same tree. +- `searchNodes` returns labels. +- `getNode({ id: "@c2" })` returns the expected node. +- Existing `getNode({ nodeId: 123 })` still works. +- Stale labels produce a helpful error. +- Labels rebuild after a new tree sync. + +## Acceptance Criteria + +- Every React node summary includes `label`. +- Existing numeric `nodeId` workflows remain valid. +- Agents can use `@cN` labels in all inspection/tree tools. +- Tool descriptions explain that labels are session-local. + diff --git a/docs/react-agent-features/03-get-component.md b/docs/react-agent-features/03-get-component.md new file mode 100644 index 00000000..039de085 --- /dev/null +++ b/docs/react-agent-features/03-get-component.md @@ -0,0 +1,159 @@ +# Get Component + +## Summary + +Add a `getComponent` tool that returns a node summary plus props, state, and hooks in one request. + +This ports the `agent-react-devtools get component @cN` workflow. It should use the existing React DevTools `inspectElement` request path that Rozenite already uses for `getProps`, `getState`, and `getHooks`. + +## PR Recommendation + +Implement in the same PR as: + +- Component labels +- Get tree +- Host filtering + +Reason: the desired workflow is `getTree` -> `getComponent({ id: "@c7" })`. Labels and tree output make this tool much easier to use. + +## Current Rozenite Behavior + +Rozenite currently provides separate paginated tools: + +- `getProps` +- `getState` +- `getHooks` + +They already request full inspection data through: + +- `requestInspectableSnapshot` in `packages/middleware/src/agent/runtime/react/store.ts` +- `inspectElement` messages with `forceFullData: true` + +## Proposed Tool + +Tool name: `getComponent` + +Input: + +```ts +type ReactGetComponentRequest = { + id?: number | string; + nodeId?: number; + include?: Array<'props' | 'state' | 'hooks'>; + valueDepth?: number; +}; +``` + +Output: + +```ts +type ReactGetComponentResult = { + node: ReactNodeSummary & { + childIds: number[]; + childLabels: string[]; + rendererId?: number; + errors?: number; + warnings?: number; + }; + props?: unknown; + state?: unknown; + hooks?: unknown; + partial?: boolean; + unavailable?: Array<'props' | 'state' | 'hooks'>; +}; +``` + +`include` defaults to all three sections. + +## Serialization + +Use the existing `createSerializableSnapshot` helper for props, state, and hooks. + +Default depth should be high enough for useful inspection but bounded. Recommended: + +- `props`: depth 4 +- `state`: depth 4 +- `hooks`: depth 6 + +If implementing a single `valueDepth`, clamp it to a safe maximum, for example 8. + +## Behavior + +- Accept `id` as numeric node ID or label. +- Accept legacy `nodeId`. +- Request full inspection if there is no cached snapshot. +- Return whichever sections React DevTools provides. +- If no sections are returned, throw the same kind of helpful error as existing `getProps`. +- Include node summary even when some sections are unavailable. +- Mark `partial: true` if at least one requested section is unavailable. + +## Implementation Steps + +1. Add request/result types to `packages/middleware/src/agent/runtime/react/types.ts`. +2. Add `GET_COMPONENT_TOOL_NAME = 'getComponent'` in `store.ts`. +3. Add helper to fetch an inspected record: + - Reuse the existing `requestInspectableSnapshot`. + - Avoid duplicating the inspection request logic used by `getInspectableEntries`. +4. Add `getComponent(deviceId, rawRequest)` to `createReactTreeStore`. +5. Resolve node through the shared label-aware ID resolver. +6. Build node details: + - `ensureNodeSummary(node)` + - `childIds` + - `childLabels` + - `rendererId` + - future `errors` and `warnings` if available +7. Serialize included sections. +8. Register the tool in `createReactDomainService`. +9. Add `getComponent` to `STATIC_DOMAIN_TOOL_NAMES.react` in `packages/agent-sdk/src/constants.ts`. +10. Update CLI skill docs. +11. Mirror CLI runtime files if needed. + +## Tool Schema + +Recommended JSON schema: + +```ts +{ + type: 'object', + properties: { + id: { + oneOf: [{ type: 'integer' }, { type: 'string' }], + description: 'React node ID or component label, for example 123 or "@c7".' + }, + nodeId: { + type: 'integer', + description: 'Deprecated alias for numeric React node ID.' + }, + include: { + type: 'array', + items: { type: 'string', enum: ['props', 'state', 'hooks'] }, + description: 'Sections to include. Defaults to all sections.' + }, + valueDepth: { + type: 'integer', + description: 'Max nested serialization depth. Default 4, max 8.' + } + } +} +``` + +Require either `id` or `nodeId` in runtime validation. JSON Schema draft support for `anyOf` may be available, but runtime validation should still enforce this. + +## Test Plan + +Cover: + +- Numeric node ID returns node plus props/state/hooks. +- Label ID returns the same component. +- `include: ['props']` returns only props. +- Missing inspected data throws a helpful error. +- Partial inspected data returns `partial: true`. +- Serialization truncates deep objects and handles functions, symbols, undefined, circular references. +- Existing `getProps/getState/getHooks` behavior remains unchanged. + +## Acceptance Criteria + +- Agents can inspect a component with one call after finding it in `getTree` or `searchNodes`. +- Existing paginated inspection tools remain available for large props/state/hooks. +- The output is bounded and JSON-safe. + diff --git a/docs/react-agent-features/04-errors-warnings.md b/docs/react-agent-features/04-errors-warnings.md new file mode 100644 index 00000000..24a556e4 --- /dev/null +++ b/docs/react-agent-features/04-errors-warnings.md @@ -0,0 +1,148 @@ +# Errors And Warnings + +## Summary + +Parse React DevTools `TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS` operations and expose error/warning counts on nodes, plus a focused tool to list affected components. + +This ports the `agent-react-devtools errors` behavior. + +## PR Recommendation + +Implement as a separate PR. + +Reason: this touches the operations parser and node model, but does not need the tree/label/component API to be useful. It is small and reviewable on its own. + +## Current Rozenite Behavior + +Rozenite defines the opcode but currently skips it: + +- `TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5` +- `parseTreeOperations` advances by four fields but does not return the counts. + +Relevant files: + +- `packages/middleware/src/agent/runtime/react/operations-parser.ts` +- `packages/middleware/src/agent/runtime/react/component-tree-store.ts` +- `packages/middleware/src/agent/runtime/react/store.ts` +- `packages/middleware/src/agent/runtime/react/types.ts` +- `packages/middleware/src/agent/local-domains.ts` + +## Protocol Shape + +React DevTools operation: + +```ts +[ + TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS, + nodeId, + errorCount, + warningCount +] +``` + +## Proposed Model Changes + +Extend parsed operations: + +```ts +type ParsedTreeOperations = { + errorWarningUpdates: Array<{ + nodeId: number; + errors: number; + warnings: number; + }>; +}; +``` + +Extend node records: + +```ts +type TreeNodeRecord = { + errors?: number; + warnings?: number; +}; + +interface ReactNodeSummary { + errors?: number; + warnings?: number; +} +``` + +Omit fields when counts are zero to keep output compact. + +## Proposed Tool + +Tool name: `getErrors` + +Input: + +```ts +type ReactGetErrorsRequest = { + root?: number | string; + limit?: number; + cursor?: string; +}; +``` + +Output: + +```ts +type ReactGetErrorsResult = { + items: Array; + totalCount: number; + page: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +}; +``` + +Alternative tool name: `errors`, matching `agent-react-devtools`. Prefer `getErrors` for Rozenite's existing verb style. + +## Behavior + +- Update node counts whenever operation type 5 arrives. +- If a node is removed, its counts disappear with it. +- `searchNodes`, `getNode`, `getChildren`, `getTree`, and `getComponent` should include non-zero counts once available. +- `getErrors` returns nodes where `errors > 0 || warnings > 0`. +- Sort by descending `errors`, then descending `warnings`, then display name or tree order. +- Support root scoping if label/tree support exists. If this ships before labels, support numeric `rootId` only or omit scoping for v1. + +## Implementation Steps + +1. Update `ParsedTreeOperations` in `operations-parser.ts`. +2. Parse operation type 5 instead of skipping it. +3. Add error/warning fields to internal tree records. +4. Apply updates in `component-tree-store.ts`. +5. Include non-zero counts in summary conversion helpers. +6. Add `getErrors` to `createReactTreeStore`. +7. Register `getErrors` in `createReactDomainService`. +8. Add `getErrors` to `STATIC_DOMAIN_TOOL_NAMES.react`. +9. Update CLI skill docs. +10. Mirror CLI runtime changes if needed. + +## Test Plan + +Cover parser: + +- Parses a valid error/warning update. +- Ignores malformed counts without throwing. +- Keeps parsing following operations after an update. + +Cover store: + +- Node summary includes non-zero counts. +- Counts update from non-zero back to zero and fields are omitted. +- Removed nodes no longer appear in `getErrors`. +- `getErrors` paginates. + +## Acceptance Criteria + +- React DevTools error/warning counts are visible to agents. +- A dedicated `getErrors` tool lists only affected components. +- Existing tree parsing behavior remains unchanged for all other operation types. + diff --git a/docs/react-agent-features/05-profile-convenience-tools.md b/docs/react-agent-features/05-profile-convenience-tools.md new file mode 100644 index 00000000..f80b31a8 --- /dev/null +++ b/docs/react-agent-features/05-profile-convenience-tools.md @@ -0,0 +1,213 @@ +# Profile Convenience Tools + +## Summary + +Add high-level profiling tools that summarize the profiling data Rozenite already captures. + +This ports the useful agent-facing commands from `agent-react-devtools`: + +- slowest renders +- most re-rendered components +- profiling timeline +- component-specific profile report + +## PR Recommendation + +Implement as a separate PR. + +Reason: Rozenite already has a robust profiling core. These tools are derived views over captured data and should be reviewed separately from tree inspection changes. + +## Current Rozenite Behavior + +Existing tools: + +- `startProfiling` +- `isProfilingStarted` +- `stopProfiling` +- `getRenderData` + +Current profiling data is normalized in: + +- `packages/middleware/src/agent/runtime/react/profiling-store.ts` +- `packages/middleware/src/agent/runtime/react/store.ts` +- `packages/middleware/src/agent/runtime/react/react-devtools-bridge.ts` + +Rozenite already waits for a confirmed stopped profiling status before requesting `getProfilingData`; preserve that behavior. + +## Proposed Tools + +### `getProfileTimeline` + +Input: + +```ts +type ReactGetProfileTimelineRequest = { + rootId?: number; + sort?: 'timeline' | 'duration-desc'; + limit?: number; + cursor?: string; +}; +``` + +Output: + +```ts +type ReactGetProfileTimelineResult = { + items: Array<{ + rootId: number; + commitIndex: number; + durationMs: number; + timestampMs: number; + renderedFiberCount: number; + }>; + totalCount: number; + page: Page; +}; +``` + +### `getSlowRenders` + +Input: + +```ts +type ReactGetSlowRendersRequest = { + thresholdMs?: number; + rootId?: number; + limit?: number; + cursor?: string; +}; +``` + +Output: + +```ts +type ReactGetSlowRendersResult = { + items: Array<{ + rootId: number; + commitIndex: number; + fiberId: number; + label?: string; + displayName: string; + actualDurationMs: number; + selfDurationMs: number; + changeTypeHints?: string[]; + }>; + totalCount: number; + thresholdMs: number; + page: Page; +}; +``` + +### `getRerenders` + +Input: + +```ts +type ReactGetRerendersRequest = { + rootId?: number; + limit?: number; + cursor?: string; +}; +``` + +Output: + +```ts +type ReactGetRerendersResult = { + items: Array<{ + fiberId: number; + label?: string; + displayName: string; + renderCount: number; + totalDurationMs: number; + averageDurationMs: number; + maxDurationMs: number; + }>; + totalCount: number; + page: Page; +}; +``` + +### `getProfileReport` + +Input: + +```ts +type ReactGetProfileReportRequest = { + id?: number | string; + fiberId?: number; +}; +``` + +Output: + +```ts +type ReactGetProfileReportResult = { + fiberId: number; + label?: string; + displayName: string; + renderCount: number; + totalDurationMs: number; + averageDurationMs: number; + maxDurationMs: number; + causes: Array<'mount' | 'props' | 'state' | 'context' | 'hooks' | 'parent'>; + changedKeys: { + props: string[]; + state: string[]; + hooks: number[]; + }; + commits: Array<{ + rootId: number; + commitIndex: number; + actualDurationMs: number; + selfDurationMs: number; + changeTypeHints?: string[]; + }>; +}; +``` + +## Behavior + +- All tools require profiling data. If none exists, throw: `No React profiling data available. Run startProfiling and stopProfiling first.` +- Derived component names should use current tree names when available, and fall back to `Fiber `. +- If label support exists, include labels. +- If a component unmounted after profiling, still return its fiber ID and fallback display name. +- Keep `getRenderData` as the detailed per-commit page tool. + +## Implementation Steps + +1. Add result/request types in `types.ts`. +2. Add helper functions in `store.ts` or a new `profile-reports.ts` module: + - iterate all roots and commits from `bridge.getProfilingDataSnapshot()` + - normalize duration entries from `Map` + - derive change hints using existing `toChangeTypeHints` +3. Add store methods: + - `getProfileTimeline` + - `getSlowRenders` + - `getRerenders` + - `getProfileReport` +4. Add cursor pagination for list tools. +5. Register tools in `createReactDomainService`. +6. Add tool names to `STATIC_DOMAIN_TOOL_NAMES.react`. +7. Update CLI skill docs with a recommended workflow. +8. Mirror CLI runtime files if needed. + +## Test Plan + +Cover: + +- Timeline over multiple roots and commits. +- Duration sorting. +- Slow render threshold. +- Rerender aggregation by fiber ID. +- Profile report with props/state/hooks/context hints. +- Missing profiling data error. +- Pagination cursor mismatch handling. +- Labels included when label support exists. + +## Acceptance Criteria + +- Agents can answer "what was slow?" without manually paging every commit. +- Existing profiling start/stop behavior remains unchanged. +- `getRenderData` still works for deep per-commit inspection. + diff --git a/docs/react-agent-features/06-host-filtering.md b/docs/react-agent-features/06-host-filtering.md new file mode 100644 index 00000000..8700a2a4 --- /dev/null +++ b/docs/react-agent-features/06-host-filtering.md @@ -0,0 +1,121 @@ +# Host Filtering + +## Summary + +Add an option to hide low-signal host nodes from tree-style React outputs. + +React Native trees contain many host components. Filtering them makes component exploration much easier for agents while preserving significant host nodes when they carry useful identity. + +## PR Recommendation + +Implement in the same PR as: + +- Get tree +- Component labels +- Get component + +Reason: host filtering is primarily a `getTree` feature and affects label/tree traversal semantics. It should be designed together with those APIs. + +## Current Rozenite Behavior + +Rozenite returns host nodes from `searchNodes`, `getChildren`, and `getNode` because they reflect the React DevTools tree directly. + +There is no current component-only tree view. + +## Proposed API + +Add `noHost?: boolean` to `getTree`. + +Optional later additions: + +- `searchNodes({ includeHost?: boolean })` +- `getChildren({ noHost?: boolean })` + +For the first implementation, keep host filtering scoped to `getTree` to avoid surprising existing tool behavior. + +## Filtering Rule + +When `noHost` is true: + +- Hide host nodes by default. +- Keep host nodes that are significant: + - node has a `key` + - display name includes `-`, which suggests a custom element + - node has non-zero errors or warnings + - node is the explicitly requested root + +When a host node is hidden, promote its children to the nearest visible ancestor in the returned tree. + +## Output Semantics + +For filtered tree output: + +- `parentId` and `parentLabel` should refer to the visible parent, not necessarily the real React parent. +- `childIds` and `childLabels` should list visible children. +- Include `realParentId` only if useful. Prefer omitting it for v1 to keep output compact. +- `depth` should be visual depth in the filtered tree. +- Labels should remain labels for real nodes. Do not create virtual nodes. + +## Implementation Steps + +1. Implement `isSignificantHost(node)` in `store.ts` or a small helper module. +2. In `getTree` traversal, add two concepts: + - real traversal parent from the React tree + - visible parent for filtered output +3. If a node is hidden: + - do not emit it + - traverse children with the same visible parent and same visual depth +4. If a node is visible: + - emit it + - traverse children with this node as visible parent and `depth + 1` +5. Patch each emitted node's visible child list after traversal, or build visible children as traversal proceeds. +6. Preserve cycle protection with a visited set. + +## Pseudocode + +```ts +const walk = (nodeId, visualParentId, depth) => { + const node = state.nodesById.get(nodeId); + if (!node || visited.has(nodeId)) return; + visited.add(nodeId); + + const hidden = + noHost && + node.elementType === 'host' && + !isSignificantHost(node) && + nodeId !== requestedRootId; + + if (hidden) { + for (const childId of node.childIds) { + walk(childId, visualParentId, depth); + } + return; + } + + emit(node, visualParentId, depth); + + for (const childId of node.childIds) { + walk(childId, nodeId, depth + 1); + } +}; +``` + +## Test Plan + +Cover: + +- Plain host nodes are hidden when `noHost: true`. +- Children of hidden hosts are promoted. +- Keyed host nodes remain visible. +- Custom element names remain visible. +- Host nodes with errors/warnings remain visible after error support lands. +- Explicit host root remains visible. +- Depth is visual depth, not raw React depth. +- Labels still resolve to real nodes. + +## Acceptance Criteria + +- `getTree({ noHost: true })` produces a compact component-focused tree. +- Existing tools remain unfiltered unless explicitly extended. +- The filtered tree is still usable with `getComponent` because labels/node IDs refer to real nodes. + diff --git a/docs/react-agent-features/README.md b/docs/react-agent-features/README.md new file mode 100644 index 00000000..ef349143 --- /dev/null +++ b/docs/react-agent-features/README.md @@ -0,0 +1,69 @@ +# React Agent Feature Port Plan + +This folder contains implementation briefs for porting the useful agent-facing behavior from `callstackincubator/agent-react-devtools` into Rozenite's built-in `react` agent domain. + +The goal is not to copy its WebSocket transport. Rozenite already receives React DevTools protocol messages through the React Native DevTools/Fusebox integration and already parses tree, inspection, and profiling data. The goal is to improve the Rozenite agent API so it is easier for humans and LLM agents to inspect a running React Native app. + +## Source Context + +- External reference clone: `/Users/szymon.chmal/Projects/agent-react-devtools` +- Rozenite React DevTools bridge: `packages/middleware/src/agent/runtime/react/react-devtools-bridge.ts` +- Rozenite React store: `packages/middleware/src/agent/runtime/react/store.ts` +- Rozenite React types: `packages/middleware/src/agent/runtime/react/types.ts` +- Rozenite React domain registration: `packages/middleware/src/agent/local-domains.ts` +- Static SDK domain tool list: `packages/agent-sdk/src/constants.ts` +- Current CLI skill reference: `packages/cli/skills/rozenite-agent/domains/react.md` +- Mirrored CLI runtime copy: `packages/cli/src/commands/agent/runtime/react/*` + +When implementing, treat `packages/middleware` as the canonical runtime location. If the CLI copy is still intentionally maintained, mirror equivalent runtime/type changes there after changing middleware. + +## Recommended PR Slicing + +### PR 1: Core Agent Ergonomics + +Implement these together because they share ID resolution, node summaries, label generation, and tree traversal: + +- [Component Labels](./02-component-labels.md) +- [Get Tree](./01-get-tree.md) +- [Get Component](./03-get-component.md) +- [Host Filtering](./06-host-filtering.md) + +This PR should add labels and then expose them through `getTree`, `getComponent`, `searchNodes`, `getNode`, and `getChildren`. Host filtering can be implemented as a `getTree` option in the same traversal code. + +### PR 2: Error And Warning Tracking + +- [Errors And Warnings](./04-errors-warnings.md) + +This touches operation parsing and tree node state, but does not need to ship with the label/tree/component API. + +### PR 3: Profiling Convenience Tools + +- [Profile Convenience Tools](./05-profile-convenience-tools.md) + +This builds on Rozenite's existing profiling store and should stay separate from tree inspection changes to keep review focused. + +## Compatibility Rules + +- Keep existing tools working: `searchNodes`, `getNode`, `getChildren`, `getProps`, `getState`, `getHooks`, `startProfiling`, `isProfilingStarted`, `stopProfiling`, `getRenderData`. +- Add new tools instead of replacing current ones. +- Keep raw numeric `nodeId` support even after adding labels. +- Prefer additive result fields such as `label`, `errors`, and `warnings`. +- Keep cursor pagination stable for existing tools. +- Update SDK constants and CLI skill docs whenever public tool names change. + +## Verification + +At minimum, each PR should run: + +```sh +pnpm --filter @rozenite/middleware test +pnpm --filter @rozenite/middleware typecheck +pnpm --filter @rozenite/agent-sdk typecheck +``` + +If mirrored CLI runtime files are changed: + +```sh +pnpm --filter rozenite typecheck +``` + diff --git a/packages/redux-devtools-plugin/README.md b/packages/redux-devtools-plugin/README.md index bb8dff31..a6a64b7f 100644 --- a/packages/redux-devtools-plugin/README.md +++ b/packages/redux-devtools-plugin/README.md @@ -91,6 +91,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/__tests__/runtime.test.ts b/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts index 45960629..8b433024 100644 --- a/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts +++ b/packages/redux-devtools-plugin/src/__tests__/runtime.test.ts @@ -1,15 +1,52 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionCreators } from '@redux-devtools/instrument'; // @ts-expect-error app-core does not publish declarations for built subpaths. import { UPDATE_STATE } from '@redux-devtools/app-core/lib/esm/constants/actionTypes.js'; // @ts-expect-error app-core does not publish declarations for built subpaths. import { instances as reduceInstances } from '@redux-devtools/app-core/lib/esm/reducers/instances.js'; import { parse } from 'jsan'; 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, ReduxDevToolsRuntimeMessage, } from '../shared/protocol'; +const bridge = vi.hoisted(() => ({ + panelCommandListener: undefined as + | ((command: ReduxDevToolsPanelCommand) => void) + | undefined, + sentMessages: [] as ReduxDevToolsRuntimeMessage[], +})); + +vi.mock('../runtime-bridge', () => ({ + getRuntimeConnectionId: () => 'test-connection', + sendRuntimeMessage: (message: ReduxDevToolsRuntimeMessage) => { + bridge.sentMessages.push(message); + }, + subscribeToPanelCommands: ( + listener: (command: ReduxDevToolsPanelCommand) => void + ) => { + bridge.panelCommandListener = listener; + + return () => { + if (bridge.panelCommandListener === listener) { + bridge.panelCommandListener = undefined; + } + }; + }, +})); + +vi.mock('react-native', () => ({ + NativeModules: { + SourceCode: { + scriptURL: undefined, + }, + }, +})); + type TestState = { counter: number; largeValue: string; @@ -41,41 +78,22 @@ const reducer = ( const setupRuntime = async () => { vi.resetModules(); - - const sentMessages: ReduxDevToolsRuntimeMessage[] = []; - let panelCommandListener: - | ((command: ReduxDevToolsPanelCommand) => void) - | null = null; - - vi.doMock('../runtime-bridge', () => ({ - getRuntimeConnectionId: () => 'test-connection', - sendRuntimeMessage: (message: ReduxDevToolsRuntimeMessage) => { - sentMessages.push(message); - }, - subscribeToPanelCommands: ( - listener: (command: ReduxDevToolsPanelCommand) => void - ) => { - panelCommandListener = listener; - - return () => { - panelCommandListener = null; - }; - }, - })); + bridge.sentMessages.length = 0; + bridge.panelCommandListener = undefined; const runtime = await import('../runtime'); const sendPanelCommand = (command: ReduxDevToolsPanelCommand) => { - if (!panelCommandListener) { + if (!bridge.panelCommandListener) { throw new Error('Panel command listener was not registered.'); } - panelCommandListener(command); + bridge.panelCommandListener(command); }; return { ...runtime, - sentMessages, + sentMessages: bridge.sentMessages, sendPanelCommand, }; }; @@ -87,7 +105,9 @@ const getStateUpdateRequests = (messages: ReduxDevToolsRuntimeMessage[]) => { }; afterEach(() => { - vi.restoreAllMocks(); + clearReduxDevToolsStoreRegistryForTests(); + bridge.panelCommandListener = undefined; + bridge.sentMessages.length = 0; vi.resetModules(); }); @@ -242,3 +262,39 @@ describe('redux devtools runtime', () => { expect(instanceState.currentStateIndex).toBe(1); }); }); + +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/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 7338b757..1034fc25 100644 --- a/packages/redux-devtools-plugin/src/runtime.ts +++ b/packages/redux-devtools-plugin/src/runtime.ts @@ -18,13 +18,16 @@ 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; +const TRACE_SNAPSHOT_DEBOUNCE_MS = 50; const PARTIAL_STATE_CHUNK_SIZE = 1; const STORE_SENTINEL = Symbol.for('rozenite.redux-devtools.store-sentinel'); @@ -51,6 +54,31 @@ export interface RozeniteDevToolsOptions { */ 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; + /** * Sanitizes each Redux state snapshot before it is sent to DevTools. * Useful for omitting large caches, entity maps, or sensitive values. @@ -111,11 +139,17 @@ 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(); + let traceSnapshotTimer: ReturnType | null = null; const reportedSanitizerErrors = new Set(); const enqueueCommand = (command: ReduxDevToolsPanelCommand) => { @@ -220,6 +254,89 @@ 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 + ): 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); + scheduleTraceSnapshot(); + }) + .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 sendStateSnapshot = (): void => { const liftedState = getLiftedStateRaw(); @@ -227,6 +344,8 @@ const createRuntimeController = ( return; } + pruneActionTraces(liftedState); + const initialComputedState = liftedState.computedStates[0]; const initialStagedActionId = liftedState.stagedActionIds[0] ?? 0; const initialAction = liftedState.actionsById[initialStagedActionId] as @@ -241,7 +360,7 @@ const createRuntimeController = ( actionsById: initialAction ? { [initialStagedActionId]: sanitizeLiftedAction( - initialAction, + decorateLiftedAction(initialStagedActionId, initialAction), initialStagedActionId ), } @@ -280,7 +399,10 @@ const createRuntimeController = ( | undefined; if (action) { - actionsById[actionId] = sanitizeLiftedAction(action, actionId); + actionsById[actionId] = sanitizeLiftedAction( + decorateLiftedAction(actionId, action), + actionId + ); } }); @@ -330,6 +452,8 @@ const createRuntimeController = ( return; } + pruneActionTraces(liftedState); + const nextActionId = liftedState.nextActionId; const liftedAction = liftedState.actionsById[ nextActionId - 1 @@ -347,7 +471,12 @@ const createRuntimeController = ( payload: stringify( sanitizeState(store.getState(), liftedState.currentStateIndex) ), - action: stringify(sanitizeLiftedAction(liftedAction, nextActionId - 1)), + action: stringify( + sanitizeLiftedAction( + decorateLiftedAction(nextActionId - 1, liftedAction), + nextActionId - 1 + ) + ), nextActionId, maxAge, isExcess: liftedState.stagedActionIds.length >= maxAge, @@ -486,6 +615,8 @@ const createRuntimeController = ( ) => { store = instrument(monitorReducer, { maxAge, + trace, + traceLimit, shouldHotReload: true, shouldRecordChanges: true, pauseActionType: '@@PAUSED', @@ -511,6 +642,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..106ceec0 --- /dev/null +++ b/packages/redux-devtools-plugin/src/symbolication/trace.ts @@ -0,0 +1,107 @@ +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(); +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, + 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 pendingTrace = pendingTraceCache.get(rawStack); + if (pendingTrace) { + return { + initialTrace: { + rawStack, + frames, + status: 'pending', + }, + pendingTrace, + }; + } + + const initialTrace: ReduxActionTrace = { + rawStack, + frames, + status: 'pending', + }; + + const nextPendingTrace = 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') { + setCachedTrace(rawStack, trace); + } + + return trace; + }) + .finally(() => { + pendingTraceCache.delete(rawStack); + }); + + pendingTraceCache.set(rawStack, nextPendingTrace); + + return { initialTrace, pendingTrace: nextPendingTrace }; +}; 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: { diff --git a/website/src/docs/official-plugins/redux-devtools.mdx b/website/src/docs/official-plugins/redux-devtools.mdx index 6627bf91..ed6373a9 100644 --- a/website/src/docs/official-plugins/redux-devtools.mdx +++ b/website/src/docs/official-plugins/redux-devtools.mdx @@ -154,6 +154,17 @@ rozeniteDevToolsEnhancer({ }) ``` +Set `trace: true` to capture the dispatch stack for each action and enable the Trace tab. Rozenite attempts to symbolicate captured stacks through Metro so frames point to source files instead of generated bundle locations. + +```ts +rozeniteDevToolsEnhancer({ + trace: true, + traceLimit: 25, +}) +``` + +Use `traceSymbolication: false` if you want to keep raw stacks without calling Metro's `/symbolicate` endpoint. + ## Contributing The Redux DevTools 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.