Skip to content

Instantly share code, notes, and snippets.

@lae
Last active April 28, 2026 13:57
Show Gist options
  • Select an option

  • Save lae/f72738a33cedaddaaf59239addab2b5b to your computer and use it in GitHub Desktop.

Select an option

Save lae/f72738a33cedaddaaf59239addab2b5b to your computer and use it in GitHub Desktop.
Browser userscript (FireMonkey, etc.) for fast-forward merge support on GitHub
// ==UserScript==
// @name Fast-forward merge for GitHub
// @namespace https://github.com/simnalamburt/ff-for-github
// @version 0.2.0
// @description Shows whether or not a GitHub PR or branch can be fast-forward merged and lets you do it.
// @match https://github.com/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect api.github.com
// @license MIT OR Apache-2.0
// ==/UserScript==
// This script is primarily written via Claude to allow being ran on Firefox, and is based on simnalamburt/ff-for-github.
(function () {
"use strict";
const TOKEN_KEY = "ghff:github-personal-access-token";
const ROOT_ID = "ghff-root";
const STYLE_ID = "ghff-style";
const PAGE_CACHE_TTL_MS = 30000;
const URL_CHECK_INTERVAL_MS = 750;
const STREAM_REFRESH_MIN_INTERVAL_MS = 2000;
const PR_PATH_PATTERN = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/;
const COMPARE_PATH_PATTERN = /^\/([^/]+)\/([^/]+)\/compare\/([^/]+)(?:\/.*)?$/;
const CSS = `
#ghff-root > article {
--ghff-border-color: var(--borderColor-default, #d0d7de);
--ghff-bg-color: var(--bgColor-muted, #f6f8fa);
--ghff-accent-color: var(--borderColor-muted, #656d76);
border: 1px solid var(--ghff-border-color);
color: var(--fgColor-default, #1f2328);
}
#ghff-root > article[data-tone="loading"] {
--ghff-border-color: var(--borderColor-attention-emphasis, #9a6700);
--ghff-bg-color: var(--bgColor-attention-muted, #fff8c5);
--ghff-accent-color: var(--fgColor-attention, #9a6700);
}
#ghff-root > article[data-tone="success"] {
--ghff-border-color: var(--borderColor-success-emphasis, #1a7f37);
--ghff-bg-color: var(--bgColor-success-muted, #dafbe1);
--ghff-accent-color: var(--fgColor-success, #1f883d);
}
#ghff-root > article[data-tone="muted"] {
--ghff-border-color: var(--borderColor-muted, #656d76);
--ghff-bg-color: var(--bgColor-muted, #f6f8fa);
--ghff-accent-color: var(--fgColor-muted, #656d76);
}
#ghff-root > article[data-tone="error"] {
--ghff-border-color: var(--borderColor-danger-emphasis, #cf222e);
--ghff-bg-color: var(--bgColor-danger-muted, #ffebe9);
--ghff-accent-color: var(--fgColor-danger, #d1242f);
}
#ghff-root .ghff-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
}
#ghff-root .ghff-detail {
font-size: 12px;
line-height: 1.5;
margin-top: 8px;
opacity: 0.78;
}
#ghff-root .ghff-detail--error {
color: var(--fgColor-danger, #d1242f);
opacity: 1;
}
#ghff-root button {
appearance: none;
border: 1px solid var(--button-default-borderColor-rest, rgba(31, 35, 40, 0.15));
border-radius: 6px;
background: var(--button-primary-bgColor-rest, #1f883d);
color: var(--button-primary-fgColor-rest, #ffffff);
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: 600;
line-height: 20px;
padding: 6px 14px;
}
#ghff-root button:hover {
background: var(--button-primary-bgColor-hover, #1a7f37);
}
#ghff-root button:disabled {
cursor: default;
opacity: 0.7;
}
#ghff-root > article[data-tone="error"] button {
background: var(--button-danger-bgColor-rest, #cf222e);
}
#ghff-root > article[data-tone="error"] button:hover {
background: var(--button-danger-bgColor-hover, #a40e26);
}
#ghff-root > .ghff-card {
background: var(--ghff-bg-color);
border-radius: 10px;
margin-top: 16px;
padding: 14px 16px;
}
#ghff-root > .ghff-card > button {
margin-top: 12px;
}
#ghff-root > .ghff-compare-banner {
background: var(--ghff-bg-color);
border-radius: 12px;
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
position: relative;
}
#ghff-root > .ghff-compare-banner::before {
background: var(--ghff-accent-color);
border-radius: 999px;
content: "";
align-self: stretch;
flex: none;
width: 6px;
}
#ghff-root > .ghff-compare-banner > .ghff-compare-banner__copy {
flex: 1;
min-width: 0;
}
#ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions {
display: flex;
flex: none;
justify-content: flex-end;
}
#ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions > button {
white-space: nowrap;
}
@media (max-width: 767px) {
#ghff-root > .ghff-compare-banner {
flex-wrap: wrap;
gap: 12px;
}
#ghff-root > .ghff-compare-banner::before {
height: 4px;
width: 100%;
}
#ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions {
width: 100%;
}
}
`;
// --- Page state ---
const pageState = {
cache: new Map(),
currentPath: "",
optimisticStatuses: new Map(),
pendingKey: null,
requestId: 0,
scheduled: false,
};
const ui = {
pageKind: null,
state: { kind: "loading" },
mergeState: { kind: "idle" },
};
// --- Token storage ---
function getToken() {
const v = GM_getValue(TOKEN_KEY, "");
return typeof v === "string" ? v : "";
}
function setToken(t) {
GM_setValue(TOKEN_KEY, t);
}
function promptForToken() {
const current = getToken();
const next = window.prompt(
"Enter your GitHub personal access token (classic).\n\n" +
"Classic: needs 'repo' scope to fast-forward merge.\n" +
"Fine-grained: needs 'Contents: Read and write' on the target repos.\n\n" +
"Leave blank to clear.",
current,
);
if (next === null) { return false; }
setToken(next.trim());
return true;
}
GM_registerMenuCommand("Set GitHub token", () => {
if (promptForToken()) {
pageState.cache.clear();
void refresh({ bypassCache: true });
}
});
GM_registerMenuCommand("Clear GitHub token", () => {
if (window.confirm("Clear the saved GitHub token?")) {
setToken("");
pageState.cache.clear();
void refresh({ bypassCache: true });
}
});
// --- Style ---
function ensureStyle() {
if (document.getElementById(STYLE_ID)) { return; }
const s = document.createElement("style");
s.id = STYLE_ID;
s.textContent = CSS;
(document.head || document.documentElement).appendChild(s);
}
const root = document.createElement("div");
root.id = ROOT_ID;
// --- HTTP ---
function gmFetch(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || "GET",
url,
headers: options.headers || {},
data: options.body,
onload: (response) => {
let data = null;
try {
data = JSON.parse(response.responseText);
} catch (_e) {
data = null;
}
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
data,
});
},
onerror: () => reject(new Error("Network error contacting api.github.com.")),
ontimeout: () => reject(new Error("Timed out contacting api.github.com.")),
});
});
}
async function githubRequest(pathname, token, init = {}) {
const headers = Object.assign(
{
Accept: "application/vnd.github+json",
"Cache-Control": "no-cache",
"X-GitHub-Api-Version": "2022-11-28",
},
init.headers || {},
);
if (token) { headers.Authorization = `Bearer ${token}`; }
const response = await gmFetch(`https://api.github.com${pathname}`, {
method: init.method || "GET",
headers,
body: init.body,
});
if (!response.ok) {
const msg = (response.data && typeof response.data.message === "string") ?
response.data.message :
`GitHub API request failed with status ${response.status}.`;
throw new Error(msg);
}
return response.data;
}
// --- Routing ---
function parseCurrentRoute(pathname) {
const prMatch = pathname.match(PR_PATH_PATTERN);
if (prMatch) {
const [, owner, repo, n] = prMatch;
const pullNumber = Number(n);
if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { return null; }
return {
kind: "pull-request",
pageKind: "pull-request",
owner,
repo,
pullNumber,
signature: `pull-request:${owner}/${repo}#${pullNumber}`,
optimisticStatusAfterMerge: "closed",
};
}
const cmpMatch = pathname.match(COMPARE_PATH_PATTERN);
if (!cmpMatch) { return null; }
const spec = parseComparisonSpec(cmpMatch[3]);
if (!spec) { return null; }
const [, owner, repo] = cmpMatch;
return {
kind: "compare",
pageKind: "compare",
owner,
repo,
base: spec.base,
head: spec.head,
signature: `compare:${owner}/${repo}:${spec.base}...${spec.head}`,
optimisticStatusAfterMerge: "up-to-date",
};
}
function parseComparisonSpec(spec) {
let decoded;
try {
decoded = decodeURIComponent(spec);
} catch (_e) {
return null;
}
const sep = decoded.indexOf("...");
if (sep <= 0) { return null; }
const base = decoded.slice(0, sep);
const head = decoded.slice(sep + 3);
if (!base || !head) { return null; }
return { base, head };
}
function findMountInstruction(locator) {
if (locator.kind === "pull-request") {
const target = document.querySelector("#partial-discussion-sidebar");
if (!target) { return null; }
return { kind: "append", element: target };
}
const commits = document.querySelector("#commits_bucket");
const summary = commits && commits.previousElementSibling;
if (!(summary instanceof HTMLElement)) { return null; }
return { kind: "before", element: summary };
}
function ensureMounted(mount) {
if (mount.kind === "append") {
if (root.parentElement !== mount.element) {
mount.element.insertAdjacentElement("beforeend", root);
}
return;
}
if (mount.element.previousElementSibling !== root) {
mount.element.insertAdjacentElement("beforebegin", root);
}
}
// --- API operations ---
function encodeGitReference(ref) {
return ref.split("/").map(encodeURIComponent).join("/");
}
function encodeComparisonReference(ref) {
return encodeURIComponent(ref);
}
function parseQualifiedReference(ref, defaultOwner) {
const sep = ref.indexOf(":");
if (sep <= 0) { return { owner: defaultOwner, ref }; }
return { owner: ref.slice(0, sep), ref: ref.slice(sep + 1) };
}
function mapComparisonStatus(s) {
switch (s) {
case "ahead":
return "ff-possible";
case "identical":
return "up-to-date";
case "behind":
return "base-ahead";
case "diverged":
return "diverged";
default:
return "unknown";
}
}
function assertAhead(cmp) {
if (cmp.status === "identical") {
throw new Error("The base branch is already up to date.");
}
if (cmp.status === "behind") {
throw new Error("The base branch is already ahead of this comparison.");
}
if (cmp.status === "diverged") {
throw new Error("Fast-forward merge is not possible because the branches have diverged.");
}
if (cmp.status !== "ahead") {
throw new Error("GitHub did not return a comparison state this extension understands.");
}
}
async function getStatusResult(locator) {
const token = getToken();
const hasToken = token.trim() !== "";
if (locator.kind === "pull-request") {
const pr = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls/${encodeURIComponent(String(locator.pullNumber))}`,
token,
);
const baseRef = (pr.base && pr.base.ref) || "";
const headSha = (pr.head && pr.head.sha) || "";
const isDraft = pr.draft === true;
const state = pr.state || "open";
if (!baseRef || !headSha) {
return {
hasGitHubPersonalAccessToken: hasToken,
status: state !== "open" ? "closed" : "unknown",
aheadBy: 0,
};
}
const cmp = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeURIComponent(baseRef)}...${encodeURIComponent(headSha)}`,
token,
);
const aheadBy = cmp.ahead_by || 0;
let status;
if (state !== "open") {
status = cmp.status === "ahead" ? "ff-possible-but-closed" : "closed";
} else {
switch (cmp.status) {
case "ahead":
status = isDraft ? "ff-possible-but-draft" : "ff-possible";
break;
case "identical":
status = "up-to-date";
break;
case "behind":
status = "base-ahead";
break;
case "diverged":
status = "diverged";
break;
default:
status = "unknown";
}
// GitHub computes a "mergeable_state" that bakes in branch protection,
// required checks, and required reviews. "blocked" means a direct ref
// update will fail; surface that up front instead of letting the user
// click into an error. "unknown" / "clean" / "unstable" / "has_hooks"
// all mean the merge is at least worth attempting.
if (status === "ff-possible" && pr.mergeable_state === "blocked") {
status = "ff-possible-but-blocked";
}
}
return { aheadBy, hasGitHubPersonalAccessToken: hasToken, status };
}
const cmp = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeComparisonReference(locator.base)}...${encodeComparisonReference(locator.head)}`,
token,
);
let status = mapComparisonStatus(cmp.status);
if (status === "ff-possible") {
// No PR context here, so check branch protection directly on the base
// branch. The /branches endpoint is readable by anyone with repo read
// access (unlike /branches/{branch}/protection which needs admin), and
// its "protected" flag captures whether a direct ref update will be
// gated. We treat any failure of this lookup as best-effort and fall
// through to the optimistic ff-possible state.
try {
const baseQual = parseQualifiedReference(locator.base, locator.owner);
const branchInfo = await githubRequest(
`/repos/${encodeURIComponent(baseQual.owner)}/${encodeURIComponent(locator.repo)}/branches/${encodeGitReference(baseQual.ref)}`,
token,
);
if (branchInfo.protected === true) {
status = "ff-possible-but-blocked";
}
} catch (_e) {
// Best-effort; ignore (404 if branch missing, 401/403 if no auth).
}
}
return {
aheadBy: cmp.ahead_by || 0,
hasGitHubPersonalAccessToken: hasToken,
status,
};
}
async function performMerge(locator) {
const token = getToken();
if (token.trim() === "") { throw new Error("No GitHub token is saved."); }
if (locator.kind === "pull-request") {
const pr = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls/${encodeURIComponent(String(locator.pullNumber))}`,
token,
);
const baseRef = (pr.base && pr.base.ref) || "";
const headSha = (pr.head && pr.head.sha) || "";
if (!baseRef || !headSha) {
throw new Error("Could not determine the pull request branch heads.");
}
const cmp = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeURIComponent(baseRef)}...${encodeURIComponent(headSha)}`,
token,
);
assertAhead(cmp);
await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/git/refs/heads/${encodeGitReference(baseRef)}`,
token,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sha: headSha, force: false }),
},
);
return;
}
const cmp = await githubRequest(
`/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeComparisonReference(locator.base)}...${encodeComparisonReference(locator.head)}`,
token,
);
assertAhead(cmp);
const baseQual = parseQualifiedReference(locator.base, locator.owner);
const headQual = parseQualifiedReference(locator.head, locator.owner);
const headCommit = await githubRequest(
`/repos/${encodeURIComponent(headQual.owner)}/${encodeURIComponent(locator.repo)}/commits/${encodeURIComponent(headQual.ref)}`,
token,
);
const headSha = headCommit.sha || "";
if (!headSha) { throw new Error("Could not determine the comparison head commit."); }
await githubRequest(
`/repos/${encodeURIComponent(baseQual.owner)}/${encodeURIComponent(locator.repo)}/git/refs/heads/${encodeGitReference(baseQual.ref)}`,
token,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sha: headSha, force: false }),
},
);
}
// --- UI ---
function getStatusPresentation(state) {
if (state.kind === "loading") {
return { tone: "loading", title: "Checking fast-forward status" };
}
if (state.kind === "error") {
return { tone: "error", title: "Fast-forward status unavailable", detail: state.message };
}
const aheadDetail = (n) => `${n} commit${n === 1 ? "" : "s"} ahead`;
const action = state.result.hasGitHubPersonalAccessToken ? "merge" : "open-options";
switch (state.result.status) {
case "ff-possible":
return {
tone: "success",
title: "Fast-forward merge possible",
detail: aheadDetail(state.result.aheadBy),
action,
};
case "ff-possible-but-closed":
return {
tone: "error",
title: "Fast-forward merge possible, but the pull request is not open",
detail: aheadDetail(state.result.aheadBy),
action,
};
case "ff-possible-but-draft":
return {
tone: "error",
title: "Fast-forward merge possible, but the pull request is a draft",
detail: aheadDetail(state.result.aheadBy),
action,
};
case "ff-possible-but-blocked":
return {
tone: "error",
title: "Fast-forward merge possible, but blocked by branch protection",
detail: aheadDetail(state.result.aheadBy),
};
case "up-to-date":
return { tone: "muted", title: "Already up to date" };
case "base-ahead":
case "diverged":
return { tone: "muted", title: "Fast-forward merge not possible" };
case "closed":
return { tone: "muted", title: "Pull request is not open" };
default:
return {
tone: "error",
title: "Fast-forward status unavailable",
detail: "GitHub did not return a comparison state this extension understands.",
};
}
}
function render() {
while (root.firstChild) {
root.removeChild(root.firstChild);
}
if (!ui.pageKind) { return; }
const presentation = getStatusPresentation(ui.state);
const isCompare = ui.pageKind === "compare";
const article = document.createElement("article");
article.className = isCompare ? "ghff-compare-banner" : "ghff-card";
article.dataset.tone = presentation.tone;
const copy = isCompare ? document.createElement("div") : article;
if (isCompare) { copy.className = "ghff-compare-banner__copy"; }
const title = document.createElement("div");
title.className = "ghff-title";
title.textContent = presentation.title;
copy.appendChild(title);
if (presentation.detail) {
const detail = document.createElement("div");
detail.className = "ghff-detail";
detail.textContent = presentation.detail;
copy.appendChild(detail);
}
if (ui.mergeState.kind === "error") {
const err = document.createElement("div");
err.className = "ghff-detail ghff-detail--error";
err.textContent = ui.mergeState.message;
copy.appendChild(err);
}
if (isCompare) { article.appendChild(copy); }
if (presentation.action) {
const button = document.createElement("button");
button.type = "button";
if (presentation.action === "merge") {
button.textContent =
ui.mergeState.kind === "submitting" ? "Fast-forwarding..." : "Fast-forward merge";
button.disabled = ui.mergeState.kind === "submitting";
button.addEventListener("click", () => {
void fastForwardMerge();
});
} else {
button.textContent = "Set up GitHub token";
button.addEventListener("click", () => {
if (promptForToken()) {
pageState.cache.clear();
void refresh({ bypassCache: true });
}
});
}
if (isCompare) {
const actions = document.createElement("div");
actions.className = "ghff-compare-banner__actions";
actions.appendChild(button);
article.appendChild(actions);
} else {
article.appendChild(button);
}
}
root.appendChild(article);
}
function setPageKind(v) {
ui.pageKind = v;
render();
}
function setState(v) {
ui.state = v;
render();
}
function setMergeState(v) {
ui.mergeState = v;
render();
}
// --- Refresh / merge ---
async function refresh(options = {}) {
if (pageState.scheduled) { return; }
pageState.scheduled = true;
await sleep(100);
pageState.scheduled = false;
const locator = parseCurrentRoute(location.pathname);
if (!locator) {
root.remove();
setPageKind(null);
setMergeState({ kind: "idle" });
return;
}
const mount = findMountInstruction(locator);
if (!mount) {
root.remove();
return;
}
setPageKind(locator.pageKind);
ensureMounted(mount);
const cached = pageState.cache.get(locator.signature);
if (!options.bypassCache && cached && Date.now() - cached.cachedAt < PAGE_CACHE_TTL_MS) {
setMergeState({ kind: "idle" });
setState({ kind: "loaded", result: cached.result });
return;
}
if (!options.preserveState) { setState({ kind: "loading" }); }
if (!options.bypassCache && pageState.pendingKey === locator.signature) { return; }
pageState.pendingKey = locator.signature;
const requestId = ++pageState.requestId;
try {
const result = await getStatusResult(locator);
if (requestId !== pageState.requestId) { return; }
const opt = pageState.optimisticStatuses.get(locator.signature);
if (opt && opt.until > Date.now() && result.status !== opt.expectedStatus) {
window.setTimeout(() => {
void refresh({ bypassCache: true, preserveState: true });
}, 1000);
return;
}
pageState.optimisticStatuses.delete(locator.signature);
if (options.bypassCache) {
pageState.cache.delete(locator.signature);
} else {
pageState.cache.set(locator.signature, { result, cachedAt: Date.now() });
}
setMergeState({ kind: "idle" });
setState({ kind: "loaded", result });
} catch (error) {
if (requestId !== pageState.requestId) { return; }
if (options.preserveState) { return; }
setState({
kind: "error",
message: error instanceof Error ? error.message : String(error),
});
} finally {
if (pageState.pendingKey === locator.signature) { pageState.pendingKey = null; }
}
}
async function fastForwardMerge() {
const locator = parseCurrentRoute(location.pathname);
if (!locator) {
setMergeState({ kind: "error", message: "This is no longer a supported GitHub page." });
return;
}
setMergeState({ kind: "submitting" });
try {
await performMerge(locator);
const currentState = ui.state;
const optimisticResult = {
aheadBy: 0,
hasGitHubPersonalAccessToken: (currentState.kind === "loaded") ?
currentState.result.hasGitHubPersonalAccessToken :
true,
status: locator.optimisticStatusAfterMerge,
};
pageState.optimisticStatuses.set(locator.signature, {
expectedStatus: locator.optimisticStatusAfterMerge,
until: Date.now() + 5000,
});
pageState.cache.set(locator.signature, {
result: optimisticResult,
cachedAt: Date.now(),
});
setMergeState({ kind: "idle" });
setState({ kind: "loaded", result: optimisticResult });
window.setTimeout(() => {
pageState.cache.delete(locator.signature);
void refresh({ bypassCache: true, preserveState: true });
}, 1500);
} catch (error) {
pageState.optimisticStatuses.delete(locator.signature);
setMergeState({
kind: "error",
message: error instanceof Error ? error.message : String(error),
});
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// --- Bootstrap ---
ensureStyle();
pageState.currentPath = location.pathname;
void refresh();
window.addEventListener("load", () => {
void refresh();
});
window.addEventListener("popstate", () => {
void refresh();
});
document.addEventListener(
"pjax:end",
() => {
void refresh();
},
true,
);
document.addEventListener(
"turbo:load",
() => {
void refresh();
},
true,
);
document.addEventListener(
"turbo:render",
() => {
void refresh();
},
true,
);
// GitHub delivers many in-page updates (new commits, comments, status checks)
// via Turbo Streams and Turbo Frames rather than full navigations. Listen to
// those too, throttled so a flurry of comment activity doesn't translate
// into a flurry of API calls. Important: invalidate the cache and let
// refresh()'s pending-fetch dedup decide whether to actually launch a
// request — bypassCache here would skip that dedup and let stream events
// invalidate an in-flight bootstrap fetch, leaving the UI stuck on
// "Checking fast-forward status" until events stop firing.
let lastStreamRefreshAt = 0;
const onStreamEvent = () => {
const now = Date.now();
if (now - lastStreamRefreshAt < STREAM_REFRESH_MIN_INTERVAL_MS) { return; }
lastStreamRefreshAt = now;
const locator = parseCurrentRoute(location.pathname);
if (locator) { pageState.cache.delete(locator.signature); }
void refresh({ preserveState: true });
};
document.addEventListener("turbo:before-stream-render", onStreamEvent, true);
document.addEventListener("turbo:frame-load", onStreamEvent, true);
setInterval(() => {
if (location.pathname !== pageState.currentPath) {
pageState.currentPath = location.pathname;
void refresh();
return;
}
const locator = parseCurrentRoute(location.pathname);
if (!locator) { return; }
// GitHub uses Turbo Streams (not full Turbo navigations) to swap parts
// of the page after events like a new commit push. Stream renders don't
// fire turbo:render, so we won't be told to re-mount or re-fetch — both
// need to be backstopped by polling.
if (!root.isConnected) {
void refresh({ preserveState: true });
return;
}
const cached = pageState.cache.get(locator.signature);
if (!cached || Date.now() - cached.cachedAt > PAGE_CACHE_TTL_MS) {
// Same reasoning as onStreamEvent: stale cache will already cause a
// fetch inside refresh(), so we don't need bypassCache — and not using
// it lets pending-fetch dedup work, avoiding the stuck-on-loading race.
void refresh({ preserveState: true });
}
}, URL_CHECK_INTERVAL_MS);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment