diff --git a/src/__tests__/mergify.test.js b/src/__tests__/mergify.test.js index e30483f..576eded 100644 --- a/src/__tests__/mergify.test.js +++ b/src/__tests__/mergify.test.js @@ -1,6 +1,7 @@ const { MergifyCache, PrStatusCache, + StackContextCache, findTimelineActions, isPullRequestOpen, isPullRequestQueued, @@ -1195,6 +1196,76 @@ describe("PrStatusCache", () => { }); }); +describe("StackContextCache", () => { + beforeEach(() => { + localStorage.clear(); + jest.spyOn(Date, "now").mockImplementation(() => 1000); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns null on cache miss", () => { + const cache = new StackContextCache(); + expect(cache.get("o", "r", 1)).toBeNull(); + }); + + it("returns stored stackData and revisionData on cache hit", () => { + const cache = new StackContextCache(); + const stackData = { schema_version: 1, pulls: [{ number: 1 }] }; + const revisionData = { schema_version: 1, entries: [] }; + cache.update("o", "r", 1, stackData, revisionData); + const got = cache.get("o", "r", 1); + expect(got).not.toBeNull(); + expect(got.stackData).toEqual(stackData); + expect(got.revisionData).toEqual(revisionData); + }); + + it("expires entries after TTL", () => { + const cache = new StackContextCache(500); + cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null); + Date.now.mockImplementation(() => 1501); + expect(cache.get("o", "r", 1)).toBeNull(); + }); + + it("returns null on corrupted entry", () => { + const cache = new StackContextCache(); + const k = cache.key("o", "r", 1); + localStorage.setItem(k, "not-json"); + const errSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + expect(cache.get("o", "r", 1)).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it("remove deletes the entry", () => { + const cache = new StackContextCache(); + cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null); + expect(cache.get("o", "r", 1)).not.toBeNull(); + cache.remove("o", "r", 1); + expect(cache.get("o", "r", 1)).toBeNull(); + }); + + it("expires after 1h by default", () => { + const cache = new StackContextCache(); + cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null); + Date.now.mockImplementation(() => 1000 + 59 * 60 * 1000); + expect(cache.get("o", "r", 1)).not.toBeNull(); + Date.now.mockImplementation(() => 1000 + 61 * 60 * 1000); + expect(cache.get("o", "r", 1)).toBeNull(); + }); + + it("preserves null revisionData", () => { + const cache = new StackContextCache(); + cache.update("o", "r", 1, { schema_version: 1, pulls: [] }, null); + const got = cache.get("o", "r", 1); + expect(got.stackData).not.toBeNull(); + expect(got.revisionData).toBeNull(); + }); +}); + describe("fetchPrStatus", () => { afterEach(() => { jest.restoreAllMocks(); diff --git a/src/cache.js b/src/cache.js index bf065fd..f765a58 100644 --- a/src/cache.js +++ b/src/cache.js @@ -105,3 +105,58 @@ export class PrStatusCache { } } } + +export class StackContextCache { + constructor(expirationMs = 60 * 60 * 1000) { + this.PREFIX = "mergify_browser_extension_stack_ctx"; + this.expirationMs = expirationMs; + } + + key(org, repo, num) { + return `${this.PREFIX}_${org}_${repo}_${num}`; + } + + get(org, repo, num) { + const k = this.key(org, repo, num); + try { + const raw = localStorage.getItem(k); + if (!raw) return null; + const data = JSON.parse(raw); + if (Date.now() - data.timestamp > this.expirationMs) { + localStorage.removeItem(k); + return null; + } + return { + stackData: data.stackData ?? null, + revisionData: data.revisionData ?? null, + }; + } catch (e) { + console.error("StackContextCache get failed:", e); + return null; + } + } + + update(org, repo, num, stackData, revisionData) { + const k = this.key(org, repo, num); + try { + localStorage.setItem( + k, + JSON.stringify({ + stackData, + revisionData, + timestamp: Date.now(), + }), + ); + } catch (e) { + console.error("StackContextCache update failed:", e); + } + } + + remove(org, repo, num) { + try { + localStorage.removeItem(this.key(org, repo, num)); + } catch (e) { + console.error("StackContextCache remove failed:", e); + } + } +} diff --git a/src/mergify.js b/src/mergify.js index d3f89fb..dd2a4c4 100644 --- a/src/mergify.js +++ b/src/mergify.js @@ -12,6 +12,7 @@ import { MergifyCache as _MergifyCache, PrStatusCache as _PrStatusCache, + StackContextCache as _StackContextCache, } from "./cache.js"; import { debug } from "./debug.js"; import { getPullRequestData, isGitHubPullRequestPage } from "./dom.js"; @@ -36,7 +37,11 @@ export * from "./timestamps.js"; // Re-export everything for the test suite (which imports from "../mergify"). // Cache classes are re-exported as named exports (not export *) so that // jest.spyOn can replace them on the module object in tests. -export { _MergifyCache as MergifyCache, _PrStatusCache as PrStatusCache }; +export { + _MergifyCache as MergifyCache, + _PrStatusCache as PrStatusCache, + _StackContextCache as StackContextCache, +}; // Clear cached PR statuses on a page reload (covers force-reload too — // browsers don't expose hard-vs-soft reload to JS, so we treat any reload diff --git a/src/stacks.js b/src/stacks.js index 20ee5ab..73d4fb0 100644 --- a/src/stacks.js +++ b/src/stacks.js @@ -1,4 +1,4 @@ -import { PrStatusCache } from "./cache.js"; +import { PrStatusCache, StackContextCache } from "./cache.js"; import { debug } from "./debug.js"; import { findFirstElement, readPrStatusFromDocument } from "./dom.js"; import { getLogoSvg, parseSvg } from "./logo.js"; @@ -30,6 +30,7 @@ export const CONTEXT_PANEL_TARGETS = [ export const _commentBodyCache = new Map(); export const _inflightStatusFetches = new Map(); export const _prStatusCache = new PrStatusCache(); +export const _stackContextCache = new StackContextCache(); export let _contextRenderGeneration = 0; export function extractMarkerJson(body, prefix) { @@ -707,6 +708,37 @@ export function removeContextSurfaces(currentPull) { export async function renderMergifyContext(currentPull) { const generation = ++_contextRenderGeneration; + + // Cache-first render: build the panel + nav from the last known good + // stack/revision data so the surfaces appear before the network roundtrips + // settle. The network refresh below replaces them in place (injectContextPanel + // / injectStackNav dedupe via data-mergify-hash, so identical data is a no-op). + const cached = _stackContextCache.get( + currentPull.org, + currentPull.repo, + currentPull.number, + ); + if (cached) { + try { + const cachedPanel = buildContextPanel( + cached.stackData, + cached.revisionData, + currentPull, + ); + if (cachedPanel) { + injectContextPanel(cachedPanel); + injectStackNav(cached.stackData, currentPull); + } + } catch (e) { + debug("Cache-first render failed; discarding entry:", e); + _stackContextCache.remove( + currentPull.org, + currentPull.repo, + currentPull.number, + ); + } + } + const bodies = await fetchCommentBodies( currentPull.org, currentPull.repo, @@ -714,6 +746,11 @@ export async function renderMergifyContext(currentPull) { ); if (generation !== _contextRenderGeneration) return; if (bodies.length === 0) { + _stackContextCache.remove( + currentPull.org, + currentPull.repo, + currentPull.number, + ); removeContextSurfaces(currentPull); return; } @@ -722,9 +759,23 @@ export async function renderMergifyContext(currentPull) { const revisionData = parseRevisionMarker(bodies, currentPull.number); const panel = buildContextPanel(stackData, revisionData, currentPull); if (!panel) { + _stackContextCache.remove( + currentPull.org, + currentPull.repo, + currentPull.number, + ); removeContextSurfaces(currentPull); return; } + + _stackContextCache.update( + currentPull.org, + currentPull.repo, + currentPull.number, + stackData, + revisionData, + ); + injectContextPanel(panel); injectStackNav(stackData, currentPull);