diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c97b42959..a39107585c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,7 @@ Legends:
- Fixed the play, mute, and "more options" icons of audio players being invisible in dark high contrast themes, such as "Aquatic" on Windows, fixes [#5710](https://github.com/microsoft/BotFramework-WebChat/issues/5710), in PR [#5906](https://github.com/microsoft/BotFramework-WebChat/pull/5906), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed the "Positive" card action button having less than 4.5:1 color contrast, fixes [#5713](https://github.com/microsoft/BotFramework-WebChat/issues/5713), in PR [#5905](https://github.com/microsoft/BotFramework-WebChat/pull/5905), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed screen readers announcing a single audio, video, or animation in a card as a list item, such as "1 of 1", fixes [#5722](https://github.com/microsoft/BotFramework-WebChat/issues/5722), in PR [#5903](https://github.com/microsoft/BotFramework-WebChat/pull/5903), by [@OEvgeny](https://github.com/OEvgeny)
+- Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny)
## [4.19.2] - 2026-10-08
diff --git a/__tests__/html2/attachment/video.url.html b/__tests__/html2/attachment/video.url.html
new file mode 100644
index 0000000000..c81d738a29
--- /dev/null
+++ b/__tests__/html2/attachment/video.url.html
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/attachment/video.url.html.snap-1.png b/__tests__/html2/attachment/video.url.html.snap-1.png
new file mode 100644
index 0000000000..fb44bcf756
Binary files /dev/null and b/__tests__/html2/attachment/video.url.html.snap-1.png differ
diff --git a/packages/component/src/Attachment/VideoContent.spec.tsx b/packages/component/src/Attachment/VideoContent.spec.tsx
new file mode 100644
index 0000000000..e38b3ef7ec
--- /dev/null
+++ b/packages/component/src/Attachment/VideoContent.spec.tsx
@@ -0,0 +1,34 @@
+/** @jest-environment @happy-dom/jest-environment */
+///
+
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import VideoContent from './VideoContent';
+
+// Production builds of `validateProps()` return props as-is, the URL is not validated.
+jest.mock('@msinternal/botframework-webchat-react-valibot', () => ({
+ ...jest.requireActual('@msinternal/botframework-webchat-react-valibot'),
+ validateProps: (_: unknown, props: unknown) => props
+}));
+
+// The video player needs the Web Chat context, it is not under test.
+jest.mock('../hooks/useStyleSet', () => () => [{ videoContent: '' }]);
+
+test.each([
+ ['root-relative', '/videos/intro.mp4'],
+ ['path-relative', 'intro.mp4'],
+ ['protocol-relative', '//example.com/intro.mp4'],
+ ['without a host', 'https://'],
+ ['with an invalid host', 'http://[invalid']
+])('when the URL is %s in production build, should render nothing', (_, src) => {
+ const { container } = render( );
+
+ expect(container.innerHTML).toBe('');
+});
+
+test('when the URL is absolute in production build, should render a video player', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('video')?.getAttribute('src')).toBe('https://example.com/intro.mp4');
+});
diff --git a/packages/component/src/Attachment/VideoContent.tsx b/packages/component/src/Attachment/VideoContent.tsx
index 35b9c7caff..5ec251db76 100644
--- a/packages/component/src/Attachment/VideoContent.tsx
+++ b/packages/component/src/Attachment/VideoContent.tsx
@@ -1,6 +1,7 @@
import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
+import { onErrorResumeNext } from 'botframework-webchat-core';
import React, { memo } from 'react';
-import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot';
+import { boolean, object, optional, pipe, readonly, string, url, type InferInput } from 'valibot';
import HTMLVideoContent from './HTMLVideoContent';
import VimeoContent from './VimeoContent';
@@ -13,31 +14,14 @@ const YOUTUBE_WWW_SHORT_DOMAIN = 'www.youtu.be';
const VIMEO_DOMAIN = 'vimeo.com';
const VIMEO_WWW_DOMAIN = 'www.vimeo.com';
-// This is a workaround
-// - Today, there is no good URL polyfill for older browser
-// - Instead of writing a URL parser, for older browser, we will use this trick to parse the URL
-function parseURL(url) {
- let urlLike;
-
- if (typeof URL === 'function') {
- urlLike = new URL(url);
- } else {
- urlLike = document.createElement('a');
- urlLike.setAttribute('href', url);
- }
-
- const { hostname, pathname, search } = urlLike;
-
- return { hostname, pathname, search };
-}
-
const videoContentPropsSchema = pipe(
object({
alt: optional(string()),
autoPlay: optional(boolean()),
loop: optional(boolean()),
poster: optional(string()),
- src: string()
+ // Relative URLs are not supported, they would point to a different server in each chat client.
+ src: pipe(string(), url())
}),
readonly()
);
@@ -47,26 +31,26 @@ type VideoContentProps = InferInput;
function VideoContent(props: VideoContentProps) {
const { alt, autoPlay = false, loop = false, poster, src } = validateProps(videoContentPropsSchema, props);
- const { hostname, pathname, search } = parseURL(src);
- const lastSegment = pathname.split('/').pop();
- const searchParams = new URLSearchParams(search);
-
- switch (hostname) {
- case VIMEO_DOMAIN:
- case VIMEO_WWW_DOMAIN:
- return ;
-
- case YOUTUBE_DOMAIN:
- case YOUTUBE_WWW_DOMAIN:
- return ;
-
- case YOUTUBE_SHORT_DOMAIN:
- case YOUTUBE_WWW_SHORT_DOMAIN:
- return ;
-
- default:
- return ;
- }
+ const parsedURL = onErrorResumeNext(() => new URL(src));
+ const hostname = parsedURL?.hostname;
+ const lastSegment = parsedURL?.pathname.split('/').pop();
+
+ // Embed YouTube and Vimeo videos only when the URL has a video ID, otherwise, play the URL in .
+ const vimeoEmbedID = (hostname === VIMEO_DOMAIN || hostname === VIMEO_WWW_DOMAIN) && lastSegment;
+ const youTubeEmbedID =
+ hostname === YOUTUBE_DOMAIN || hostname === YOUTUBE_WWW_DOMAIN
+ ? parsedURL?.searchParams.get('v')
+ : (hostname === YOUTUBE_SHORT_DOMAIN || hostname === YOUTUBE_WWW_SHORT_DOMAIN) && lastSegment;
+
+ // Production builds do not validate props, leave out the video if the URL is relative or cannot be parsed.
+ // When `validateProps()` is enabled for production, remove this branch as it will be unreachable.
+ return !parsedURL ? null : vimeoEmbedID ? (
+
+ ) : youTubeEmbedID ? (
+
+ ) : (
+
+ );
}
export default memo(VideoContent);