From 811e62ba3988bc59f446e78f7fb1ba92a199b08d Mon Sep 17 00:00:00 2001 From: Anay Gupta Date: Sun, 13 Sep 2026 04:35:50 +0530 Subject: [PATCH 1/2] fix(web): folder picker revalidates its listing when reopened Browse query atoms outlive the picker through their idle TTL, and the swr wrapper only re-checks staleness when an atom node is rebuilt, so reopening the add-project folder picker rendered a cached listing and never refetched. Folders created outside the app stayed invisible for the whole TTL window. Refresh once when the picker mounts onto a warm atom that already holds settled data, on web (command palette) and mobile (add-project browser). Cold atoms keep their single initial fetch. Fixes pingdotgg/t3code#11476 Co-Authored-By: Claude Fable 5 --- .../features/projects/AddProjectScreen.tsx | 11 ++++--- apps/mobile/src/state/query.ts | 29 +++++++++++++++++++ apps/web/src/components/CommandPalette.tsx | 17 ++++++----- apps/web/src/state/query.ts | 29 +++++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5231228829d3..da638c5ba2dc 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -52,7 +52,7 @@ import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; -import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentQuery, useWarmEnvironmentQueryRevalidation } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -761,14 +761,17 @@ function FolderBrowser(props: { () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); - const browseState = useEnvironmentQuery( + const browseAtom = browseInput === null ? null : filesystemEnvironment.browse({ environmentId: props.environment.environmentId, input: browseInput, - }), - ); + }); + const browseState = useEnvironmentQuery(browseAtom); + // The browser unmounts with its screen while the browse atoms stay warm, so + // re-entering the add-project flow must revalidate against the filesystem. + useWarmEnvironmentQueryRevalidation(browseAtom); // A pinned repository folder does not exist yet, so filtering the listing by // it would empty the folder picker. Anything the user typed still filters. const pinnedDirectoryName = props.pinnedDirectoryName ?? ""; diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397bc..3d652f49f411 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -2,6 +2,7 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-environment-query:empty"), @@ -21,6 +22,34 @@ function formatError(cause: Cause.Cause): string { : "The environment request failed."; } +/** + * Revalidates an environment query that mounts onto a warm cached atom. + * + * Query atoms outlive their subscribers through an idle TTL, and the swr + * wrapper only re-checks staleness when the atom node is rebuilt. A view that + * remounts onto a warm node therefore renders the cached value and never + * refetches, which freezes reads whose ground truth changes outside the app, + * such as the filesystem browse listing. Refreshes once per atom when it + * already holds settled data at subscribe time; cold atoms are left to their + * own initial fetch. + */ +export function useWarmEnvironmentQueryRevalidation( + atom: Atom.Atom> | null, +): void { + const result = useAtomValue(atom ?? EMPTY_ASYNC_RESULT_ATOM); + const refresh = useAtomRefresh(atom ?? EMPTY_ASYNC_RESULT_ATOM); + const revalidatedAtom = useRef> | null>(null); + useEffect(() => { + if (atom === null || revalidatedAtom.current === atom) { + return; + } + revalidatedAtom.current = atom; + if (result._tag === "Success" && !result.waiting) { + refresh(); + } + }, [atom, refresh, result]); +} + export function useEnvironmentQuery( atom: Atom.Atom> | null, ): EnvironmentQueryView { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8af4419fca31..542ae5d347e1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -79,7 +79,7 @@ import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentQuery, useWarmEnvironmentQueryRevalidation } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; @@ -1006,11 +1006,11 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const browseQuery = useEnvironmentQuery( + const browseAtom = isBrowsing && - browsePath.directoryPath.length > 0 && - browseEnvironmentId !== null && - !relativePathNeedsActiveProject + browsePath.directoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { @@ -1018,8 +1018,11 @@ function OpenCommandPaletteDialog(props: { ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) - : null, - ); + : null; + const browseQuery = useEnvironmentQuery(browseAtom); + // The palette dialog unmounts on close while the browse atoms stay warm, so + // reopening the folder picker must revalidate against the real filesystem. + useWarmEnvironmentQueryRevalidation(browseAtom); const browseResult = browseQuery.data; const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index b2823a51e332..8aff582e8780 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -2,6 +2,7 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("web-environment-query:empty"), @@ -22,6 +23,34 @@ export function formatEnvironmentQueryError(cause: Cause.Cause): string : "The environment request failed."; } +/** + * Revalidates an environment query that mounts onto a warm cached atom. + * + * Query atoms outlive their subscribers through an idle TTL, and the swr + * wrapper only re-checks staleness when the atom node is rebuilt. A view that + * remounts onto a warm node therefore renders the cached value and never + * refetches, which freezes reads whose ground truth changes outside the app, + * such as the filesystem browse listing. Refreshes once per atom when it + * already holds settled data at subscribe time; cold atoms are left to their + * own initial fetch. + */ +export function useWarmEnvironmentQueryRevalidation( + atom: Atom.Atom> | null, +): void { + const result = useAtomValue(atom ?? EMPTY_ASYNC_RESULT_ATOM); + const refresh = useAtomRefresh(atom ?? EMPTY_ASYNC_RESULT_ATOM); + const revalidatedAtom = useRef> | null>(null); + useEffect(() => { + if (atom === null || revalidatedAtom.current === atom) { + return; + } + revalidatedAtom.current = atom; + if (result._tag === "Success" && !result.waiting) { + refresh(); + } + }, [atom, refresh, result]); +} + export function useEnvironmentQuery( atom: Atom.Atom> | null, ): EnvironmentQueryView { From d645e20600334bdc28525a157c1b2665840f7786 Mon Sep 17 00:00:00 2001 From: Anay Gupta Date: Sun, 13 Sep 2026 06:19:56 +0530 Subject: [PATCH 2/2] fix(web): retry warm browse failures and skip fresh prefetched listings Review follow-up: the warm-mount revalidation only refreshed cached successes, so a transient browse failure stayed rendered for the whole idle TTL, and it also refreshed listings the picker's own navigation had prefetched moments earlier, doubling every click-navigation request. Refresh any settled result, but skip successes younger than a short freshness window. Verified in the dev client: click navigation mounts the prefetched atom at ~35ms age (one request), a reopened picker mounts seconds-old data and revalidates. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/state/query.ts | 18 ++++++++++++++---- apps/web/src/state/query.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index 3d652f49f411..97ecc91f9a14 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -22,6 +22,10 @@ function formatError(cause: Cause.Cause): string { : "The environment request failed."; } +// A success the picker's own navigation prefetched moments before the atom +// mounts is fresh; only values that sat in the warm cache need revalidation. +const WARM_QUERY_FRESH_MS = 500; + /** * Revalidates an environment query that mounts onto a warm cached atom. * @@ -30,8 +34,10 @@ function formatError(cause: Cause.Cause): string { * remounts onto a warm node therefore renders the cached value and never * refetches, which freezes reads whose ground truth changes outside the app, * such as the filesystem browse listing. Refreshes once per atom when it - * already holds settled data at subscribe time; cold atoms are left to their - * own initial fetch. + * mounts holding a settled result: successes older than a short freshness + * window (so navigation prefetches are not fetched twice) and failures always + * (so a transient error does not stick for the whole TTL). Cold atoms are + * left to their own initial fetch. */ export function useWarmEnvironmentQueryRevalidation( atom: Atom.Atom> | null, @@ -44,9 +50,13 @@ export function useWarmEnvironmentQueryRevalidation( return; } revalidatedAtom.current = atom; - if (result._tag === "Success" && !result.waiting) { - refresh(); + if (result.waiting || result._tag === "Initial") { + return; + } + if (result._tag === "Success" && Date.now() - result.timestamp < WARM_QUERY_FRESH_MS) { + return; } + refresh(); }, [atom, refresh, result]); } diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 8aff582e8780..9e34b51c0567 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -23,6 +23,10 @@ export function formatEnvironmentQueryError(cause: Cause.Cause): string : "The environment request failed."; } +// A success the picker's own navigation prefetched moments before the atom +// mounts is fresh; only values that sat in the warm cache need revalidation. +const WARM_QUERY_FRESH_MS = 500; + /** * Revalidates an environment query that mounts onto a warm cached atom. * @@ -31,8 +35,10 @@ export function formatEnvironmentQueryError(cause: Cause.Cause): string * remounts onto a warm node therefore renders the cached value and never * refetches, which freezes reads whose ground truth changes outside the app, * such as the filesystem browse listing. Refreshes once per atom when it - * already holds settled data at subscribe time; cold atoms are left to their - * own initial fetch. + * mounts holding a settled result: successes older than a short freshness + * window (so navigation prefetches are not fetched twice) and failures always + * (so a transient error does not stick for the whole TTL). Cold atoms are + * left to their own initial fetch. */ export function useWarmEnvironmentQueryRevalidation( atom: Atom.Atom> | null, @@ -45,9 +51,13 @@ export function useWarmEnvironmentQueryRevalidation( return; } revalidatedAtom.current = atom; - if (result._tag === "Success" && !result.waiting) { - refresh(); + if (result.waiting || result._tag === "Initial") { + return; + } + if (result._tag === "Success" && Date.now() - result.timestamp < WARM_QUERY_FRESH_MS) { + return; } + refresh(); }, [atom, refresh, result]); }