diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 7394a2a3f3..5b79102ffe 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -21,7 +21,7 @@ import { TreeItem } from './treeItem'; import { CopyToClipboard } from './copyToClipboard'; import './links.css'; import { linkifyText } from '@web/renderUtils'; -import { clsx } from '@web/uiUtils'; +import { clsx, useFlash } from '@web/uiUtils'; export function navigate(href: string | URL) { window.history.pushState({}, '', href); @@ -73,8 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, result, href, linkName, openInNewTab }) => { - const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); - const searchParams = React.useContext(SearchParamsContext); + const [flash, triggerFlash] = useFlash(); + useAnchor('attachment-' + result.attachments.indexOf(attachment), triggerFlash); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} @@ -85,7 +85,7 @@ export const AttachmentLink: React.FunctionComponent<{ )} } loadChildren={attachment.body ? () => { return [
{linkifyText(attachment.body!)}
]; - } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={isAnchored ? searchParams : undefined}>
; + } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={flash}>; }; export const SearchParamsContext = React.createContext(new URLSearchParams(window.location.hash.slice(1))); @@ -119,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing'; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; -export function useAnchor(id: AnchorID, onReveal: () => void) { +export function useAnchor(id: AnchorID, onReveal: React.EffectCallback) { const searchParams = React.useContext(SearchParamsContext); const isAnchored = useIsAnchored(id); React.useEffect(() => { if (isAnchored) - onReveal(); + return onReveal(); }, [isAnchored, onReveal, searchParams]); } diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 5372a71347..7ae9b840f8 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import './treeItem.css'; import * as icons from './icons'; -import { clsx, useFlash } from '@web/uiUtils'; +import { clsx } from '@web/uiUtils'; export const TreeItem: React.FunctionComponent<{ title: JSX.Element, @@ -26,11 +26,10 @@ export const TreeItem: React.FunctionComponent<{ expandByDefault?: boolean, depth: number, style?: React.CSSProperties, - flash?: any + flash?: boolean }> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => { - const addFlashClass = useFlash(flash); const [expanded, setExpanded] = React.useState(expandByDefault || false); - return
+ return
{ onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index f6c0dc2d04..818e180ff4 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -37,16 +37,18 @@ const ExpandableAttachment: React.FunctionComponent = const [expanded, setExpanded] = React.useState(false); const [attachmentText, setAttachmentText] = React.useState(null); const [placeholder, setPlaceholder] = React.useState(null); + const [flash, triggerFlash] = useFlash(); const ref = React.useRef(null); const isTextAttachment = isTextualMimeType(attachment.contentType); const hasContent = !!attachment.sha1 || !!attachment.path; React.useEffect(() => { - if (reveal) + if (reveal) { ref.current?.scrollIntoView({ behavior: 'smooth' }); - }, [reveal]); - const flash = useFlash(reveal); + return triggerFlash(); + } + }, [reveal, triggerFlash]); React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index fe24828b97..c1ba6799b7 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -14,6 +14,7 @@ limitations under the License. */ +import type { EffectCallback } from 'react'; import React from 'react'; // Recalculates the value when dependencies change. @@ -225,15 +226,21 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) { const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); -// flash is retriggered whenever the value changes -export function useFlash(flash: any | undefined) { - const [flashState, setFlashState] = React.useState(false); - React.useEffect(() => { - if (flash) { - setFlashState(true); - const timeout = setTimeout(() => setFlashState(false), 1000); - return () => clearTimeout(timeout); - } - }, [flash]); - return flashState; +export function useFlash(): [boolean, EffectCallback] { + const [flash, setFlash] = React.useState(false); + const trigger = React.useCallback(() => { + let timeout: number | undefined; + setFlash(currentlyFlashing => { + if (!currentlyFlashing) { + timeout = setTimeout(() => setFlash(false), 1000) as any; + return true; + } + + // It's already flashing, so we remove the class and re-add it after 50ms to trigger the animation again. + timeout = setTimeout(() => setFlash(true), 50) as any; + return false; + }); + return () => clearTimeout(timeout); + }, [setFlash]); + return [flash, trigger]; }