diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 6b37f510..56469e37 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -12,8 +12,20 @@ import { ThemeConfig, ThemeMode, } from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import { + FC, + MutableRefObject, + PropsWithChildren, + ReactElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import ThemeContext from './ThemeContext'; +import ThemeRootContext, {ThemeRootContextValue} from './ThemeRootContext'; import applyThemeToDOM from '../../utils/applyThemeToDOM'; import buildThemeConfigFromFlowMeta from '../../utils/buildThemeConfigFromFlowMeta'; import normalizeThemeConfig from '../../utils/normalizeThemeConfig'; @@ -26,7 +38,8 @@ export interface ThemeProviderProps { */ designTheme?: FlowMetaTheme | null; /** - * Initial color scheme override. Overrides the server default when provided. + * Color scheme override. Overrides the server default when provided, and the color scheme follows it + * when it changes. */ mode?: ThemeMode; @@ -90,6 +103,16 @@ const ThemeProvider: FC> = ({ const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(getInitialColorScheme); + // Follow a change of mode after mount, such as a host app's own color scheme toggle. + const [seededMode, setSeededMode] = useState(mode); + + if (mode !== seededMode) { + setSeededMode(mode); + if (mode !== undefined) { + setColorScheme(getInitialColorScheme()); + } + } + // When mode is unset, sync the color scheme with the server default once flow meta loads. useEffect(() => { if (mode === undefined && flowMetaTheme?.defaultColorScheme) { @@ -164,17 +187,37 @@ const ThemeProvider: FC> = ({ const direction: 'ltr' | 'rtl' = flowMetaTheme?.direction ?? 'ltr'; - // Apply CSS variables to the document root. - useEffect(() => { - applyThemeToDOM(theme); - }, [theme]); + // The innermost ThemeProvider owns the document root: an outer one stops writing its CSS variables + // and text direction while a nested one is mounted, and writes them again once it unmounts. + const parentRoot: ThemeRootContextValue | null = useContext(ThemeRootContext); + const claims: MutableRefObject = useRef(0); + const applied: MutableRefObject<{direction: 'ltr' | 'rtl'; theme: Theme} | null> = useRef(null); + + const applyToRoot: () => void = useCallback(() => { + if (!applied.current || typeof document === 'undefined') return; + applyThemeToDOM(applied.current.theme); + document.documentElement.dir = applied.current.direction; + }, []); + + const root: ThemeRootContextValue = useMemo( + () => ({ + claim: (): (() => void) => { + claims.current += 1; + return (): void => { + claims.current -= 1; + if (claims.current === 0) applyToRoot(); + }; + }, + }), + [applyToRoot], + ); + + useEffect(() => parentRoot?.claim(), [parentRoot]); - // Apply text direction to the document root. useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.dir = direction; - } - }, [direction]); + applied.current = {direction, theme}; + if (claims.current === 0) applyToRoot(); + }, [applyToRoot, direction, theme]); const value: any = { colorScheme, @@ -183,7 +226,11 @@ const ThemeProvider: FC> = ({ toggleTheme, }; - return {children}; + return ( + + {children} + + ); }; export default ThemeProvider; diff --git a/packages/react/src/contexts/Theme/ThemeRootContext.ts b/packages/react/src/contexts/Theme/ThemeRootContext.ts new file mode 100644 index 00000000..38d3c50a --- /dev/null +++ b/packages/react/src/contexts/Theme/ThemeRootContext.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Context, createContext} from 'react'; + +export interface ThemeRootContextValue { + /** + * Hands the document root to a nested `ThemeProvider` until the returned release is called, so the + * innermost provider's CSS variables and text direction are the ones on the page. + */ + claim: () => () => void; +} + +const ThemeRootContext: Context = createContext(null); + +ThemeRootContext.displayName = 'ThemeRootContext'; + +export default ThemeRootContext; diff --git a/packages/react/src/contexts/Theme/__tests__/ThemeProvider.nested.test.tsx b/packages/react/src/contexts/Theme/__tests__/ThemeProvider.nested.test.tsx new file mode 100644 index 00000000..b76d0a7c --- /dev/null +++ b/packages/react/src/contexts/Theme/__tests__/ThemeProvider.nested.test.tsx @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, screen} from '@testing-library/react'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it} from 'vitest'; +import ThemeProvider from '../ThemeProvider'; +import useTheme from '../useTheme'; + +const primary = (): string => document.documentElement.style.getPropertyValue('--thunderid-color-primary-main').trim(); + +const Outer = ({inner, main}: {inner: boolean; main: string}): ReactElement => ( + + {inner && ( + +
+ + )} + +); + +const Scheme = (): ReactElement => {useTheme().colorScheme}; + +describe('ThemeProvider', () => { + afterEach(() => { + cleanup(); + document.documentElement.removeAttribute('style'); + }); + + it('keeps the nested theme on the document root when the outer theme changes', () => { + const {rerender} = render(); + + expect(primary()).toBe('#ef4444'); + + rerender(); + + expect(primary()).toBe('#ef4444'); + }); + + it('writes the outer theme again when the nested one unmounts', () => { + const {rerender} = render(); + + rerender(); + + expect(primary()).toBe('#ff7300'); + }); + + it('follows a change of mode after mount', () => { + const {rerender} = render( + + + , + ); + + expect(screen.getByTestId('scheme')).toHaveTextContent('light'); + + rerender( + + + , + ); + + expect(screen.getByTestId('scheme')).toHaveTextContent('dark'); + }); +}); diff --git a/packages/vue/src/__tests__/providers/ThemeProvider.nested.test.ts b/packages/vue/src/__tests__/providers/ThemeProvider.nested.test.ts new file mode 100644 index 00000000..04934c53 --- /dev/null +++ b/packages/vue/src/__tests__/providers/ThemeProvider.nested.test.ts @@ -0,0 +1,74 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it} from 'vitest'; +import {defineComponent, h, inject} from 'vue'; +import {THEME_KEY} from '../../keys'; +import ThemeProvider from '../../providers/ThemeProvider'; + +const primary = (): string => document.documentElement.style.getPropertyValue('--thunderid-color-primary-main').trim(); + +const INNER_THEME = {colors: {primary: {main: '#ef4444'}}}; + +const Outer = defineComponent({ + props: {inner: {default: true, type: Boolean}, main: {required: true, type: String}}, + setup(props) { + return () => + h( + ThemeProvider, + {mode: 'light', theme: {colors: {primary: {main: props.main}}}}, + { + default: () => + props.inner ? h(ThemeProvider, {mode: 'light', theme: INNER_THEME}, {default: () => h('div')}) : null, + }, + ); + }, +}); + +const Scheme = defineComponent({ + setup() { + const theme = inject(THEME_KEY); + return () => h('span', theme?.colorScheme.value); + }, +}); + +describe('ThemeProvider', () => { + afterEach(() => { + document.documentElement.removeAttribute('style'); + }); + + it('keeps the nested theme on the document root when the outer theme changes', async () => { + const wrapper = mount(Outer, {props: {main: '#ff7300'}}); + + expect(primary()).toBe('#ef4444'); + + await wrapper.setProps({main: '#0055ff'}); + + expect(primary()).toBe('#ef4444'); + wrapper.unmount(); + }); + + it('writes the outer theme again when the nested one unmounts', async () => { + const wrapper = mount(Outer, {props: {main: '#ff7300'}}); + + await wrapper.setProps({inner: false}); + + expect(primary()).toBe('#ff7300'); + wrapper.unmount(); + }); + + it('follows a change of mode after mount', async () => { + const wrapper = mount(ThemeProvider, { + props: {mode: 'light'}, + slots: {default: () => h(Scheme)}, + }); + + expect(wrapper.text()).toBe('light'); + + await wrapper.setProps({mode: 'dark'}); + + expect(wrapper.text()).toBe('dark'); + wrapper.unmount(); + }); +}); diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts index e0f66994..78152bc4 100644 --- a/packages/vue/src/keys.ts +++ b/packages/vue/src/keys.ts @@ -8,6 +8,7 @@ import type { FlowMetaContextValue, I18nContextValue, ThemeContextValue, + ThemeRootContextValue, UserContextValue, } from './models/contexts'; @@ -36,6 +37,11 @@ export const FLOW_META_KEY: InjectionKey = Symbol('thunder */ export const THEME_KEY: InjectionKey = Symbol('thunderid-theme'); +/** + * Injection key a nested ThemeProvider uses to take the document root from the one above it. + */ +export const THEME_ROOT_KEY: InjectionKey = Symbol('thunderid-theme-root'); + /** * Injection key for the I18n context (translation function, language switching). */ diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 51f27ede..d9a43c88 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -230,6 +230,17 @@ export interface FlowMetaContextValue { /** * Shape of the Theme context exposed by `useTheme()`. */ +/** + * Lets a nested `ThemeProvider` take the document root from the one above it. + */ +export interface ThemeRootContextValue { + /** + * Hands the document root to a nested `ThemeProvider` until the returned release is called, so the + * innermost provider's CSS variables and text direction are the ones on the page. + */ + claim: () => () => void; +} + export interface ThemeContextValue { /** The current color scheme ('light' | 'dark'). */ colorScheme: Readonly>; diff --git a/packages/vue/src/providers/ThemeProvider.ts b/packages/vue/src/providers/ThemeProvider.ts index a6caa9fd..5c415236 100644 --- a/packages/vue/src/providers/ThemeProvider.ts +++ b/packages/vue/src/providers/ThemeProvider.ts @@ -34,8 +34,8 @@ import { type SetupContext, type VNode, } from 'vue'; -import {FLOW_META_KEY, THEME_KEY} from '../keys'; -import type {FlowMetaContextValue, ThemeContextValue} from '../models/contexts'; +import {FLOW_META_KEY, THEME_KEY, THEME_ROOT_KEY} from '../keys'; +import type {FlowMetaContextValue, ThemeContextValue, ThemeRootContextValue} from '../models/contexts'; import buildThemeConfigFromFlowMeta from '../utils/buildThemeConfigFromFlowMeta'; /** @@ -155,26 +155,36 @@ const ThemeProvider: Component = defineComponent({ colorScheme.value = colorScheme.value === 'light' ? 'dark' : 'light'; }; - // Apply CSS variables to DOM - const applyToDom = (theme: Theme): void => { + // Apply CSS variables and text direction to the document root. + const applyToRoot = (): void => { if (typeof document === 'undefined') return; const root: HTMLElement = document.documentElement; // Use the pre-computed cssVariables map from createTheme() which contains // correctly-named CSS variables (e.g. --thunderid-color-primary-main). - Object.entries(theme.cssVariables).forEach(([key, value]: [key: string, value: string]): void => { + Object.entries(resolvedTheme.value.cssVariables).forEach(([key, value]: [key: string, value: string]): void => { root.style.setProperty(key, value); }); + root.dir = direction.value; }; - watch(resolvedTheme, (theme: Theme): void => applyToDom(theme), {immediate: true}); + // The innermost ThemeProvider owns the document root: an outer one stops writing its CSS variables + // and text direction while a nested one is mounted, and writes them again once it unmounts. + let claims = 0; + const themeRoot: ThemeRootContextValue = { + claim: (): (() => void) => { + claims += 1; + return (): void => { + claims -= 1; + if (claims === 0) applyToRoot(); + }; + }, + }; + const release: (() => void) | undefined = inject(THEME_ROOT_KEY, null)?.claim(); - // Apply direction to document watch( - direction, - (dir: 'ltr' | 'rtl'): void => { - if (typeof document !== 'undefined') { - document.documentElement.dir = dir; - } + [resolvedTheme, direction], + (): void => { + if (claims === 0) applyToRoot(); }, {immediate: true}, ); @@ -187,7 +197,15 @@ const ThemeProvider: Component = defineComponent({ colorScheme.value = isDark ? 'dark' : 'light'; }; - onMounted((): void => { + const stopDetection = (): void => { + classObserver?.disconnect(); + classObserver = null; + mediaQuery?.disconnect(); + mediaQuery = null; + }; + + const startDetection = (): void => { + stopDetection(); if (props.mode === 'branding') return; if (props.mode === 'class') { @@ -198,11 +216,22 @@ const ThemeProvider: Component = defineComponent({ } else if (props.mode === 'system') { mediaQuery = createMediaQueryListener(handleThemeChange); } - }); + }; + + onMounted(startDetection); + + // Follow a change of mode after mount, such as a host app's own color scheme toggle. + watch( + () => props.mode, + (): void => { + colorScheme.value = initColorScheme(); + startDetection(); + }, + ); onBeforeUnmount((): void => { - if (classObserver) classObserver.disconnect(); - mediaQuery?.disconnect(); + stopDetection(); + release?.(); }); const context: ThemeContextValue = { @@ -213,6 +242,7 @@ const ThemeProvider: Component = defineComponent({ }; provide(THEME_KEY, context); + provide(THEME_ROOT_KEY, themeRoot); return () => h('div', {style: 'display:contents'}, slots['default']?.()); },