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() + }) + }) +}