Skip to content

fix(profile): stop work experience skills failing to save in silence - #6645

Merged
rebelchris merged 2 commits into
mainfrom
fix/work-experience-skills-silent-save-failure
Sep 11, 2026
Merged

fix(profile): stop work experience skills failing to save in silence#6645
rebelchris merged 2 commits into
mainfrom
fix/work-experience-skills-silent-save-failure

Conversation

@rebelchris

@rebelchris rebelchris commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

ENG-1886. Pairs with daily-api#4264 (readable messages for the same limits), but neither blocks the other — this PR is safe to ship alone.

The bug

Two defects compound into what was reported.

The limit was server-only and its error was invisible. The API caps skills at 50 per experience and 100 characters each. The client schema had no skills field at all, so nothing was caught before the request. On rejection the API returns ZOD_VALIDATION_ERROR with a generic message plus extensions.issues; useUserExperienceForm.onError routed those to applyZodErrorsToForm and skipped the toast, while ProfileSkills never read its error. The issue landed nowhere and the user saw nothing at all.

The dirty-form flow then made it destructive. The save is one transaction, so a rejected skills array means nothing is saved. The form stays dirty, and on navigation DirtyFormModal offers "Save changes" — which called onSave() and closed immediately without awaiting the mutation, so a second silent failure looked like success. The next attempt reopens the modal, and "Discard" throws the whole section away.

The fix

1. Prevent it client-side. skills added to the client schema, mirroring the server contract, with the limits as named constants. ProfileSkills enforces them at input time: a paste is capped rather than dropped whole and the remainder is named in a toast, over-length entries are rejected with copy, and skills are deduped case-insensitively to match the server's slugify() identity (today "React" and "react" round-trip as two rows). Helper copy at the limit, no counter, per AGENTS.md.

2. Make rejections impossible to miss. ProfileSkills renders its error, and zod errors always raise a toast too — the safety net for any path with no error-rendering field (companyId, externalLocationId, …), which is why this doesn't need a field-by-field audit. The trade-off: a field that does render its error now shows both inline text and a toast.

3. Stop it costing the user their edits. The dirty-form save validates first and awaits the mutation, so the modal stays open and pending until it settles. On failure the modal closes back to the still-dirty form with the error visible and navigation aborted; the form is never reset(). Client validation that blocks the save toasts as well — otherwise the modal just closes with nothing saved and we are back to the original bug.

Two things worth reviewing

useController subscribes with exact: true. Handling the sparse-array shape of an item-level error is not enough: setError('skills.3') never re-renders a Controller named skills at all. Verified both ways before fixing; ProfileSkills reads errors through useFormState, whose non-exact subscription catches the array-level and item-level paths.

The dirty-save path keeps getValues(). Switching it to handleSubmit looks equivalent but silently drops employmentType, locationType, externalLocationId and grade — the client schema is a subset of the form, so zod strips them. It validates with trigger() instead.

useDirtyForm's onSave may now return a promise. The only other consumer, useUserInfoForm, stays synchronous and keeps the previous fire-and-forget close.

Tests

  • ProfileSkills.spec.tsx — adding past the limit blocked with visible copy; an over-limit paste capped and reported; case-insensitive duplicate not added twice; array-level and item-level (skills.3) errors each render
  • useUserExperienceForm.spec.tsx — a ZOD_VALIDATION_ERROR on skills produces both a form error and a toast, with no reset and no navigation; non-zod errors keep existing behavior; an invalid form never reaches the mutation; a failing save resolves only once it settles, values intact
  • DirtyFormModal.spec.tsx (new) — stays open until an async save settles, closes immediately for a sync one, closes after a rejection so the form and its error stay visible

Full suites: shared 3042/3044, webapp 691/692. Both failures (numberFormat.spec.ts, WorldGuideSheet.spec.tsx) are the same pre-existing locale issue on unmodified files (1.234 vs 1,234), unrelated to this change.

E2E deliberately skipped: packages/playwright runs against production with a shared real account, and an experience-editing spec would mutate that account's real profile.

Noted, not done

  • useUserExperienceForm.spec.tsx is added to the strict-typecheck skip list. Its errors are one pre-existing cause — the form is typed useForm<UserExperience> but holds values that type doesn't describe (Date rather than string dates, current, skills as string[]). Fixing it means introducing a form-values type and settling where the page's serialized startedAt string becomes a Date, across the hook, the edit page and every experience form.
  • user_experience_skill has no unique index on (slugify(value), experienceId), so case-variant duplicates are only prevented client-side. A durable fix needs a dedupe/backfill migration — worth a follow-up issue.
  • Whether 50/100 are the right limits is a product call; this PR surfaces whatever the server enforces.

🤖 Generated with Claude Code

Preview domain

https://fix-work-experience-skills-silen.preview.app.daily.dev

Saving an experience with too many skills, or one skill over the length
cap, was rejected by the API and the user was told nothing: the client
schema had no skills field, so nothing was caught before the request,
and ProfileSkills never rendered its error, so the rejection landed
nowhere. The save is a single transaction, so the whole section was
lost, and the still-dirty form then offered "Save changes" on the way
out, which failed just as silently until the user discarded their work.

Three layers, none of which alone is enough:

- The input enforces the limits it knows about. Skills are deduped on
  the API's slugify() identity, a paste is capped rather than dropped
  whole, and whatever did not fit is named in a toast.
- Rejections are visible. ProfileSkills renders the error, and a zod
  error always raises a toast as well, since fields like companyId have
  nowhere to show one. ProfileSkills reads it through useFormState:
  useController subscribes to its exact name, so an issue on `skills.3`
  never reached the Controller at all.
- A failed save no longer costs the user the section. The dirty-form
  path validates first and awaits the mutation, so DirtyFormModal stays
  open until it settles and the form is never reset on failure. Client
  validation that blocks the save says so too, otherwise the modal just
  closes and we are back to the original bug.

useDirtyForm's onSave may now return a promise; sync callers keep the
previous fire-and-forget close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Sep 11, 2026 12:34pm UTC

Request Review

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Traced the three paths (input-time enforcement in ProfileSkills, onError toast + applyZodErrorsToForm, and the awaited dirty-form save) against useDirtyForm, DirtyFormModal, edit.tsx and the RHF subscription model. The fix is sound: useFormState({ name }) is non-exact so skills.N item errors reach the component, getValues() + trigger() correctly avoids the resolver stripping employmentType/grade, and the sync-vs-promise branch in DirtyFormModal keeps useUserInfoForm behaviour unchanged. The client limits mirror the server contract in daily-api#4264 (paths ['skills'] and ['skills', N]), and both shapes are covered by tests.

No blocking findings. Three non-blocking notes inline, plus one cross-cutting one:

Comment density (non-blocking). Root AGENTS.md asks that code match the surrounding comment density and that the reasoning behind a fix live in the commit message/PR description rather than above the code. This diff adds roughly ten rationale blocks (useController exact subscription, getValues() vs resolver output, sparse-array error shape, the skip-list justification, etc.) whose content already sits in the PR description. Worth trimming to the one or two that a future reader genuinely cannot recover from the code (the useFormState subscription note is the strongest candidate to keep).

Verification

  • Read root/shared AGENTS.md, PR description and companion API PR context
  • Traced state/error/navigation flow for submit, dirty-save success, dirty-save server rejection and dirty-save client-invalid
  • Checked both useDirtyForm consumers
  • Did not run tests/typecheck locally (relying on the author's reported suites and CI)

Reviewed by AI.

overLimit === 1 ? 'skill was' : 'skills were'
} not added.`,
);
} else if (tooLong) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: because this is an else if, a single paste that both overflows the 50-skill cap and contains over-length entries only reports the over-limit count; the tooLong rejections are dropped silently, which is the exact class of failure this PR is fixing. Either fold both counts into one toast, or show the tooLong toast independently of overLimit.

Reviewed by AI.

Comment thread scripts/typecheck-strict-changed.js Outdated
// means introducing a form-values type and settling where the page's
// serialized `startedAt` string becomes a Date — a refactor across the
// hook, the edit page and every experience form, not this bug fix.
'packages/shared/src/hooks/useUserExperienceForm.spec.tsx',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: adding a spec to the strict skip list means future edits to it also bypass the strict gate. Since the mismatch is confined to fixtures and setValue calls in the spec, a local form-values alias in the spec (e.g. type ExperienceFormValues = Omit<UserExperience, 'startedAt' | 'endedAt'> & { startedAt: Date; endedAt?: Date; current?: boolean; skills?: string[] } with a cast at the useForm/fixture boundary) would keep the file under strict checking without the wider refactor you describe. Fine to leave as a follow-up if you prefer, but please open the issue so the entry does not become permanent.

Reviewed by AI.

} catch {
// The caller owns surfacing the failure; the modal closes either way so
// the user lands back on their still-unsaved form.
} finally {

@rebelchris rebelchris Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking question: while isSaving is true only the two buttons are disabled; the modal itself can still be dismissed via Escape/overlay through the default onRequestClose. If that happens mid-save, this finally later calls closeModal() on whatever lazy modal is current at that point (possibly a different one the user opened in the meantime), and setIsSaving fires on an unmounted component. Consider either blocking onRequestClose while saving or tracking mount state before calling closeModal() in finally. Low likelihood given the short window, so a judgment call.

Reviewed by AI.

- A paste that both overflows the cap and carries over-length entries
  reported only the first reason and dropped the rest of the count
  silently, which is the failure this PR exists to remove. One toast now
  names every reason and the true number left out.
- DirtyFormModal could be dismissed by Escape or the overlay mid-save,
  so the save would later close whichever modal was current by then.
  Both are inert while a save is in flight.
- Types the form values instead of skip-listing the spec. The form was
  declared as UserExperience while holding values that type does not
  describe, so every fixture and setValue call in the spec mismatched.
  UserExperienceFormValues says what the form actually holds, including
  dates that arrive from the page serialized and become Dates once the
  month/year selects write to them. The GraphQL shape stays at the
  mutation boundary, where the cast already lived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rebelchris
rebelchris merged commit dd8b06f into main Sep 11, 2026
11 checks passed
@rebelchris
rebelchris deleted the fix/work-experience-skills-silent-save-failure branch September 11, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant