From 02eee11aa498c375353ad23b91fbc75f76358b24 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Tue, 3 Nov 2026 15:42:38 -0800 Subject: [PATCH] Design: require a theme for Flex theme consumers useThemeState and useThemeAppearance now throw when no ThemeProvider, ThemedRoot, or legacy ThemeContext provider is above the caller. The default theme state and default appearance state are removed. Add design/testing/theme with TestThemeProvider and createThemeWrapper so tests can render Flex consumers. It is a separate entry point because design/testing must stay free of react-native. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/design-require-theme.md | 14 ++ apps/bundle-size/baseline.json | 168 +++++++++--------- apps/bundle-size/scenarios.json | 8 +- .../references/tests-and-stories.md | 3 +- packages/agentic/design/AGENTS.md | 2 +- packages/agentic/design/package.json | 6 + .../agentic/design/references/contributing.md | 2 +- .../agentic/design/references/entry-points.md | 7 +- packages/agentic/design/references/testing.md | 25 ++- packages/agentic/design/references/theming.md | 19 +- .../agentic/design/src/flex/themeState.ts | 9 - .../design/src/flex/useThemeState.test.ts | 16 +- .../agentic/design/src/flex/useThemeState.ts | 25 ++- .../src/test/TestThemeProvider.test.tsx | 76 ++++++++ .../design/src/test/TestThemeProvider.tsx | 42 +++++ .../__snapshots__/entryPoints.test.ts.snap | 7 + .../test/compatibility/entryPoints.test.ts | 1 + .../compatibility/importBoundaries.test.ts | 7 +- .../agentic/design/src/theming/appearance.ts | 2 - 19 files changed, 316 insertions(+), 123 deletions(-) create mode 100644 .changeset/design-require-theme.md create mode 100644 packages/agentic/design/src/test/TestThemeProvider.test.tsx create mode 100644 packages/agentic/design/src/test/TestThemeProvider.tsx diff --git a/.changeset/design-require-theme.md b/.changeset/design-require-theme.md new file mode 100644 index 0000000000..1dc9cf3a84 --- /dev/null +++ b/.changeset/design-require-theme.md @@ -0,0 +1,14 @@ +--- +'@fluentui-react-native/design': minor +'@fluentui-react-native/components': minor +--- + +Flex theme consumers now require a theme. `useThemeState` and `useThemeAppearance` throw when no `ThemeProvider`, +`ThemedRoot`, or legacy `ThemeContext` provider is above the caller instead of falling back to a default theme state, +and the default theme state is removed. Render components inside a `ThemedRoot` or `ThemeProvider`. + +Add the `@fluentui-react-native/design/testing/theme` entry point with `TestThemeProvider` and `createThemeWrapper` for +tests that render Flex consumers. It is separate from `design/testing`, which stays free of `react-native`. + +A legacy `ThemeReference` paired with `useThemeState` no longer bundles the x3 defaults and interaction algorithm, and +a component bundle that does not render `ThemedRoot` is about 17 KB raw smaller. diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json index 7eae7325d2..b2d45eb5a8 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -4,37 +4,37 @@ { "platform": "macos", "scenario": "components-button", - "rawBytes": 73626, - "gzipBytes": 21384, - "moduleCount": 78, + "rawBytes": 56816, + "gzipBytes": 16036, + "moduleCount": 70, "metafileInputCount": 243, "workspaceModules": { "@fluentui-react-native/components": 16, - "@fluentui-react-native/design": 34, + "@fluentui-react-native/design": 26, "@fluentui-react-native/framework-base": 27 }, "workspaceBytes": { - "@fluentui-react-native/components": 9761, - "@fluentui-react-native/design": 50730, - "@fluentui-react-native/framework-base": 13099 + "@fluentui-react-native/components": 9754, + "@fluentui-react-native/design": 33947, + "@fluentui-react-native/framework-base": 13079 } }, { "platform": "macos", "scenario": "components-catalog", - "rawBytes": 229314, - "gzipBytes": 63682, - "moduleCount": 227, + "rawBytes": 212442, + "gzipBytes": 58291, + "moduleCount": 219, "metafileInputCount": 243, "workspaceModules": { "@fluentui-react-native/components": 151, - "@fluentui-react-native/design": 36, + "@fluentui-react-native/design": 28, "@fluentui-react-native/framework-base": 27, "@fluentui-react-native/native-core": 12 }, "workspaceBytes": { "@fluentui-react-native/components": 152677, - "@fluentui-react-native/design": 51975, + "@fluentui-react-native/design": 35103, "@fluentui-react-native/framework-base": 15305, "@fluentui-react-native/native-core": 9228 } @@ -42,8 +42,8 @@ { "platform": "macos", "scenario": "design-theme-state", - "rawBytes": 53271, - "gzipBytes": 14189, + "rawBytes": 53427, + "gzipBytes": 14275, "moduleCount": 47, "metafileInputCount": 73, "workspaceModules": { @@ -51,15 +51,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 51266, + "@fluentui-react-native/design": 51422, "@fluentui-react-native/framework-base": 1964 } }, { "platform": "macos", "scenario": "design-themed-root", - "rawBytes": 55262, - "gzipBytes": 14869, + "rawBytes": 55414, + "gzipBytes": 14952, "moduleCount": 51, "metafileInputCount": 73, "workspaceModules": { @@ -67,7 +67,7 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 53256, + "@fluentui-react-native/design": 53408, "@fluentui-react-native/framework-base": 1964 } }, @@ -88,24 +88,24 @@ { "platform": "macos", "scenario": "design-theme-state-legacy-source", - "rawBytes": 53804, - "gzipBytes": 14217, - "moduleCount": 48, + "rawBytes": 37032, + "gzipBytes": 8964, + "moduleCount": 40, "metafileInputCount": 76, "workspaceModules": { - "@fluentui-react-native/design": 29, + "@fluentui-react-native/design": 21, "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 51799, - "@fluentui-react-native/framework-base": 1964 + "@fluentui-react-native/design": 35032, + "@fluentui-react-native/framework-base": 1960 } }, { "platform": "macos", "scenario": "design-legacy-theme", - "rawBytes": 39106, - "gzipBytes": 9004, + "rawBytes": 39095, + "gzipBytes": 8997, "moduleCount": 42, "metafileInputCount": 82, "workspaceModules": { @@ -113,15 +113,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 37097, + "@fluentui-react-native/design": 37086, "@fluentui-react-native/framework-base": 1967 } }, { "platform": "macos", "scenario": "design-legacy-theme-from-flex", - "rawBytes": 59872, - "gzipBytes": 16095, + "rawBytes": 59860, + "gzipBytes": 16090, "moduleCount": 54, "metafileInputCount": 82, "workspaceModules": { @@ -129,7 +129,7 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 57863, + "@fluentui-react-native/design": 57851, "@fluentui-react-native/framework-base": 1967 } }, @@ -150,8 +150,8 @@ { "platform": "macos", "scenario": "design-fluent-theme", - "rawBytes": 27455, - "gzipBytes": 6743, + "rawBytes": 27443, + "gzipBytes": 6737, "moduleCount": 35, "metafileInputCount": 68, "workspaceModules": { @@ -159,15 +159,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 25446, + "@fluentui-react-native/design": 25434, "@fluentui-react-native/framework-base": 1973 } }, { "platform": "macos", "scenario": "design-fluent-hc-theme", - "rawBytes": 27343, - "gzipBytes": 6694, + "rawBytes": 27331, + "gzipBytes": 6693, "moduleCount": 32, "metafileInputCount": 68, "workspaceModules": { @@ -175,15 +175,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 25335, + "@fluentui-react-native/design": 25323, "@fluentui-react-native/framework-base": 1972 } }, { "platform": "macos", "scenario": "design-flex-theme", - "rawBytes": 23368, - "gzipBytes": 8032, + "rawBytes": 22129, + "gzipBytes": 7701, "moduleCount": 33, "metafileInputCount": 68, "workspaceModules": { @@ -191,15 +191,15 @@ "@fluentui-react-native/framework-base": 16 }, "workspaceBytes": { - "@fluentui-react-native/design": 22632, + "@fluentui-react-native/design": 21393, "@fluentui-react-native/framework-base": 700 } }, { "platform": "macos", "scenario": "design-flex-warm-theme", - "rawBytes": 23432, - "gzipBytes": 8064, + "rawBytes": 22193, + "gzipBytes": 7736, "moduleCount": 33, "metafileInputCount": 68, "workspaceModules": { @@ -207,44 +207,44 @@ "@fluentui-react-native/framework-base": 16 }, "workspaceBytes": { - "@fluentui-react-native/design": 22696, + "@fluentui-react-native/design": 21457, "@fluentui-react-native/framework-base": 700 } }, { "platform": "windows", "scenario": "components-button", - "rawBytes": 73547, - "gzipBytes": 21342, - "moduleCount": 79, + "rawBytes": 56740, + "gzipBytes": 15996, + "moduleCount": 71, "metafileInputCount": 244, "workspaceModules": { "@fluentui-react-native/components": 16, - "@fluentui-react-native/design": 35, + "@fluentui-react-native/design": 27, "@fluentui-react-native/framework-base": 27 }, "workspaceBytes": { - "@fluentui-react-native/components": 9761, - "@fluentui-react-native/design": 50651, - "@fluentui-react-native/framework-base": 13099 + "@fluentui-react-native/components": 9754, + "@fluentui-react-native/design": 33871, + "@fluentui-react-native/framework-base": 13079 } }, { "platform": "windows", "scenario": "components-catalog", - "rawBytes": 229238, - "gzipBytes": 63628, - "moduleCount": 228, + "rawBytes": 212366, + "gzipBytes": 58246, + "moduleCount": 220, "metafileInputCount": 244, "workspaceModules": { "@fluentui-react-native/components": 151, - "@fluentui-react-native/design": 37, + "@fluentui-react-native/design": 29, "@fluentui-react-native/framework-base": 27, "@fluentui-react-native/native-core": 12 }, "workspaceBytes": { "@fluentui-react-native/components": 152677, - "@fluentui-react-native/design": 51899, + "@fluentui-react-native/design": 35027, "@fluentui-react-native/framework-base": 15305, "@fluentui-react-native/native-core": 9228 } @@ -252,8 +252,8 @@ { "platform": "windows", "scenario": "design-theme-state", - "rawBytes": 53195, - "gzipBytes": 14148, + "rawBytes": 53351, + "gzipBytes": 14236, "moduleCount": 48, "metafileInputCount": 74, "workspaceModules": { @@ -261,15 +261,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 51190, + "@fluentui-react-native/design": 51346, "@fluentui-react-native/framework-base": 1964 } }, { "platform": "windows", "scenario": "design-themed-root", - "rawBytes": 55186, - "gzipBytes": 14822, + "rawBytes": 55342, + "gzipBytes": 14897, "moduleCount": 52, "metafileInputCount": 74, "workspaceModules": { @@ -277,7 +277,7 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 53180, + "@fluentui-react-native/design": 53336, "@fluentui-react-native/framework-base": 1964 } }, @@ -298,24 +298,24 @@ { "platform": "windows", "scenario": "design-theme-state-legacy-source", - "rawBytes": 53728, - "gzipBytes": 14172, - "moduleCount": 49, + "rawBytes": 36958, + "gzipBytes": 8923, + "moduleCount": 41, "metafileInputCount": 77, "workspaceModules": { - "@fluentui-react-native/design": 30, + "@fluentui-react-native/design": 22, "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 51723, - "@fluentui-react-native/framework-base": 1964 + "@fluentui-react-native/design": 34958, + "@fluentui-react-native/framework-base": 1960 } }, { "platform": "windows", "scenario": "design-legacy-theme", - "rawBytes": 42907, - "gzipBytes": 9653, + "rawBytes": 42896, + "gzipBytes": 9647, "moduleCount": 42, "metafileInputCount": 83, "workspaceModules": { @@ -323,15 +323,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 40895, + "@fluentui-react-native/design": 40884, "@fluentui-react-native/framework-base": 1970 } }, { "platform": "windows", "scenario": "design-legacy-theme-from-flex", - "rawBytes": 63686, - "gzipBytes": 16781, + "rawBytes": 63672, + "gzipBytes": 16778, "moduleCount": 54, "metafileInputCount": 83, "workspaceModules": { @@ -339,7 +339,7 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 61677, + "@fluentui-react-native/design": 61663, "@fluentui-react-native/framework-base": 1967 } }, @@ -360,8 +360,8 @@ { "platform": "windows", "scenario": "design-fluent-theme", - "rawBytes": 31248, - "gzipBytes": 7323, + "rawBytes": 31236, + "gzipBytes": 7314, "moduleCount": 35, "metafileInputCount": 69, "workspaceModules": { @@ -369,15 +369,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 29227, + "@fluentui-react-native/design": 29215, "@fluentui-react-native/framework-base": 1985 } }, { "platform": "windows", "scenario": "design-fluent-hc-theme", - "rawBytes": 31131, - "gzipBytes": 7279, + "rawBytes": 31119, + "gzipBytes": 7275, "moduleCount": 32, "metafileInputCount": 69, "workspaceModules": { @@ -385,15 +385,15 @@ "@fluentui-react-native/framework-base": 18 }, "workspaceBytes": { - "@fluentui-react-native/design": 29110, + "@fluentui-react-native/design": 29098, "@fluentui-react-native/framework-base": 1985 } }, { "platform": "windows", "scenario": "design-flex-theme", - "rawBytes": 23356, - "gzipBytes": 8025, + "rawBytes": 22114, + "gzipBytes": 7696, "moduleCount": 33, "metafileInputCount": 69, "workspaceModules": { @@ -401,15 +401,15 @@ "@fluentui-react-native/framework-base": 16 }, "workspaceBytes": { - "@fluentui-react-native/design": 22620, + "@fluentui-react-native/design": 21378, "@fluentui-react-native/framework-base": 700 } }, { "platform": "windows", "scenario": "design-flex-warm-theme", - "rawBytes": 23420, - "gzipBytes": 8058, + "rawBytes": 22178, + "gzipBytes": 7727, "moduleCount": 33, "metafileInputCount": 69, "workspaceModules": { @@ -417,7 +417,7 @@ "@fluentui-react-native/framework-base": 16 }, "workspaceBytes": { - "@fluentui-react-native/design": 22684, + "@fluentui-react-native/design": 21442, "@fluentui-react-native/framework-base": 700 } } diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index 2ad4fd7240..a53744d491 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -51,8 +51,12 @@ ], "forbiddenInputPatterns": ["/design/src/fluent/compat/", "/design/src/deprecated/", "/design/src/test/", "/color-lib/compositing."], "requiredInputPatterns": ["/design/src/fluent/themeReference."], - "forbiddenOutputPatterns": ["/design/src/flex/flexThemeReference."], - "requiredOutputPatterns": ["/color-lib/interaction.", "/design/src/flex/colors/interactionPolicy."] + "forbiddenOutputPatterns": [ + "/design/src/flex/flexThemeReference.", + "/design/src/flex/defaults/", + "/color-lib/interaction.", + "/design/src/flex/colors/interactionPolicy." + ] }, { "name": "design-legacy-theme", diff --git a/packages/agentic/components/references/tests-and-stories.md b/packages/agentic/components/references/tests-and-stories.md index edc3276894..a692a66a7d 100644 --- a/packages/agentic/components/references/tests-and-stories.md +++ b/packages/agentic/components/references/tests-and-stories.md @@ -20,7 +20,8 @@ component scenes. It places a `ThemedRoot` outside the scene and any supplied wrapper, preserving the same root settings through rerenders. Import events, queries, and types from `@testing-library/react-native`; do not import `react-test-renderer` directly. Tests can dispatch root modality events through -the helper's `test-scene-root` test ID. +the helper's `test-scene-root` test ID. Components read the theme through `useThemeState`, which throws without a +themed ancestor, so every rendered component needs the helper's `ThemedRoot` or a `ThemeProvider`. Prefer public semantics: diff --git a/packages/agentic/design/AGENTS.md b/packages/agentic/design/AGENTS.md index 190617829c..375e4a9804 100644 --- a/packages/agentic/design/AGENTS.md +++ b/packages/agentic/design/AGENTS.md @@ -36,7 +36,7 @@ repository reference `components.agentic.styles-and-tokens`. state computes once per resolved color. - Treat `src/tokens/mappings/flex-token-map.yaml` as the canonical token mapping (R-DESIGN-001), and regenerate rather than edit generated token and projection files. -- Production code must not depend on the `testing` entry point (R-DESIGN-002). +- Production code must not depend on the `testing` entry points (R-DESIGN-002). - Keep the Fluent 2 token JSON loaders and pipeline mappers in `src/deprecated/`. Only tests, other deprecated code, build scripts, and the existing deprecated consumers may use them, and no other entry point may reach them (R-DESIGN-003). diff --git a/packages/agentic/design/package.json b/packages/agentic/design/package.json index 4245af1802..23586b12bc 100644 --- a/packages/agentic/design/package.json +++ b/packages/agentic/design/package.json @@ -75,6 +75,12 @@ "import": "./lib/test/index.js", "default": "./src/test/index.ts" }, + "./testing/theme": { + "types": "./lib/test/TestThemeProvider.d.ts", + "react-native": "./src/test/TestThemeProvider.tsx", + "import": "./lib/test/TestThemeProvider.js", + "default": "./src/test/TestThemeProvider.tsx" + }, "./deprecated": { "types": "./lib/deprecated/index.d.ts", "react-native": "./src/deprecated/index.ts", diff --git a/packages/agentic/design/references/contributing.md b/packages/agentic/design/references/contributing.md index 09d0fb9dc4..5b6755414a 100644 --- a/packages/agentic/design/references/contributing.md +++ b/packages/agentic/design/references/contributing.md @@ -61,7 +61,7 @@ committed; change their inputs and regenerate rather than editing them. - Keep platform appearance listeners subscription-scoped; nothing subscribes at module import. - Keep the experimental native appearance modules and platform theme packages out of this package's dependencies; platform theme packages inject their sources. -- Production code must not import the `testing` entry point (R-DESIGN-002). +- Production code must not import the `testing` entry points (R-DESIGN-002). - Keep the Fluent 2 token JSON behind `src/deprecated/`: only tests, other deprecated modules, build scripts, and the existing deprecated consumers may use it, and no other production entry point may reach it (R-DESIGN-003). diff --git a/packages/agentic/design/references/entry-points.md b/packages/agentic/design/references/entry-points.md index df9c54bc7f..239986fa06 100644 --- a/packages/agentic/design/references/entry-points.md +++ b/packages/agentic/design/references/entry-points.md @@ -23,14 +23,15 @@ public symbols by name from each entry point; do not add barrel re-exports. | `./styling/stylesheet` | The legacy `themedStyleSheet` helper | | `./styling/tokens` | Legacy token-to-style builders | | `./testing` | Test-only fixtures and validation; see `testing.md` | +| `./testing/theme` | Test-only `TestThemeProvider` and `createThemeWrapper`, which need `react-native`; see `testing.md` | | `./deprecated` | Fluent 2 token JSON loaders and pipeline mappers, restricted to existing consumers; see [below](#deprecated-entry-point) | The `apps/bundle-size` `design-*` scenarios guard these boundaries. Input patterns check the import graph and output patterns check the modules a tree-shaken bundle retains. Every design scenario except the color-library one forbids `src/deprecated/` from the import graph. The modern root and legacy-source scenarios forbid the compatibility modules -and `color-lib/compositing`. The modern root scenarios and the legacy-source scenario, which pairs a legacy -`ThemeReference` with `useThemeState`, require the retained Flex interaction policy and `color-lib/interaction`; the -legacy-source scenario also forbids a retained `FlexThemeReference`. Only the legacy theme scenario forbids any retained +and `color-lib/compositing`. The modern root scenarios require the retained Flex interaction policy and `color-lib/interaction`. The legacy-source +scenario, which pairs a legacy `ThemeReference` with `useThemeState`, forbids a retained `FlexThemeReference`, x3 +default, interaction policy, or `color-lib/interaction`, because the forward adapter derives no interaction state. Only the legacy theme scenario forbids any retained `color-lib/` or `flex/` module. The Fluent theme scenarios forbid `useTheme` and the compatibility modules from the import graph and any retained `flex/`, `color-lib/`, or Flex theme module, and a bundle that imports only `fluentLightTheme` retains no other theme and no `ThemeReference`, while one that imports only `fluentHcTheme` retains diff --git a/packages/agentic/design/references/testing.md b/packages/agentic/design/references/testing.md index 9f06ce82c3..f691711864 100644 --- a/packages/agentic/design/references/testing.md +++ b/packages/agentic/design/references/testing.md @@ -7,7 +7,8 @@ owner_paths: # Testing helpers -- **R-DESIGN-002:** Production code must not depend on `@fluentui-react-native/design/testing`. +- **R-DESIGN-002:** Production code must not depend on `@fluentui-react-native/design/testing` or + `@fluentui-react-native/design/testing/theme`. `@fluentui-react-native/design/testing` is the test-only entry point for fixtures and validation helpers associated with the design package. It exports: @@ -34,6 +35,28 @@ import { defaultFlexTokens, getContrastFailures, validateContrastPairs } from '@ const failures = getContrastFailures(validateContrastPairs(defaultFlexTokens.color, 'light')); ``` +## Theme helpers + +`useThemeState` and `useThemeAppearance` throw when no theme is above the caller, so a test that renders a Flex +consumer needs a boundary. `@fluentui-react-native/design/testing/theme` exports the helpers that provide one. It is a +separate entry point because it imports `react-native` through `ThemeProvider`, while `./testing` stays free of +`react-native` for plain-Node Jest platforms and for `@fluentui-react-native/test-tools`, which re-exports `mockTheme`. + +- `TestThemeProvider` is a `ThemeProvider` that defaults to one `FlexThemeReference` per mounted provider and an + appearance source pinned to `light`, `standard`, and `base`, so results match `defaultFlexTokens` on every platform. + It accepts `theme`, `appearance`, `appearanceSource`, and `fallbackAppearance` overrides. +- `createThemeWrapper(props)` returns a stable `wrapper` component for `render` and `renderHook` from + `@testing-library/react-native`, or for wrapping a `react-test-renderer` tree. + +```tsx +import { createThemeWrapper } from '@fluentui-react-native/design/testing/theme'; + +const { result } = renderHook(() => useThemeState(), { wrapper: createThemeWrapper({ appearance: { colorScheme: 'dark' } }) }); +``` + +Agentic component tests render through the package-private `render` helper, which places a `ThemedRoot` around the +scene; see the components `tests-and-stories.md`. + The package's internal validation suite audits rest, hover, and pressed values for both the cool and warm color tones. Its pinned conformance fixtures and the bulk `getInteractionColorOverrides` helper under `src/test/fixtures` are diff --git a/packages/agentic/design/references/theming.md b/packages/agentic/design/references/theming.md index f38db516f8..9a94a56b58 100644 --- a/packages/agentic/design/references/theming.md +++ b/packages/agentic/design/references/theming.md @@ -84,11 +84,15 @@ legacy boundary for all of them, which keeps deprecated `ThemeLayer` and direct ## Consumption and laziness -| Hook | Returns | Without a boundary | -| -------------------- | ---------------------------------------------------------------- | ---------------------------- | -| `useThemeState` | `ThemeState`: `tokens`, resolved `appearance`, and `themeStyles` | The default state | -| `useThemeAppearance` | The requested and resolved appearance | The default appearance state | -| `useTheme` | The legacy `Theme` (from the `theming` subpath) | `undefined` | +| Hook | Returns | Without a boundary | +| -------------------- | ---------------------------------------------------------------- | ------------------ | +| `useThemeState` | `ThemeState`: `tokens`, resolved `appearance`, and `themeStyles` | Throws | +| `useThemeAppearance` | The requested and resolved appearance | Throws | +| `useTheme` | The legacy `Theme` (from the `theming` subpath) | `undefined` | + +Flex consumers require a theme: there is no default `ThemeState`. Render them inside a `ThemedRoot`, a `ThemeProvider`, +or a legacy `ThemeContext` provider. In tests, use `TestThemeProvider` or `createThemeWrapper` from +`@fluentui-react-native/design/testing/theme` (see `testing.md`). `ThemeState.highContrast` is deprecated; use `appearance.contrast`. @@ -188,9 +192,8 @@ The package root exports only the modern authoring and consumption APIs. It reac which `useThemeState` needs for legacy sources, and the Flex interaction policy with the raw `color-lib` interaction algorithm, but not `useTheme`, the reverse converter, the Fluent themes it projects onto, or deprecated token data. The legacy `ThemeReference`, `useTheme`, and `ThemeProvider` graphs reach neither Flex nor `color-lib`. An app that pairs a -legacy `ThemeReference` with `useThemeState` reaches the forward adapter and, through the default theme state, the x3 -defaults and the interaction policy with `color-lib/interaction`, but not `FlexThemeReference`, `color-lib/compositing`, -or the compatibility projection. `entry-points.md` lists every subpath. +legacy `ThemeReference` with `useThemeState` reaches the forward adapter, but not the x3 defaults, the interaction +policy, `color-lib/interaction`, `FlexThemeReference`, `color-lib/compositing`, or the compatibility projection. `entry-points.md` lists every subpath. `themeFromFlexTokens` in `theming/compat` builds a complete `Theme` in this order: diff --git a/packages/agentic/design/src/flex/themeState.ts b/packages/agentic/design/src/flex/themeState.ts index 156c197f60..32f382600d 100644 --- a/packages/agentic/design/src/flex/themeState.ts +++ b/packages/agentic/design/src/flex/themeState.ts @@ -1,19 +1,10 @@ -import { defaultFlexTokens } from './defaults/defaultTokens'; import { flexTokensFromTheme } from './adapters/flexTokensFromTheme'; import type { FlexTokens } from './tokens.types'; -import { defaultResolvedThemeAppearance } from '../theming/appearance'; import { getFlexSourceTokens, getThemeAppearanceCache } from '../theming/themeRuntime'; import type { ThemeSource } from '../theming/themeSource'; import type { NormalizedThemeAppearance } from '../types/appearance.types'; import type { ThemeState } from './themeState.types'; -export const defaultThemeState: ThemeState = { - tokens: defaultFlexTokens, - appearance: defaultResolvedThemeAppearance, - highContrast: false, - themeStyles: {}, -}; - /** * Resolve Flex tokens for any source. Legacy sources use the forward adapter. */ diff --git a/packages/agentic/design/src/flex/useThemeState.test.ts b/packages/agentic/design/src/flex/useThemeState.test.ts index ce3980b6fa..828b18b025 100644 --- a/packages/agentic/design/src/flex/useThemeState.test.ts +++ b/packages/agentic/design/src/flex/useThemeState.test.ts @@ -11,6 +11,7 @@ import { FlexThemeReference } from './flexThemeReference'; import { ThemeProvider } from '../theming/ThemeProvider'; import type { Theme } from '../fluent/types/Theme.types'; import { mockTheme } from '../test/fixtures/mockTheme'; +import { TestThemeProvider } from '../test/TestThemeProvider'; import { themedStyleSheetFactory, useThemeAppearance, useThemeState } from './useThemeState'; import type { ThemeState } from './themeState.types'; @@ -70,13 +71,24 @@ function createMutableAppearanceSource() { } describe('useThemeState', () => { + it('throws when no theme is provided', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => act(() => void create(React.createElement(ThemeStateProbe, { states: [] })))).toThrow('useThemeState requires a theme'); + expect(() => act(() => void create(React.createElement(ThemeAppearanceProbe, { states: [] })))).toThrow( + 'useThemeAppearance requires a theme', + ); + + consoleError.mockRestore(); + }); + it('shares lazily created theme style sheets between component instances', () => { const states: ThemeState[] = []; let component: ReactTestRenderer; act(() => { component = create( React.createElement( - React.Fragment, + TestThemeProvider, null, React.createElement(ThemeStateProbe, { states }), React.createElement(ThemeStateProbe, { states }), @@ -106,7 +118,7 @@ describe('useThemeState', () => { const states: ThemeState[] = []; let component: ReactTestRenderer; act(() => { - component = create(React.createElement(ThemeStateProbe, { states })); + component = create(React.createElement(TestThemeProvider, null, React.createElement(ThemeStateProbe, { states }))); }); let createCount = 0; diff --git a/packages/agentic/design/src/flex/useThemeState.ts b/packages/agentic/design/src/flex/useThemeState.ts index 71db5db091..a3e0fc4c86 100644 --- a/packages/agentic/design/src/flex/useThemeState.ts +++ b/packages/agentic/design/src/flex/useThemeState.ts @@ -1,13 +1,24 @@ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; -import { defaultThemeAppearanceState } from '../theming/appearance'; +import type { ThemeSourceContextValue } from '../theming/context'; import { useThemeBoundary } from '../theming/context'; -import { defaultThemeState, getThemeState } from './themeState'; +import { getThemeState } from './themeState'; import type { ThemeAppearanceState } from '../types/appearance.types'; import type { ThemeState } from './themeState.types'; export type ThemeStyleSheet = Record; +function useRequiredThemeBoundary(hookName: string): ThemeSourceContextValue { + const boundary = useThemeBoundary(); + if (!boundary) { + throw new Error( + `${hookName} requires a theme. Render the component inside a ThemedRoot or ThemeProvider; in tests, use the ` + + `helpers from '@fluentui-react-native/design/testing/theme'.`, + ); + } + return boundary; +} + /** * Theme state should be consistent on object identity until a theme change or system appearance change occurs. Each new * theme state must own a new themeStyles registry so cached style sheets cannot leak across themes. @@ -17,18 +28,20 @@ export type ThemeStyleSheet = Record * and resolved appearance shares one state identity. * * @returns The current theme state, including flex tokens, high contrast mode status, and shared theme style sheets. + * @throws When no `ThemeProvider`, `ThemedRoot`, or legacy theme context is above the caller. */ export function useThemeState(): ThemeState { - const boundary = useThemeBoundary(); - return boundary ? getThemeState(boundary.source, boundary.appearance.resolved) : defaultThemeState; + const boundary = useRequiredThemeBoundary('useThemeState'); + return getThemeState(boundary.source, boundary.appearance.resolved); } /** * Access both the requested appearance and its concrete resolution. + * + * @throws When no `ThemeProvider`, `ThemedRoot`, or legacy theme context is above the caller. */ export function useThemeAppearance(): Readonly { - const boundary = useThemeBoundary(); - return boundary?.appearance ?? defaultThemeAppearanceState; + return useRequiredThemeBoundary('useThemeAppearance').appearance; } /** diff --git a/packages/agentic/design/src/test/TestThemeProvider.test.tsx b/packages/agentic/design/src/test/TestThemeProvider.test.tsx new file mode 100644 index 0000000000..3cb405e407 --- /dev/null +++ b/packages/agentic/design/src/test/TestThemeProvider.test.tsx @@ -0,0 +1,76 @@ +import { act, create } from 'react-test-renderer'; + +import { defaultResolvedThemeAppearance } from '../theming/appearance'; +import { defaultFlexTokens } from '../flex/defaults/defaultTokens'; +import { FlexThemeReference } from '../flex/flexThemeReference'; +import { useThemeAppearance, useThemeState } from '../flex/useThemeState'; +import type { ThemeState } from '../flex/themeState.types'; +import { createThemeWrapper, TestThemeProvider } from './TestThemeProvider'; + +function Probe({ states, appearances }: { states: ThemeState[]; appearances: ReturnType[] }) { + states.push(useThemeState()); + appearances.push(useThemeAppearance()); + return null; +} + +describe('TestThemeProvider', () => { + it('provides the default Flex tokens in the light appearance', () => { + const states: ThemeState[] = []; + const appearances: ReturnType[] = []; + + act( + () => + void create( + + + , + ), + ); + + expect(states[0].tokens).toEqual(defaultFlexTokens); + expect(states[0].appearance).toBe(defaultResolvedThemeAppearance); + expect(appearances[0].resolved).toBe(defaultResolvedThemeAppearance); + }); + + it('accepts a theme and appearance request', () => { + const states: ThemeState[] = []; + const theme = new FlexThemeReference({ base: { color: { backgroundBrandHeavy: '#123456' } } }); + + act( + () => + void create( + + + , + ), + ); + + expect(states[0].tokens.color.backgroundBrandHeavy.rest).toBe('#123456'); + expect(states[0].appearance.colorScheme).toBe('dark'); + }); + + it('creates a wrapper that keeps one theme state across rerenders', () => { + const Wrapper = createThemeWrapper(); + const states: ThemeState[] = []; + let renderer: ReturnType; + + act(() => { + renderer = create( + + + , + ); + }); + act(() => + renderer.update( + + + , + ), + ); + + expect(states).toHaveLength(2); + expect(states[1]).toBe(states[0]); + act(() => renderer.unmount()); + }); +}); diff --git a/packages/agentic/design/src/test/TestThemeProvider.tsx b/packages/agentic/design/src/test/TestThemeProvider.tsx new file mode 100644 index 0000000000..c02373e1d4 --- /dev/null +++ b/packages/agentic/design/src/test/TestThemeProvider.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { FlexThemeReference } from '../flex/flexThemeReference'; +import { ThemeProvider } from '../theming/ThemeProvider'; +import type { ThemeProviderProps } from '../theming/ThemeProvider'; +import type { ThemeAppearanceSource } from '../types/appearance.types'; + +const lightAppearanceSnapshot = Object.freeze({ colorScheme: 'light', contrast: 'standard', interfaceLevel: 'base' } as const); + +/** + * Pins the appearance so tests do not depend on the host platform's appearance. + */ +const lightAppearanceSource: ThemeAppearanceSource = { + getSnapshot: () => lightAppearanceSnapshot, + subscribe: () => () => undefined, +}; + +export interface TestThemeProviderProps + extends React.PropsWithChildren, Partial> {} + +/** + * Supplies the theme that Flex hooks require. With no props it provides one default `FlexThemeReference` per mounted + * provider, pinned to the light, standard, base appearance. + */ +export function TestThemeProvider(props: TestThemeProviderProps): React.JSX.Element { + const { theme, appearanceSource = lightAppearanceSource, children, ...rest } = props; + const [defaultTheme] = React.useState(() => new FlexThemeReference()); + return ( + + {children} + + ); +} + +/** + * Creates a stable `wrapper` component for `render`, `renderHook`, or `create` that mounts a `TestThemeProvider`. + */ +export function createThemeWrapper(props: Omit = {}): React.ComponentType { + return function ThemeWrapper({ children }: React.PropsWithChildren): React.JSX.Element { + return {children}; + }; +} diff --git a/packages/agentic/design/src/test/compatibility/__snapshots__/entryPoints.test.ts.snap b/packages/agentic/design/src/test/compatibility/__snapshots__/entryPoints.test.ts.snap index dfc0a221e8..51ec1772db 100644 --- a/packages/agentic/design/src/test/compatibility/__snapshots__/entryPoints.test.ts.snap +++ b/packages/agentic/design/src/test/compatibility/__snapshots__/entryPoints.test.ts.snap @@ -110,6 +110,13 @@ exports[`public entry points ./testing keeps its runtime export names 1`] = ` ] `; +exports[`public entry points ./testing/theme keeps its runtime export names 1`] = ` +[ + "TestThemeProvider: function", + "createThemeWrapper: function", +] +`; + exports[`public entry points ./themes keeps its runtime export names 1`] = ` [ "flexTheme: object", diff --git a/packages/agentic/design/src/test/compatibility/entryPoints.test.ts b/packages/agentic/design/src/test/compatibility/entryPoints.test.ts index 45018161be..c936c263ad 100644 --- a/packages/agentic/design/src/test/compatibility/entryPoints.test.ts +++ b/packages/agentic/design/src/test/compatibility/entryPoints.test.ts @@ -20,6 +20,7 @@ const publicEntryPoints = [ './theming/compat', './themes', './testing', + './testing/theme', './deprecated', './styling', './styling/stylesheet', diff --git a/packages/agentic/design/src/test/compatibility/importBoundaries.test.ts b/packages/agentic/design/src/test/compatibility/importBoundaries.test.ts index 3609408765..bbf6e45099 100644 --- a/packages/agentic/design/src/test/compatibility/importBoundaries.test.ts +++ b/packages/agentic/design/src/test/compatibility/importBoundaries.test.ts @@ -137,14 +137,15 @@ describe('production code never depends on test collateral', () => { expect(resolvedTestImports).toContain('test/index.ts'); }); - it('has no production import of src/test or the testing entry point', () => { + it('has no production import of src/test or the testing entry points', () => { + const testingSpecifier = '@fluentui-react-native/design/testing'; expect(productionFiles.length).toBeGreaterThan(50); const violations = productionFiles.flatMap((file) => readImports(file) .filter((edge) => edge.specifier.startsWith('.') ? resolveRelative(file, edge.specifier).some((resolved) => toSrcPath(resolved).startsWith('test/')) - : edge.specifier === '@fluentui-react-native/design/testing', + : edge.specifier === testingSpecifier || edge.specifier.startsWith(`${testingSpecifier}/`), ) .map((edge) => `${toSrcPath(file)} -> ${edge.specifier}`), ); @@ -191,7 +192,7 @@ describe('deprecated code stays behind its boundary', () => { it('keeps every production entry point free of deprecated code', () => { const packageJson = jest.requireActual<{ exports: Record }>('../../../package.json'); const entries = Object.entries(packageJson.exports).flatMap(([subpath, conditions]) => - typeof conditions === 'string' || subpath === './deprecated' || subpath === './testing' + typeof conditions === 'string' || subpath === './deprecated' || subpath.startsWith('./testing') ? [] : [conditions['react-native'].slice('./src/'.length)], ); diff --git a/packages/agentic/design/src/theming/appearance.ts b/packages/agentic/design/src/theming/appearance.ts index 69f5e114b0..d58ed4fa7d 100644 --- a/packages/agentic/design/src/theming/appearance.ts +++ b/packages/agentic/design/src/theming/appearance.ts @@ -149,5 +149,3 @@ export function appearanceOptionFromResolved(appearance: ResolvedThemeAppearance } return appearance.colorScheme; } - -export const defaultThemeAppearanceState = resolveThemeAppearance();