Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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: 21 additions & 1 deletion apps/web/src/assets/assetUrls.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<AssetResource>,
Expand Down
47 changes: 38 additions & 9 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

Expand All @@ -13,44 +13,72 @@ 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 <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />;
return (
<ProjectFaviconFallback className={input.className} icon={FallbackIcon} onClick={refresh} />
);
}

return (
<ProjectFaviconImage
key={src}
key={`${input.environmentId}:${input.cwd}`}
src={src}
className={input.className}
fallbackIcon={FallbackIcon}
onClick={refresh}
Comment thread
cursor[bot] marked this conversation as resolved.
/>
);
}

function ProjectFaviconFallback({
className,
icon: Icon,
onClick,
}: {
readonly className?: string | undefined;
readonly icon: ComponentType<{ className?: string }>;
readonly onClick?: (() => void) | undefined;
}) {
return <Icon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />;
const icon = <Icon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />;
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 (
<span className="contents" onClick={onClick}>
{icon}
</span>
);
}

// 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",
Expand All @@ -59,11 +87,12 @@ function ProjectFaviconImage({
return (
Comment thread
colonelpanic8 marked this conversation as resolved.
<>
{status !== "loaded" ? (
<ProjectFaviconFallback className={className} icon={FallbackIcon} />
<ProjectFaviconFallback className={className} icon={FallbackIcon} onClick={onClick} />
) : null}
<img
src={src}
alt=""
onClick={onClick}
className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${className ?? ""}`}
onLoad={() => {
loadedProjectFaviconSrcs.add(src);
Expand Down
19 changes: 19 additions & 0 deletions packages/client-runtime/src/state/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/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,
Expand Down
Loading