Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/6051-fix-fluent-asset-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Fixed Fluent typing indicator image broken with React 19 Strict Mode or `<Activity>`, in PR [#6051](https://github.com/microsoft/BotFramework-WebChat/pull/6051), by [@OEvgeny](https://github.com/OEvgeny)
119 changes: 119 additions & 0 deletions __tests__/html2/react19/fluentTheme.typingIndicator.activity.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19 &lt;Activity&gt;: the Fluent typing indicator shows its image after hide and show</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
"botframework-webchat-fluent-theme": "/__dist__/packages/fluent-theme/static/botframework-webchat-fluent-theme.js",
"react": "https://esm.sh/react@19.3.1?dev",
"react/jsx-runtime": "https://esm.sh/react@19.3.1/jsx-runtime?dev",
"react-dom": "https://esm.sh/react-dom@19.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client?dev"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, ReactWebChat, testIds } from 'botframework-webchat';
import { FluentThemeProvider } from 'botframework-webchat-fluent-theme';
import { Activity, createElement, useEffect, useState, version } from 'react';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();
let numMounts = 0;
let numUnmounts = 0;
let setVisible;

const MountCounter = () => {
useEffect(() => {
numMounts++;

return () => {
numUnmounts++;
};
}, []);

return null;
};

const App = () => {
const [visible, setVisibleState] = useState(true);

setVisible = setVisibleState;

return createElement(
Activity,
{ mode: visible ? 'visible' : 'hidden' },
createElement(MountCounter),
createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine, store }))
);
};

const emulateTyping = id =>
directLine.emulateIncomingActivity({ from: { id: 'u-00001', name: 'Bot', role: 'bot' }, id, type: 'typing' });

// A revoked `blob:` URL completes with a width of 0.
const expectTypingIndicatorImageLoaded = async () => {
await waitFor(() => expect(pageElements.typingIndicator()).toBeTruthy());

const image = pageElements.typingIndicator().querySelector('img');

await waitFor(() => expect(image.complete).toBe(true));
expect(image.naturalWidth).toBeGreaterThan(0);
};

// GIVEN: React 19.2 or later, which has <Activity>.
expect(version).toBe('19.3.1');

createRoot(document.getElementById('webchat')).render(createElement(App));

await pageConditions.uiConnected();

// WHEN: The bot is typing.
await emulateTyping('a-00001');

// THEN: The typing indicator image should load.
await expectTypingIndicatorImageLoaded();

// WHEN: The bot stops typing.
await directLine.emulateIncomingActivity({
channelData: { webChat: { styleOptions: { typingAnimationDuration: 0 } } },
from: { id: 'u-00001', name: 'Bot', role: 'bot' },
id: 'a-00001',
type: 'typing'
});

await waitFor(() => expect(pageElements.typingIndicator()).toBeFalsy());

// WHEN: Web Chat is hidden and shown with <Activity>, which runs effect cleanups and effects again without rendering.
setVisible(false);
await waitFor(() => expect(numUnmounts).toBe(1));

setVisible(true);
await waitFor(() => expect(numMounts).toBe(2));

// WHEN: The bot is typing again.
await emulateTyping('a-00002');

// THEN: The new typing indicator image should load.
// No snapshot until react-scroll-to-bottom keeps its styles after hide and show: the transcript layout is lost.
await expectTypingIndicatorImageLoaded();
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19 in Strict Mode: the Fluent typing indicator shows its image</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
"botframework-webchat-fluent-theme": "/__dist__/packages/fluent-theme/static/botframework-webchat-fluent-theme.js",
"react": "https://esm.sh/react@19.3.1?dev",
"react/jsx-runtime": "https://esm.sh/react@19.3.1/jsx-runtime?dev",
"react-dom": "https://esm.sh/react-dom@19.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client?dev"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, ReactWebChat, testIds } from 'botframework-webchat';
import { FluentThemeProvider } from 'botframework-webchat-fluent-theme';
import { createElement, StrictMode, useEffect, version } from 'react';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();
let numMounts = 0;

const MountCounter = () => {
useEffect(() => {
numMounts++;
}, []);

return null;
};

// GIVEN: The development build of React 19, which runs the checks of Strict Mode.
expect(version).toBe('19.3.1');

// WHEN: Web Chat with the Fluent theme is rendered in Strict Mode.
createRoot(document.getElementById('webchat')).render(
createElement(
StrictMode,
{},
createElement(MountCounter),
createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine, store }))
)
);

// THEN: Strict Mode should mount, unmount, and mount again, which runs effect cleanups without rendering again.
await waitFor(() => expect(numMounts).toBe(2));

await pageConditions.uiConnected();

// WHEN: The bot is typing.
await directLine.emulateIncomingActivity({
from: { id: 'u-00001', name: 'Bot', role: 'bot' },
id: 'a-00001',
type: 'typing'
});

await waitFor(() => expect(pageElements.typingIndicator()).toBeTruthy());

const image = pageElements.typingIndicator().querySelector('img');

// THEN: The typing indicator image should load: a revoked `blob:` URL completes with a width of 0.
// No snapshot until react-scroll-to-bottom keeps its styles in Strict Mode: the transcript layout is lost.
await waitFor(() => expect(image.complete).toBe(true));
expect(image.naturalWidth).toBeGreaterThan(0);
});
</script>
</body>
</html>
58 changes: 26 additions & 32 deletions packages/fluent-theme/src/components/assets/AssetComposer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ContextType>(
() =>
Object.freeze({
urlStateMap: new Map<AssetName, readonly [URL]>([
['sliding dots', Object.freeze([new URL(slidingDotsURL)])],
['sliding dots reduced-motion', Object.freeze([new URL(slidingDotsReducedMotionURL)])]
])
}),
[slidingDotsURL, slidingDotsReducedMotionURL]
);

return <Context.Provider value={context}>{children}</Context.Provider>;
});
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 <StrictMode> and <Activity> run cleanups and effects again without rendering.
let context: ContextType | undefined;

function getContext(): ContextType {
context ||
(context = Object.freeze({
urlStateMap: new Map<AssetName, readonly [URL]>([
['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) => (
<Context.Provider value={getContext()}>{children}</Context.Provider>
));

AssetComposer.displayName = 'AssetComposer';

Expand Down
Loading