Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-themes-switch.md
Original file line number Diff line number Diff line change
@@ -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.
212 changes: 212 additions & 0 deletions packages/cli/tests-e2e/themer-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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: 'pnpm',
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,
)
{
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<string> = []
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()
}
})

test('@blocking applies the system theme in a blank app with blocked storage', async ({
browser,
}) => {
const app = await createReactAppFixture({
appName: 'themer-blank-storage-app',
blank: true,
addOns: ['themer'],
packageManager: 'pnpm',
runQualityGatesChecks: true,
})
try {
for (const system of ['light', 'dark'] as const) {
const context = await browser.newContext({ colorScheme: system })
try {
const page = await context.newPage()
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', {
get() {
throw new DOMException(
'Storage blocked for test',
'SecurityError',
)
},
})
})
await page.route('**/*', async (route) => {
if (route.request().resourceType() === 'script') await route.abort()
else await route.continue()
})
await page.goto(app.url, { waitUntil: 'domcontentloaded' })
await page.waitForFunction(
() =>
performance.getEntriesByName('first-contentful-paint').length > 0,
)
await expect(page.locator('html')).toHaveClass(system)
expect(
await page.locator('html').evaluate((root) => root.style.colorScheme),
).toBe(system)
} finally {
await context.close()
}
}
} finally {
await app.stop()
await app.cleanup()
}
})
5 changes: 5 additions & 0 deletions packages/create/src/frameworks/react/add-ons/themer/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeProvider
storage={storage}
attribute={['class', 'data-theme']}
defaultTheme="system"
>
{children}
</ThemeProvider>
)
}
19 changes: 19 additions & 0 deletions packages/create/src/frameworks/react/add-ons/themer/info.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"@lonik/themer": "^0.5.4"
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={() => setTheme(nextMode)}
aria-label={label}
title={label}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
>
{mode === 'system' ? 'Auto' : mode === 'dark' ? 'Dark' : mode === 'light' ? 'Light' : 'Theme'}
</button>
)
}
<% } else { %>
import { useEffect, useState } from 'react'

type ThemeMode = 'light' | 'dark' | 'auto'
Expand Down Expand Up @@ -80,3 +104,5 @@ export default function ThemeToggle() {
</button>
)
}

<% } %>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<% const showDevtools = !blank || integrations.some((integration) => integration.type === 'devtools') -%>
<% const themeInitScript = "(function(){try{var stored=null;try{stored=window.localStorage.getItem('theme')}catch(e){}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) { %>
Expand Down Expand Up @@ -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";
<% } %>
Expand Down Expand Up @@ -98,8 +101,9 @@ export const Route = <% if (hasContext) { %>createRootRouteWithContext<MyRouterC

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<% if (addOnEnabled.paraglide) { %><html lang={getLocale()}><% } else { %><html lang="en"><% } %>
<% if (addOnEnabled.paraglide) { %><html lang={getLocale()}<% if (addOnEnabled.themer) { %> suppressHydrationWarning<% } %>><% } else { %><html lang="en"<% if (addOnEnabled.themer) { %> suppressHydrationWarning<% } %>><% } %>
<head>
<% if (addOnEnabled.themer) { %><script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /><% } %>
<HeadContent />
</head>
<body>
Expand Down Expand Up @@ -158,7 +162,7 @@ interface MyRouterContext <% if (addOnEnabled["apollo-client"]) {%> extends Apol
<% } %>
}<% } %>

const THEME_INIT_SCRIPT = `(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 THEME_INIT_SCRIPT = <%- JSON.stringify(themeInitScript) %>

export const Route = <% if (hasContext) { %>createRootRouteWithContext<MyRouterContext>()<% } else { %>createRootRoute<% } %>({
<% if (addOnEnabled.paraglide) { %>
Expand Down
Loading