From 6ec62660915bc62bb29b43181e200490b4b9d5b3 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 19 Aug 2026 21:58:59 +0100 Subject: [PATCH 1/2] refactor(webapp): replace render-time ref initialization --- .../errors/ConfigureErrorAlerts.tsx | 24 +++++----- .../app/components/navigation/SideMenu.tsx | 45 +++++++++++-------- .../components/primitives/TooltipPortal.tsx | 15 +++---- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx index 08cbcb55ad9..6b3edccec3c 100644 --- a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx +++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx @@ -103,11 +103,11 @@ export function ConfigureErrorAlerts({ } }, [fetcher.state, fetcher.data, closeHref, navigate, toast]); - const emailFieldValues = useRef( + const [emailFieldValues, setEmailFieldValues] = useState(() => existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""] ); - const webhookFieldValues = useRef( + const [webhookFieldValues, setWebhookFieldValues] = useState(() => existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""] ); @@ -118,8 +118,8 @@ export function ConfigureErrorAlerts({ }, shouldRevalidate: "onSubmit", defaultValue: { - emails: emailFieldValues.current, - webhooks: webhookFieldValues.current, + emails: emailFieldValues, + webhooks: webhookFieldValues, }, }); const { emails, webhooks, slackChannel, slackIntegrationId } = fields; @@ -167,10 +167,12 @@ export function ConfigureErrorAlerts({ placeholder={index === 0 ? "Enter an email address" : "Add another email"} icon={EnvelopeIcon} onChange={(e) => { - emailFieldValues.current[index] = e.target.value; + const nextValues = [...emailFieldValues]; + nextValues[index] = e.target.value; + setEmailFieldValues(nextValues); if ( - emailFields.length === emailFieldValues.current.length && - emailFieldValues.current.every((v) => v !== "") + emailFields.length === nextValues.length && + nextValues.every((value) => value !== "") ) { form.insert({ name: emails.name }); } @@ -321,10 +323,12 @@ export function ConfigureErrorAlerts({ } icon={GlobeLinesIcon} onChange={(e) => { - webhookFieldValues.current[index] = e.target.value; + const nextValues = [...webhookFieldValues]; + nextValues[index] = e.target.value; + setWebhookFieldValues(nextValues); if ( - webhookFields.length === webhookFieldValues.current.length && - webhookFieldValues.current.every((v) => v !== "") + webhookFields.length === nextValues.length && + nextValues.every((value) => value !== "") ) { form.insert({ name: webhooks.name }); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d653d44dc1b..018ec84d02d 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -366,25 +366,32 @@ export function SideMenu({ const rafRef = useRef(null); // Mirror of `isCollapsed` for the drag handlers (outside React's render cycle; no stale closures). const isCollapsedRef = useRef(isCollapsed); + // Freeze first-paint values so React never fights the imperative width writes after hydration. + const [initialVisual] = useState(() => { + const collapsed = user.dashboardPreferences.sideMenu?.isCollapsed ?? false; + const expandedWidth = clamp( + user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, + DEFAULT_WIDTH, + MAX_WIDTH + ); + const width = collapsed ? COLLAPSED_WIDTH : expandedWidth; + const progress = collapsed ? 1 : 0; + + return { + expandedWidth, + width, + progress, + style: { + width, + "--sm-collapse": String(progress), + "--sm-label-opacity": String(progressToLabelOpacity(progress)), + } as CSSProperties, + }; + }); // The last-committed expanded width; animation targets and re-expansion read from here. - const expandedWidthRef = useRef( - clamp(user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, DEFAULT_WIDTH, MAX_WIDTH) - ); - // Frozen first-paint width; never changes, so React never fights the imperative width writes. - const initialWidthRef = useRef( - (user.dashboardPreferences.sideMenu?.isCollapsed ?? false) - ? COLLAPSED_WIDTH - : expandedWidthRef.current - ); - const widthRef = useRef(initialWidthRef.current); - const progressRef = useRef((user.dashboardPreferences.sideMenu?.isCollapsed ?? false) ? 1 : 0); - // Frozen initial style (incl. CSS vars) so the SSR HTML has the right collapsed/expanded visuals - // (no pre-hydration flash). Stable identity, so React never rewrites it after writeVisual. - const initialStyleRef = useRef({ - width: initialWidthRef.current, - "--sm-collapse": String(progressRef.current), - "--sm-label-opacity": String(progressToLabelOpacity(progressRef.current)), - } as CSSProperties); + const expandedWidthRef = useRef(initialVisual.expandedWidth); + const widthRef = useRef(initialVisual.width); + const progressRef = useRef(initialVisual.progress); // Removes an in-flight drag's window listeners (set on pointerdown; cleared on finish/unmount). const dragCleanupRef = useRef<(() => void) | null>(null); @@ -1066,7 +1073,7 @@ export function SideMenu({ return (
(); const [popperElement, setPopperElement] = useState(); - const virtualElementRef = useLazyRef(() => new VirtualElement()); + const [virtualElement] = useState(() => new VirtualElement()); - const { styles, attributes, update } = usePopper( - virtualElementRef.current, - popperElement, - POPPER_OPTIONS - ); + const { styles, attributes, update } = usePopper(virtualElement, popperElement, POPPER_OPTIONS); useEffect(() => { const el = document.createElement("div"); @@ -50,7 +45,7 @@ export default function TooltipPortal({ active = true, children }: PopperPortalP }, []); useEvent("mousemove", ({ clientX: x, clientY: y }) => { - virtualElementRef.current?.update(x, y); + virtualElement.update(x, y); if (!active) return; update?.(); }); @@ -59,9 +54,9 @@ export default function TooltipPortal({ active = true, children }: PopperPortalP if (!active) return; // Seed from the last known pointer so the tooltip appears at the cursor immediately, even if the // mouse is held still after hovering onto a point (otherwise it flashes in the top-left corner). - virtualElementRef.current?.update(lastPointer.x, lastPointer.y); + virtualElement.update(lastPointer.x, lastPointer.y); update?.(); - }, [active, update, virtualElementRef]); + }, [active, update, virtualElement]); if (!portalElement) return null; From 0e27584c62e1b1d111f4fb8794e402c624e61f9d Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 20 Aug 2026 06:53:52 +0100 Subject: [PATCH 2/2] fix(webapp): preserve alert form field state --- .../errors/ConfigureErrorAlerts.tsx | 26 +++++++++---------- apps/webapp/app/hooks/useLazyRef.ts | 12 --------- 2 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 apps/webapp/app/hooks/useLazyRef.ts diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx index 6b3edccec3c..4d49d44855b 100644 --- a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx +++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx @@ -103,13 +103,15 @@ export function ConfigureErrorAlerts({ } }, [fetcher.state, fetcher.data, closeHref, navigate, toast]); - const [emailFieldValues, setEmailFieldValues] = useState(() => + const [emailDefaultValues] = useState(() => existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""] ); + const emailFieldValues = useRef([...emailDefaultValues]); - const [webhookFieldValues, setWebhookFieldValues] = useState(() => + const [webhookDefaultValues] = useState(() => existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""] ); + const webhookFieldValues = useRef([...webhookDefaultValues]); const [form, fields] = useForm>({ id: "configure-error-alerts", @@ -118,8 +120,8 @@ export function ConfigureErrorAlerts({ }, shouldRevalidate: "onSubmit", defaultValue: { - emails: emailFieldValues, - webhooks: webhookFieldValues, + emails: emailDefaultValues, + webhooks: webhookDefaultValues, }, }); const { emails, webhooks, slackChannel, slackIntegrationId } = fields; @@ -167,12 +169,10 @@ export function ConfigureErrorAlerts({ placeholder={index === 0 ? "Enter an email address" : "Add another email"} icon={EnvelopeIcon} onChange={(e) => { - const nextValues = [...emailFieldValues]; - nextValues[index] = e.target.value; - setEmailFieldValues(nextValues); + emailFieldValues.current[index] = e.target.value; if ( - emailFields.length === nextValues.length && - nextValues.every((value) => value !== "") + emailFields.length === emailFieldValues.current.length && + emailFieldValues.current.every((value) => value !== "") ) { form.insert({ name: emails.name }); } @@ -323,12 +323,10 @@ export function ConfigureErrorAlerts({ } icon={GlobeLinesIcon} onChange={(e) => { - const nextValues = [...webhookFieldValues]; - nextValues[index] = e.target.value; - setWebhookFieldValues(nextValues); + webhookFieldValues.current[index] = e.target.value; if ( - webhookFields.length === nextValues.length && - nextValues.every((value) => value !== "") + webhookFields.length === webhookFieldValues.current.length && + webhookFieldValues.current.every((value) => value !== "") ) { form.insert({ name: webhooks.name }); } diff --git a/apps/webapp/app/hooks/useLazyRef.ts b/apps/webapp/app/hooks/useLazyRef.ts deleted file mode 100644 index c2fc66273bb..00000000000 --- a/apps/webapp/app/hooks/useLazyRef.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; - -const useLazyRef = (initialValFunc: () => T) => { - const ref: MutableRefObject = useRef(null); - if (ref.current === null) { - ref.current = initialValFunc(); - } - return ref; -}; - -export default useLazyRef;