From c2a814ec43de5ea3993b28418fc78076a0f544fb Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:59:39 +0300 Subject: [PATCH] Optimize first load rerenders --- app/features/events/events-hooks.ts | 25 +++++++++++++------ app/features/layout/LayoutDataProvider.tsx | 10 ++------ .../notifications/NotificationsProvider.tsx | 6 ++--- app/hooks/useBackgroundResource.ts | 10 +++++--- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/features/events/events-hooks.ts b/app/features/events/events-hooks.ts index 98932c7aa..2ab38d2a2 100644 --- a/app/features/events/events-hooks.ts +++ b/app/features/events/events-hooks.ts @@ -35,6 +35,15 @@ export function useEventsReadyState(): EventsReadyState { ); } +/** Whether the shared SSE connection is up; unlike the ready state, the CLOSED to CONNECTING flip doesn't re-render. */ +export function useEventsConnected(): boolean { + return React.useSyncExternalStore( + eventsClient.subscribeToReadyState, + getIsConnected, + getServerIsConnected, + ); +} + /** Calls `listener` for every server event received over the shared SSE connection. */ export function useServerEventListener(listener: (event: ServerEvent) => void) { const handleEvent = React.useEffectEvent(listener); @@ -64,7 +73,7 @@ export function useEventStreamCatchUp({ enabled: boolean; onCatchUp: () => void; }) { - const readyState = useEventsReadyState(); + const connected = useEventsConnected(); const latestOnCatchUp = React.useRef(onCatchUp); latestOnCatchUp.current = onCatchUp; const scheduledRef = React.useRef | null>(null); @@ -88,7 +97,7 @@ export function useEventStreamCatchUp({ [], ); - useCatchUpOnConnect(enabled, readyState, catchUp); + useCatchUpOnConnect(enabled, connected, catchUp); React.useEffect(() => { if (!enabled) return; @@ -97,11 +106,11 @@ export function useEventStreamCatchUp({ }, [enabled, catchUp]); React.useEffect(() => { - if (!enabled || readyState === "CONNECTED") return; + if (!enabled || connected) return; const interval = setInterval(catchUp, EVENTS_DOWN_CATCH_UP_MS); return () => clearInterval(interval); - }, [enabled, readyState, catchUp]); + }, [enabled, connected, catchUp]); return catchUp; } @@ -112,7 +121,7 @@ export function useEventStreamCatchUp({ */ function useCatchUpOnConnect( enabled: boolean, - readyState: EventsReadyState, + connected: boolean, onConnect: () => void, ) { const hasConnectedRef = React.useRef(false); @@ -126,7 +135,7 @@ function useCatchUpOnConnect( } listeningSinceRef.current ??= Date.now(); - if (readyState !== "CONNECTED") return; + if (!connected) return; const isFirstConnect = !hasConnectedRef.current; hasConnectedRef.current = true; @@ -138,7 +147,7 @@ function useCatchUpOnConnect( } onConnect(); - }, [enabled, readyState, onConnect]); + }, [enabled, connected, onConnect]); } const returnListeners = new Set<() => void>(); @@ -187,3 +196,5 @@ function noticeReturn() { } const getServerReadyState = (): EventsReadyState => "CLOSED"; +const getIsConnected = () => eventsClient.getReadyState() === "CONNECTED"; +const getServerIsConnected = () => false; diff --git a/app/features/layout/LayoutDataProvider.tsx b/app/features/layout/LayoutDataProvider.tsx index 560a6085b..baef17665 100644 --- a/app/features/layout/LayoutDataProvider.tsx +++ b/app/features/layout/LayoutDataProvider.tsx @@ -19,12 +19,10 @@ interface LayoutData { interface LayoutDataContextValue extends LayoutData { /** Refetches the app shell data, without touching the page's own loaders. */ refresh: () => void; - isRefreshing: boolean; } const LayoutDataContext = React.createContext({ refresh: () => {}, - isRefreshing: false, }); /** @@ -44,14 +42,11 @@ export function LayoutDataProvider({ refresh, } = useBackgroundResource>(LAYOUT_DATA_ROUTE); - // a ref so a poll elsewhere does not re-run the effect and restart the interval before it fires - const isLoadingRef = React.useRef(isLoading); - isLoadingRef.current = isLoading; const lastRefreshedAtRef = React.useRef(0); React.useEffect(() => { const loadIfIdle = () => { - if (isLoadingRef.current) return; + if (isLoading()) return; // alt-tabbing back is not worth a full app shell rebuild if one just happened if (Date.now() - lastRefreshedAtRef.current < ONE_MINUTE) return; @@ -72,7 +67,7 @@ export function LayoutDataProvider({ document.removeEventListener("visibilitychange", handleVisibilityChange); clearInterval(interval); }; - }, [refresh]); + }, [refresh, isLoading]); const newest = useNewestOf(data, polledData); @@ -85,7 +80,6 @@ export function LayoutDataProvider({ const value: LayoutDataContextValue = { ...newest, refresh, - isRefreshing: isLoading, }; return ( diff --git a/app/features/notifications/NotificationsProvider.tsx b/app/features/notifications/NotificationsProvider.tsx index 15884ab39..c7e0f6570 100644 --- a/app/features/notifications/NotificationsProvider.tsx +++ b/app/features/notifications/NotificationsProvider.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useFetchers, useLocation, useNavigation } from "react-router"; import { useEventStreamCatchUp, - useEventsReadyState, + useEventsConnected, useServerEventListener, } from "~/features/events/events-hooks"; import { useBackgroundResource } from "~/hooks/useBackgroundResource"; @@ -39,10 +39,10 @@ export function NotificationsProvider({ const { data, refresh } = useBackgroundResource>( NOTIFICATIONS_DATA_ROUTE, ); - const readyState = useEventsReadyState(); + const connected = useEventsConnected(); const loggedIn = Boolean(user); - const eventsDown = loggedIn && readyState !== "CONNECTED"; + const eventsDown = loggedIn && !connected; React.useEffect(() => { if (!loggedIn) return; diff --git a/app/hooks/useBackgroundResource.ts b/app/hooks/useBackgroundResource.ts index a4fb852a0..85c847bce 100644 --- a/app/hooks/useBackgroundResource.ts +++ b/app/hooks/useBackgroundResource.ts @@ -3,16 +3,17 @@ import * as React from "react"; /** * Keeps an app shell resource route's JSON fresh outside the router: an in-flight `useFetcher` load is folded * into a starting navigation and must settle first, so it would hold up every page change it overlaps. + * Only a landed response renders; whether a fetch is in flight is read on demand via `isLoading()`. */ export function useBackgroundResource(url: string) { const [data, setData] = React.useState(); - const [isLoading, setIsLoading] = React.useState(false); + const inFlightRef = React.useRef(false); const latestRequestRef = React.useRef(0); // stable so effects refreshing after a mutation don't re-run every render const refresh = React.useCallback(async () => { const requestId = ++latestRequestRef.current; - setIsLoading(true); + inFlightRef.current = true; try { const response = await fetch(url); @@ -26,9 +27,12 @@ export function useBackgroundResource(url: string) { } catch { // a background refresh failing just leaves the last data in place } finally { - if (requestId === latestRequestRef.current) setIsLoading(false); + if (requestId === latestRequestRef.current) inFlightRef.current = false; } }, [url]); + // stable for the same reason + const isLoading = React.useCallback(() => inFlightRef.current, []); + return { data, isLoading, refresh }; }