From 7ba3a0da9a4efce1f56af14310f5e85860ab3777 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 21 May 2026 01:34:15 +0200 Subject: [PATCH] fix(frontend): custom actor icons not displaying --- frontend/src/app/actor-builds-list.tsx | 99 ++------------ frontend/src/app/actors-grid.tsx | 112 ++++----------- .../data-providers/engine-data-provider.tsx | 47 ++++--- frontend/src/components/lazy-icon.stories.tsx | 74 ++++++++++ frontend/src/components/lazy-icon.tsx | 127 ++++++++++++++++++ frontend/src/components/ui/icon-picker.tsx | 49 +------ 6 files changed, 267 insertions(+), 241 deletions(-) create mode 100644 frontend/src/components/lazy-icon.stories.tsx create mode 100644 frontend/src/components/lazy-icon.tsx diff --git a/frontend/src/app/actor-builds-list.tsx b/frontend/src/app/actor-builds-list.tsx index 637b11b08a..8533728620 100644 --- a/frontend/src/app/actor-builds-list.tsx +++ b/frontend/src/app/actor-builds-list.tsx @@ -1,91 +1,15 @@ -import { faActorsBorderless, Icon, type IconProp } from "@rivet-gg/icons"; import { useInfiniteQuery, usePrefetchInfiniteQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; -import { - Fragment, - type LazyExoticComponent, - lazy, - type ReactNode, - Suspense, -} from "react"; +import { Fragment } from "react"; import { Button, cn, Skeleton } from "@/components"; +import { ActorIcon } from "@/components/lazy-icon"; import { useEngineCompatDataProvider } from "@/components/actors"; import { VisibilitySensor } from "@/components/visibility-sensor"; import { features } from "@/lib/features"; import { RECORDS_PER_PAGE } from "./data-providers/default-data-provider"; -const emojiRegex = - /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u; - -function isEmoji(str: string): boolean { - return emojiRegex.test(str); -} - -function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -function toPascalCase(str: string): string { - return str - .split("-") - .map((part) => capitalize(part)) - .join(""); -} - -const iconModules = import.meta.glob>( - "../../packages/icons/dist/icons/*.js", -); - -const lazyIconCache = new Map< - string, - LazyExoticComponent<(props: { className?: string }) => ReactNode> ->(); - -function getLazyIcon( - iconName: string, -): LazyExoticComponent<(props: { className?: string }) => ReactNode> { - let component = lazyIconCache.get(iconName); - if (!component) { - const loader = - iconModules[`../../packages/icons/dist/icons/${iconName}.js`]; - component = lazy(() => - (loader ? loader() : Promise.reject()) - .then((mod) => ({ - default: ({ className }: { className?: string }) => ( - - ), - })) - .catch(() => ({ - default: ({ className }: { className?: string }) => ( - - ), - })), - ); - lazyIconCache.set(iconName, component); - } - return component; -} - -function ActorIcon({ iconValue }: { iconValue: string | null }) { - const className = - "opacity-80 group-hover:opacity-100 group-data-active:opacity-100"; - - if (iconValue && isEmoji(iconValue)) { - return {iconValue}; - } - - const iconName = iconValue ? `fa${toPascalCase(iconValue)}` : null; - - if (!iconName) { - return ; - } - - const LazyIcon = getLazyIcon(iconName); - return ; -} +const ICON_CLASS = + "opacity-80 group-hover:opacity-100 group-data-active:opacity-100"; export function ActorBuildsList() { usePrefetchInfiniteQuery({...useEngineCompatDataProvider().buildsQueryOptions(), pages: Infinity}); @@ -123,16 +47,11 @@ export function ActorBuildsList() { "data-active:text-foreground data-active:bg-foreground/[0.06]", )} startIcon={ - - } - > - - + } variant={"ghost"} size="sm" diff --git a/frontend/src/app/actors-grid.tsx b/frontend/src/app/actors-grid.tsx index dfa540648e..3d827fa484 100644 --- a/frontend/src/app/actors-grid.tsx +++ b/frontend/src/app/actors-grid.tsx @@ -1,10 +1,4 @@ -import { - faActorsBorderless, - faGear, - faPlus, - Icon, - type IconProp, -} from "@rivet-gg/icons"; +import { faGear, faPlus, Icon } from "@rivet-gg/icons"; import { queryOptions, useInfiniteQuery, @@ -12,7 +6,7 @@ import { useSuspenseInfiniteQuery, } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; -import { lazy, Suspense, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { Button, cn, @@ -22,73 +16,12 @@ import { SmallText, WithTooltip, } from "@/components"; +import { ActorIcon } from "@/components/lazy-icon"; import { useDataProvider, useCloudNamespaceDataProvider } from "@/components/actors"; +import { VisibilitySensor } from "@/components/visibility-sensor"; import { ImagesTable } from "@/app/images-table"; import { NoProvidersAlert } from "@/components/actors/no-providers-alert"; -const emojiRegex = - /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u; - -function isEmoji(str: string): boolean { - return emojiRegex.test(str); -} - -function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -function toPascalCase(str: string): string { - return str - .split("-") - .map((part) => capitalize(part)) - .join(""); -} - -const iconModules = import.meta.glob>( - "../../packages/icons/dist/icons/*.js", -); - -function getLazyIcon(iconName: string) { - const loader = iconModules[`../../packages/icons/dist/icons/${iconName}.js`]; - return lazy(() => - (loader ? loader() : Promise.reject()) - .then((mod) => ({ - default: ({ className }: { className?: string }) => ( - - ), - })) - .catch(() => ({ - default: ({ className }: { className?: string }) => ( - - ), - })), - ); -} - -function ActorIcon({ - iconValue, - className, -}: { - iconValue: string | null; - className?: string; -}) { - if (iconValue && isEmoji(iconValue)) { - return {iconValue}; - } - - const iconName = iconValue ? `fa${toPascalCase(iconValue)}` : null; - - if (!iconName) { - return ; - } - - const LazyIcon = getLazyIcon(iconName); - return ; -} - function GridCard({ children, className, @@ -135,9 +68,8 @@ export function ActorsGrid({ }) { const dataProvider = useDataProvider(); const navigate = useNavigate(); - const { data, isLoading } = useInfiniteQuery( - dataProvider.buildsQueryOptions(), - ); + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + useInfiniteQuery(dataProvider.buildsQueryOptions()); const { data: runnerNamesCount = 0 } = useInfiniteQuery({ ...dataProvider.runnerNamesQueryOptions(), select: (data) => data.pages.flatMap((page) => page.names).length, @@ -247,7 +179,8 @@ export function ActorsGrid({ ) ) : ( -
+ <> +
{sorted.map((build) => { const meta = build.name.metadata as | Record @@ -279,19 +212,10 @@ export function ActorsGrid({ )} >
- - } - > - - +
{displayName} @@ -304,7 +228,17 @@ export function ActorsGrid({ ); })} -
+ {isFetchingNextPage + ? Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton loaders are static + + )) + : null} +
+ {hasNextPage && !isFetchingNextPage ? ( + + ) : null} + )} diff --git a/frontend/src/app/data-providers/engine-data-provider.tsx b/frontend/src/app/data-providers/engine-data-provider.tsx index ba9af9be5b..98a081a98c 100644 --- a/frontend/src/app/data-providers/engine-data-provider.tsx +++ b/frontend/src/app/data-providers/engine-data-provider.tsx @@ -825,28 +825,45 @@ export const createNamespaceContext = ({ queryKey: [{ namespace }, "actors", "count"] as QueryKey, enabled: true, queryFn: async () => { - // TODO: fetch all actor names only to get the count is inefficient + // TODO: Replace this whole probe with a single request once the + // engine supports namespace-wide actor existence. The /actors + // list endpoint currently requires a name (or actor_ids), so we + // cannot ask "does this namespace have any actor?" in one call. + // Add either a no-name namespace-wide list/scan or a dedicated + // "has actors / count" endpoint, then this becomes one request. + // + // Every consumer only checks whether the result is > 0, so this + // resolves to 0 or 1 rather than a true total. Until the engine + // change lands, existence is probed per name in parallel batches, + // stopping at the first actor found. A namespace with actors + // usually resolves in the first batch; only an empty (onboarding) + // namespace pays a full scan, where the name list is small. const namesList = await client.actorsListNames({ namespace, limit: 100, }); const names = Object.keys(namesList.names); + const BATCH_SIZE = 32; + + for (let i = 0; i < names.length; i += BATCH_SIZE) { + const batch = names.slice(i, i + BATCH_SIZE); + const results = await Promise.all( + batch.map((name) => + client.actorsList({ + namespace, + name, + limit: 1, + includeDestroyed: true, + }), + ), + ); + if (results.some((r) => r.actors.length > 0)) { + return 1; + } + } - const data = await Promise.all( - names.map((name) => - client.actorsList({ - namespace, - name, - limit: 1, - includeDestroyed: true, - }), - ), - ); - return data.reduce( - (acc, curr) => acc + curr.actors.length, - 0, - ); + return 0; }, retry: shouldRetryAllExpect403, throwOnError: noThrow, diff --git a/frontend/src/components/lazy-icon.stories.tsx b/frontend/src/components/lazy-icon.stories.tsx new file mode 100644 index 0000000000..e7203bd26f --- /dev/null +++ b/frontend/src/components/lazy-icon.stories.tsx @@ -0,0 +1,74 @@ +import { faQuestion } from "@rivet-gg/icons"; +import type { Story } from "@ladle/react"; +import { Suspense } from "react"; +import "../../.ladle/ladle.css"; +import { ActorIcon, LazyIcon } from "./lazy-icon"; + +function Frame({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Cell({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {children} +
+ {label} +
+ ); +} + +// Covers every branch of ActorIcon. Emoji and absent values render +// synchronously; a valid name lazily loads the real glyph; an unknown name and +// a null value both fall back to the default actor icon (this is the state that +// looked identical to the loading pulse and hid the re-render bug). +export const ActorIconStates: Story = () => ( + +
+ + + + + + + + + + + + + + + + + + +
+ +); + +// LazyIcon is the low-level primitive: it always resolves to a glyph (the real +// icon, or the provided fallback for an unknown name) and the caller owns the +// Suspense boundary. +export const LazyIconPrimitive: Story = () => ( + + +
+ + + + + + + + + +
+
+ +); diff --git a/frontend/src/components/lazy-icon.tsx b/frontend/src/components/lazy-icon.tsx new file mode 100644 index 0000000000..5ebe146d50 --- /dev/null +++ b/frontend/src/components/lazy-icon.tsx @@ -0,0 +1,127 @@ +import { faActorsBorderless, Icon, type IconProp } from "@rivet-gg/icons"; +import { + lazy, + type LazyExoticComponent, + type ReactNode, + Suspense, +} from "react"; +import { cn } from "./lib/utils"; + +// Each FontAwesome icon ships as its own module so it can be code-split and +// loaded on demand by export name. The glob is resolved relative to this file. +const iconModules = import.meta.glob>( + "../../packages/icons/dist/icons/*.js", +); + +const emojiRegex = + /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u; + +export function isEmoji(str: string): boolean { + return emojiRegex.test(str); +} + +// Convert a kebab-case icon name ("arrow-right") to its FontAwesome export name +// ("faArrowRight"). +export function toIconExportName(name: string): string { + return `fa${name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("")}`; +} + +type LazyIconComponent = LazyExoticComponent< + (props: { className?: string; fallback: IconProp }) => ReactNode +>; + +const lazyIconCache = new Map(); + +// The lazy component must be memoized per export name. Creating a fresh `lazy()` +// on every render gives it a new identity, so React remounts it and the +// surrounding Suspense boundary drops back to its loading fallback on every +// parent re-render. +function getLazyIcon(exportName: string): LazyIconComponent { + const cached = lazyIconCache.get(exportName); + if (cached) return cached; + + const loader = + iconModules[`../../packages/icons/dist/icons/${exportName}.js`]; + const component = lazy(() => + (loader ? loader() : Promise.reject()) + .then((mod) => ({ + default: ({ + className, + fallback, + }: { + className?: string; + fallback: IconProp; + }) => ( + + ), + })) + .catch(() => ({ + default: ({ + className, + fallback, + }: { + className?: string; + fallback: IconProp; + }) => , + })), + ); + lazyIconCache.set(exportName, component); + return component; +} + +// Lazily renders a FontAwesome icon by its kebab-case name. Suspends while the +// icon module loads, so callers must provide their own Suspense boundary. +export function LazyIcon({ + name, + className, + fallback, +}: { + name: string; + className?: string; + fallback: IconProp; +}) { + const Component = getLazyIcon(toIconExportName(name)); + return ; +} + +// Renders an actor icon from its metadata value, which may be an emoji, a +// kebab-case FontAwesome icon name, or absent. Emoji and absent values render +// synchronously; named icons are lazily loaded behind a pulsing fallback. +export function ActorIcon({ + iconValue, + className, + emojiClassName, + fallback = faActorsBorderless, +}: { + iconValue: string | null; + className?: string; + emojiClassName?: string; + fallback?: IconProp; +}) { + if (iconValue && isEmoji(iconValue)) { + return {iconValue}; + } + + if (!iconValue) { + return ; + } + + return ( + + } + > + + + ); +} diff --git a/frontend/src/components/ui/icon-picker.tsx b/frontend/src/components/ui/icon-picker.tsx index 7e68d0a676..f340649aed 100644 --- a/frontend/src/components/ui/icon-picker.tsx +++ b/frontend/src/components/ui/icon-picker.tsx @@ -5,8 +5,6 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { type ComponentPropsWithoutRef, forwardRef, - type LazyExoticComponent, - lazy, type ReactNode, Suspense, useEffect, @@ -14,54 +12,12 @@ import { useRef, useState, } from "react"; +import { LazyIcon } from "../lazy-icon"; import { cn } from "../lib/utils"; import { Button } from "./button"; import { Input } from "./input"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -const iconModules = import.meta.glob>( - "../../../packages/icons/dist/icons/*.js", -); - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -function toExportName(iconName: string): string { - return `fa${iconName.split("-").map(capitalize).join("")}`; -} - -const lazyIconCache = new Map< - string, - LazyExoticComponent<(props: { className?: string }) => ReactNode> ->(); - -function getLazyIcon(iconName: string) { - const exportName = toExportName(iconName); - const cached = lazyIconCache.get(exportName); - if (cached) return cached; - - const loader = iconModules[`../../../packages/icons/dist/icons/${exportName}.js`]; - const component = lazy(() => - (loader ? loader() : Promise.reject()) - .then((mod) => ({ - default: ({ className }: { className?: string }) => ( - - ), - })) - .catch(() => ({ - default: ({ className }: { className?: string }) => ( - - ), - })), - ); - lazyIconCache.set(exportName, component); - return component; -} - export function IconRenderer({ name, className, @@ -72,10 +28,9 @@ export function IconRenderer({ fallback?: ReactNode; }) { if (!name) return <>{fallback ?? null}; - const LazyIcon = getLazyIcon(name); return ( }> - + ); }