diff --git a/TRACKER.md b/TRACKER.md index abc59fa..430aa27 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 3 — Journey graph -- **Next step:** 3.2 — Action effects -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1 +- **Next step:** 3.3 — Journey query (lazy expansion) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.2 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) ## What CodeRadar is @@ -187,7 +187,7 @@ The heart of the project. C1 and B1 live here. **Build:** `RouteNode` (`path`, `layout`, `guards`) in core. Adapters: React Router (`createBrowserRouter` / `` trees, nested + lazy) and Next.js file-based (app + pages router). `routes-to` edges route → page-component instance tree. **Accept:** fixtures `b4-react-router`, `b4-nextjs-approuter` green: every golden route maps to its page component. -### [ ] 3.2 Action effects +### [x] 3.2 Action effects **Failure modes:** B3, B2 (dispatch half) **Build:** mine resolved handler bodies (from 2.3) for effects, each a typed `triggers` edge from the EventNode: - `navigate(...)`/`router.push(...)` → `navigates-to` with route pattern (computed strings → `:param` form, B3) diff --git a/eval/fixtures/b3-programmatic-nav/app/routes.tsx b/eval/fixtures/b3-programmatic-nav/app/routes.tsx new file mode 100644 index 0000000..7c6e128 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/routes.tsx @@ -0,0 +1,13 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { CartPage } from "./screens/CartPage"; +import { CheckoutPage } from "./screens/CheckoutPage"; +import { UserDetail } from "./screens/UserDetail"; +import { UsersPage } from "./screens/UsersPage"; + +export const router = createBrowserRouter([ + { path: "/users", element: }, + { path: "/users/:userId", element: }, + { path: "/cart", element: }, + { path: "/checkout", element: }, +]); diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx new file mode 100644 index 0000000..89622ea --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; + +import { clearCart } from "../store/cartSlice"; + +export function CartPage() { + const dispatch = useDispatch(); + const [open, setOpen] = useState(false); + + // Local setter → writes-state on the local useState slot. + const showDetails = () => setOpen(true); + + return ( +
+

Your cart

+ {/* Inline dispatch of a plain action → writes-state on the cart slice. */} + + + {open ?

Cart details

: null} +
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx new file mode 100644 index 0000000..64ec5c6 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx @@ -0,0 +1,18 @@ +import { useNavigate } from "react-router-dom"; + +export function CheckoutPage() { + const navigate = useNavigate(); + + // Resolves to a known route → navigates-to /cart. + const goBack = () => navigate("/cart"); + // Resolves to a path with no declared route → flagged unresolved-nav. + const goBroken = () => navigate("/nowhere"); + + return ( +
+

Checkout

+ + +
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx new file mode 100644 index 0000000..8ac7097 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx @@ -0,0 +1,7 @@ +export function UserDetail() { + return ( +
+

User profile

+
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx b/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx new file mode 100644 index 0000000..544433c --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx @@ -0,0 +1,36 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { fetchUsers } from "../store/usersSlice"; + +interface Row { + id: string; + name: string; +} + +export function UsersPage() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const rows: Row[] = []; + + // Local handler dispatching a thunk → writes-state on the users slice. + const refreshUsers = () => dispatch(fetchUsers()); + // Local handler issuing a fetch → triggers a data source. + const exportUsers = () => fetch("/api/users/export", { method: "POST" }); + + return ( +
+

All users

+ + +
    + {rows.map((row) => ( +
  • + {/* Inline navigate with a computed param → /users/:id, shape-joined to the route. */} + +
  • + ))} +
+
+ ); +} diff --git a/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts b/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts new file mode 100644 index 0000000..ca729c0 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts @@ -0,0 +1,18 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const cartSlice = createSlice({ + name: "cart", + initialState: { items: [] as string[] }, + reducers: { + // A plain reducer action: dispatch(clearCart()) writes this slice. + clearCart(state) { + state.items = []; + }, + addItem(state, action) { + state.items.push(action.payload); + }, + }, +}); + +export const { clearCart, addItem } = cartSlice.actions; +export default cartSlice.reducer; diff --git a/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts b/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts new file mode 100644 index 0000000..b728d9d --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts @@ -0,0 +1,25 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +// Thunk: the fetch lives here; dispatch(fetchUsers()) in a handler triggers it. +export const fetchUsers = createAsyncThunk("users/fetch", async () => { + const res = await fetch("/api/users"); + return res.json(); +}); + +const usersSlice = createSlice({ + name: "users", + initialState: { list: [] as string[] }, + reducers: { + selectUser(state, action) { + (state as { selected?: string }).selected = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchUsers.fulfilled, (state, action) => { + state.list = action.payload; + }); + }, +}); + +export const { selectUser } = usersSlice.actions; +export default usersSlice.reducer; diff --git a/eval/fixtures/b3-programmatic-nav/golden.json b/eval/fixtures/b3-programmatic-nav/golden.json new file mode 100644 index 0000000..dd25c84 --- /dev/null +++ b/eval/fixtures/b3-programmatic-nav/golden.json @@ -0,0 +1,29 @@ +{ + "failureMode": "B3", + "note": "Action effects: resolved handler bodies mined for effects, each a typed edge from the EventNode. navigate()/router.push → navigates-to a RouteNode (computed target folded to :param form, joined by path shape); fetch in a handler → triggers a data source; dispatch(thunk|action) → writes-state on the slice; setState setter → writes-state on the local slot. A navigate to a path with no declared route is flagged unresolved-nav, never silent.", + "expect": { + "components": [ + { "name": "UsersPage", "instances": 0 }, + { "name": "CartPage", "instances": 0 }, + { "name": "CheckoutPage", "instances": 0 } + ], + "routes": [ + { "path": "/users", "component": "UsersPage" }, + { "path": "/users/:userId", "component": "UserDetail" }, + { "path": "/cart", "component": "CartPage" }, + { "path": "/checkout", "component": "CheckoutPage" } + ], + "effects": [ + { "component": "UsersPage", "event": "onClick", "effect": "writes-state", "to": "users" }, + { "component": "UsersPage", "event": "onClick", "effect": "triggers", "to": "/api/users/export" }, + { "component": "UsersPage", "event": "onClick", "effect": "navigates-to", "to": "/users/:userId" }, + { "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "cart" }, + { "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "open" }, + { "component": "CheckoutPage", "event": "onClick", "effect": "navigates-to", "to": "/cart" } + ], + "queries": [ + { "terms": ["All users"], "status": "ok", "top": "UsersPage" }, + { "terms": ["Your cart"], "status": "ok", "top": "CartPage" } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index f9af332..f499edb 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -128,6 +128,43 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): }); } + for (const expected of golden.expect.effects ?? []) { + const id = `effect:${expected.component}.${expected.event}:${expected.effect}->${expected.to}`; + const owner = graph.nodes.find( + (n) => n.kind === "component" && n.name === expected.component, + ); + // Events the component handles, filtered to the named event (onClick, …). + const eventIds = new Set( + graph.edges + .filter((e) => e.kind === "handles" && owner !== undefined && e.from === owner.id) + .map((e) => e.to) + .filter((eventId) => { + const event = graph.nodes.find((n) => n.id === eventId); + return event?.kind === "event" && event.event === expected.event; + }), + ); + const matched = graph.edges.some((edge) => { + if (edge.kind !== expected.effect || !eventIds.has(edge.from)) return false; + const target = graph.nodes.find((n) => n.id === edge.to); + if (target === undefined) return false; + if (target.kind === "route") return target.path === expected.to; + if (target.kind === "data-source") return target.endpoint === expected.to; + if (target.kind === "state") return target.name === expected.to; + return false; + }); + finalize( + "effects", + id, + matched, + expected.expectedFail, + matched + ? undefined + : owner === undefined + ? "component not found" + : `no ${expected.effect} edge from a ${expected.component}.${expected.event} event to ${expected.to}`, + ); + } + for (const query of golden.expect.queries ?? []) { const id = `query:${query.terms.join("+")}`; const result = matchComponentsByText(graph, query.terms); diff --git a/eval/src/golden.ts b/eval/src/golden.ts index 7f7cfb3..7cbc479 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -53,6 +53,22 @@ export interface GoldenRoute { expectedFail?: string; } +export interface GoldenEffect { + /** Component that owns the event (a `handles` edge from it reaches the event). */ + component: string; + /** Event name the effect hangs off, e.g. "onClick", "onSubmit". */ + event: string; + /** The effect edge kind asserted from the event node. */ + effect: "navigates-to" | "triggers" | "writes-state"; + /** + * Expected target, matched against the edge's destination node: + * a route path (navigates-to), a data-source endpoint (triggers), or a + * state/slice name (writes-state). + */ + to: string; + expectedFail?: string; +} + export interface Golden { failureMode: string; note?: string; @@ -72,6 +88,8 @@ export interface Golden { routes?: GoldenRoute[]; /** Paths that must NOT exist as routes (api handlers, _app…). Never xfails. */ forbiddenRoutes?: string[]; + /** Action effects (step 3.2): event → navigates-to / triggers / writes-state. */ + effects?: GoldenEffect[]; }; } @@ -80,7 +98,7 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass"; export interface CheckResult { /** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */ id: string; - kind: "components" | "attributions" | "forbidden" | "queries" | "routes"; + kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects"; status: CheckStatus; detail?: string; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index afb30e7..800c13b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -219,9 +219,10 @@ export type EdgeKind = | "fetches-from" // component | hook -> data-source | "provides-data" // data-source -> instance (via a prop; Phase 2.2) | "reads-state" // component | hook -> state - | "writes-state" // data-source | event -> state (Phase 2.4) + | "writes-state" // data-source | event -> state (Phase 2.4; dispatch effects Phase 3.2) | "handles" // component -> event - | "triggers" // event -> data-source | state (handler causes a fetch / state write) + | "triggers" // event -> data-source (resolved handler body issues a fetch; Phase 3.2) + | "navigates-to" // event -> route (handler calls navigate/router.push; Phase 3.2, B3) | "routes-to"; // route -> page component definition (its instances form the page tree) /** A statically-detected condition guarding an edge (feature flag, role, branch). */ diff --git a/packages/parser-react/src/effects.test.ts b/packages/parser-react/src/effects.test.ts new file mode 100644 index 0000000..48ba09b --- /dev/null +++ b/packages/parser-react/src/effects.test.ts @@ -0,0 +1,92 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { LineageGraph, LineageNode } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures"); + +const b3 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b3-programmatic-nav/app") })); + +/** The effect target reachable from a `handler`-named event by an edge of `kind`. */ +function effectTargets(graph: LineageGraph, handler: string, kind: string): LineageNode[] { + const event = graph.nodes.find((n) => n.kind === "event" && n.handler === handler); + const inline = graph.nodes.filter((n) => n.kind === "event" && n.handler === null); + const events = event !== undefined ? [event] : inline; + const ids = new Set(events.map((e) => e.id)); + return graph.edges + .filter((e) => e.kind === kind && ids.has(e.from)) + .map((e) => graph.nodes.find((n) => n.id === e.to)) + .filter((n): n is LineageNode => n !== undefined); +} + +describe("action effects (b3 fixture, TRACKER 3.2)", () => { + it("navigate() in a handler → navigates-to the matching route (exact path)", () => { + const targets = effectTargets(b3, "goBack", "navigates-to"); + expect(targets.map((t) => (t.kind === "route" ? t.path : t.id))).toEqual(["/cart"]); + }); + + it("navigate(`/users/${id}`) joins the route by param-agnostic shape", () => { + // Handler is an inline arrow (handler === null); match the /users/:userId route. + const routes = b3.edges + .filter((e) => e.kind === "navigates-to") + .map((e) => b3.nodes.find((n) => n.id === e.to)) + .filter((n) => n?.kind === "route"); + expect(routes.some((r) => r?.kind === "route" && r.path === "/users/:userId")).toBe(true); + }); + + it("a navigate to a path with no declared route is flagged, never silently dropped", () => { + const broken = b3.nodes.find((n) => n.kind === "event" && n.handler === "goBroken"); + expect(broken?.flags).toContain("unresolved-nav"); + expect(b3.edges.some((e) => e.kind === "navigates-to" && e.from === broken?.id)).toBe(false); + }); + + it("fetch() in a handler → triggers a data source", () => { + const targets = effectTargets(b3, "exportUsers", "triggers"); + const endpoints = targets.map((t) => (t.kind === "data-source" ? t.endpoint : t.id)); + expect(endpoints).toContain("/api/users/export"); + }); + + it("dispatch(thunk()) → writes-state on the slice the thunk populates", () => { + const targets = effectTargets(b3, "refreshUsers", "writes-state"); + const names = targets.map((t) => t.name); + expect(names).toContain("users"); + }); + + it("dispatch(action()) of a plain reducer → writes-state on that slice", () => { + // Inline arrow onClick={() => dispatch(clearCart())}. + const writes = b3.edges + .filter((e) => e.kind === "writes-state") + .map((e) => b3.nodes.find((n) => n.id === e.to)) + .filter((n) => n?.kind === "state"); + const fromEvent = b3.edges.some((e) => { + if (e.kind !== "writes-state") return false; + const from = b3.nodes.find((n) => n.id === e.from); + const to = b3.nodes.find((n) => n.id === e.to); + return from?.kind === "event" && to?.kind === "state" && to.name === "cart"; + }); + expect(writes.some((n) => n?.name === "cart")).toBe(true); + expect(fromEvent).toBe(true); + }); + + it("a local setState setter → writes-state on the local slot", () => { + const targets = effectTargets(b3, "showDetails", "writes-state"); + const names = targets.map((t) => t.name); + expect(names).toContain("open"); + const slot = targets.find((t) => t.name === "open"); + expect(slot?.kind === "state" ? slot.stateKind : undefined).toBe("useState"); + }); +}); + +describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => { + const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") })); + + it("a 4-level drilled handler still emits its triggers edge", () => { + const event = b1.nodes.find((n) => n.kind === "event" && n.handler === "onSave"); + const triggers = b1.edges.filter((e) => e.kind === "triggers" && e.from === event?.id); + const target = b1.nodes.find((n) => n.id === triggers[0]?.to); + expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/drafts"); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index acb5e6d..4f55dde 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -134,6 +134,8 @@ export function scanReact(options: ScanOptions): LineageGraph { } }; const stores = detectStores(project, root, nodes, addEdge, baseUrls, wrappers); + /** Event node id → the handler expressions it wires, mined for effects (3.2). */ + const handlerExprs = new Map(); for (const sourceFile of project.getSourceFiles()) { const file = toPosix(path.relative(root, sourceFile.getFilePath())); @@ -171,10 +173,10 @@ export function scanReact(options: ScanOptions): LineageGraph { nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName }); } - extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores); + extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); } - scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores); + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs); collectHocAliases(sourceFile, hocAliases); } @@ -187,8 +189,20 @@ export function scanReact(options: ScanOptions): LineageGraph { root, ); resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers); - resolveHandlerChains(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers, root); + // Routes first: navigate() effects (3.2) join to RouteNodes by path shape. detectRoutes(project, root, nodes, addEdge); + resolveHandlerChains( + pendingInstances, + instanceIds, + nodes, + edges, + addEdge, + baseUrls, + wrappers, + stores, + handlerExprs, + root, + ); return { version: 2, @@ -407,6 +421,7 @@ function extractBodyFacts( baseUrls: string[], wrappers: WrapperRegistry, stores: StoreRegistry, + handlerExprs: Map, ): void { // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call @@ -488,8 +503,10 @@ function extractBodyFacts( if (!/^on[A-Z]/.test(attrName)) continue; const init = attr.getInitializer(); let handler: string | null = null; + let handlerExpr: Node | undefined; if (init !== undefined && Node.isJsxExpression(init)) { const expr = init.getExpression(); + handlerExpr = expr; // Plain references (handleDelete) and method references (this.refresh). if ( expr !== undefined && @@ -509,6 +526,13 @@ function extractBodyFacts( handler, }); } + // The handler expression is mined for effects in resolveHandlerChains (3.2); + // stored by node id so the same evId collects every inline arrow it carries. + if (handlerExpr !== undefined) { + const list = handlerExprs.get(evId); + if (list) list.push(handlerExpr); + else handlerExprs.set(evId, [handlerExpr]); + } addEdge({ from: ownerId, to: evId, kind: "handles" }); } } @@ -708,7 +732,7 @@ function stateVariableName(call: CallExpression): string | null { return null; } -/** Store registry (TRACKER step 2.4, failure mode C6). */ +/** Store registry (TRACKER step 2.4, failure mode C6; dispatch effects 3.2). */ interface StoreRegistry { /** Redux slice name → its global StateNode id. */ reduxSlices: Map; @@ -716,6 +740,14 @@ interface StoreRegistry { zustandHooks: Map; /** createAsyncThunk name → the data-source node ids its payload creator hits. */ thunkSources: Map; + /** + * Reducer action-creator name (a `reducers` key of a createSlice) → the + * slice StateNode it mutates. Lets `dispatch(clearCart())` resolve to the + * cart slice (TRACKER step 3.2, B2 dispatch half). + */ + actionSlices: Map; + /** createAsyncThunk name → the slice StateNode its extraReducers populate. */ + thunkSlices: Map; } /** @@ -737,6 +769,8 @@ function detectStores( reduxSlices: new Map(), zustandHooks: new Map(), thunkSources: new Map(), + actionSlices: new Map(), + thunkSlices: new Map(), }; const ensureDataSource = (call: Node, file: string): string | null => { @@ -788,6 +822,19 @@ function detectStores( }); } registry.reduxSlices.set(sliceName, stateId); + // Reducer keys are action-creator names: dispatch(clearCart()) writes here. + const reducers = config !== undefined ? propertyInitializer(config, "reducers") : undefined; + if (reducers !== undefined && Node.isObjectLiteralExpression(reducers)) { + for (const property of reducers.getProperties()) { + const nameNode = + Node.isPropertyAssignment(property) || Node.isMethodDeclaration(property) + ? property.getNameNode() + : Node.isShorthandPropertyAssignment(property) + ? property.getNameNode() + : undefined; + if (nameNode !== undefined) registry.actionSlices.set(nameNode.getText(), stateId); + } + } } else if (callee === "createAsyncThunk") { const payloadCreator = init.getArguments()[1]; const sources: string[] = []; @@ -835,6 +882,8 @@ function detectStores( if (nameInit === undefined || !Node.isStringLiteral(nameInit)) continue; const stateId = registry.reduxSlices.get(nameInit.getLiteralValue()); if (stateId === undefined) continue; + // dispatch(fetchUsers()) writes this slice (via the thunk's fulfilled case). + registry.thunkSlices.set(thunkName, stateId); for (const dsId of sources) { addEdge({ from: dsId, to: stateId, kind: "writes-state" }); } @@ -857,6 +906,7 @@ function scanClassComponents( localeTable: LocaleTable | null, pendingInstances: PendingInstance[], stores: StoreRegistry, + handlerExprs: Map, ): void { for (const cls of sourceFile.getClasses()) { const name = cls.getName(); @@ -898,7 +948,7 @@ function scanClassComponents( }); collectInstanceSites(render, id, file, pendingInstances); // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. - extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores); + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs); extractClassState(cls, name, id, file, nodes, addEdge); } } @@ -1309,22 +1359,42 @@ function resolvePropFlow( } /** - * Handler resolution through props (TRACKER step 2.3, failure mode B1). + * Handler resolution + action effects (TRACKER steps 2.3 & 3.2; B1, B3, B2). * - *