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 (
{ field.handleChange(color as string); - handleAutoSave(); }} /> ))} @@ -93,20 +93,9 @@ function ColorSectionForm() { )} - Primary Color} - controlled - handleAutoSave={handleAutoSave} - /> - Text Color} handleAutoSave={handleAutoSave} /> - Background Color} - handleAutoSave={handleAutoSave} - /> + Primary Color} controlled /> + Text Color} /> + Background Color} /> ); } @@ -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(); }} /> }