mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
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:
@@ -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();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user