Skip to content

TypeScript/Redux: action handlers (.match / addCase) emit only imports — "What listens to X?" returns the dispatchers instead (PE-80047) #61

Open

Description

@filipechagas

Found while validating the fork on lawnstarter/sales-platform for PE-80047 (https://lawnstarter.atlassian.net/browse/PE-80047). Graded shape: Q3 — "What listens to event X?".

Summary

For Redux Toolkit events, the graph is inverted relative to the question. The code that dispatches an action gets a typed calls edge (it invokes the action creator). The code that handles it — action.match(...) in a middleware, builder.addCase(action, ...) in a slice — gets only a plain imports edge, indistinguishable from a test file or a type-only importer.

So the only relation-typed answer explain can give for "what listens to X" points at the emitters, and the real listeners are buried in undifferentiated imports.

Minimal reproduction

Four files, graphify update .:

// src/actions.ts
import { createAction } from '@reduxjs/toolkit';
export const thingHappened = createAction<{ id: string }>('tracking/thingHappened');
// src/listenerMiddleware.ts   — LISTENER
import { thingHappened } from './actions';
export const middleware = () => (next: (a: unknown) => unknown) => (action: unknown) => {
  if (thingHappened.match(action)) {
    console.log('handled');
  }
  return next(action);
};
// src/listenerSlice.ts        — LISTENER
import { createSlice } from '@reduxjs/toolkit';
import { thingHappened } from './actions';
export const slice = createSlice({
  name: 'thing',
  initialState: { flag: false },
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(thingHappened, (state) => { state.flag = true; });
  },
});
// src/dispatcher.ts           — DISPATCHER, not a listener
import { thingHappened } from './actions';
export function fire(dispatch: (a: unknown) => void) {
  dispatch(thingHappened({ id: 'x' }));
}

Inbound edges to thingHappened:

contains <- actions.ts             | src/actions.ts
imports  <- dispatcher.ts          | src/dispatcher.ts
calls    <- fire()                 | src/dispatcher.ts            <-- the EMITTER gets the typed edge
imports  <- listenerMiddleware.ts  | src/listenerMiddleware.ts    <-- LISTENER, untyped
imports  <- listenerSlice.ts       | src/listenerSlice.ts         <-- LISTENER, untyped

Real-world impact measured on sales-platform

propertyIncompleteAtClose (apps/sales-tool/src/store/modules/tracking/actions.ts:38) has exactly two listeners by git grep + per-hit adjudication:

  • apps/sales-tool/src/store/middleware/analyticsMiddleware.ts:828 — if (propertyIncompleteAtClose.match(action)) { ... }
  • apps/sales-tool/src/store/modules/property-details/slice.ts:38 — .addCase(propertyIncompleteAtClose, (state) => { ... })

graphify explain "propertyIncompleteAtClose" returns 13 connections. Both listeners are in there — as imports, alongside three test files that are also imports. The only three calls edges are the three dispatchers:

<-- middleware/analyticsMiddleware.ts [imports]  ...analyticsMiddleware.ts:L37    <-- listener
<-- property-details/slice.ts         [imports]  ...property-details/slice.ts:L4  <-- listener
<-- PaymentInformationSection()       [calls]    ...PaymentInformationSection/index.tsx:L41
<-- ActionButton()                    [calls]    ...ConfirmationSection/components/ActionButton.tsx:L28
<-- PostPaymentContinue()             [calls]    ...PaymentInformationSection/components/PostPaymentContinue.tsx:L30

An agent that trusts the typed edges answers "the three CTA components listen to it" — which is exactly backwards.

Environment

  • fork v8 @ 5e9d95c11837aeabbbb5d90cb45544f49dc58ca4, graphify 0.9.40
  • cold cache, fresh clone of lawnstarter/sales-platform @ 701c8eb; 1266 files, 6722 nodes, 15324 edges

Suggested direction

Recognize the two RTK handler-registration shapes and emit a distinct relation (e.g. handles / listens_to) from the enclosing middleware or slice node to the action node:

  • <actionCreator>.match(x) used as a condition
  • builder.addCase(<actionCreator>, ...) / addMatcher(<actionCreator>.match, ...)

Emitting dispatch(<actionCreator>(...)) as dispatches rather than plain calls would make the pair queryable in both directions.

Activity

  1. filipechagas commented on Aug 11, 2026

    @filipechagas
    CollaboratorAuthor

    Triage: lead issue for the Redux Toolkit awareness work unit (with #65). One PR against the JS/TS extractor: endpoint node minting inside injectEndpoints/createApi callbacks (#65), plus one recognizer covering all three handler-registration shapes — <action>.match(x), addCase(<action>, …), addMatcher(<api>.endpoints.<name>.matchFulfilled, …) — emitting a listener-direction edge. #65's homonym-warning sub-ask needs no separate code: minting the endpoint node activates the existing ambiguity tiers.

    Sequencing notes:

    #65 stays open; both are closed by the one PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions