diff --git a/.changelog/6051-fix-fluent-asset-urls.md b/.changelog/6051-fix-fluent-asset-urls.md new file mode 100644 index 0000000000..f6d8fa9f96 --- /dev/null +++ b/.changelog/6051-fix-fluent-asset-urls.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed Fluent typing indicator image broken with React 19 Strict Mode or ``, in PR [#6051](https://github.com/microsoft/BotFramework-WebChat/pull/6051), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/html2/react19/fluentTheme.typingIndicator.activity.html b/__tests__/html2/react19/fluentTheme.typingIndicator.activity.html new file mode 100644 index 0000000000..c7ff5aba26 --- /dev/null +++ b/__tests__/html2/react19/fluentTheme.typingIndicator.activity.html @@ -0,0 +1,119 @@ + + + + React 19 <Activity>: the Fluent typing indicator shows its image after hide and show + + + +
+ + + + diff --git a/__tests__/html2/react19/fluentTheme.typingIndicator.strictMode.html b/__tests__/html2/react19/fluentTheme.typingIndicator.strictMode.html new file mode 100644 index 0000000000..b6b71d3511 --- /dev/null +++ b/__tests__/html2/react19/fluentTheme.typingIndicator.strictMode.html @@ -0,0 +1,84 @@ + + + + React 19 in Strict Mode: the Fluent typing indicator shows its image + + + +
+ + + + diff --git a/packages/fluent-theme/src/components/assets/AssetComposer.tsx b/packages/fluent-theme/src/components/assets/AssetComposer.tsx index 6d64a67d5e..18b42530ee 100644 --- a/packages/fluent-theme/src/components/assets/AssetComposer.tsx +++ b/packages/fluent-theme/src/components/assets/AssetComposer.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useMemo, type ReactNode } from 'react'; +import React, { memo, type ReactNode } from 'react'; import { type AssetName } from './AssetName'; import Context, { type ContextType } from './private/Context'; @@ -15,37 +15,31 @@ const SLIDING_DOTS_REDUCED_MOTION_SVG_STRING = SLIDING_DOTS_SVG_STRING.replaceAl 'repeatCount="0.01" fill="freeze"' ); -const AssetComposer = memo(({ children }: AssetComposerProps) => { - const slidingDotsURL = useMemo( - // Content is hardcoded. - // eslint-disable-next-line no-restricted-properties - () => URL.createObjectURL(new Blob([SLIDING_DOTS_SVG_STRING], { type: 'image/svg+xml' })), - [] - ); - - const slidingDotsReducedMotionURL = useMemo( - // Content is hardcoded. - // eslint-disable-next-line no-restricted-properties - () => URL.createObjectURL(new Blob([SLIDING_DOTS_REDUCED_MOTION_SVG_STRING], { type: 'image/svg+xml' })), - [] - ); - - useEffect(() => () => URL.revokeObjectURL(slidingDotsURL), [slidingDotsURL]); - useEffect(() => () => URL.revokeObjectURL(slidingDotsReducedMotionURL), [slidingDotsReducedMotionURL]); - - const context = useMemo( - () => - Object.freeze({ - urlStateMap: new Map([ - ['sliding dots', Object.freeze([new URL(slidingDotsURL)])], - ['sliding dots reduced-motion', Object.freeze([new URL(slidingDotsReducedMotionURL)])] - ]) - }), - [slidingDotsURL, slidingDotsReducedMotionURL] - ); - - return {children}; -}); +function createSVGURLState(svg: string): readonly [URL] { + // Content is hardcoded. + // eslint-disable-next-line no-restricted-properties + return Object.freeze([new URL(URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })))]); +} + +// Created on first use and never revoked, shared by every instance: the images are small and constant. +// Revoking in an effect cleanup broke them, as React 19 and run cleanups and effects again without rendering. +let context: ContextType | undefined; + +function getContext(): ContextType { + context || + (context = Object.freeze({ + urlStateMap: new Map([ + ['sliding dots', createSVGURLState(SLIDING_DOTS_SVG_STRING)], + ['sliding dots reduced-motion', createSVGURLState(SLIDING_DOTS_REDUCED_MOTION_SVG_STRING)] + ]) + })); + + return context; +} + +const AssetComposer = memo(({ children }: AssetComposerProps) => ( + {children} +)); AssetComposer.displayName = 'AssetComposer';