Optimize first load rerenders

This commit is contained in:
Kalle
2026-09-08 21:59:39 +03:00
parent 0b7e9606dc
commit c2a814ec43
4 changed files with 30 additions and 21 deletions

View File

@@ -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<ReturnType<typeof setTimeout> | 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;

View File

@@ -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<LayoutDataContextValue>({
refresh: () => {},
isRefreshing: false,
});
/**
@@ -44,14 +42,11 @@ export function LayoutDataProvider({
refresh,
} = useBackgroundResource<SerializeFrom<typeof loader>>(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 (

View File

@@ -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<SerializeFrom<typeof loader>>(
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;

View File

@@ -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<T>(url: string) {
const [data, setData] = React.useState<T>();
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<T>(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 };
}