Skip to content
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"use client";

import { ErrorPage } from "@/components/ErrorPage";
import { formCountriesList } from "@/lib/languages";
import { preloadIcons } from "@/scenes/PortalV3/common/Icon";
import { FetchLocalisationsDocument } from "@/scenes/common/Teams/TeamId/Apps/AppId/Configuration/AppStore/graphql/client/fetch-localisations.generated";
import { FetchAppMetadataDocument } from "@/scenes/common/Teams/TeamId/Apps/AppId/Configuration/graphql/client/fetch-app-metadata.generated";
import { useRemoveFromReview } from "@/scenes/common/Teams/TeamId/Apps/common/hooks/use-remove-from-review";
import { useQuery } from "@apollo/client/react";
import { useAtom } from "jotai";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { preload } from "react-dom";
import { AppStoreFormProvider } from "../AppStore/app-store-form-provider";
import {
AppMetadata,
Expand All @@ -25,6 +28,24 @@ type ConfigurationWizardPageProps = {
params: Record<string, string> | null | undefined;
};

/**
* Portal-v3 icons used across Get verified wizard steps. Warm with the page
* layout so conditional / late-visible glyphs aren't blank on first paint.
*/
const configurationWizardPreloadIcons = [
"share-ios", // LogoDropZone + LocalisedContentStep empty drop zones
"radio-check", // Stepper completed steps + AppModeCards selection
"star", // ReviewStep rating placeholder
"xmark", // ChipSelect remove on selected chips
"dropdown-check", // ChipSelect selected option in country/language lists
] as const;

// Country flags only mount inside the Availability dropdown — warm them with
// the Get verified layout so the list isn't blank on first open.
const configurationWizardPreloadFlagHrefs = formCountriesList().map(
(country) => `/icons/flags/${country.value}.svg`,
);

/**
* Route entry for the redesigned configuration wizard. Mirrors the previous
* page's data plumbing exactly: draft/verified row selection via the shared
Expand All @@ -34,6 +55,11 @@ type ConfigurationWizardPageProps = {
export const ConfigurationWizardPage = ({
params,
}: ConfigurationWizardPageProps) => {
preloadIcons(configurationWizardPreloadIcons);
for (const href of configurationWizardPreloadFlagHrefs) {
preload(href, { as: "image", type: "image/svg+xml" });
}

const routeParams = useParams<{ appId: `app_${string}`; teamId: string }>();
const appId = (params?.appId || routeParams?.appId) as `app_${string}`;
const teamId = (params?.teamId || routeParams?.teamId) as `team_${string}`;
Expand Down
15 changes: 14 additions & 1 deletion web/scenes/PortalV3/Teams/TeamId/Apps/page/AppsPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Role_Enum } from "@/graphql/graphql";
import { Auth0SessionUser } from "@/lib/types";
import { checkUserPermissions } from "@/lib/utils";
import { Icon } from "@/scenes/PortalV3/common/Icon";
import { Icon, preloadIcons } from "@/scenes/PortalV3/common/Icon";
import { InkButton } from "@/scenes/PortalV3/common/InkButton";
import { FetchAppsDocument } from "@/scenes/common/layout/AppSelector/graphql/client/fetch-apps.generated";
import { useCreateAppDialog } from "@/scenes/common/layout/CreateAppDialog/useCreateAppDialog";
Expand All @@ -24,6 +24,17 @@ const CreateKeyModal = dynamic(
// Collapses Chrome's visibilitychange+focus double-fire on a tab return.
const RETURN_CHECK_MIN_INTERVAL_MS = 1_000;

/**
* Static icons for the post-team-creation empty overview. This is the landing
* page after create-team, so warm the cards here plus radio-check for the
* World ID register-RP option cards that follow first-app creation.
*/
const appsPagePreloadIcons = [
"card-toolkit", // "Create an app" card
"card-wand", // "Set up MCP via API key" card
"radio-check", // Register-RP / World ID 4.0 option cards (next step)
] as const;

const ActionCard = (props: {
icon: ReactNode;
iconClassName: string;
Expand Down Expand Up @@ -62,6 +73,8 @@ export const AppsPageClient = (props: {
teamId: string;
initialIsOwner?: boolean;
}) => {
preloadIcons(appsPagePreloadIcons);

const { open: openCreateAppDialog } = useCreateAppDialog();
const [createKeyOpen, setCreateKeyOpen] = useState(false);
const [keyDialogMounted, setKeyDialogMounted] = useState(false);
Expand Down
18 changes: 17 additions & 1 deletion web/scenes/PortalV3/common/Icon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { AlertIcon } from "@/components/Icons/AlertIcon";
import { preload } from "react-dom";
import { twMerge } from "tailwind-merge";

const ICON_PATH = "/images/portal-v3/icons";

export const getIconPath = (name: string) => `${ICON_PATH}/${name}.svg`;

/**
* Starts loading static icon assets before a lazily mounted menu needs them.
* Safe to call during render — React de-duplicates equivalent preload hints.
*/
export const preloadIcons = (names: readonly string[]) => {
for (const name of names) {
preload(getIconPath(name), {
as: "image",
type: "image/svg+xml",
});
}
};

/**
* Optical alignment for an icon sitting beside cap-height text. Labels use
* tight leading (`leading-none` / `leading-[1.2]`), so their glyphs sit high
Expand Down Expand Up @@ -51,7 +67,7 @@ export const bubbleDigitClassName = "inline-block translate-y-[0.12em]";
*/
export const Icon = (props: { name: string; className?: string }) => (
<img
src={`${ICON_PATH}/${props.name}.svg`}
src={getIconPath(props.name)}
alt=""
aria-hidden="true"
draggable={false}
Expand Down
18 changes: 17 additions & 1 deletion web/scenes/PortalV3/layout/Shell/SearchableSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Icon, opticalIconClassName } from "@/scenes/PortalV3/common/Icon";
import {
Icon,
opticalIconClassName,
preloadIcons,
} from "@/scenes/PortalV3/common/Icon";
import { SearchIcon } from "lucide-react";
import Link from "next/link";
import {
Expand All @@ -27,6 +31,16 @@ export type SwitcherItem = { id: string; name: string };
export const switcherTriggerClassName =
"h-9 min-w-0 cursor-pointer px-3 font-world text-13 leading-none font-medium";

/**
* Static icons inside the lazily mounted switcher popover.
* Shared by team + app switchers; apps-empty-icon is apps-only.
*/
const searchableSwitcherPreloadIcons = [
"dropdown-plus", // create-action footer
"dropdown-check", // selected row
"apps-empty-icon", // AppsDropdown empty state
] as const;

const createActionClassName =
"h-10 w-full justify-start gap-2 rounded-8 px-3 font-world text-13 font-medium text-portal-text hover:bg-grey-50 hover:text-portal-text";

Expand Down Expand Up @@ -61,6 +75,8 @@ export const SearchableSwitcher = <T extends SwitcherItem>(
const [showScrollHint, setShowScrollHint] = useState(false);
const listRef = useRef<HTMLUListElement>(null);

preloadIcons(searchableSwitcherPreloadIcons);

const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
const filteredItems = useMemo(
() =>
Expand Down
25 changes: 24 additions & 1 deletion web/scenes/PortalV3/layout/Shell/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "@/lib/team-settings";
import { urls } from "@/lib/urls";
import { cn } from "@/lib/utils";
import { Icon } from "@/scenes/PortalV3/common/Icon";
import { Icon, preloadIcons } from "@/scenes/PortalV3/common/Icon";
import {
ArrowLeftIcon,
BadgeCheckIcon,
Expand Down Expand Up @@ -124,10 +124,33 @@ const NavIcon = (props: { name: string; active?: boolean }) => (
</span>
);

/**
* Static `/images/portal-v3/icons` assets used by the sidebar. Top-level
* NavIcon rows mount idle+active together; `nav-configuration` only mounts
* when the World ID submenu opens, so warm the full set with the nav itself.
*/
const sidebarPreloadIcons = [
// Top-level rows (idle + active)
"nav-world-id",
"nav-world-id-active",
"nav-mini-app",
"nav-mini-app-active",
"nav-home",
"nav-home-active",
"nav-settings",
"nav-settings-active",
// World ID submenu
"nav-configuration",
// Sandbox CTA
"world-id-sandbox-app-icon",
] as const;

export const SidebarNav = (props: {
initialSandboxRequest?: SandboxAccessRequestState | null;
apiKeyTeamIds?: string[];
}) => {
preloadIcons(sidebarPreloadIcons);

const pathname = usePathname() ?? "";
const params = useParams<{ teamId?: string; appId?: string }>();
const committedSearchParams = useSearchParams();
Expand Down
31 changes: 30 additions & 1 deletion web/scenes/PortalV3/layout/Shell/UserPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
import { urls } from "@/lib/urls";
import { Color } from "@/scenes/common/Profile/types";
import { colorAtom } from "@/scenes/common/layout/color-atom";
import { Icon, opticalIconClassName } from "@/scenes/PortalV3/common/Icon";
import {
Icon,
opticalIconClassName,
preloadIcons,
} from "@/scenes/PortalV3/common/Icon";
import { useAtomValue } from "jotai";
import { ChevronsUpDownIcon } from "lucide-react";
import Link from "next/link";
Expand All @@ -35,6 +39,27 @@ const accountLinks = [
},
];

/**
* Static icons inside the lazily mounted account menu.
* Keep in sync with UserPopup rows + HelpCenterMenu link icons.
*/
const userPopupPreloadIcons = [
// Account menu rows
"profile-menu-profile",
"nav-help",
"profile-menu-log-out",
// Help Center submenu
"profile-menu-docs",
"profile-menu-help",
"profile-menu-privacy",
"profile-menu-status",
"profile-menu-telegram",
"profile-menu-message",
"profile-menu-discord",
"profile-menu-policy",
"profile-menu-terms",
];

const getInitials = (name: string) => {
const parts = name
.split(/[.\s@_-]+/)
Expand Down Expand Up @@ -71,6 +96,10 @@ export const UserPopup = (props: { user: PortalUser; color: Color | null }) => {
const selectedColor = useAtomValue(colorAtom);
const color = selectedColor ?? props.color;

// Menu icons live in Radix's lazily mounted portal — warm them while the
// always-visible trigger renders so the first open is instant.
preloadIcons(userPopupPreloadIcons);

return (
<SidebarMenu>
<SidebarMenuItem>
Expand Down
59 changes: 59 additions & 0 deletions web/tests/unit/portal-v3-shell-dropdown-focus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,65 @@ jest.mock("@/hooks/use-mobile", () => ({

// #region Trigger focus treatment
describe("Portal v3 shell dropdown focus treatment", () => {
it("preloads account-menu icons before the lazy menu opens", () => {
render(
<TooltipProvider>
<SidebarProvider>
<UserPopup
user={{ name: "Ada Lovelace", email: "ada@example.com" }}
color={null}
/>
</SidebarProvider>
</TooltipProvider>,
);

expect(screen.queryByRole("menu")).not.toBeInTheDocument();

const preloadedImages = Array.from(
document.head.querySelectorAll<HTMLLinkElement>(
'link[rel="preload"][as="image"]',
),
(link) => link.getAttribute("href"),
);

expect(preloadedImages).toEqual(
expect.arrayContaining([
"/images/portal-v3/icons/profile-menu-profile.svg",
"/images/portal-v3/icons/nav-help.svg",
"/images/portal-v3/icons/profile-menu-log-out.svg",
"/images/portal-v3/icons/profile-menu-docs.svg",
"/images/portal-v3/icons/profile-menu-terms.svg",
]),
);
});

it("preloads switcher icons before the lazy popover opens", () => {
render(
<TooltipProvider>
<SidebarProvider>
<TeamsDropdown teams={[{ id: "team_1", name: "Example team" }]} />
</SidebarProvider>
</TooltipProvider>,
);

expect(screen.queryByTestId("team-switcher-list")).not.toBeInTheDocument();

const preloadedImages = Array.from(
document.head.querySelectorAll<HTMLLinkElement>(
'link[rel="preload"][as="image"]',
),
(link) => link.getAttribute("href"),
);

expect(preloadedImages).toEqual(
expect.arrayContaining([
"/images/portal-v3/icons/dropdown-plus.svg",
"/images/portal-v3/icons/dropdown-check.svg",
"/images/portal-v3/icons/apps-empty-icon.svg",
]),
);
});

it("uses a clean background cue instead of a persistent focus ring", () => {
render(
<TooltipProvider>
Expand Down
Loading