perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.
This commit is contained in:
Amruth Pillai
2026-07-05 20:45:42 +02:00
parent 5c1845b1a6
commit 5c75147df3
5 changed files with 48 additions and 54 deletions
@@ -28,7 +28,11 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import {
isEditableElementFocused,
useCurrentBuilderResumeSelector,
useResumeStore,
} from "@/features/resume/builder/draft";
import { authClient } from "@/libs/auth/client";
type BuilderDockProps = {
@@ -38,7 +42,9 @@ type BuilderDockProps = {
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
// Narrow slices: selecting the whole resume re-renders the dock on every keystroke.
const resumeSlug = useCurrentBuilderResumeSelector((resume) => resume.slug);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const navigate = useNavigate();
const [_, copyToClipboard] = useCopyToClipboard();
@@ -67,9 +73,9 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
});
const publicUrl = useMemo(() => {
if (!session?.user.username || !resume?.slug) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session?.user.username, resume?.slug]);
if (!session?.user.username || !resumeSlug) return "";
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
}, [session?.user.username, resumeSlug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
@@ -100,8 +106,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
icon={ChatCircleDotsIcon}
title={t`Open AI agent`}
onClick={() => {
if (!resume) return;
void navigate({ to: "/agent/new", search: { resumeId: resume.id } });
if (!resumeId) return;
void navigate({ to: "/agent/new", search: { resumeId } });
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
@@ -27,7 +27,12 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
import {
useCurrentBuilderResumeSelector,
useCurrentResume,
usePatchResume,
useResumeStore,
} from "@/features/resume/builder/draft";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
@@ -37,9 +42,11 @@ import { BuilderAiAssistant } from "./ai-assistant";
import { BuilderVersionHistory } from "./version-history";
export function BuilderHeader() {
const resume = useCurrentResume();
const name = resume.name;
const isLocked = resume.isLocked;
// Subscribe to only the metadata fields this header renders. Selecting the whole resume re-renders
// the header on every keystroke (immer replaces the resume reference on each content edit).
const name = useCurrentBuilderResumeSelector((resume) => resume.name);
const isLocked = useCurrentBuilderResumeSelector((resume) => resume.isLocked);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { toggleSidebar } = useBuilderSidebar();
// Equal-width flex-1 side groups keep the center title group truly centered regardless of the
@@ -77,8 +84,8 @@ export function BuilderHeader() {
<h2 className="min-w-0 truncate font-medium">{name}</h2>
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
<SaveStatusIndicator />
<BuilderAiAssistant resumeId={resume.id} />
<BuilderVersionHistory resumeId={resume.id} />
<BuilderAiAssistant resumeId={resumeId} />
<BuilderVersionHistory resumeId={resumeId} />
<BuilderHeaderDropdown />
</div>
@@ -35,6 +35,14 @@ function BasicsSectionForm() {
const form = useAppForm({
defaultValues: basics,
validators: { onChange: formSchema },
// Persist on every field change via a form-level listener. Previously each field called
// `form.handleSubmit()` on change, which re-validated the whole form AND toggled submit state —
// firing the render cascade twice per keystroke. A listener persists once, without the submit churn.
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
@@ -64,7 +72,6 @@ function BasicsSectionForm() {
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -88,7 +95,6 @@ function BasicsSectionForm() {
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -113,7 +119,6 @@ function BasicsSectionForm() {
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -137,7 +142,6 @@ function BasicsSectionForm() {
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -161,7 +165,6 @@ function BasicsSectionForm() {
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -182,7 +185,6 @@ function BasicsSectionForm() {
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
void form.handleSubmit();
}}
/>
<FormMessage errors={field.state.meta.errors} />
@@ -37,7 +37,6 @@ export const CustomFieldsSection = withForm({
values={customFieldsField.state.value}
onReorder={(fields) => {
customFieldsField.setValue(fields);
void form.handleSubmit();
}}
>
{customFieldsField.state.value.map((field: CustomField, index: number) => (
@@ -53,7 +52,6 @@ export const CustomFieldsSection = withForm({
className="rounded-r-none! border-e-0!"
onChange={(icon) => {
iconField.handleChange(icon);
void form.handleSubmit();
}}
/>
}
@@ -73,7 +71,6 @@ export const CustomFieldsSection = withForm({
className="rounded-l-none!"
onChange={(e) => {
textField.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
@@ -109,7 +106,6 @@ export const CustomFieldsSection = withForm({
})}
onChange={(e) => {
linkField.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
</div>
@@ -124,7 +120,6 @@ export const CustomFieldsSection = withForm({
aria-label={t`Remove custom field`}
onClick={() => {
customFieldsField.removeValue(index);
void form.handleSubmit();
}}
>
<XIcon />
@@ -136,7 +131,6 @@ export const CustomFieldsSection = withForm({
variant="ghost"
onClick={() => {
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
void form.handleSubmit();
}}
>
<ListPlusIcon />
@@ -36,6 +36,11 @@ function useColorSectionForm(colors: ColorValues, persist: (data: ColorValues) =
const form = useAppForm({
defaultValues: colors,
validators: { onChange: colorDesignSchema },
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
@@ -59,10 +64,6 @@ function ColorSectionForm() {
const form = useColorSectionForm(colors, persist);
const handleAutoSave = () => {
persist(form.state.values);
};
return (
<form
className="space-y-4"
@@ -85,7 +86,6 @@ function ColorSectionForm() {
active={color === field.state.value}
onSelect={(color) => {
field.handleChange(color as string);
handleAutoSave();
}}
/>
))}
@@ -93,20 +93,9 @@ function ColorSectionForm() {
)}
</form.Field>
<ColorFormField
form={form}
name="primary"
label={<Trans>Primary Color</Trans>}
controlled
handleAutoSave={handleAutoSave}
/>
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} handleAutoSave={handleAutoSave} />
<ColorFormField
form={form}
name="background"
label={<Trans>Background Color</Trans>}
handleAutoSave={handleAutoSave}
/>
<ColorFormField form={form} name="primary" label={<Trans>Primary Color</Trans>} controlled />
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} />
<ColorFormField form={form} name="background" label={<Trans>Background Color</Trans>} />
</form>
);
}
@@ -116,10 +105,9 @@ type ColorFormFieldProps = {
name: keyof ColorValues;
label: ReactNode;
controlled?: boolean;
handleAutoSave: () => void;
};
function ColorFormField({ form, name, label, controlled, handleAutoSave }: ColorFormFieldProps) {
function ColorFormField({ form, name, label, controlled }: ColorFormFieldProps) {
return (
<form.Field name={name}>
{(field) => (
@@ -130,7 +118,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
{...(controlled ? { value: field.state.value } : { defaultValue: field.state.value })}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
<FormControl
@@ -141,7 +128,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
handleAutoSave();
}}
/>
}
@@ -235,16 +221,17 @@ function LevelSectionForm() {
const form = useAppForm({
defaultValues: levelDesign,
validators: { onChange: levelDesignSchema },
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
});
useSyncFormValues(form, levelDesign);
const handleAutoSave = () => {
persist(form.state.values);
};
const previewType = useStore(form.store, (s) => s.values.type);
const previewIcon = useStore(form.store, (s) => s.values.icon);
const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" });
@@ -296,7 +283,6 @@ function LevelSectionForm() {
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
handleAutoSave();
}}
/>
}
@@ -318,7 +304,6 @@ function LevelSectionForm() {
onValueChange={(value) => {
if (!value) return;
field.handleChange(value as LevelType);
handleAutoSave();
}}
/>
}