diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc9..6459b85ab906 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -43,6 +43,26 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc return result.url; } +/** + * Re-issue an asset URL from the environment. + * + * Asset URLs embed the resolution the server performed when the URL was minted + * (for project favicons: which icon file was found, or that none was). Asking + * for a new URL therefore re-runs that resolution and yields a freshly signed + * URL, which also gets the browser past its cached copy of the old one. + */ +export function useRefreshAssetUrl( + environmentId: EnvironmentId, + resource: AssetResource, +): () => void { + return useAtomRefresh( + assetEnvironment.createUrl({ + environmentId, + input: { resource }, + }), + ); +} + export function useAssetUrls( environmentId: EnvironmentId, resources: ReadonlyArray, diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832ff..022e998e4940 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -3,7 +3,7 @@ import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrl, useRefreshAssetUrl } from "../assets/assetUrls"; const loadedProjectFaviconSrcs = new Set(); @@ -13,22 +13,32 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const resource = { _tag: "project-favicon", cwd: input.cwd } as const; + const src = useAssetUrl(input.environmentId, resource); + // Clicking an icon re-issues its asset URL, which makes the server resolve + // the project's icon from scratch. That is what picks up an icon that was + // just added, replaced, or moved — the URL bakes in the file the server + // found when it was minted, so cache-busting the old URL would keep + // fetching the old file. Every icon for this project shares one query, so + // they all refresh together. + const refresh = useRefreshAssetUrl(input.environmentId, resource); const FallbackIcon = input.fallbackIcon ?? FolderIcon; + // The click is deliberately not consumed: it still bubbles to the + // surrounding row, so pressing the icon both opens the row and refreshes. if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ( + + ); } return ( ); } @@ -36,21 +46,39 @@ export function ProjectFavicon(input: { function ProjectFaviconFallback({ className, icon: Icon, + onClick, }: { readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; + readonly onClick?: (() => void) | undefined; }) { - return ; + const icon = ; + if (onClick === undefined) { + return icon; + } + // A project showing the placeholder still needs a refresh target, otherwise + // an icon it just gained could never be picked up by clicking. `contents` + // keeps the wrapper out of layout while giving the click somewhere to land. + return ( + + {icon} + + ); } +// Keyed by environment and cwd rather than `src`: switching projects resets +// stale image state, while refreshing one project's URL keeps its current +// image visible until the new bytes decode. function ProjectFaviconImage({ src, className, fallbackIcon: FallbackIcon, + onClick, }: { readonly src: string; readonly className?: string | undefined; readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly onClick: () => void; }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", @@ -59,11 +87,12 @@ function ProjectFaviconImage({ return ( <> {status !== "loaded" ? ( - + ) : null} { loadedProjectFaviconSrcs.add(src); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 58add31d6bbe..ea873a8156a2 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -80,6 +80,25 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrl(originalTarget)); }); + it("forwards manual refreshes so a stale asset URL can be re-issued", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< + EnvironmentRegistry, + never + >; + const assets = createAssetEnvironmentAtoms(runtime); + const atom = assets.createUrl({ + environmentId: EnvironmentId.make("environment-1"), + input: { + resource: { _tag: "project-favicon", cwd: "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/repo/original" }, + }, + }); + + // Clicking a project favicon re-issues its URL so the server resolves the + // project's icon again. That only works while the query atom carries a + // refresh hook down to the underlying request. + expect(typeof atom.refresh).toBe("function"); + }); + it("keys collections while preserving independent resource queries", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< EnvironmentRegistry,