diff --git a/apps/web/src/routes/builder/$resumeId/-components/dock.tsx b/apps/web/src/routes/builder/$resumeId/-components/dock.tsx
index fe9539ffe..eedd55750 100644
--- a/apps/web/src/routes/builder/$resumeId/-components/dock.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-components/dock.tsx
@@ -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 } });
}}
/>
diff --git a/apps/web/src/routes/builder/$resumeId/-components/header.tsx b/apps/web/src/routes/builder/$resumeId/-components/header.tsx
index 1cf7c135e..2d5539e93 100644
--- a/apps/web/src/routes/builder/$resumeId/-components/header.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-components/header.tsx
@@ -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() {
{name}
{isLocked && }
-
-
+
+
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
index 979d412a8..bc574168c 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx
@@ -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();
}}
/>
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx
index d012ddc26..31a393f06 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom-fields.tsx
@@ -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();
}}
/>
@@ -124,7 +120,6 @@ export const CustomFieldsSection = withForm({
aria-label={t`Remove custom field`}
onClick={() => {
customFieldsField.removeValue(index);
- void form.handleSubmit();
}}
>
@@ -136,7 +131,6 @@ export const CustomFieldsSection = withForm({
variant="ghost"
onClick={() => {
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
- void form.handleSubmit();
}}
>
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
index c9aa378ae..cfebb956e 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
@@ -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 (
);
}
@@ -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 (
{(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();
}}
/>
{
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();
}}
/>
}