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 @@
33import { useLayoutEffect, useRef } from "react";
44
55import { acquireBrowserSurface } from "./browserSurfaceStore";
6import { observeResize } from "../lib/observeResize";
67
78export function BrowserSurfaceSlot(props: {
89 readonly tabId: string;
......@@ -62,22 +63,20 @@ export function BrowserSurfaceSlot(props: {
6263 };
6364 updateRef.current = update;
6465 update();
65 const observer = new ResizeObserver(update);
66 observer.observe(element);
66 const observed: Element[] = [element];
6767 // Inline panels animate their outer width while keeping the content at
6868 // full width. The slot moves without resizing, so measure on shell resizes too.
6969 const panel = element.closest('[data-preview-panel-mode="inline"]');
70 if (panel) observer.observe(panel);
70 if (panel) observed.push(panel);
7171 // A sidebar opening or closing shifts the whole column, so a slot that keeps
7272 // its size and its offset inside the column (the mini player on the left)
7373 // 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);
7776 window.addEventListener("resize", update);
7877 window.addEventListener("scroll", update, true);
7978 return () => {
80 observer.disconnect();
79 stopObserving();
8180 window.removeEventListener("resize", update);
8281 window.removeEventListener("scroll", update, true);
8382 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";
4141import { toastManager } from "~/components/ui/toast";
4242import { cn } from "~/lib/utils";
4343import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream";
44import { observeResize } from "~/lib/observeResize";
4445
4546/** Chrome-row controls for a server tab; commands require current ownership. */
4647export interface ServerBrowserHandle {
......@@ -354,14 +355,13 @@ export function ServerBrowserSurface(props: {
354355 },
355356 );
356357 };
357 const observer = new ResizeObserver(() => {
358 const stopObserving = observeResize(element, () => {
358359 if (timer !== null) clearTimeout(timer);
359360 // The first size connects right away; later ones settle before resizing the page.
360361 timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS);
361362 });
362 observer.observe(element);
363363 return () => {
364 observer.disconnect();
364 stopObserving();
365365 if (timer !== null) clearTimeout(timer);
366366 };
367367 }, [followSize]);
apps/web/src/components/AnimatedHeight.tsx+3-3
......@@ -1,6 +1,7 @@
11"use client";
22
33import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
4import { observeResize } from "../lib/observeResize";
45
56const HEIGHT_TRANSITION_FALLBACK_MS = 250;
67
......@@ -69,10 +70,9 @@ export function AnimatedHeight({
6970 };
7071
7172 updateHeightAfterPaint();
72 const resizeObserver = new ResizeObserver(updateHeightAfterPaint);
73 resizeObserver.observe(element);
73 const stopObserving = observeResize(element, updateHeightAfterPaint);
7474 return () => {
75 resizeObserver.disconnect();
75 stopObserving();
7676 cancelPendingFrames();
7777 };
7878 }, [holdHeight]);
apps/web/src/components/BranchToolbar.tsx+3-3
......@@ -69,6 +69,7 @@ import { useComposerMenuProps } from "./chat/composerEventScope";
6969import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
7070import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
7171import { cn } from "~/lib/utils";
72import { observeResize } from "~/lib/observeResize";
7273
7374export interface BranchToolbarHandle {
7475 openBranchPicker: () => void;
......@@ -580,11 +581,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
580581
581582 useEffect(() => {
582583 if (!element) return;
583 const observer = new ResizeObserver(measure);
584 observer.observe(element);
584 const stopObserving = observeResize(element, measure);
585585 document.fonts.addEventListener("loadingdone", measure);
586586 return () => {
587 observer.disconnect();
587 stopObserving();
588588 document.fonts.removeEventListener("loadingdone", measure);
589589 };
590590 }, [element, measure]);
apps/web/src/components/ChatView.tsx+5-11
......@@ -241,7 +241,6 @@ import { isCommandPaletteOpen } from "../commandPaletteBus";
241241import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
242242import { useMediaQuery } from "../hooks/useMediaQuery";
243243import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
244import { useElementWidth } from "../hooks/useElementWidth";
245244import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize";
246245import {
247246 RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY,
......@@ -606,6 +605,7 @@ import {
606605 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
607606 recallableComposerPrompt,
608607} from "./chat/composerPromptHistory";
608import { observeResize } from "~/lib/observeResize";
609609
610610const EMPTY_PROVIDERS: ServerProvider[] = [];
611611const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];
......@@ -1938,7 +1938,7 @@ export default function ChatView(props: ChatViewProps) {
19381938 useState<Record<string, number>>({});
19391939 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
19401940 const isMobileViewport = useMediaQuery("max-sm");
1941 const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth<HTMLDivElement>();
1941 const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState<HTMLDivElement | null>(null);
19421942 const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(null);
19431943 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
19441944 // 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) {
22552255 // Electron hosts its own browser tabs; other clients need the environment to host them.
22562256 const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser;
22572257 const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
2258 containerWidth: workspaceLayoutWidth ?? undefined,
2258 container: workspaceLayoutElement,
22592259 widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
22602260 });
22612261 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
......@@ -7053,13 +7053,7 @@ export default function ChatView(props: ChatViewProps) {
70537053 };
70547054
70557055 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);
70637057 }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);
70647058 // Swapping the composer for the status bar (or back) changes what the
70657059 // overlay holds, so rebuild the reservation from the new content.
......@@ -11248,7 +11242,7 @@ export default function ChatView(props: ChatViewProps) {
1124811242
1124911243 return (
1125011244 <div
11251 ref={workspaceLayoutRef}
11245 ref={setWorkspaceLayoutElement}
1125211246 className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"
1125311247 >
1125411248 <Dialog
apps/web/src/components/RightPanelTabs.tsx+6-4
......@@ -81,6 +81,7 @@ import { previewBridge } from "./preview/previewBridge";
8181import { PierreEntryIcon } from "./chat/PierreEntryIcon";
8282import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
8383import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
84import { observeResize } from "~/lib/observeResize";
8485
8586interface RightPanelTabsProps {
8687 mode: PreviewPanelMode;
......@@ -1051,14 +1052,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
10511052 if (!viewport) return;
10521053
10531054 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 );
10571059 viewport.addEventListener("scroll", updateTabScrollState, { passive: true });
10581060 updateTabScrollState();
10591061
10601062 return () => {
1061 resizeObserver.disconnect();
1063 stopObserving();
10621064 viewport.removeEventListener("scroll", updateTabScrollState);
10631065 };
10641066 }, [updateTabScrollState]);
apps/web/src/components/chat/AssistantCitationChip.tsx+11-8
......@@ -6,6 +6,7 @@ import {
66import { Link, useNavigate } from "@tanstack/react-router";
77import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
88import {
9 useCallback,
910 useEffect,
1011 useEffectEvent,
1112 useRef,
......@@ -30,6 +31,7 @@ import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor
3031import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
3132import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
3233import { composerFloatingLayerProps } from "./composerEventScope";
34import { observeResize } from "~/lib/observeResize";
3335
3436export function AssistantCitationChip({
3537 citation,
......@@ -242,21 +244,22 @@ export function AssistantCitationChip({
242244
243245function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) {
244246 const [fade, setFade] = useState({ top: false, bottom: false });
245 const updateFade = (element: HTMLElement) => {
247 const updateFade = useCallback((element: HTMLElement) => {
246248 const top = element.scrollTop > 1;
247249 const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1;
248250 setFade((current) =>
249251 current.top === top && current.bottom === bottom ? current : { top, bottom },
250252 );
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 );
252260 return (
253261 <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}
260263 onScroll={(event) => updateFade(event.currentTarget)}
261264 className={cn(
262265 "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 } = {}) {
141141 };
142142 const mutationCallbacks = new Set<MutationCallback>();
143143 let resize: ResizeObserverCallback = () => {};
144 const resizeEntries: ResizeObserverEntry[] = [];
144145 vi.stubGlobal(
145146 "MutationObserver",
146147 class {
......@@ -159,7 +160,9 @@ function createSource({ reducedMotion = false } = {}) {
159160 constructor(callback: ResizeObserverCallback) {
160161 resize = callback;
161162 }
162 observe() {}
163 observe(target: Element) {
164 resizeEntries.push({ target } as ResizeObserverEntry);
165 }
163166 disconnect() {
164167 resize = () => {};
165168 }
......@@ -298,7 +301,7 @@ function createSource({ reducedMotion = false } = {}) {
298301 root.animations.at(-1)?.finish();
299302 await Promise.resolve();
300303 },
301 resize: () => resize([], {} as ResizeObserver),
304 resize: () => resize(resizeEntries, {} as ResizeObserver),
302305 setMeasured: (value: boolean) => {
303306 measured = value;
304307 },
apps/web/src/components/chat/AssistantCitationSource.tsx+3-4
......@@ -6,6 +6,7 @@ import {
66 type AssistantCitationSourceAnchor,
77} from "~/lib/assistantTextSelection";
88import { toastManager } from "../ui/toast";
9import { observeResize } from "~/lib/observeResize";
910
1011const CITATION_PULSE_DURATION_MS = 650;
1112// The second pulse settles into a held highlight so late glances still find the quote.
......@@ -303,9 +304,7 @@ export function observeAssistantCitationSource({
303304 }
304305 });
305306 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);
309308 const state = list.getState();
310309 const unsubscribe = [
311310 state.listenToPosition(itemKey, schedule),
......@@ -334,7 +333,7 @@ export function observeAssistantCitationSource({
334333 pulseAnimation?.cancel();
335334 if (frame !== null) cancelAnimationFrame(frame);
336335 observer.disconnect();
337 resizeObserver.disconnect();
336 stopObservingResize();
338337 for (const stop of unsubscribe) stop();
339338 if (activation.cancelScroll === cancelScroll) delete activation.cancelScroll;
340339 clear();
apps/web/src/components/chat/ChatCanvas.tsx+5-7
......@@ -7,10 +7,10 @@ import {
77 type ComponentProps,
88 type CSSProperties,
99} from "react";
10import { flushSync } from "react-dom";
1110import { ChatCanvasContext } from "./ChatCanvasContext";
1211import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
1312import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
13import { observeResize } from "../../lib/observeResize";
1414
1515/**
1616 * Owns the available conversation space. Cards only report where they sit; the
......@@ -91,12 +91,10 @@ export function ChatCanvas({
9191 );
9292 };
9393 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);
10098 }, [composerOverlayElement, timelineElement]);
10199 const context = useMemo(() => {
102100 const container = { width: measurements.width, height: measurements.height };
apps/web/src/components/chat/ChatComposer.tsx+27-38
......@@ -340,6 +340,7 @@ import {
340340 suppressActiveComposerScrollGesture,
341341} from "./composerScrollGesture";
342342import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
343import { observeResize } from "~/lib/observeResize";
343344
344345function ComposerVideoThumbnail({ file }: { file: File }) {
345346 const setVideo = useCallback(
......@@ -919,10 +920,10 @@ function useComposerRestingTransition(
919920
920921 useLayoutEffect(() => {
921922 const element = elementRef.current;
922 if (!element || typeof ResizeObserver === "undefined") return;
923 if (!element) return;
923924
924925 const body = element.querySelector<HTMLElement>('[data-chat-composer-body="true"]');
925 const observer = new ResizeObserver((entries) => {
926 return observeResize(body ? [element, body] : element, (entries) => {
926927 if (animationRef.current) {
927928 if (body && entries.some((entry) => entry.target === body)) {
928929 transitionToCurrentGeometry(false);
......@@ -959,9 +960,6 @@ function useComposerRestingTransition(
959960 ?.getBoundingClientRect().bottom ?? elementRect.bottom) - elementRect.bottom,
960961 };
961962 });
962 observer.observe(element);
963 if (body) observer.observe(body);
964 return () => observer.disconnect();
965963 }, [restingControlsRef, transitionToCurrentGeometry]);
966964
967965 useEffect(() => {
......@@ -1038,21 +1036,18 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
10381036 window.addEventListener("resize", updatePosition);
10391037 window.addEventListener("scroll", updatePosition, true);
10401038
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);
10521046 }
1047 const stopObserving = observeResize(observed, updatePosition);
10531048
10541049 return () => {
1055 observer?.disconnect();
1050 stopObserving();
10561051 window.removeEventListener("resize", updatePosition);
10571052 window.removeEventListener("scroll", updatePosition, true);
10581053 };
......@@ -1200,16 +1195,19 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro
12001195 useEffect(() => {
12011196 const currentHost = useControlsAsHost ? controls : host;
12021197 if (!currentHost || !controls) return;
1203 const observer = new ResizeObserver(measure);
1198 let stopObserving = () => {};
12041199 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 );
12131211 measure();
12141212 };
12151213 observeControls();
......@@ -1217,7 +1215,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro
12171215 mutations.observe(controls, { childList: true, subtree: true, characterData: true });
12181216 document.fonts.addEventListener("loadingdone", measure);
12191217 return () => {
1220 observer.disconnect();
1218 stopObserving();
12211219 mutations.disconnect();
12221220 document.fonts.removeEventListener("loadingdone", measure);
12231221 };
......@@ -3589,8 +3587,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35893587 const initialCompactness = measureFooterCompactness();
35903588 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
35913589 setIsComposerFooterCompact(initialCompactness.footerCompact);
3592 if (typeof ResizeObserver === "undefined") return;
3593 const observer = new ResizeObserver(() => {
3590 return observeResize(composerForm, () => {
35943591 const nextCompactness = measureFooterCompactness();
35953592 setIsComposerPrimaryActionsCompact((previous) =>
35963593 previous === nextCompactness.primaryActionsCompact
......@@ -3601,11 +3598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36013598 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
36023599 );
36033600 });
3604
3605 observer.observe(composerForm);
3606 return () => {
3607 observer.disconnect();
3608 };
36093601 }, [
36103602 activeThreadId,
36113603 composerFooterActionLayoutKey,
......@@ -5257,10 +5249,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52575249 if (!isComposerResting || !restingActionsElement) return;
52585250 const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth);
52595251 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);
52645253 }, [isComposerResting, restingActionsElement]);
52655254 const restingImagePreviewCounts = getRestingComposerImagePreviewCounts(
52665255 standaloneComposerImages.length,
apps/web/src/components/chat/ChatHeader.tsx+3-3
......@@ -36,6 +36,7 @@ import {
3636 WorkspaceBreadcrumbSeparator,
3737 WorkspaceBreadcrumbText,
3838} from "../WorkspaceBreadcrumb";
39import { observeResize } from "~/lib/observeResize";
3940import { cn } from "~/lib/utils";
4041import { useClientSettings } from "../../hooks/useSettings";
4142
......@@ -154,12 +155,11 @@ export const ChatHeader = memo(function ChatHeader({
154155 measure();
155156 const frame = requestAnimationFrame(measure);
156157 document.fonts.addEventListener("loadingdone", measure);
157 const observer = new ResizeObserver(measure);
158 observer.observe(list);
158 const stopObserving = observeResize(list, measure);
159159 return () => {
160160 cancelAnimationFrame(frame);
161161 document.fonts.removeEventListener("loadingdone", measure);
162 observer.disconnect();
162 stopObserving();
163163 };
164164 }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]);
165165 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 () => {
3636 vi.stubGlobal(
3737 "ResizeObserver",
3838 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 }]);
4142 }
42 observe() {}
4343 disconnect() {}
4444 },
4545 );
apps/web/src/components/chat/ComposerBannerStack.tsx+3-3
......@@ -5,6 +5,7 @@ import { cn } from "~/lib/utils";
55import { Button } from "../ui/button";
66import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
77import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
8import { observeResize } from "~/lib/observeResize";
89
910// Match the duration-220 exit transition before removing a dismissed notice.
1011const DISMISS_TRANSITION_MS = 220;
......@@ -270,13 +271,12 @@ function NoticeDescription({ children }: { children: ReactNode }) {
270271 );
271272 };
272273 measure();
273 const observer = new ResizeObserver(measure);
274 observer.observe(description);
274 const stopObserving = observeResize(description, measure);
275275 // A child can reveal new text without resizing its clipped box.
276276 const mutations = new MutationObserver(measure);
277277 mutations.observe(description, { childList: true, subtree: true, characterData: true });
278278 return () => {
279 observer.disconnect();
279 stopObserving();
280280 mutations.disconnect();
281281 };
282282 }, []);
apps/web/src/components/chat/HtmlRenderFrame.tsx+2-3
......@@ -14,6 +14,7 @@ import type { ChatFileAttachment } from "~/types";
1414import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
1515import { Button } from "../ui/button";
1616import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import { observeResize } from "~/lib/observeResize";
1718
1819// A frame may load its URL a little after mounting.
1920const MIN_URL_LIFE_MS = 5 * 60_000;
......@@ -38,11 +39,9 @@ export function HtmlRenderFrame(props: {
3839 const box = boxRef.current;
3940 if (!box) return;
4041 setWidth(box.clientWidth);
41 const observer = new ResizeObserver(([entry]) => {
42 return observeResize(box, ([entry]) => {
4243 if (entry) setWidth(entry.contentRect.width);
4344 });
44 observer.observe(box);
45 return () => observer.disconnect();
4645 }, []);
4746 // Client fonts can wrap a page taller than the server measured it; a frame
4847 // 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";
3232import { useTurnItemDetail } from "~/state/queries";
3333import { mcpAppEnvironment } from "~/state/mcpApps";
3434import { useAtomCommand } from "~/state/use-atom-command";
35import { observeResize } from "~/lib/observeResize";
3536
3637const commandFailure = (result: {
3738 readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"];
......@@ -103,11 +104,9 @@ export function McpAppFrame(props: {
103104 const box = boxRef.current;
104105 if (!box) return;
105106 setWidth(box.clientWidth);
106 const observer = new ResizeObserver(([entry]) => {
107 return observeResize(box, ([entry]) => {
107108 if (entry) setWidth(Math.round(entry.contentRect.width));
108109 });
109 observer.observe(box);
110 return () => observer.disconnect();
111110 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box.
112111 }, [closed]);
113112
apps/web/src/components/chat/MessagesTimeline.tsx+5-7
......@@ -58,6 +58,7 @@ import {
5858 subagentGroupSummary,
5959 summarizeSubagentStatuses,
6060} from "@t3tools/client-runtime/state/subagent-display";
61import { observeResize } from "~/lib/observeResize";
6162
6263const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
6364const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
......@@ -1287,12 +1288,11 @@ const ConversationTimeline = memo(function ConversationTimeline({
12871288
12881289 const frame = requestAnimationFrame(measure);
12891290
1290 const observer = new ResizeObserver(measure);
1291 observer.observe(timelineViewportElement);
1291 const stopObserving = observeResize(timelineViewportElement, measure);
12921292
12931293 return () => {
12941294 cancelAnimationFrame(frame);
1295 observer.disconnect();
1295 stopObserving();
12961296 };
12971297 }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);
12981298
......@@ -3605,10 +3605,8 @@ function ExpandedWorkGroupEntries({
36053605 const element = listRef.current?.getScrollableNode();
36063606 if (!element) return;
36073607 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);
36123610 }, [updateScrollFades]);
36133611
36143612 const renderEntry = useCallback(
apps/web/src/components/chat/ThreadDetailsCard.tsx+2-3
......@@ -10,6 +10,7 @@ import {
1010 resolveThreadDetailsCardDensity,
1111 resolveThreadDetailsCardLayout,
1212} from "./threadDetailsCardLayout";
13import { observeResize } from "../../lib/observeResize";
1314
1415/** One card owns its placement and folds content only when that content cannot fit. */
1516export function ThreadDetailsCard({
......@@ -115,9 +116,7 @@ export function ThreadDetailsCard({
115116 });
116117 };
117118 measure();
118 const observer = new ResizeObserver(measure);
119 observer.observe(element);
120 return () => observer.disconnect();
119 return observeResize(element, measure);
121120 }, [contentElement, density, measurementKey]);
122121 const card = (
123122 <div
apps/web/src/components/chat/useComposerMultilinePrompt.ts+3-4
......@@ -1,4 +1,5 @@
11import { useLayoutEffect, useState } from "react";
2import { observeResize } from "../../lib/observeResize";
23
34export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null {
45 const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
......@@ -30,14 +31,12 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean {
3031 if (next !== null) setIsMultiline(next);
3132 };
3233 measure();
33 const resizeObserver = new ResizeObserver(measure);
34 resizeObserver.observe(body);
3534 const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
36 if (editor) resizeObserver.observe(editor);
35 const stopObserving = observeResize(editor ? [body, editor] : body, measure);
3736 const mutationObserver = new MutationObserver(measure);
3837 mutationObserver.observe(body, { childList: true, characterData: true, subtree: true });
3938 return () => {
40 resizeObserver.disconnect();
39 stopObserving();
4140 mutationObserver.disconnect();
4241 };
4342 }, [body]);
apps/web/src/components/device/DeviceDuoViewport.tsx+3-3
......@@ -6,6 +6,7 @@ import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
66import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
77import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control";
88import { bindPhoneTrackpad } from "./phoneTrackpad";
9import { observeResize } from "../../lib/observeResize";
910
1011const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");
1112
......@@ -64,8 +65,7 @@ export function DeviceDuoViewport(props: {
6465 const { width, height } = host.getBoundingClientRect();
6566 viewerRef.current?.resize(width, height, window.devicePixelRatio);
6667 };
67 const observer = new ResizeObserver(resize);
68 observer.observe(host);
68 const stopObserving = observeResize(host, resize);
6969 const blur = () => {
7070 interactionRef.current?.end();
7171 trackpad?.cancel();
......@@ -159,7 +159,7 @@ export function DeviceDuoViewport(props: {
159159 onResetReady(null);
160160 onFrameListener(null);
161161 client.current?.setDuoPanels(null);
162 observer.disconnect();
162 stopObserving();
163163 window.removeEventListener("blur", blur);
164164 viewerRef.current?.dispose();
165165 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
88import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
99import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
1010import { bindPhoneTrackpad } from "./phoneTrackpad";
11import { observeResize } from "../../lib/observeResize";
1112
1213const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");
1314
......@@ -64,8 +65,7 @@ export function DevicePhoneViewport(props: {
6465 const { width, height } = host.getBoundingClientRect();
6566 viewerRef.current?.resize(width, height, window.devicePixelRatio);
6667 };
67 const observer = new ResizeObserver(resize);
68 observer.observe(host);
68 const stopObserving = observeResize(host, resize);
6969 const blur = () => {
7070 interactionRef.current?.end();
7171 trackpad?.cancel();
......@@ -113,7 +113,7 @@ export function DevicePhoneViewport(props: {
113113 onInputCancel(null);
114114 onResetReady(null);
115115 onFrameListener(null);
116 observer.disconnect();
116 stopObserving();
117117 window.removeEventListener("blur", blur);
118118 viewerRef.current?.dispose();
119119 viewerRef.current = null;
apps/web/src/components/device/DeviceStreamView.tsx+2-3
......@@ -22,6 +22,7 @@ import {
2222 type DeviceStreamClient,
2323 type DeviceStreamStatus,
2424} from "@t3tools/client-runtime/device/stream";
25import { observeResize } from "~/lib/observeResize";
2526
2627const AX_POLL_INTERVAL_MS = 2_000;
2728const CONTROLS_RAIL_WIDTH = 56;
......@@ -230,9 +231,7 @@ export function DeviceStreamView(props: {
230231 );
231232 };
232233 update();
233 const observer = new ResizeObserver(update);
234 observer.observe(element);
235 return () => observer.disconnect();
234 return observeResize(element, update);
236235 }, []);
237236 const frame = useMemo(() => {
238237 return fitDeviceFrame(aspect, host.width, host.height, controlsInset);
apps/web/src/components/preview/PreviewView.tsx+2-3
......@@ -91,6 +91,7 @@ import {
9191} from "~/browser/browserRecording";
9292import { stackedThreadToast, toastManager } from "~/components/ui/toast";
9393import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
94import { observeResize } from "~/lib/observeResize";
9495
9596interface Props {
9697 threadRef: ScopedThreadRef;
......@@ -218,11 +219,9 @@ export function PreviewView({
218219 useEffect(() => {
219220 const element = serverBodyRef.current;
220221 if (!element || !serverToolbarShown) return;
221 const observer = new ResizeObserver(([entry]) =>
222 return observeResize(element, ([entry]) =>
222223 setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))),
223224 );
224 observer.observe(element);
225 return () => observer.disconnect();
226225 }, [serverToolbarShown]);
227226 const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null);
228227 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";
33import { cn } from "~/lib/utils";
44import { Button } from "../ui/button";
55import { PullRequestMarkdown } from "./PullRequestMarkdown";
6import { observeResize } from "~/lib/observeResize";
67
78/** Keep the complete markdown intact while limiting long reports to a readable preview. */
89export function PullRequestCommentBody({
......@@ -19,9 +20,7 @@ export function PullRequestCommentBody({
1920 if (!element) return;
2021 const measure = () => setOverflowing(element.getBoundingClientRect().height > 240);
2122 measure();
22 const observer = new ResizeObserver(measure);
23 observer.observe(element);
24 return () => observer.disconnect();
23 return observeResize(element, measure);
2524 }, []);
2625
2726 return (
apps/web/src/components/sidebar/SidebarChrome.tsx+2-3
......@@ -29,6 +29,7 @@ import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
2929import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
3030import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3131import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
32import { observeResize } from "~/lib/observeResize";
3233
3334export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3435 isElectron,
......@@ -85,11 +86,9 @@ export function SidebarBrandWidthProbe({
8586}) {
8687 const observeWidth = useCallback(
8788 (probe: HTMLDivElement) => {
88 const observer = new ResizeObserver(([entry]) => {
89 return observeResize(probe, ([entry]) => {
8990 if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
9091 });
91 observer.observe(probe);
92 return () => observer.disconnect();
9392 },
9493 [onWidthChange],
9594 );
apps/web/src/components/usage/UsageProviderChart.tsx+3-5
......@@ -12,6 +12,7 @@ import {
1212} from "@t3tools/shared/usageFormat";
1313import { cn } from "~/lib/utils";
1414import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
15import { observeResize } from "~/lib/observeResize";
1516
1617const VIEW_WIDTH = 960;
1718const VIEW_HEIGHT = 260;
......@@ -307,12 +308,9 @@ export function UsageProviderChart({
307308
308309 const plot = plotRef.current;
309310 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 observer.observe(plot);
314 observer.observe(tooltip);
315 return () => observer.disconnect();
313 return observeResize([plot, tooltip], positionTooltip);
316314 }, [hoverIndex, positionTooltip]);
317315
318316 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 @@
1import { type RefObject, useEffect, useLayoutEffect, useState } from "react";
1import { type RefObject, useLayoutEffect, useState } from "react";
22
33import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth";
4import { observeResize } from "../lib/observeResize";
45
56export interface PreviewPanelInlineSize {
67 readonly width: number;
......@@ -28,15 +29,11 @@ export function usePreviewPanelInlineSize(
2829 readonly enabled?: boolean | undefined;
2930 readonly widthStorageKey?: string | undefined;
3031 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;
3334 } = {},
3435): 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);
4037 return useResizableWidth({
4138 storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
4239 defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
......@@ -54,46 +51,40 @@ export function usePreviewPanelInlineSize(
5451function useViewportClampedMaxWidth(
5552 hostRef: RefObject<HTMLElement | null> | undefined,
5653 enabled: boolean,
57 containerWidth?: number,
54 container: HTMLElement | null | undefined,
5855): 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(() => {
6260 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();
6371 let frame = 0;
6472 const onResize = () => {
6573 if (frame !== 0) return;
6674 frame = window.requestAnimationFrame(() => {
6775 frame = 0;
68 setVw(window.innerWidth);
76 measure();
6977 });
7078 };
7179 window.addEventListener("resize", onResize);
80 const stopObserving = row ? observeResize(row, measure) : undefined;
7281 return () => {
7382 window.removeEventListener("resize", onResize);
7483 if (frame !== 0) window.cancelAnimationFrame(frame);
84 stopObserving?.();
7585 };
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;
9788}
9889export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number {
9990 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", () => {
8181 function createHarness() {
8282 vi.useFakeTimers();
8383 const frames = new Map<number, FrameRequestCallback>();
84 const resizeCallbacks = new Set<() => void>();
84 const resizeTargets = new Map<(entries: { target: Element }[]) => void, Set<Element>>();
8585 const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {});
8686 let frameId = 0;
8787 const requestFrame = vi.fn((callback: FrameRequestCallback) => {
......@@ -169,12 +169,17 @@ describe("GhosttyTerminalSurface visibility", () => {
169169 vi.stubGlobal(
170170 "ResizeObserver",
171171 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);
174180 }
175 observe() {}
176181 disconnect() {
177 resizeCallbacks.delete(this.callback);
182 resizeTargets.delete(this.callback);
178183 }
179184 },
180185 );
......@@ -199,7 +204,9 @@ describe("GhosttyTerminalSurface visibility", () => {
199204 for (const callback of queued) callback(0);
200205 },
201206 resize() {
202 for (const callback of resizeCallbacks) callback();
207 for (const [callback, targets] of resizeTargets) {
208 callback([...targets].map((target) => ({ target })));
209 }
203210 },
204211 pointer(
205212 type: string,
apps/web/src/terminal/ghostty/surface.ts+4-4
......@@ -16,6 +16,7 @@ import {
1616} from "./renderer";
1717import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
1818import { isMonospaceFamily } from "../../appearanceFonts";
19import { observeResize } from "../../lib/observeResize";
1920
2021export const DEFAULT_TERMINAL_FONT_SIZE = 12;
2122const MIN_TERMINAL_FONT_SIZE = 6;
......@@ -575,7 +576,7 @@ export class GhosttyTerminalSurface {
575576 private fontSize: number;
576577 private fontEpoch = 0;
577578 private pendingFontEpoch: number | null = null;
578 private readonly resizeObserver: ResizeObserver;
579 private readonly stopObservingResize: () => void;
579580 private readonly scrollbarThumb: HTMLDivElement;
580581 private snapshot: GhosttySnapshot | null = null;
581582 private frame = 0;
......@@ -666,12 +667,11 @@ export class GhosttyTerminalSurface {
666667 this.fontFamily = fontFamily;
667668 this.requestedFontFamily = options.font?.family;
668669 this.fontSize = terminalFontSize(options.font?.size);
669 this.resizeObserver = new ResizeObserver(() => this.fit());
670670 this.installEvents();
671671 this.watchDevicePixelRatio();
672672 this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange);
673673 document.fonts.addEventListener("loadingdone", this.onFontsLoaded);
674 this.resizeObserver.observe(mount);
674 this.stopObservingResize = observeResize(mount, () => this.fit());
675675 }
676676
677677 static async create(
......@@ -1058,7 +1058,7 @@ export class GhosttyTerminalSurface {
10581058 dispose(): void {
10591059 if (this.disposed) return;
10601060 this.disposed = true;
1061 this.resizeObserver.disconnect();
1061 this.stopObservingResize();
10621062 document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);
10631063 this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
10641064 this.dprMedia = null;
oxlint-plugin-t3code/index.ts+2
......@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts";
1111import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
12import noRawResizeObserver from "./rules/no-raw-resize-observer.ts";
1213import noTestInLoop from "./rules/no-test-in-loop.ts";
1314import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
1415import noUnscopedHas from "./rules/no-unscoped-has.ts";
......@@ -30,6 +31,7 @@ export default definePlugin({
3031 "no-native-title-tooltip": noNativeTitleTooltip,
3132 "no-outset-state-indicators": noOutsetStateIndicators,
3233 "no-raw-mcp-registration": noRawMcpRegistration,
34 "no-raw-resize-observer": noRawResizeObserver,
3335 "no-test-in-loop": noTestInLoop,
3436 "no-rpc-permission-bypass": noRpcPermissionBypass,
3537 "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({
250250 files: ["apps/mobile/src/**"],
251251 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },
252252 },
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 },
253263 {
254264 // Every class in web code must be one Tailwind generates: a typo or a class nothing
255265 // declares ships silently unstyled. JS hooks use data attributes, not class names.