authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 22:51:35-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 22:51:35-03:00
logd97060709ca7741c0c45d2ffad4e241b992259f2
tree1a6be3c4fbd72e7f15eb11becef6e4da017bf2a1
parentb87b13d41426418c4f42c6477ce62cfe2a43fbae
signature Signed by PGP key B5690EEEBB952194

fix(web): every resize-driven layout commits in the same frame (#17656)


34 files changed, 311 insertions(+), 211 deletions(-)

apps/web/src/browser/BrowserSurfaceSlot.tsx+6-7
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3import { useLayoutEffect, useRef } from "react";3import { useLayoutEffect, useRef } from "react";
44
5import { acquireBrowserSurface } from "./browserSurfaceStore";5import { acquireBrowserSurface } from "./browserSurfaceStore";
6import { observeResize } from "../lib/observeResize";
67
7export function BrowserSurfaceSlot(props: {8export 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 at67 // 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 keeps71 // 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";
41import { toastManager } from "~/components/ui/toast";41import { toastManager } from "~/components/ui/toast";
42import { cn } from "~/lib/utils";42import { cn } from "~/lib/utils";
43import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream";43import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream";
44import { observeResize } from "~/lib/observeResize";
4445
45/** Chrome-row controls for a server tab; commands require current ownership. */46/** Chrome-row controls for a server tab; commands require current ownership. */
46export interface ServerBrowserHandle {47export 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";
22
3import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";3import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
4import { observeResize } from "../lib/observeResize";
45
5const HEIGHT_TRANSITION_FALLBACK_MS = 250;6const HEIGHT_TRANSITION_FALLBACK_MS = 250;
67
...@@ -69,10 +70,9 @@ export function AnimatedHeight({...@@ -69,10 +70,9 @@ export function AnimatedHeight({
69 };70 };
7071
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";
69import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";69import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
70import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";70import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
71import { cn } from "~/lib/utils";71import { cn } from "~/lib/utils";
72import { observeResize } from "~/lib/observeResize";
7273
73export interface BranchToolbarHandle {74export 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 {
580581
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";
241import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";241import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
242import { useMediaQuery } from "../hooks/useMediaQuery";242import { useMediaQuery } from "../hooks/useMediaQuery";
243import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";243import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
244import { useElementWidth } from "../hooks/useElementWidth";
245import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize";244import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize";
246import {245import {
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";
608import { observeResize } from "~/lib/observeResize";
609609
610const EMPTY_PROVIDERS: ServerProvider[] = [];610const EMPTY_PROVIDERS: ServerProvider[] = [];
611const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];611const 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 };
70547054
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 the7058 // 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) {
1124811242
11249 return (11243 return (
11250 <div11244 <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 <Dialog11248 <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";
81import { PierreEntryIcon } from "./chat/PierreEntryIcon";81import { PierreEntryIcon } from "./chat/PierreEntryIcon";
82import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";82import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
83import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";83import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
84import { observeResize } from "~/lib/observeResize";
8485
85interface RightPanelTabsProps {86interface 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;
10521053
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();
10591061
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 {
6import { Link, useNavigate } from "@tanstack/react-router";6import { Link, useNavigate } from "@tanstack/react-router";
7import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";7import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
8import {8import {
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
30import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";31import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
31import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";32import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
32import { composerFloatingLayerProps } from "./composerEventScope";33import { composerFloatingLayerProps } from "./composerEventScope";
34import { observeResize } from "~/lib/observeResize";
3335
34export function AssistantCitationChip({36export function AssistantCitationChip({
35 citation,37 citation,
...@@ -242,21 +244,22 @@ export function AssistantCitationChip({...@@ -242,21 +244,22 @@ export function AssistantCitationChip({
242244
243function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) {245function 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 <div261 <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";
8import { toastManager } from "../ui/toast";8import { toastManager } from "../ui/toast";
9import { observeResize } from "~/lib/observeResize";
910
10const CITATION_PULSE_DURATION_MS = 650;11const 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";
10import { flushSync } from "react-dom";
11import { ChatCanvasContext } from "./ChatCanvasContext";10import { ChatCanvasContext } from "./ChatCanvasContext";
12import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";11import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
13import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";12import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
13import { observeResize } from "../../lib/observeResize";
1414
15/**15/**
16 * Owns the available conversation space. Cards only report where they sit; the16 * 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";
342import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";342import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
343import { observeResize } from "~/lib/observeResize";
343344
344function ComposerVideoThumbnail({ file }: { file: File }) {345function ComposerVideoThumbnail({ file }: { file: File }) {
345 const setVideo = useCallback(346 const setVideo = useCallback(
...@@ -919,10 +920,10 @@ function useComposerRestingTransition(...@@ -919,10 +920,10 @@ function useComposerRestingTransition(
919920
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;
923924
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]);
966964
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);
10401038
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 side1042 // they resize on every frame of the panel animation.
1045 // panel slides it sideways without ever resizing it. Watching the anchor1043 const observed: Element[] = [anchor];
1046 // alone would leave the menu behind; the ancestors are what shrink, and1044 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);
10531048
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 controls1203 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.primaryActionsCompact3593 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";
39import { observeResize } from "~/lib/observeResize";
39import { cn } from "~/lib/utils";40import { cn } from "~/lib/utils";
40import { useClientSettings } from "../../hooks/useSettings";41import { useClientSettings } from "../../hooks/useSettings";
4142
...@@ -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";
5import { Button } from "../ui/button";5import { Button } from "../ui/button";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";7import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
8import { observeResize } from "~/lib/observeResize";
89
9// Match the duration-220 exit transition before removing a dismissed notice.10// Match the duration-220 exit transition before removing a dismissed notice.
10const DISMISS_TRANSITION_MS = 220;11const 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";
14import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";14import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
15import { Button } from "../ui/button";15import { Button } from "../ui/button";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { observeResize } from "~/lib/observeResize";
1718
18// A frame may load its URL a little after mounting.19// A frame may load its URL a little after mounting.
19const MIN_URL_LIFE_MS = 5 * 60_000;20const 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 frame46 // 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";
32import { useTurnItemDetail } from "~/state/queries";32import { useTurnItemDetail } from "~/state/queries";
33import { mcpAppEnvironment } from "~/state/mcpApps";33import { mcpAppEnvironment } from "~/state/mcpApps";
34import { useAtomCommand } from "~/state/use-atom-command";34import { useAtomCommand } from "~/state/use-atom-command";
35import { observeResize } from "~/lib/observeResize";
3536
36const commandFailure = (result: {37const 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]);
113112
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";
61import { observeResize } from "~/lib/observeResize";
6162
62const NOOP_USE_ARTIFACT_TEMPLATE = () => {};63const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
63const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};64const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
...@@ -1287,12 +1288,11 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1287,12 +1288,11 @@ const ConversationTimeline = memo(function ConversationTimeline({
12871288
1288 const frame = requestAnimationFrame(measure);1289 const frame = requestAnimationFrame(measure);
12891290
1290 const observer = new ResizeObserver(measure);1291 const stopObserving = observeResize(timelineViewportElement, measure);
1291 observer.observe(timelineViewportElement);
12921292
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]);
12981298
...@@ -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]);
36133611
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";
13import { observeResize } from "../../lib/observeResize";
1314
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. */
15export function ThreadDetailsCard({16export 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 <div122 <div
apps/web/src/components/chat/useComposerMultilinePrompt.ts+3-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useLayoutEffect, useState } from "react";1import { useLayoutEffect, useState } from "react";
2import { observeResize } from "../../lib/observeResize";
23
3export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null {4export 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";
6import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";6import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
7import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control";7import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control";
8import { bindPhoneTrackpad } from "./phoneTrackpad";8import { bindPhoneTrackpad } from "./phoneTrackpad";
9import { observeResize } from "../../lib/observeResize";
910
10const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");11const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");
1112
...@@ -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
8import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";8import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
9import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";9import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
10import { bindPhoneTrackpad } from "./phoneTrackpad";10import { bindPhoneTrackpad } from "./phoneTrackpad";
11import { observeResize } from "../../lib/observeResize";
1112
12const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");13const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");
1314
...@@ -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";
25import { observeResize } from "~/lib/observeResize";
2526
26const AX_POLL_INTERVAL_MS = 2_000;27const AX_POLL_INTERVAL_MS = 2_000;
27const CONTROLS_RAIL_WIDTH = 56;28const 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";
92import { stackedThreadToast, toastManager } from "~/components/ui/toast";92import { stackedThreadToast, toastManager } from "~/components/ui/toast";
93import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";93import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
94import { observeResize } from "~/lib/observeResize";
9495
95interface Props {96interface 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";
3import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
4import { Button } from "../ui/button";4import { Button } from "../ui/button";
5import { PullRequestMarkdown } from "./PullRequestMarkdown";5import { PullRequestMarkdown } from "./PullRequestMarkdown";
6import { observeResize } from "~/lib/observeResize";
67
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. */
8export function PullRequestCommentBody({9export 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 }, []);
2625
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";
29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
32import { observeResize } from "~/lib/observeResize";
3233
33export const SidebarChromeHeader = memo(function SidebarChromeHeader({34export 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";
13import { cn } from "~/lib/utils";13import { cn } from "~/lib/utils";
14import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";14import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
15import { observeResize } from "~/lib/observeResize";
1516
16const VIEW_WIDTH = 960;17const VIEW_WIDTH = 960;
17const VIEW_HEIGHT = 260;18const VIEW_HEIGHT = 260;
...@@ -307,12 +308,9 @@ export function UsageProviderChart({...@@ -307,12 +308,9 @@ export function UsageProviderChart({
307308
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;
311312
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]);
317315
318 const handleMove = useCallback(316 const handleMove = useCallback(
apps/web/src/hooks/useElementWidth.ts deleted-25
...@@ -1,25 +0,0 @@
1import { useLayoutEffect, useState } from "react";
2
3export 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 @@
1import { type RefObject, useEffect, useLayoutEffect, useState } from "react";1import { type RefObject, useLayoutEffect, useState } from "react";
22
3import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth";3import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth";
4import { observeResize } from "../lib/observeResize";
45
5export interface PreviewPanelInlineSize {6export 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(
54function useViewportClampedMaxWidth(51function 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}
98export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number {89export 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 @@
1import { flushSync } from "react-dom";
2
3type ResizeCallback = (entries: readonly ResizeObserverEntry[]) => void;
4
5interface Subscription {
6 readonly callback: ResizeCallback;
7 active: boolean;
8}
9
10interface 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
17let observer: ResizeObserver | null = null;
18const targets = new Map<Element, Target>();
19
20function 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 */
67export 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";
17import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";17import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
18import { isMonospaceFamily } from "../../appearanceFonts";18import { isMonospaceFamily } from "../../appearanceFonts";
19import { observeResize } from "../../lib/observeResize";
1920
20export const DEFAULT_TERMINAL_FONT_SIZE = 12;21export const DEFAULT_TERMINAL_FONT_SIZE = 12;
21const MIN_TERMINAL_FONT_SIZE = 6;22const 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 }
676676
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
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts";10import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts";
11import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";11import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
12import noRawResizeObserver from "./rules/no-raw-resize-observer.ts";
12import noTestInLoop from "./rules/no-test-in-loop.ts";13import noTestInLoop from "./rules/no-test-in-loop.ts";
13import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";14import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
14import noUnscopedHas from "./rules/no-unscoped-has.ts";15import 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 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self"]);
4
5function 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
14export 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 nothing264 // 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.