-
+
{children}
{!!entries.length && (
diff --git a/packages/component/src/hooks/useRenderMarkdownAsHTML.ts b/packages/component/src/hooks/useRenderMarkdownAsHTML.ts
index 626babd322..ca78bffc89 100644
--- a/packages/component/src/hooks/useRenderMarkdownAsHTML.ts
+++ b/packages/component/src/hooks/useRenderMarkdownAsHTML.ts
@@ -12,15 +12,15 @@ import styles from './RenderMarkdown.module.css';
const { useLocalizer, useStyleOptions } = hooks;
+type RenderMarkdownAsHTMLFn = (
+ markdown: string,
+ styleOptions?: Readonly,
+ options?: Readonly<{ externalLinkAlt: string }>
+) => string;
+
export default function useRenderMarkdownAsHTML(
mode: 'accessible name' | 'adaptive cards' | 'citation modal' | 'message activity' = 'message activity'
-):
- | ((
- markdown: string,
- styleOptions?: Readonly,
- options?: Readonly<{ externalLinkAlt: string }>
- ) => string)
- | undefined {
+): RenderMarkdownAsHTMLFn | undefined {
const { renderMarkdown } = useWebChatUIContext();
const [styleOptions] = useStyleOptions();
const localize = useLocalizer();
@@ -41,6 +41,8 @@ export default function useRenderMarkdownAsHTML(
[classNames, mode]
);
+ // For 'message activity' mode, streaming rendering is available via useStreamingMarkdownWithDefinitions hook.
+ // This hook still returns a full-render function for backward compatibility and other consumers.
return useMemo(
() =>
renderMarkdown &&
diff --git a/packages/component/src/hooks/useStreamingMarkdownWithDefinitions.ts b/packages/component/src/hooks/useStreamingMarkdownWithDefinitions.ts
new file mode 100644
index 0000000000..d446d3f201
--- /dev/null
+++ b/packages/component/src/hooks/useStreamingMarkdownWithDefinitions.ts
@@ -0,0 +1,188 @@
+import { cx } from '@emotion/css';
+import { hooks } from 'botframework-webchat-api';
+import type { Definition } from 'mdast';
+import { fromMarkdown } from 'mdast-util-from-markdown';
+import { useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useStyles } from '@msinternal/botframework-webchat-styles/react';
+import { useRefFrom } from 'use-ref-from';
+
+import { useTransformHTMLContent } from '../providers/HTMLContentTransformCOR/index';
+import parseDocumentFragmentFromString from '../Utils/parseDocumentFragmentFromString';
+import useWebChatUIContext from './internal/useWebChatUIContext';
+
+import styles from './RenderMarkdown.module.css';
+
+const { useLocalizer, useStyleOptions } = hooks;
+
+type MarkdownLinkDefinition = Readonly<{
+ identifier: string;
+ label?: string;
+ title?: string;
+ url: string;
+}>;
+
+type StreamingNextOptions = Readonly<{
+ container: HTMLElement;
+ containerClassName?: string | undefined;
+ transformFragment?: ((fragment: DocumentFragment) => DocumentFragment) | undefined;
+}>;
+
+type StreamingNextResult = Readonly<{
+ definitions: readonly MarkdownLinkDefinition[];
+}>;
+
+type StreamingRenderer = Readonly<{
+ finalize: (options: StreamingNextOptions) => StreamingNextResult;
+ next: (chunk: string, options: StreamingNextOptions) => void;
+ reset: () => void;
+}>;
+
+const EMPTY_DEFINITIONS: readonly MarkdownLinkDefinition[] = Object.freeze([]);
+
+export default function useStreamingMarkdownWithDefinitions(
+ containerRef: Readonly<{ current: HTMLDivElement | null }>,
+ markdown: string,
+ finalize = false
+): { readonly definitions: readonly MarkdownLinkDefinition[] } {
+ const { renderMarkdown } = useWebChatUIContext();
+ const [styleOptions] = useStyleOptions();
+ const localize = useLocalizer();
+ const transformHTMLContent = useTransformHTMLContent();
+ const styleOptionsRef = useRefFrom(styleOptions);
+
+ const classNames = useStyles(styles);
+
+ const externalLinkAlt = localize('MARKDOWN_EXTERNAL_LINK_ALT');
+
+ const containerClassName = useMemo(
+ () =>
+ cx(classNames['render-markdown'], {
+ [classNames['render-markdown--message-activity']]: true
+ }),
+ [classNames]
+ );
+
+ const hasStreamingSupport = !!renderMarkdown?.createStreamingRenderer;
+
+ const previousMarkdownRef = useRef('');
+
+ const streamingRenderer = useMemo(() => {
+ previousMarkdownRef.current = '';
+
+ if (renderMarkdown?.createStreamingRenderer) {
+ return renderMarkdown.createStreamingRenderer(styleOptionsRef.current, { externalLinkAlt });
+ }
+
+ return undefined;
+ }, [externalLinkAlt, renderMarkdown, styleOptionsRef]);
+
+ const [definitions, setDefinitions] = useState(EMPTY_DEFINITIONS);
+
+ useLayoutEffect(() => {
+ if (!streamingRenderer) {
+ return;
+ }
+
+ const container = containerRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ if (!markdown) {
+ streamingRenderer.reset();
+ previousMarkdownRef.current = '';
+ container.textContent = '';
+
+ return;
+ }
+
+ const prev = previousMarkdownRef.current;
+ const isAppendOnly = !!prev && markdown.startsWith(prev);
+
+ if (!isAppendOnly) {
+ streamingRenderer.reset();
+ }
+
+ const chunk = isAppendOnly ? markdown.slice(prev.length) : markdown;
+ const options: StreamingNextOptions = {
+ container,
+ containerClassName,
+ transformFragment: transformHTMLContent
+ };
+
+ previousMarkdownRef.current = markdown;
+
+ if (finalize) {
+ if (chunk) {
+ streamingRenderer.next(chunk, options);
+ }
+
+ const { definitions } = streamingRenderer.finalize(options);
+
+ setDefinitions(definitions);
+ } else {
+ streamingRenderer.next(chunk, options);
+ }
+ }, [containerClassName, containerRef, finalize, markdown, streamingRenderer, transformHTMLContent]);
+
+ const fallbackHTML = useMemo(() => {
+ if (hasStreamingSupport || !renderMarkdown || !markdown) {
+ return undefined;
+ }
+
+ return renderMarkdown(markdown, styleOptionsRef.current, { externalLinkAlt });
+ }, [externalLinkAlt, hasStreamingSupport, markdown, renderMarkdown, styleOptionsRef]);
+
+ const fallbackDefinitions = useMemo(
+ () =>
+ hasStreamingSupport
+ ? EMPTY_DEFINITIONS
+ : fromMarkdown(markdown).children.filter((node): node is Definition => node.type === 'definition'),
+ [hasStreamingSupport, markdown]
+ );
+
+ useLayoutEffect(() => {
+ if (streamingRenderer || fallbackHTML === undefined) {
+ return;
+ }
+
+ const container = containerRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ const documentFragment = transformHTMLContent(parseDocumentFragmentFromString(fallbackHTML));
+ const wrapper = document.createElement('div');
+
+ wrapper.className = containerClassName || '';
+ wrapper.append(...documentFragment.childNodes);
+
+ container.textContent = '';
+ container.appendChild(wrapper);
+ }, [containerClassName, containerRef, fallbackHTML, streamingRenderer, transformHTMLContent]);
+
+ useLayoutEffect(() => {
+ if (streamingRenderer || fallbackHTML !== undefined) {
+ return;
+ }
+
+ const container = containerRef.current;
+
+ if (!container) {
+ return;
+ }
+
+ container.textContent = '';
+ }, [containerRef, fallbackHTML, streamingRenderer]);
+
+ useLayoutEffect(() => {
+ previousMarkdownRef.current = '';
+ setDefinitions(EMPTY_DEFINITIONS);
+ }, [streamingRenderer]);
+
+ return Object.freeze({ definitions: definitions ?? fallbackDefinitions });
+}
+
+export { type MarkdownLinkDefinition };
diff --git a/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts b/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts
index 07c3d1f9e7..1413f930a1 100644
--- a/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts
+++ b/packages/core/src/reducers/activities/sort/private/computeSortedActivities.ts
@@ -1,4 +1,21 @@
-import type { Activity, State } from '../types';
+import type { Activity, LivestreamSessionMap, State } from '../types';
+
+function* yieldSessionActivities(
+ session: NonNullable>,
+ activityMap: State['activityMap']
+): Generator {
+ if (session.finalized) {
+ // After finalization, only yield the final revision — intermediate revisions are pruned.
+ // eslint-disable-next-line no-magic-numbers
+ const lastEntry = session.activities.at(-1);
+
+ lastEntry && (yield activityMap.get(lastEntry.activityLocalId)!.activity);
+ } else {
+ for (const activityEntry of session.activities) {
+ yield activityMap.get(activityEntry.activityLocalId)!.activity;
+ }
+ }
+}
export default function computeSortedActivities(
temporalState: Pick
@@ -20,17 +37,13 @@ export default function computeSortedActivities(
} else {
howToPartEntry.type satisfies 'livestream session';
- for (const activityEntry of livestreamSessionMap.get(howToPartEntry.livestreamSessionId)!.activities) {
- yield activityMap.get(activityEntry.activityLocalId)!.activity;
- }
+ yield* yieldSessionActivities(livestreamSessionMap.get(howToPartEntry.livestreamSessionId)!, activityMap);
}
}
} else {
sortedEntry.type satisfies 'livestream session';
- for (const activityEntry of livestreamSessionMap.get(sortedEntry.livestreamSessionId)!.activities) {
- yield activityMap.get(activityEntry.activityLocalId)!.activity;
- }
+ yield* yieldSessionActivities(livestreamSessionMap.get(sortedEntry.livestreamSessionId)!, activityMap);
}
}
})()
diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts
index c917d77568..48529c6346 100644
--- a/packages/core/src/reducers/activities/sort/upsert.ts
+++ b/packages/core/src/reducers/activities/sort/upsert.ts
@@ -19,6 +19,13 @@ import {
type State
} from './types';
+// @ts-ignore No @types/core-js-pure
+import { default as toSpliced_ } from 'core-js-pure/features/array/to-spliced.js';
+
+function toSpliced(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] {
+ return toSpliced_(array, start, deleteCount, ...items);
+}
+
// Honoring timestamp or not:
//
// - Update activity
@@ -41,6 +48,8 @@ import {
// - Always copy timestamp, except when it's a livestream of 2...N-1 revision
// - Part grouping timestamp is copied from upserting entry (either livestream session or activity)
+const POSITION_INCREMENT = 1_000;
+
const INITIAL_STATE = Object.freeze({
activityIdToLocalIdMap: Object.freeze(new Map()),
activityMap: Object.freeze(new Map()),
@@ -58,6 +67,139 @@ const INITIAL_STATE = Object.freeze({
// - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort
function upsert(ponyfill: Pick, state: State, activity: Activity): State {
+ const activityLocalId = getLocalIdFromActivity(activity);
+ const logicalTimestamp = getLogicalTimestamp(activity, ponyfill);
+ const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity);
+
+ // #region Streaming fast path
+ //
+ // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping:
+ // skip O(n) Map copies, sortedChatHistoryList recomputation, computeSortedActivities, and
+ // full position sequencing. This turns each streaming revision from O(n) to O(session_revisions).
+ if (activityLivestreamingMetadata) {
+ const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId;
+ const existingSession = state.livestreamSessionMap.get(sessionId);
+ const finalized = activityLivestreamingMetadata.type === 'final activity';
+
+ if (
+ existingSession &&
+ !existingSession.finalized &&
+ !finalized &&
+ !getPartGroupingMetadataMap(activity).has('HowTo')
+ ) {
+ // 1. activityIdToLocalIdMap: +1 entry for activity.id (if present).
+ const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap);
+
+ if (typeof activity.id !== 'undefined') {
+ nextActivityIdToLocalIdMap.set(activity.id, activityLocalId);
+ }
+
+ // 2. activityMap: +1 entry.
+ const nextActivityMap = new Map(state.activityMap);
+
+ nextActivityMap.set(
+ activityLocalId,
+ Object.freeze({ activity, activityLocalId, logicalTimestamp, type: 'activity' as const })
+ );
+
+ // 3. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise.
+ const { clientActivityID } = activity.channelData;
+ let nextClientActivityIdToLocalIdMap = state.clientActivityIdToLocalIdMap;
+
+ if (typeof clientActivityID !== 'undefined') {
+ nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap);
+ nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId);
+ }
+
+ // 4. livestreamSessionMap: append revision to the existing session.
+ // Timestamp is NOT updated for rev 2..N-1 (only for first and final).
+ const nextLivestreamSessionMap = new Map(state.livestreamSessionMap);
+
+ const nextSessionEntry: LivestreamSessionMapEntry = {
+ activities: Object.freeze(
+ insertSorted(
+ existingSession.activities,
+ Object.freeze({
+ activityLocalId,
+ logicalTimestamp,
+ sequenceNumber: activityLivestreamingMetadata.sequenceNumber,
+ type: 'activity'
+ }),
+ ({ sequenceNumber: x }, { sequenceNumber: y }) =>
+ typeof x === 'undefined' || typeof y === 'undefined'
+ ? // eslint-disable-next-line no-magic-numbers
+ -1
+ : x - y
+ )
+ ),
+ finalized: false,
+ logicalTimestamp: existingSession.logicalTimestamp
+ };
+
+ nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry));
+
+ // 5. sortedActivities: insert the new revision into the session's block.
+ // Find where the session's last activity lives in the sorted array and splice after it.
+ // eslint-disable-next-line no-magic-numbers
+ const prevLastSessionActivity = existingSession.activities.at(-1);
+ let insertIndex = state.sortedActivities.length;
+
+ if (prevLastSessionActivity) {
+ for (let i = state.sortedActivities.length - 1; i >= 0; i--) {
+ // eslint-disable-next-line security/detect-object-injection
+ if (getLocalIdFromActivity(state.sortedActivities[i]!) === prevLastSessionActivity.activityLocalId) {
+ insertIndex = i + 1;
+ break;
+ }
+ }
+ }
+
+ // 6. Position: assign the new activity a position based on its neighbors.
+ const prevPosition =
+ insertIndex > 0 ? (queryPositionFromActivity(state.sortedActivities[insertIndex - 1]!) ?? 0) : 0;
+
+ const nextSiblingPosition =
+ insertIndex < state.sortedActivities.length
+ ? queryPositionFromActivity(state.sortedActivities[+insertIndex]!)
+ : undefined;
+
+ let newPosition = prevPosition + POSITION_INCREMENT;
+
+ // Squeeze if the default increment would collide with the next sibling.
+ if (typeof nextSiblingPosition !== 'undefined' && newPosition >= nextSiblingPosition) {
+ newPosition = prevPosition + 1;
+ }
+
+ // If position is valid (no collision), return fast path result.
+ // Otherwise fall through to slow path for full re-sequencing.
+ if (typeof nextSiblingPosition === 'undefined' || newPosition < nextSiblingPosition) {
+ const positionedActivity = setPositionInActivity(activity, newPosition);
+
+ const positionedEntry: ActivityMapEntry = Object.freeze({
+ activity: positionedActivity,
+ activityLocalId,
+ logicalTimestamp,
+ type: 'activity'
+ });
+
+ nextActivityMap.set(activityLocalId, positionedEntry);
+
+ return Object.freeze({
+ activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap),
+ activityMap: Object.freeze(nextActivityMap),
+ clientActivityIdToLocalIdMap: Object.freeze(nextClientActivityIdToLocalIdMap),
+ howToGroupingMap: state.howToGroupingMap,
+ livestreamSessionMap: Object.freeze(nextLivestreamSessionMap),
+ sortedActivities: Object.freeze(toSpliced(state.sortedActivities, insertIndex, 0, positionedActivity)),
+ sortedChatHistoryList: state.sortedChatHistoryList
+ } satisfies State);
+ }
+ }
+ }
+
+ // #endregion
+
+ // Slow path: full recalculation for non-streaming, first/final revisions, reorders, or HowTo grouping.
const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap);
const nextActivityMap = new Map(state.activityMap);
const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap);
@@ -65,9 +207,6 @@ function upsert(ponyfill: Pick, state: State, activ
const nextHowToGroupingMap = new Map(state.howToGroupingMap);
let nextSortedChatHistoryList = Array.from(state.sortedChatHistoryList);
- const activityLocalId = getLocalIdFromActivity(activity);
- const logicalTimestamp = getLogicalTimestamp(activity, ponyfill);
-
if (typeof activity.id !== 'undefined') {
nextActivityIdToLocalIdMap.set(activity.id, activityLocalId);
}
@@ -96,8 +235,6 @@ function upsert(ponyfill: Pick, state: State, activ
// #region Livestreaming
- const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity);
-
if (activityLivestreamingMetadata) {
const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId;
@@ -279,7 +416,6 @@ function upsert(ponyfill: Pick, state: State, activ
// #region Sequence sorted activities
let lastPosition = 0;
- const POSITION_INCREMENT = 1_000;
for (
let index = 0, { length: nextSortedActivitiesLength } = nextSortedActivities;