From dc67ae886b7af6cf7b7fadae4935f08fc54b9fa4 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 6 Nov 2026 01:44:54 -0700 Subject: [PATCH 1/3] feat(create): add Themer integration --- .changeset/soft-themes-switch.md | 5 + packages/cli/tests-e2e/themer-smoke.spec.ts | 164 ++++++++++++++++++ .../frameworks/react/add-ons/themer/README.md | 5 + .../src/integrations/themer/provider.tsx | 56 ++++++ .../frameworks/react/add-ons/themer/info.json | 19 ++ .../react/add-ons/themer/package.json | 5 + .../base/src/components/ThemeToggle.tsx.ejs | 26 +++ .../project/base/src/routes/__root.tsx.ejs | 10 +- 8 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 .changeset/soft-themes-switch.md create mode 100644 packages/cli/tests-e2e/themer-smoke.spec.ts create mode 100644 packages/create/src/frameworks/react/add-ons/themer/README.md create mode 100644 packages/create/src/frameworks/react/add-ons/themer/assets/src/integrations/themer/provider.tsx create mode 100644 packages/create/src/frameworks/react/add-ons/themer/info.json create mode 100644 packages/create/src/frameworks/react/add-ons/themer/package.json diff --git a/.changeset/soft-themes-switch.md b/.changeset/soft-themes-switch.md new file mode 100644 index 00000000..8f6959e7 --- /dev/null +++ b/.changeset/soft-themes-switch.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": minor +--- + +Add the optional Themer integration for React Start, preserving saved light, dark and system preferences before hydration and across browser tabs. diff --git a/packages/cli/tests-e2e/themer-smoke.spec.ts b/packages/cli/tests-e2e/themer-smoke.spec.ts new file mode 100644 index 00000000..4d814694 --- /dev/null +++ b/packages/cli/tests-e2e/themer-smoke.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test' + +import { createReactAppFixture } from './helpers' + +declare global { + interface Window { + __themerMutations: Array<{ at: number; className: string }> + } +} + +// Blocking client modules lets this check inspect the server-rendered document +// after its first contentful paint, before React can repair a wrong theme. +test('@blocking preserves Themer preferences before hydration and across tabs', async ({ + browser, +}) => { + const app = await createReactAppFixture({ + appName: 'themer-smoke-app', + addOns: ['themer'], + packageManager: 'bun', + runQualityGatesChecks: true, + }) + + try { + for (const scenario of [ + { stored: 'light', system: 'dark', expected: 'light' }, + { stored: 'dark', system: 'light', expected: 'dark' }, + { stored: 'auto', system: 'dark', expected: 'dark' }, + { stored: 'auto', system: 'light', expected: 'light' }, + { stored: 'denied', system: 'dark', expected: 'dark' }, + ] as const) { + const context = await browser.newContext({ colorScheme: scenario.system }) + try { + const page = await context.newPage() + await page.addInitScript(({ stored }) => { + window.__themerMutations = [] + new MutationObserver(() => { + if (document.documentElement) + window.__themerMutations.push({ + at: performance.now(), + className: document.documentElement.className, + }) + }).observe(document, { + subtree: true, + attributes: true, + attributeFilter: ['class'], + }) + if (stored === 'denied') { + Object.defineProperty(window, 'localStorage', { + get() { + throw new DOMException( + 'Storage blocked for test', + 'SecurityError', + ) + }, + }) + } else { + localStorage.setItem('theme', stored) + } + }, scenario) + const blockModules = async ( + route: import('@playwright/test').Route, + ) => { + if (route.request().resourceType() === 'script') await route.abort() + else await route.continue() + } + await page.route('**/*', blockModules) + await page.goto(app.url, { waitUntil: 'domcontentloaded' }) + await expect( + page.getByRole('button', { name: 'Switch theme', exact: true }), + ).toHaveText('Theme') + await page.waitForFunction( + () => + performance.getEntriesByName('first-contentful-paint').length > 0, + ) + if (scenario.stored !== 'denied') { + const atPaint = await page.evaluate(() => { + const paint = performance.getEntriesByName( + 'first-contentful-paint', + )[0].startTime + return window.__themerMutations + .filter((change) => change.at <= paint) + .at(-1) + }) + expect(atPaint?.className.split(/\s+/)).toContain(scenario.expected) + } + const before = await page.evaluate( + () => getComputedStyle(document.body).color, + ) + expect(before).toBe( + scenario.expected === 'dark' + ? 'rgb(215, 236, 232)' + : 'rgb(23, 58, 64)', + ) + + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if ( + message.type() === 'error' && + /hydrat|did not match/i.test(message.text()) + ) + errors.push(message.text()) + }) + await page.unroute('**/*', blockModules) + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect( + page.getByRole('button', { name: /Theme mode:/ }), + ).toBeVisible() + expect( + await page.evaluate(() => getComputedStyle(document.body).color), + ).toBe(before) + expect(errors).toEqual([]) + if (scenario.stored === 'denied') { + await page.getByRole('button', { name: /Theme mode:/ }).click() + await expect( + page.getByRole('button', { name: /Theme mode: light/ }), + ).toBeVisible() + await page.evaluate(() => + window.dispatchEvent( + new StorageEvent('storage', { key: 'theme', newValue: 'dark' }), + ), + ) + expect(errors).toEqual([]) + } + } finally { + await context.close() + } + } + + const context = await browser.newContext({ colorScheme: 'dark' }) + try { + const first = await context.newPage() + await first.goto(app.url, { waitUntil: 'domcontentloaded' }) + await first.getByRole('button', { name: /Theme mode: system/ }).click() + await expect( + first.getByRole('button', { name: /Theme mode: light/ }), + ).toBeVisible() + await expect + .poll(() => first.evaluate(() => localStorage.getItem('theme'))) + .toBe('light') + await first.reload({ waitUntil: 'domcontentloaded' }) + await expect( + first.getByRole('button', { name: /Theme mode: light/ }), + ).toBeVisible() + const second = await context.newPage() + await second.goto(app.url, { waitUntil: 'domcontentloaded' }) + await second.getByRole('button', { name: /Theme mode: light/ }).click() + await expect( + first.getByRole('button', { name: /Theme mode: dark/ }), + ).toBeVisible() + await first.getByRole('button', { name: /Theme mode: dark/ }).click() + await expect + .poll(() => first.evaluate(() => localStorage.getItem('theme'))) + .toBe('auto') + await first.emulateMedia({ colorScheme: 'light' }) + await expect(first.locator('html')).toHaveClass(/light/) + } finally { + await context.close() + } + } finally { + await app.stop() + await app.cleanup() + } +}) diff --git a/packages/create/src/frameworks/react/add-ons/themer/README.md b/packages/create/src/frameworks/react/add-ons/themer/README.md new file mode 100644 index 00000000..62eb224e --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/themer/README.md @@ -0,0 +1,5 @@ +## Themer + +`src/integrations/themer/provider.tsx` connects `@lonik/themer` to the root document. Use `useTheme` from `@lonik/themer` to read the current theme or switch between `light`, `dark`, and `system`. + +The existing header toggle uses the provider. The storage adapter preserves the scaffold's `theme` preference, including its `auto` value for system mode, and listens for changes from other tabs. diff --git a/packages/create/src/frameworks/react/add-ons/themer/assets/src/integrations/themer/provider.tsx b/packages/create/src/frameworks/react/add-ons/themer/assets/src/integrations/themer/provider.tsx new file mode 100644 index 00000000..ccb83c2d --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/themer/assets/src/integrations/themer/provider.tsx @@ -0,0 +1,56 @@ +import { ThemeProvider } from '@lonik/themer' +import type { ReactNode } from 'react' +import type { ThemeStorage } from '@lonik/themer' + +function normalizeTheme(value: string | null) { + return value === 'light' || value === 'dark' ? value : 'system' +} + +// Keep the scaffold's existing storage format, including older open tabs. +const storage: ThemeStorage = { + getItem(key) { + try { + return normalizeTheme(window.localStorage.getItem(key)) + } catch { + return 'system' + } + }, + setItem(key, value) { + try { + window.localStorage.setItem(key, value === 'system' ? 'auto' : value) + } catch { + // Theme switching still works when browser storage is unavailable. + } + }, + subscribe(key, callback) { + const handleStorage = (event: StorageEvent) => { + let localStorageArea: Storage + try { + localStorageArea = window.localStorage + } catch { + // Storage access can be revoked while this tab remains open. + return + } + if ( + event.storageArea === localStorageArea && + (event.key === key || event.key === null) + ) { + callback(normalizeTheme(event.newValue)) + } + } + window.addEventListener('storage', handleStorage) + return () => window.removeEventListener('storage', handleStorage) + }, +} + +export default function ThemerProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/packages/create/src/frameworks/react/add-ons/themer/info.json b/packages/create/src/frameworks/react/add-ons/themer/info.json new file mode 100644 index 00000000..3ea22bd3 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/themer/info.json @@ -0,0 +1,19 @@ +{ + "name": "Themer", + "description": "Light, dark, and system themes with @lonik/themer.", + "phase": "add-on", + "type": "add-on", + "category": "styling", + "priority": 80, + "link": "https://lukonik.github.io/themer", + "modes": [ + "file-router" + ], + "integrations": [ + { + "type": "provider", + "jsName": "ThemerProvider", + "path": "src/integrations/themer/provider.tsx" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/themer/package.json b/packages/create/src/frameworks/react/add-ons/themer/package.json new file mode 100644 index 00000000..816e0402 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/themer/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@lonik/themer": "^0.5.4" + } +} diff --git a/packages/create/src/frameworks/react/project/base/src/components/ThemeToggle.tsx.ejs b/packages/create/src/frameworks/react/project/base/src/components/ThemeToggle.tsx.ejs index 61b6d82f..abc65cc9 100644 --- a/packages/create/src/frameworks/react/project/base/src/components/ThemeToggle.tsx.ejs +++ b/packages/create/src/frameworks/react/project/base/src/components/ThemeToggle.tsx.ejs @@ -1,4 +1,28 @@ <% if (!includeExamples) { ignoreFile(); return; } %> +<% if (addOnEnabled.themer) { %> +import { useEffect, useState } from 'react' +import { useTheme } from '@lonik/themer' + +export default function ThemeToggle() { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + const mode = mounted ? theme : undefined + const nextMode = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light' + const label = mode ? `Theme mode: ${mode}. Click to switch mode.` : 'Switch theme' + return ( + + ) +} +<% } else { %> import { useEffect, useState } from 'react' type ThemeMode = 'light' | 'dark' | 'auto' @@ -80,3 +104,5 @@ export default function ThemeToggle() { ) } + +<% } %> diff --git a/packages/create/src/frameworks/react/project/base/src/routes/__root.tsx.ejs b/packages/create/src/frameworks/react/project/base/src/routes/__root.tsx.ejs index aa7f0e11..c5b5446e 100644 --- a/packages/create/src/frameworks/react/project/base/src/routes/__root.tsx.ejs +++ b/packages/create/src/frameworks/react/project/base/src/routes/__root.tsx.ejs @@ -1,4 +1,4 @@ -<% const showDevtools = !blank || integrations.some((integration) => integration.type === 'devtools') -%> +<% const themeInitScript = "(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();"; const showDevtools = !blank || integrations.some((integration) => integration.type === 'devtools') -%> <% if (routerOnly) { %> import { Outlet, createRootRoute } from '@tanstack/react-router' <% if (showDevtools) { %> @@ -43,6 +43,9 @@ import <%= integration.jsName %> from '<%= relativePath(integration.path, true) import { getLocale } from '#/paraglide/runtime' <% } %> import appCss from '../styles.css?url' +<% if (addOnEnabled.themer) { %> +const THEME_INIT_SCRIPT = <%- JSON.stringify(themeInitScript) %> +<% } %> <% if (addOnEnabled["apollo-client"]) { %> import type { ApolloClientIntegration } from "@apollo/client-integration-tanstack-start"; <% } %> @@ -98,8 +101,9 @@ export const Route = <% if (hasContext) { %>createRootRouteWithContext<% } else { %><% } %> + <% if (addOnEnabled.paraglide) { %> suppressHydrationWarning<% } %>><% } else { %> suppressHydrationWarning<% } %>><% } %> + <% if (addOnEnabled.themer) { %>