July 6, 2026
The Sidebar That Wouldn't Sit Still
A one-line CSS change surfaced a sidebar that visibly resized on every page change. Chasing it meant reading Astro's own swap implementation — and learning why two "obvious" fixes both made things worse.
The bug report was one sentence: “when changing page in the admin, the sidebar enlarges then shrinks.” No repro steps, no screenshot — just a shape changing on screen faster than anyone could point at it. That kind of bug is a trap. It’s tempting to reach for the nearest plausible cause, patch it, and move on. Twice, that’s exactly what happened, and twice the fix made something else worse.
01 — The setup: a resizable, collapsible sidebar
The admin layout keeps its sidebar width in a single CSS custom property, --sidebar-w, set on a grid container:
<div id="shell" style="--sidebar-w: 17rem; grid-template-columns: var(--sidebar-w) minmax(0, 1fr)">
<header style="width: var(--sidebar-w)">...</header>
...
</div>
A small script restores the user’s saved width (or collapsed state) from localStorage on load, and the CSS declares transition: grid-template-columns 220ms so drags and toggles animate smoothly. Straightforward, until you add client-side page transitions into the mix.
The app uses Astro’s ClientRouter, which intercepts same-origin link clicks, fetches the new page, and swaps it in without a full reload. Every navigation re-renders the whole layout from scratch — including #shell, freshly parsed with its server-rendered default of 17rem. The script that restores the saved width only runs after that swap completes. For anyone who had resized or collapsed the sidebar, that gap was visible: default width, then a snap to the real one, on every single click.
Geography doesn’t fail loudly and neither does a CSS variable — a stale default just sits there, confidently, until something notices.
02 — First fix, wrong layer
The obvious tool for “don’t recreate this on navigation” is Astro’s transition:persist directive — mark an element and it survives the swap instead of being rebuilt. Applied to #shell, it worked immediately: no more flash.
It also stopped the page from changing. transition:persist persists the entire subtree of the element it’s placed on, and #shell was the grid container wrapping both the sidebar and the content <slot />. Persisting the container persisted the content too — every navigation kept showing the old page’s body, because the DOM node holding it was, by design, no longer being replaced.
The lesson wasn’t “don’t use persist,” it was persist the smallest node that actually needs to survive, not the one that happens to be a convenient ancestor.
03 — Second fix, wrong tradeoff
Correcting course, transition:persist moved down one level, onto the <header> sidebar itself — leaving the content div free to swap normally. Width stopped flashing (the header now genuinely never got rebuilt). But the active-link highlight froze on whatever page had been open first.
That highlight comes from server-side logic — isActive(item.href) compares the current path to each nav item at render time. Persisting the header means Astro never re-renders it, so that comparison never runs again after the first page load. The fix for one symptom quietly disabled the mechanism responsible for another piece of correct behavior. Nothing failed loudly; the sidebar just stopped updating, which is arguably worse than a visible bug because it looks like nothing is wrong until you actually click around.
Two attempts in, the pattern was clear: persisting anything upstream of content-that-must-change or state-that-must-recompute was going to keep trading one bug for another. The right fix couldn’t freeze a DOM subtree — it needed to change what value the fresh subtree was born with.
04 — Reading the swap, not guessing at it
Rather than try a third variation blind, it was worth reading what Astro’s router actually does during a transition. Its swap implementation lives in a small, readable file, and the relevant shape of it is:
async function doSwap(afterPreparation, viewTransition, afterDispatch) {
const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition);
document.dispatchEvent(event); // astro:before-swap — listeners can mutate event.newDocument here
if (afterDispatch) await afterDispatch();
event.swap(); // swap(event.newDocument) — this is what actually replaces the DOM
return event;
}
astro:before-swap fires with a live reference to the incoming page’s parsed Document, before that document replaces anything on screen. Any mutation made to it in that window ships with the swap — there’s no separate “final” copy taken afterward. That’s a hook for exactly this problem: instead of persisting a node to dodge a rebuild, reach into the about-to-be-inserted page and give it the right starting value before it’s ever painted.
document.addEventListener("astro:before-swap", (e) => {
const oldShell = document.getElementById("shell");
const newShell = e.newDocument.getElementById("shell");
if (!oldShell || !newShell) return;
const w = oldShell.style.getPropertyValue("--sidebar-w");
if (w) newShell.style.setProperty("--sidebar-w", w);
if ("collapsed" in oldShell.dataset) newShell.dataset.collapsed = "";
else delete newShell.dataset.collapsed;
});
No persisted nodes, no frozen subtrees. #shell still gets rebuilt on every navigation — it’s just no longer born with the wrong value. The content div and the sidebar’s active-link logic keep re-rendering normally, because nothing about them changed.
05 — Verifying the invisible
A flash that lasts a fraction of a transition’s 220ms isn’t something you confirm by eyeballing a dev server. Rather than assert the fix worked from reading the code, it made more sense to drive a real headless browser over the CDP protocol, set a non-default sidebar width in localStorage, sample the sidebar’s computed width every 8ms across a full navigation, and check whether it ever touched the default:
const iv = setInterval(() => {
samples.push({
t: performance.now() - start,
shellVar: shell.style.getPropertyValue("--sidebar-w"),
headerWidth: header.getBoundingClientRect().width,
});
}, 8);
Across the whole sampled window — collapsed, expanded, and custom widths — the value never moved. Same test, checked the active nav link after a click landed on /writing: exactly one link marked aria-current="page", and it was the right one. Both properties, confirmed in the same run, rather than assumed from two separate “should work now” edits.
06 — The same bug, one layer earlier
astro:before-swap fixed every in-app navigation. It did nothing for a hard refresh or the first load of a session, because there’s no “old page” for that event to copy a width from — there’s no swap at all, just a fresh parse.
The restore script still worked there too, technically. It read localStorage and set --sidebar-w correctly. It just did so from a handler on astro:page-load, which fires once the DOM has already been parsed and painted with the server-rendered default of 17rem. For anyone with a saved width or a collapsed sidebar, that produced exactly the original bug’s sibling: paint at the wrong size, then correct, with the 220ms transition: grid-template-columns turning the correction into a visible little resize instead of an instant snap.
This is the same shape of bug as section 01, just moved from “between pages” to “before the first page.” The fix follows the same principle as before-swap: give the element the right value before it’s shown, don’t correct it after. Since there’s no incoming-document hook to reach into on a fresh load, the right value has to be applied synchronously, in-place, before the browser gets a chance to paint:
<div id="shell" style="--sidebar-w: 17rem; ...">
<script is:inline>
(function () {
const shell = document.getElementById("shell");
const savedC = localStorage.getItem("admin-sidebar-collapsed") === "true";
const savedW = localStorage.getItem("admin-sidebar-width") ?? "17rem";
if (savedC) {
shell.dataset.collapsed = "";
shell.style.setProperty("--sidebar-w", "3.25rem");
} else {
shell.style.setProperty("--sidebar-w", savedW);
}
})();
</script>
<header style="width: var(--sidebar-w)">...</header>
</div>
Placed immediately after #shell’s opening tag, this script runs the instant that element exists in the DOM — before <header> is even parsed, let alone painted. It’s a synchronous, non-module script for exactly that reason: anything deferred, async, or bound to a page-load event runs too late to matter here. The astro:page-load listener still exists for the collapse button and drag-resize to keep working; it just no longer carries the burden of being the first thing to set the width.
What stuck
The actual fix is eleven lines in an event listener nobody will notice, plus, a week later, fourteen more inline lines for the load path that event listener couldn’t reach. Both bugs were the same mistake wearing a different hat: a DOM node born with a placeholder value, corrected only after something had already looked at it. Getting there took reading a framework’s source rather than pattern-matching on the first API that sounded relevant, and it took building a throwaway test harness rather than trusting that a fix which compiled must be a fix that works. Neither step was strictly necessary to try something — but both were necessary to know, rather than hope, that the sidebar would finally sit still.