Skip to content

Commit 8e86eb2

Browse files
fix(client-runtime): searches and refreshes no longer wait on an unreachable environment (#25703)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5811d5c commit 8e86eb2

11 files changed

Lines changed: 479 additions & 30 deletions

File tree

‎apps/mobile/src/features/files/useFileTreeEntries.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import { useAtomMount, useAtomSubscribe } from "@effect/atom-react";
12
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
23
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
34
import * as Cause from "effect/Cause";
5+
import { Atom } from "effect/reactivity";
46
import { useEffect, useReducer, useRef, useState } from "react";
57

8+
import { environmentCatalog } from "../../connection/catalog";
69
import { appAtomRegistry } from "../../state/atom-registry";
710
import { projectEnvironment } from "../../state/projects";
811
import { useDebouncedValue } from "../../state/queries";
912
import { useEnvironmentQuery } from "../../state/query";
1013

14+
const noSessionGeneration = Atom.make(0);
15+
1116
export function useFileTreeEntries(input: {
1217
readonly cwd: string | null;
1318
readonly environmentId: EnvironmentId | null;
@@ -94,6 +99,17 @@ export function useFileTreeEntries(input: {
9499
render();
95100
});
96101
};
102+
// A folder read fails at once while its environment is unreachable; read it again once a new
103+
// session is up rather than leaving the error until someone refreshes.
104+
const sessionGeneration =
105+
environmentId === null
106+
? noSessionGeneration
107+
: environmentCatalog.sessionGenerationAtom(environmentId);
108+
// A listener alone does not compute the atom, so nothing would follow the connection.
109+
useAtomMount(sessionGeneration);
110+
useAtomSubscribe(sessionGeneration, () => {
111+
for (const path of directories.errors.keys()) void loadDirectory(path);
112+
});
97113
const { refresh: refreshRoot, data: rootData } = root;
98114
const { refresh: refreshSearch, data: searchData } = search;
99115
const snapshot = (() => {

‎apps/mobile/src/features/pull-requests/usePullRequestList.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { RegistryContext, useAtomValue } from "@effect/atom-react";
1+
import { RegistryContext, useAtomMount, useAtomSubscribe, useAtomValue } from "@effect/atom-react";
22
import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
33
import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts";
44
import * as Cause from "effect/Cause";
55
import * as Effect from "effect/Effect";
66
import * as Option from "effect/Option";
77
import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";
88
import { use, useCallback, useMemo, useState } from "react";
9+
import { environmentCatalog } from "../../connection/catalog";
910
import {
1011
composerPullRequests,
1112
pullRequestListPagesFamily,
@@ -142,6 +143,15 @@ const refreshLoadFamily = Atom.family((scopeKey: string) =>
142143
),
143144
);
144145

146+
// Changes when any of the scope's environments brings up a new session.
147+
const sessionsFamily = Atom.family((scopeKey: string) =>
148+
Atom.make((get) =>
149+
(JSON.parse(scopeKey) as readonly ListTarget[])
150+
.map(({ environmentId }) => get(environmentCatalog.sessionGenerationAtom(environmentId)))
151+
.join(),
152+
),
153+
);
154+
145155
const settledError = (result: AsyncResult.AsyncResult<string | null, never>) =>
146156
AsyncResult.isSuccess(result) && !result.waiting ? result.value : null;
147157

@@ -156,6 +166,19 @@ export function usePullRequestList(targets: readonly ListTarget[]) {
156166
// Starting one kind of load clears the other, so at most one holds an error.
157167
const loadError = settledError(refresh) ?? settledError(page);
158168

169+
// A page or refresh asked for while an environment was unreachable fails at once, and its
170+
// error stops loading on scroll. Once it is back, ask for the page again; a refresh's error is
171+
// stale by then because the list queries re-run on reconnect.
172+
// A listener alone does not compute the atom, so nothing would follow the connections.
173+
useAtomMount(sessionsFamily(scopeKey));
174+
useAtomSubscribe(sessionsFamily(scopeKey), () => {
175+
const pageResult = registry.get(pageAtom);
176+
const refreshResult = registry.get(refreshAtom);
177+
if (settledError(refreshResult) !== null) registry.set(refreshAtom, Atom.Reset);
178+
else if (settledError(pageResult) !== null && !refreshResult.waiting)
179+
registry.set(pageAtom, true);
180+
});
181+
159182
// Read from the registry, not the render: a second onEndReached before React re-renders must
160183
// see the load the first one started.
161184
const loadMore = (retry = false) => {

‎apps/mobile/src/state/assets.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const state = vi.hoisted(() => ({
1919

2020
vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback }));
2121
vi.mock("@effect/atom-react", () => ({
22+
useAtomMount: () => undefined,
23+
useAtomSubscribe: () => undefined,
2224
useAtomValue: (atom: unknown) =>
2325
atom === state.assetAtom
2426
? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })
@@ -39,6 +41,9 @@ vi.mock("./query", () => ({
3941
}));
4042
vi.mock("./projectClones", () => ({ environmentProjectCloneListAtom: () => null }));
4143
vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
44+
vi.mock("../connection/catalog", () => ({
45+
environmentCatalog: { sessionGenerationAtom: () => null },
46+
}));
4247
vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({
4348
...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()),
4449
createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }),

‎apps/mobile/src/state/assets.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useAtomValue } from "@effect/atom-react";
1+
import { useAtomMount, useAtomSubscribe, useAtomValue } from "@effect/atom-react";
22
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
33
import {
44
assetUrlStateFromResult,
@@ -13,6 +13,7 @@ import * as Effect from "effect/Effect";
1313
import { Atom, AsyncResult } from "effect/reactivity";
1414
import { useCallback } from "react";
1515

16+
import { environmentCatalog } from "../connection/catalog";
1617
import { connectionAtomRuntime } from "../connection/runtime";
1718
import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
1819
import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
@@ -112,7 +113,7 @@ export async function refreshAssetUrl(
112113
reportFailure: false,
113114
...(signal ? { signal } : {}),
114115
});
115-
// The query can wait through a reconnect; resolve against the connection that answered it.
116+
// A route can change while the query runs; resolve against the connection now current.
116117
const connection = appAtomRegistry.get(connectionAtom);
117118
if (connection._tag === "None") return null;
118119
const state = assetUrlStateFromResult(result, connection.value.httpBaseUrl);
@@ -154,6 +155,7 @@ const freshAssetUrlFamily = Atom.family((key: FreshAssetUrlKey) =>
154155
const noFreshAssetUrl = Atom.make(AsyncResult.initial<AuthorizedAssetUrl, Error>()).pipe(
155156
Atom.withLabel("fresh-asset-url:none"),
156157
);
158+
const noSessionGeneration = Atom.make(0);
157159

158160
/**
159161
* A signed URL minted when the reader mounts, not a cached one: cached links can
@@ -166,9 +168,20 @@ export function useFreshAssetUrl(
166168
): AsyncResult.AsyncResult<AuthorizedAssetUrl, Error> {
167169
const connection = usePreparedConnection(environmentId);
168170
const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
169-
return useAtomValue(
171+
const atom =
170172
resource === null
171173
? noFreshAssetUrl
172-
: freshAssetUrlFamily({ environmentId, resource, httpBaseUrl, revision }),
173-
);
174+
: freshAssetUrlFamily({ environmentId, resource, httpBaseUrl, revision });
175+
// Minting fails at once while the environment is unreachable; mint again once a new session is
176+
// up instead of keeping the error until Retry.
177+
const sessionGeneration =
178+
environmentId === null
179+
? noSessionGeneration
180+
: environmentCatalog.sessionGenerationAtom(environmentId);
181+
// A listener alone does not compute the atom, so nothing would follow the connection.
182+
useAtomMount(sessionGeneration);
183+
useAtomSubscribe(sessionGeneration, () => {
184+
if (AsyncResult.isFailure(appAtomRegistry.get(atom))) appAtomRegistry.refresh(atom);
185+
});
186+
return useAtomValue(atom);
174187
}

‎apps/web/src/components/chat/useComposerContextImport.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import {
2+
ConnectionBlockedError,
3+
ConnectionTransientError,
4+
} from "@t3tools/client-runtime/connection";
5+
import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc";
6+
import * as Cause from "effect/Cause";
7+
import * as Schema from "effect/Schema";
18
import type { RefObject } from "react";
29
import type { ChatComposerProps } from "./ChatComposer";
310
import {
@@ -49,6 +56,10 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
4956
import { useStableCallback } from "../../hooks/useStableCallback";
5057
import { countPreparingComposerImages } from "./preparingComposerImages";
5158

59+
const isUnreachable = Schema.is(
60+
Schema.Union([ConnectionTransientError, ConnectionBlockedError, EnvironmentRpcUnavailableError]),
61+
);
62+
5263
async function downloadAttachmentBlob(url: string): Promise<Blob | null> {
5364
try {
5465
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
@@ -162,7 +173,12 @@ export function useComposerContextImport(input: {
162173
? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl)
163174
: null;
164175
if (!url) {
165-
fail("The original attachment is no longer available.");
176+
const cause = result._tag === "Failure" ? Cause.squash(result.cause) : null;
177+
fail(
178+
isUnreachable(cause)
179+
? "The environment it came from is not reachable right now."
180+
: "The original attachment is no longer available.",
181+
);
166182
return;
167183
}
168184
const blob = await downloadAttachmentBlob(url);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { RegistryContext } from "@effect/atom-react";
2+
import { EnvironmentId } from "@t3tools/contracts";
3+
import * as Cause from "effect/Cause";
4+
import { AsyncResult, Atom } from "effect/reactivity";
5+
import { describe, expect, it, vi } from "vite-plus/test";
6+
7+
import { appAtomRegistry } from "~/rpc/atomRegistry";
8+
import { act, render } from "../../../test/render";
9+
import { useDirectoryEntries } from "./useDirectoryEntries";
10+
11+
const mocks = vi.hoisted(() => ({
12+
reachable: null as unknown as Atom.Writable<boolean>,
13+
connectedSession: null as unknown as Atom.Writable<number>,
14+
}));
15+
16+
vi.mock("~/connection/catalog", () => ({
17+
// Derived like the real one, which only follows the connection once something computes it.
18+
environmentCatalog: {
19+
sessionGenerationAtom: () => Atom.make((get) => get(mocks.connectedSession)),
20+
},
21+
}));
22+
vi.mock("~/state/projects", () => ({
23+
projectEnvironment: {
24+
listEntries: () =>
25+
Atom.make((get) =>
26+
get(mocks.reachable)
27+
? AsyncResult.success({
28+
entries: [{ path: "README.md", kind: "file" }],
29+
truncated: false,
30+
})
31+
: AsyncResult.failure(Cause.fail(new Error("Environment is reconnecting."))),
32+
),
33+
},
34+
}));
35+
36+
function setup() {
37+
mocks.reachable = Atom.make(false);
38+
mocks.connectedSession = Atom.make(1);
39+
}
40+
41+
function Probe() {
42+
const directory = useDirectoryEntries(EnvironmentId.make("env-files"), "/repo");
43+
return (
44+
<output>{directory.error ?? directory.entries.map((entry) => entry.path).join(",")}</output>
45+
);
46+
}
47+
48+
describe("useDirectoryEntries", () => {
49+
it("reads a folder that failed while unreachable again once a new session is up", async () => {
50+
setup();
51+
await using screen = await render(
52+
<RegistryContext.Provider value={appAtomRegistry}>
53+
<Probe />
54+
</RegistryContext.Provider>,
55+
// A bare hook probe; nothing here navigates.
56+
{ router: false },
57+
);
58+
await expect.element(screen.getByText("Environment is reconnecting.")).toBeInTheDocument();
59+
60+
await act(() => {
61+
appAtomRegistry.set(mocks.reachable, true);
62+
appAtomRegistry.set(mocks.connectedSession, 2);
63+
});
64+
await expect.element(screen.getByText("README.md")).toBeInTheDocument();
65+
});
66+
});

‎apps/web/src/components/files/useDirectoryEntries.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2+
import { useAtomMount, useAtomSubscribe } from "@effect/atom-react";
23
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
34
import * as Cause from "effect/Cause";
45
import { useEffect, useRef, useState } from "react";
56

7+
import { environmentCatalog } from "~/connection/catalog";
68
import { appAtomRegistry } from "~/rpc/atomRegistry";
79
import { projectEnvironment } from "~/state/projects";
810

@@ -100,6 +102,15 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
100102
};
101103
}, [startRequest]);
102104

105+
// A folder read fails at once while its environment is unreachable; read it again once a new
106+
// session is up rather than leaving the error until someone retries by hand.
107+
const sessionGeneration = environmentCatalog.sessionGenerationAtom(environmentId);
108+
// A listener alone does not compute the atom, so nothing would follow the connection.
109+
useAtomMount(sessionGeneration);
110+
useAtomSubscribe(sessionGeneration, () => {
111+
for (const path of errors.keys()) void load(path);
112+
});
113+
103114
const entries: ProjectEntry[] = [];
104115
const visit = (path: string) => {
105116
for (const entry of directories.get(path) ?? []) {

0 commit comments

Comments
 (0)