Skip to content

Commit 201fe15

Browse files
authored
fix(extension): guard overlay refresh against out-of-order pull-context responses (#7489)
mountOverlay's refresh button called load() with no request-ordering guard, so two rapid clicks issued two independent pull-context round-trips and whichever resolved last won. Under network jitter the older click's response could resolve after the newer one, silently leaving the overlay showing stale data. Move load() into a per-overlay-instance createOverlayLoader closure that stamps each invocation with a monotonically incrementing ticket and discards any resolved response whose ticket is no longer the latest, so the rendered state always reflects the most recently initiated request. Single-click behavior (loading text, error and success rendering) is unchanged. Closes #7463 Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent e7cbb1b commit 201fe15

2 files changed

Lines changed: 61 additions & 14 deletions

File tree

apps/loopover-extension/content.js

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ function mountOverlay(target) {
3939
} else {
4040
document.body.appendChild(container);
4141
}
42+
const load = createOverlayLoader(container, target);
4243
const refresh = container.querySelector(".loopover-overlay__refresh");
43-
refresh?.addEventListener("click", () => load(container, target));
44-
void load(container, target);
44+
refresh?.addEventListener("click", () => load());
45+
void load();
4546
}
4647

4748
function findPullRequestSidebar() {
@@ -53,17 +54,23 @@ function findPullRequestSidebar() {
5354
);
5455
}
5556

56-
async function load(container, target) {
57-
const body = container.querySelector(".loopover-overlay__body");
58-
if (!body) return;
59-
body.textContent = "Loading private context...";
60-
const response = await chrome.runtime.sendMessage({ type: "loopover:pull-context", ...target });
61-
if (!response?.ok) {
62-
body.innerHTML = `<div class="loopover-overlay__error">${escapeHtml(response?.error || "Context unavailable")}</div>`;
63-
return;
64-
}
65-
body.innerHTML = renderPullContext(response.payload);
66-
renderActions(body, response.payload?.actions);
57+
function createOverlayLoader(container, target) {
58+
let latestTicket = 0;
59+
return async function load() {
60+
const body = container.querySelector(".loopover-overlay__body");
61+
if (!body) return;
62+
const ticket = ++latestTicket;
63+
body.textContent = "Loading private context...";
64+
const response = await chrome.runtime.sendMessage({ type: "loopover:pull-context", ...target });
65+
// A newer load() has started while this request was in flight; discard the stale response.
66+
if (ticket !== latestTicket) return;
67+
if (!response?.ok) {
68+
body.innerHTML = `<div class="loopover-overlay__error">${escapeHtml(response?.error || "Context unavailable")}</div>`;
69+
return;
70+
}
71+
body.innerHTML = renderPullContext(response.payload);
72+
renderActions(body, response.payload?.actions);
73+
};
6774
}
6875

6976
function renderPullContext(payload) {
@@ -186,6 +193,7 @@ if (globalThis.__LOOPOVER_EXTENSION_TEST__) {
186193
globalThis.__loopoverContentInternals = {
187194
matchGitHubPageTarget,
188195
matchPullRequestTarget,
196+
createOverlayLoader,
189197
renderPullContext,
190198
renderSection,
191199
renderLegacyPanels,

test/unit/extension-content.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,46 @@ describe("extension content script", () => {
7474
expect(html).toContain("public");
7575
expect(html).toContain("no");
7676
});
77+
78+
it("discards an out-of-order refresh response so the overlay keeps the newest request's payload", async () => {
79+
const resolvers: Array<(response: unknown) => void> = [];
80+
const sendMessage = vi.fn(() => new Promise((resolve) => resolvers.push(resolve)));
81+
const internals = loadContentInternals({
82+
chrome: { runtime: { sendMessage } },
83+
});
84+
85+
const body = { innerHTML: "", textContent: "" };
86+
const container = { querySelector: vi.fn(() => body) };
87+
const load = internals.createOverlayLoader(container, {
88+
kind: "pull_request",
89+
owner: "JSONbored",
90+
repo: "loopover",
91+
pullNumber: 42,
92+
});
93+
94+
// Two rapid refresh clicks: the second (newer) request is issued while the first is still in flight.
95+
const first = load();
96+
const second = load();
97+
expect(sendMessage).toHaveBeenCalledTimes(2);
98+
const [resolveFirst, resolveSecond] = resolvers;
99+
if (!resolveFirst || !resolveSecond) throw new Error("expected two in-flight pull-context requests");
100+
101+
// The newer request resolves and renders first...
102+
resolveSecond({ ok: true, payload: { sections: [{ label: "Newer context" }] } });
103+
await second;
104+
expect(body.innerHTML).toContain("Newer context");
105+
106+
// ...then the older, first-issued request resolves last — the out-of-order case. Its stale
107+
// payload must be discarded rather than clobbering the fresher render already on screen.
108+
resolveFirst({ ok: true, payload: { sections: [{ label: "Stale context" }] } });
109+
await first;
110+
111+
expect(body.innerHTML).toContain("Newer context");
112+
expect(body.innerHTML).not.toContain("Stale context");
113+
});
77114
});
78115

79-
function loadContentInternals() {
116+
function loadContentInternals(overrides: Record<string, unknown> = {}) {
80117
const context: Record<string, unknown> = {
81118
__LOOPOVER_EXTENSION_TEST__: true,
82119
location: { pathname: "/JSONbored/loopover/issues/146" },
@@ -88,6 +125,7 @@ function loadContentInternals() {
88125
body: { appendChild: vi.fn() },
89126
},
90127
chrome: { runtime: { sendMessage: vi.fn() } },
128+
...overrides,
91129
};
92130
context.globalThis = context;
93131
const vmContext = createContext(context);
@@ -97,6 +135,7 @@ function loadContentInternals() {
97135
pathname: string,
98136
) => { kind: "pull_request"; owner: string; repo: string; pullNumber: number } | null;
99137
matchPullRequestTarget: (pathname: string) => { owner: string; repo: string; pullNumber: number } | null;
138+
createOverlayLoader: (container: { querySelector: (selector: string) => unknown }, target: unknown) => () => Promise<void>;
100139
renderPullContext: (payload: unknown) => string;
101140
};
102141
}

0 commit comments

Comments
 (0)