Skip to content

Commit 77831ae

Browse files
camilossantos2809jamonholmgrenarkthurinfinitered-circleciksarmalkarbb
authoredMar 16, 2025··
feat(reactotron-app): show redux diffs in timeline (#1531 by @camilossantos2809)
## Please verify the following: - [x] `yarn build-and-test:local` passes - [x] I have added tests for any new features, if relevant - [ ] `README.md` (or relevant documentation) has been updated with your changes ## Describe your PR This change allows developers to see what has been modified in the store after an action is dispatched. It is particularly useful when an action updates multiple modules. When an action is called in Redux, the current store is compared with the new store, and the differences are sent to Reactotron to be displayed in the timeline. To determine these differences, the microdiff package was used and added as a dependency to reactotron-redux. I am not entirely familiar with your build process, but I assume that the microdiff package will be installed automatically via npm/yarn when developers upgrade the Reactotron plugin in their projects. I'm open to feedback and suggestions for further improvements to this implementation. Please feel free to reach out if you have any questions or if there are areas you'd like me to refine. closes #496 ![Screenshot 2025-01-20 at 14 12 29](https://github.com/user-attachments/assets/1528678c-1143-49ab-917f-8be9dd1a62ae) --------- Co-authored-by: Jamon Holmgren <code@jamon.dev> Co-authored-by: Ítalo Masserano <italo_masserano@outlook.com> Co-authored-by: Infinite Red <ci@infinite.red> Co-authored-by: Kedar Sarmalkar <110056501+ksarmalkarbb@users.noreply.github.com>
1 parent 7af103a commit 77831ae

File tree

13 files changed

+215
-15
lines changed

13 files changed

+215
-15
lines changed
 

‎lib/reactotron-core-contract/src/command.ts

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export interface Command<
9393
important: boolean
9494
messageId: number
9595
payload: Payload
96+
diff?: any
9697
}
9798

9899
export type CommandEvent = (command: Command) => void
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface DifferenceCreate {
2+
type: "CREATE"
3+
path: (string | number)[]
4+
value: any
5+
}
6+
export interface DifferenceRemove {
7+
type: "REMOVE"
8+
path: (string | number)[]
9+
oldValue: any
10+
}
11+
export interface DifferenceChange {
12+
type: "CHANGE"
13+
path: (string | number)[]
14+
value: any
15+
oldValue: any
16+
}
17+
export type Difference = DifferenceCreate | DifferenceRemove | DifferenceChange

‎lib/reactotron-core-contract/src/reactotron-core-contract.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./log"
33
export * from "./openInEditor"
44
export * from "./server-events"
55
export * from "./state"
6+
export * from "./diff"

‎lib/reactotron-core-server/src/reactotron-core-server.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,14 @@ export default class Server {
221221
socket.on("message", (incoming: RawData) => {
222222
const message = JSON.parse(incoming.toString())
223223
repair(message)
224-
const { type, important, payload, deltaTime = 0 } = message
224+
const { type, important, payload, deltaTime = 0, diff } = message
225225
this.messageId++
226226

227227
const fullCommand: Command = {
228228
type,
229229
important,
230230
payload,
231+
diff,
231232
connectionId: thisConnectionId,
232233
messageId: this.messageId,
233234
date: extractOrCreateDate(message.date),
@@ -291,6 +292,7 @@ export default class Server {
291292
id: thisConnectionId,
292293
address: partConn.address,
293294
clientId: fullCommand.clientId,
295+
diff,
294296
})
295297

296298
// then trigger the connection
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { ReactElement, type ReactNode } from "react"
2+
import { JSONTree, type ValueRenderer } from "react-json-tree"
3+
import styled from "styled-components"
4+
5+
import useColorScheme from "../../hooks/useColorScheme"
6+
import { ReactotronTheme, themes } from "../../themes"
7+
import type { Difference } from "reactotron-core-contract"
8+
9+
// TODO: Ripping this right from reactotron right now... should probably be better.
10+
const theme = {
11+
author: "david hart (http://hart-dev.com)",
12+
base0A: "#f9ee98",
13+
base0B: "#8f9d6a",
14+
base0C: "#afc4db",
15+
base0D: "#7587a6",
16+
base0E: "#9b859d",
17+
base0F: "#9b703f",
18+
base00: "#1e1e1e",
19+
base01: "#323537",
20+
base02: "#464b50",
21+
base03: "#5f5a60",
22+
base04: "#838184",
23+
base05: "#a7a7a7",
24+
base06: "#c3c3c3",
25+
base07: "#ffffff",
26+
base08: "#cf6a4c",
27+
base09: "#cda869",
28+
scheme: "twilight",
29+
}
30+
31+
const RemovedValue = styled.span`
32+
color: ${(props) => props.theme.delete};
33+
text-decoration: line-through;
34+
margin-right: 4px;
35+
`
36+
const NewValue = styled.span`
37+
color: ${(props) => props.theme.string};
38+
`
39+
40+
const getTreeTheme = (baseTheme: ReactotronTheme) => ({
41+
tree: { backgroundColor: "transparent", marginTop: -3 },
42+
...theme,
43+
base0B: baseTheme.foreground,
44+
})
45+
46+
interface Props {
47+
value?: Difference[]
48+
level?: number
49+
}
50+
51+
export default function TreeViewDiff({ value, level = 1 }: Props): ReactElement | null {
52+
const colorScheme = useColorScheme()
53+
if (value == null || value.length === 0) return null
54+
55+
const mappedValues: Record<string, ReactNode> = value.reduce((acc, diff) => {
56+
if (diff.type === "CHANGE") {
57+
acc[diff.path.join(".")] = (
58+
<span>
59+
<RemovedValue>{JSON.stringify(diff.oldValue)}</RemovedValue>
60+
<NewValue>{JSON.stringify(diff.value)}</NewValue>
61+
</span>
62+
)
63+
return acc
64+
}
65+
if (diff.type === "CREATE") {
66+
acc[diff.path.join(".")] = <NewValue>{JSON.stringify(diff.value)}</NewValue>
67+
68+
return acc
69+
}
70+
if (diff.type === "REMOVE") {
71+
acc[diff.path.join(".")] = <RemovedValue>{JSON.stringify(diff.oldValue)}</RemovedValue>
72+
return acc
73+
}
74+
return acc
75+
}, {})
76+
77+
const treeData: Record<string, string> = value.reduce((acc, diff) => {
78+
acc[diff.path.join(".")] = diff.path.join(".")
79+
return acc
80+
}, {})
81+
82+
const valueRenderer: ValueRenderer = (_, untransformed) => {
83+
const mappedValue = mappedValues[untransformed as string]
84+
return mappedValue
85+
}
86+
87+
return (
88+
<JSONTree
89+
data={treeData}
90+
hideRoot
91+
shouldExpandNodeInitially={(_keyName, _data, minLevel) => minLevel <= level}
92+
theme={getTreeTheme(themes[colorScheme])}
93+
valueRenderer={valueRenderer}
94+
/>
95+
)
96+
}

‎lib/reactotron-core-ui/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import TimelineCommandTabButton from "./components/TimelineCommandTabButton"
1414
import Timestamp from "./components/Timestamp"
1515
import Tooltip from "./components/Tooltip"
1616
import TreeView from "./components/TreeView"
17+
import TreeViewDiff from "./components/TreeViewDiff"
1718

1819
// Contexts
1920
import ReactotronContext, { ReactotronProvider } from "./contexts/Reactotron"
@@ -67,6 +68,7 @@ export {
6768
Timestamp,
6869
Tooltip,
6970
TreeView,
71+
TreeViewDiff,
7072
}
7173

7274
export type { CustomCommand } from "./contexts/CustomCommands/useCustomCommands"

‎lib/reactotron-core-ui/src/timelineCommands/StateActionCompleteCommand/index.tsx

+43-2
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
1-
import React, { FunctionComponent } from "react"
1+
import React, { FunctionComponent, useState } from "react"
22
import styled from "styled-components"
33
import { MdRepeat, MdCode } from "react-icons/md"
44
import stringifyObject from "stringify-object"
5+
import type { Difference } from "reactotron-core-contract"
56

67
import TimelineCommand from "../../components/TimelineCommand"
78
import ContentView from "../../components/ContentView"
89
import { TimelineCommandProps, buildTimelineCommand } from "../BaseCommand"
10+
import TreeViewDiff from "../../components/TreeViewDiff"
911

1012
const NameContainer = styled.div`
1113
color: ${(props) => props.theme.bold};
1214
padding-bottom: 10px;
1315
`
16+
const TabContainer = styled.div`
17+
display: flex;
18+
padding-top: 8px;
19+
padding-bottom: 8px;
20+
`
21+
const TabItem = styled.div<{ $active: boolean }>`
22+
color: ${({ $active, theme }) => ($active ? theme.bold : theme.foregroundDark)};
23+
cursor: pointer;
24+
padding-right: 8px;
25+
padding-left: 8px;
26+
border-right: 1px solid ${(props) => props.theme.chromeLine};
27+
:last-child {
28+
border-right: none;
29+
}
30+
:first-child {
31+
padding-left: 0px;
32+
}
33+
`
34+
const Tab = styled.div`
35+
display: flex;
36+
padding: 10px;
37+
38+
border-bottom: 1px solid ${({ theme }) => theme.chromeLine};
39+
`
1440

1541
interface StateActionCompletePayload {
1642
name: string
1743
action: any
44+
diff?: Difference[]
1845
}
1946

2047
interface Props extends TimelineCommandProps<StateActionCompletePayload> {}
@@ -27,6 +54,7 @@ const StateActionCompleteCommand: FunctionComponent<Props> = ({
2754
openDispatchDialog,
2855
}) => {
2956
const { payload, date, deltaTime } = command
57+
const [tabActive, setTabActive] = useState<"preview" | "diff">("preview")
3058

3159
const toolbar = []
3260

@@ -66,7 +94,20 @@ const StateActionCompleteCommand: FunctionComponent<Props> = ({
6694
toolbar={toolbar}
6795
>
6896
<NameContainer>{payload.name}</NameContainer>
69-
<ContentView value={payload.action} />
97+
{payload.diff != null && (
98+
<TabContainer>
99+
<Tab>
100+
<TabItem $active={tabActive === "preview"} onClick={() => setTabActive("preview")}>
101+
Preview
102+
</TabItem>
103+
<TabItem $active={tabActive === "diff"} onClick={() => setTabActive("diff")}>
104+
Diff
105+
</TabItem>
106+
</Tab>
107+
</TabContainer>
108+
)}
109+
{tabActive === "preview" && <ContentView value={payload.action} />}
110+
{tabActive === "diff" && <TreeViewDiff value={payload.diff} />}
70111
</TimelineCommand>
71112
)
72113
}

‎lib/reactotron-redux/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
"typecheck": "yarn tsc",
4141
"ci:test": "yarn test --runInBand"
4242
},
43+
"dependencies": {
44+
"microdiff": "^1.5.0"
45+
},
4346
"peerDependencies": {
4447
"reactotron-core-client": "*",
4548
"redux": ">=4.0.0"

‎lib/reactotron-redux/rollup.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ export default {
2525
: null,
2626
filesize(),
2727
],
28-
external: ["redux", "reactotron-core-client"],
28+
external: ["redux", "reactotron-core-client", "microdiff"],
2929
}

‎lib/reactotron-redux/src/customDispatch.test.ts

+30-6
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe("customDispatch", () => {
2020
dispatch(action)
2121

2222
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
23-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false)
23+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [])
2424
})
2525

2626
it.todo("should handle 'PERFORM_ACTION' actions correctly")
@@ -94,7 +94,7 @@ describe("customDispatch", () => {
9494
dispatch(action)
9595

9696
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
97-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false)
97+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [])
9898
})
9999

100100
it("should respect the exclude list and not send an item off of it if it is a regex", () => {
@@ -142,7 +142,7 @@ describe("customDispatch", () => {
142142
dispatch(action)
143143

144144
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
145-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false)
145+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [])
146146
})
147147

148148
it("should respect the exclude list and should still send items not on the list", () => {
@@ -166,7 +166,7 @@ describe("customDispatch", () => {
166166
dispatch(action)
167167

168168
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
169-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false)
169+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [])
170170
})
171171

172172
it("should exclude the restoreActionType by default", () => {
@@ -213,7 +213,7 @@ describe("customDispatch", () => {
213213
dispatch(action)
214214

215215
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
216-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, true)
216+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, true, [])
217217
})
218218

219219
it("should call isActionImportant and mark the action as important if it returns false", () => {
@@ -237,6 +237,30 @@ describe("customDispatch", () => {
237237
dispatch(action)
238238

239239
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
240-
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false)
240+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [])
241+
})
242+
it("should send the difference between the old state and the new state", () => {
243+
const mockReactotron = {
244+
startTimer: () => jest.fn().mockReturnValue(1000),
245+
reportReduxAction: jest.fn(),
246+
}
247+
const mockStore = {
248+
dispatch: jest.fn(),
249+
getState: jest.fn().mockReturnValueOnce({ value: "old value" }).mockReturnValueOnce({
250+
value: "new value",
251+
}),
252+
}
253+
const action = {
254+
type: "@module/someAction",
255+
payload: { value: "new value" },
256+
}
257+
258+
const dispatch = createCustomDispatch(mockReactotron, mockStore, {})
259+
dispatch(action)
260+
261+
expect(mockStore.dispatch).toHaveBeenCalledWith(action)
262+
expect(mockReactotron.reportReduxAction).toHaveBeenCalledWith(action, 1000, false, [
263+
{ oldValue: "old value", value: "new value", type: "CHANGE", path: ["value"] },
264+
])
241265
})
242266
})

‎lib/reactotron-redux/src/customDispatch.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import diff from "microdiff"
2+
13
import { PluginConfig } from "./pluginConfig"
24

35
export default function createCustomDispatch(
46
reactotron: any,
57
// eslint-disable-next-line @typescript-eslint/ban-types
6-
store: { dispatch: Function },
8+
store: { dispatch: Function; getState?: Function },
79
pluginConfig: PluginConfig
810
) {
911
const exceptions = [pluginConfig.restoreActionType, ...(pluginConfig.except || [])]
@@ -12,6 +14,9 @@ export default function createCustomDispatch(
1214
// start a timer
1315
const elapsed = reactotron.startTimer()
1416

17+
// save the state before the action is dispatched to be used on "diff"
18+
const oldState = store?.getState?.()
19+
1520
// call the original dispatch that actually does the real work
1621
const result = store.dispatch(action)
1722

@@ -41,8 +46,8 @@ export default function createCustomDispatch(
4146
if (pluginConfig && typeof pluginConfig.isActionImportant === "function") {
4247
important = !!pluginConfig.isActionImportant(unwrappedAction)
4348
}
44-
45-
reactotron.reportReduxAction(unwrappedAction, ms, important)
49+
const state = store?.getState?.()
50+
reactotron.reportReduxAction(unwrappedAction, ms, important, diff(oldState, state))
4651
}
4752

4853
return result

‎lib/reactotron-redux/src/sendAction.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ReactotronCore } from "reactotron-core-client"
33
export default function createSendAction<Client extends ReactotronCore = ReactotronCore>(
44
reactotron: Client
55
) {
6-
return (action: { type: any }, ms: number, important = false) => {
6+
return (action: { type: any }, ms: number, important = false, diff?: any) => {
77
// let's call the type, name because that's "generic" name in Reactotron
88
let { type: name } = action
99

@@ -16,6 +16,6 @@ export default function createSendAction<Client extends ReactotronCore = Reactot
1616
}
1717

1818
// off ya go!
19-
reactotron.send("state.action.complete", { name, action, ms }, important)
19+
reactotron.send("state.action.complete", { name, action, ms, diff }, important)
2020
}
2121
}

0 commit comments

Comments
 (0)
Please sign in to comment.