From 9dc170c61c5eea08fb9ed291f5178f0cf1d73d02 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 28 Oct 2026 18:28:44 +0530 Subject: [PATCH] Let a nested ThemeProvider own the page's theme and follow mode changes Every ThemeProvider writes its CSS variables and text direction onto the document root. An app that renders its own ThemeProvider inside the one ThunderIDProvider renders lost to it: the outer provider wrote its theme (the flow meta Design theme) last on load in React, and again whenever its theme changed in both React and Vue, so the SDK components showed a different theme from the app until something made the inner one write again. The innermost ThemeProvider now owns the document root. A nested provider claims it from the one above, which stops writing while the claim stands and writes its own theme again once the nested provider unmounts. `mode` also only seeded the color scheme, so a host app that drives it from its own color scheme toggle never saw the change. The color scheme now follows `mode` when it changes, in React and Vue. Signed-off-by: Brion --- .../src/contexts/Theme/ThemeProvider.tsx | 71 +++++++++++++++--- .../src/contexts/Theme/ThemeRootContext.ts | 18 +++++ .../__tests__/ThemeProvider.nested.test.tsx | 65 ++++++++++++++++ .../providers/ThemeProvider.nested.test.ts | 74 +++++++++++++++++++ packages/vue/src/keys.ts | 6 ++ packages/vue/src/models/contexts.ts | 11 +++ packages/vue/src/providers/ThemeProvider.ts | 62 ++++++++++++---- 7 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 packages/react/src/contexts/Theme/ThemeRootContext.ts create mode 100644 packages/react/src/contexts/Theme/__tests__/ThemeProvider.nested.test.tsx create mode 100644 packages/vue/src/__tests__/providers/ThemeProvider.nested.test.ts 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']?.()); },