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