From deb6bdb3cac186a8c3af7600d8437658370131f3 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 16:04:23 -0700 Subject: [PATCH 1/3] Add an internal CSS module theme context to useStyles(), shared by all packages - styles/react: CSSModuleThemeContext, combineCSSModules() (concatenates class names of the same local name), (combines its theme with the one from a composer above), and useStyles(styles, transform?) adding the theme classes of the same local name. The default transform keeps today's output (" "); webChatPrefixedClassName() gives "webchat__". - One runtime copy: the styles package is bundled into every package that imports it and each copy has its own context. botframework-webchat-component/internal.js re-exports it (like InjectStyleElements), botframework-webchat re-exports that, and bundle/Fluent use it instead of their own copy (enforced by no-restricted-imports). - Tests: unit tests for combine/useStyles/composer, a dist test for one runtime copy, and an html2 test where a composer above Fluent reaches component, bundle and Fluent elements. --- .../useStyles/cssModuleTheme.fluent.html | 95 ++++++++++++++ .../cssModuleTheme.fluent.html.snap-1.png | Bin 0 -> 13936 bytes packages/bundle/.eslintrc.yml | 3 + .../Attachment/AdaptiveCardRenderer.tsx | 3 +- .../Attachment/AnimatedImageContent.tsx | 3 +- .../Attachment/AnimationCardContent.tsx | 2 +- .../Attachment/AudioCardContent.tsx | 2 +- .../Attachment/SignInCardContent.tsx | 2 +- .../Attachment/VideoCardContent.tsx | 2 +- packages/bundle/src/boot/actual/internal.ts | 5 +- packages/component/src/boot/internal.ts | 10 +- packages/fluent-theme/.eslintrc.yml | 5 + .../src/__tests__/sharedStylesRuntime.spec.js | 59 +++++++++ .../components/activity/ActivityLoader.tsx | 3 +- .../src/components/icon/FluentIcon.tsx | 2 +- .../SlidingDotsTypingIndicator.tsx | 2 +- packages/fluent-theme/src/styles/useStyles.ts | 4 +- packages/styles/src/react/index.ts | 8 ++ .../private/CSSModuleThemeComposer.spec.tsx | 71 +++++++++++ .../react/private/CSSModuleThemeComposer.tsx | 37 ++++++ .../react/private/CSSModuleThemeContext.ts | 19 +++ .../src/react/private/classNameTransforms.ts | 17 +++ .../react/private/combineCSSModules.spec.ts | 70 +++++++++++ .../src/react/private/combineCSSModules.ts | 33 +++++ .../src/react/private/useStyles.spec.tsx | 116 ++++++++++++++++++ .../styles/src/react/private/useStyles.ts | 40 +++--- 26 files changed, 583 insertions(+), 30 deletions(-) create mode 100644 __tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html create mode 100644 __tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png create mode 100644 packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js create mode 100644 packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx create mode 100644 packages/styles/src/react/private/CSSModuleThemeComposer.tsx create mode 100644 packages/styles/src/react/private/CSSModuleThemeContext.ts create mode 100644 packages/styles/src/react/private/classNameTransforms.ts create mode 100644 packages/styles/src/react/private/combineCSSModules.spec.ts create mode 100644 packages/styles/src/react/private/combineCSSModules.ts create mode 100644 packages/styles/src/react/private/useStyles.spec.tsx diff --git a/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html new file mode 100644 index 0000000000..fa68c5d8d8 --- /dev/null +++ b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png b/__tests__/html2/hooks/useStyles/cssModuleTheme.fluent.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..ab29e8bcb964b9e734de7c3eb98ea5d679cf5fb4 GIT binary patch literal 13936 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 literal 0 HcmV?d00001 diff --git a/packages/bundle/.eslintrc.yml b/packages/bundle/.eslintrc.yml index 120e30c270..a4936c5bda 100644 --- a/packages/bundle/.eslintrc.yml +++ b/packages/bundle/.eslintrc.yml @@ -14,3 +14,6 @@ rules: - group: - '@msinternal/botframework-webchat-api-*' message: 'To prevent duplicated import, @msinternal/botframework-webchat-api-* is only allowed to import in botframework-webchat-api.' + paths: + - name: '@msinternal/botframework-webchat-styles/react' + message: 'Every copy of @msinternal/botframework-webchat-styles/react has its own React context, import it from botframework-webchat-component/internal.js instead.' diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx index 5e6aa6a763..53e40b6b8c 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx @@ -1,11 +1,10 @@ /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 2] }] */ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { type Action as AdaptiveCardAction, type OpenUrlAction, type SubmitAction } from 'adaptivecards'; import { ErrorBoxPolymiddlewareProxy } from 'botframework-webchat-api/middleware.js'; import { getTabIndex, hooks } from 'botframework-webchat-component'; -import { useQueueStaticElement } from 'botframework-webchat-component/internal.js'; +import { useQueueStaticElement, useStyles } from 'botframework-webchat-component/internal.js'; import { type DirectLineCardAction } from 'botframework-webchat-core'; import cx from 'classnames'; import React, { diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx index e16d4fd3e8..2582bf19a1 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimatedImageContent.tsx @@ -1,7 +1,6 @@ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { hooks } from 'botframework-webchat-component'; -import { ActivityButton, FixedWidthImage } from 'botframework-webchat-component/internal.js'; +import { ActivityButton, FixedWidthImage, useStyles } from 'botframework-webchat-component/internal.js'; import cx from 'classnames'; import React, { memo, useCallback, useRef, useState, type SyntheticEvent } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx index 4b05aef877..7a8488c557 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import { onErrorResumeNext } from 'botframework-webchat-core'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx index 4be222e319..5b90cb61ef 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx index 57887dc9ff..05f8512a2b 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx @@ -1,5 +1,5 @@ import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx index 42645da61c..c3cb99460c 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx @@ -1,8 +1,8 @@ /* eslint react/no-array-index-key: "off" */ import type { InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { Components } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-component/internal.js'; import React, { memo } from 'react'; import { boolean, object, optional, parse, pipe, readonly, string } from 'valibot'; diff --git a/packages/bundle/src/boot/actual/internal.ts b/packages/bundle/src/boot/actual/internal.ts index fc8c411144..36614f5d2f 100644 --- a/packages/bundle/src/boot/actual/internal.ts +++ b/packages/bundle/src/boot/actual/internal.ts @@ -2,7 +2,9 @@ export { usePostVoiceActivity, useShouldShowMicrophoneButton } from 'botframework-webchat-api/internal.js'; export { CodeHighlighterComposer, + combineCSSModules, createIconComponent, + CSSModuleThemeComposer, InjectStyleElements, injectStyleElementsPropsSchema, parseDocumentFragmentFromString, @@ -11,7 +13,8 @@ export { serializeDocumentFragmentIntoString, useActivityStyleOptions, useCodeHighlighter, - useLiveRegion + useLiveRegion, + useStyles } from 'botframework-webchat-component/internal.js'; export { getOrgSchemaMessage, diff --git a/packages/component/src/boot/internal.ts b/packages/component/src/boot/internal.ts index 2faa3f1041..521c84a81f 100644 --- a/packages/component/src/boot/internal.ts +++ b/packages/component/src/boot/internal.ts @@ -5,7 +5,15 @@ export { type BetterLinkDocumentModAttributeSetter, type BetterLinkDocumentModDecoration } from '@msinternal/botframework-webchat-component-better-link'; -export { InjectStyleElements, injectStyleElementsPropsSchema } from '@msinternal/botframework-webchat-styles/react'; +// The styles package is bundled into every package that imports it, and each copy has its own React context. +// Other packages must use this copy, so a theme above Web Chat reaches every useStyles(). +export { + combineCSSModules, + CSSModuleThemeComposer, + InjectStyleElements, + injectStyleElementsPropsSchema, + useStyles +} from '@msinternal/botframework-webchat-styles/react'; export { default as ActivityButton } from '../Attachment/Text/private/ActivityButton'; export { CodeHighlighterComposer, diff --git a/packages/fluent-theme/.eslintrc.yml b/packages/fluent-theme/.eslintrc.yml index 8307709bdc..194d6ebbb3 100644 --- a/packages/fluent-theme/.eslintrc.yml +++ b/packages/fluent-theme/.eslintrc.yml @@ -7,5 +7,10 @@ env: browser: true rules: + no-restricted-imports: + - error + - paths: + - name: '@msinternal/botframework-webchat-styles/react' + message: 'Every copy of @msinternal/botframework-webchat-styles/react has its own React context, import it from botframework-webchat/internal.js instead.' react/destructuring-assignment: off react/require-default-props: off diff --git a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js new file mode 100644 index 0000000000..a174ce898c --- /dev/null +++ b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js @@ -0,0 +1,59 @@ +/** @jest-environment @happy-dom/jest-environment */ + +// Every copy of the styles runtime has its own CSSModuleThemeContext: a theme provided through one copy is invisible to +// useStyles() of another copy. Web Chat must ship one copy, from botframework-webchat-component. + +const { readdirSync, readFileSync } = require('fs'); +const { dirname, join } = require('path'); + +// Lists the build outputs that bundle the styles runtime, per format, from esbuild metafiles (tsup `metafile: true`). +function outputsWithStylesRuntime(packageName) { + const distPath = dirname(require.resolve(packageName)); + + return Object.fromEntries( + // Paths come from require.resolve() and the dist folder listing, not from user input. + // eslint-disable-next-line security/detect-non-literal-fs-filename + readdirSync(distPath) + .filter(name => /^metafile-\w+\.json$/u.test(name)) + .map(name => [ + name, + // eslint-disable-next-line security/detect-non-literal-fs-filename + Object.entries(JSON.parse(readFileSync(join(distPath, name), 'utf8')).outputs) + .filter(([, { inputs }]) => + Object.keys(inputs).some(input => input.includes('styles/dist/botframework-webchat-styles.react.')) + ) + .map(([output]) => output) + ]) + ); +} + +test('botframework-webchat/internal.js should re-export the styles runtime of botframework-webchat-component', () => { + const bundleInternal = require('botframework-webchat/internal.js'); + const componentInternal = require('botframework-webchat-component/internal.js'); + + expect(bundleInternal.CSSModuleThemeComposer).toBe(componentInternal.CSSModuleThemeComposer); + expect(bundleInternal.useStyles).toBe(componentInternal.useStyles); +}); + +test('botframework-webchat-component should bundle the styles runtime into one chunk per module format', () => { + const outputs = outputsWithStylesRuntime('botframework-webchat-component'); + + expect(Object.keys(outputs).sort()).toEqual(['metafile-cjs.json', 'metafile-esm.json']); + expect(outputs['metafile-cjs.json']).toHaveLength(1); + expect(outputs['metafile-esm.json']).toHaveLength(1); +}); + +test('botframework-webchat should not bundle the styles runtime', () => + // IIFE builds get it from botframework-webchat-component. + expect(outputsWithStylesRuntime('botframework-webchat')).toEqual({ + 'metafile-cjs.json': [], + 'metafile-esm.json': [], + 'metafile-iife.json': [] + })); + +test('botframework-webchat-fluent-theme should not bundle the styles runtime', () => + expect(outputsWithStylesRuntime('botframework-webchat-fluent-theme')).toEqual({ + 'metafile-cjs.json': [], + 'metafile-esm.json': [], + 'metafile-iife.json': [] + })); diff --git a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx index 3c2348f1ef..2e844a2092 100644 --- a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx +++ b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx @@ -1,10 +1,9 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import cx from 'classnames'; import React, { Fragment, memo } from 'react'; import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import { useVariantClassName } from '../../styles'; +import { useStyles, useVariantClassName } from '../../styles'; import SlidingDots from '../assets/SlidingDots'; import styles from './ActivityLoader.module.css'; diff --git a/packages/fluent-theme/src/components/icon/FluentIcon.tsx b/packages/fluent-theme/src/components/icon/FluentIcon.tsx index 416796237a..48ee4d1451 100644 --- a/packages/fluent-theme/src/components/icon/FluentIcon.tsx +++ b/packages/fluent-theme/src/components/icon/FluentIcon.tsx @@ -1,10 +1,10 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { createIconComponent } from 'botframework-webchat/internal.js'; import cx from 'classnames'; import React, { memo, useMemo, type CSSProperties } from 'react'; import { object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; +import { useStyles } from '../../styles'; import styles from './FluentIcon.module.css'; const baseFluentIconPropsSchema = pipe( diff --git a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx index 6e35a5022b..62cb72f039 100644 --- a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx +++ b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx @@ -1,8 +1,8 @@ -import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { testIds } from 'botframework-webchat'; import cx from 'classnames'; import React, { memo } from 'react'; +import { useStyles } from '../../styles'; import SlidingDots from '../assets/SlidingDots'; import styles from './SlidingDotsTypingIndicator.module.css'; diff --git a/packages/fluent-theme/src/styles/useStyles.ts b/packages/fluent-theme/src/styles/useStyles.ts index 974db3840f..d5c1e00f0d 100644 --- a/packages/fluent-theme/src/styles/useStyles.ts +++ b/packages/fluent-theme/src/styles/useStyles.ts @@ -1,2 +1,2 @@ -// TODO: refactor after initial approval gained -export { useStyles as default } from '@msinternal/botframework-webchat-styles/react'; +// Use the copy from Web Chat, so Fluent elements read the same CSSModuleThemeContext as Web Chat elements. +export { useStyles as default } from 'botframework-webchat/internal.js'; diff --git a/packages/styles/src/react/index.ts b/packages/styles/src/react/index.ts index 692bb72103..ac3668c1e8 100644 --- a/packages/styles/src/react/index.ts +++ b/packages/styles/src/react/index.ts @@ -1,2 +1,10 @@ +export { localClassName, webChatPrefixedClassName, type ClassNameTransform } from './private/classNameTransforms'; +export { default as combineCSSModules } from './private/combineCSSModules'; +export { + default as CSSModuleThemeComposer, + cssModuleThemeComposerPropsSchema, + type CSSModuleThemeComposerProps +} from './private/CSSModuleThemeComposer'; +export { type CSSModuleTheme } from './private/CSSModuleThemeContext'; export { default as InjectStyleElements, injectStyleElementsPropsSchema } from './private/InjectStyleElements'; export { default as useStyles } from './private/useStyles'; diff --git a/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx b/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx new file mode 100644 index 0000000000..55a6cb8e41 --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeComposer.spec.tsx @@ -0,0 +1,71 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import CSSModuleThemeComposer from './CSSModuleThemeComposer'; +import useStyles from './useStyles'; + +const styles = Object.freeze({ bubble: 'wA_bubble' }); + +function Probe({ results }: { readonly results: unknown[] }) { + results.push(useStyles(styles)); + + return null; +} + +function renderApp({ + container, + outerTheme, + results, + theme +}: { + readonly container: HTMLElement; + readonly outerTheme: { readonly [localName: string]: string }; + readonly results: unknown[]; + readonly theme: { readonly [localName: string]: string }; +}) { + act(() => { + render( + + + + + , + container + ); + }); +} + +scenario( + 'CSSModuleThemeComposer', + bdd => { + bdd + .given('an outer composer (e.g. from the host) around an inner composer (e.g. a theme)', () => ({ + container: document.createElement('div'), + outerTheme: Object.freeze({ bubble: 'wUser_bubble' }), + results: [], + theme: Object.freeze({ bubble: 'wTheme_bubble' }) + })) + .when('rendered', props => renderApp(props)) + .then('the outer classes should be added after the inner ones, not replace them', ({ results }) => + expect(results).toStrictEqual([{ bubble: 'bubble wA_bubble wTheme_bubble wUser_bubble' }]) + ) + .when('rendered again with the same theme objects', props => renderApp(props)) + .then('useStyles() should return the same object, the combined theme is memoized', ({ results }) => { + expect(results).toHaveLength(2); + expect(results[1]).toBe(results[0]); + }) + .when('the outer composer gets a new theme', props => + renderApp({ ...props, outerTheme: Object.freeze({ bubble: 'wUser2_bubble' }) }) + ) + .then('useStyles() should return the new classes', ({ results }) => + expect(results[2]).toStrictEqual({ bubble: 'bubble wA_bubble wTheme_bubble wUser2_bubble' }) + ); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/CSSModuleThemeComposer.tsx b/packages/styles/src/react/private/CSSModuleThemeComposer.tsx new file mode 100644 index 0000000000..2f6aaa9bd3 --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeComposer.tsx @@ -0,0 +1,37 @@ +import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useContext, useMemo } from 'react'; +import { object, optional, pipe, readonly } from 'valibot'; + +import combineCSSModules from './combineCSSModules'; +import CSSModuleThemeContext, { cssModuleThemeSchema } from './CSSModuleThemeContext'; + +const cssModuleThemeComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + // Pass a stable object (e.g. built once at module level with combineCSSModules()) to avoid re-rendering every useStyles(). + theme: cssModuleThemeSchema + }), + readonly() +); + +type CSSModuleThemeComposerProps = InferReactProps; + +/** + * Provides class names for CSS module local names to every `useStyles()` below, e.g. a theme styling Web Chat elements. + * + * Classes from a composer above are added to ours, never replace them, so an outer composer can add to a theme. + */ +function CSSModuleThemeComposer(props: CSSModuleThemeComposerProps) { + const { children, theme } = validateProps(cssModuleThemeComposerPropsSchema, props); + + const outerTheme = useContext(CSSModuleThemeContext); + + const context = useMemo(() => combineCSSModules(theme, outerTheme), [outerTheme, theme]); + + return {children}; +} + +CSSModuleThemeComposer.displayName = 'CSSModuleThemeComposer'; + +export default memo(CSSModuleThemeComposer); +export { cssModuleThemeComposerPropsSchema, type CSSModuleThemeComposerProps }; diff --git a/packages/styles/src/react/private/CSSModuleThemeContext.ts b/packages/styles/src/react/private/CSSModuleThemeContext.ts new file mode 100644 index 0000000000..409663239c --- /dev/null +++ b/packages/styles/src/react/private/CSSModuleThemeContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; +import { pipe, readonly, record, string, type InferOutput } from 'valibot'; + +const cssModuleThemeSchema = pipe(record(string(), string()), readonly()); + +/** + * Extra class names for CSS module local names, e.g. `{ bubble: 'w1abc_bubble w2def_bubble' }`. + * + * `useStyles()` adds the classes for a local name to the element that uses the same local name. + */ +type CSSModuleTheme = InferOutput; + +// Internal: themes provide it via , components read it via useStyles(); not a public API. +const CSSModuleThemeContext = createContext(Object.freeze({})); + +CSSModuleThemeContext.displayName = 'WebChat.CSSModuleThemeContext'; + +export default CSSModuleThemeContext; +export { cssModuleThemeSchema, type CSSModuleTheme }; diff --git a/packages/styles/src/react/private/classNameTransforms.ts b/packages/styles/src/react/private/classNameTransforms.ts new file mode 100644 index 0000000000..d3c0b6def6 --- /dev/null +++ b/packages/styles/src/react/private/classNameTransforms.ts @@ -0,0 +1,17 @@ +/** + * Returns the readable class name to add next to the hashed class name of a CSS module local name, + * or `undefined` to add none. + */ +type ClassNameTransform = (localName: string) => string | undefined; + +/** Today's readable class name: the local name itself, e.g. `bubble`. */ +function localClassName(localName: string): string { + return localName; +} + +/** Stable readable class name for legacy selectors, e.g. `webchat__bubble`. */ +function webChatPrefixedClassName(localName: string): string { + return `webchat__${localName}`; +} + +export { localClassName, webChatPrefixedClassName, type ClassNameTransform }; diff --git a/packages/styles/src/react/private/combineCSSModules.spec.ts b/packages/styles/src/react/private/combineCSSModules.spec.ts new file mode 100644 index 0000000000..3fd42cdf8c --- /dev/null +++ b/packages/styles/src/react/private/combineCSSModules.spec.ts @@ -0,0 +1,70 @@ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import combineCSSModules from './combineCSSModules'; + +scenario( + 'combineCSSModules', + bdd => { + bdd + .given('two CSS modules that both define "bubble"', () => + Object.freeze([ + Object.freeze({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }), + Object.freeze({ bubble: 'wB_bubble', transcript: 'wB_transcript' }) + ]) + ) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('"bubble" should have the class names of both modules, in order', (_, result) => + expect(result).toHaveProperty('bubble', 'wA_bubble wB_bubble') + ) + .and('other local names should be kept as-is', (_, result) => + expect(result).toStrictEqual({ + bubble: 'wA_bubble wB_bubble', + 'bubble--from-user': 'wA_bubble--from-user', + transcript: 'wB_transcript' + }) + ) + .and('the result should be frozen', (_, result) => expect(Object.isFrozen(result)).toBe(true)); + + bdd + .given('a combined module and one of its modules again', () => + Object.freeze([ + Object.freeze({ bubble: 'wA_bubble wB_bubble' }), // Output of an earlier combineCSSModules() + Object.freeze({ bubble: 'wA_bubble' }) + ]) + ) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('every class name should appear once', (_, result) => + expect(result).toStrictEqual({ bubble: 'wA_bubble wB_bubble' }) + ); + + bdd + .given('a module with an empty class name', () => Object.freeze([Object.freeze({ bubble: '' })])) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('the local name should be left out', (_, result) => expect(result).toStrictEqual({})); + + bdd + .given('no modules', () => Object.freeze([])) + .when('combined', cssModules => combineCSSModules(...cssModules)) + .then('the result should be an empty frozen map', (_, result) => { + expect(result).toStrictEqual({}); + expect(Object.isFrozen(result)).toBe(true); + }); + + bdd + .given('a module with a non-string class name', () => Object.freeze([Object.freeze({ bubble: 1 })])) + .when('combined', cssModules => { + try { + combineCSSModules(...cssModules); + } catch (error) { + return error; + } + + return undefined; + }) + .then('it should throw', (_, error) => expect(error).toBeInstanceOf(Error)); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/combineCSSModules.ts b/packages/styles/src/react/private/combineCSSModules.ts new file mode 100644 index 0000000000..c4d151bd5c --- /dev/null +++ b/packages/styles/src/react/private/combineCSSModules.ts @@ -0,0 +1,33 @@ +import { array, parse } from 'valibot'; + +import { cssModuleThemeSchema, type CSSModuleTheme } from './CSSModuleThemeContext'; + +const cssModulesSchema = array(cssModuleThemeSchema); + +/** + * Combines CSS module class maps into one map for `CSSModuleThemeContext`. + * + * Class names of the same local name are concatenated, never replaced, and appear once: + * `combineCSSModules({ bubble: 'w1_bubble' }, { bubble: 'w2_bubble' })` returns `{ bubble: 'w1_bubble w2_bubble' }`. + */ +export default function combineCSSModules(...cssModules: readonly CSSModuleClasses[]): CSSModuleTheme { + const classNamesMap = new Map>(); + + for (const cssModule of parse(cssModulesSchema, cssModules)) { + for (const [localName, classNames] of Object.entries(cssModule)) { + const combinedClassNames = classNamesMap.get(localName) ?? new Set(); + + for (const className of classNames.split(/\s+/u)) { + className && combinedClassNames.add(className); + } + + combinedClassNames.size && classNamesMap.set(localName, combinedClassNames); + } + } + + return Object.freeze( + Object.fromEntries( + Array.from(classNamesMap, ([localName, classNames]) => [localName, Array.from(classNames).join(' ')]) + ) + ); +} diff --git a/packages/styles/src/react/private/useStyles.spec.tsx b/packages/styles/src/react/private/useStyles.spec.tsx new file mode 100644 index 0000000000..fe44713867 --- /dev/null +++ b/packages/styles/src/react/private/useStyles.spec.tsx @@ -0,0 +1,116 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import { webChatPrefixedClassName, type ClassNameTransform } from './classNameTransforms'; +import CSSModuleThemeComposer from './CSSModuleThemeComposer'; +import useStyles from './useStyles'; + +// Same shape as a CSS module import: local name → hashed class name. +const styles = Object.freeze({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }); + +const hashedClassNameOnly: ClassNameTransform = () => undefined; + +function Probe({ + results, + transform +}: { + readonly results: unknown[]; + readonly transform?: ClassNameTransform | undefined; +}) { + results.push(useStyles(styles, transform)); + + return null; +} + +scenario( + 'useStyles', + bdd => { + bdd + .given('no CSSModuleThemeComposer', () => ({ results: [] })) + .when('useStyles() is called without a transform', ({ results }) => { + act(() => { + render(, document.createElement('div')); + }); + + return results[0]; + }) + .then('every local name should map to " ", as before', (_, classNames) => + expect(classNames).toStrictEqual({ + bubble: 'bubble wA_bubble', + 'bubble--from-user': 'bubble--from-user wA_bubble--from-user' + }) + ) + .and('the result should be frozen', (_, classNames) => expect(Object.isFrozen(classNames)).toBe(true)); + + bdd + .given('a CSSModuleThemeComposer with "bubble" and "transcript"', () => ({ + results: [], + theme: Object.freeze({ bubble: 'wT_bubble', transcript: 'wT_transcript' }) + })) + .when('useStyles() is called below it', ({ results, theme }) => { + act(() => { + render( + + + , + document.createElement('div') + ); + }); + + return results[0]; + }) + .then('"bubble" should also have the theme class', (_, classNames) => + expect(classNames).toHaveProperty('bubble', 'bubble wA_bubble wT_bubble') + ) + .and('local names not in the theme should be unchanged', (_, classNames) => + expect(classNames).toHaveProperty('bubble--from-user', 'bubble--from-user wA_bubble--from-user') + ) + .and('"transcript" should not be added, the CSS module does not use it', (_, classNames) => + expect(classNames).not.toHaveProperty('transcript') + ); + + bdd + .given('a CSSModuleThemeComposer with "bubble"', () => ({ + results: [], + theme: Object.freeze({ bubble: 'wT_bubble' }) + })) + .when('useStyles() is called with the "webchat__" prefix transform', ({ results, theme }) => { + act(() => { + render( + + + , + document.createElement('div') + ); + }); + + return results[0]; + }) + .then('the readable class names should be prefixed', (_, classNames) => + expect(classNames).toStrictEqual({ + bubble: 'webchat__bubble wA_bubble wT_bubble', + 'bubble--from-user': 'webchat__bubble--from-user wA_bubble--from-user' + }) + ); + + bdd + .given('no CSSModuleThemeComposer', () => ({ results: [] })) + .when('useStyles() is called with a transform that returns undefined', ({ results }) => { + act(() => { + render(, document.createElement('div')); + }); + + return results[0]; + }) + .then('only the hashed class names should be left', (_, classNames) => + expect(classNames).toStrictEqual({ bubble: 'wA_bubble', 'bubble--from-user': 'wA_bubble--from-user' }) + ); + }, + JestGlobals +); diff --git a/packages/styles/src/react/private/useStyles.ts b/packages/styles/src/react/private/useStyles.ts index 216656910d..09396a14b7 100644 --- a/packages/styles/src/react/private/useStyles.ts +++ b/packages/styles/src/react/private/useStyles.ts @@ -1,19 +1,31 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; + +import { localClassName, type ClassNameTransform } from './classNameTransforms'; +import CSSModuleThemeContext from './CSSModuleThemeContext'; + +/** + * Maps every local name of a CSS module to the class names its element should carry: + * + * 1. the readable class name from `transform` (default: the local name itself), + * 2. the module's own hashed class name, + * 3. the classes of the same local name from `CSSModuleThemeContext`, if any. + */ +function useStyles(styles: T, transform: ClassNameTransform = localClassName): T { + const theme = useContext(CSSModuleThemeContext); -function useStyles(styles: T): T { // @ts-expect-error: entries/fromEntries don't allow to specify keys type - return useMemo( - () => - Object.freeze( - Object.fromEntries( - Object.entries(styles).map(([baseClassName, resultClassName]) => [ - baseClassName, - `${baseClassName} ${resultClassName}` - ]) - ) - ), - [styles] - ); + return useMemo(() => { + const themeClassNamesMap = new Map(Object.entries(theme)); + + return Object.freeze( + Object.fromEntries( + Object.entries(styles).map(([localName, className]) => [ + localName, + [transform(localName), className, themeClassNamesMap.get(localName)].filter(Boolean).join(' ') + ]) + ) + ); + }, [styles, theme, transform]); } export default useStyles; From 32bee770cede62dc0aba788a32cc7b26d85da220 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 19:34:17 -0700 Subject: [PATCH 2/3] Type the styles runtime with CSSModuleTheme so the published typings do not use the ambient CSSModuleClasses The typings of botframework-webchat/internal.js referred to CSSModuleClasses, which is declared only in the styles package's env.d.ts: consumers with skipLibCheck: false got TS2304. --- .../fluent-theme/src/__tests__/sharedStylesRuntime.spec.js | 4 ++-- packages/styles/src/react/private/combineCSSModules.spec.ts | 1 + packages/styles/src/react/private/combineCSSModules.ts | 2 +- packages/styles/src/react/private/useStyles.ts | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js index a174ce898c..1b81189d1e 100644 --- a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js +++ b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js @@ -43,8 +43,8 @@ test('botframework-webchat-component should bundle the styles runtime into one c expect(outputs['metafile-esm.json']).toHaveLength(1); }); -test('botframework-webchat should not bundle the styles runtime', () => - // IIFE builds get it from botframework-webchat-component. +test('botframework-webchat should not bundle its own copy of the styles runtime', () => + // IIFE builds (webchat.js) contain one copy, inside the botframework-webchat-component code they inline. expect(outputsWithStylesRuntime('botframework-webchat')).toEqual({ 'metafile-cjs.json': [], 'metafile-esm.json': [], diff --git a/packages/styles/src/react/private/combineCSSModules.spec.ts b/packages/styles/src/react/private/combineCSSModules.spec.ts index 3fd42cdf8c..3838c9398b 100644 --- a/packages/styles/src/react/private/combineCSSModules.spec.ts +++ b/packages/styles/src/react/private/combineCSSModules.spec.ts @@ -57,6 +57,7 @@ scenario( .given('a module with a non-string class name', () => Object.freeze([Object.freeze({ bubble: 1 })])) .when('combined', cssModules => { try { + // @ts-expect-error: invalid input on purpose. combineCSSModules(...cssModules); } catch (error) { return error; diff --git a/packages/styles/src/react/private/combineCSSModules.ts b/packages/styles/src/react/private/combineCSSModules.ts index c4d151bd5c..e3f98f4210 100644 --- a/packages/styles/src/react/private/combineCSSModules.ts +++ b/packages/styles/src/react/private/combineCSSModules.ts @@ -10,7 +10,7 @@ const cssModulesSchema = array(cssModuleThemeSchema); * Class names of the same local name are concatenated, never replaced, and appear once: * `combineCSSModules({ bubble: 'w1_bubble' }, { bubble: 'w2_bubble' })` returns `{ bubble: 'w1_bubble w2_bubble' }`. */ -export default function combineCSSModules(...cssModules: readonly CSSModuleClasses[]): CSSModuleTheme { +export default function combineCSSModules(...cssModules: readonly CSSModuleTheme[]): CSSModuleTheme { const classNamesMap = new Map>(); for (const cssModule of parse(cssModulesSchema, cssModules)) { diff --git a/packages/styles/src/react/private/useStyles.ts b/packages/styles/src/react/private/useStyles.ts index 09396a14b7..30927a8fa3 100644 --- a/packages/styles/src/react/private/useStyles.ts +++ b/packages/styles/src/react/private/useStyles.ts @@ -1,7 +1,7 @@ import { useContext, useMemo } from 'react'; import { localClassName, type ClassNameTransform } from './classNameTransforms'; -import CSSModuleThemeContext from './CSSModuleThemeContext'; +import CSSModuleThemeContext, { type CSSModuleTheme } from './CSSModuleThemeContext'; /** * Maps every local name of a CSS module to the class names its element should carry: @@ -10,7 +10,7 @@ import CSSModuleThemeContext from './CSSModuleThemeContext'; * 2. the module's own hashed class name, * 3. the classes of the same local name from `CSSModuleThemeContext`, if any. */ -function useStyles(styles: T, transform: ClassNameTransform = localClassName): T { +function useStyles(styles: T, transform: ClassNameTransform = localClassName): T { const theme = useContext(CSSModuleThemeContext); // @ts-expect-error: entries/fromEntries don't allow to specify keys type From b83a34f5cb3f594f4f99bce007f92f5ac3cc1489 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 21:55:40 -0700 Subject: [PATCH 3/3] Check the published scripts of botframework-webchat-fluent-theme for the styles runtime The package does not publish its esbuild metafiles ("files" in package.json), so the test found none in CI, where packages are unpacked from npm pack tarballs. --- .../src/__tests__/sharedStylesRuntime.spec.js | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js index 1b81189d1e..079f01305e 100644 --- a/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js +++ b/packages/fluent-theme/src/__tests__/sharedStylesRuntime.spec.js @@ -6,6 +6,9 @@ const { readdirSync, readFileSync } = require('fs'); const { dirname, join } = require('path'); +// Only the styles runtime names its context, minifiers keep string literals. +const STYLES_RUNTIME_MARKER = 'WebChat.CSSModuleThemeContext'; + // Lists the build outputs that bundle the styles runtime, per format, from esbuild metafiles (tsup `metafile: true`). function outputsWithStylesRuntime(packageName) { const distPath = dirname(require.resolve(packageName)); @@ -41,6 +44,15 @@ test('botframework-webchat-component should bundle the styles runtime into one c expect(Object.keys(outputs).sort()).toEqual(['metafile-cjs.json', 'metafile-esm.json']); expect(outputs['metafile-cjs.json']).toHaveLength(1); expect(outputs['metafile-esm.json']).toHaveLength(1); + + // The marker used by the botframework-webchat-fluent-theme test below is in the styles runtime chunks. + [...outputs['metafile-cjs.json'], ...outputs['metafile-esm.json']].forEach(output => + expect( + // Paths come from the esbuild metafile, not from user input. + // eslint-disable-next-line security/detect-non-literal-fs-filename + readFileSync(join(dirname(require.resolve('botframework-webchat-component')), '..', output), 'utf8') + ).toContain(STYLES_RUNTIME_MARKER) + ); }); test('botframework-webchat should not bundle its own copy of the styles runtime', () => @@ -51,9 +63,22 @@ test('botframework-webchat should not bundle its own copy of the styles runtime' 'metafile-iife.json': [] })); -test('botframework-webchat-fluent-theme should not bundle the styles runtime', () => - expect(outputsWithStylesRuntime('botframework-webchat-fluent-theme')).toEqual({ - 'metafile-cjs.json': [], - 'metafile-esm.json': [], - 'metafile-iife.json': [] - })); +test('botframework-webchat-fluent-theme should not bundle the styles runtime', () => { + // The package does not publish its esbuild metafiles ("files" in package.json), so check the published scripts. + const distPath = dirname(require.resolve('botframework-webchat-fluent-theme')); + const scripts = readdirSync(distPath).filter(name => /\.m?js$/u.test(name)); + + // CommonJS, ES modules, and the development and production IIFE builds; no chunks. + expect(scripts.sort()).toEqual([ + 'botframework-webchat-fluent-theme.development.js', + 'botframework-webchat-fluent-theme.js', + 'botframework-webchat-fluent-theme.mjs', + 'botframework-webchat-fluent-theme.production.min.js' + ]); + expect( + scripts.filter(name => + // eslint-disable-next-line security/detect-non-literal-fs-filename + readFileSync(join(distPath, name), 'utf8').includes(STYLES_RUNTIME_MARKER) + ) + ).toEqual([]); +});