diff --git a/apps/web/src/features/shared/navbar/navbar-desktop.tsx b/apps/web/src/features/shared/navbar/navbar-desktop.tsx index 2c5b48db33..67d0410a03 100644 --- a/apps/web/src/features/shared/navbar/navbar-desktop.tsx +++ b/apps/web/src/features/shared/navbar/navbar-desktop.tsx @@ -3,6 +3,7 @@ import { UserAvatar } from "@/features/shared"; import { AnonUserButtons } from "@/features/shared/navbar/anon-user-buttons"; import { NavbarMainSidebar } from "@/features/shared/navbar/navbar-main-sidebar"; import { NavbarMainSidebarToggle } from "@/features/shared/navbar/navbar-main-sidebar-toggle"; +import { NavbarSearchShell } from "@/features/shared/navbar/navbar-search-shell"; import { NavbarNotificationsButton } from "@/features/shared/navbar/navbar-notifications-button"; import { NavbarPerksButton } from "@/features/shared/navbar/navbar-perks-button"; import { NavbarSide } from "@/features/shared/navbar/sidebar/navbar-side"; @@ -12,21 +13,12 @@ import { Tooltip } from "@ui/tooltip"; import { classNameObject } from "@ui/util"; import i18next from "i18next"; import { useEffect, useState } from "react"; -import dynamic from "next/dynamic"; import { NavbarTextMenu } from "./navbar-text-menu"; import { useHydrated } from "@/api/queries"; import { useMattermostUnread } from "@/features/chat/mattermost-api"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -// The desktop search (input + suggester + transfer/bookmarks/drafts/gallery -// modules) is heavy. The desktop navbar is `hidden md:flex` but still mounts on -// mobile, so a static import shipped all of that into the mobile critical path -// purely as waste. Load it as a separate chunk and only mount it once the -// viewport is actually desktop-width — never on phones. -const Search = dynamic( - () => import("@/features/shared/navbar/search").then((m) => ({ default: m.Search })), - { ssr: false } -); +import { Search } from "@/features/shared/navbar/navbar-search-dynamic"; interface Props { step?: number; @@ -99,8 +91,12 @@ export function NavbarDesktop({ {(step !== 1 || transparentVerify) && ( // Slot is always rendered so it reserves its flex space (no layout // shift when Search mounts). Only the heavy Search itself is gated on - // isDesktop, so its chunk never loads on mobile. -
{isDesktop && }
+ // isDesktop, so its chunk never loads on mobile; until it mounts the + // pixel-identical server-rendered shell keeps the input visible from + // the first paint (#1664). +
+ {isDesktop ? : } +
)}
diff --git a/apps/web/src/features/shared/navbar/navbar-main-sidebar-toggle.tsx b/apps/web/src/features/shared/navbar/navbar-main-sidebar-toggle.tsx index df941c960f..d48b251f51 100644 --- a/apps/web/src/features/shared/navbar/navbar-main-sidebar-toggle.tsx +++ b/apps/web/src/features/shared/navbar/navbar-main-sidebar-toggle.tsx @@ -14,12 +14,19 @@ export function NavbarMainSidebarToggle({ onClick }: Props) {
diff --git a/apps/web/src/features/shared/navbar/navbar-search-dynamic.tsx b/apps/web/src/features/shared/navbar/navbar-search-dynamic.tsx new file mode 100644 index 0000000000..ebb295caad --- /dev/null +++ b/apps/web/src/features/shared/navbar/navbar-search-dynamic.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { NavbarSearchShell } from "./navbar-search-shell"; + +/* + The desktop search (input + suggester + transfer/bookmarks/drafts/gallery + modules) is heavy. The desktop navbar is `hidden md:flex` but still mounts on + mobile, so a static import would ship all of that into the mobile critical + path purely as waste. Load it as a separate chunk; the caller gates mounting + on a confirmed desktop viewport so the chunk never loads on phones. + + The loading fallback is the same pixel-identical shell the caller renders + before the gate opens: without it the slot would flash empty between the + moment isDesktop flips true and the moment the chunk arrives (#1665 review). +*/ +export const Search = dynamic( + () => import("@/features/shared/navbar/search").then((m) => ({ default: m.Search })), + { ssr: false, loading: () => } +); diff --git a/apps/web/src/features/shared/navbar/navbar-search-shell.tsx b/apps/web/src/features/shared/navbar/navbar-search-shell.tsx new file mode 100644 index 0000000000..3e1a627cb6 --- /dev/null +++ b/apps/web/src/features/shared/navbar/navbar-search-shell.tsx @@ -0,0 +1,27 @@ +import i18next from "i18next"; +import type { JSX } from "react"; +import { SearchBox } from "../search-box"; + +/** + * Server-renderable stand-in for the desktop navbar Search (#1664). + * + * The real Search is a `dynamic({ssr:false})` chunk gated on a client-set + * `isDesktop`, so its slot used to server-render empty and the input popped + * in seconds after first paint. This shell reuses the same lightweight + * SearchBox inside the same idle SuggestionList wrapper markup + * (`suggestion relative` + trailing div), so it is pixel-identical to the + * idle live component and is replaced in place when Search mounts. + * + * Deliberately NOT the live placeholder: the live one interpolates + * searchIndexCount, which is client data; the shell always shows the plain + * placeholder so the server output is stable. The input is readOnly until + * the live component takes over. + */ +export function NavbarSearchShell(): JSX.Element { + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/specs/features/shared/navbar-first-paint.spec.tsx b/apps/web/src/specs/features/shared/navbar-first-paint.spec.tsx new file mode 100644 index 0000000000..b2ed37f7ab --- /dev/null +++ b/apps/web/src/specs/features/shared/navbar-first-paint.spec.tsx @@ -0,0 +1,60 @@ +import { vi, describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import "@testing-library/jest-dom"; + +const captured = vi.hoisted(() => ({ dynamicOptions: [] as Array> })); +vi.mock("next/dynamic", () => ({ + default: (_importer: unknown, opts?: Record) => { + captured.dynamicOptions.push(opts ?? {}); + return () => null; + } +})); + +import { NavbarMainSidebarToggle } from "@/features/shared/navbar/navbar-main-sidebar-toggle"; +import { NavbarSearchShell } from "@/features/shared/navbar/navbar-search-shell"; +import "@/features/shared/navbar/navbar-search-dynamic"; + +/* + Pins for #1664: the navbar must be complete at first paint. +*/ +describe("NavbarMainSidebarToggle logo", () => { + it("is not lazy-loaded (paints with the first frame)", () => { + const { container } = render(); + const img = container.querySelector("img.logo"); + expect(img).toBeInTheDocument(); + // next/image without `priority` emits loading="lazy", which deferred the + // request past the first layout and made the logo pop in at ~2.4s. + expect(img).not.toHaveAttribute("loading", "lazy"); + }); +}); + +describe("Search dynamic handoff", () => { + it("keeps the shell visible while the search chunk loads", () => { + /* + When isDesktop flips true, the slot swaps to the dynamic component; + Next's DEFAULT loading state renders null, which would flash the slot + empty until the chunk arrives. The dynamic() options must therefore + provide the shell as the loading fallback (#1665 review). + */ + // Other modules in the import graph may register their own dynamic() + // components; find the one whose loading fallback renders the shell. + const fallbacks = captured.dynamicOptions + .filter((o) => typeof o.loading === "function") + .map((o) => renderToString((o.loading as () => JSX.Element)())); + const shellFallback = fallbacks.find((html) => html.includes("search-box")); + expect(shellFallback).toBeDefined(); + expect(shellFallback).toContain('placeholder="search.placeholder"'); + }); +}); + +describe("NavbarSearchShell", () => { + it("server-renders the idle search input markup", () => { + // renderToString runs no effects, exactly like the server. + const html = renderToString(); + expect(html).toContain("suggestion relative"); + expect(html).toContain("search-box"); + expect(html).toContain('placeholder="search.placeholder"'); + expect(html).toContain("