Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions apps/mobile/src/components/ProjectFavicon.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import { useAssetUrl } from "../state/assets";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import { projectFaviconUrlAtom } from "../state/assets";

import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
Expand All @@ -16,6 +19,8 @@ import {
markProjectFaviconLoaded,
} from "./projectFaviconCache";

const EMPTY_FAVICON_URL = Atom.make<string | null>(null);

/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
Expand All @@ -26,15 +31,14 @@ export function ProjectFavicon(props: {
readonly faviconPath?: string | null;
}) {
const size = props.size ?? 42;
const faviconUrl = useAssetUrl(
props.environmentId,
props.workspaceRoot === null || props.workspaceRoot === undefined
? null
: {
_tag: "project-favicon",
const faviconUrl = useAtomValue(
props.workspaceRoot == null
? EMPTY_FAVICON_URL
: projectFaviconUrlAtom({
environmentId: props.environmentId,
cwd: props.workspaceRoot,
...(props.faviconPath ? { path: props.faviconPath } : {}),
},
faviconPath: props.faviconPath,
}),
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
const cacheKey =
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
assetUrlStateFromResult,
createAssetEnvironmentAtoms,
createProjectFaviconUrlAtomFamily,
EMPTY_ASSET_URL_ATOM,
} from "@t3tools/client-runtime/state/assets";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
Expand All @@ -16,13 +17,18 @@ import { useCallback } from "react";
import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
import { usePreparedConnection } from "./session";
import { environmentSession, usePreparedConnection } from "./session";
import { useAtomQueryRunner } from "./use-atom-query-runner";

export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";

export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);

export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
createUrl: assetEnvironment.createUrl,
preparedConnection: environmentSession.preparedConnectionValueAtom,
});

const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
Atom.withLabel("mobile-asset-connection-state:empty"),
);
Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"

const testState = vi.hoisted(() => ({
faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
lastResource: null as unknown,
lastTarget: null as unknown,
}));

const hooks = vi.hoisted(() => {
Expand Down Expand Up @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({
DynamicIcon: "dynamic-icon",
iconNames: ["alarm-clock", "folder-code"],
}));
vi.mock("../assets/assetUrls", () => ({
useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
testState.lastResource = resource;
return { _tag: "Success", url: testState.faviconUrl };
vi.mock("@effect/atom-react", () => ({
useAtomValue: () => testState.faviconUrl,
}));
vi.mock("../state/assets", () => ({
projectFaviconUrlAtom: (input: unknown) => {
testState.lastTarget = input;
},
}));

Expand Down Expand Up @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => {
faviconPath: "brand/icon.svg",
});

expect(testState.lastResource).toEqual({
_tag: "project-favicon",
expect(testState.lastTarget).toMatchObject({
environmentId: "environment-test",
cwd: "/workspace-test",
path: "brand/icon.svg",
faviconPath: "brand/icon.svg",
});
});
});
18 changes: 3 additions & 15 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
import type { IconName } from "lucide-react/dynamic";
import type { ComponentType } from "react";
import { lazy, Suspense, useState } from "react";
import { useAssetUrlState } from "../assets/assetUrls";
import { useAtomValue } from "@effect/atom-react";
import { projectFaviconUrlAtom } from "../state/assets";
import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
import { projectIconColorClassName } from "../projectIconColors";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -103,8 +104,7 @@ export function ProjectFavicon(input: {
className?: string | undefined;
fallbackIcon?: ComponentType<{ className?: string }>;
}) {
const state = useProjectFaviconAsset(input);
const src = state._tag === "Success" ? state.url : null;
const src = useAtomValue(projectFaviconUrlAtom(input));
if (input.projectIcon?.kind === "emoji") {
return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;
}
Expand Down Expand Up @@ -165,18 +165,6 @@ export function ProjectFavicon(input: {
);
}

export function useProjectFaviconAsset(input: {
readonly environmentId: EnvironmentId;
readonly cwd: string;
readonly faviconPath?: string | null | undefined;
}) {
return useAssetUrlState(input.environmentId, {
_tag: "project-favicon",
cwd: input.cwd,
...(input.faviconPath ? { path: input.faviconPath } : {}),
});
}

function ProjectFaviconFallback({
className,
colorClassName,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/preview/PreviewView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({
useThreadRecentHistory: () => EMPTY_HISTORY,
}));

vi.mock("~/state/session", () => ({
vi.mock("~/state/session", async (importOriginal) => ({
...(await importOriginal<typeof import("~/state/session")>()),
readPreparedConnection: mocks.readPreparedConnection,
}));

Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/state/assets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets";
import {
createAssetEnvironmentAtoms,
createProjectFaviconUrlAtomFamily,
} from "@t3tools/client-runtime/state/assets";

import { connectionAtomRuntime } from "../connection/runtime";
import { environmentSession } from "./session";

export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);

export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
createUrl: assetEnvironment.createUrl,
preparedConnection: environmentSession.preparedConnectionValueAtom,
});
101 changes: 99 additions & 2 deletions packages/client-runtime/src/state/assets.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { describe, expect, it } from "@effect/vitest";
import { EnvironmentId } from "@t3tools/contracts";
import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";

import type { EnvironmentRegistry } from "../connection/registry.ts";
import {
createAssetEnvironmentAtoms,
createProjectFaviconUrlAtomFamily,
InvalidAssetCollectionKeyError,
parseAssetCollectionKey,
} from "./assets.ts";
Expand Down Expand Up @@ -118,3 +121,97 @@ describe("createAssetEnvironmentAtoms", () => {
).not.toBe(assets.createUrls({ environmentId, resources }));
});
});

describe("project favicon URL cache", () => {
it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => {
const registry = AtomRegistry.make();
const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>(
AsyncResult.initial(),
);
const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" }));
const favicon = createProjectFaviconUrlAtomFamily({
createUrl: () => result,
preparedConnection: () => connection,
})({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" });
let unmount = registry.mount(favicon);
try {
expect(registry.get(favicon)).toBeNull();
registry.set(
result,
AsyncResult.success({
expiresAt: 4_000_000_000_000,
relativeUrl: "/api/assets/token-a/icon.svg",
}),
);
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg");

registry.set(connection, Option.none());
registry.set(result, AsyncResult.failure(Cause.die("disconnected")));
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg");
unmount();
unmount = registry.mount(favicon);
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg");

registry.set(result, AsyncResult.initial());
registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" }));
expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg");
registry.set(
result,
AsyncResult.success({
expiresAt: 4_000_000_000_000,
relativeUrl: "/api/assets/token-b/icon.svg",
}),
);
expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg");

registry.set(
result,
AsyncResult.success({
expiresAt: 4_000_000_000_000,
relativeUrl: "/api/assets/token-c/project-favicon-missing",
}),
);
expect(registry.get(favicon)).toBe(
"https://reconnected.test/api/assets/token-c/project-favicon-missing",
);
registry.set(connection, Option.none());
expect(registry.get(favicon)).toBe(
"https://reconnected.test/api/assets/token-c/project-favicon-missing",
);
} finally {
unmount();
registry.dispose();
}
});

it("does not reuse another environment, workspace, or selected icon's cached URL", () => {
const registry = AtomRegistry.make();
const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>(
AsyncResult.success({
expiresAt: 4_000_000_000_000,
relativeUrl: "/api/assets/token/icon.svg",
}),
);
const favicon = createProjectFaviconUrlAtomFamily({
createUrl: () => result,
preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })),
});
const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" };
const unmount = registry.mount(favicon(target));
try {
expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg");
registry.set(result, AsyncResult.failure(Cause.die("disconnected")));
expect(
registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })),
).toBeNull();
expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull();
expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull();
expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe(
"https://remote.test/api/assets/token/icon.svg",
);
} finally {
unmount();
registry.dispose();
}
});
});
37 changes: 37 additions & 0 deletions packages/client-runtime/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
EnvironmentId,
WS_METHODS,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { AsyncResult, Atom } from "effect/unstable/reactivity";

Expand Down Expand Up @@ -112,3 +113,39 @@ export function createAssetEnvironmentAtoms<R, E>(
}) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),
};
}

/**
* Keeps project icons visible while their environment reconnects. Each resource
* owns its last resolved URL, including a confirmed missing-icon response.
*/
export function createProjectFaviconUrlAtomFamily(input: {
readonly createUrl: (target: {
readonly environmentId: EnvironmentId;
readonly input: { readonly resource: AssetResource };
}) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>;
readonly preparedConnection: (
environmentId: EnvironmentId,
) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;
}) {
const decodeKey = Schema.decodeUnknownSync(
Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
);
const family = Atom.family((key: string) => {
const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
Comment thread
StiensWout marked this conversation as resolved.
const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
return Atom.make((get): string | null => {
const result = get(input.createUrl({ environmentId, input: { resource } }));
const connection = get(input.preparedConnection(environmentId));
const state = assetUrlStateFromResult(
result,
Option.isSome(connection) ? connection.value.httpBaseUrl : null,
);
return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>());
}).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
});
return (target: {
readonly environmentId: EnvironmentId;
readonly cwd: string;
readonly faviconPath?: string | null | undefined;
}) => family(JSON.stringify([target.environmentId, target.cwd, target.faviconPath || null]));
}
Loading