Improve page load times and rendering
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
|
||||
aria-busy="true"
|
||||
aria-label={t("loadingCreateFlow")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CreateFlowLayoutClient = dynamic(
|
||||
() => import("./CreateFlowLayoutClient"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <CreateFlowLayoutLoading />,
|
||||
},
|
||||
);
|
||||
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,
|
||||
}: {
|
||||
|
||||
@@ -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<string | null>(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;
|
||||
|
||||
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
|
||||
initialStep = null,
|
||||
enableLocalDraftMirroring = false,
|
||||
}: CreateFlowProviderProps) {
|
||||
const [state, setState] = useState<CreateFlowState>(() => {
|
||||
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<CreateFlowState>({});
|
||||
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||
const [currentStep] = useState<CreateFlowStep | null>(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
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="h-14 w-full border-b border-[var(--color-border-default-primary)] md:h-16" />
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
if (isBackendSyncEnabled()) {
|
||||
if (!isBackendSyncEnabled()) return;
|
||||
setFreshEntryPending();
|
||||
try {
|
||||
await deleteServerDraft();
|
||||
} finally {
|
||||
clearFreshEntryPending();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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 <main className="flex-1">{children}</main>;
|
||||
return (
|
||||
<>
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user