Sibling of #20 (same function, independent bug; reproduces on stock 0.2.7 with or without the #20 buffer fix).
Summary
createChannelFromSelector (src/utils/sagas/selector-channel-effects.ts) updates its comparison baseline after calling the emitter:
const payload = cachedSelector(reduxStore.getState(), ...args);
if (shallowEqual(prevValue, payload)) return;
emitter({ payload, prevPayload: prevValue });
prevValue = payload; // too late — see below
emitter() is synchronous: with a taker attached it resumes the take*FromSelector loop, which forks the worker, and the worker's queued put is flushed by the redux-saga scheduler before emitter() returns. If that dispatch is a reducer no-op, the store still notifies subscribers, emitCurrentValue re-enters while prevValue is still the old value, shallowEqual(old, payload) is false again, and the same transition is emitted a second time (payload B, prevPayload A, twice) for a single store update.
If the worker dispatches a real state change instead, the nested emission carries the wrong prevPayload (A → C instead of B → C).
Repro
Real Redux createStore + saga middleware, takeEveryFromSelector(selectValue, worker):
- Initial state
{ value: 'A' }, selector state => state.value.
- Worker records
[prevPayload, payload], then yield put({ type: 'noop' }) (reducer returns the same state).
store.dispatch(set('B')).
Observed: worker called twice with ['A', 'B']. Expected: once.
Variant: worker puts set('C') when it sees B → observed [['A','B'], ['A','C']], expected [['A','B'], ['B','C']].
Suggested fix
Record the baseline before emitting, keeping prevPayload semantics:
const prevPayload = prevValue;
prevValue = payload;
emitter({ payload, prevPayload });
The selector still throws before the assignment, so reportRuntimeError handling and the retained old baseline on selector failure are unchanged.
Downstream tracking: intent-hq/intent#5040 (interim pnpm patch in cloudlands-fe, alongside the #20 patch).
Sibling of #20 (same function, independent bug; reproduces on stock 0.2.7 with or without the #20 buffer fix).
Summary
createChannelFromSelector(src/utils/sagas/selector-channel-effects.ts) updates its comparison baseline after calling the emitter:emitter()is synchronous: with a taker attached it resumes thetake*FromSelectorloop, which forks the worker, and the worker's queuedputis flushed by the redux-saga scheduler beforeemitter()returns. If that dispatch is a reducer no-op, the store still notifies subscribers,emitCurrentValuere-enters whileprevValueis still the old value,shallowEqual(old, payload)is false again, and the same transition is emitted a second time (payload B, prevPayload A, twice) for a single store update.If the worker dispatches a real state change instead, the nested emission carries the wrong
prevPayload(A → C instead of B → C).Repro
Real Redux
createStore+ saga middleware,takeEveryFromSelector(selectValue, worker):{ value: 'A' }, selectorstate => state.value.[prevPayload, payload], thenyield put({ type: 'noop' })(reducer returns the same state).store.dispatch(set('B')).Observed: worker called twice with
['A', 'B']. Expected: once.Variant: worker puts
set('C')when it seesB→ observed[['A','B'], ['A','C']], expected[['A','B'], ['B','C']].Suggested fix
Record the baseline before emitting, keeping
prevPayloadsemantics:The selector still throws before the assignment, so
reportRuntimeErrorhandling and the retained old baseline on selector failure are unchanged.Downstream tracking: intent-hq/intent#5040 (interim pnpm patch in cloudlands-fe, alongside the #20 patch).