diff --git a/apps/web/content/docs/dev/deployments/meta.json b/apps/web/content/docs/dev/deployments/meta.json index 86ca336d61..037bb8502d 100644 --- a/apps/web/content/docs/dev/deployments/meta.json +++ b/apps/web/content/docs/dev/deployments/meta.json @@ -2,5 +2,5 @@ "title": "Deployments", "description": "Deploy a VitNode application to production with cloud or self-hosted infrastructure", "icon": "HardDriveUpload", - "pages": ["self-hosted", "cloud"] + "pages": ["self-hosted", "cloud", "prerendering"] } diff --git a/apps/web/content/docs/dev/deployments/prerendering.mdx b/apps/web/content/docs/dev/deployments/prerendering.mdx new file mode 100644 index 0000000000..dae8ea2333 --- /dev/null +++ b/apps/web/content/docs/dev/deployments/prerendering.mdx @@ -0,0 +1,127 @@ +--- +title: Prerender Static Pages +description: Render marketing pages once during the build and serve them as static HTML files, in every language your app speaks. +icon: FileCode +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Prerendering renders a page once, during `vitnode build`, and saves it as an HTML file the server sends as-is. Use it for pages that look the same for every visitor - a home page, a pricing page, a list of solutions. Your server stops rendering them on every request, and visitors get the page as fast as a static file can travel. + +Pages that change per visitor - settings, anything behind a sign-in, a feed of new posts - stay server-rendered. You do not need to change them. + +## Prerender your pages + + + + +### List the pages + +Create a module with the paths to prerender. Write each path once, without a locale prefix: + +```ts title="src/prerender.ts" +export const PRERENDERED_PATHS = ['/', '/pricing', '/solutions'] +``` + +A route with a parameter, such as `/solutions/$slug`, is never found on its own. List every value you want as a file: `/solutions/help-center`. + + + + +### Hand the list to Nitro + +Pass the paths through `prerenderRoutes` from `@vitnode/core/framework/vite` and give the result to the `prerender.routes` option of `nitro()`: + +```ts title="vite.config.ts" +import { prerenderRoutes, vitnode } from '@vitnode/core/framework/vite' // [!code highlight] +import { nitro } from 'nitro/vite' +import { defineConfig } from 'vite' + +import { PRERENDERED_PATHS } from './src/prerender.ts' // [!code highlight] +import { vitNodeConfig } from './src/vitnode.config.ts' // [!code highlight] + +export default defineConfig({ + plugins: [ + vitnode({ appRoot: import.meta.dirname }), + nitro({ + // [!code highlight:6] + prerender: { + routes: prerenderRoutes({ + i18n: vitNodeConfig.i18n, + paths: PRERENDERED_PATHS, + }), + }, + }), + // tailwindcss(), tanstackStart(), viteReact() as before + ], +}) +``` + +`prerenderRoutes` writes every path once per enabled locale, using the same URL rules as the router. With English as the default and Polish as a second locale, `/pricing` becomes `/pricing` and `/pl/pricing`, and a translated path from `i18n.routePaths` is used where one exists. + +Vite loads this file before anything else is built, so keep the `.ts` extension on local imports and keep the path module free of components. + + + + +### Build + + + +```bash tab="bun" +bun run build +``` + +```bash tab="pnpm" +pnpm build +``` + +```bash tab="npm" +npm run build +``` + + + +Nitro starts a temporary server, requests every route, and writes the HTML into `.output/public` before it packages your production server. + + + + +## Check the result + +Each listed route has its own file, one folder per path: + +```text +.output/public/index.html +.output/public/pricing/index.html +.output/public/pl/pricing/index.html +``` + +Start the app with `vitnode start` and compare the headers of a prerendered page and a server-rendered one: + +```bash +curl -sI http://localhost:3000/pricing +curl -sI http://localhost:3000/login +``` + +The prerendered page answers with an `etag` and a `last-modified` date. The server-rendered page answers with `cache-control: private, no-store` instead, because VitNode renders it for one visitor. + +## What a prerendered page leaves out + +A prerendered file is shared by every visitor and built without your API, so VitNode leaves out anything that comes from the database or belongs to one visitor. Your build does not need a database. + +- **The user menu loads in the browser.** The main layout skips reading the session while prerendering. The header ships with a placeholder where the avatar goes, and the browser fills it in once the page loads - the same thing a server-rendered page shows for a split second. +- **The header navigation loads in the browser.** The links you edit in **AdminCP → System → Navigation** are not part of the file. The header shows a placeholder in their place, the browser asks the API for the links once the page loads, and a change shows up right away, without a rebuild. +- **The language choice is stored by the browser.** A visit to `/pl/...` normally sets the locale cookie in the server's response. A static file has no such response, so VitNode sets the same cookie from the browser when the page loads. + +## Pages you should not prerender + + + With `i18n.domains`, one path serves a different language on every domain, and + one file cannot do that. `prerenderRoutes` throws a `PrerenderDomainsError` + instead of writing the wrong language. Keep those pages server-rendered. + + +Only list pages that look the same for everyone. A page behind a sign-in would be prerendered as a signed-out visitor sees it, and every signed-in visitor would get that copy. + +A page whose own loader reads from your API - a list of the latest blog posts, for example - needs that API and its database during the build, and its content stays as it was at build time. Keep such pages server-rendered too. diff --git a/apps/web/src/docs/examples/date-picker-range.tsx b/apps/web/src/docs/examples/date-picker-range.tsx index 451bbab8a4..3048214787 100644 --- a/apps/web/src/docs/examples/date-picker-range.tsx +++ b/apps/web/src/docs/examples/date-picker-range.tsx @@ -4,7 +4,7 @@ import { DateRangePicker } from '@vitnode/core/components/ui/date-range-picker' import { Label } from '@vitnode/core/components/ui/label' import React from 'react' -type Preset = 'last-30-days' | 'last-7-days' | 'this-month' +type Preset = 'last-7-days' | 'last-30-days' | 'this-month' const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()) diff --git a/apps/web/src/site/prerender.ts b/apps/web/src/site/prerender.ts new file mode 100644 index 0000000000..87cb91a3ce --- /dev/null +++ b/apps/web/src/site/prerender.ts @@ -0,0 +1,8 @@ +import { SOLUTION_SLUGS, solutionPath } from './solutions/catalog.ts' + +export const PRERENDERED_PATHS = [ + '/', + '/plugins', + '/solutions', + ...SOLUTION_SLUGS.map(solutionPath), +] diff --git a/apps/web/src/site/solutions/catalog.ts b/apps/web/src/site/solutions/catalog.ts index 3dd9436de4..d256676fc4 100644 --- a/apps/web/src/site/solutions/catalog.ts +++ b/apps/web/src/site/solutions/catalog.ts @@ -36,6 +36,8 @@ const SOLUTION_CATALOG = { export type SolutionSlug = keyof typeof SOLUTION_CATALOG +export const SOLUTION_SLUGS = Object.keys(SOLUTION_CATALOG) as SolutionSlug[] + const isSolutionSlug = (slug: string): slug is SolutionSlug => Object.hasOwn(SOLUTION_CATALOG, slug) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2cfe67ed8b..a685db6830 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,11 +2,14 @@ import tailwindcss from '@tailwindcss/vite' import { devtools } from '@tanstack/devtools-vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' -import { vitnode } from '@vitnode/core/framework/vite' +import { prerenderRoutes, vitnode } from '@vitnode/core/framework/vite' import { fumadocsMdx } from 'fumadocs-mdx/vite' import { nitro } from 'nitro/vite' import { defineConfig } from 'vite' +import { PRERENDERED_PATHS } from './src/site/prerender.ts' +import { vitNodeConfig } from './src/vitnode.config.ts' + const config = defineConfig({ resolve: { alias: [{ find: /^@\//, replacement: `${import.meta.dirname}/src/` }], @@ -17,7 +20,14 @@ const config = defineConfig({ vitnode({ appRoot: import.meta.dirname }), fumadocsMdx({ index: false }), devtools(), - nitro(), + nitro({ + prerender: { + routes: prerenderRoutes({ + i18n: vitNodeConfig.i18n, + paths: PRERENDERED_PATHS, + }), + }, + }), tailwindcss(), tanstackStart(), viteReact(), diff --git a/packages/vitnode/src/framework/vite/index.ts b/packages/vitnode/src/framework/vite/index.ts index 4a2fb4eca8..6b2e01427e 100644 --- a/packages/vitnode/src/framework/vite/index.ts +++ b/packages/vitnode/src/framework/vite/index.ts @@ -3,6 +3,8 @@ export { vitNodeEnv } from "./env"; export { vitNodeOptimizeDeps } from "./optimize-deps"; export type { VitNodePluginRoutesOptions } from "./plugin-routes"; export { vitNodePluginRoutes } from "./plugin-routes"; +export type { PrerenderRoutesOptions } from "./prerender-routes"; +export { PrerenderDomainsError, prerenderRoutes } from "./prerender-routes"; export type { VitNodeSsrExternalsOptions } from "./ssr-externals"; export { vitNodeSsrExternals } from "./ssr-externals"; export type { VitNodeTailwindSourcesOptions } from "./tailwind-sources"; diff --git a/packages/vitnode/src/framework/vite/prerender-routes.test.ts b/packages/vitnode/src/framework/vite/prerender-routes.test.ts new file mode 100644 index 0000000000..20b038f5d6 --- /dev/null +++ b/packages/vitnode/src/framework/vite/prerender-routes.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { PrerenderDomainsError, prerenderRoutes } from "./prerender-routes"; + +const locales = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +describe("prerenderRoutes", () => { + it("writes every path once per locale, the default locale unprefixed", () => { + expect( + prerenderRoutes({ + i18n: { defaultLocale: "en", locales }, + paths: ["/", "/plugins"], + }), + ).toEqual(["/", "/plugins", "/pl", "/pl/plugins"]); + }); + + it("uses a locale's translated route path", () => { + expect( + prerenderRoutes({ + i18n: { + defaultLocale: "en", + locales, + routePaths: { pl: { "/discover": "/odkrywaj" } }, + }, + paths: ["/discover"], + }), + ).toEqual(["/discover", "/pl/odkrywaj"]); + }); + + it("prefixes the default locale too when every locale is prefixed", () => { + expect( + prerenderRoutes({ + i18n: { defaultLocale: "en", localePrefix: "always", locales }, + paths: ["/plugins"], + }), + ).toEqual(["/en/plugins", "/pl/plugins"]); + }); + + it("skips a disabled locale", () => { + expect( + prerenderRoutes({ + i18n: { + defaultLocale: "en", + locales: [locales[0], { ...locales[1], enabled: false }], + }, + paths: ["/plugins"], + }), + ).toEqual(["/plugins"]); + }); + + it("lists a repeated path only once", () => { + expect( + prerenderRoutes({ + i18n: { defaultLocale: "en", locales: [locales[0]] }, + paths: ["/plugins", "/plugins"], + }), + ).toEqual(["/plugins"]); + }); + + it("refuses per-domain locales, which one file cannot answer", () => { + expect(() => + prerenderRoutes({ + i18n: { + defaultLocale: "en", + domains: [ + { defaultLocale: "en", origin: "https://example.com" }, + { defaultLocale: "pl", origin: "https://example.pl" }, + ], + locales, + }, + paths: ["/"], + }), + ).toThrow(PrerenderDomainsError); + }); +}); diff --git a/packages/vitnode/src/framework/vite/prerender-routes.ts b/packages/vitnode/src/framework/vite/prerender-routes.ts new file mode 100644 index 0000000000..cb9791aa24 --- /dev/null +++ b/packages/vitnode/src/framework/vite/prerender-routes.ts @@ -0,0 +1,34 @@ +import type { LocaleConfig, VitNodeI18nConfig } from "@/lib/i18n/types"; + +import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; + +export interface PrerenderRoutesOptions { + i18n: Pick< + VitNodeI18nConfig, + "defaultLocale" | "domains" | "localePrefix" | "routePaths" + > & { locales: LocaleConfig[] }; + paths: readonly string[]; +} + +export class PrerenderDomainsError extends Error { + constructor() { + super( + "Prerendering writes one file per path, so it cannot answer per-domain locales. Remove `i18n.domains` or keep these pages server-rendered.", + ); + this.name = "PrerenderDomainsError"; + } +} + +export const prerenderRoutes = ({ + i18n, + paths, +}: PrerenderRoutesOptions): string[] => { + const localeRouting = localeRoutingFromConfig(i18n); + if (localeRouting.domains.length > 0) throw new PrerenderDomainsError(); + + const localized = localeRouting.locales.flatMap(locale => + paths.map(path => localeRouting.localizePathname(path, locale)), + ); + + return [...new Set(localized)]; +}; diff --git a/packages/vitnode/src/lib/i18n/remembered-locale.test.ts b/packages/vitnode/src/lib/i18n/remembered-locale.test.ts new file mode 100644 index 0000000000..131f8c20af --- /dev/null +++ b/packages/vitnode/src/lib/i18n/remembered-locale.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { localeRoutingFromConfig } from "./locale-routing"; +import { urlLocaleToRemember } from "./remembered-locale"; + +const localeRouting = localeRoutingFromConfig({ + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], +}); + +const remember = (pathname: string, cookieHeader?: string) => + urlLocaleToRemember({ + cookieHeader, + host: "vitnode.test", + localeRouting, + pathname, + }); + +describe("urlLocaleToRemember", () => { + it("remembers a locale the URL asks for", () => { + expect(remember("/pl/plugins")).toBe("pl"); + }); + + it("leaves a cookie that already says so alone", () => { + expect(remember("/pl/plugins", "vitnode_locale=pl")).toBeUndefined(); + }); + + it("replaces a cookie that says something else", () => { + expect(remember("/pl", "vitnode_locale=en")).toBe("pl"); + }); + + it("does not treat an unprefixed URL as a choice", () => { + expect(remember("/plugins", "vitnode_locale=pl")).toBeUndefined(); + }); + + it("ignores paths that never carry a locale", () => { + expect(remember("/admin")).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/lib/i18n/remembered-locale.ts b/packages/vitnode/src/lib/i18n/remembered-locale.ts new file mode 100644 index 0000000000..5a0d308756 --- /dev/null +++ b/packages/vitnode/src/lib/i18n/remembered-locale.ts @@ -0,0 +1,26 @@ +import type { LocaleRouting } from "./locale-routing"; + +import { readLocaleCookie } from "./locale-cookie"; + +export interface UrlLocaleToRememberInput { + cookieHeader: null | string | undefined; + host: string | undefined; + localeRouting: LocaleRouting; + pathname: string; +} + +export const urlLocaleToRemember = ({ + cookieHeader, + host, + localeRouting, + pathname, +}: UrlLocaleToRememberInput): string | undefined => { + if (localeRouting.shouldIgnoreLocalePath(pathname)) return undefined; + + const urlLocale = localeRouting.extractLocaleFromPath(pathname, { host }); + if (!urlLocale || urlLocale === readLocaleCookie(cookieHeader)) { + return undefined; + } + + return urlLocale; +}; diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts index ae59be6212..2d82e1507a 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts @@ -1,7 +1,8 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + DEFERRED_MIDDLEWARE_CONFIG, knownMiddlewareConfig, loadMiddlewareConfig, middlewareConfigQueryOptions, @@ -53,3 +54,35 @@ describe("loadMiddlewareConfig", () => { expect(config).toEqual(known); }); }); + +describe("middlewareConfigQueryOptions", () => { + const isStale = (data: MiddlewareConfigState) => + new QueryObserver( + seeded(data), + middlewareConfigQueryOptions(), + ).getCurrentResult().isStale; + + it("asks again as soon as a page shipped without the configuration", () => { + expect(isStale(DEFERRED_MIDDLEWARE_CONFIG)).toBe(true); + }); + + it("asks again after the API could not answer", () => { + expect(isStale(UNKNOWN_MIDDLEWARE_CONFIG)).toBe(true); + }); + + it("keeps a known configuration fresh", () => { + expect( + isStale( + knownMiddlewareConfig({ + ai: { models: [] }, + bottomBar: [], + isEmail: true, + navigation: [], + passkeys: false, + password: true, + sso: [], + }), + ), + ).toBe(false); + }); +}); diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index cb6ea03b2f..46f922316a 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -13,11 +13,13 @@ import { normalizeSSOProviders } from "@/views/auth/sso/providers"; export type MiddlewareConfig = z.infer; export interface MiddlewareConfigState extends MiddlewareConfig { + isDeferred: boolean; isKnown: boolean; } export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ ai: { models: [] }, + isDeferred: false, isEmail: false, isKnown: false, navigation: [], @@ -27,10 +29,15 @@ export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ sso: [], }); +export const DEFERRED_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ + ...UNKNOWN_MIDDLEWARE_CONFIG, + isDeferred: true, +}); + /** The API's answer, marked as one. */ export const knownMiddlewareConfig = ( config: MiddlewareConfig, -): MiddlewareConfigState => ({ ...config, isKnown: true }); +): MiddlewareConfigState => ({ ...config, isDeferred: false, isKnown: true }); const fetchMiddlewareConfig = async (): Promise => { try { @@ -61,7 +68,8 @@ export const middlewareConfigQueryOptions = () => queryOptions({ queryFn: async () => await fetchMiddlewareConfig(), queryKey: MIDDLEWARE_QUERY_KEY, - staleTime: MIDDLEWARE_STALE_TIME, + refetchOnMount: query => (query.state.data?.isKnown ? true : "always"), + staleTime: query => (query.state.data?.isKnown ? MIDDLEWARE_STALE_TIME : 0), }); export const loadMiddlewareConfig = async ( diff --git a/packages/vitnode/src/tanstack/i18n/index.ts b/packages/vitnode/src/tanstack/i18n/index.ts index ed0831b714..6eeb7de1fe 100644 --- a/packages/vitnode/src/tanstack/i18n/index.ts +++ b/packages/vitnode/src/tanstack/i18n/index.ts @@ -17,6 +17,7 @@ export { MAX_NAMESPACES, validateIntlInput, } from "./query"; +export { RememberUrlLocale } from "./remember-url-locale"; export { RouteMessages } from "./route-messages"; export type { ConfigureIntlOptions, diff --git a/packages/vitnode/src/tanstack/i18n/remember-url-locale.tsx b/packages/vitnode/src/tanstack/i18n/remember-url-locale.tsx new file mode 100644 index 0000000000..149937c93e --- /dev/null +++ b/packages/vitnode/src/tanstack/i18n/remember-url-locale.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { serializeLocaleCookie } from "@/lib/i18n/locale-cookie"; +import { urlLocaleToRemember } from "@/lib/i18n/remembered-locale"; + +import { browserHostOf } from "./host"; +import { getIntlRuntime } from "./runtime"; + +export const RememberUrlLocale = () => { + React.useEffect(() => { + const { cookie } = globalThis.document; + const { location } = globalThis; + const locale = urlLocaleToRemember({ + cookieHeader: cookie, + host: browserHostOf(location), + localeRouting: getIntlRuntime().localeRouting, + pathname: location.pathname, + }); + if (!locale) return; + + globalThis.document.cookie = serializeLocaleCookie(locale, { + secure: location.protocol === "https:", + }); + }, []); + + return null; +}; diff --git a/packages/vitnode/src/tanstack/i18n/request.ts b/packages/vitnode/src/tanstack/i18n/request.ts index fa502ac314..3a814cbf3b 100644 --- a/packages/vitnode/src/tanstack/i18n/request.ts +++ b/packages/vitnode/src/tanstack/i18n/request.ts @@ -2,11 +2,8 @@ import "@tanstack/react-start/server-only"; import type { LocaleRouting } from "@/lib/i18n/locale-routing"; -import { - LOCALE_COOKIE_NAME, - readLocaleCookie, - serializeLocaleCookie, -} from "@/lib/i18n/locale-cookie"; +import { serializeLocaleCookie } from "@/lib/i18n/locale-cookie"; +import { urlLocaleToRemember } from "@/lib/i18n/remembered-locale"; import { requestHostOf } from "./host"; @@ -76,18 +73,20 @@ export const handleLocaleRequest = ( // Read before anything redirects. `extractLocaleFromPath` answers only for a // prefix this app actually writes, so `/en/admin` - the default locale spelled // out - is a URL to canonicalise rather than a choice to record. - const urlLocale = localeRouting.extractLocaleFromPath(pathname, { host }); - const cookieLocale = readLocaleCookie( - request.headers.get("cookie"), - LOCALE_COOKIE_NAME, - ); + const rememberedLocale = urlLocaleToRemember({ + cookieHeader: request.headers.get("cookie"), + host, + localeRouting, + pathname, + }); // Only when it would actually change something. A `Set-Cookie` on every // request to a prefixed URL makes each of them individually cacheable and // achieves nothing else. - const setCookie = - urlLocale && urlLocale !== cookieLocale - ? serializeLocaleCookie(urlLocale, { secure: url.protocol === "https:" }) - : undefined; + const setCookie = rememberedLocale + ? serializeLocaleCookie(rememberedLocale, { + secure: url.protocol === "https:", + }) + : undefined; const redirectTo = localeRouting.redirectUrlFor(url, { host }); if (redirectTo === undefined) return setCookie ? { setCookie } : {}; diff --git a/packages/vitnode/src/tanstack/layout/header.tsx b/packages/vitnode/src/tanstack/layout/header.tsx index b2df1a0442..3ecc51b135 100644 --- a/packages/vitnode/src/tanstack/layout/header.tsx +++ b/packages/vitnode/src/tanstack/layout/header.tsx @@ -1,7 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AbstractIntlMessages } from "use-intl"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import React from "react"; import { createTranslator, useTranslations } from "use-intl"; @@ -21,6 +21,7 @@ import { MobileNavBar } from "@/views/layouts/theme/header/mobile-nav/mobile-nav import { navigationOutsideBottomBar } from "@/views/layouts/theme/header/mobile-nav/mobile-nav-model"; import { + DEFERRED_MIDDLEWARE_CONFIG, middlewareConfigQueryOptions, useMiddlewareConfigQuery, } from "../auth/middleware-config"; @@ -28,8 +29,11 @@ import { prefetchSession } from "../auth/session-query"; import { useLocale } from "../i18n/locale"; import { GLOBAL_NAMESPACE, intlQueryOptions } from "../i18n/query"; import { NotificationsBell } from "../notifications/bell"; +import { isPrerenderRequest } from "../prerender"; import { MobileUserMenu } from "./mobile-user-menu"; +const NO_MESSAGES: AbstractIntlMessages = {}; + const asNavigationNodes = ( items: readonly PublicNavigationItem[], ): PublicNavigationNode[] => items.map(item => ({ ...item, items: [] })); @@ -63,11 +67,15 @@ export const Header = ({ user }: { user?: React.ReactNode }) => { [...config.navigation, ...bottomBarNodes], GLOBAL_NAMESPACE, ); - const { data } = useSuspenseQuery(intlQueryOptions({ locale, namespaces })); + const { data, isPlaceholderData } = useQuery({ + ...intlQueryOptions({ locale, namespaces }), + placeholderData: keepPreviousData, + }); + const messages = data?.messages ?? NO_MESSAGES; const translate = React.useMemo( - () => headerNavTranslator(locale, data.messages), - [data.messages, locale], + () => headerNavTranslator(locale, messages), + [messages, locale], ); const navigation = React.useMemo( () => headerNavItemsFrom({ items: config.navigation, locale, translate }), @@ -85,6 +93,7 @@ export const Header = ({ user }: { user?: React.ReactNode }) => { return ( <> { } moreNavigationLabel={t("more_navigation")} navigation={navigation} + navigationLoadingLabel={t("loading")} notifications={} user={user} /> @@ -102,20 +112,17 @@ export const Header = ({ user }: { user?: React.ReactNode }) => { ); }; -export const loadMainShell = async ({ +const loadShellNavigation = async ({ locale, queryClient, }: { locale: string; queryClient: QueryClient; }): Promise => { - const [config] = await Promise.all([ - queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }), - prefetchSession(queryClient), - ]); + const config = await queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }); await queryClient.query({ ...intlQueryOptions({ @@ -128,3 +135,25 @@ export const loadMainShell = async ({ staleTime: "static", }); }; + +export const loadMainShell = async ( + context: { locale: string; queryClient: QueryClient }, + { + isPrerendering = isPrerenderRequest, + }: { isPrerendering?: () => boolean } = {}, +): Promise => { + if (isPrerendering()) { + context.queryClient.setQueryData( + middlewareConfigQueryOptions().queryKey, + DEFERRED_MIDDLEWARE_CONFIG, + ); + await loadShellNavigation(context); + + return; + } + + await Promise.all([ + loadShellNavigation(context), + prefetchSession(context.queryClient), + ]); +}; diff --git a/packages/vitnode/src/tanstack/layout/main-shell.test.ts b/packages/vitnode/src/tanstack/layout/main-shell.test.ts new file mode 100644 index 0000000000..e172579d95 --- /dev/null +++ b/packages/vitnode/src/tanstack/layout/main-shell.test.ts @@ -0,0 +1,97 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MiddlewareConfigState } from "../auth/middleware-config"; + +import { + DEFERRED_MIDDLEWARE_CONFIG, + knownMiddlewareConfig, + middlewareConfigQueryOptions, + UNKNOWN_MIDDLEWARE_CONFIG, +} from "../auth/middleware-config"; +import { setAuthTransport } from "../auth/transport"; +import { intlQueryOptions } from "../i18n/query"; +import { loadMainShell } from "./header"; + +let sessionReads = 0; + +const unreachable = () => { + throw new Error("the shell loaders call no mutation"); +}; + +setAuthTransport({ + changePasswordFromReset: unreachable, + completeSso: unreachable, + finishAdminPasskeySignIn: unreachable, + finishPasskeySignIn: unreachable, + linkSso: unreachable, + readSession: async () => { + sessionReads += 1; + + return await Promise.resolve({ user: null }); + }, + requestPasswordReset: unreachable, + signIn: unreachable, + signOut: unreachable, + signUp: unreachable, + startAdminPasskeySignIn: unreachable, + startPasskeySignIn: unreachable, + startSso: unreachable, +}); + +const KNOWN_CONFIG = knownMiddlewareConfig({ + ai: { models: [] }, + bottomBar: [], + isEmail: false, + navigation: [], + passkeys: false, + password: true, + sso: [], +}); + +const PRERENDERING = { isPrerendering: () => true }; + +const shellContext = (config: MiddlewareConfigState) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(middlewareConfigQueryOptions().queryKey, config); + queryClient.setQueryData(intlQueryOptions({ locale: "en" }).queryKey, { + locale: "en", + messages: {}, + }); + + return { locale: "en", queryClient }; +}; + +beforeEach(() => { + sessionReads = 0; +}); + +describe("loadMainShell", () => { + it("renders the header for the visitor, so it reads the session", async () => { + await loadMainShell(shellContext(KNOWN_CONFIG)); + + expect(sessionReads).toBe(1); + }); + + it("leaves the visitor out of a prerendered page", async () => { + await loadMainShell(shellContext(KNOWN_CONFIG), PRERENDERING); + + expect(sessionReads).toBe(0); + }); + + it("ships the header without asking the API for the navigation", async () => { + const context = shellContext(KNOWN_CONFIG); + + await loadMainShell(context, PRERENDERING); + + expect( + context.queryClient.getQueryData(middlewareConfigQueryOptions().queryKey), + ).toEqual(DEFERRED_MIDDLEWARE_CONFIG); + }); + + it("renders the fallback header outside a prerender", async () => { + await expect( + loadMainShell(shellContext(UNKNOWN_MIDDLEWARE_CONFIG)), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/tanstack/layout/root-providers.tsx b/packages/vitnode/src/tanstack/layout/root-providers.tsx index 5eaa2b3811..75c97abb3c 100644 --- a/packages/vitnode/src/tanstack/layout/root-providers.tsx +++ b/packages/vitnode/src/tanstack/layout/root-providers.tsx @@ -6,6 +6,7 @@ import { LucideIconCollectorContext } from "@/components/ui/icon-registry"; import { VitNodeProviders } from "@/views/layouts/providers"; import { VitNodeWebSocketProvider } from "@/ws/provider"; +import { RememberUrlLocale } from "../i18n/remember-url-locale"; import { RouteMessages } from "../i18n/route-messages"; import { lucideIconCollectorOf } from "../icons/ssr"; import { RealtimeListeners } from "../realtime/realtime-listeners"; @@ -27,6 +28,7 @@ export const VitNodeRootProviders = ({ + {children} diff --git a/packages/vitnode/src/tanstack/prerender.ts b/packages/vitnode/src/tanstack/prerender.ts new file mode 100644 index 0000000000..64f1785c51 --- /dev/null +++ b/packages/vitnode/src/tanstack/prerender.ts @@ -0,0 +1,14 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; +import { getRequestHeader } from "@tanstack/react-start/server"; + +const PRERENDER_REQUEST_HEADER = "x-nitro-prerender"; + +export const isPrerenderRequest = createIsomorphicFn() + .server(() => { + try { + return getRequestHeader(PRERENDER_REQUEST_HEADER) !== undefined; + } catch { + return false; + } + }) + .client(() => false); diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx new file mode 100644 index 0000000000..37083fd0d5 --- /dev/null +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx @@ -0,0 +1,67 @@ +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { HeaderLayoutContent } from "./header-content"; + +const mount = async ({ + isNavigationPending, +}: { + isNavigationPending: boolean; +}) => { + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: createRootRoute({ + component: () => ( + + ), + }), + }); + + await act(async () => { + await router.load(); + }); + render(); +}; + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + }, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("the header while its navigation loads", () => { + it("holds the navigation's place with an announced placeholder", async () => { + await mount({ isNavigationPending: true }); + + expect(screen.getByRole("status").textContent).toBe("Loading navigation"); + expect(screen.queryByRole("link", { name: "Search" })).toBeNull(); + }); + + it("shows the links once they are known", async () => { + await mount({ isNavigationPending: false }); + + expect(screen.getByRole("link", { name: "Search" })).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx index f72d0525fa..9e2534acd4 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx @@ -4,26 +4,30 @@ import { cn } from "cn"; import type { HeaderNavItem } from "./header-nav"; import { HEADER_HREF } from "./header-nav"; -import { HeaderNavMenu } from "./header-nav-menu"; +import { HeaderNavMenu, HeaderNavSkeleton } from "./header-nav-menu"; export interface HeaderLayoutContentProps extends Omit< React.ComponentProps<"header">, "children" > { + isNavigationPending?: boolean; logo: React.ReactNode; mobileUser?: React.ReactNode; moreNavigationLabel: string; navigation: HeaderNavItem[]; + navigationLoadingLabel: string; notifications?: React.ReactNode; user?: React.ReactNode; } export const HeaderLayoutContent = ({ className, + isNavigationPending = false, logo, mobileUser, moreNavigationLabel, navigation, + navigationLoadingLabel, notifications, user, ...props @@ -38,11 +42,15 @@ export const HeaderLayoutContent = ({
{logo} - + {isNavigationPending ? ( + + ) : ( + + )}
{notifications} diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.tsx b/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.tsx index 78aa2a5d1c..511bc10fa9 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.tsx @@ -13,6 +13,7 @@ import { NavigationMenuTrigger, navigationMenuTriggerStyle, } from "@/components/ui/navigation-menu"; +import { Skeleton } from "@/components/ui/skeleton"; import { parseEmojiIcon } from "@/lib/emoji-icon"; import { isExternalNavigationHref } from "@/lib/navigation"; @@ -328,3 +329,27 @@ export const HeaderNavMenu = ({
); }; + +const NAV_SKELETON_WIDTHS = ["w-20", "w-24", "w-16"] as const; + +export const HeaderNavSkeleton = ({ + className, + label, +}: { + className?: string; + label: string; +}) => ( +
+ {label} + {NAV_SKELETON_WIDTHS.map(width => ( + + ))} +
+);