Page action dropped from the panel after two quick monitor actions (`relayState` sends a stale snapshot)
Page action disappears from the panel when two monitor actions land within the 500 ms relay window (relayState throttles a stale lifted-state snapshot)
Summary
relayState in extension/src/pageScript/index.ts is a lodash throttle whose callback takes the lifted state as an argument. handleChange passes a snapshot taken at call time. When two monitor actions (Jump, Skip, Sweep, Commit, Reorder, or any other lifted action from the panel) arrive inside one throttle window, the second one fires on the trailing edge 500 ms later with the snapshot from when it was queued. Any page action dispatched in between was already relayed as an ACTION message and is then overwritten by that stale STATE. The panel loses the action and the page script's sendingActionId falls one behind the store until the next page action triggers a PARTIAL_STATE catch-up.
Users see it as: click Skip twice quickly (or Jump then Skip), the app dispatches something, and that action never appears in the action list until another action comes in. Jump/Skip on the following rows then target the wrong state index in the meantime.
Reproduction
Automated: extension/test-e2e/chrome/roundtrip.test.ts, test KNOWN DEFECT: two quick panel edits then a page dispatch drop the new action from the panel. It clicks Skip twice on the same row within the throttle window, dispatches DECREMENT on the fixture page, waits past the throttle, then asserts:
- the page store's
liftedStore.getState().nextActionIdis 6, - the last
STATEmessage the page script posted hasnextActionId: 5, - the panel shows 5 rows instead of 6.
The comment above the test says to flip the row count to 6 once the fix lands.
Manual, with the extension loaded and the counter example open in the docked panel:
- Dispatch three actions on the page.
- In the panel click Skip on row 2, then immediately click Skip on row 2 again (un-skip / re-skip, within half a second).
- Immediately dispatch another action on the page.
- Wait a second. The panel still shows the three original rows; the fourth action is missing. Dispatch one more action and both appear.
Root cause
extension/src/pageScript/index.ts:
- L176-203:
relayState = throttle((liftedState?, libConfig?) => { relayAction.cancel(); const state = liftedState || store.liftedStore.getState(); sendingActionId = state.nextActionId; toContentScript({ type: 'STATE', payload: filterState(state, ...) }) }, latency).latencydefaults to 500. - L502-519:
handleChangeruns on every store change. For monitor actions (L504monitor.isMonitorAction()), it falls through to L511const liftedState = store.liftedStore.getState()and L518relayState(liftedState). - L473-482:
notifyErrorsalso passes a snapshot (relayState(state)at L479). - L386 (
UPDATE) and L393 (START) callrelayState()/relayState(undefined, libConfig)and so read fresh state; those paths are fine.
Sequence for the failing case (times relative to the first Skip):
| t | Event | What runs |
|---|---|---|
| 0 | first TOGGLE_ACTION reaches the page store |
handleChange → relayState(snapshot A); throttle leading edge fires now. STATE with nextActionId: 5 goes out, sendingActionId = 5. |
| ~50 ms | second TOGGLE_ACTION |
handleChange → relayState(snapshot B). Inside the window, so lodash stores snapshot B as the trailing-edge args. snapshot B.nextActionId is still 5. |
| ~100 ms | page dispatches DECREMENT (action id 5) |
handleChange → not a monitor action → relayAction(). Leading edge fires: sendingActionId === currentActionId (5 === 5), so it sends ACTION with nextActionId: 6 and sets sendingActionId = 6. Panel now has the row. |
| 500 ms | throttle trailing edge | relayState(snapshot B) runs: relayAction.cancel(), sendingActionId = 5, sends STATE built from snapshot B, which has no action 5. The panel's UPDATE_STATE reducer replaces its lifted state with this payload, dropping the row. |
| next page action (id 6) | relayAction |
sendingActionId (5) !== currentActionId (6), so it goes down the multi-action path (startingFrom(5, ...), L272) and sends a PARTIAL_STATE that includes actions 5 and 6. Panel catches up. |
The stale snapshot is the whole problem. The lodash throttle trailing call is the right time to send; it is just sending the wrong data.
Proposed fix
Make the throttled callback always read the store when it runs, and drop the liftedState parameter:
const relayState = throttle((libConfig?: LibConfig) => {
relayAction.cancel();
const state = store.liftedStore.getState();
sendingActionId = state.nextActionId;
toContentScript({ type: 'STATE', payload: filterState(state, ...), source, instanceId, libConfig }, serializeState, serializeAction);
}, latency);Callers: handleChange L518 → relayState(); notifyErrors L479 → relayState(); UPDATE L386 unchanged; START L393 → relayState({ name, actionCreators, ... }). monitor = new Monitor(relayState) (L205) calls it with no arguments, so it is unaffected. Then flip the assertion in roundtrip.test.ts (rows 5 → 6, lastState.nextActionId 5 → 6) and rename the test.
The libConfig argument has the same trailing-edge hazard in principle (a START followed within 500 ms by a monitor action would send the second STATE without libConfig), but START sends libConfig on the leading edge, and the panel keeps it from the first message, so that is fine to leave.
The same shape exists in packages/redux-devtools-remote/src/devTools.ts (relay('STATE', liftedState) from handleChange), but there relay is not throttled, so the snapshot is always current. No change needed there.
Source: reduxjs/redux-devtools