From 046009cc55c68d2ed90b656fecbc9b7f6c07d032 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 15:32:08 -0700 Subject: [PATCH 1/2] Refresh a project favicon when its icon is clicked Project favicon asset URLs are signed tokens that bake in the file the server resolved when the URL was minted, so a project whose icon changed on disk keeps showing the old one until the app reloads. Worse, a project with no icon renders a placeholder rather than an , so there was no way at all to pick up an icon it just gained. Clicking a project icon now re-issues its asset URL. The server resolves the project's icon again, so a newly added, replaced, or moved icon is picked up, and the freshly signed URL also gets the browser past its cached copy. The placeholder is clickable too, and because every instance of a project's icon shares one query, they all refresh together. The click is not consumed, so pressing an icon in a sidebar row still opens the row as before. Co-Authored-By: Claude Opus 5 --- apps/web/src/assets/assetUrls.ts | 22 ++++++++- apps/web/src/components/ProjectFavicon.tsx | 46 +++++++++++++++---- .../client-runtime/src/state/assets.test.ts | 19 ++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) 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..4080e13b5423 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,31 @@ 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 +45,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} + + ); } +// Not keyed on `src`: a refresh mints a new URL for the same icon, and +// remounting would drop back to the placeholder for a frame. Reusing the +// element lets the browser hold the current image 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 +86,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, From 37604c99b46f990e333033ec143f391719d05093 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 18:36:08 -0700 Subject: [PATCH 2/2] fix(web): reset favicon state when project scope changes --- apps/web/src/components/ProjectFavicon.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 4080e13b5423..022e998e4940 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -34,6 +34,7 @@ export function ProjectFavicon(input: { return (