From cad2e2a9680e6f00bf5185c472aa7b77d46ff89e Mon Sep 17 00:00:00 2001 From: Mark90 Date: Fri, 18 Sep 2026 16:08:51 +0200 Subject: [PATCH] Fix github repo stats rendering twice, and fix them in the nav drawer The header showed the repo stats twice on a cold page load. Our source.html override kept upstream's data-md-component="source", so Material mounted its own fact-fetching on the same element repo-source.js writes to, and both appended a list. Material's mount runs once at bundle-eval time and never again, which is why the duplicate disappeared as soon as a navigation repainted the element. Marking the link with data-repo-source instead leaves repo-source.js as the only writer, and halves the GitHub API calls per cold load from four to two. The partial also renders a second time in the navigation drawer, the copy shown below 960px, which repo-source.js never patched because it used a singular querySelector. Its facts came from Material's session-wide cache and could be another project's, and instant navigation replaces that node, so they vanished entirely after the first in-project navigation. Both copies are now re-queried and repainted on each navigation rather than captured once, and the route's resolved facts are memoised so the extra repaints cost no requests. Co-Authored-By: Claude Opus 5 (1M context) --- docs/js/repo-source.js | 151 +++++++++++++++++++++++---------- overrides/partials/source.html | 12 ++- 2 files changed, 116 insertions(+), 47 deletions(-) diff --git a/docs/js/repo-source.js b/docs/js/repo-source.js index 6d6e7bb3..c6480c42 100644 --- a/docs/js/repo-source.js +++ b/docs/js/repo-source.js @@ -1,5 +1,6 @@ /* - * Update the header source link after each instant-navigation swap. + * Keep the header/drawer source link pointing at the right repository, and + * show that repo's GitHub stats, after each instant-navigation swap. * * Material's instant-nav inject() only replaces a fixed whitelist of * [data-md-component] elements (announce, container, header-topic, outdated, @@ -8,8 +9,6 @@ * Material's own fact-fetching is also a session-wide singleton keyed by a * single sessionStorage entry, not scoped per repo, so it can't be reused * for a monorepo where each tab points at a different GitHub repository. - * The update below is intentionally limited to top-level route changes: - * moving between pages within one project cannot change its repository. * * The route->repo mapping is read from a data-repo-map attribute on the * source link element, populated by the server via @@ -17,28 +16,48 @@ * Adding a new sub-project to the monorepo automatically makes its repo * available here -- no duplicate config to hand-edit. * + * That partial marks the link with data-repo-source instead of Material's own + * data-md-component="source", so Material never mounts its fact-fetching over + * the same element. Both writers appending to one element is what rendered the + * stats twice on a cold load: Material's mount runs once at bundle-eval time + * and appends without clearing, and this script appended a second list. The + * duplicate disappeared on the first navigation only because the repaint below + * rewrites the element's text, dropping every child it had. + * + * The partial is rendered TWICE per page -- once in the header, once in the + * navigation drawer (the copy actually shown below 960px) -- so everything here + * works over both copies. The drawer copy lives inside + * [data-md-component=container], which instant navigation replaces wholesale, + * so its element is a brand new node after every swap while the header's + * survives. Element references are therefore re-queried on each repaint rather + * than captured once, the same way nav-persistence.js and glightbox-instant.js + * re-query on each document$ emission. Caching them would leave the drawer + * facts painted onto a detached node and the live one empty. + * * Uses window.document$ -- Material's own public observable, exposed for - * exactly this kind of post-navigation patch -- to re-sync the link and - * fetch stars/forks from the public GitHub API. + * exactly this kind of post-navigation patch. */ (() => { - const SRC = document.querySelector("[data-md-component=source]") - if (!SRC) return - - const textEl = SRC.querySelector(".md-source__repository") const ORG_URL = "https://github.com/workfloworchestrator" const ORG_NAME = "workfloworchestrator" - /* Built from the server-sent data-repo-map attribute. */ - let repoMap = {} - try { - repoMap = JSON.parse(SRC.getAttribute("data-repo-map") || "{}") - } catch (_) { - /* Keep the root fallback if malformed data ever reaches the page. */ + /* Re-queried on every repaint, never cached -- see the header comment. */ + function sources() { + return [...document.querySelectorAll("[data-repo-source]")] } - let lastPrefix = null + /* Both copies carry the same server-rendered map, and it's identical on + every page of a given build, so reading it once at startup is safe. */ + let repoMap = {} + const first = document.querySelector("[data-repo-source]") + if (first) { + try { + repoMap = JSON.parse(first.getAttribute("data-repo-map") || "{}") + } catch (_) { + /* Keep the root fallback if malformed data ever reaches the page. */ + } + } /* Same markup Material's own renderSourceFacts produces, so its CSS applies. */ function renderFacts(facts) { @@ -48,16 +67,25 @@ return `` } - function clearFacts() { - const facts = SRC.querySelector(".md-source__facts") - if (facts) facts.remove() - if (textEl) textEl.classList.remove("md-source__repository--active") - } - - function showFacts(facts) { - if (!textEl || !facts || !Object.keys(facts).length) return - textEl.insertAdjacentHTML("beforeend", renderFacts(facts)) - textEl.classList.add("md-source__repository--active") + /* Point every copy at one repo and, when its facts are already known, render + them in the same pass. Setting textContent drops whatever the element held, + so the facts list is always rebuilt rather than appended to -- that is what + keeps a second list from ever accumulating. */ + function paint(url, name, facts) { + for (const el of sources()) { + el.href = url + + const textEl = el.querySelector(".md-source__repository") + if (!textEl) continue + + textEl.textContent = name + if (facts && Object.keys(facts).length) { + textEl.insertAdjacentHTML("beforeend", renderFacts(facts)) + textEl.classList.add("md-source__repository--active") + } else { + textEl.classList.remove("md-source__repository--active") + } + } } async function fetchJSON(url) { @@ -113,20 +141,24 @@ return at > 0 ? tagName.slice(at + 1) : tagName } - function update() { - const prefix = location.pathname.split("/", 2)[1] || "" - if (prefix === lastPrefix) return - lastPrefix = prefix + /* Resolved facts per route prefix. The drawer element is rebuilt on every + navigation, so repainting is frequent while the answer almost never + changes; memoising what was resolved lets a repaint render synchronously, + with no flash of a bare link and no extra API call. Failures memoise their + empty result too -- without that, a repo whose request failed (or a 404 on + releases/latest) would be retried on every single navigation and drain the + hourly budget. */ + const factsByPrefix = new Map() - clearFacts() - const repo = repoMap[prefix] + function factsFor(prefix, repo) { + if (factsByPrefix.has(prefix)) return factsByPrefix.get(prefix) + let pending if (repo) { - SRC.href = repo.url - if (textEl) textEl.textContent = repo.name - const api = apiURL(repo.url) - if (api) { + if (!api) { + pending = Promise.resolve({}) + } else { /* Stats and the latest release are independent facts: a repo without any releases (404 on releases/latest) must still show stars/forks, so each request degrades on its own rather than sharing one catch. */ @@ -137,26 +169,55 @@ .then(info => ({ version: formatVersion(info.tag_name) })) .catch(() => ({})) - Promise.all([stats, release]) - .then(([statsFacts, releaseFacts]) => showFacts({ ...statsFacts, ...releaseFacts })) + pending = Promise.all([stats, release]).then(([s, r]) => ({ ...s, ...r })) } } else { - SRC.href = ORG_URL - if (textEl) textEl.textContent = ORG_NAME + pending = cachedFetchJSON(`https://api.github.com/users/${ORG_NAME}`) + .then(info => ({ repositories: info.public_repos })) + .catch(() => ({})) + } + + /* Swap the promise for its resolved value so later repaints are synchronous. */ + const entry = pending.then(facts => { + factsByPrefix.set(prefix, facts) + return facts + }) + factsByPrefix.set(prefix, entry) + return entry + } - cachedFetchJSON(`https://api.github.com/users/${ORG_NAME}`) - .then(info => showFacts({ repositories: info.public_repos })) - .catch(() => {}) + /* Guards against a slow response for a route the reader has already left: + only the newest prefix is allowed to paint. */ + let currentPrefix = null + + function update() { + const prefix = location.pathname.split("/", 2)[1] || "" + currentPrefix = prefix + + const repo = repoMap[prefix] + const url = repo ? repo.url : ORG_URL + const name = repo ? repo.name : ORG_NAME + + /* Paint the link immediately; facts follow, synchronously when memoised. */ + const facts = factsFor(prefix, repo) + if (typeof facts.then !== "function") { + paint(url, name, facts) + return } + + paint(url, name, null) + facts.then(resolved => { + if (currentPrefix === prefix) paint(url, name, resolved) + }) } update() if (window.document$) { /* document$ replays the current document; skip that event because update() already ran above, then react only to later instant-navigation swaps. */ - let first = true + let replayed = false window.document$.subscribe(() => { - if (first) { first = false; return } + if (!replayed) { replayed = true; return } update() }) } diff --git a/overrides/partials/source.html b/overrides/partials/source.html index 95065517..e731c7af 100644 --- a/overrides/partials/source.html +++ b/overrides/partials/source.html @@ -1,5 +1,5 @@ {#- - CUSTOMIZED: copied from mkdocs-material 9.7.6 + CUSTOMIZED: copied from mkdocs-material 9.7.7 (material/templates/partials/source.html) to prefer page.meta.repo_url and page.meta.repo_name. The merge_subproject_configs hook's on_page_context sets those for pages inside !include'd sub-projects so @@ -11,10 +11,18 @@ a single repo -- both wrong for a monorepo with a different repo per tab. docs/js/repo-source.js re-derives the correct repo from the current URL and re-fetches its stats after every navigation; see that file for why. + + CUSTOMIZED: the link is marked with data-repo-source instead of upstream's + data-md-component="source". Material mounts its own fact-fetching over every + element carrying that component name, which would append a second stats list + next to the one repo-source.js renders -- the cause of the stats showing + twice on a cold page load. Dropping the component name leaves repo-source.js + as the only writer and saves a redundant GitHub API call; nothing else keys + off the value, as the theme's styling targets the .md-source* classes. -#} {% set repo_url = page.meta.repo_url if page and page.meta.repo_url else config.repo_url %} {% set repo_name = page.meta.repo_name if page and page.meta.repo_name else config.repo_name %} - +
{% set icon = config.theme.icon.repo or "fontawesome/brands/git-alt" %} {% include ".icons/" ~ icon ~ ".svg" %}