From 7c8a2097f07607b4bac6c328139aa466e64615f1 Mon Sep 17 00:00:00 2001 From: Sil Date: Thu, 15 Oct 2026 12:32:30 +0200 Subject: [PATCH 1/3] Keep the theme across ClientRouter navigation Astro's ClientRouter copies the next page's attributes, which cannot know the theme. ThemeProvider now carries the applied theme onto the next page in astro:before-swap, so the theme no longer flashes and the class observer no longer saves the server's light theme as the visitor's choice. Replaces #260, which predates the system preference syncing on main. Co-authored-by: El Mahdi Bennajah Co-Authored-By: Claude Opus 5.5 --- .changeset/theme-client-router.md | 5 + public/r/styles/base-luma/theme-toggle.json | 2 +- public/r/styles/base-lyra/theme-toggle.json | 2 +- public/r/styles/base-maia/theme-toggle.json | 2 +- public/r/styles/base-mira/theme-toggle.json | 2 +- public/r/styles/base-nova/theme-toggle.json | 2 +- public/r/styles/base-rhea/theme-toggle.json | 2 +- public/r/styles/base-sera/theme-toggle.json | 2 +- public/r/styles/base-vega/theme-toggle.json | 2 +- public/r/theme-toggle.json | 2 +- .../ui/theme-toggle/theme-provider.astro | 10 ++ src/content/pages/components/theme-toggle.mdx | 1 + tests/browser/client-router.test.mjs | 127 ++++++++++++++++++ 13 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 .changeset/theme-client-router.md create mode 100644 tests/browser/client-router.test.mjs diff --git a/.changeset/theme-client-router.md b/.changeset/theme-client-router.md new file mode 100644 index 000000000..2c7f1c437 --- /dev/null +++ b/.changeset/theme-client-router.md @@ -0,0 +1,5 @@ +--- +"fulldev-ui": patch +--- + +`ThemeProvider` keeps the theme across Astro `` navigation. Before, each navigation briefly showed the light theme and saved it as the visitor's choice, so a dark theme was lost. diff --git a/public/r/styles/base-luma/theme-toggle.json b/public/r/styles/base-luma/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-luma/theme-toggle.json +++ b/public/r/styles/base-luma/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-lyra/theme-toggle.json b/public/r/styles/base-lyra/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-lyra/theme-toggle.json +++ b/public/r/styles/base-lyra/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-maia/theme-toggle.json b/public/r/styles/base-maia/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-maia/theme-toggle.json +++ b/public/r/styles/base-maia/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-mira/theme-toggle.json b/public/r/styles/base-mira/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-mira/theme-toggle.json +++ b/public/r/styles/base-mira/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-nova/theme-toggle.json b/public/r/styles/base-nova/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-nova/theme-toggle.json +++ b/public/r/styles/base-nova/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-rhea/theme-toggle.json b/public/r/styles/base-rhea/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-rhea/theme-toggle.json +++ b/public/r/styles/base-rhea/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-sera/theme-toggle.json b/public/r/styles/base-sera/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-sera/theme-toggle.json +++ b/public/r/styles/base-sera/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-vega/theme-toggle.json b/public/r/styles/base-vega/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/styles/base-vega/theme-toggle.json +++ b/public/r/styles/base-vega/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/theme-toggle.json b/public/r/theme-toggle.json index e698ee0cf..baa97e863 100644 --- a/public/r/theme-toggle.json +++ b/public/r/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/src/components/ui/theme-toggle/theme-provider.astro b/src/components/ui/theme-toggle/theme-provider.astro index 443fe4e96..5e5e7cdaa 100644 --- a/src/components/ui/theme-toggle/theme-provider.astro +++ b/src/components/ui/theme-toggle/theme-provider.astro @@ -70,6 +70,16 @@ applyTheme(getThemePreference(), true) }) + // Astro's replaces the attributes with those of the + // next page, which cannot know the theme. Carry the theme over before the + // swap, so it neither flashes nor reaches the observer below as a choice. + document.addEventListener("astro:before-swap", (event) => { + event.newDocument.documentElement.classList.toggle( + "dark", + appliedTheme === "dark" + ) + }) + if (typeof localStorage !== "undefined") { const observer = new MutationObserver(() => { const theme = document.documentElement.classList.contains("dark") diff --git a/src/content/pages/components/theme-toggle.mdx b/src/content/pages/components/theme-toggle.mdx index e956ec13c..798264b72 100644 --- a/src/content/pages/components/theme-toggle.mdx +++ b/src/content/pages/components/theme-toggle.mdx @@ -61,6 +61,7 @@ import { ThemeToggle } from "@/components/ui/theme-toggle" - `ThemeToggle` is a two-state light and dark switch. - `ThemeProvider` reads the saved preference first and falls back to the system color scheme on first load. +- `ThemeProvider` keeps the theme across Astro `` navigation. - If you need a richer theme picker, build that separately on top of the same `.dark` class convention. diff --git a/tests/browser/client-router.test.mjs b/tests/browser/client-router.test.mjs new file mode 100644 index 000000000..6b85a8a97 --- /dev/null +++ b/tests/browser/client-router.test.mjs @@ -0,0 +1,127 @@ +// Checks that ThemeProvider keeps the theme across Astro +// navigation in Chromium, Firefox and WebKit: no flash, and no theme saved +// that the visitor did not choose. Builds its own two-page site, so it needs +// no docs build. Run with `vp run test:browser`. +import assert from "node:assert/strict" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join, resolve } from "node:path" +import { after, before, test } from "node:test" +import { build, preview } from "astro" +import { chromium, firefox, webkit } from "playwright" + +const src = resolve("src") +const root = resolve("node_modules/.cache/client-router-site") +const page = (title, next) => `--- +import { ClientRouter } from "astro:transitions" +import { ThemeProvider, ThemeToggle } from "${src}/components/ui/theme-toggle" +--- + + + + ${title} + + + + +

${title}

+ Next + + + +` + +let server +let origin +before(async () => { + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, "src/pages"), { recursive: true }) + writeFileSync(join(root, "src/pages/index.astro"), page("One", "/two/")) + writeFileSync(join(root, "src/pages/two.astro"), page("Two", "/")) + const config = { + root, + configFile: false, + logLevel: "error", + vite: { resolve: { alias: { "@": src } } }, + } + await build(config) + server = await preview({ ...config, server: { port: 0 } }) + origin = `http://localhost:${server.port}` +}) +after(async () => { + await server?.stop() + rmSync(root, { recursive: true, force: true }) +}) + +// Navigates with the ClientRouter and reports whether the page showed the +// light theme at any moment of the swap. +const navigate = async (page, go) => { + await page.evaluate(() => { + window.__loaded = false + window.__lightDuringSwap = false + const loaded = () => (window.__loaded = true) + document.addEventListener("astro:page-load", loaded, { once: true }) + new MutationObserver(() => { + if (!document.documentElement.classList.contains("dark")) + window.__lightDuringSwap = true + }).observe(document.documentElement, { attributes: true }) + }) + await go() + await page.waitForFunction(() => window.__loaded) + return page.evaluate(() => window.__lightDuringSwap) +} +const state = (page) => + page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + stored: localStorage.getItem("theme"), + toggle: document.querySelector("[data-theme-toggle]")?.dataset.state, + })) + +for (const browserType of [chromium, firefox, webkit]) { + test(`${browserType.name()} keeps the theme across ClientRouter navigation`, async (t) => { + const browser = await browserType.launch() + t.after(() => browser.close()) + + await t.test("system dark without a choice", async () => { + const context = await browser.newContext({ colorScheme: "dark" }) + const page = await context.newPage() + await page.goto(`${origin}/`) + const flashed = await navigate(page, () => page.click("a")) + assert.equal(flashed, false) + assert.deepEqual(await state(page), { + dark: true, + stored: null, + toggle: "on", + }) + await context.close() + }) + + await t.test("a dark choice on a light system, also back", async () => { + const context = await browser.newContext({ colorScheme: "light" }) + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.click("[data-theme-toggle]") + assert.equal(await navigate(page, () => page.click("a")), false) + assert.equal(await navigate(page, () => page.goBack()), false) + assert.deepEqual(await state(page), { + dark: true, + stored: "dark", + toggle: "on", + }) + await context.close() + }) + + await t.test("a light choice on a dark system", async () => { + const context = await browser.newContext({ colorScheme: "dark" }) + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.click("[data-theme-toggle]") + await navigate(page, () => page.click("a")) + assert.deepEqual(await state(page), { + dark: false, + stored: "light", + toggle: "off", + }) + await context.close() + }) + }) +} From bb79e4c2d86604825f4b620c7911200bb2b139ab Mon Sep 17 00:00:00 2001 From: Sil Date: Thu, 15 Oct 2026 12:56:50 +0200 Subject: [PATCH 2/3] fix: preserve theme changes during Astro fallback transitions --- public/r/styles/base-luma/theme-toggle.json | 2 +- public/r/styles/base-lyra/theme-toggle.json | 2 +- public/r/styles/base-maia/theme-toggle.json | 2 +- public/r/styles/base-mira/theme-toggle.json | 2 +- public/r/styles/base-nova/theme-toggle.json | 2 +- public/r/styles/base-rhea/theme-toggle.json | 2 +- public/r/styles/base-sera/theme-toggle.json | 2 +- public/r/styles/base-vega/theme-toggle.json | 2 +- public/r/theme-toggle.json | 2 +- .../ui/theme-toggle/theme-provider.astro | 12 ++++++--- tests/browser/client-router.test.mjs | 27 +++++++++++++++++-- 11 files changed, 42 insertions(+), 15 deletions(-) diff --git a/public/r/styles/base-luma/theme-toggle.json b/public/r/styles/base-luma/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-luma/theme-toggle.json +++ b/public/r/styles/base-luma/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-lyra/theme-toggle.json b/public/r/styles/base-lyra/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-lyra/theme-toggle.json +++ b/public/r/styles/base-lyra/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-maia/theme-toggle.json b/public/r/styles/base-maia/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-maia/theme-toggle.json +++ b/public/r/styles/base-maia/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-mira/theme-toggle.json b/public/r/styles/base-mira/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-mira/theme-toggle.json +++ b/public/r/styles/base-mira/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-nova/theme-toggle.json b/public/r/styles/base-nova/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-nova/theme-toggle.json +++ b/public/r/styles/base-nova/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-rhea/theme-toggle.json b/public/r/styles/base-rhea/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-rhea/theme-toggle.json +++ b/public/r/styles/base-rhea/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-sera/theme-toggle.json b/public/r/styles/base-sera/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-sera/theme-toggle.json +++ b/public/r/styles/base-sera/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-vega/theme-toggle.json b/public/r/styles/base-vega/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/styles/base-vega/theme-toggle.json +++ b/public/r/styles/base-vega/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/theme-toggle.json b/public/r/theme-toggle.json index baa97e863..381ec031a 100644 --- a/public/r/theme-toggle.json +++ b/public/r/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/src/components/ui/theme-toggle/theme-provider.astro b/src/components/ui/theme-toggle/theme-provider.astro index 5e5e7cdaa..fb8456348 100644 --- a/src/components/ui/theme-toggle/theme-provider.astro +++ b/src/components/ui/theme-toggle/theme-provider.astro @@ -74,10 +74,14 @@ // next page, which cannot know the theme. Carry the theme over before the // swap, so it neither flashes nor reaches the observer below as a choice. document.addEventListener("astro:before-swap", (event) => { - event.newDocument.documentElement.classList.toggle( - "dark", - appliedTheme === "dark" - ) + const swap = event.swap + event.swap = () => { + event.newDocument.documentElement.classList.toggle( + "dark", + appliedTheme === "dark" + ) + swap() + } }) if (typeof localStorage !== "undefined") { diff --git a/tests/browser/client-router.test.mjs b/tests/browser/client-router.test.mjs index 6b85a8a97..c859dd08f 100644 --- a/tests/browser/client-router.test.mjs +++ b/tests/browser/client-router.test.mjs @@ -1,7 +1,7 @@ // Checks that ThemeProvider keeps the theme across Astro // navigation in Chromium, Firefox and WebKit: no flash, and no theme saved // that the visitor did not choose. Builds its own two-page site, so it needs -// no docs build. Run with `vp run test:browser`. +// no docs build. Run with `vp node --test tests/browser/client-router.test.mjs`. import assert from "node:assert/strict" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { join, resolve } from "node:path" @@ -16,7 +16,7 @@ import { ClientRouter } from "astro:transitions" import { ThemeProvider, ThemeToggle } from "${src}/components/ui/theme-toggle" --- - + ${title} @@ -123,5 +123,28 @@ for (const browserType of [chromium, firefox, webkit]) { }) await context.close() }) + + await t.test("a newer choice during the fallback fade", async () => { + const context = await browser.newContext({ colorScheme: "dark" }) + await context.addInitScript(() => { + document.startViewTransition = undefined + }) + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.evaluate(() => { + document.addEventListener( + "astro:before-swap", + () => setTimeout(() => window.__setTheme("light"), 20), + { once: true } + ) + }) + await navigate(page, () => page.click("a")) + assert.deepEqual(await state(page), { + dark: false, + stored: "light", + toggle: "off", + }) + await context.close() + }) }) } From 34ce7f6be8dcd9e59ce976451fc1dc9872fd2ae7 Mon Sep 17 00:00:00 2001 From: Sil Date: Thu, 15 Oct 2026 12:57:11 +0200 Subject: [PATCH 3/3] fix: preserve existing Astro swap callback contract --- public/r/styles/base-luma/theme-toggle.json | 2 +- public/r/styles/base-lyra/theme-toggle.json | 2 +- public/r/styles/base-maia/theme-toggle.json | 2 +- public/r/styles/base-mira/theme-toggle.json | 2 +- public/r/styles/base-nova/theme-toggle.json | 2 +- public/r/styles/base-rhea/theme-toggle.json | 2 +- public/r/styles/base-sera/theme-toggle.json | 2 +- public/r/styles/base-vega/theme-toggle.json | 2 +- public/r/theme-toggle.json | 2 +- src/components/ui/theme-toggle/theme-provider.astro | 4 ++-- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/public/r/styles/base-luma/theme-toggle.json b/public/r/styles/base-luma/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-luma/theme-toggle.json +++ b/public/r/styles/base-luma/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-lyra/theme-toggle.json b/public/r/styles/base-lyra/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-lyra/theme-toggle.json +++ b/public/r/styles/base-lyra/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-maia/theme-toggle.json b/public/r/styles/base-maia/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-maia/theme-toggle.json +++ b/public/r/styles/base-maia/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-mira/theme-toggle.json b/public/r/styles/base-mira/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-mira/theme-toggle.json +++ b/public/r/styles/base-mira/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-nova/theme-toggle.json b/public/r/styles/base-nova/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-nova/theme-toggle.json +++ b/public/r/styles/base-nova/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-rhea/theme-toggle.json b/public/r/styles/base-rhea/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-rhea/theme-toggle.json +++ b/public/r/styles/base-rhea/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-sera/theme-toggle.json b/public/r/styles/base-sera/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-sera/theme-toggle.json +++ b/public/r/styles/base-sera/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/styles/base-vega/theme-toggle.json b/public/r/styles/base-vega/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/styles/base-vega/theme-toggle.json +++ b/public/r/styles/base-vega/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/public/r/theme-toggle.json b/public/r/theme-toggle.json index 381ec031a..7eef0d675 100644 --- a/public/r/theme-toggle.json +++ b/public/r/theme-toggle.json @@ -17,7 +17,7 @@ }, { "path": "src/components/ui/theme-toggle/theme-provider.astro", - "content": "\n", + "content": "\n", "type": "registry:ui" }, { diff --git a/src/components/ui/theme-toggle/theme-provider.astro b/src/components/ui/theme-toggle/theme-provider.astro index fb8456348..eebceef26 100644 --- a/src/components/ui/theme-toggle/theme-provider.astro +++ b/src/components/ui/theme-toggle/theme-provider.astro @@ -75,12 +75,12 @@ // swap, so it neither flashes nor reaches the observer below as a choice. document.addEventListener("astro:before-swap", (event) => { const swap = event.swap - event.swap = () => { + event.swap = function (...args) { event.newDocument.documentElement.classList.toggle( "dark", appliedTheme === "dark" ) - swap() + return swap.apply(this, args) } })