Improve page load times and rendering

This commit is contained in:
adilallo
2026-05-26 06:59:52 -06:00
parent 6b45a2e5d0
commit 3be188a3cc
29 changed files with 467 additions and 176 deletions
+7 -23
View File
@@ -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;
+34 -14
View File
@@ -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
+17
View File
@@ -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) {
+3 -2
View File
@@ -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();
}
}