diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index 7bf86bab8f..431b285ce1 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -1,31 +1,147 @@ -import { ChevronDown, ListTree } from 'lucide-react'; +import { useId, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@librechat/client'; +import { ChevronDown } from 'lucide-react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import { + useExpandCollapse, + scheduleMessageContentLayoutReconcile, + EXPAND_TRANSITION, +} from '~/hooks'; +import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import { getActivityLabelText } from '~/utils/activityLabels'; import { EmptyText } from './Parts'; import Container from './Container'; import { cn } from '~/utils'; +/** Matches `EXPAND_TRANSITION` so the header, the panel, and the card chrome + * all resolve on the same curve — three properties animating on two different + * easings is what makes a fold read as two separate movements. */ +const FOLD_EASING = 'duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none'; + type ActivityPhasePart = Extract & { activity_label_type?: 'phase'; activity_start_index?: number; activity_end_index?: number; }; +/** + * Runs `callback` once the browser has painted the current styles. One frame + * is not enough: React can flush passive effects before paint, and a start + * value the compositor never saw produces an instant jump rather than a + * transition. Returns a canceller for whichever frame is still pending. + */ +function schedulePostPaint(callback: () => void): () => void { + let frameId: number | undefined; + frameId = window.requestAnimationFrame(() => { + frameId = window.requestAnimationFrame(() => { + frameId = undefined; + callback(); + }); + }); + return () => { + if (frameId != null) { + window.cancelAnimationFrame(frameId); + frameId = undefined; + } + }; +} + export default function ActivityPhaseGroup({ labelPart, children, hasContent, showCursor = false, + animateEntrance = false, }: { labelPart: ActivityPhasePart; children: ReactNode; hasContent: boolean; showCursor?: boolean; + animateEntrance?: boolean; }) { const label = getActivityLabelText(labelPart); const hasFailure = labelPart.status === 'failed' || labelPart.status === 'partial'; + const smoothStreaming = useSmoothStreaming(); + /** Capture the marker's arrival state. The parent renderer records the new + * marker after this commit; a later sibling update must not cancel the + * already-scheduled fold before its first animation frame. */ + const [shouldAnimateEntrance] = useState(smoothStreaming && animateEntrance && label.length > 0); + /** A filled phase marker lands on top of activity the reader is already + * looking at. The card therefore mounts in the shape of what was there + * BEFORE it — header at zero height, panel open, chrome transparent — and + * folds into the summary on the next painted frame. Growing the header + * while the panel collapses keeps the block's height strictly decreasing, + * so the content compresses upward instead of being shoved down by a + * header that appeared underneath it and then yanked back up. */ + const foldsIn = shouldAnimateEntrance && hasContent; + const [isExpanded, setIsExpanded] = useState(foldsIn); + const [isSettled, setIsSettled] = useState(!foldsIn); + const rootRef = useRef(null); + const panelId = useId(); + const cancelEntranceRef = useRef<(() => void) | null>(null); + const cancelLayoutReconcileRef = useRef<(() => void) | null>(null); + const previousIsExpandedRef = useRef(isExpanded); + const userOverrideRef = useRef(false); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); + + useEffect(() => { + if (!foldsIn || userOverrideRef.current) { + return; + } + cancelEntranceRef.current = schedulePostPaint(() => { + cancelEntranceRef.current = null; + if (userOverrideRef.current) { + return; + } + setIsSettled(true); + setIsExpanded(false); + }); + return () => { + cancelEntranceRef.current?.(); + cancelEntranceRef.current = null; + }; + }, [foldsIn]); + + useEffect(() => { + const wasExpanded = previousIsExpandedRef.current; + previousIsExpandedRef.current = isExpanded; + if (wasExpanded && !isExpanded) { + cancelLayoutReconcileRef.current?.(); + cancelLayoutReconcileRef.current = scheduleMessageContentLayoutReconcile(rootRef.current); + } + }, [isExpanded]); + + useEffect( + () => () => { + cancelLayoutReconcileRef.current?.(); + }, + [], + ); + + const handleToggle = useCallback(() => { + userOverrideRef.current = true; + cancelEntranceRef.current?.(); + cancelEntranceRef.current = null; + setIsSettled(true); + setIsExpanded((expanded) => !expanded); + }, []); + + /** Only the folding entrance drives the header off its natural height. + * History and reduced-motion render the plain, unstyled row. */ + const headerStyle = useMemo(() => { + if (!foldsIn) { + return undefined; + } + return { + display: 'grid', + gridTemplateRows: isSettled ? '1fr' : '0fr', + transition: EXPAND_TRANSITION, + opacity: isSettled ? 1 : 0, + }; + }, [foldsIn, isSettled]); + const cursor = showCursor ? ( @@ -35,15 +151,16 @@ export default function ActivityPhaseGroup({ return <>{children}; } const group = !hasContent ? ( -
-
+ ); return ( <> diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 7656949a65..6db6f77244 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -1,4 +1,4 @@ -import { memo, useRef, useMemo, useCallback, Fragment } from 'react'; +import { memo, useRef, useMemo, useEffect, useCallback, Fragment } from 'react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, @@ -215,6 +215,37 @@ const ContentParts = memo(function ContentParts({ (localIndex: number) => contentIndices?.[localIndex] ?? localIndex + contentIndexOffset, [contentIndexOffset, contentIndices], ); + /** Hoisted above the early returns to feed the entrance-detection hook + * below, so it is memoized rather than re-walked on every unrelated + * re-render of a message that has no phases at all. */ + const phaseSegments = useMemo( + () => (nestedActivityPhase ? undefined : groupActivityPhases(content)), + [nestedActivityPhase, content], + ); + const completedPhaseIndices = useMemo(() => { + const indices = new Set(); + for (const segment of phaseSegments ?? []) { + if (segment.type === 'phase') { + indices.add(segment.labelIndex); + } + } + return indices; + }, [phaseSegments]); + /** A phase label can finish after the root text stream settles, so + * `isSubmitting` is not a reliable entrance signal. Compare committed + * phase markers instead: a marker that appears after this renderer has + * mounted is live; markers present on the first render are history. + * + * The recorded set is scoped to the message it described. `MultiMessage` + * renders siblings without a key, so this instance survives a sibling + * switch with its refs intact — an unscoped set would report the previous + * sibling's phases and animate the newly selected sibling's history. */ + const previousPhaseRef = useRef<{ messageId: string; indices: Set } | null>(null); + const previousPhaseIndices = + previousPhaseRef.current?.messageId === messageId ? previousPhaseRef.current.indices : null; + useEffect(() => { + previousPhaseRef.current = { messageId, indices: completedPhaseIndices }; + }, [messageId, completedPhaseIndices]); const handleGroupExpansionChange = useCallback( (groupId: string, state: ToolCallGroupExpansionState) => { @@ -452,7 +483,6 @@ const ContentParts = memo(function ContentParts({ ); } - const phaseSegments = nestedActivityPhase ? undefined : groupActivityPhases(content); if (phaseSegments != null) { const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []); const globalLastContentIdx = @@ -500,6 +530,9 @@ const ContentParts = memo(function ContentParts({ key={`activity-phase-${messageId}-${segment.labelIndex}`} labelPart={segment.labelPart} hasContent={segment.hasContent} + animateEntrance={ + previousPhaseIndices != null && !previousPhaseIndices.has(segment.labelIndex) + } showCursor={ isLast && effectiveIsSubmitting && diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 3e636da8ff..61f9a105d5 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -166,7 +166,7 @@ const Part = memo(function Part({ const failed = part.status === 'failed' || part.status === 'partial'; return (
{display}
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index d70a10e0bc..85ea2f778e 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -379,7 +379,7 @@ export default function ToolCallGroup({ true); +const mockScheduleLayoutReconcile = jest.fn((_target: HTMLElement | null) => jest.fn()); + +jest.mock('~/hooks/Messages/useSmoothStreaming', () => ({ + __esModule: true, + default: () => mockUseSmoothStreaming(), +})); + +jest.mock('~/hooks', () => { + const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse'); + return { + useExpandCollapse: expandCollapse.default, + EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION, + scheduleMessageContentLayoutReconcile: (target: HTMLElement | null) => + mockScheduleLayoutReconcile(target), + }; +}); + +const LABEL = 'Compared both release paths'; + const labelPart = { type: ContentTypes.ACTIVITY_LABEL, - [ContentTypes.ACTIVITY_LABEL]: 'Compared both release paths', + [ContentTypes.ACTIVITY_LABEL]: LABEL, activity_label_type: 'phase', activity_start_index: 0, pending: false, } as unknown as Extract; describe('ActivityPhaseGroup', () => { + let frames: Array; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + /** The fold waits for a painted start value, so it spans more than one + * frame. Drain the queue the way a real compositor would. */ + const flushFrames = () => + act(() => { + for (let index = 0; index < frames.length; index += 1) { + const frame = frames[index]; + frames[index] = undefined; + frame?.(index); + } + }); + + const pendingFrames = () => frames.filter((frame) => frame != null).length; + + beforeEach(() => { + frames = []; + mockUseSmoothStreaming.mockReturnValue(true); + mockScheduleLayoutReconcile.mockClear(); + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + writable: true, + value: jest.fn((callback: FrameRequestCallback) => frames.push(callback)), + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + writable: true, + value: jest.fn((handle: number) => { + frames[handle - 1] = undefined; + }), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (originalRequestAnimationFrame == null) { + Reflect.deleteProperty(window, 'requestAnimationFrame'); + } else { + window.requestAnimationFrame = originalRequestAnimationFrame; + } + if (originalCancelAnimationFrame == null) { + Reflect.deleteProperty(window, 'cancelAnimationFrame'); + } else { + window.cancelAnimationFrame = originalCancelAnimationFrame; + } + }); + test('renders a streaming cursor after an active tail phase', () => { const { container } = render( @@ -21,4 +90,106 @@ describe('ActivityPhaseGroup', () => { expect(container.querySelector('.result-thinking')).toBeInTheDocument(); }); + + test('renders the label flush left with no leading glyph', () => { + render( + +
+ , + ); + + const trigger = screen.getByRole('button', { name: LABEL }); + expect(trigger).toHaveClass('justify-start', 'text-left'); + expect(screen.getByText(LABEL)).toHaveClass('flex-1', 'text-left'); + expect(trigger.querySelectorAll('svg')).toHaveLength(1); + }); + + test('mounts in the pre-marker shape, then folds the activity into the summary', () => { + const { container } = render( + +
+ , + ); + + const card = container.querySelector('.my-2') as HTMLElement; + const trigger = screen.getByRole('button', { name: LABEL }); + const header = trigger.parentElement?.parentElement as HTMLElement; + const panel = screen.getByTestId('activity-phase-panel'); + + /** Frame zero must be indistinguishable from the layout the marker + * replaced: no chrome, no header height, activity still open. */ + expect(card).toHaveClass('border-transparent'); + expect(card).not.toHaveClass('bg-surface-secondary/40'); + expect(header).toHaveStyle({ gridTemplateRows: '0fr', opacity: '0' }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(panel).toHaveAttribute('aria-hidden', 'false'); + + flushFrames(); + + expect(header).toHaveStyle({ gridTemplateRows: '1fr', opacity: '1' }); + expect(card).toHaveClass('border-border-light', 'bg-surface-secondary/40'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(panel).toHaveAttribute('aria-hidden', 'true'); + expect(mockScheduleLayoutReconcile).toHaveBeenCalledTimes(1); + }); + + test('keeps historical phases closed without replaying the entrance', () => { + render( + +
+ , + ); + + const trigger = screen.getByRole('button', { name: LABEL }); + const header = trigger.parentElement?.parentElement as HTMLElement; + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(header.getAttribute('style')).toBeNull(); + expect(pendingFrames()).toBe(0); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + test('honors the smooth-streaming preference', () => { + mockUseSmoothStreaming.mockReturnValue(false); + + const { container } = render( + +
+ , + ); + + const trigger = screen.getByRole('button', { name: LABEL }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(container.querySelector('.my-2')).toHaveClass('border-border-light'); + expect(pendingFrames()).toBe(0); + }); + + test('a click during the entrance wins over the scheduled fold', () => { + render( + +
+ , + ); + + const trigger = screen.getByRole('button', { name: LABEL }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + flushFrames(); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + test('a phase without activity renders a label-only header', () => { + render( + +
+ , + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText(LABEL)).toHaveClass('text-left'); + expect(pendingFrames()).toBe(0); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index df52bb72d7..75cc6977e3 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -69,8 +69,20 @@ jest.mock('../ToolCallGroup', () => ({ jest.mock('../ActivityPhaseGroup', () => ({ __esModule: true, - default: ({ children, showCursor }: { children: React.ReactNode; showCursor?: boolean }) => ( -
+ default: ({ + children, + showCursor, + animateEntrance, + }: { + children: React.ReactNode; + showCursor?: boolean; + animateEntrance?: boolean; + }) => ( +
{children}
), @@ -350,6 +362,7 @@ describe('ContentParts — activity phase state', () => { const parent = screen.getByTestId('activity-phase-group'); const finalPart = screen.getByTestId(`real-part-${ContentTypes.TEXT}`); expect(parent.compareDocumentPosition(finalPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(parent).toHaveAttribute('data-animate-entrance', 'false'); expect(finalPart).toHaveAttribute('data-index', '2'); }); @@ -448,6 +461,47 @@ describe('ContentParts — activity phase state', () => { rerender(); expect(screen.getByTestId('activity-phase-group')).toBeInTheDocument(); + expect(screen.getByTestId('activity-phase-group')).toHaveAttribute( + 'data-animate-entrance', + 'true', + ); expect(screen.getByTestId('tool-call-group')).toHaveAttribute('data-initial-expanded', 'false'); }); + + test('does not replay the entrance when switching to a sibling with its own phases', () => { + const phase = (label: string) => + ({ + type: ContentTypes.ACTIVITY_LABEL, + [ContentTypes.ACTIVITY_LABEL]: label, + activity_label_type: 'phase', + activity_start_index: 0, + activity_count: 1, + pending: false, + }) as unknown as TMessageContentParts; + + const tool = { + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { id: 'tool-1', name: 'search', args: {}, output: 'one' }, + } as unknown as TMessageContentParts; + + const { rerender } = render( + , + ); + expect(screen.getByTestId('activity-phase-group')).toHaveAttribute( + 'data-animate-entrance', + 'false', + ); + + /** MultiMessage swaps siblings without a key, so this instance keeps its + * refs while messageId and content change wholesale. The incoming + * sibling's phase is history and must not animate. */ + rerender( + , + ); + + expect(screen.getByTestId('activity-phase-group')).toHaveAttribute( + 'data-animate-entrance', + 'false', + ); + }); }); diff --git a/client/src/hooks/Messages/__tests__/useExpandCollapse.spec.tsx b/client/src/hooks/Messages/__tests__/useExpandCollapse.spec.tsx new file mode 100644 index 0000000000..8025bd45b1 --- /dev/null +++ b/client/src/hooks/Messages/__tests__/useExpandCollapse.spec.tsx @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react'; +import useExpandCollapse, { + EXPAND_TRANSITION, + REDUCED_MOTION_EXPAND_TRANSITION, +} from '../useExpandCollapse'; + +function stubReducedMotion(reduce: boolean) { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: query.includes('prefers-reduced-motion') ? reduce : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })) as unknown as typeof window.matchMedia; +} + +describe('useExpandCollapse', () => { + const original = window.matchMedia; + + afterEach(() => { + window.matchMedia = original; + }); + + test('animates the disclosure by default', () => { + stubReducedMotion(false); + + const { result } = renderHook(() => useExpandCollapse(true)); + + expect(result.current.style.transition).toBe(EXPAND_TRANSITION); + expect(result.current.style.gridTemplateRows).toBe('1fr'); + expect(result.current.style.opacity).toBe(1); + }); + + /** The transition is an inline style, so it cannot carry a media query. + * Without this the panel still folds over 300ms for a reader who asked + * the platform for no motion. */ + test('collapses the duration under prefers-reduced-motion', () => { + stubReducedMotion(true); + + const { result } = renderHook(() => useExpandCollapse(true)); + + expect(result.current.style.transition).toBe(REDUCED_MOTION_EXPAND_TRANSITION); + expect(result.current.style.transition).not.toBe(EXPAND_TRANSITION); + }); + + /** `transition: none` would emit no `transitionend`, and ToolCallGroup + * waits on that event to unmount a collapsed body. Keeping a real (if + * imperceptible) transition preserves that completion signal. */ + test('keeps a transition that still emits transitionend', () => { + stubReducedMotion(true); + + const { result } = renderHook(() => useExpandCollapse(false)); + + expect(result.current.style.transition).not.toBe('none'); + expect(result.current.style.transition).toContain('grid-template-rows'); + expect(result.current.style.transition).toContain('opacity'); + }); + + test('still collapses to a zero row under reduced motion', () => { + stubReducedMotion(true); + + const { result } = renderHook(() => useExpandCollapse(false)); + + expect(result.current.style.gridTemplateRows).toBe('0fr'); + expect(result.current.style.opacity).toBe(0); + }); +}); diff --git a/client/src/hooks/Messages/useExpandCollapse.ts b/client/src/hooks/Messages/useExpandCollapse.ts index dc298c7f6c..f32ffd80c3 100644 --- a/client/src/hooks/Messages/useExpandCollapse.ts +++ b/client/src/hooks/Messages/useExpandCollapse.ts @@ -1,14 +1,32 @@ import { useRef, useLayoutEffect, useMemo } from 'react'; +import { useMediaQuery } from '@librechat/client'; import type { CSSProperties } from 'react'; export const EXPAND_TRANSITION = 'grid-template-rows 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1)'; +/** + * Reduced motion shortens the duration instead of removing the transition. + * `transition: none` emits no `transitionend`, and consumers wait on that + * event to unmount a collapsed subtree — dropping it would leave every closed + * panel's children mounted for exactly the readers who opted out of motion. + * 0.01ms is imperceptible and still fires. + */ +export const REDUCED_MOTION_EXPAND_TRANSITION = + 'grid-template-rows 0.01ms linear, opacity 0.01ms linear'; + +/** + * The disclosure motion is an inline style, so it cannot carry a + * `prefers-reduced-motion` media query the way a utility class can. Resolve + * the preference here instead and drop the transition outright — every + * expanding panel in the message content shares this hook. + */ export default function useExpandCollapse(isExpanded: boolean): { style: CSSProperties; ref: React.RefObject; } { const ref = useRef(null); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); useLayoutEffect(() => { const el = ref.current; @@ -27,10 +45,10 @@ export default function useExpandCollapse(isExpanded: boolean): { () => ({ display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr', - transition: EXPAND_TRANSITION, + transition: reducedMotion ? REDUCED_MOTION_EXPAND_TRANSITION : EXPAND_TRANSITION, opacity: isExpanded ? 1 : 0, }), - [isExpanded], + [isExpanded, reducedMotion], ); return { style, ref }; diff --git a/e2e/specs/mock/activity-phases.spec.ts b/e2e/specs/mock/activity-phases.spec.ts index fe70d04708..23b77f1e4d 100644 --- a/e2e/specs/mock/activity-phases.spec.ts +++ b/e2e/specs/mock/activity-phases.spec.ts @@ -214,7 +214,7 @@ test.describe('parent activity phases', () => { const finalTextIndex = content.findIndex((part) => contentPartText(part).includes(finalText)); expect(finalTextIndex).toBe(phasePart?.activity_end_index); - const parent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`); + const parent = messagesView(page).getByRole('button', { name: PARENT_LABEL, exact: true }); await expect(parent).toBeVisible({ timeout: 30000 }); await expect(messagesView(page).getByText(finalText)).toBeVisible({ timeout: 30000 }); await parent.click(); @@ -241,7 +241,10 @@ test.describe('parent activity phases', () => { ).toBe(true); await page.reload(); - const reloadedParent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`); + const reloadedParent = messagesView(page).getByRole('button', { + name: PARENT_LABEL, + exact: true, + }); await expect(reloadedParent).toBeVisible({ timeout: 30000 }); await expect(messagesView(page).getByText(finalText)).toBeVisible(); await reloadedParent.click(); diff --git a/packages/client/src/hooks/__tests__/useMediaQuery.spec.tsx b/packages/client/src/hooks/__tests__/useMediaQuery.spec.tsx new file mode 100644 index 0000000000..1f775d3396 --- /dev/null +++ b/packages/client/src/hooks/__tests__/useMediaQuery.spec.tsx @@ -0,0 +1,66 @@ +import { renderHook, act } from '@testing-library/react'; +import useMediaQuery from '../useMediaQuery'; + +type Listener = () => void; + +/** The shared setup installs a writable (not configurable) `matchMedia`, so + * these stubs assign over it rather than redefining the property. */ +function stubMatchMedia(matches: boolean) { + const listeners = new Set(); + const media = { + matches, + addEventListener: (_event: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_event: string, listener: Listener) => listeners.delete(listener), + }; + window.matchMedia = jest.fn(() => media) as unknown as typeof window.matchMedia; + return (next: boolean) => { + media.matches = next; + for (const listener of listeners) { + listener(); + } + }; +} + +describe('useMediaQuery', () => { + const original = window.matchMedia; + + afterEach(() => { + window.matchMedia = original; + }); + + /** Callers that branch once at mount — freezing an entrance animation, + * choosing a layout before paint — only ever see the first render. */ + test('reports a match on the first render, before any effect runs', () => { + stubMatchMedia(true); + + const { result } = renderHook(() => useMediaQuery('(prefers-reduced-motion: reduce)')); + + expect(result.current).toBe(true); + }); + + test('reports no match on the first render when the query does not match', () => { + stubMatchMedia(false); + + const { result } = renderHook(() => useMediaQuery('(prefers-reduced-motion: reduce)')); + + expect(result.current).toBe(false); + }); + + test('tracks later changes to the query', () => { + const change = stubMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + expect(result.current).toBe(false); + + act(() => change(true)); + + expect(result.current).toBe(true); + }); + + test('falls back to no match where matchMedia is unavailable', () => { + window.matchMedia = undefined as unknown as typeof window.matchMedia; + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); + + expect(result.current).toBe(false); + }); +}); diff --git a/packages/client/src/hooks/useMediaQuery.tsx b/packages/client/src/hooks/useMediaQuery.tsx index 067474c964..1a17617776 100644 --- a/packages/client/src/hooks/useMediaQuery.tsx +++ b/packages/client/src/hooks/useMediaQuery.tsx @@ -1,9 +1,25 @@ import { useEffect, useState } from 'react'; +function readMatches(query: string): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(query).matches; +} + +/** + * Resolves the query on the FIRST render rather than after a passive effect. + * Callers that branch once at mount — freezing an entrance animation, picking + * a layout before paint — read the deferred value as "no match" and never see + * the correction, which is how `prefers-reduced-motion` came to be ignored. + */ export default function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false); + const [matches, setMatches] = useState(() => readMatches(query)); useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } const media = window.matchMedia(query); if (media.matches !== matches) { setMatches(media.matches);