Skip to content
Merged
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
20 changes: 8 additions & 12 deletions apps/web/src/features/shared/navbar/navbar-desktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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.
<div className="max-w-[400px] w-full">{isDesktop && <Search />}</div>
// 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).
<div className="max-w-[400px] w-full">
{isDesktop ? <Search /> : <NavbarSearchShell />}
</div>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
)}
<div className="flex items-center ml-3 gap-3">
<NavbarPerksButton />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ export function NavbarMainSidebarToggle({ onClick }: Props) {
<div className="h-[40px] min-w-[40px] md:min-w-[60px] flex items-center gap-1.5 cursor-pointer relative">
<Button onClick={onClick} appearance="gray-link" noPadding={true} icon={<UilBars />} aria-label={i18next.t("navbar.toggle-menu")} />
<Link className="hidden md:block" href="/">
{/*
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).
*/}
<Image
src={defaults.logo}
className="logo relative min-w-[40px] max-w-[40px]"
alt="Logo"
width={40}
height={40}
priority
/>
</Link>
</div>
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/features/shared/navbar/navbar-search-dynamic.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <NavbarSearchShell /> }
);
27 changes: 27 additions & 0 deletions apps/web/src/features/shared/navbar/navbar-search-shell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="suggestion relative">
<SearchBox placeholder={i18next.t("search.placeholder")} value="" readOnly={true} />
<div />
</div>
);
}
60 changes: 60 additions & 0 deletions apps/web/src/specs/features/shared/navbar-first-paint.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> }));
vi.mock("next/dynamic", () => ({
default: (_importer: unknown, opts?: Record<string, unknown>) => {
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";

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
/*
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(<NavbarMainSidebarToggle onClick={vi.fn()} />);
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(<NavbarSearchShell />);
expect(html).toContain("suggestion relative");
expect(html).toContain("search-box");
expect(html).toContain('placeholder="search.placeholder"');
expect(html).toContain("<input");
});
});
Loading