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..97ecc91f9a14 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,44 @@ 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. + * + * 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 + * 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, +): 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.waiting || result._tag === "Initial") { + return; + } + if (result._tag === "Success" && Date.now() - result.timestamp < WARM_QUERY_FRESH_MS) { + return; + } + 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..9e34b51c0567 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,44 @@ 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. + * + * 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 + * 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, +): 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.waiting || result._tag === "Initial") { + return; + } + if (result._tag === "Success" && Date.now() - result.timestamp < WARM_QUERY_FRESH_MS) { + return; + } + refresh(); + }, [atom, refresh, result]); +} + export function useEnvironmentQuery( atom: Atom.Atom> | null, ): EnvironmentQueryView {