Skip to content

Commit edb48d5

Browse files
committed
fix(webapp): close the queued-reload race and the backslash route escape
1 parent a6782c0 commit edb48d5

5 files changed

Lines changed: 23 additions & 3 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ export function DashboardAgentTurns({
301301
// Must be the exact parts the turns render: the winners map keys by part index.
302302
const stripped = useMemo(() => messages.map(stripStepParts), [messages]);
303303

304-
// Must stay the last child of this fragment; see `progress-line.ts`.
304+
// Must not go null mid-flight: null unmounts the line and it blinks. The error
305+
// callout below legitimately renders after it.
305306
const progress = liveProgress(stripped, activity);
306307

307308
const investigationWinners = useInvestigationWinners(stripped);

apps/webapp/app/components/dashboard-agent/coalesced-reload.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ describe("createCoalescedReload", () => {
7777
expect(started).toBe(2);
7878
});
7979

80+
it("joins the queued run when a request lands as the run in front of it settles", async () => {
81+
const c = controllable();
82+
const reload = createCoalescedReload(c.run);
83+
84+
const first = reload();
85+
// Runs in the window between the first run settling and the queued one starting.
86+
void first.then(() => {
87+
void reload();
88+
});
89+
void reload();
90+
91+
c.finish(0);
92+
await settle();
93+
expect(c.started).toBe(2);
94+
});
95+
8096
it("starts a fresh run once nothing is in flight", async () => {
8197
const c = controllable();
8298
const reload = createCoalescedReload(c.run);

apps/webapp/app/components/dashboard-agent/coalesced-reload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ export function createCoalescedReload(run: () => Promise<void>): () => Promise<v
1717
};
1818

1919
return () => {
20-
if (!inFlight) return start();
20+
// Queued first: `inFlight` is cleared one microtask before the queued run starts.
2121
if (queued) return queued;
22+
if (!inFlight) return start();
2223
// Settles either way: a failed run must not strand the queued one.
2324
const next = inFlight
2425
.catch(() => {})

apps/webapp/app/components/dashboard-agent/navigate-target.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe("navigateDestination", () => {
5757

5858
it("refuses a protocol-relative path, which would change host", () => {
5959
expect(navigateDestination({ path: "//evil.example/runs" })).toEqual({ kind: "none" });
60+
expect(navigateDestination({ path: "/\\evil.example/runs" })).toEqual({ kind: "none" });
6061
});
6162

6263
it("resolves to nothing when there is no target", () => {

apps/webapp/app/components/dashboard-agent/navigate-target.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export function navigateDestination(
4848
const target = resolved?.path;
4949
if (!target) return { kind: "none" };
5050

51-
const routable = !resolved?.external && target.startsWith("/") && !target.startsWith("//");
51+
// `/\` too: a URL parser maps the backslash to a slash, so it leaves the origin.
52+
const routable = !resolved?.external && target.startsWith("/") && !/^\/[/\\]/.test(target);
5253
if (routable) return { kind: "route", path: appendRunFilters(target, filters) };
5354

5455
// Run filters belong to the runs page, so they are dropped rather than pushed onto a foreign URL.

0 commit comments

Comments
 (0)