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();