Skip to content

Commit 3776287

Browse files
Merge pull request #17 from officialCodeWork/build/phase-2/step-2.5-portals
feat(parser-react): portals & toasts (A9) — Gate 2 complete, Phase 2 done
2 parents acbf574 + eee44d0 commit 3776287

9 files changed

Lines changed: 144 additions & 10 deletions

File tree

TRACKER.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
## Status
66

7-
- **Current phase:** 2Instance graph & cross-file data flow
8-
- **Next step:** 2.5Portals, modals, toasts
9-
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.4
10-
- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) · Gate 1 (precision 1.000 ≥ 0.90, recall 0.895 ≥ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits)
7+
- **Current phase:** 3Journey graph
8+
- **Next step:** 3.1Router adapters
9+
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5
10+
- **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)
1111

1212
## What CodeRadar is
1313

@@ -173,7 +173,7 @@ The heart of the project. C1 and B1 live here.
173173
- `traceLineage` through a StateNode now continues to its writers: reader component → slice → populating API.
174174
**Accept:** fixture `c6-store-decoupled` green — component with **no fetch of its own** correctly attributed to the API called on a different page at login.
175175

176-
### [ ] 2.5 Portals, modals, toasts
176+
### [x] 2.5 Portals, modals, toasts
177177
**Failure modes:** A9
178178
**Build:** `createPortal` detection + adapter list for common modal/toast libs (`react-modal`, radix `Dialog`, `react-hot-toast`): the triggering instance gets a `triggers-render` edge to the portal-content instance.
179179
**Accept:** fixture `a9-modal-portal` green: matching the modal's text surfaces both the modal component and its trigger site. **Gate 2 passes.**
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createPortal } from "react-dom";
2+
3+
export function ConfirmDialog({
4+
open,
5+
onConfirm,
6+
}: {
7+
open: boolean;
8+
onConfirm: () => void;
9+
}) {
10+
if (!open) return null;
11+
return createPortal(
12+
<div role="dialog">
13+
<h2>Delete this order?</h2>
14+
<p>This cannot be undone.</p>
15+
<button onClick={onConfirm}>Confirm delete</button>
16+
</div>,
17+
document.body,
18+
);
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useState } from "react";
2+
import { toast } from "react-hot-toast";
3+
4+
import { ConfirmDialog } from "./ConfirmDialog";
5+
6+
export function OrdersToolbar() {
7+
const [confirming, setConfirming] = useState(false);
8+
9+
const handleConfirm = () => {
10+
fetch("/api/orders/selected", { method: "DELETE" });
11+
setConfirming(false);
12+
toast("Order deleted");
13+
};
14+
15+
return (
16+
<div role="toolbar">
17+
<button onClick={() => setConfirming(true)}>Delete order</button>
18+
<ConfirmDialog open={confirming} onConfirm={handleConfirm} />
19+
</div>
20+
);
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"failureMode": "A9",
3+
"note": "Portals & toasts: the dialog renders into document.body via createPortal — a screenshot of it shows nothing from its trigger's DOM subtree. Matching the modal text must surface ConfirmDialog (whose instance list points at the OrdersToolbar trigger site). Toast text ('Order deleted') exists only as a call argument; it must match the CALLING component.",
4+
"expect": {
5+
"components": [
6+
{ "name": "ConfirmDialog", "instances": 1 },
7+
{ "name": "OrdersToolbar", "instances": 0 }
8+
],
9+
"attributions": [
10+
{ "component": "OrdersToolbar", "endpoints": ["/api/orders/selected"] },
11+
{
12+
"component": "ConfirmDialog",
13+
"instanceAt": "OrdersToolbar.tsx",
14+
"endpoints": ["/api/orders/selected"],
15+
"_note": "not data-in: the dialog's confirm button TRIGGERS the DELETE via the resolved handler chain (2.3) — correct effect lineage"
16+
}
17+
],
18+
"queries": [
19+
{ "terms": ["Delete this order?"], "status": "ok", "top": "ConfirmDialog" },
20+
{ "terms": ["Confirm delete"], "status": "ok", "top": "ConfirmDialog" },
21+
{ "terms": ["Order deleted"], "status": "ok", "top": "OrdersToolbar" },
22+
{ "terms": ["Delete order"], "status": "ok", "top": "OrdersToolbar" }
23+
]
24+
}
25+
}

eval/history.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
1010
{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
1111
{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
12+
{"generatedAt":"2026-07-14T16:19:16.873Z","commitSha":"acbf574f777e001b553ece6ea206ddbe1f1a3892","pass":137,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}

packages/core/src/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ export interface RenderedText {
4747
text: string;
4848
/**
4949
* Where the text comes from: JSX children, a string attribute
50-
* (placeholder/label/title/alt/aria-label), or an i18n key resolved
51-
* against locale files.
50+
* (placeholder/label/title/alt/aria-label), an i18n key resolved against
51+
* locale files, or portal-rendered content (toast("Order deleted") — the
52+
* text appears far from the caller in the DOM, but the CALLER is the
53+
* component a screenshot of it should match).
5254
*/
53-
source: "jsx" | "attribute" | "i18n";
55+
source: "jsx" | "attribute" | "i18n" | "portal";
5456
/** i18n entries only: the translation key, e.g. "team.title". */
5557
key?: string;
5658
/** i18n entries only: which locale this text belongs to. */
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
import { matchComponentsByText } from "@coderadar/core";
5+
import { describe, expect, it } from "vitest";
6+
7+
import { scanReact } from "./scan.js";
8+
9+
const fixture = path.resolve(
10+
path.dirname(fileURLToPath(import.meta.url)),
11+
"../../../eval/fixtures/a9-modal-portal/app",
12+
);
13+
14+
const graph = scanReact({ root: fixture });
15+
16+
describe("portals & toasts (a9 fixture)", () => {
17+
it("flags createPortal components", () => {
18+
const dialog = graph.nodes.find((n) => n.kind === "component" && n.name === "ConfirmDialog");
19+
expect(dialog?.flags).toContain("portal");
20+
});
21+
22+
it("matching modal text surfaces the component AND its trigger site", () => {
23+
const result = matchComponentsByText(graph, ["Delete this order?"]);
24+
expect(result.status).toBe("ok");
25+
const match = result.candidates[0]?.value;
26+
expect(match?.component.name).toBe("ConfirmDialog");
27+
expect(match?.instances.map((i) => i.loc.file)).toEqual(["OrdersToolbar.tsx"]);
28+
});
29+
30+
it("attributes toast text to the calling component with portal provenance", () => {
31+
const toolbar = graph.nodes.find(
32+
(n) => n.kind === "component" && n.name === "OrdersToolbar",
33+
);
34+
if (toolbar?.kind !== "component") throw new Error("OrdersToolbar not found");
35+
const entry = toolbar.renderedText.find((e) => e.text === "Order deleted");
36+
expect(entry?.source).toBe("portal");
37+
const result = matchComponentsByText(graph, ["Order deleted"]);
38+
expect(result.candidates[0]?.value.component.name).toBe("OrdersToolbar");
39+
});
40+
});

packages/parser-react/src/scan.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ const TEXT_ATTRIBUTES = new Set([
100100
"value",
101101
]);
102102
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
103+
const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/;
103104

104105
/** Scan a directory of React source and produce a lineage graph. */
105106
export function scanReact(options: ScanOptions): LineageGraph {
@@ -144,6 +145,12 @@ export function scanReact(options: ScanOptions): LineageGraph {
144145
const id = nodeId(kind, file, decl.name);
145146

146147
if (isComponent) {
148+
// Portal components (A9): rendered into document.body etc., far from
149+
// where they're triggered — flagged so agents know the screenshot's
150+
// DOM position won't match the render-tree position.
151+
const usesPortal = decl.fn
152+
.getDescendantsOfKind(SyntaxKind.CallExpression)
153+
.some((c) => /^(ReactDOM\.)?createPortal$/.test(c.getExpression().getText()));
147154
nodes.set(id, {
148155
id,
149156
kind: "component",
@@ -156,6 +163,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
156163
...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []),
157164
],
158165
rendersComponents: extractRenderedComponents(decl.fn),
166+
...(usesPortal ? { flags: ["portal"] } : {}),
159167
});
160168
collectInstanceSites(decl.fn, id, file, pendingInstances);
161169
} else {
@@ -292,6 +300,17 @@ function extractRenderedText(fn: Node): RenderedText[] {
292300
}
293301
}
294302

303+
// Toast/notification calls (A9): toast("Order deleted") renders via a portal
304+
// mounted elsewhere — the text belongs to the CALLING component for matching.
305+
for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
306+
if (!TOAST_CALLEES.test(call.getExpression().getText())) continue;
307+
const arg = call.getArguments()[0];
308+
if (arg !== undefined && Node.isStringLiteral(arg)) {
309+
const text = arg.getLiteralValue().trim();
310+
if (text.length > 0) add({ text, source: "portal", ...branchTag(call, fn) });
311+
}
312+
}
313+
295314
// Template literals rendered as JSX children: {`${count} items in cart`} →
296315
// "* items in cart" (unknown segments become * wildcards).
297316
for (const expr of fn.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
@@ -1213,6 +1232,10 @@ function resolvePropFlow(
12131232

12141233
const init = varDecl.getInitializer();
12151234
if (init !== undefined) {
1235+
// Function values are callbacks, not data: the fetch inside
1236+
// onConfirm={handleConfirm} is an EFFECT the child can trigger
1237+
// (handler chains, step 2.3), never data flowing into it.
1238+
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) continue;
12161239
// Direct result: const { data } = useQuery(...) / const r = useApi("/x")
12171240
// — including calls wrapped in await / as-casts.
12181241
fromCall(init);
@@ -1264,6 +1287,8 @@ function resolvePropFlow(
12641287
if (instanceId === null || instanceId === undefined) continue;
12651288
for (const attr of pending.element.getAttributes()) {
12661289
if (!Node.isJsxAttribute(attr)) continue;
1290+
// Event-handler props (onConfirm, onClick…) carry effects, not data.
1291+
if (/^on[A-Z]/.test(attr.getNameNode().getText())) continue;
12671292
const init = attr.getInitializer();
12681293
if (init === undefined || !Node.isJsxExpression(init)) continue;
12691294
const expr = init.getExpression();

schemas/lineage-graph.schema.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,10 @@
191191
"enum": [
192192
"jsx",
193193
"attribute",
194-
"i18n"
194+
"i18n",
195+
"portal"
195196
],
196-
"description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), or an i18n key resolved against locale files."
197+
"description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), an i18n key resolved against locale files, or portal-rendered content (toast(\"Order deleted\") — the text appears far from the caller in the DOM, but the CALLER is the component a screenshot of it should match)."
197198
},
198199
"key": {
199200
"type": "string",

0 commit comments

Comments
 (0)