Skip to content
Closed
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
6 changes: 4 additions & 2 deletions src/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { memo, useCallback, useState } from 'react';

import { PreviewGroup } from '@/Image';

Expand Down Expand Up @@ -48,7 +48,8 @@ const Markdown = memo<MarkdownProps>((props) => {
...rest
} = props;

const delayedAnimated = useDelayedAnimated(animated);
const [streamAnimationDelayMs, setStreamAnimationDelayMs] = useState(1000);
const delayedAnimated = useDelayedAnimated(animated, streamAnimationDelayMs);

const Render = useCallback(
({
Expand Down Expand Up @@ -89,6 +90,7 @@ const Markdown = memo<MarkdownProps>((props) => {
enableLatex={enableLatex}
enableMermaid={enableMermaid}
fullFeaturedCodeBlock={fullFeaturedCodeBlock}
onStreamAnimationDelayChange={setStreamAnimationDelayMs}
rehypePlugins={rehypePlugins}
rehypePluginsAhead={rehypePluginsAhead}
remarkPlugins={remarkPlugins}
Expand Down
63 changes: 60 additions & 3 deletions src/Markdown/SyntaxMarkdown/StreamdownRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ import { useSmoothStreamContent } from './useSmoothStreamContent';
import { type BlockInfo, useStreamQueue } from './useStreamQueue';

const STREAM_FADE_DURATION = 280;
const STREAM_EXIT_BUFFER = 120;
const STREAM_EXIT_DELAY_MAX = 4000;
const STREAM_EXIT_DELAY_MIN = 400;
const STREAM_SPEED_DELAY_MAX = 36;
const STREAM_SPEED_DELAY_MIN = 6;

function countChars(text: string): number {
return [...text].length;
}

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

Expand Down Expand Up @@ -102,13 +111,13 @@ const StreamdownBlock = memo<Options>(
StreamdownBlock.displayName = 'StreamdownBlock';

export const StreamdownRender = memo<Options>(({ children, ...rest }) => {
const { streamSmoothingPreset = 'balanced' } = useMarkdownContext();
const { onStreamAnimationDelayChange, streamSmoothingPreset = 'balanced' } = useMarkdownContext();
const escapedContent = useMarkdownContent(children || '');
const components = useMarkdownComponents();
const baseRehypePlugins = useStablePlugins(useMarkdownRehypePlugins());
const remarkPlugins = useStablePlugins(useMarkdownRemarkPlugins());
const generatedId = useId();
const smoothedContent = useSmoothStreamContent(
const { content: smoothedContent, metrics } = useSmoothStreamContent(
typeof escapedContent === 'string' ? escapedContent : '',
{ preset: streamSmoothingPreset },
);
Expand All @@ -127,10 +136,16 @@ export const StreamdownRender = memo<Options>(({ children, ...rest }) => {
});
}, [processedContent]);

const { getBlockState, charDelay } = useStreamQueue(blocks);
const preferredCharDelay = clamp(
1000 / Math.max(metrics.displayCps, 1),
STREAM_SPEED_DELAY_MIN,
STREAM_SPEED_DELAY_MAX,
);
const { getBlockState, charDelay } = useStreamQueue(blocks, { preferredCharDelay });
const prevBlockCharCountRef = useRef<Map<number, number>>(new Map());
const blockTimelineRef = useRef<Map<number, number>>(new Map());
const lastRenderTsRef = useRef<number | null>(null);
const lastReportedExitDelayRef = useRef<number | null>(null);

const renderTs = typeof performance === 'undefined' ? Date.now() : performance.now();
const frameDt =
Expand Down Expand Up @@ -173,6 +188,48 @@ export const StreamdownRender = memo<Options>(({ children, ...rest }) => {
lastRenderTsRef.current = typeof performance === 'undefined' ? Date.now() : performance.now();
}, [blocks, timelineForRender]);

useEffect(() => {
if (!onStreamAnimationDelayChange) return;

let maxRemainingAnimationMs = 0;

for (const block of blocks) {
const blockCharCount = countChars(block.content);
if (blockCharCount === 0) continue;

const latestCharStart = Math.max(0, (blockCharCount - 1) * charDelay);
const elapsed = timelineForRender.get(block.startOffset) ?? 0;
maxRemainingAnimationMs = Math.max(
maxRemainingAnimationMs,
latestCharStart + STREAM_FADE_DURATION - elapsed,
);
}

const smoothingTailMs =
metrics.backlogChars > 0
? (metrics.backlogChars * 1000) / Math.max(metrics.displayCps, 1)
: 0;
const nextExitDelay =
Math.round(
clamp(
maxRemainingAnimationMs + smoothingTailMs + STREAM_EXIT_BUFFER,
STREAM_EXIT_DELAY_MIN,
STREAM_EXIT_DELAY_MAX,
) / 50,
) * 50;

if (lastReportedExitDelayRef.current === nextExitDelay) return;
lastReportedExitDelayRef.current = nextExitDelay;
onStreamAnimationDelayChange(nextExitDelay);
}, [
blocks,
charDelay,
metrics.backlogChars,
metrics.displayCps,
onStreamAnimationDelayChange,
timelineForRender,
]);

return (
<div className={styles.animated}>
{blocks.map((block, index) => {
Expand Down
26 changes: 24 additions & 2 deletions src/Markdown/SyntaxMarkdown/useSmoothStreamContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ interface UseSmoothStreamContentOptions {
preset?: StreamSmoothingPreset;
}

export interface SmoothStreamMetrics {
arrivalCps: number;
backlogChars: number;
displayCps: number;
inputActive: boolean;
}

export const useSmoothStreamContent = (
content: string,
{ enabled = true, preset = 'balanced' }: UseSmoothStreamContentOptions = {},
): string => {
): { content: string; metrics: SmoothStreamMetrics } => {
const config = PRESET_CONFIG[preset];
const [displayedContent, setDisplayedContent] = useState(content);

Expand All @@ -98,6 +105,8 @@ export const useSmoothStreamContent = (
const lastInputCountRef = useRef(targetCountRef.current);
const chunkSizeEmaRef = useRef(1);
const arrivalCpsEmaRef = useRef(config.defaultCps);
const displayCpsRef = useRef(config.defaultCps);
const inputActiveRef = useRef(false);

const rafRef = useRef<number | null>(null);
const lastFrameTsRef = useRef<number | null>(null);
Expand Down Expand Up @@ -128,6 +137,8 @@ export const useSmoothStreamContent = (
emaCpsRef.current = config.defaultCps;
chunkSizeEmaRef.current = 1;
arrivalCpsEmaRef.current = config.defaultCps;
displayCpsRef.current = config.defaultCps;
inputActiveRef.current = false;
lastInputTsRef.current = now;
lastInputCountRef.current = chars.length;
},
Expand Down Expand Up @@ -160,6 +171,7 @@ export const useSmoothStreamContent = (
const idleMs = now - lastInputTsRef.current;
const inputActive = idleMs <= config.activeInputWindowMs;
const settling = !inputActive && idleMs >= config.settleAfterMs;
inputActiveRef.current = inputActive;

const baseCps = clamp(emaCpsRef.current, config.minCps, config.maxCps);
const baseLagChars = Math.max(1, Math.round((baseCps * config.targetBufferMs) / 1000));
Expand Down Expand Up @@ -201,6 +213,7 @@ export const useSmoothStreamContent = (
);
currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps);
}
displayCpsRef.current = currentCps;

const urgentBacklog = inputActive && targetLagChars > 0 && backlog > targetLagChars * 2.2;
const burstyInput = inputActive && chunkSizeEmaRef.current >= targetLagChars * 0.9;
Expand Down Expand Up @@ -279,6 +292,7 @@ export const useSmoothStreamContent = (
targetContentRef.current = content;
targetCharsRef.current = [...targetCharsRef.current, ...appendedChars];
targetCountRef.current += appendedCount;
inputActiveRef.current = true;

const deltaChars = targetCountRef.current - lastInputCountRef.current;
const deltaMs = Math.max(1, now - lastInputTsRef.current);
Expand Down Expand Up @@ -319,5 +333,13 @@ export const useSmoothStreamContent = (
};
}, [stopFrameLoop]);

return displayedContent;
return {
content: displayedContent,
metrics: {
arrivalCps: arrivalCpsEmaRef.current,
backlogChars: Math.max(0, targetCountRef.current - displayedCountRef.current),
displayCps: displayCpsRef.current,
inputActive: inputActiveRef.current,
},
};
};
23 changes: 23 additions & 0 deletions src/Markdown/SyntaxMarkdown/useStreamQueue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { useStreamQueue } from './useStreamQueue';

describe('useStreamQueue', () => {
it('accelerates the active block when the preferred char delay drops', () => {
const blocks = [{ content: 'streaming block', startOffset: 0 }];

const { result, rerender } = renderHook(
({ preferredCharDelay }) => useStreamQueue(blocks, { preferredCharDelay }),
{ initialProps: { preferredCharDelay: 24 } },
);

expect(result.current.charDelay).toBe(24);

rerender({ preferredCharDelay: 8 });
expect(result.current.charDelay).toBe(8);

rerender({ preferredCharDelay: 30 });
expect(result.current.charDelay).toBe(8);
});
});
37 changes: 31 additions & 6 deletions src/Markdown/SyntaxMarkdown/useStreamQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,26 @@ const BASE_DELAY = 18;
const ACCELERATION_FACTOR = 0.3;
const MAX_BLOCK_DURATION = 3000;
const FADE_DURATION = 280;
const MAX_DELAY = 36;
const MIN_DELAY = 6;

function countChars(text: string): number {
return [...text].length;
}

function computeCharDelay(queueLength: number, charCount: number): number {
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

function computeCharDelay(
queueLength: number,
charCount: number,
preferredDelay = BASE_DELAY,
): number {
const acceleration = 1 + queueLength * ACCELERATION_FACTOR;
let delay = BASE_DELAY / acceleration;
let delay = preferredDelay / acceleration;
delay = Math.min(delay, MAX_BLOCK_DURATION / Math.max(charCount, 1));
return delay;
return clamp(delay, MIN_DELAY, MAX_DELAY);
}

export interface UseStreamQueueReturn {
Expand All @@ -29,7 +39,14 @@ export interface UseStreamQueueReturn {
queueLength: number;
}

export function useStreamQueue(blocks: BlockInfo[]): UseStreamQueueReturn {
interface UseStreamQueueOptions {
preferredCharDelay?: number;
}

export function useStreamQueue(
blocks: BlockInfo[],
{ preferredCharDelay = BASE_DELAY }: UseStreamQueueOptions = {},
): UseStreamQueueReturn {
const [revealedCount, setRevealedCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevBlocksLenRef = useRef(0);
Expand Down Expand Up @@ -87,13 +104,21 @@ export function useStreamQueue(blocks: BlockInfo[]): UseStreamQueueReturn {

// Freeze charDelay when entering a new active block (animating or streaming)
const frozenRef = useRef({ delay: BASE_DELAY, index: -1 });
const nextDelay = computeCharDelay(queueLength, activeCharCount, preferredCharDelay);
if (activeIndex >= 0 && activeIndex !== frozenRef.current.index) {
frozenRef.current = {
delay: computeCharDelay(queueLength, activeCharCount),
delay: nextDelay,
index: activeIndex,
};
} else if (activeIndex >= 0) {
// Allow in-flight blocks to accelerate with the upstream stream rate
// without regressing already-started character progress.
frozenRef.current = {
delay: Math.min(frozenRef.current.delay, nextDelay),
index: activeIndex,
Comment on lines +116 to 118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Base animation timeout on remaining progress

Updating frozenRef.current.delay for the current active block makes charDelay change in-flight, but the timeout that advances revealedCount is still recreated from the full block duration each time (setTimeout is recomputed from (animatingCharCount - 1) * charDelay). During bursts where preferred delay keeps changing, this repeatedly restarts completion from “now” instead of using elapsed progress, so a block can stay animating longer than intended and subsequent blocks remain queued/hidden until updates settle.

Useful? React with 👍 / 👎.

};
}
const charDelay = activeIndex >= 0 ? frozenRef.current.delay : BASE_DELAY;
const charDelay = activeIndex >= 0 ? frozenRef.current.delay : nextDelay;

const onAnimationDone = useCallback(() => {
setRevealedCount(effectiveRevealedCount + 1);
Expand Down
7 changes: 6 additions & 1 deletion src/Markdown/components/MarkdownProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { createContext, memo, type PropsWithChildren, use } from 'react';

import { type SyntaxMarkdownProps } from '../type';

export type MarkdownContentConfig = Omit<SyntaxMarkdownProps, 'children' | 'reactMarkdownProps'>;
export interface MarkdownContentConfig extends Omit<
SyntaxMarkdownProps,
'children' | 'reactMarkdownProps'
> {
onStreamAnimationDelayChange?: (delayMs: number) => void;
}

export const MarkdownContext = createContext<MarkdownContentConfig>({});

Expand Down
40 changes: 40 additions & 0 deletions src/Markdown/components/useDelayedAnimated.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useDelayedAnimated } from './useDelayedAnimated';

const Demo = ({ animated, delayMs }: { animated?: boolean; delayMs?: number }) => {
const value = useDelayedAnimated(animated, delayMs);

return <div data-testid="value">{String(value)}</div>;
};

describe('useDelayedAnimated', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('keeps animation enabled until the configured delay completes', () => {
const { rerender } = render(<Demo animated delayMs={1600} />);

expect(screen.getByTestId('value').textContent).toBe('true');

rerender(<Demo animated={false} delayMs={1600} />);
expect(screen.getByTestId('value').textContent).toBe('true');

act(() => {
vi.advanceTimersByTime(1599);
});
expect(screen.getByTestId('value').textContent).toBe('true');

act(() => {
vi.advanceTimersByTime(1);
});
expect(screen.getByTestId('value').textContent).toBe('false');
});
});
8 changes: 4 additions & 4 deletions src/Markdown/components/useDelayedAnimated.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { useEffect, useState } from 'react';

export const useDelayedAnimated = (animated?: boolean) => {
export const useDelayedAnimated = (animated?: boolean, delayMs = 1000) => {
const [delayedAnimated, setDelayedAnimated] = useState(animated);

// Watch for changes in animated prop
useEffect(() => {
if (animated === undefined) return;
// If animated changes from true to false, delay the update by 1 second
// Keep stream rendering alive long enough for the tail animation to finish.
if (animated === false && delayedAnimated === true) {
const timer = setTimeout(() => {
setDelayedAnimated(false);
}, 1000);
}, delayMs);

return () => clearTimeout(timer);
} else {
// For any other changes, update immediately
setDelayedAnimated(animated);
}
}, [animated, delayedAnimated]);
}, [animated, delayedAnimated, delayMs]);

return delayedAnimated;
};
Loading