diff --git a/packages/blocks/src/cms/resolve.test.ts b/packages/blocks/src/cms/resolve.test.ts index bce681f1..0bb79a98 100644 --- a/packages/blocks/src/cms/resolve.test.ts +++ b/packages/blocks/src/cms/resolve.test.ts @@ -23,14 +23,16 @@ vi.mock("./registry", () => ({ import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; import { findPageByPath } from "./loader"; import { getSection } from "./registry"; -import type { AsyncRenderingConfig, DeferredSection } from "./resolve"; +import type { AsyncRenderingConfig, DeferredSection, MatcherContext } from "./resolve"; import { clearCommerceLoaders, DEFAULT_FOLD_THRESHOLD, extractSeoFromProps, getAsyncRenderingConfig, isEagerRequest, + reExtractRawProps, registerCommerceLoader, + registerMatcher, registerEagerSections, registerAlwaysDeferSections, registerNeverDeferSections, @@ -736,16 +738,6 @@ describe("resolvePageSeoBlock — per-section ignoreStructuredData drives the fe }); }); -// --------------------------------------------------------------------------- -// resolveDecoPage — #277 client-side navigation disables deferral -// --------------------------------------------------------------------------- -// -// When isClientNavigation is true, resolveDecoPageImpl sets useAsync = false so -// shouldDeferSection is never called. All sections — including CMS ⚡-wrapped -// ones — are resolved eagerly. This prevents client-nav from returning a -// deferredSections array that loadDeferredSection would then try to resolve -// without the per-request commerce app context. - describe("extractSeoFromProps — commerce jsonLD structured data", () => { const plp = (overrides: Record = {}) => ({ "@type": "ProductListingPage", @@ -888,22 +880,42 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => { }); }); -describe("resolveDecoPage — #277 client-side navigation disables deferral", () => { +// --------------------------------------------------------------------------- +// resolveDecoPage — client nav gets the SAME eager/deferred split as SSR +// --------------------------------------------------------------------------- +// +// A TanStack route loader is BLOCKING: the router will not commit the +// transition until the loader promise settles. Eager-resolving every ⚡ +// below-fold section on client nav therefore freezes the previous page for as +// long as the slowest upstream takes (measured on a real PDP: 20 awaited +// sections, 2717ms, 3.41MB — worse than a full reload). So deferral must apply +// to client nav exactly as it does to SSR. +// +// The historical `!isClientNavigation` gate (decocms/blocks#277) was a +// workaround for deferred loaders that appeared to lose per-request app +// context. It traded a page-wide latency regression for that symptom. The +// second hop (`loadDeferredSection`) is the SAME server fn the SSR path has +// always used and rebuilds MatcherContext from the real request — see the +// "#277 — per-request context survives the deferred second hop" cases below. +// --------------------------------------------------------------------------- + +describe("resolveDecoPage — deferral parity between SSR and client nav", () => { const lazySec = { __resolveType: WELL_KNOWN_TYPES.LAZY, section: { __resolveType: "site/sections/Hero.tsx" }, }; + const eagerSec = { __resolveType: "site/sections/Banner.tsx" }; beforeEach(() => { - // Enable async rendering so useAsync can be true for SSR requests. + // Enable async rendering so useAsync can be true. setAsyncRenderingConfig({ foldThreshold: Infinity, respectCmsLazy: true }); // resolveSectionShallow unwraps the ⚡ and looks up the inner key via // getSection — return truthy so it produces a DeferredSection rather than // falling back to eager resolution. (getSection as ReturnType).mockReturnValue({ default: () => null }); - // Return a page with one CMS ⚡-wrapped section. + // A page with one plain section followed by one CMS ⚡-wrapped section. (findPageByPath as ReturnType).mockReturnValue({ - page: { name: "test", sections: [lazySec] }, + page: { name: "test", sections: [eagerSec, lazySec] }, params: {}, blockKey: "test-page", }); @@ -914,15 +926,178 @@ describe("resolveDecoPage — #277 client-side navigation disables deferral", () (findPageByPath as ReturnType).mockReset(); }); - it("SSR request defers a CMS ⚡ section", async () => { + it("SSR request defers the CMS ⚡ section and keeps the plain one eager", async () => { const result = await resolveDecoPage("/product/foo", {}); expect(result?.deferredSections).toHaveLength(1); expect(result?.deferredSections[0].component).toBe("site/sections/Hero.tsx"); + expect(result?.resolvedSections.map((s) => s.component)).toEqual(["site/sections/Banner.tsx"]); + }); + + it("client nav produces the IDENTICAL split — deferral is not disabled", async () => { + const ssr = await resolveDecoPage("/product/foo", {}); + const nav = await resolveDecoPage("/product/foo", { isClientNavigation: true }); + + // The acceptance criterion: client nav returns deferredSections and awaits + // only the eager set, exactly like SSR. + expect(nav?.deferredSections).toHaveLength(1); + expect(nav?.deferredSections.map((d) => d.component)).toEqual( + ssr?.deferredSections.map((d) => d.component), + ); + expect(nav?.resolvedSections.map((s) => s.component)).toEqual( + ssr?.resolvedSections.map((s) => s.component), + ); }); - it("client-nav (isClientNavigation: true) resolves the ⚡ section eagerly — empty deferredSections", async () => { - const result = await resolveDecoPage("/product/foo", { isClientNavigation: true }); + it("the ⚡ section's index is preserved on client nav (ordering on the merge)", async () => { + const nav = await resolveDecoPage("/product/foo", { isClientNavigation: true }); + expect(nav?.deferredSections[0].index).toBe(1); + }); + + it("bots stay fully eager on a client-nav-flagged request (SEO guarantee)", async () => { + const result = await resolveDecoPage("/product/foo", { + isClientNavigation: true, + userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + }); + expect(result?.deferredSections).toHaveLength(0); + expect(result?.resolvedSections).toHaveLength(2); + }); + + it("?__deco_ssr=1 stays fully eager on a client-nav-flagged request", async () => { + const result = await resolveDecoPage("/product/foo", { + isClientNavigation: true, + url: "https://store.com/product/foo?__deco_ssr=1", + }); expect(result?.deferredSections).toHaveLength(0); + expect(result?.resolvedSections).toHaveLength(2); + }); + + it("a genuine programmatic fetch (Sec-Fetch-Dest: empty, no client-nav flag) stays eager", async () => { + const result = await resolveDecoPage("/product/foo", { + request: new Request("https://store.com/product/foo", { + headers: { "sec-fetch-dest": "empty" }, + }), + }); + expect(result?.deferredSections).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// #277 — per-request context survives the deferred second hop +// --------------------------------------------------------------------------- +// +// This is the invariant that made it safe to re-enable deferral on client nav. +// #277 reported deferred sections rendering blank because their loaders "lost" +// per-request app context. The guarantee is that `resolveDeferredSectionFull` +// (and `loadDeferredSection`, which wraps it in @decocms/tanstack) threads the +// caller's MatcherContext — cookies, url, path, userAgent, request — into BOTH +// the cache-hit and the cache-miss (`reExtractRawProps`) branch. A different +// isolate misses the in-process rawProps Map, so the miss path is the one that +// actually runs in production on Cloudflare Workers; if it ever stops receiving +// matcherCtx, cookie-dependent loaders silently resolve against an anonymous +// request. Do not relax these assertions. + +describe("#277 — deferred second hop keeps per-request context", () => { + const PROBE = "test/matchers/probe.ts"; + const CTX: MatcherContext & { request: Request } = { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile Safari", + url: "https://store.com/product/foo?utm=x", + path: "/product/foo", + cookies: { deco_segment: "abc", VtexIdclientAutCookie: "tok" }, + request: new Request("https://store.com/product/foo?utm=x"), + }; + + /** Every MatcherContext the probe matcher was evaluated with. */ + let seen: MatcherContext[] = []; + + // The ⚡ section sits behind a multivariate flag whose rule is the probe + // matcher. Reaching the inner Shelf at all therefore PROVES the resolver + // evaluated the rule, and the probe records exactly which context it saw — + // an end-to-end assertion rather than a mock of the call site. + const gatedLazy = { + __resolveType: WELL_KNOWN_TYPES.MULTIVARIATE, + variants: [ + { + rule: { __resolveType: PROBE }, + value: { + __resolveType: WELL_KNOWN_TYPES.LAZY, + section: { __resolveType: "site/sections/Shelf.tsx", title: "Mais vendidos" }, + }, + }, + ], + }; + + beforeEach(() => { + seen = []; + registerMatcher(PROBE, (_rule, ctx) => { + seen.push(ctx); + return true; + }); + setAsyncRenderingConfig({ foldThreshold: Infinity, respectCmsLazy: true }); + (getSection as ReturnType).mockReturnValue({ default: () => null }); + (findPageByPath as ReturnType).mockReturnValue({ + page: { name: "test", sections: [gatedLazy] }, + params: {}, + blockKey: "test-page", + }); + (runSingleSectionLoader as ReturnType).mockImplementation( + async (s: unknown) => s, + ); + }); + + afterEach(() => { + (getSection as ReturnType).mockReset(); + (findPageByPath as ReturnType).mockReset(); + (runSingleSectionLoader as ReturnType).mockReset(); + }); + + it("reExtractRawProps (cross-isolate cache miss) resolves with the caller's cookies/UA/url", async () => { + // A cold isolate never populated the in-process rawProps Map, so this is + // the branch that actually runs in production on Cloudflare Workers. + const rawProps = await reExtractRawProps("/product/foo", "site/sections/Shelf.tsx", 0, CTX); + + expect(rawProps).toMatchObject({ title: "Mais vendidos" }); + expect(seen).not.toHaveLength(0); + for (const ctx of seen) { + expect(ctx.cookies).toEqual(CTX.cookies); + expect(ctx.userAgent).toBe(CTX.userAgent); + expect(ctx.url).toBe(CTX.url); + expect(ctx.request).toBe(CTX.request); + } + }); + + it("resolveDeferredSectionFull on a cold cache still resolves + enriches the section", async () => { + const ds = { + component: "site/sections/Shelf.tsx", + index: 0, + props: {}, + } as unknown as DeferredSection; + + const section = await resolveDeferredSectionFull(ds, "/product/foo", CTX.request, CTX); + + expect(section).not.toBeNull(); + expect(section?.component).toBe("site/sections/Shelf.tsx"); + expect(section?.index).toBe(0); + // The deferred hop must run the section's own loader — this is what #277 + // reported as missing, and it is what makes the second hop equivalent to + // eager resolution. Scope note: this asserts the request THIS function was + // handed reaches the loader. `loadDeferredSection` in @decocms/tanstack + // constructs its own `new Request(pageUrl || serverUrl, { headers })` before + // calling in, so the fidelity of that reconstruction is a separate concern + // and is not covered here. + expect(runSingleSectionLoader).toHaveBeenCalled(); + const [, passedRequest] = (runSingleSectionLoader as ReturnType).mock + .calls[0] as [unknown, Request]; + expect(passedRequest).toBe(CTX.request); + }); + + it("a context-free second hop is observably different — guards against dropping matcherCtx", async () => { + // If reExtractRawProps ever stops threading matcherCtx, cookie/UA-gated + // variants silently resolve against an anonymous request. Assert the probe + // can actually tell the two apart, so the test above is not vacuous. + await reExtractRawProps("/product/foo", "site/sections/Shelf.tsx", 0, undefined); + expect(seen).not.toHaveLength(0); + expect(seen[0].cookies).toBeUndefined(); + expect(seen[0].userAgent).toBeUndefined(); }); }); diff --git a/packages/blocks/src/cms/resolve.ts b/packages/blocks/src/cms/resolve.ts index 8249da0a..881d27a1 100644 --- a/packages/blocks/src/cms/resolve.ts +++ b/packages/blocks/src/cms/resolve.ts @@ -388,9 +388,10 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean { * (`image`/`script`/`style`/`font`) do not — navigations stay deferred because * they CAN hydrate and resolve deferred sections. SPA navigations (TanStack * `` → `/_serverFn`) also send `empty` but set `isClientNavigation`, and - * are excluded here so page-SEO commerce loaders stay off for humans - * (decocms/blocks#286); their sections already render eagerly via the - * `!isClientNav` branch of the `useAsync` gate. + * are excluded here on both counts: page-SEO commerce loaders stay off for + * humans (decocms/blocks#286), and their sections stay deferred, because a + * client nav renders through `DecoPageRenderer` and therefore CAN resolve a + * deferred section on scroll — unlike a real AJAX consumer. * * Like {@link hasForceEagerParam}, this must stay in lock-step with the edge * cache key: `workerEntry` keys `Sec-Fetch-Dest: empty` page requests into a @@ -447,11 +448,15 @@ export interface MatcherContext { */ flags?: StoredFlag[]; /** - * Client-side (SPA) navigation via TanStack ``. Disables section - * deferral: deferral is a streaming-SSR optimization, but a client nav - * receives the server-fn JSON in one shot, so deferral adds a round-trip + - * skeleton with no benefit (and breaks loaders that need per-request app - * context — see decocms/blocks#277). Set by the route loaders. + * Client-side (SPA) navigation via TanStack ``. Set by the route + * loaders, where the incoming request URL is the `/_serverFn/...` endpoint + * rather than the page being navigated to. + * + * Consumed by `derivePageUrl` (rebuilding the real page URL, #280) and by + * {@link isProgrammaticFetch} (a SPA nav sends `Sec-Fetch-Dest: empty` but is + * NOT an AJAX consumer). It deliberately does NOT affect section deferral — + * a TanStack route loader is blocking, so eager-resolving below-fold sections + * on client nav stalls the whole transition. See `resolveDecoPageImpl`. */ isClientNavigation?: boolean; } @@ -1523,6 +1528,15 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo * then they can only force a NON-⚡ section eager — they never override the * editor's ⚡ choice. * + * Because deferral now applies to client (SPA) navigations too, a ⚡ section is + * resolved on a SECOND server hop (`loadDeferredSection`) in both cases. That + * hop rebuilds MatcherContext from the real request, but it cannot reconstruct + * "which branch of the page renders at all" — so a *gate* section (one whose + * loader picks between `children`/`fallback`, e.g. a combined PDP/PLP route) + * must be left un-⚡ in the admin. There is deliberately no code-level override + * for this: `neverDefer`/`alwaysEager` sit below the admin check and cannot win + * against an explicit editorial ⚡ (see decocms/blocks#277). + * * Exported for unit testing. */ export function shouldDeferSection( @@ -2008,13 +2022,32 @@ async function resolveDecoPageImpl( } const isBotReq = isEagerRequest(matcherCtx); - // SPA navigation (TanStack ) receives the server-fn JSON in one shot — - // there is no HTTP streaming, so deferral adds a round-trip + skeleton with - // no benefit (and breaks loaders that need per-request app context, #277). - // Resolve everything eagerly on client nav; SSR/bots keep deferral. - const isClientNav = matcherCtx?.isClientNavigation ?? false; + // Deferral applies identically to SSR documents and SPA navigations. A + // TanStack route loader is BLOCKING: the router does not commit the + // transition until the loader promise settles, so resolving every + // below-the-fold section eagerly on client nav freezes the previous page for + // as long as the slowest shelf/upstream takes (measured: 2.7s and a 3.4MB + // payload on a real PDP — worse than a full reload). Deferral's job is + // decoupling first paint from below-fold data, which matters MORE here, not + // less. + // + // #277 (deferred loaders missing per-request app context) is not a reason to + // disable deferral on client nav: the deferred second hop is the same + // `loadDeferredSection` server fn the SSR path has always used, and it runs + // server-side with matcherCtx rebuilt from the real request + // (url/path/cookies/request). It may well land in a DIFFERENT isolate — that + // is exactly why `reExtractRawProps` exists as the rawProps cache-miss path — + // but per-request state is reconstructed from the request either way, so a + // client nav is no more exposed than an SSR document already was. A section + // whose loader genuinely cannot be resolved on a second hop (e.g. a PDP/PLP + // gate that decides what renders at all) must not be marked ⚡ in the admin; + // see `shouldDeferSection`. + // + // `isClientNavigation` is still load-bearing for `derivePageUrl` (duplicate + // query params) and for `isProgrammaticFetch` (SPA `Sec-Fetch-Dest: empty` + // must not be mistaken for an AJAX call) — it just no longer gates deferral. const currentAsyncConfig = getAsyncConfig(); - const useAsync = currentAsyncConfig !== null && !isBotReq && !isClientNav; + const useAsync = currentAsyncConfig !== null && !isBotReq; const eagerResults: (ResolvedSection[] | Promise)[] = []; const deferredSections: DeferredSection[] = []; diff --git a/packages/tanstack/src/hooks/DecoPageRenderer.tsx b/packages/tanstack/src/hooks/DecoPageRenderer.tsx index a5b1c7f1..dd49d300 100644 --- a/packages/tanstack/src/hooks/DecoPageRenderer.tsx +++ b/packages/tanstack/src/hooks/DecoPageRenderer.tsx @@ -307,9 +307,9 @@ function DeferredSectionWrapper({ const el = ref.current; if (!el) return; - if (typeof IntersectionObserver === "undefined") { + const key = stableKey; + const load = () => { triggered.current = true; - const key0 = stableKey; loadFn({ component: deferred.component, pagePath, @@ -317,37 +317,60 @@ function DeferredSectionWrapper({ index: deferred.index, }) .then((result) => { - if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() }); + if (result) deferredSectionCache.set(key, { section: result, ts: Date.now() }); setSection(result); }) .catch((e) => setError(e)); + }; + + if (typeof IntersectionObserver === "undefined") { + load(); return; } - const observer = new IntersectionObserver( - ([entry]) => { - if (entry?.isIntersecting && !triggered.current) { - triggered.current = true; - observer.disconnect(); - const key1 = stableKey; - loadFn({ - component: deferred.component, - pagePath, - pageUrl, - index: deferred.index, - }) - .then((result) => { - if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() }); - setSection(result); - }) - .catch((e) => setError(e)); - } - }, - { rootMargin: "300px" }, - ); - - observer.observe(el); - return () => observer.disconnect(); + let observer: IntersectionObserver | undefined; + const startObserving = () => { + if (triggered.current) return; + observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && !triggered.current) { + observer?.disconnect(); + load(); + } + }, + { rootMargin: "300px" }, + ); + observer.observe(el); + }; + + // Wait one frame before observing, so the router's scroll reset lands first. + // + // On a client (SPA) navigation the scroll position is still wherever the + // user left the PREVIOUS page when this effect runs. TanStack resets it from + // the `onRendered` event, which is emitted in a `useLayoutEffect` + // (react-router's `OnRendered`) that depends on the `resolvedLocation` store + // — and that store is written from ANOTHER `useLayoutEffect` (Transitioner). + // So the reset necessarily lands one commit after the one that mounts these + // skeletons, and React flushes this commit's passive effects before starting + // that follow-up render. Verified ordering: observe → scroll reset. + // + // Observing synchronously therefore evaluates intersection against the stale + // scroll offset: a user navigating from the bottom of a long page has every + // skeleton "in view" at once, so every deferred section fires its serverFn + // POST simultaneously on commit — the exact thundering herd deferral exists + // to avoid. One frame is enough to let the reset apply. + // + // `triggered` is re-checked inside, so a section that resolved from cache in + // the meantime never starts an observer. + if (typeof requestAnimationFrame === "undefined") { + startObserving(); + return () => observer?.disconnect(); + } + const raf = requestAnimationFrame(startObserving); + return () => { + cancelAnimationFrame(raf); + observer?.disconnect(); + }; }, [deferred.component, deferred.index, deferred.propsHash, pagePath, pageUrl, section, loadFn]); if (error) { @@ -495,6 +518,11 @@ interface Props { * Unawaited promises for deferred sections, keyed by `d_`. * Created by the route loader for TanStack native SSR streaming. * When provided, takes precedence over `loadDeferredSectionFn`. + * + * SSR-ONLY: promises cannot cross the server-fn JSON boundary, so a client + * (SPA) navigation never receives them. Any site that defers sections needs + * `loadDeferredSectionFn` wired regardless — it is the only path that works + * for both SSR and client nav. */ deferredPromises?: Record>; pagePath?: string; @@ -509,7 +537,15 @@ interface Props { device?: Device; loadingFallback?: ReactNode; errorFallback?: ReactNode; - /** @deprecated Use deferredPromises instead (TanStack native streaming). */ + /** + * IntersectionObserver-driven loader for deferred sections — wire this to + * `deferredSectionLoader` from `@decocms/tanstack`. + * + * NOT deprecated: `deferredPromises` only covers SSR (promises can't be + * serialized through a server fn), so this is the only deferred-resolution + * path available on a client (SPA) navigation. Without it, deferred sections + * on a SPA transition render a skeleton that never resolves. + */ loadDeferredSectionFn?: (data: { component: string; rawProps?: Record; diff --git a/packages/tanstack/src/hooks/NavigationProgress.test.tsx b/packages/tanstack/src/hooks/NavigationProgress.test.tsx new file mode 100644 index 00000000..6799b91e --- /dev/null +++ b/packages/tanstack/src/hooks/NavigationProgress.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +// The component only needs the router's isLoading flag. +let isLoading = true; +vi.mock("@tanstack/react-router", () => ({ + useRouterState: ({ select }: { select: (s: { isLoading: boolean }) => unknown }) => + select({ isLoading }), +})); + +const { NavigationProgress } = await import("./NavigationProgress"); + +/** + * The bug this guards: the bar used to be painted with the Tailwind utility + * `bg-brand-primary-500`. That token is a *site* concern — framework code + * cannot assume it exists. On a Tailwind v4 theme that resets + * `--color-*: initial` the utility is never generated, so the class resolves to + * nothing and the bar is fully transparent: an invisible progress indicator, in + * production, with no build error and nothing in the console. + * + * The fix paints through an inline custom-property expression, which a CSS build + * cannot elide. It has to satisfy BOTH directions at once: sites that never + * defined the token must still see a bar, and sites that DID must keep their + * brand color rather than silently dropping to black — hence the var-with- + * fallback rather than a bare `currentColor`. + */ +describe("NavigationProgress — visibility does not depend on site color tokens", () => { + it("paints via inline style, never a site-defined Tailwind color utility", () => { + isLoading = true; + const html = renderToStaticMarkup(); + + expect(html).not.toMatch(/bg-brand-/); + expect(html).toMatch(/background-color:\s*currentColor/); + }); + + it("keeps the brand token when the site defines it, and falls back when it does not", () => { + isLoading = true; + const html = renderToStaticMarkup(); + + // Both halves matter: the var preserves branding for sites where the old + // utility worked, the fallback is what makes it visible for those where it + // silently did not. + expect(html).toContain("--color-brand-primary-500"); + expect(html).toMatch(/var\(--color-brand-primary-500,\s*currentColor\)/); + }); + + it("accepts an explicit color so a site can brand it without that token name", () => { + isLoading = true; + const html = renderToStaticMarkup(); + expect(html).toContain("#ff0080"); + expect(html).not.toContain("--color-brand-primary-500"); + }); + + it("renders nothing when the router is idle", () => { + isLoading = false; + expect(renderToStaticMarkup()).toBe(""); + }); +}); diff --git a/packages/tanstack/src/hooks/NavigationProgress.tsx b/packages/tanstack/src/hooks/NavigationProgress.tsx index efb25dbe..8e76d13b 100644 --- a/packages/tanstack/src/hooks/NavigationProgress.tsx +++ b/packages/tanstack/src/hooks/NavigationProgress.tsx @@ -5,17 +5,59 @@ const PROGRESS_CSS = ` .nav-progress-bar { animation: progressSlide 1s ease-in-out infinite; } `; +/** + * Brand token when the site defines it, inherited text color when it does not. + * + * The bar used to be painted with the Tailwind utility `bg-brand-primary-500`. + * That token is a *site* concern, and framework code cannot assume it exists: on + * a Tailwind v4 theme that resets `--color-*: initial` the utility is never + * generated, so the class resolved to nothing and the bar rendered fully + * transparent — an invisible progress indicator, in production, with no build + * error and nothing in the console. + * + * A CSS custom property with a fallback fixes that without regressing the sites + * where it already worked: they keep their brand color (Tailwind v4 emits + * `--color-brand-primary-500` for a `brand-primary` palette entry), and + * everyone else falls back to `currentColor` instead of nothing. Unlike a + * utility class, neither half can be dropped by a CSS build. + */ +const DEFAULT_COLOR = "var(--color-brand-primary-500, currentColor)"; + +export interface NavigationProgressProps { + /** + * Bar color. Any CSS color or custom-property expression. Defaults to + * {@link DEFAULT_COLOR} — the site's `brand-primary-500` token when defined, + * otherwise the inherited text color. Pass an explicit value to brand the bar + * without relying on that token name. + */ + color?: string; +} + /** * Top-of-page loading bar that appears during SPA navigation. * Uses the router's isLoading state — no extra dependencies. */ -export function NavigationProgress() { +export function NavigationProgress({ color = DEFAULT_COLOR }: NavigationProgressProps = {}) { const isLoading = useRouterState({ select: (s) => s.isLoading }); if (!isLoading) return null; return ( -
+