From 053c047254eaf693d3367502effc334dd0099c04 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 20 May 2026 12:07:21 +0200 Subject: [PATCH] perf(extension): inject Mergify merge-box row before network state arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the row was the very last thing to appear in the merge box on cold loads — the user would see GitHub's "Squash and merge" panel for seconds before the Mergify "Add to merge queue" button materialised. The reason is that `_tryInject` awaited four network roundtrips in sequence before building the row: 1. `/search?q=…mergify.yml` (is Mergify enabled on this repo?) 2. fetchCommentBodies (stack/revision context) 3. `/{org}/{repo}/pull/{n}/checks` (find the queue check-run ID) 4. `/{org}/{repo}/runs/{check_run_id}` (resolve "queued" vs "evaluating") Reorder so the row appears as soon as the merge box exists: - The is-Mergify-enabled check now tries the synchronous detection path first (Mergify app-icon in the DOM or a cached repo result). The `/search` fetch is only paid on the rare cold load where Mergify has not yet commented on the page. - The initial row is built with state derived synchronously from the DOM (`deriveQueueButtonState` reads open/merged/closed, the expanded Checks section if present, and the last `@mergifyio` command — all sync), then injected immediately into the merge box. - `renderMergifyContext` and `fetchQueueStateIfNeeded` now run in parallel as background fire-and-forgets. When the queue-state fetch resolves, a `then()` callback runs `updateMergifyRow`, which swaps the button in place if its `data-mergify-queue-btn` attribute changed — no full row rebuild. - The merge-box DOM-walking is extracted into `injectRowIntoMergeBox()` to keep the new early-injection path readable. Trade-off: on a PR that *is* queued, when the Checks section has not been expanded, the button briefly shows "Add to merge queue" before flipping to "Remove from merge queue" once the network state arrives. This is a single-button swap, not a row reflow. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: Iaab5480cfb96d04421bd4136576620f2487c4780 --- src/mergify.js | 112 +++++++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/src/mergify.js b/src/mergify.js index d3f89fb..305a57e 100644 --- a/src/mergify.js +++ b/src/mergify.js @@ -61,6 +61,48 @@ export function resetForNavigation() { lastPullRequestUrl = null; } +function injectRowIntoMergeBox() { + // New merge box — the mergebox-partial may be inside discussion-timeline-actions + // or a separate element in the page (GitHub layout varies) + const mergeBoxPartial = document.querySelector( + '[data-testid="mergebox-partial"]', + ); + if (mergeBoxPartial) { + const mergeBoxContainer = + mergeBoxPartial.querySelector(".border.rounded-2"); + if (mergeBoxContainer) { + mergeBoxContainer.appendChild(buildMergifyRow()); + debug("Mergify section injected inside merge box container"); + return true; + } + } + + let detailSection = document.querySelector( + "div[class=discussion-timeline-actions]", + ); + if (detailSection) { + const mergeBoxContainer = detailSection.querySelector( + ".merge-pr .border.rounded-2", + ); + if (mergeBoxContainer) { + mergeBoxContainer.appendChild(buildMergifyRow()); + debug("Mergify section injected inside merge-pr container"); + return true; + } + debug("Merge box container not found yet, waiting for render"); + return false; + } + + // Classic merge box + detailSection = document.querySelector(".mergeability-details"); + if (detailSection) { + detailSection.insertBefore(buildMergifyRow(), detailSection.firstChild); + debug("Mergify section injected in classic merge box"); + return true; + } + return false; +} + async function _tryInject() { if (!isGitHubPullRequestPage()) { // SPA-navigated away from a PR (e.g., back to /pulls). The @@ -81,70 +123,48 @@ async function _tryInject() { lastPullRequestUrl = currentUrl; } - const hasMergifyConfiguration = await getMergifyConfigurationStatus(); - if (!isMergifyEnabledOnTheRepo(hasMergifyConfiguration)) { + // Try synchronous Mergify-enabled detection first (Mergify app icon in + // DOM, or cached repo result). Only fall back to the /search fetch when + // we have no synchronous signal — that fetch is the single biggest + // contributor to first-row latency on cold loads. + let mergifyEnabled = isMergifyEnabledOnTheRepo(false); + if (!mergifyEnabled) { + const hasMergifyConfiguration = await getMergifyConfigurationStatus(); + mergifyEnabled = isMergifyEnabledOnTheRepo(hasMergifyConfiguration); + } + if (!mergifyEnabled) { debug("Mergify is not enabled on the repo"); return; } const _data = getPullRequestData(); - await renderMergifyContext({ + const contextPayload = { org: _data.org, repo: _data.repo, number: Number.parseInt(_data.pull, 10), subpath: _data.subpath, - }); + }; const existingRow = document.querySelector("#mergify"); if (existingRow) { updateMergifyRow(existingRow); + void renderMergifyContext(contextPayload); return; } - // Fetch queue state before first render (only once per PR page visit) - await fetchQueueStateIfNeeded(); + // Inject the row immediately with state derived synchronously from the + // DOM. The queue-state and stack-context fetches run in parallel in the + // background — when fetchQueueStateIfNeeded resolves, we re-derive the + // button state and swap it in if it changed. + const injected = injectRowIntoMergeBox(); - // New merge box — the mergebox-partial may be inside discussion-timeline-actions - // or a separate element in the page (GitHub layout varies) - const mergeBoxPartial = document.querySelector( - '[data-testid="mergebox-partial"]', - ); - if (mergeBoxPartial) { - const mergeBoxContainer = - mergeBoxPartial.querySelector(".border.rounded-2"); - if (mergeBoxContainer) { - mergeBoxContainer.appendChild(buildMergifyRow()); - debug("Mergify section injected inside merge box container"); - scheduleQueueStatePoll(); - return; - } - } + void renderMergifyContext(contextPayload); + void fetchQueueStateIfNeeded().then(() => { + const row = document.querySelector("#mergify"); + if (row) updateMergifyRow(row); + }); - let detailSection = document.querySelector( - "div[class=discussion-timeline-actions]", - ); - if (detailSection) { - // Fallback: look for merge box inside discussion-timeline-actions - const mergeBoxContainer = detailSection.querySelector( - ".merge-pr .border.rounded-2", - ); - if (mergeBoxContainer) { - mergeBoxContainer.appendChild(buildMergifyRow()); - debug("Mergify section injected inside merge-pr container"); - scheduleQueueStatePoll(); - } else { - debug("Merge box container not found yet, waiting for render"); - } - return; - } - // Classic merge box - detailSection = document.querySelector(".mergeability-details"); - if (detailSection) { - detailSection.insertBefore(buildMergifyRow(), detailSection.firstChild); - debug("Mergify section injected in classic merge box"); - scheduleQueueStatePoll(); - return; - } + if (injected) scheduleQueueStatePoll(); } function tryInject() {