Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow using 'localStorage' and 'sessionStorage' for storing the theme #293

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
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
15 changes: 14 additions & 1 deletion next-themes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ Let's dig into the details.

All your theme configuration is passed to ThemeProvider.

- `storageKey = 'theme'`: Key used to store theme setting in localStorage
- `storageKey = 'theme'`: Key used to store theme setting in the selected storage (see `storage` property below)
- `storage`: Optional storage configuration. Defaults to 'localStorage'. Can be set to 'localStorage', 'sessionStorage'.
- `defaultTheme = 'system'`: Default theme name (for v0.0.12 and lower the default was `light`). If `enableSystem` is false, the default theme is `light`
- `forcedTheme`: Forced theme name for the current page (does not modify saved theme settings)
- `enableSystem = true`: Whether to switch between `dark` and `light` based on `prefers-color-scheme`
Expand Down Expand Up @@ -292,6 +293,18 @@ next-themes is designed to support any number of themes! Simply pass a list of t

For an example on how to use this, check out the [multi-theme example](./examples/multi-theme/README.md)

### Storage

The ThemeProvider persists the users selected theme in a storage location.
By default, the theme is persisted in the browsers localStorage.

The available storages are:

- `storage='localStorage'` (default)
- when using localStorage, the theme is synced across tabs and windows
- `storage='sessionStorage'`
- when using sessionStorage, the theme is only persisted for the current session, and not synced across tabs and windows

### Without CSS variables

This library does not rely on your theme styling using CSS variables. You can hard-code the values in your CSS, and everything will work as expected (without any flashing):
Expand Down
112 changes: 60 additions & 52 deletions next-themes/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,12 @@ import { cleanup } from '@testing-library/react'

import { ThemeProvider, useTheme } from '../src/index'
import { ThemeProviderProps } from '../src/types'
import { setDeviceTheme } from './mocks/device-theme'
import { makeBrowserStorageMock } from './mocks/storage'
import exp from 'constants'

let originalLocalStorage: Storage
const localStorageMock: Storage = (() => {
let store: Record<string, string> = {}

return {
getItem: vi.fn((key: string): string => store[key] ?? null),
setItem: vi.fn((key: string, value: string): void => {
store[key] = value.toString()
}),
removeItem: vi.fn((key: string): void => {
delete store[key]
}),
clear: vi.fn((): void => {
store = {}
}),
key: vi.fn((index: number): string | null => ''),
length: Object.keys(store).length
}
})()
let originalSessionStorage: Storage

// HelperComponent to render the theme inside a paragraph-tag and setting a theme via the forceSetTheme prop
const HelperComponent = ({ forceSetTheme }: { forceSetTheme?: string }) => {
Expand All @@ -48,28 +34,12 @@ const HelperComponent = ({ forceSetTheme }: { forceSetTheme?: string }) => {
)
}

function setDeviceTheme(theme: 'light' | 'dark') {
// Create a mock of the window.matchMedia function
// Based on: https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: theme === 'dark' ? true : false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
}

beforeAll(() => {
// Create mocks of localStorage getItem and setItem functions
originalLocalStorage = window.localStorage
window.localStorage = localStorageMock

// Create mocks of sessionStorage getItem and setItem functions
originalSessionStorage = window.sessionStorage
})

beforeEach(() => {
Expand All @@ -79,8 +49,9 @@ beforeEach(() => {
document.documentElement.removeAttribute('data-theme')
document.documentElement.removeAttribute('class')

// Clear the localStorage-mock
localStorageMock.clear()
// Clear storage-mocks
window.sessionStorage = makeBrowserStorageMock()
window.localStorage = makeBrowserStorageMock()
})

afterEach(() => {
Expand All @@ -89,6 +60,7 @@ afterEach(() => {

afterAll(() => {
window.localStorage = originalLocalStorage
window.sessionStorage = originalSessionStorage
})

function makeWrapper(props: ThemeProviderProps) {
Expand Down Expand Up @@ -152,24 +124,60 @@ describe('provider', () => {
})

describe('storage', () => {
test('should not set localStorage with default value', () => {
renderHook(() => useTheme(), {
wrapper: makeWrapper({ defaultTheme: 'dark' })
})

expect(window.localStorage.setItem).toBeCalledTimes(0)
expect(window.localStorage.getItem('theme')).toBeNull()
})

test('should set localStorage when switching themes', () => {
// check default storage
test('Storage[default]: should use localStorage by default', () => {
const { result } = renderHook(() => useTheme(), {
// 'storage' prop is not set
wrapper: makeWrapper({})
})
result.current.setTheme('dark')

expect(window.localStorage.setItem).toBeCalledTimes(1)
expect(window.localStorage.getItem('theme')).toBe('dark')
})

const storageTypes: {
name: string
storagePropValue: ThemeProviderProps['storage']
// Callback to allow retrieval of the storages inside the test-functions
getStorage: () => Storage
}[] = [
{
name: 'localStorage',
storagePropValue: 'localStorage',
getStorage: () => window.localStorage
},
{
name: 'sessionStorage',
storagePropValue: 'sessionStorage',
getStorage: () => window.sessionStorage
}
]

storageTypes.forEach(({ name, storagePropValue, getStorage }) => {
const getTestName = (test: string) => `Storage[${name}]: ${test}`

test(getTestName('should not set default value in storage'), () => {
renderHook(() => useTheme(), {
wrapper: makeWrapper({ defaultTheme: 'dark', storage: storagePropValue })
})

const storage = getStorage()
expect(storage.setItem).toBeCalledTimes(0)
expect(storage.getItem('theme')).toBeNull()
})

test(getTestName('should set theme in storage when switching themes'), () => {
const { result } = renderHook(() => useTheme(), {
wrapper: makeWrapper({ storage: storagePropValue })
})
result.current.setTheme('dark')

const storage = getStorage()
expect(storage.setItem).toBeCalledTimes(1)
expect(storage.getItem('theme')).toBe('dark')
})
})
})

describe('custom storageKey', () => {
Expand Down Expand Up @@ -253,7 +261,7 @@ describe('custom attribute', () => {

describe('custom value-mapping', () => {
test('should use custom value mapping when using value={{pink:"my-pink-theme"}}', () => {
localStorageMock.setItem('theme', 'pink')
window.localStorage.setItem('theme', 'pink')

act(() => {
render(
Expand Down Expand Up @@ -314,7 +322,7 @@ describe('custom value-mapping', () => {

describe('forcedTheme', () => {
test('should render saved theme when no forcedTheme is set', () => {
localStorageMock.setItem('theme', 'dark')
window.localStorage.setItem('theme', 'dark')

const { result } = renderHook(() => useTheme(), {
wrapper: makeWrapper({})
Expand All @@ -325,7 +333,7 @@ describe('forcedTheme', () => {
})

test('should render light theme when forcedTheme is set to light', () => {
localStorageMock.setItem('theme', 'dark')
window.localStorage.setItem('theme', 'dark')

const { result } = renderHook(() => useTheme(), {
wrapper: makeWrapper({
Expand Down
18 changes: 18 additions & 0 deletions next-themes/__tests__/mocks/device-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { vi } from 'vitest'
export function setDeviceTheme(theme: 'light' | 'dark') {
// Create a mock of the window.matchMedia function
// Based on: https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: theme === 'dark' ? true : false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
}
23 changes: 23 additions & 0 deletions next-themes/__tests__/mocks/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { vi } from 'vitest'

export function makeBrowserStorageMock(initialState: Record<string, string> = {}): Storage {
return (() => {
let store: Record<string, string> = initialState

return {
name: 'MockStorage',
getItem: vi.fn((key: string): string => store[key] ?? null),
setItem: vi.fn((key: string, value: string): void => {
store[key] = value.toString()
}),
removeItem: vi.fn((key: string): void => {
delete store[key]
}),
clear: vi.fn((): void => {
store = {}
}),
key: vi.fn((index: number): string | null => ''),
length: Object.keys(store).length
}
})()
}
28 changes: 21 additions & 7 deletions next-themes/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,17 @@ const Theme = ({
value,
children,
nonce,
scriptProps
scriptProps,
storage: storageConfig = 'localStorage'
}: ThemeProviderProps) => {
const [theme, setThemeState] = React.useState(() => getTheme(storageKey, defaultTheme))
const [resolvedTheme, setResolvedTheme] = React.useState(() => getTheme(storageKey))
const storage = React.useMemo(() => {
if (isServer) return undefined
if (storageConfig === 'sessionStorage') return window.sessionStorage
return window.localStorage
}, [storageConfig])

const [theme, setThemeState] = React.useState(() => getTheme(storageKey, storage, defaultTheme))
const [resolvedTheme, setResolvedTheme] = React.useState(() => getTheme(storageKey, storage))
const attrs = !value ? themes : Object.values(value)

const applyTheme = React.useCallback(theme => {
Expand Down Expand Up @@ -86,7 +93,7 @@ const Theme = ({

// Save to storage
try {
localStorage.setItem(storageKey, newTheme)
storage.setItem(storageKey, newTheme)
} catch (e) {
// Unsupported
}
Expand Down Expand Up @@ -119,6 +126,10 @@ const Theme = ({

// localStorage event handling
React.useEffect(() => {
if (storageConfig !== 'localStorage') {
return
}

const handleStorage = (e: StorageEvent) => {
if (e.key !== storageKey) {
return
Expand All @@ -131,7 +142,7 @@ const Theme = ({

window.addEventListener('storage', handleStorage)
return () => window.removeEventListener('storage', handleStorage)
}, [setTheme])
}, [setTheme, storageConfig])

// Whenever theme or forcedTheme changes, apply it
React.useEffect(() => {
Expand All @@ -155,6 +166,7 @@ const Theme = ({
<ThemeScript
{...{
forcedTheme,
storageConfig,
storageKey,
attribute,
enableSystem,
Expand All @@ -175,6 +187,7 @@ const Theme = ({
const ThemeScript = React.memo(
({
forcedTheme,
storage,
storageKey,
attribute,
enableSystem,
Expand All @@ -187,6 +200,7 @@ const ThemeScript = React.memo(
}: Omit<ThemeProviderProps, 'children'> & { defaultTheme: string }) => {
const scriptArgs = JSON.stringify([
attribute,
storage,
storageKey,
defaultTheme,
forcedTheme,
Expand All @@ -208,11 +222,11 @@ const ThemeScript = React.memo(
)

// Helpers
const getTheme = (key: string, fallback?: string) => {
const getTheme = (key: string, storage: Storage, fallback?: string) => {
if (isServer) return undefined
let theme
try {
theme = localStorage.getItem(key) || undefined
theme = storage.getItem(key) || undefined
} catch (e) {
// Unsupported
}
Expand Down
4 changes: 3 additions & 1 deletion next-themes/src/script.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const script = (
attribute,
storageConfig,
storageKey,
defaultTheme,
forcedTheme,
Expand Down Expand Up @@ -42,7 +43,8 @@ export const script = (
updateDOM(forcedTheme)
} else {
try {
const themeName = localStorage.getItem(storageKey) || defaultTheme
const storage = 'localStorage' === storageConfig ? window.localStorage : window.sessionStorage
const themeName = storage.getItem(storageKey) || defaultTheme
const isSystem = enableSystem && themeName === 'system'
const theme = isSystem ? getSystemTheme() : themeName
updateDOM(theme)
Expand Down
4 changes: 3 additions & 1 deletion next-themes/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export interface ThemeProviderProps extends React.PropsWithChildren {
/** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */
value?: ValueObject | undefined
/** Nonce string to pass to the inline script for CSP headers */
nonce?: string
nonce?: string | undefined
/** Props to pass the inline script */
scriptProps?: ScriptProps
/** Define where the users theme value is stored. Defaults to 'localStorage' */
storage?: 'localStorage' | 'sessionStorage' | undefined
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@
"node": ">=20",
"pnpm": ">=9"
},
"packageManager": "pnpm@9.0.6"
"packageManager": "pnpm@9.1.0"
}