Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood
51c539ee46 fix(site): move useRef render-time reads/writes into effects
Audit of all useRef calls in site/src/pages/AgentsPage found 12
violations of React's rule: "Don't read or write ref.current
during rendering."

Changes per file:

- AgentChatInput.tsx: Move .focus() side effect from render body
  into useEffect keyed on isLoading. Replace useState-based
  prev-tracking with useRef.

- AgentsPage.tsx: Wrap activeChatIDRef.current sync in useEffect.

- AgentCreateForm.tsx: Remove selectedWorkspaceIdRef and
  selectedModelRef entirely — the memo boundary they were
  protecting is already broken by inline JSX props and direct
  selectedModel prop. Use values directly in useCallback deps.

- AgentDetailView.tsx: Wrap isFetchingRef and onFetchRef syncs in
  useEffect inside ScrollAnchoredContainer.

- ChatContext.ts: Use lazy init pattern for storeRef to avoid
  calling createChatStore() on every render. Wrap
  lastMessageIdRef sync in useEffect.

- useWorkspaceCreationWatcher.ts: Move processedToolCallIdsRef
  reset from derived-state-from-props block into useEffect keyed
  on chatID.

- useFileAttachments.ts: Wrap previewUrlsRef sync in useEffect.

- AgentEmbedPage.tsx: Wrap latestEmbedSessionMutationRef sync in
  useEffect.

- useDesktopConnection.ts: Add rfb state variable so consumers
  get reactive updates. Keep rfbRef for synchronous access in
  callbacks/cleanup. Return state instead of ref snapshot.
2026-03-18 22:15:43 +00:00
9 changed files with 48 additions and 36 deletions

View File

@@ -469,17 +469,13 @@ export const AgentChatInput = memo<AgentChatInputProps>(
// Re-focus the editor after a send completes (isLoading goes
// from true → false) so the user can immediately type again.
// Uses the "store previous value in state" pattern recommended
// by React for responding to prop changes during render.
const [prevIsLoading, setPrevIsLoading] = useState(isLoading);
if (prevIsLoading !== isLoading) {
setPrevIsLoading(isLoading);
if (prevIsLoading && !isLoading) {
if (!isMobileViewport()) {
internalRef.current?.focus();
}
const prevIsLoadingRef = useRef(isLoading);
useEffect(() => {
if (prevIsLoadingRef.current && !isLoading && !isMobileViewport()) {
internalRef.current?.focus();
}
}
prevIsLoadingRef.current = isLoading;
}, [isLoading]);
const isUploading = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploading",

View File

@@ -245,14 +245,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
lastUsedModelID,
]);
// Keep a mutable ref to selectedWorkspaceId and selectedModel so
// that the onSend callback always sees the latest values without
// the shared input component re-rendering on every change.
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
setSelectedWorkspaceId(null);
@@ -278,15 +270,15 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
await onCreateChat({
message,
fileIDs,
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
model: selectedModelRef.current || undefined,
workspaceId: selectedWorkspaceId ?? undefined,
model: selectedModel || undefined,
}).catch(() => {
// Re-enable draft persistence so the user can edit
// and retry after a failed send attempt.
resetDraft();
});
},
[submitDraft, resetDraft, onCreateChat],
[submitDraft, resetDraft, onCreateChat, selectedWorkspaceId, selectedModel],
);
const selectedWorkspace = selectedWorkspaceId

View File

@@ -436,7 +436,10 @@ export const useChatStore = (
} = options;
const queryClient = useQueryClient();
const storeRef = useRef<ChatStore>(createChatStore());
const storeRef = useRef<ChatStore | null>(null);
if (storeRef.current === null) {
storeRef.current = createChatStore();
}
const streamResetFrameRef = useRef<number | null>(null);
const queuedMessagesHydratedChatIDRef = useRef<string | null>(null);
// Tracks whether the WebSocket has delivered a queue_update for the
@@ -449,7 +452,7 @@ export const useChatStore = (
const activeChatIDRef = useRef<string | null>(null);
const prevChatIDRef = useRef<string | undefined>(chatID);
const store = storeRef.current;
const store = storeRef.current!;
// Compute the last REST-fetched message ID so the stream can
// skip messages the client already has. We use a ref so the
@@ -457,10 +460,12 @@ export const useChatStore = (
// chatMessages in its dependency array (which would cause
// unnecessary reconnections).
const lastMessageIdRef = useRef<number | undefined>(undefined);
lastMessageIdRef.current =
chatMessages && chatMessages.length > 0
? chatMessages[chatMessages.length - 1].id
: undefined;
useEffect(() => {
lastMessageIdRef.current =
chatMessages && chatMessages.length > 0
? chatMessages[chatMessages.length - 1].id
: undefined;
}, [chatMessages]);
// True once the initial REST page has resolved for the current
// chat. The WebSocket effect gates on this so that

View File

@@ -31,14 +31,16 @@ export function useWorkspaceCreationWatcher({
const streamState = useChatSelector(store, selectStreamState);
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
// Reset processed IDs when chatID changes during render,
// before effects run.
// Reset processed IDs when chatID changes.
const [previousChatID, setPreviousChatID] = useState(chatID);
if (previousChatID !== chatID) {
setPreviousChatID(chatID);
processedToolCallIdsRef.current = new Set();
}
useEffect(() => {
processedToolCallIdsRef.current = new Set();
}, [chatID]);
// Watch stream tool results for create_workspace completions.
useEffect(() => {
if (!streamState || !chatID) {

View File

@@ -519,9 +519,13 @@ const ScrollAnchoredContainer: FC<{
const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const isFetchingRef = useRef(isFetchingMoreMessages);
isFetchingRef.current = isFetchingMoreMessages;
useEffect(() => {
isFetchingRef.current = isFetchingMoreMessages;
}, [isFetchingMoreMessages]);
const onFetchRef = useRef(onFetchMoreMessages);
onFetchRef.current = onFetchMoreMessages;
useEffect(() => {
onFetchRef.current = onFetchMoreMessages;
}, [onFetchMoreMessages]);
// Sentinel observer — triggers loading older messages.
// All changing values are read from refs so the observer

View File

@@ -61,7 +61,9 @@ const AgentEmbedPage: FC = () => {
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
);
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
latestEmbedSessionMutationRef.current = embedSessionMutation;
useEffect(() => {
latestEmbedSessionMutationRef.current = embedSessionMutation;
}, [embedSessionMutation]);
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
const [chatErrorReasons, setChatErrorReasons] = useState<

View File

@@ -341,7 +341,9 @@ const AgentsPage: FC = () => {
// WebSocket handler can read it without re-subscribing on
// every navigation.
const activeChatIDRef = useRef(agentId);
activeChatIDRef.current = agentId;
useEffect(() => {
activeChatIDRef.current = agentId;
}, [agentId]);
useEffect(() => {
return createReconnectingWebSocket({

View File

@@ -46,6 +46,7 @@ export function useDesktopConnection({
}: UseDesktopConnectionOptions): UseDesktopConnectionResult {
const [status, setStatus] = useState<DesktopConnectionStatus>("idle");
const [hasConnected, setHasConnected] = useState(false);
const [rfb, setRfb] = useState<RFB | null>(null);
const rfbRef = useRef<RFB | null>(null);
const offscreenContainerRef = useRef<HTMLElement | null>(null);
@@ -66,6 +67,9 @@ export function useDesktopConnection({
// Ignore errors during disconnect.
}
rfbRef.current = null;
if (!disposedRef.current) {
setRfb(null);
}
}
}, []);
@@ -109,6 +113,7 @@ export function useDesktopConnection({
rfb.addEventListener("disconnect", () => {
if (disposedRef.current) return;
rfbRef.current = null;
setRfb(null);
if (!sessionConnected && !hasConnectedRef.current) {
// The VNC handshake never completed and the desktop
@@ -140,10 +145,12 @@ export function useDesktopConnection({
rfb.addEventListener("securityfailure", () => {
if (disposedRef.current) return;
rfbRef.current = null;
setRfb(null);
setStatus("error");
});
rfbRef.current = rfb;
setRfb(rfb);
} catch {
setStatus("error");
}
@@ -203,6 +210,6 @@ export function useDesktopConnection({
connect,
disconnect,
attach,
rfb: rfbRef.current,
rfb,
};
}

View File

@@ -34,7 +34,9 @@ export function useFileAttachments(
// Revoke blob URLs on unmount to prevent memory leaks.
const previewUrlsRef = useRef(previewUrls);
previewUrlsRef.current = previewUrls;
useEffect(() => {
previewUrlsRef.current = previewUrls;
}, [previewUrls]);
useEffect(() => {
return () => {
for (const [, url] of previewUrlsRef.current) {