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..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 e698ee0cf..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 443fe4e96..eebceef26 100644
--- a/src/components/ui/theme-toggle/theme-provider.astro
+++ b/src/components/ui/theme-toggle/theme-provider.astro
@@ -70,6 +70,20 @@
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) => {
+ const swap = event.swap
+ event.swap = function (...args) {
+ event.newDocument.documentElement.classList.toggle(
+ "dark",
+ appliedTheme === "dark"
+ )
+ return swap.apply(this, args)
+ }
+ })
+
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..c859dd08f
--- /dev/null
+++ b/tests/browser/client-router.test.mjs
@@ -0,0 +1,150 @@
+// 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 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"
+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()
+ })
+
+ 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()
+ })
+ })
+}