From 3be188a3cc2753b30a36ccf54ce2c516f8dcd277 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 26 May 2026 06:59:52 -0600 Subject: [PATCH] Improve page load times and rendering --- app/(admin)/layout.tsx | 12 +- app/(app)/create/CreateFlowLayoutGate.tsx | 30 ++--- app/(app)/create/SignedInDraftHydration.tsx | 22 ++++ .../create/context/CreateFlowContext.tsx | 48 +++++--- app/(app)/create/loading.tsx | 17 +++ .../screens/createFlowScreenComponents.tsx | 110 +++++++++++++++--- app/(app)/create/utils/createFlowPaths.ts | 5 +- .../utils/prepareFreshCreateFlowEntry.ts | 60 +++++++++- app/(app)/layout.tsx | 14 ++- app/(app)/profile/ProfilePageClient.tsx | 8 +- app/(marketing)/layout.tsx | 2 + app/(marketing)/loading.tsx | 14 +++ .../templates/TemplatesPageClient.tsx | 10 +- app/(marketing)/use-cases/page.tsx | 49 ++++---- app/components/asset/Avatar/Avatar.tsx | 10 +- .../cards/CaseStudy/CaseStudy.view.tsx | 38 ++++-- .../navigation/MarketingNavigation.tsx | 27 +++++ .../navigation/Top/Top.container.tsx | 15 ++- .../QuoteBlock/QuoteBlock.container.tsx | 11 -- .../sections/QuoteBlock/QuoteBlock.types.ts | 2 - .../sections/QuoteBlock/QuoteBlock.view.tsx | 25 +--- .../RuleStack/RuleStack.container.tsx | 8 +- app/layout.tsx | 36 ++++-- messages/en/pages/useCases.json | 4 +- next.config.mjs | 14 ++- tests/components/Top.test.tsx | 4 +- tests/components/cards/CaseStudy.test.tsx | 12 +- tests/unit/Layout.test.jsx | 9 +- vitest.config.mjs | 27 +++++ 29 files changed, 467 insertions(+), 176 deletions(-) create mode 100644 app/(app)/create/loading.tsx create mode 100644 app/(marketing)/loading.tsx create mode 100644 app/components/navigation/MarketingNavigation.tsx diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 52df07c..6f7f23d 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,7 +1,17 @@ import type { ReactNode } from "react"; +import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; + +// Reads the session for admin chrome (matches the HttpOnly cookie on first +// HTML response). Scoped here so `(marketing)` can render statically. +export const dynamic = "force-dynamic"; // Operator/admin dashboards (e.g. `/monitor`) intentionally render without the // public marketing footer. Auth/access is enforced upstream. export default function AdminLayout({ children }: { children: ReactNode }) { - return
{children}
; + return ( + <> + +
{children}
+ + ); } diff --git a/app/(app)/create/CreateFlowLayoutGate.tsx b/app/(app)/create/CreateFlowLayoutGate.tsx index 1cd755e..eb0e999 100644 --- a/app/(app)/create/CreateFlowLayoutGate.tsx +++ b/app/(app)/create/CreateFlowLayoutGate.tsx @@ -1,28 +1,12 @@ -"use client"; - -import dynamic from "next/dynamic"; import type { ReactNode } from "react"; -import { useTranslation } from "../../contexts/MessagesContext"; - -function CreateFlowLayoutLoading() { - const t = useTranslation("controlsChrome"); - return ( -
- ); -} - -const CreateFlowLayoutClient = dynamic( - () => import("./CreateFlowLayoutClient"), - { - ssr: false, - loading: () => , - }, -); +import CreateFlowLayoutClient from "./CreateFlowLayoutClient"; +/** + * Server-renders the create-flow chrome shell so users see real layout instead + * of a black `aria-busy` div while the client bundle hydrates. The provider + * inside `CreateFlowLayoutClient` defers `localStorage` reads to a mount-once + * effect so SSR + first client render align. + */ export default function CreateFlowLayoutGate({ children, }: { diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index d1554a0..e5b1588 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -16,6 +16,7 @@ import { isValidStep, parseCreateFlowScreenFromPathname, } from "./utils/flowSteps"; +import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; @@ -52,6 +53,22 @@ export function SignedInDraftHydration({ const [loadingHydration, setLoadingHydration] = useState(false); const finishedUserIdRef = useRef(null); + const [freshEntryPending, setFreshEntryPending] = useState(false); + + // Poll the sessionStorage sentinel set by `prepareFreshCreateFlowEntrySync`. + // Cheap because the gate is open within a few hundred ms in practice; the + // poll stops as soon as the in-flight DELETE clears the flag. + useEffect(() => { + if (!hasFreshEntryPending()) return; + setFreshEntryPending(true); + const id = window.setInterval(() => { + if (!hasFreshEntryPending()) { + setFreshEntryPending(false); + window.clearInterval(id); + } + }, 50); + return () => window.clearInterval(id); + }, []); useEffect(() => { if (!isBackendSyncEnabled()) return; @@ -68,6 +85,10 @@ export function SignedInDraftHydration({ return; } + if (freshEntryPending) { + return; + } + // Local draft wins over server: no fetch, no replaceState. The provider // already hydrated from localStorage at mount, so the user sees their // unsaved keystrokes immediately. @@ -122,6 +143,7 @@ export function SignedInDraftHydration({ replaceState, pathname, router, + freshEntryPending, ]); if (!loadingHydration) return null; diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index d6cb111..8aba4a0 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -56,29 +56,49 @@ export function CreateFlowProvider({ initialStep = null, enableLocalDraftMirroring = false, }: CreateFlowProviderProps) { - const [state, setState] = useState(() => { - const base = enableLocalDraftMirroring - ? readAnonymousCreateFlowState() - : {}; - const storedDetails = readCoreValueDetailsFromLocalStorage(); - if (Object.keys(storedDetails).length === 0) return base; - return { - ...base, - coreValueDetailsByChipId: { - ...storedDetails, - ...(base.coreValueDetailsByChipId ?? {}), - }, - }; - }); + // Initializer must NOT touch `localStorage`: this provider runs through SSR + // now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed + // by a client read of stored data would be a hydration mismatch. The + // `mount-once` effect below replays the read on the client. + const [state, setState] = useState({}); const [interactionTouched, setInteractionTouched] = useState(false); const [currentStep] = useState(initialStep); const prevPersistRef = useRef(enableLocalDraftMirroring); const persistWriteSkipRef = useRef(true); + const initialHydrateDoneRef = useRef(false); useEffect(() => { clearLegacyCreateFlowKeysOnce(); }, []); + // Replay the previous `useState` initializer on mount (client-only). Keeps + // SSR + first client render aligned with the empty default while still + // hydrating any persisted draft / core-value details that existed before + // the user landed back on a wizard step. + useEffect(() => { + if (initialHydrateDoneRef.current) return; + initialHydrateDoneRef.current = true; + const base = enableLocalDraftMirroring + ? readAnonymousCreateFlowState() + : {}; + const storedDetails = readCoreValueDetailsFromLocalStorage(); + const baseEmpty = Object.keys(base).length === 0; + const detailsEmpty = Object.keys(storedDetails).length === 0; + if (baseEmpty && detailsEmpty) return; + setState((prev) => { + const merged: CreateFlowState = { ...base, ...prev }; + if (!detailsEmpty) { + merged.coreValueDetailsByChipId = { + ...storedDetails, + ...(base.coreValueDetailsByChipId ?? {}), + ...(prev.coreValueDetailsByChipId ?? {}), + }; + } + return merged; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-once + }, []); + // Session resolved after initial paint: hydrate from localStorage, merging // with anything already in state. We can't bail on `prev` being non-empty: // the initializer pre-populates `coreValueDetailsByChipId` from a separate diff --git a/app/(app)/create/loading.tsx b/app/(app)/create/loading.tsx new file mode 100644 index 0000000..0c0a982 --- /dev/null +++ b/app/(app)/create/loading.tsx @@ -0,0 +1,17 @@ +/** + * Route-level fallback shown while `/create/...` RSC streams in. Mirrors the + * create-flow chrome surface (top bar height + dark canvas) so the user sees + * structural feedback instead of a flash of the previous page. + */ +export default function CreateFlowRouteLoading() { + return ( +
+
+
+
+ ); +} diff --git a/app/(app)/create/screens/createFlowScreenComponents.tsx b/app/(app)/create/screens/createFlowScreenComponents.tsx index 581ca0f..40a58f9 100644 --- a/app/(app)/create/screens/createFlowScreenComponents.tsx +++ b/app/(app)/create/screens/createFlowScreenComponents.tsx @@ -2,24 +2,108 @@ * Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView} * thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so * new steps do not drift. + * + * `InformationalScreen` is statically imported because it is the entry step; + * every other screen is lazy-loaded so visiting `/create/informational` does + * not pull the rest of the wizard into the initial bundle. */ +import dynamic from "next/dynamic"; import type { ReactNode } from "react"; import type { CreateFlowStep } from "../types"; import { InformationalScreen } from "./informational/InformationalScreen"; -import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen"; -import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; -import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; -import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen"; -import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; -import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; -import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; -import { FinalReviewScreen } from "./review/FinalReviewScreen"; -import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen"; -import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen"; -import { ConflictManagementScreen } from "./card/ConflictManagementScreen"; -import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen"; -import { CompletedScreen } from "./completed/CompletedScreen"; + +const CreateFlowTextFieldScreen = dynamic( + () => + import("./text/CreateFlowTextFieldScreen").then((m) => ({ + default: m.CreateFlowTextFieldScreen, + })), + { loading: () => null }, +); +const CommunitySizeSelectScreen = dynamic( + () => + import("./select/CommunitySizeSelectScreen").then((m) => ({ + default: m.CommunitySizeSelectScreen, + })), + { loading: () => null }, +); +const CommunityStructureSelectScreen = dynamic( + () => + import("./select/CommunityStructureSelectScreen").then((m) => ({ + default: m.CommunityStructureSelectScreen, + })), + { loading: () => null }, +); +const CoreValuesSelectScreen = dynamic( + () => + import("./select/CoreValuesSelectScreen").then((m) => ({ + default: m.CoreValuesSelectScreen, + })), + { loading: () => null }, +); +const ConfirmStakeholdersScreen = dynamic( + () => + import("./select/ConfirmStakeholdersScreen").then((m) => ({ + default: m.ConfirmStakeholdersScreen, + })), + { loading: () => null }, +); +const CommunityUploadScreen = dynamic( + () => + import("./upload/CommunityUploadScreen").then((m) => ({ + default: m.CommunityUploadScreen, + })), + { loading: () => null }, +); +const CommunityReviewScreen = dynamic( + () => + import("./review/CommunityReviewScreen").then((m) => ({ + default: m.CommunityReviewScreen, + })), + { loading: () => null }, +); +const FinalReviewScreen = dynamic( + () => + import("./review/FinalReviewScreen").then((m) => ({ + default: m.FinalReviewScreen, + })), + { loading: () => null }, +); +const CommunicationMethodsScreen = dynamic( + () => + import("./card/CommunicationMethodsScreen").then((m) => ({ + default: m.CommunicationMethodsScreen, + })), + { loading: () => null }, +); +const MembershipMethodsScreen = dynamic( + () => + import("./card/MembershipMethodsScreen").then((m) => ({ + default: m.MembershipMethodsScreen, + })), + { loading: () => null }, +); +const ConflictManagementScreen = dynamic( + () => + import("./card/ConflictManagementScreen").then((m) => ({ + default: m.ConflictManagementScreen, + })), + { loading: () => null }, +); +const DecisionApproachesScreen = dynamic( + () => + import("./right-rail/DecisionApproachesScreen").then((m) => ({ + default: m.DecisionApproachesScreen, + })), + { loading: () => null }, +); +const CompletedScreen = dynamic( + () => + import("./completed/CompletedScreen").then((m) => ({ + default: m.CompletedScreen, + })), + { loading: () => null }, +); export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode { switch (screenId) { diff --git a/app/(app)/create/utils/createFlowPaths.ts b/app/(app)/create/utils/createFlowPaths.ts index 9e2e0e6..70f6f11 100644 --- a/app/(app)/create/utils/createFlowPaths.ts +++ b/app/(app)/create/utils/createFlowPaths.ts @@ -7,13 +7,14 @@ import type { CreateFlowStep } from "../types"; import { CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, + FIRST_STEP, } from "./flowSteps"; export const CREATE_ROUTES = { root: "/", createRoot: "/create", - /** First step resolves via redirect from `/create`. */ - createFirstStep: "/create", + /** Direct path to the first wizard step so client navigations skip the redirect hop. */ + createFirstStep: `/create/${FIRST_STEP}`, review: "/create/review", finalReview: "/create/final-review", completed: "/create/completed", diff --git a/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts index 116f2c2..6947bb0 100644 --- a/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts +++ b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts @@ -4,19 +4,73 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; +/** + * Sentinel set on click and cleared once the in-flight DELETE settles. Read by + * {@link SignedInDraftHydration} so it skips the server draft fetch while the + * fresh-entry cleanup is racing the user's first paint of `/create`. + */ +export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending"; + +export function hasFreshEntryPending(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(FRESH_ENTRY_PENDING_KEY) === "1"; + } catch { + return false; + } +} + +function setFreshEntryPending(): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(FRESH_ENTRY_PENDING_KEY, "1"); + } catch { + /* ignore — sessionStorage may be unavailable */ + } +} + +function clearFreshEntryPending(): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(FRESH_ENTRY_PENDING_KEY); + } catch { + /* ignore */ + } +} + /** * Call **before** navigating into `/create` from marketing or profile “new rule” * entry points so signed-in + sync matches an anonymous fresh start: wipe * `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`. - * Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone - * before mount so {@link SignedInDraftHydration} does not rehydrate stale work. + * + * Synchronous variant: returns immediately after clearing local state and + * scheduling the server draft delete in the background. Sets a sessionStorage + * sentinel that {@link SignedInDraftHydration} checks before fetching, so the + * brief race window does not hydrate from a not-yet-deleted server draft. * * Do **not** use for “Continue draft” — that path should load the server draft. */ +export function prepareFreshCreateFlowEntrySync(): void { + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); + if (!isBackendSyncEnabled()) return; + setFreshEntryPending(); + void deleteServerDraft().finally(clearFreshEntryPending); +} + +/** + * Awaitable variant kept for callers that genuinely need the DELETE to settle + * before continuing (e.g. tests, programmatic reset flows). Most click handlers + * should use {@link prepareFreshCreateFlowEntrySync} for instant navigation. + */ export async function prepareFreshCreateFlowEntry(): Promise { clearAnonymousCreateFlowStorage(); clearCoreValueDetailsLocalStorage(); - if (isBackendSyncEnabled()) { + if (!isBackendSyncEnabled()) return; + setFreshEntryPending(); + try { await deleteServerDraft(); + } finally { + clearFreshEntryPending(); } } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 0ccbb16..2218eaf 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,8 +1,20 @@ import type { ReactNode } from "react"; +import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; + +// Reads `cr_session` via Server Components on every navigation so the header +// matches the HttpOnly cookie on the first HTML response (no "Log in" flash +// before `/api/auth/session`). Scoped here instead of the root layout so +// `(marketing)` can render statically. +export const dynamic = "force-dynamic"; // Signed-in product surfaces (`/create/*`, `/login`) run without the marketing // footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g. // CreateFlow) is composed in nested layouts. export default function AppLayout({ children }: { children: ReactNode }) { - return
{children}
; + return ( + <> + +
{children}
+ + ); } diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index 1631a69..14eea43 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -23,7 +23,7 @@ import { import type { CreateFlowStep } from "../create/types"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; -import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry"; +import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { ProfilePageSignedOutView, @@ -253,10 +253,8 @@ export default function ProfilePageClient() { }, [draft, router]); const handleStartNewCustomRule = useCallback(() => { - void (async () => { - await prepareFreshCreateFlowEntry(); - router.push("/create"); - })(); + prepareFreshCreateFlowEntrySync(); + router.push("/create/informational"); }, [router]); const handleRequestDeleteDraft = useCallback(() => { diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx index bd29e83..188695d 100644 --- a/app/(marketing)/layout.tsx +++ b/app/(marketing)/layout.tsx @@ -1,5 +1,6 @@ import dynamic from "next/dynamic"; import type { ReactNode } from "react"; +import MarketingNavigation from "../components/navigation/MarketingNavigation"; // Site footer is part of the public marketing chrome only — not rendered for // signed-in product surfaces, admin dashboards, or dev previews. See @@ -14,6 +15,7 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), { export default function MarketingLayout({ children }: { children: ReactNode }) { return ( <> +
{children}