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
71 changes: 59 additions & 12 deletions packages/react/src/contexts/Theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -90,6 +103,16 @@ const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = ({

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<ThemeMode | undefined>(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) {
Expand Down Expand Up @@ -164,17 +187,37 @@ const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = ({

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<number> = useRef<number>(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,
Expand All @@ -183,7 +226,11 @@ const ThemeProvider: FC<PropsWithChildren<ThemeProviderProps>> = ({
toggleTheme,
};

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
return (
<ThemeContext.Provider value={value}>
<ThemeRootContext.Provider value={root}>{children}</ThemeRootContext.Provider>
</ThemeContext.Provider>
);
};

export default ThemeProvider;
18 changes: 18 additions & 0 deletions packages/react/src/contexts/Theme/ThemeRootContext.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeRootContextValue | null> = createContext<ThemeRootContextValue | null>(null);

ThemeRootContext.displayName = 'ThemeRootContext';

export default ThemeRootContext;
Original file line number Diff line number Diff line change
@@ -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 => (
<ThemeProvider mode="light" theme={{colors: {primary: {main}}}}>
{inner && (
<ThemeProvider mode="light" theme={{colors: {primary: {main: '#ef4444'}}}}>
<div />
</ThemeProvider>
)}
</ThemeProvider>
);

const Scheme = (): ReactElement => <span data-testid="scheme">{useTheme().colorScheme}</span>;

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(<Outer inner main="#ff7300" />);

expect(primary()).toBe('#ef4444');

rerender(<Outer inner main="#0055ff" />);

expect(primary()).toBe('#ef4444');
});

it('writes the outer theme again when the nested one unmounts', () => {
const {rerender} = render(<Outer inner main="#ff7300" />);

rerender(<Outer inner={false} main="#ff7300" />);

expect(primary()).toBe('#ff7300');
});

it('follows a change of mode after mount', () => {
const {rerender} = render(
<ThemeProvider mode="light">
<Scheme />
</ThemeProvider>,
);

expect(screen.getByTestId('scheme')).toHaveTextContent('light');

rerender(
<ThemeProvider mode="dark">
<Scheme />
</ThemeProvider>,
);

expect(screen.getByTestId('scheme')).toHaveTextContent('dark');
});
});
74 changes: 74 additions & 0 deletions packages/vue/src/__tests__/providers/ThemeProvider.nested.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
6 changes: 6 additions & 0 deletions packages/vue/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
FlowMetaContextValue,
I18nContextValue,
ThemeContextValue,
ThemeRootContextValue,
UserContextValue,
} from './models/contexts';

Expand Down Expand Up @@ -36,6 +37,11 @@ export const FLOW_META_KEY: InjectionKey<FlowMetaContextValue> = Symbol('thunder
*/
export const THEME_KEY: InjectionKey<ThemeContextValue> = 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<ThemeRootContextValue> = Symbol('thunderid-theme-root');

/**
* Injection key for the I18n context (translation function, language switching).
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/vue/src/models/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ref<'light' | 'dark'>>;
Expand Down
Loading
Loading