| author | |
| committer | |
| log | d97060709ca7741c0c45d2ffad4e241b992259f2 |
| tree | 1a6be3c4fbd72e7f15eb11becef6e4da017bf2a1 |
| parent | b87b13d41426418c4f42c6477ce62cfe2a43fbae |
| signature | Signed by PGP key B5690EEEBB952194 |
34 files changed, 311 insertions(+), 211 deletions(-)
apps/web/src/browser/BrowserSurfaceSlot.tsx+6-7| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | import { useLayoutEffect, useRef } from "react"; | 3 | import { useLayoutEffect, useRef } from "react"; |
| 4 | 4 | ||
| 5 | import { acquireBrowserSurface } from "./browserSurfaceStore"; | 5 | import { acquireBrowserSurface } from "./browserSurfaceStore"; |
| 6 | import { observeResize } from "../lib/observeResize"; | ||
| 6 | 7 | ||
| 7 | export function BrowserSurfaceSlot(props: { | 8 | export function BrowserSurfaceSlot(props: { |
| 8 | readonly tabId: string; | 9 | readonly tabId: string; |
| ... | @@ -62,22 +63,20 @@ export function BrowserSurfaceSlot(props: { | ... | @@ -62,22 +63,20 @@ export function BrowserSurfaceSlot(props: { |
| 62 | }; | 63 | }; |
| 63 | updateRef.current = update; | 64 | updateRef.current = update; |
| 64 | update(); | 65 | update(); |
| 65 | const observer = new ResizeObserver(update); | 66 | const observed: Element[] = [element]; |
| 66 | observer.observe(element); | ||
| 67 | // Inline panels animate their outer width while keeping the content at | 67 | // Inline panels animate their outer width while keeping the content at |
| 68 | // full width. The slot moves without resizing, so measure on shell resizes too. | 68 | // full width. The slot moves without resizing, so measure on shell resizes too. |
| 69 | const panel = element.closest('[data-preview-panel-mode="inline"]'); | 69 | const panel = element.closest('[data-preview-panel-mode="inline"]'); |
| 70 | if (panel) observer.observe(panel); | 70 | if (panel) observed.push(panel); |
| 71 | // A sidebar opening or closing shifts the whole column, so a slot that keeps | 71 | // A sidebar opening or closing shifts the whole column, so a slot that keeps |
| 72 | // its size and its offset inside the column (the mini player on the left) | 72 | // its size and its offset inside the column (the mini player on the left) |
| 73 | // still moves on screen. The gap element carries the sidebar's width. | 73 | // still moves on screen. The gap element carries the sidebar's width. |
| 74 | for (const gap of document.querySelectorAll('[data-slot="sidebar-gap"]')) { | 74 | observed.push(...document.querySelectorAll('[data-slot="sidebar-gap"]')); |
| 75 | observer.observe(gap); | 75 | const stopObserving = observeResize(observed, update); |
| 76 | } | ||
| 77 | window.addEventListener("resize", update); | 76 | window.addEventListener("resize", update); |
| 78 | window.addEventListener("scroll", update, true); | 77 | window.addEventListener("scroll", update, true); |
| 79 | return () => { | 78 | return () => { |
| 80 | observer.disconnect(); | 79 | stopObserving(); |
| 81 | window.removeEventListener("resize", update); | 80 | window.removeEventListener("resize", update); |
| 82 | window.removeEventListener("scroll", update, true); | 81 | window.removeEventListener("scroll", update, true); |
| 83 | if (updateRef.current === update) updateRef.current = null; | 82 | if (updateRef.current === update) updateRef.current = null; |
apps/web/src/browser/ServerBrowserSurface.tsx+3-3| ... | @@ -41,6 +41,7 @@ import { Input } from "~/components/ui/input"; | ... | @@ -41,6 +41,7 @@ import { Input } from "~/components/ui/input"; |
| 41 | import { toastManager } from "~/components/ui/toast"; | 41 | import { toastManager } from "~/components/ui/toast"; |
| 42 | import { cn } from "~/lib/utils"; | 42 | import { cn } from "~/lib/utils"; |
| 43 | import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; | 43 | import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; |
| 44 | import { observeResize } from "~/lib/observeResize"; | ||
| 44 | 45 | ||
| 45 | /** Chrome-row controls for a server tab; commands require current ownership. */ | 46 | /** Chrome-row controls for a server tab; commands require current ownership. */ |
| 46 | export interface ServerBrowserHandle { | 47 | export interface ServerBrowserHandle { |
| ... | @@ -354,14 +355,13 @@ export function ServerBrowserSurface(props: { | ... | @@ -354,14 +355,13 @@ export function ServerBrowserSurface(props: { |
| 354 | }, | 355 | }, |
| 355 | ); | 356 | ); |
| 356 | }; | 357 | }; |
| 357 | const observer = new ResizeObserver(() => { | 358 | const stopObserving = observeResize(element, () => { |
| 358 | if (timer !== null) clearTimeout(timer); | 359 | if (timer !== null) clearTimeout(timer); |
| 359 | // The first size connects right away; later ones settle before resizing the page. | 360 | // The first size connects right away; later ones settle before resizing the page. |
| 360 | timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS); | 361 | timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS); |
| 361 | }); | 362 | }); |
| 362 | observer.observe(element); | ||
| 363 | return () => { | 363 | return () => { |
| 364 | observer.disconnect(); | 364 | stopObserving(); |
| 365 | if (timer !== null) clearTimeout(timer); | 365 | if (timer !== null) clearTimeout(timer); |
| 366 | }; | 366 | }; |
| 367 | }, [followSize]); | 367 | }, [followSize]); |
apps/web/src/components/AnimatedHeight.tsx+3-3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; | 3 | import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 4 | import { observeResize } from "../lib/observeResize"; | ||
| 4 | 5 | ||
| 5 | const HEIGHT_TRANSITION_FALLBACK_MS = 250; | 6 | const HEIGHT_TRANSITION_FALLBACK_MS = 250; |
| 6 | 7 | ||
| ... | @@ -69,10 +70,9 @@ export function AnimatedHeight({ | ... | @@ -69,10 +70,9 @@ export function AnimatedHeight({ |
| 69 | }; | 70 | }; |
| 70 | 71 | ||
| 71 | updateHeightAfterPaint(); | 72 | updateHeightAfterPaint(); |
| 72 | const resizeObserver = new ResizeObserver(updateHeightAfterPaint); | 73 | const stopObserving = observeResize(element, updateHeightAfterPaint); |
| 73 | resizeObserver.observe(element); | ||
| 74 | return () => { | 74 | return () => { |
| 75 | resizeObserver.disconnect(); | 75 | stopObserving(); |
| 76 | cancelPendingFrames(); | 76 | cancelPendingFrames(); |
| 77 | }; | 77 | }; |
| 78 | }, [holdHeight]); | 78 | }, [holdHeight]); |
apps/web/src/components/BranchToolbar.tsx+3-3| ... | @@ -69,6 +69,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope"; | ... | @@ -69,6 +69,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 69 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; | 69 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; |
| 70 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; | 70 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 71 | import { cn } from "~/lib/utils"; | 71 | import { cn } from "~/lib/utils"; |
| 72 | import { observeResize } from "~/lib/observeResize"; | ||
| 72 | 73 | ||
| 73 | export interface BranchToolbarHandle { | 74 | export interface BranchToolbarHandle { |
| 74 | openBranchPicker: () => void; | 75 | openBranchPicker: () => void; |
| ... | @@ -580,11 +581,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -580,11 +581,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 580 | 581 | ||
| 581 | useEffect(() => { | 582 | useEffect(() => { |
| 582 | if (!element) return; | 583 | if (!element) return; |
| 583 | const observer = new ResizeObserver(measure); | 584 | const stopObserving = observeResize(element, measure); |
| 584 | observer.observe(element); | ||
| 585 | document.fonts.addEventListener("loadingdone", measure); | 585 | document.fonts.addEventListener("loadingdone", measure); |
| 586 | return () => { | 586 | return () => { |
| 587 | observer.disconnect(); | 587 | stopObserving(); |
| 588 | document.fonts.removeEventListener("loadingdone", measure); | 588 | document.fonts.removeEventListener("loadingdone", measure); |
| 589 | }; | 589 | }; |
| 590 | }, [element, measure]); | 590 | }, [element, measure]); |
apps/web/src/components/ChatView.tsx+5-11| ... | @@ -241,7 +241,6 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; | ... | @@ -241,7 +241,6 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 241 | import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; | 241 | import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; |
| 242 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 242 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 243 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 243 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 244 | import { useElementWidth } from "../hooks/useElementWidth"; | ||
| 245 | import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; | 244 | import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; |
| 246 | import { | 245 | import { |
| 247 | RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, | 246 | RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, |
| ... | @@ -606,6 +605,7 @@ import { | ... | @@ -606,6 +605,7 @@ import { |
| 606 | ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | 605 | ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, |
| 607 | recallableComposerPrompt, | 606 | recallableComposerPrompt, |
| 608 | } from "./chat/composerPromptHistory"; | 607 | } from "./chat/composerPromptHistory"; |
| 608 | import { observeResize } from "~/lib/observeResize"; | ||
| 609 | 609 | ||
| 610 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 610 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 611 | const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; | 611 | const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; |
| ... | @@ -1938,7 +1938,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1938,7 +1938,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1938 | useState<Record<string, number>>({}); | 1938 | useState<Record<string, number>>({}); |
| 1939 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | 1939 | const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); |
| 1940 | const isMobileViewport = useMediaQuery("max-sm"); | 1940 | const isMobileViewport = useMediaQuery("max-sm"); |
| 1941 | const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth<HTMLDivElement>(); | 1941 | const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState<HTMLDivElement | null>(null); |
| 1942 | const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(null); | 1942 | const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(null); |
| 1943 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. | 1943 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 1944 | // When set, the thread-change reset effect will open the sidebar instead of closing it. | 1944 | // When set, the thread-change reset effect will open the sidebar instead of closing it. |
| ... | @@ -2255,7 +2255,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2255,7 +2255,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2255 | // Electron hosts its own browser tabs; other clients need the environment to host them. | 2255 | // Electron hosts its own browser tabs; other clients need the environment to host them. |
| 2256 | const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; | 2256 | const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; |
| 2257 | const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { | 2257 | const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { |
| 2258 | containerWidth: workspaceLayoutWidth ?? undefined, | 2258 | container: workspaceLayoutElement, |
| 2259 | widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, | 2259 | widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, |
| 2260 | }); | 2260 | }); |
| 2261 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | 2261 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| ... | @@ -7053,13 +7053,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7053,13 +7053,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7053 | }; | 7053 | }; |
| 7054 | 7054 | ||
| 7055 | updateHeight(); | 7055 | updateHeight(); |
| 7056 | if (typeof ResizeObserver === "undefined") return; | 7056 | return observeResize(composerOverlayElement, updateHeight); |
| 7057 | |||
| 7058 | const resizeObserver = new ResizeObserver(updateHeight); | ||
| 7059 | resizeObserver.observe(composerOverlayElement); | ||
| 7060 | return () => { | ||
| 7061 | resizeObserver.disconnect(); | ||
| 7062 | }; | ||
| 7063 | }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); | 7057 | }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); |
| 7064 | // Swapping the composer for the status bar (or back) changes what the | 7058 | // Swapping the composer for the status bar (or back) changes what the |
| 7065 | // overlay holds, so rebuild the reservation from the new content. | 7059 | // overlay holds, so rebuild the reservation from the new content. |
| ... | @@ -11248,7 +11242,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11248,7 +11242,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11248 | 11242 | ||
| 11249 | return ( | 11243 | return ( |
| 11250 | <div | 11244 | <div |
| 11251 | ref={workspaceLayoutRef} | 11245 | ref={setWorkspaceLayoutElement} |
| 11252 | className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" | 11246 | className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" |
| 11253 | > | 11247 | > |
| 11254 | <Dialog | 11248 | <Dialog |
apps/web/src/components/RightPanelTabs.tsx+6-4| ... | @@ -81,6 +81,7 @@ import { previewBridge } from "./preview/previewBridge"; | ... | @@ -81,6 +81,7 @@ import { previewBridge } from "./preview/previewBridge"; |
| 81 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; | 81 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 82 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; | 82 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 83 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 83 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 84 | import { observeResize } from "~/lib/observeResize"; | ||
| 84 | 85 | ||
| 85 | interface RightPanelTabsProps { | 86 | interface RightPanelTabsProps { |
| 86 | mode: PreviewPanelMode; | 87 | mode: PreviewPanelMode; |
| ... | @@ -1051,14 +1052,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -1051,14 +1052,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 1051 | if (!viewport) return; | 1052 | if (!viewport) return; |
| 1052 | 1053 | ||
| 1053 | const content = viewport.firstElementChild; | 1054 | const content = viewport.firstElementChild; |
| 1054 | const resizeObserver = new ResizeObserver(updateTabScrollState); | 1055 | const stopObserving = observeResize( |
| 1055 | resizeObserver.observe(viewport); | 1056 | content ? [viewport, content] : viewport, |
| 1056 | if (content) resizeObserver.observe(content); | 1057 | updateTabScrollState, |
| 1058 | ); | ||
| 1057 | viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); | 1059 | viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); |
| 1058 | updateTabScrollState(); | 1060 | updateTabScrollState(); |
| 1059 | 1061 | ||
| 1060 | return () => { | 1062 | return () => { |
| 1061 | resizeObserver.disconnect(); | 1063 | stopObserving(); |
| 1062 | viewport.removeEventListener("scroll", updateTabScrollState); | 1064 | viewport.removeEventListener("scroll", updateTabScrollState); |
| 1063 | }; | 1065 | }; |
| 1064 | }, [updateTabScrollState]); | 1066 | }, [updateTabScrollState]); |
apps/web/src/components/chat/AssistantCitationChip.tsx+11-8| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | import { Link, useNavigate } from "@tanstack/react-router"; | 6 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 7 | import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; | 7 | import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; |
| 8 | import { | 8 | import { |
| 9 | useCallback, | ||
| 9 | useEffect, | 10 | useEffect, |
| 10 | useEffectEvent, | 11 | useEffectEvent, |
| 11 | useRef, | 12 | useRef, |
| ... | @@ -30,6 +31,7 @@ import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor | ... | @@ -30,6 +31,7 @@ import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor |
| 30 | import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; | 31 | import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; |
| 31 | import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; | 32 | import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; |
| 32 | import { composerFloatingLayerProps } from "./composerEventScope"; | 33 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 34 | import { observeResize } from "~/lib/observeResize"; | ||
| 33 | 35 | ||
| 34 | export function AssistantCitationChip({ | 36 | export function AssistantCitationChip({ |
| 35 | citation, | 37 | citation, |
| ... | @@ -242,21 +244,22 @@ export function AssistantCitationChip({ | ... | @@ -242,21 +244,22 @@ export function AssistantCitationChip({ |
| 242 | 244 | ||
| 243 | function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { | 245 | function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { |
| 244 | const [fade, setFade] = useState({ top: false, bottom: false }); | 246 | const [fade, setFade] = useState({ top: false, bottom: false }); |
| 245 | const updateFade = (element: HTMLElement) => { | 247 | const updateFade = useCallback((element: HTMLElement) => { |
| 246 | const top = element.scrollTop > 1; | 248 | const top = element.scrollTop > 1; |
| 247 | const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; | 249 | const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; |
| 248 | setFade((current) => | 250 | setFade((current) => |
| 249 | current.top === top && current.bottom === bottom ? current : { top, bottom }, | 251 | current.top === top && current.bottom === bottom ? current : { top, bottom }, |
| 250 | ); | 252 | ); |
| 251 | }; | 253 | }, []); |
| 254 | // Stable so a fade update during resize delivery does not resubscribe the element. | ||
| 255 | const observeFade = useCallback( | ||
| 256 | (element: HTMLDivElement | null) => | ||
| 257 | element ? observeResize(element, () => updateFade(element)) : undefined, | ||
| 258 | [updateFade], | ||
| 259 | ); | ||
| 252 | return ( | 260 | return ( |
| 253 | <div | 261 | <div |
| 254 | ref={(element) => { | 262 | ref={observeFade} |
| 255 | if (!element) return; | ||
| 256 | const observer = new ResizeObserver(() => updateFade(element)); | ||
| 257 | observer.observe(element); | ||
| 258 | return () => observer.disconnect(); | ||
| 259 | }} | ||
| 260 | onScroll={(event) => updateFade(event.currentTarget)} | 263 | onScroll={(event) => updateFade(event.currentTarget)} |
| 261 | className={cn( | 264 | className={cn( |
| 262 | "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", | 265 | "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", |
apps/web/src/components/chat/AssistantCitationSource.test.ts+5-2| ... | @@ -141,6 +141,7 @@ function createSource({ reducedMotion = false } = {}) { | ... | @@ -141,6 +141,7 @@ function createSource({ reducedMotion = false } = {}) { |
| 141 | }; | 141 | }; |
| 142 | const mutationCallbacks = new Set<MutationCallback>(); | 142 | const mutationCallbacks = new Set<MutationCallback>(); |
| 143 | let resize: ResizeObserverCallback = () => {}; | 143 | let resize: ResizeObserverCallback = () => {}; |
| 144 | const resizeEntries: ResizeObserverEntry[] = []; | ||
| 144 | vi.stubGlobal( | 145 | vi.stubGlobal( |
| 145 | "MutationObserver", | 146 | "MutationObserver", |
| 146 | class { | 147 | class { |
| ... | @@ -159,7 +160,9 @@ function createSource({ reducedMotion = false } = {}) { | ... | @@ -159,7 +160,9 @@ function createSource({ reducedMotion = false } = {}) { |
| 159 | constructor(callback: ResizeObserverCallback) { | 160 | constructor(callback: ResizeObserverCallback) { |
| 160 | resize = callback; | 161 | resize = callback; |
| 161 | } | 162 | } |
| 162 | observe() {} | 163 | observe(target: Element) { |
| 164 | resizeEntries.push({ target } as ResizeObserverEntry); | ||
| 165 | } | ||
| 163 | disconnect() { | 166 | disconnect() { |
| 164 | resize = () => {}; | 167 | resize = () => {}; |
| 165 | } | 168 | } |
| ... | @@ -298,7 +301,7 @@ function createSource({ reducedMotion = false } = {}) { | ... | @@ -298,7 +301,7 @@ function createSource({ reducedMotion = false } = {}) { |
| 298 | root.animations.at(-1)?.finish(); | 301 | root.animations.at(-1)?.finish(); |
| 299 | await Promise.resolve(); | 302 | await Promise.resolve(); |
| 300 | }, | 303 | }, |
| 301 | resize: () => resize([], {} as ResizeObserver), | 304 | resize: () => resize(resizeEntries, {} as ResizeObserver), |
| 302 | setMeasured: (value: boolean) => { | 305 | setMeasured: (value: boolean) => { |
| 303 | measured = value; | 306 | measured = value; |
| 304 | }, | 307 | }, |
apps/web/src/components/chat/AssistantCitationSource.tsx+3-4| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | type AssistantCitationSourceAnchor, | 6 | type AssistantCitationSourceAnchor, |
| 7 | } from "~/lib/assistantTextSelection"; | 7 | } from "~/lib/assistantTextSelection"; |
| 8 | import { toastManager } from "../ui/toast"; | 8 | import { toastManager } from "../ui/toast"; |
| 9 | import { observeResize } from "~/lib/observeResize"; | ||
| 9 | 10 | ||
| 10 | const CITATION_PULSE_DURATION_MS = 650; | 11 | const CITATION_PULSE_DURATION_MS = 650; |
| 11 | // The second pulse settles into a held highlight so late glances still find the quote. | 12 | // The second pulse settles into a held highlight so late glances still find the quote. |
| ... | @@ -303,9 +304,7 @@ export function observeAssistantCitationSource({ | ... | @@ -303,9 +304,7 @@ export function observeAssistantCitationSource({ |
| 303 | } | 304 | } |
| 304 | }); | 305 | }); |
| 305 | observer.observe(scrollNode, { childList: true, characterData: true, subtree: true }); | 306 | observer.observe(scrollNode, { childList: true, characterData: true, subtree: true }); |
| 306 | const resizeObserver = new ResizeObserver(schedule); | 307 | const stopObservingResize = observeResize([root, scrollNode], schedule); |
| 307 | resizeObserver.observe(root); | ||
| 308 | resizeObserver.observe(scrollNode); | ||
| 309 | const state = list.getState(); | 308 | const state = list.getState(); |
| 310 | const unsubscribe = [ | 309 | const unsubscribe = [ |
| 311 | state.listenToPosition(itemKey, schedule), | 310 | state.listenToPosition(itemKey, schedule), |
| ... | @@ -334,7 +333,7 @@ export function observeAssistantCitationSource({ | ... | @@ -334,7 +333,7 @@ export function observeAssistantCitationSource({ |
| 334 | pulseAnimation?.cancel(); | 333 | pulseAnimation?.cancel(); |
| 335 | if (frame !== null) cancelAnimationFrame(frame); | 334 | if (frame !== null) cancelAnimationFrame(frame); |
| 336 | observer.disconnect(); | 335 | observer.disconnect(); |
| 337 | resizeObserver.disconnect(); | 336 | stopObservingResize(); |
| 338 | for (const stop of unsubscribe) stop(); | 337 | for (const stop of unsubscribe) stop(); |
| 339 | if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll; | 338 | if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll; |
| 340 | clear(); | 339 | clear(); |
apps/web/src/components/chat/ChatCanvas.tsx+5-7| ... | @@ -7,10 +7,10 @@ import { | ... | @@ -7,10 +7,10 @@ import { |
| 7 | type ComponentProps, | 7 | type ComponentProps, |
| 8 | type CSSProperties, | 8 | type CSSProperties, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | import { flushSync } from "react-dom"; | ||
| 11 | import { ChatCanvasContext } from "./ChatCanvasContext"; | 10 | import { ChatCanvasContext } from "./ChatCanvasContext"; |
| 12 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; | 11 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; |
| 13 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; | 12 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; |
| 13 | import { observeResize } from "../../lib/observeResize"; | ||
| 14 | 14 | ||
| 15 | /** | 15 | /** |
| 16 | * Owns the available conversation space. Cards only report where they sit; the | 16 | * Owns the available conversation space. Cards only report where they sit; the |
| ... | @@ -91,12 +91,10 @@ export function ChatCanvas({ | ... | @@ -91,12 +91,10 @@ export function ChatCanvas({ |
| 91 | ); | 91 | ); |
| 92 | }; | 92 | }; |
| 93 | measure(); | 93 | measure(); |
| 94 | const observer = new ResizeObserver(() => flushSync(measure)); | 94 | const observed: Element[] = [element, probe]; |
| 95 | observer.observe(element); | 95 | if (composerOverlayElement) observed.push(composerOverlayElement); |
| 96 | observer.observe(probe); | 96 | if (timelineElement) observed.push(timelineElement); |
| 97 | if (composerOverlayElement) observer.observe(composerOverlayElement); | 97 | return observeResize(observed, measure); |
| 98 | if (timelineElement) observer.observe(timelineElement); | ||
| 99 | return () => observer.disconnect(); | ||
| 100 | }, [composerOverlayElement, timelineElement]); | 98 | }, [composerOverlayElement, timelineElement]); |
| 101 | const context = useMemo(() => { | 99 | const context = useMemo(() => { |
| 102 | const container = { width: measurements.width, height: measurements.height }; | 100 | const container = { width: measurements.width, height: measurements.height }; |
apps/web/src/components/chat/ChatComposer.tsx+27-38| ... | @@ -340,6 +340,7 @@ import { | ... | @@ -340,6 +340,7 @@ import { |
| 340 | suppressActiveComposerScrollGesture, | 340 | suppressActiveComposerScrollGesture, |
| 341 | } from "./composerScrollGesture"; | 341 | } from "./composerScrollGesture"; |
| 342 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | 342 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 343 | import { observeResize } from "~/lib/observeResize"; | ||
| 343 | 344 | ||
| 344 | function ComposerVideoThumbnail({ file }: { file: File }) { | 345 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 345 | const setVideo = useCallback( | 346 | const setVideo = useCallback( |
| ... | @@ -919,10 +920,10 @@ function useComposerRestingTransition( | ... | @@ -919,10 +920,10 @@ function useComposerRestingTransition( |
| 919 | 920 | ||
| 920 | useLayoutEffect(() => { | 921 | useLayoutEffect(() => { |
| 921 | const element = elementRef.current; | 922 | const element = elementRef.current; |
| 922 | if (!element || typeof ResizeObserver === "undefined") return; | 923 | if (!element) return; |
| 923 | 924 | ||
| 924 | const body = element.querySelector<HTMLElement>('[data-chat-composer-body="true"]'); | 925 | const body = element.querySelector<HTMLElement>('[data-chat-composer-body="true"]'); |
| 925 | const observer = new ResizeObserver((entries) => { | 926 | return observeResize(body ? [element, body] : element, (entries) => { |
| 926 | if (animationRef.current) { | 927 | if (animationRef.current) { |
| 927 | if (body && entries.some((entry) => entry.target === body)) { | 928 | if (body && entries.some((entry) => entry.target === body)) { |
| 928 | transitionToCurrentGeometry(false); | 929 | transitionToCurrentGeometry(false); |
| ... | @@ -959,9 +960,6 @@ function useComposerRestingTransition( | ... | @@ -959,9 +960,6 @@ function useComposerRestingTransition( |
| 959 | ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, | 960 | ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom, |
| 960 | }; | 961 | }; |
| 961 | }); | 962 | }); |
| 962 | observer.observe(element); | ||
| 963 | if (body) observer.observe(body); | ||
| 964 | return () => observer.disconnect(); | ||
| 965 | }, [restingControlsRef, transitionToCurrentGeometry]); | 963 | }, [restingControlsRef, transitionToCurrentGeometry]); |
| 966 | 964 | ||
| 967 | useEffect(() => { | 965 | useEffect(() => { |
| ... | @@ -1038,21 +1036,18 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: | ... | @@ -1038,21 +1036,18 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: |
| 1038 | window.addEventListener("resize", updatePosition); | 1036 | window.addEventListener("resize", updatePosition); |
| 1039 | window.addEventListener("scroll", updatePosition, true); | 1037 | window.addEventListener("scroll", updatePosition, true); |
| 1040 | 1038 | ||
| 1041 | const observer = | 1039 | // The composer is centered and capped at a max width, so opening a side |
| 1042 | typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition); | 1040 | // panel slides it sideways without ever resizing it. Watching the anchor |
| 1043 | if (observer) { | 1041 | // alone would leave the menu behind; the ancestors are what shrink, and |
| 1044 | // The composer is centered and capped at a max width, so opening a side | 1042 | // they resize on every frame of the panel animation. |
| 1045 | // panel slides it sideways without ever resizing it. Watching the anchor | 1043 | const observed: Element[] = [anchor]; |
| 1046 | // alone would leave the menu behind; the ancestors are what shrink, and | 1044 | for (let element = anchor.parentElement; element; element = element.parentElement) { |
| 1047 | // they resize on every frame of the panel animation. | 1045 | observed.push(element); |
| 1048 | observer.observe(anchor); | ||
| 1049 | for (let element = anchor.parentElement; element; element = element.parentElement) { | ||
| 1050 | observer.observe(element); | ||
| 1051 | } | ||
| 1052 | } | 1046 | } |
| 1047 | const stopObserving = observeResize(observed, updatePosition); | ||
| 1053 | 1048 | ||
| 1054 | return () => { | 1049 | return () => { |
| 1055 | observer?.disconnect(); | 1050 | stopObserving(); |
| 1056 | window.removeEventListener("resize", updatePosition); | 1051 | window.removeEventListener("resize", updatePosition); |
| 1057 | window.removeEventListener("scroll", updatePosition, true); | 1052 | window.removeEventListener("scroll", updatePosition, true); |
| 1058 | }; | 1053 | }; |
| ... | @@ -1200,16 +1195,19 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro | ... | @@ -1200,16 +1195,19 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro |
| 1200 | useEffect(() => { | 1195 | useEffect(() => { |
| 1201 | const currentHost = useControlsAsHost ? controls : host; | 1196 | const currentHost = useControlsAsHost ? controls : host; |
| 1202 | if (!currentHost || !controls) return; | 1197 | if (!currentHost || !controls) return; |
| 1203 | const observer = new ResizeObserver(measure); | 1198 | let stopObserving = () => {}; |
| 1204 | const observeControls = () => { | 1199 | const observeControls = () => { |
| 1205 | observer.disconnect(); | 1200 | stopObserving(); |
| 1206 | observer.observe(currentHost); | 1201 | stopObserving = observeResize( |
| 1207 | observer.observe(controls); | 1202 | [ |
| 1208 | controls | 1203 | currentHost, |
| 1209 | .querySelectorAll<HTMLElement>( | 1204 | controls, |
| 1210 | "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", | 1205 | ...controls.querySelectorAll<HTMLElement>( |
| 1211 | ) | 1206 | "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]", |
| 1212 | .forEach((element) => observer.observe(element)); | 1207 | ), |
| 1208 | ], | ||
| 1209 | measure, | ||
| 1210 | ); | ||
| 1213 | measure(); | 1211 | measure(); |
| 1214 | }; | 1212 | }; |
| 1215 | observeControls(); | 1213 | observeControls(); |
| ... | @@ -1217,7 +1215,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro | ... | @@ -1217,7 +1215,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro |
| 1217 | mutations.observe(controls, { childList: true, subtree: true, characterData: true }); | 1215 | mutations.observe(controls, { childList: true, subtree: true, characterData: true }); |
| 1218 | document.fonts.addEventListener("loadingdone", measure); | 1216 | document.fonts.addEventListener("loadingdone", measure); |
| 1219 | return () => { | 1217 | return () => { |
| 1220 | observer.disconnect(); | 1218 | stopObserving(); |
| 1221 | mutations.disconnect(); | 1219 | mutations.disconnect(); |
| 1222 | document.fonts.removeEventListener("loadingdone", measure); | 1220 | document.fonts.removeEventListener("loadingdone", measure); |
| 1223 | }; | 1221 | }; |
| ... | @@ -3589,8 +3587,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3589,8 +3587,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3589 | const initialCompactness = measureFooterCompactness(); | 3587 | const initialCompactness = measureFooterCompactness(); |
| 3590 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | 3588 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 3591 | setIsComposerFooterCompact(initialCompactness.footerCompact); | 3589 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 3592 | if (typeof ResizeObserver === "undefined") return; | 3590 | return observeResize(composerForm, () => { |
| 3593 | const observer = new ResizeObserver(() => { | ||
| 3594 | const nextCompactness = measureFooterCompactness(); | 3591 | const nextCompactness = measureFooterCompactness(); |
| 3595 | setIsComposerPrimaryActionsCompact((previous) => | 3592 | setIsComposerPrimaryActionsCompact((previous) => |
| 3596 | previous === nextCompactness.primaryActionsCompact | 3593 | previous === nextCompactness.primaryActionsCompact |
| ... | @@ -3601,11 +3598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3601,11 +3598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3601 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | 3598 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, |
| 3602 | ); | 3599 | ); |
| 3603 | }); | 3600 | }); |
| 3604 | |||
| 3605 | observer.observe(composerForm); | ||
| 3606 | return () => { | ||
| 3607 | observer.disconnect(); | ||
| 3608 | }; | ||
| 3609 | }, [ | 3601 | }, [ |
| 3610 | activeThreadId, | 3602 | activeThreadId, |
| 3611 | composerFooterActionLayoutKey, | 3603 | composerFooterActionLayoutKey, |
| ... | @@ -5257,10 +5249,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5257,10 +5249,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5257 | if (!isComposerResting || !restingActionsElement) return; | 5249 | if (!isComposerResting || !restingActionsElement) return; |
| 5258 | const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); | 5250 | const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); |
| 5259 | measure(); | 5251 | measure(); |
| 5260 | if (typeof ResizeObserver === "undefined") return; | 5252 | return observeResize(restingActionsElement, measure); |
| 5261 | const observer = new ResizeObserver(measure); | ||
| 5262 | observer.observe(restingActionsElement); | ||
| 5263 | return () => observer.disconnect(); | ||
| 5264 | }, [isComposerResting, restingActionsElement]); | 5253 | }, [isComposerResting, restingActionsElement]); |
| 5265 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( | 5254 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( |
| 5266 | standaloneComposerImages.length, | 5255 | standaloneComposerImages.length, |
apps/web/src/components/chat/ChatHeader.tsx+3-3| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | WorkspaceBreadcrumbSeparator, | 36 | WorkspaceBreadcrumbSeparator, |
| 37 | WorkspaceBreadcrumbText, | 37 | WorkspaceBreadcrumbText, |
| 38 | } from "../WorkspaceBreadcrumb"; | 38 | } from "../WorkspaceBreadcrumb"; |
| 39 | import { observeResize } from "~/lib/observeResize"; | ||
| 39 | import { cn } from "~/lib/utils"; | 40 | import { cn } from "~/lib/utils"; |
| 40 | import { useClientSettings } from "../../hooks/useSettings"; | 41 | import { useClientSettings } from "../../hooks/useSettings"; |
| 41 | 42 | ||
| ... | @@ -154,12 +155,11 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -154,12 +155,11 @@ export const ChatHeader = memo(function ChatHeader({ |
| 154 | measure(); | 155 | measure(); |
| 155 | const frame = requestAnimationFrame(measure); | 156 | const frame = requestAnimationFrame(measure); |
| 156 | document.fonts.addEventListener("loadingdone", measure); | 157 | document.fonts.addEventListener("loadingdone", measure); |
| 157 | const observer = new ResizeObserver(measure); | 158 | const stopObserving = observeResize(list, measure); |
| 158 | observer.observe(list); | ||
| 159 | return () => { | 159 | return () => { |
| 160 | cancelAnimationFrame(frame); | 160 | cancelAnimationFrame(frame); |
| 161 | document.fonts.removeEventListener("loadingdone", measure); | 161 | document.fonts.removeEventListener("loadingdone", measure); |
| 162 | observer.disconnect(); | 162 | stopObserving(); |
| 163 | }; | 163 | }; |
| 164 | }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); | 164 | }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); |
| 165 | const renameCommittedRef = useRef(false); | 165 | const renameCommittedRef = useRef(false); |
apps/web/src/components/chat/ComposerBannerStack.test.tsx+3-3| ... | @@ -36,10 +36,10 @@ it("only offers notice details when the description cannot fit", async () => { | ... | @@ -36,10 +36,10 @@ it("only offers notice details when the description cannot fit", async () => { |
| 36 | vi.stubGlobal( | 36 | vi.stubGlobal( |
| 37 | "ResizeObserver", | 37 | "ResizeObserver", |
| 38 | class { | 38 | class { |
| 39 | constructor(callback: () => void) { | 39 | constructor(private readonly callback: (entries: { target: unknown }[]) => void) {} |
| 40 | resize = callback; | 40 | observe(target: unknown) { |
| 41 | resize = () => this.callback([{ target }]); | ||
| 41 | } | 42 | } |
| 42 | observe() {} | ||
| 43 | disconnect() {} | 43 | disconnect() {} |
| 44 | }, | 44 | }, |
| 45 | ); | 45 | ); |
apps/web/src/components/chat/ComposerBannerStack.tsx+3-3| ... | @@ -5,6 +5,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -5,6 +5,7 @@ import { cn } from "~/lib/utils"; |
| 5 | import { Button } from "../ui/button"; | 5 | import { Button } from "../ui/button"; |
| 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 7 | import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; | 7 | import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; |
| 8 | import { observeResize } from "~/lib/observeResize"; | ||
| 8 | 9 | ||
| 9 | // Match the duration-220 exit transition before removing a dismissed notice. | 10 | // Match the duration-220 exit transition before removing a dismissed notice. |
| 10 | const DISMISS_TRANSITION_MS = 220; | 11 | const DISMISS_TRANSITION_MS = 220; |
| ... | @@ -270,13 +271,12 @@ function NoticeDescription({ children }: { children: ReactNode }) { | ... | @@ -270,13 +271,12 @@ function NoticeDescription({ children }: { children: ReactNode }) { |
| 270 | ); | 271 | ); |
| 271 | }; | 272 | }; |
| 272 | measure(); | 273 | measure(); |
| 273 | const observer = new ResizeObserver(measure); | 274 | const stopObserving = observeResize(description, measure); |
| 274 | observer.observe(description); | ||
| 275 | // A child can reveal new text without resizing its clipped box. | 275 | // A child can reveal new text without resizing its clipped box. |
| 276 | const mutations = new MutationObserver(measure); | 276 | const mutations = new MutationObserver(measure); |
| 277 | mutations.observe(description, { childList: true, subtree: true, characterData: true }); | 277 | mutations.observe(description, { childList: true, subtree: true, characterData: true }); |
| 278 | return () => { | 278 | return () => { |
| 279 | observer.disconnect(); | 279 | stopObserving(); |
| 280 | mutations.disconnect(); | 280 | mutations.disconnect(); |
| 281 | }; | 281 | }; |
| 282 | }, []); | 282 | }, []); |
apps/web/src/components/chat/HtmlRenderFrame.tsx+2-3| ... | @@ -14,6 +14,7 @@ import type { ChatFileAttachment } from "~/types"; | ... | @@ -14,6 +14,7 @@ import type { ChatFileAttachment } from "~/types"; |
| 14 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; | 14 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; |
| 15 | import { Button } from "../ui/button"; | 15 | import { Button } from "../ui/button"; |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 17 | import { observeResize } from "~/lib/observeResize"; | ||
| 17 | 18 | ||
| 18 | // A frame may load its URL a little after mounting. | 19 | // A frame may load its URL a little after mounting. |
| 19 | const MIN_URL_LIFE_MS = 5 * 60_000; | 20 | const MIN_URL_LIFE_MS = 5 * 60_000; |
| ... | @@ -38,11 +39,9 @@ export function HtmlRenderFrame(props: { | ... | @@ -38,11 +39,9 @@ export function HtmlRenderFrame(props: { |
| 38 | const box = boxRef.current; | 39 | const box = boxRef.current; |
| 39 | if (!box) return; | 40 | if (!box) return; |
| 40 | setWidth(box.clientWidth); | 41 | setWidth(box.clientWidth); |
| 41 | const observer = new ResizeObserver(([entry]) => { | 42 | return observeResize(box, ([entry]) => { |
| 42 | if (entry) setWidth(entry.contentRect.width); | 43 | if (entry) setWidth(entry.contentRect.width); |
| 43 | }); | 44 | }); |
| 44 | observer.observe(box); | ||
| 45 | return () => observer.disconnect(); | ||
| 46 | }, []); | 45 | }, []); |
| 47 | // Client fonts can wrap a page taller than the server measured it; a frame | 46 | // Client fonts can wrap a page taller than the server measured it; a frame |
| 48 | // left short would scroll inside the thread and take the reader's scroll. | 47 | // left short would scroll inside the thread and take the reader's scroll. |
apps/web/src/components/chat/McpAppFrame.tsx+2-3| ... | @@ -32,6 +32,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -32,6 +32,7 @@ import { cn } from "~/lib/utils"; |
| 32 | import { useTurnItemDetail } from "~/state/queries"; | 32 | import { useTurnItemDetail } from "~/state/queries"; |
| 33 | import { mcpAppEnvironment } from "~/state/mcpApps"; | 33 | import { mcpAppEnvironment } from "~/state/mcpApps"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | 34 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 35 | import { observeResize } from "~/lib/observeResize"; | ||
| 35 | 36 | ||
| 36 | const commandFailure = (result: { | 37 | const commandFailure = (result: { |
| 37 | readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"]; | 38 | readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"]; |
| ... | @@ -103,11 +104,9 @@ export function McpAppFrame(props: { | ... | @@ -103,11 +104,9 @@ export function McpAppFrame(props: { |
| 103 | const box = boxRef.current; | 104 | const box = boxRef.current; |
| 104 | if (!box) return; | 105 | if (!box) return; |
| 105 | setWidth(box.clientWidth); | 106 | setWidth(box.clientWidth); |
| 106 | const observer = new ResizeObserver(([entry]) => { | 107 | return observeResize(box, ([entry]) => { |
| 107 | if (entry) setWidth(Math.round(entry.contentRect.width)); | 108 | if (entry) setWidth(Math.round(entry.contentRect.width)); |
| 108 | }); | 109 | }); |
| 109 | observer.observe(box); | ||
| 110 | return () => observer.disconnect(); | ||
| 111 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box. | 110 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box. |
| 112 | }, [closed]); | 111 | }, [closed]); |
| 113 | 112 |
apps/web/src/components/chat/MessagesTimeline.tsx+5-7| ... | @@ -58,6 +58,7 @@ import { | ... | @@ -58,6 +58,7 @@ import { |
| 58 | subagentGroupSummary, | 58 | subagentGroupSummary, |
| 59 | summarizeSubagentStatuses, | 59 | summarizeSubagentStatuses, |
| 60 | } from "@t3tools/client-runtime/state/subagent-display"; | 60 | } from "@t3tools/client-runtime/state/subagent-display"; |
| 61 | import { observeResize } from "~/lib/observeResize"; | ||
| 61 | 62 | ||
| 62 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; | 63 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 63 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | 64 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| ... | @@ -1287,12 +1288,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1287,12 +1288,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1287 | 1288 | ||
| 1288 | const frame = requestAnimationFrame(measure); | 1289 | const frame = requestAnimationFrame(measure); |
| 1289 | 1290 | ||
| 1290 | const observer = new ResizeObserver(measure); | 1291 | const stopObserving = observeResize(timelineViewportElement, measure); |
| 1291 | observer.observe(timelineViewportElement); | ||
| 1292 | 1292 | ||
| 1293 | return () => { | 1293 | return () => { |
| 1294 | cancelAnimationFrame(frame); | 1294 | cancelAnimationFrame(frame); |
| 1295 | observer.disconnect(); | 1295 | stopObserving(); |
| 1296 | }; | 1296 | }; |
| 1297 | }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); | 1297 | }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]); |
| 1298 | 1298 | ||
| ... | @@ -3605,10 +3605,8 @@ function ExpandedWorkGroupEntries({ | ... | @@ -3605,10 +3605,8 @@ function ExpandedWorkGroupEntries({ |
| 3605 | const element = listRef.current?.getScrollableNode(); | 3605 | const element = listRef.current?.getScrollableNode(); |
| 3606 | if (!element) return; | 3606 | if (!element) return; |
| 3607 | updateScrollFades(); | 3607 | updateScrollFades(); |
| 3608 | const observer = new ResizeObserver(updateScrollFades); | 3608 | const content = element.firstElementChild; |
| 3609 | observer.observe(element); | 3609 | return observeResize(content ? [element, content] : element, updateScrollFades); |
| 3610 | if (element.firstElementChild) observer.observe(element.firstElementChild); | ||
| 3611 | return () => observer.disconnect(); | ||
| 3612 | }, [updateScrollFades]); | 3610 | }, [updateScrollFades]); |
| 3613 | 3611 | ||
| 3614 | const renderEntry = useCallback( | 3612 | const renderEntry = useCallback( |
apps/web/src/components/chat/ThreadDetailsCard.tsx+2-3| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | resolveThreadDetailsCardDensity, | 10 | resolveThreadDetailsCardDensity, |
| 11 | resolveThreadDetailsCardLayout, | 11 | resolveThreadDetailsCardLayout, |
| 12 | } from "./threadDetailsCardLayout"; | 12 | } from "./threadDetailsCardLayout"; |
| 13 | import { observeResize } from "../../lib/observeResize"; | ||
| 13 | 14 | ||
| 14 | /** One card owns its placement and folds content only when that content cannot fit. */ | 15 | /** One card owns its placement and folds content only when that content cannot fit. */ |
| 15 | export function ThreadDetailsCard({ | 16 | export function ThreadDetailsCard({ |
| ... | @@ -115,9 +116,7 @@ export function ThreadDetailsCard({ | ... | @@ -115,9 +116,7 @@ export function ThreadDetailsCard({ |
| 115 | }); | 116 | }); |
| 116 | }; | 117 | }; |
| 117 | measure(); | 118 | measure(); |
| 118 | const observer = new ResizeObserver(measure); | 119 | return observeResize(element, measure); |
| 119 | observer.observe(element); | ||
| 120 | return () => observer.disconnect(); | ||
| 121 | }, [contentElement, density, measurementKey]); | 120 | }, [contentElement, density, measurementKey]); |
| 122 | const card = ( | 121 | const card = ( |
| 123 | <div | 122 | <div |
apps/web/src/components/chat/useComposerMultilinePrompt.ts+3-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useLayoutEffect, useState } from "react"; | 1 | import { useLayoutEffect, useState } from "react"; |
| 2 | import { observeResize } from "../../lib/observeResize"; | ||
| 2 | 3 | ||
| 3 | export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { | 4 | export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { |
| 4 | const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]'); | 5 | const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]'); |
| ... | @@ -30,14 +31,12 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { | ... | @@ -30,14 +31,12 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { |
| 30 | if (next !== null) setIsMultiline(next); | 31 | if (next !== null) setIsMultiline(next); |
| 31 | }; | 32 | }; |
| 32 | measure(); | 33 | measure(); |
| 33 | const resizeObserver = new ResizeObserver(measure); | ||
| 34 | resizeObserver.observe(body); | ||
| 35 | const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]'); | 34 | const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]'); |
| 36 | if (editor) resizeObserver.observe(editor); | 35 | const stopObserving = observeResize(editor ? [body, editor] : body, measure); |
| 37 | const mutationObserver = new MutationObserver(measure); | 36 | const mutationObserver = new MutationObserver(measure); |
| 38 | mutationObserver.observe(body, { childList: true, characterData: true, subtree: true }); | 37 | mutationObserver.observe(body, { childList: true, characterData: true, subtree: true }); |
| 39 | return () => { | 38 | return () => { |
| 40 | resizeObserver.disconnect(); | 39 | stopObserving(); |
| 41 | mutationObserver.disconnect(); | 40 | mutationObserver.disconnect(); |
| 42 | }; | 41 | }; |
| 43 | }, [body]); | 42 | }, [body]); |
apps/web/src/components/device/DeviceDuoViewport.tsx+3-3| ... | @@ -6,6 +6,7 @@ import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame"; | ... | @@ -6,6 +6,7 @@ import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame"; |
| 6 | import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream"; | 6 | import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream"; |
| 7 | import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control"; | 7 | import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control"; |
| 8 | import { bindPhoneTrackpad } from "./phoneTrackpad"; | 8 | import { bindPhoneTrackpad } from "./phoneTrackpad"; |
| 9 | import { observeResize } from "../../lib/observeResize"; | ||
| 9 | 10 | ||
| 10 | const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer"); | 11 | const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer"); |
| 11 | 12 | ||
| ... | @@ -64,8 +65,7 @@ export function DeviceDuoViewport(props: { | ... | @@ -64,8 +65,7 @@ export function DeviceDuoViewport(props: { |
| 64 | const { width, height } = host.getBoundingClientRect(); | 65 | const { width, height } = host.getBoundingClientRect(); |
| 65 | viewerRef.current?.resize(width, height, window.devicePixelRatio); | 66 | viewerRef.current?.resize(width, height, window.devicePixelRatio); |
| 66 | }; | 67 | }; |
| 67 | const observer = new ResizeObserver(resize); | 68 | const stopObserving = observeResize(host, resize); |
| 68 | observer.observe(host); | ||
| 69 | const blur = () => { | 69 | const blur = () => { |
| 70 | interactionRef.current?.end(); | 70 | interactionRef.current?.end(); |
| 71 | trackpad?.cancel(); | 71 | trackpad?.cancel(); |
| ... | @@ -159,7 +159,7 @@ export function DeviceDuoViewport(props: { | ... | @@ -159,7 +159,7 @@ export function DeviceDuoViewport(props: { |
| 159 | onResetReady(null); | 159 | onResetReady(null); |
| 160 | onFrameListener(null); | 160 | onFrameListener(null); |
| 161 | client.current?.setDuoPanels(null); | 161 | client.current?.setDuoPanels(null); |
| 162 | observer.disconnect(); | 162 | stopObserving(); |
| 163 | window.removeEventListener("blur", blur); | 163 | window.removeEventListener("blur", blur); |
| 164 | viewerRef.current?.dispose(); | 164 | viewerRef.current?.dispose(); |
| 165 | viewerRef.current = null; | 165 | viewerRef.current = null; |
apps/web/src/components/device/DevicePhoneViewport.tsx+3-3| ... | @@ -8,6 +8,7 @@ import type { DeviceShapeProfile } from "@t3tools/client-runtime/device/shape-pr | ... | @@ -8,6 +8,7 @@ import type { DeviceShapeProfile } from "@t3tools/client-runtime/device/shape-pr |
| 8 | import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction"; | 8 | import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction"; |
| 9 | import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream"; | 9 | import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream"; |
| 10 | import { bindPhoneTrackpad } from "./phoneTrackpad"; | 10 | import { bindPhoneTrackpad } from "./phoneTrackpad"; |
| 11 | import { observeResize } from "../../lib/observeResize"; | ||
| 11 | 12 | ||
| 12 | const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer"); | 13 | const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer"); |
| 13 | 14 | ||
| ... | @@ -64,8 +65,7 @@ export function DevicePhoneViewport(props: { | ... | @@ -64,8 +65,7 @@ export function DevicePhoneViewport(props: { |
| 64 | const { width, height } = host.getBoundingClientRect(); | 65 | const { width, height } = host.getBoundingClientRect(); |
| 65 | viewerRef.current?.resize(width, height, window.devicePixelRatio); | 66 | viewerRef.current?.resize(width, height, window.devicePixelRatio); |
| 66 | }; | 67 | }; |
| 67 | const observer = new ResizeObserver(resize); | 68 | const stopObserving = observeResize(host, resize); |
| 68 | observer.observe(host); | ||
| 69 | const blur = () => { | 69 | const blur = () => { |
| 70 | interactionRef.current?.end(); | 70 | interactionRef.current?.end(); |
| 71 | trackpad?.cancel(); | 71 | trackpad?.cancel(); |
| ... | @@ -113,7 +113,7 @@ export function DevicePhoneViewport(props: { | ... | @@ -113,7 +113,7 @@ export function DevicePhoneViewport(props: { |
| 113 | onInputCancel(null); | 113 | onInputCancel(null); |
| 114 | onResetReady(null); | 114 | onResetReady(null); |
| 115 | onFrameListener(null); | 115 | onFrameListener(null); |
| 116 | observer.disconnect(); | 116 | stopObserving(); |
| 117 | window.removeEventListener("blur", blur); | 117 | window.removeEventListener("blur", blur); |
| 118 | viewerRef.current?.dispose(); | 118 | viewerRef.current?.dispose(); |
| 119 | viewerRef.current = null; | 119 | viewerRef.current = null; |
apps/web/src/components/device/DeviceStreamView.tsx+2-3| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | type DeviceStreamClient, | 22 | type DeviceStreamClient, |
| 23 | type DeviceStreamStatus, | 23 | type DeviceStreamStatus, |
| 24 | } from "@t3tools/client-runtime/device/stream"; | 24 | } from "@t3tools/client-runtime/device/stream"; |
| 25 | import { observeResize } from "~/lib/observeResize"; | ||
| 25 | 26 | ||
| 26 | const AX_POLL_INTERVAL_MS = 2_000; | 27 | const AX_POLL_INTERVAL_MS = 2_000; |
| 27 | const CONTROLS_RAIL_WIDTH = 56; | 28 | const CONTROLS_RAIL_WIDTH = 56; |
| ... | @@ -230,9 +231,7 @@ export function DeviceStreamView(props: { | ... | @@ -230,9 +231,7 @@ export function DeviceStreamView(props: { |
| 230 | ); | 231 | ); |
| 231 | }; | 232 | }; |
| 232 | update(); | 233 | update(); |
| 233 | const observer = new ResizeObserver(update); | 234 | return observeResize(element, update); |
| 234 | observer.observe(element); | ||
| 235 | return () => observer.disconnect(); | ||
| 236 | }, []); | 235 | }, []); |
| 237 | const frame = useMemo(() => { | 236 | const frame = useMemo(() => { |
| 238 | return fitDeviceFrame(aspect, host.width, host.height, controlsInset); | 237 | return fitDeviceFrame(aspect, host.width, host.height, controlsInset); |
apps/web/src/components/preview/PreviewView.tsx+2-3| ... | @@ -91,6 +91,7 @@ import { | ... | @@ -91,6 +91,7 @@ import { |
| 91 | } from "~/browser/browserRecording"; | 91 | } from "~/browser/browserRecording"; |
| 92 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 92 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 93 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 93 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 94 | import { observeResize } from "~/lib/observeResize"; | ||
| 94 | 95 | ||
| 95 | interface Props { | 96 | interface Props { |
| 96 | threadRef: ScopedThreadRef; | 97 | threadRef: ScopedThreadRef; |
| ... | @@ -218,11 +219,9 @@ export function PreviewView({ | ... | @@ -218,11 +219,9 @@ export function PreviewView({ |
| 218 | useEffect(() => { | 219 | useEffect(() => { |
| 219 | const element = serverBodyRef.current; | 220 | const element = serverBodyRef.current; |
| 220 | if (!element || !serverToolbarShown) return; | 221 | if (!element || !serverToolbarShown) return; |
| 221 | const observer = new ResizeObserver(([entry]) => | 222 | return observeResize(element, ([entry]) => |
| 222 | setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))), | 223 | setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))), |
| 223 | ); | 224 | ); |
| 224 | observer.observe(element); | ||
| 225 | return () => observer.disconnect(); | ||
| 226 | }, [serverToolbarShown]); | 225 | }, [serverToolbarShown]); |
| 227 | const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null); | 226 | const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null); |
| 228 | const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; | 227 | const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; |
apps/web/src/components/pullRequest/PullRequestCommentBody.tsx+2-3| ... | @@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState, type ComponentProps } from "react"; | ... | @@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState, type ComponentProps } from "react"; |
| 3 | import { cn } from "~/lib/utils"; | 3 | import { cn } from "~/lib/utils"; |
| 4 | import { Button } from "../ui/button"; | 4 | import { Button } from "../ui/button"; |
| 5 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | 5 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 6 | import { observeResize } from "~/lib/observeResize"; | ||
| 6 | 7 | ||
| 7 | /** Keep the complete markdown intact while limiting long reports to a readable preview. */ | 8 | /** Keep the complete markdown intact while limiting long reports to a readable preview. */ |
| 8 | export function PullRequestCommentBody({ | 9 | export function PullRequestCommentBody({ |
| ... | @@ -19,9 +20,7 @@ export function PullRequestCommentBody({ | ... | @@ -19,9 +20,7 @@ export function PullRequestCommentBody({ |
| 19 | if (!element) return; | 20 | if (!element) return; |
| 20 | const measure = () => setOverflowing(element.getBoundingClientRect().height > 240); | 21 | const measure = () => setOverflowing(element.getBoundingClientRect().height > 240); |
| 21 | measure(); | 22 | measure(); |
| 22 | const observer = new ResizeObserver(measure); | 23 | return observeResize(element, measure); |
| 23 | observer.observe(element); | ||
| 24 | return () => observer.disconnect(); | ||
| 25 | }, []); | 24 | }, []); |
| 26 | 25 | ||
| 27 | return ( | 26 | return ( |
apps/web/src/components/sidebar/SidebarChrome.tsx+2-3| ... | @@ -29,6 +29,7 @@ import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | ... | @@ -29,6 +29,7 @@ import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 29 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; | 29 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; |
| 30 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; | 30 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; |
| 31 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 31 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 32 | import { observeResize } from "~/lib/observeResize"; | ||
| 32 | 33 | ||
| 33 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ | 34 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 34 | isElectron, | 35 | isElectron, |
| ... | @@ -85,11 +86,9 @@ export function SidebarBrandWidthProbe({ | ... | @@ -85,11 +86,9 @@ export function SidebarBrandWidthProbe({ |
| 85 | }) { | 86 | }) { |
| 86 | const observeWidth = useCallback( | 87 | const observeWidth = useCallback( |
| 87 | (probe: HTMLDivElement) => { | 88 | (probe: HTMLDivElement) => { |
| 88 | const observer = new ResizeObserver(([entry]) => { | 89 | return observeResize(probe, ([entry]) => { |
| 89 | if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); | 90 | if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); |
| 90 | }); | 91 | }); |
| 91 | observer.observe(probe); | ||
| 92 | return () => observer.disconnect(); | ||
| 93 | }, | 92 | }, |
| 94 | [onWidthChange], | 93 | [onWidthChange], |
| 95 | ); | 94 | ); |
apps/web/src/components/usage/UsageProviderChart.tsx+3-5| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | } from "@t3tools/shared/usageFormat"; | 12 | } from "@t3tools/shared/usageFormat"; |
| 13 | import { cn } from "~/lib/utils"; | 13 | import { cn } from "~/lib/utils"; |
| 14 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; | 14 | import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; |
| 15 | import { observeResize } from "~/lib/observeResize"; | ||
| 15 | 16 | ||
| 16 | const VIEW_WIDTH = 960; | 17 | const VIEW_WIDTH = 960; |
| 17 | const VIEW_HEIGHT = 260; | 18 | const VIEW_HEIGHT = 260; |
| ... | @@ -307,12 +308,9 @@ export function UsageProviderChart({ | ... | @@ -307,12 +308,9 @@ export function UsageProviderChart({ |
| 307 | 308 | ||
| 308 | const plot = plotRef.current; | 309 | const plot = plotRef.current; |
| 309 | const tooltip = tooltipRef.current; | 310 | const tooltip = tooltipRef.current; |
| 310 | if (plot === null || tooltip === null || typeof ResizeObserver === "undefined") return; | 311 | if (plot === null || tooltip === null) return; |
| 311 | 312 | ||
| 312 | const observer = new ResizeObserver(positionTooltip); | 313 | return observeResize([plot, tooltip], positionTooltip); |
| 313 | observer.observe(plot); | ||
| 314 | observer.observe(tooltip); | ||
| 315 | return () => observer.disconnect(); | ||
| 316 | }, [hoverIndex, positionTooltip]); | 314 | }, [hoverIndex, positionTooltip]); |
| 317 | 315 | ||
| 318 | const handleMove = useCallback( | 316 | const handleMove = useCallback( |
apps/web/src/hooks/useElementWidth.ts deleted-25| ... | @@ -1,25 +0,0 @@ | ||
| 1 | import { useLayoutEffect, useState } from "react"; | ||
| 2 | |||
| 3 | export function useElementWidth<T extends HTMLElement>() { | ||
| 4 | const [element, setElement] = useState<T | null>(null); | ||
| 5 | const [width, setWidth] = useState<number | null>(null); | ||
| 6 | |||
| 7 | useLayoutEffect(() => { | ||
| 8 | if (!element) return; | ||
| 9 | |||
| 10 | const update = (nextWidth: number) => { | ||
| 11 | setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)); | ||
| 12 | }; | ||
| 13 | |||
| 14 | update(element.getBoundingClientRect().width); | ||
| 15 | if (typeof ResizeObserver === "undefined") return; | ||
| 16 | |||
| 17 | const observer = new ResizeObserver(([entry]) => { | ||
| 18 | if (entry) update(entry.contentRect.width); | ||
| 19 | }); | ||
| 20 | observer.observe(element); | ||
| 21 | return () => observer.disconnect(); | ||
| 22 | }, [element]); | ||
| 23 | |||
| 24 | return [setElement, width] as const; | ||
| 25 | } | ||
apps/web/src/hooks/usePreviewPanelInlineSize.ts+25-34| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { type RefObject, useEffect, useLayoutEffect, useState } from "react"; | 1 | import { type RefObject, useLayoutEffect, useState } from "react"; |
| 2 | 2 | ||
| 3 | import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth"; | 3 | import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth"; |
| 4 | import { observeResize } from "../lib/observeResize"; | ||
| 4 | 5 | ||
| 5 | export interface PreviewPanelInlineSize { | 6 | export interface PreviewPanelInlineSize { |
| 6 | readonly width: number; | 7 | readonly width: number; |
| ... | @@ -28,15 +29,11 @@ export function usePreviewPanelInlineSize( | ... | @@ -28,15 +29,11 @@ export function usePreviewPanelInlineSize( |
| 28 | readonly enabled?: boolean | undefined; | 29 | readonly enabled?: boolean | undefined; |
| 29 | readonly widthStorageKey?: string | undefined; | 30 | readonly widthStorageKey?: string | undefined; |
| 30 | readonly defaultWidth?: number | undefined; | 31 | readonly defaultWidth?: number | undefined; |
| 31 | /** Use the caller's existing row measurement instead of observing the panel's parent. */ | 32 | /** Measure this row instead of the panel's parent. */ |
| 32 | readonly containerWidth?: number | undefined; | 33 | readonly container?: HTMLElement | null | undefined; |
| 33 | } = {}, | 34 | } = {}, |
| 34 | ): PreviewPanelInlineSize { | 35 | ): PreviewPanelInlineSize { |
| 35 | const maxWidth = useViewportClampedMaxWidth( | 36 | const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); |
| 36 | hostRef, | ||
| 37 | options.enabled ?? true, | ||
| 38 | options.containerWidth, | ||
| 39 | ); | ||
| 40 | return useResizableWidth({ | 37 | return useResizableWidth({ |
| 41 | storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, | 38 | storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, |
| 42 | defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, | 39 | defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, |
| ... | @@ -54,46 +51,40 @@ export function usePreviewPanelInlineSize( | ... | @@ -54,46 +51,40 @@ export function usePreviewPanelInlineSize( |
| 54 | function useViewportClampedMaxWidth( | 51 | function useViewportClampedMaxWidth( |
| 55 | hostRef: RefObject<HTMLElement | null> | undefined, | 52 | hostRef: RefObject<HTMLElement | null> | undefined, |
| 56 | enabled: boolean, | 53 | enabled: boolean, |
| 57 | containerWidth?: number, | 54 | container: HTMLElement | null | undefined, |
| 58 | ): number { | 55 | ): number { |
| 59 | const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); | 56 | const [maxWidth, setMaxWidth] = useState(() => |
| 60 | const [measuredContainerWidth, setContainerWidth] = useState<number | undefined>(undefined); | 57 | getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), |
| 61 | useEffect(() => { | 58 | ); |
| 59 | useLayoutEffect(() => { | ||
| 62 | if (typeof window === "undefined") return; | 60 | if (typeof window === "undefined") return; |
| 61 | const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; | ||
| 62 | // Measure before first paint: the persisted width must be clamped against | ||
| 63 | // the row on the initial render, not one observer tick later (the panel | ||
| 64 | // would flash over-wide on every mount). Only the derived cap is stored: the | ||
| 65 | // row resizes every frame of a sidebar drag, but the cap rarely moves, and an | ||
| 66 | // unchanged cap skips re-rendering the panel's owner. | ||
| 67 | const measure = () => { | ||
| 68 | setMaxWidth(getPreviewPanelMaxWidth(window.innerWidth, row?.clientWidth)); | ||
| 69 | }; | ||
| 70 | measure(); | ||
| 63 | let frame = 0; | 71 | let frame = 0; |
| 64 | const onResize = () => { | 72 | const onResize = () => { |
| 65 | if (frame !== 0) return; | 73 | if (frame !== 0) return; |
| 66 | frame = window.requestAnimationFrame(() => { | 74 | frame = window.requestAnimationFrame(() => { |
| 67 | frame = 0; | 75 | frame = 0; |
| 68 | setVw(window.innerWidth); | 76 | measure(); |
| 69 | }); | 77 | }); |
| 70 | }; | 78 | }; |
| 71 | window.addEventListener("resize", onResize); | 79 | window.addEventListener("resize", onResize); |
| 80 | const stopObserving = row ? observeResize(row, measure) : undefined; | ||
| 72 | return () => { | 81 | return () => { |
| 73 | window.removeEventListener("resize", onResize); | 82 | window.removeEventListener("resize", onResize); |
| 74 | if (frame !== 0) window.cancelAnimationFrame(frame); | 83 | if (frame !== 0) window.cancelAnimationFrame(frame); |
| 84 | stopObserving?.(); | ||
| 75 | }; | 85 | }; |
| 76 | }, []); | 86 | }, [container, hostRef, enabled]); |
| 77 | useLayoutEffect(() => { | 87 | return maxWidth; |
| 78 | if (!enabled) return; | ||
| 79 | const parent = hostRef?.current?.parentElement; | ||
| 80 | if (!parent) return; | ||
| 81 | // Measure before first paint: the persisted width must be clamped against | ||
| 82 | // the row on the initial render, not one observer tick later (the panel | ||
| 83 | // would flash over-wide on every mount). clientWidth is integral, so | ||
| 84 | // sub-pixel resize deltas bail out of re-rendering. | ||
| 85 | const measure = () => { | ||
| 86 | setContainerWidth(parent.clientWidth); | ||
| 87 | }; | ||
| 88 | measure(); | ||
| 89 | if (typeof ResizeObserver === "undefined") return; | ||
| 90 | const observer = new ResizeObserver(measure); | ||
| 91 | observer.observe(parent); | ||
| 92 | return () => { | ||
| 93 | observer.disconnect(); | ||
| 94 | }; | ||
| 95 | }, [hostRef, enabled]); | ||
| 96 | return getPreviewPanelMaxWidth(vw, containerWidth ?? measuredContainerWidth); | ||
| 97 | } | 88 | } |
| 98 | export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { | 89 | export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { |
| 99 | const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); | 90 | const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); |
apps/web/src/lib/observeResize.ts created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | import { flushSync } from "react-dom"; | ||
| 2 | |||
| 3 | type ResizeCallback = (entries: readonly ResizeObserverEntry[]) => void; | ||
| 4 | |||
| 5 | interface Subscription { | ||
| 6 | readonly callback: ResizeCallback; | ||
| 7 | active: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | interface Target { | ||
| 11 | readonly subscriptions: Set<Subscription>; | ||
| 12 | /** Subscribers that joined after the first delivery and still await their initial entry. */ | ||
| 13 | waiting: Set<Subscription> | null; | ||
| 14 | last: ResizeObserverEntry | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | let observer: ResizeObserver | null = null; | ||
| 18 | const targets = new Map<Element, Target>(); | ||
| 19 | |||
| 20 | function deliver(entries: readonly ResizeObserverEntry[]) { | ||
| 21 | const batches = new Map<Subscription, ResizeObserverEntry[]>(); | ||
| 22 | for (const entry of entries) { | ||
| 23 | const target = targets.get(entry.target); | ||
| 24 | if (!target) continue; | ||
| 25 | let recipients = target.subscriptions; | ||
| 26 | if (target.waiting) { | ||
| 27 | // Re-observing for a new subscriber redelivers an unchanged size to everyone else too. | ||
| 28 | const previous = target.last?.contentRect; | ||
| 29 | if ( | ||
| 30 | previous?.width === entry.contentRect.width && | ||
| 31 | previous.height === entry.contentRect.height | ||
| 32 | ) { | ||
| 33 | recipients = target.waiting; | ||
| 34 | } | ||
| 35 | target.waiting = null; | ||
| 36 | } | ||
| 37 | target.last = entry; | ||
| 38 | for (const subscription of recipients) { | ||
| 39 | const batch = batches.get(subscription); | ||
| 40 | if (batch) batch.push(entry); | ||
| 41 | else batches.set(subscription, [entry]); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | if (batches.size === 0) return; | ||
| 45 | flushSync(() => { | ||
| 46 | for (const [subscription, batch] of batches) { | ||
| 47 | if (!subscription.active) continue; | ||
| 48 | try { | ||
| 49 | subscription.callback(batch); | ||
| 50 | } catch (error) { | ||
| 51 | queueMicrotask(() => { | ||
| 52 | throw error; | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | } | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Observes element sizes on one shared ResizeObserver. Its callbacks run after | ||
| 61 | * layout and before paint, but React commits state set there after the paint, | ||
| 62 | * so layout derived from an observed size would land one frame late. Every | ||
| 63 | * resize delivered in a frame runs inside one flushSync, so all size-derived | ||
| 64 | * state commits in a single render before that paint. A callback gets one call | ||
| 65 | * with the entries for all of its targets, starting with an initial one each. | ||
| 66 | */ | ||
| 67 | export function observeResize( | ||
| 68 | elements: Element | readonly Element[], | ||
| 69 | callback: ResizeCallback, | ||
| 70 | ): () => void { | ||
| 71 | if (typeof ResizeObserver === "undefined") return () => {}; | ||
| 72 | const shared = (observer ??= new ResizeObserver(deliver)); | ||
| 73 | const subscription: Subscription = { callback, active: true }; | ||
| 74 | const observed: readonly Element[] = Array.isArray(elements) ? elements : [elements as Element]; | ||
| 75 | for (const element of observed) { | ||
| 76 | const target = targets.get(element); | ||
| 77 | if (!target) { | ||
| 78 | targets.set(element, { subscriptions: new Set([subscription]), waiting: null, last: null }); | ||
| 79 | shared.observe(element); | ||
| 80 | continue; | ||
| 81 | } | ||
| 82 | target.subscriptions.add(subscription); | ||
| 83 | // An initial entry still pending reaches every subscriber; after it, only | ||
| 84 | // observing afresh makes the browser report the current size again. | ||
| 85 | if (!target.last) continue; | ||
| 86 | (target.waiting ??= new Set()).add(subscription); | ||
| 87 | shared.unobserve(element); | ||
| 88 | shared.observe(element); | ||
| 89 | } | ||
| 90 | return () => { | ||
| 91 | if (!subscription.active) return; | ||
| 92 | subscription.active = false; | ||
| 93 | const unobserved = observed.filter((element) => { | ||
| 94 | const target = targets.get(element); | ||
| 95 | if (!target?.subscriptions.delete(subscription)) return false; | ||
| 96 | target.waiting?.delete(subscription); | ||
| 97 | return target.subscriptions.size === 0 && targets.delete(element); | ||
| 98 | }); | ||
| 99 | if (targets.size > 0) { | ||
| 100 | for (const element of unobserved) shared.unobserve(element); | ||
| 101 | } else { | ||
| 102 | // A fresh observer next time also picks up a ResizeObserver a test stubbed since. | ||
| 103 | observer?.disconnect(); | ||
| 104 | observer = null; | ||
| 105 | } | ||
| 106 | }; | ||
| 107 | } | ||
apps/web/src/terminal/ghostty/surface.test.ts+13-6| ... | @@ -81,7 +81,7 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -81,7 +81,7 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 81 | function createHarness() { | 81 | function createHarness() { |
| 82 | vi.useFakeTimers(); | 82 | vi.useFakeTimers(); |
| 83 | const frames = new Map<number, FrameRequestCallback>(); | 83 | const frames = new Map<number, FrameRequestCallback>(); |
| 84 | const resizeCallbacks = new Set<() => void>(); | 84 | const resizeTargets = new Map<(entries: { target: Element }[]) => void, Set<Element>>(); |
| 85 | const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {}); | 85 | const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {}); |
| 86 | let frameId = 0; | 86 | let frameId = 0; |
| 87 | const requestFrame = vi.fn((callback: FrameRequestCallback) => { | 87 | const requestFrame = vi.fn((callback: FrameRequestCallback) => { |
| ... | @@ -169,12 +169,17 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -169,12 +169,17 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 169 | vi.stubGlobal( | 169 | vi.stubGlobal( |
| 170 | "ResizeObserver", | 170 | "ResizeObserver", |
| 171 | class { | 171 | class { |
| 172 | constructor(private readonly callback: () => void) { | 172 | constructor(private readonly callback: (entries: { target: Element }[]) => void) { |
| 173 | resizeCallbacks.add(callback); | 173 | resizeTargets.set(callback, new Set()); |
| 174 | } | ||
| 175 | observe(target: Element) { | ||
| 176 | resizeTargets.get(this.callback)?.add(target); | ||
| 177 | } | ||
| 178 | unobserve(target: Element) { | ||
| 179 | resizeTargets.get(this.callback)?.delete(target); | ||
| 174 | } | 180 | } |
| 175 | observe() {} | ||
| 176 | disconnect() { | 181 | disconnect() { |
| 177 | resizeCallbacks.delete(this.callback); | 182 | resizeTargets.delete(this.callback); |
| 178 | } | 183 | } |
| 179 | }, | 184 | }, |
| 180 | ); | 185 | ); |
| ... | @@ -199,7 +204,9 @@ describe("GhosttyTerminalSurface visibility", () => { | ... | @@ -199,7 +204,9 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 199 | for (const callback of queued) callback(0); | 204 | for (const callback of queued) callback(0); |
| 200 | }, | 205 | }, |
| 201 | resize() { | 206 | resize() { |
| 202 | for (const callback of resizeCallbacks) callback(); | 207 | for (const [callback, targets] of resizeTargets) { |
| 208 | callback([...targets].map((target) => ({ target }))); | ||
| 209 | } | ||
| 203 | }, | 210 | }, |
| 204 | pointer( | 211 | pointer( |
| 205 | type: string, | 212 | type: string, |
apps/web/src/terminal/ghostty/surface.ts+4-4| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | } from "./renderer"; | 16 | } from "./renderer"; |
| 17 | import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; | 17 | import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; |
| 18 | import { isMonospaceFamily } from "../../appearanceFonts"; | 18 | import { isMonospaceFamily } from "../../appearanceFonts"; |
| 19 | import { observeResize } from "../../lib/observeResize"; | ||
| 19 | 20 | ||
| 20 | export const DEFAULT_TERMINAL_FONT_SIZE = 12; | 21 | export const DEFAULT_TERMINAL_FONT_SIZE = 12; |
| 21 | const MIN_TERMINAL_FONT_SIZE = 6; | 22 | const MIN_TERMINAL_FONT_SIZE = 6; |
| ... | @@ -575,7 +576,7 @@ export class GhosttyTerminalSurface { | ... | @@ -575,7 +576,7 @@ export class GhosttyTerminalSurface { |
| 575 | private fontSize: number; | 576 | private fontSize: number; |
| 576 | private fontEpoch = 0; | 577 | private fontEpoch = 0; |
| 577 | private pendingFontEpoch: number | null = null; | 578 | private pendingFontEpoch: number | null = null; |
| 578 | private readonly resizeObserver: ResizeObserver; | 579 | private readonly stopObservingResize: () => void; |
| 579 | private readonly scrollbarThumb: HTMLDivElement; | 580 | private readonly scrollbarThumb: HTMLDivElement; |
| 580 | private snapshot: GhosttySnapshot | null = null; | 581 | private snapshot: GhosttySnapshot | null = null; |
| 581 | private frame = 0; | 582 | private frame = 0; |
| ... | @@ -666,12 +667,11 @@ export class GhosttyTerminalSurface { | ... | @@ -666,12 +667,11 @@ export class GhosttyTerminalSurface { |
| 666 | this.fontFamily = fontFamily; | 667 | this.fontFamily = fontFamily; |
| 667 | this.requestedFontFamily = options.font?.family; | 668 | this.requestedFontFamily = options.font?.family; |
| 668 | this.fontSize = terminalFontSize(options.font?.size); | 669 | this.fontSize = terminalFontSize(options.font?.size); |
| 669 | this.resizeObserver = new ResizeObserver(() => this.fit()); | ||
| 670 | this.installEvents(); | 670 | this.installEvents(); |
| 671 | this.watchDevicePixelRatio(); | 671 | this.watchDevicePixelRatio(); |
| 672 | this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); | 672 | this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); |
| 673 | document.fonts.addEventListener("loadingdone", this.onFontsLoaded); | 673 | document.fonts.addEventListener("loadingdone", this.onFontsLoaded); |
| 674 | this.resizeObserver.observe(mount); | 674 | this.stopObservingResize = observeResize(mount, () => this.fit()); |
| 675 | } | 675 | } |
| 676 | 676 | ||
| 677 | static async create( | 677 | static async create( |
| ... | @@ -1058,7 +1058,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1058,7 +1058,7 @@ export class GhosttyTerminalSurface { |
| 1058 | dispose(): void { | 1058 | dispose(): void { |
| 1059 | if (this.disposed) return; | 1059 | if (this.disposed) return; |
| 1060 | this.disposed = true; | 1060 | this.disposed = true; |
| 1061 | this.resizeObserver.disconnect(); | 1061 | this.stopObservingResize(); |
| 1062 | document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); | 1062 | document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); |
| 1063 | this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); | 1063 | this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); |
| 1064 | this.dprMedia = null; | 1064 | this.dprMedia = null; |
oxlint-plugin-t3code/index.ts+2| ... | @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e | ... | @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e |
| 9 | import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; | 9 | import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; |
| 10 | import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; | 10 | import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; |
| 11 | import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; | 11 | import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; |
| 12 | import noRawResizeObserver from "./rules/no-raw-resize-observer.ts"; | ||
| 12 | import noTestInLoop from "./rules/no-test-in-loop.ts"; | 13 | import noTestInLoop from "./rules/no-test-in-loop.ts"; |
| 13 | import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; | 14 | import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; |
| 14 | import noUnscopedHas from "./rules/no-unscoped-has.ts"; | 15 | import noUnscopedHas from "./rules/no-unscoped-has.ts"; |
| ... | @@ -30,6 +31,7 @@ export default definePlugin({ | ... | @@ -30,6 +31,7 @@ export default definePlugin({ |
| 30 | "no-native-title-tooltip": noNativeTitleTooltip, | 31 | "no-native-title-tooltip": noNativeTitleTooltip, |
| 31 | "no-outset-state-indicators": noOutsetStateIndicators, | 32 | "no-outset-state-indicators": noOutsetStateIndicators, |
| 32 | "no-raw-mcp-registration": noRawMcpRegistration, | 33 | "no-raw-mcp-registration": noRawMcpRegistration, |
| 34 | "no-raw-resize-observer": noRawResizeObserver, | ||
| 33 | "no-test-in-loop": noTestInLoop, | 35 | "no-test-in-loop": noTestInLoop, |
| 34 | "no-rpc-permission-bypass": noRpcPermissionBypass, | 36 | "no-rpc-permission-bypass": noRpcPermissionBypass, |
| 35 | "no-unscoped-has": noUnscopedHas, | 37 | "no-unscoped-has": noUnscopedHas, |
oxlint-plugin-t3code/rules/no-raw-resize-observer.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { defineRule, type ESTree } from "@oxlint/plugins"; | ||
| 2 | |||
| 3 | const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self"]); | ||
| 4 | |||
| 5 | function isResizeObserverConstructor(node: ESTree.Node): boolean { | ||
| 6 | if (node.type === "Identifier") return node.name === "ResizeObserver"; | ||
| 7 | if (node.type !== "MemberExpression" || node.object.type !== "Identifier") return false; | ||
| 8 | if (!GLOBAL_OBJECTS.has(node.object.name)) return false; | ||
| 9 | const { property } = node; | ||
| 10 | if (!node.computed && property.type === "Identifier") return property.name === "ResizeObserver"; | ||
| 11 | return property.type === "Literal" && property.value === "ResizeObserver"; | ||
| 12 | } | ||
| 13 | |||
| 14 | export default defineRule({ | ||
| 15 | meta: { | ||
| 16 | type: "problem", | ||
| 17 | docs: { | ||
| 18 | description: "Disallow constructing ResizeObserver directly in web code; use observeResize.", | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | create(context) { | ||
| 22 | return { | ||
| 23 | NewExpression(node) { | ||
| 24 | if (!isResizeObserverConstructor(node.callee)) return; | ||
| 25 | context.report({ | ||
| 26 | node: node.callee, | ||
| 27 | message: | ||
| 28 | "Use observeResize from ~/lib/observeResize. React commits state set in a raw ResizeObserver callback after the paint, so layout derived from the observed size lands one frame late.", | ||
| 29 | }); | ||
| 30 | }, | ||
| 31 | }; | ||
| 32 | }, | ||
| 33 | }); | ||
vite.config.ts+10| ... | @@ -250,6 +250,16 @@ export default defineConfig({ | ... | @@ -250,6 +250,16 @@ export default defineConfig({ |
| 250 | files: ["apps/mobile/src/**"], | 250 | files: ["apps/mobile/src/**"], |
| 251 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, | 251 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, |
| 252 | }, | 252 | }, |
| 253 | { | ||
| 254 | // React commits state set in a raw ResizeObserver callback after the paint, so derived | ||
| 255 | // layout lands a frame late. observeResize flushes every observed resize in one render. | ||
| 256 | files: ["apps/web/src/**"], | ||
| 257 | excludeFiles: [ | ||
| 258 | "apps/web/src/lib/observeResize.ts", | ||
| 259 | "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", | ||
| 260 | ], | ||
| 261 | rules: { "t3code/no-raw-resize-observer": "error" }, | ||
| 262 | }, | ||
| 253 | { | 263 | { |
| 254 | // Every class in web code must be one Tailwind generates: a typo or a class nothing | 264 | // Every class in web code must be one Tailwind generates: a typo or a class nothing |
| 255 | // declares ships silently unstyled. JS hooks use data attributes, not class names. | 265 | // declares ships silently unstyled. JS hooks use data attributes, not class names. |