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).
+
} aria-label={i18next.t("navbar.toggle-menu")} />
+ {/*
+ priority: the logo is always above the fold; the default-lazy
+ behavior deferred its request until the first layout finished
+ computing viewport intersection, so it popped in well after first
+ paint (#1664).
+ */}
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("