Auth forms on TanStack Form v2 #410

Closed
opened 2026-10-15 12:31:59 +00:00 by root · 10 comments
root commented 2026-10-15 12:31:59 +00:00 (Migrated from gitlab.core-project.space)

Problem Statement

The front's forms are hand-rolled, and the largest ones show it:

  • Sign-up writes its own debounce for the live Username check, its own pre-submit validation, its own map from the server's refusal codes to fields, and its own rule for when a server refusal clears. Its field state is split between two files: the Username where the check needs Query, the email and password in the layout.
  • Sign-in, forgot-password, reset-password and the Account delete dialog each keep one useState per field and validate by hand at submit.
  • The shared auth field and form components rebuild what a form library binds once: value, change, invalid state, hint, refusal, submit in flight.

Source: the front audit, docs/research/front-audit.md, sections 2.2 and 2.2.1. The owner chose TanStack Form v2, on the alpha, now (section 5).

Solution

Every auth form runs on TanStack Form v2. The design-system input and the form's refusal and submit button are bound once. Contract schemas and our refusal mapping stay the source of truth for what is valid and what the Player reads. A Player sees the same forms, the same checks and the same messages as today.

User Stories

  1. As a Player signing up, I want the Username checked while I type, once I pause, so that I learn it is taken before I submit.
  2. As a Player signing up, I want each field's problem shown under that field, in the words used today, so that I know what to fix.
  3. As a Player signing up, I want a Username the server refused to stay marked until I type in that field again, so that the refusal doesn't vanish when I touch another field.
  4. As a Player signing up, I want an email the server refused (taken) shown under the email field, so that I know which field the server refused.
  5. As a Player signing up, I want "too many attempts" shown for the whole form with the minutes to wait, so that I know when to retry.
  6. As a Player signing up with Guest Games, I want the "add my Games" choice kept, ticked by default, so that my history comes with me (ADR 0035).
  7. As a Player signing in, I want one refusal for a wrong email or password, so that the form tells nobody which emails have an Account.
  8. As a Player signing in, I want credentials no Account could have refused without a request, in the server's words, so that I am answered at once.
  9. As a Player asking for a reset link, I want an invalid email refused under the field and the "sent" confirmation as today, so that the flow is unchanged.
  10. As a Player choosing a new password from a reset link, I want too-short and too-long refused under the field, and an expired link to show the invalid-link screen, so that the flow is unchanged.
  11. As a signed-in Player deleting my Account, I want a wrong password refused inside the dialog, and the dialog to start clean when reopened, so that the flow is unchanged.
  12. As a Player, I want the submit button to ignore a second press while the form is sending, so that nothing is sent twice.
  13. As a Player using a keyboard or a screen reader, I want focus on the first invalid field after a refused submit, and each field's problem announced, so that I can fix it without hunting.
  14. As a French-speaking Player, I want every message in French as today, so that the site reads in my language.
  15. As a maintainer, I want the design-system input bound to TanStack Form once, so that a new form is fields and rules, not wiring.
  16. As a maintainer, I want every read of a field's state inside the bound components, so that a new v2 alpha touches one file plus each form's validators.

Seams

  • S1 — each auth page with the auth harness and MSW (front view tests). Covers 1-14: the existing sign-up, sign-in, forgot-password, reset-password and account-page tests stay green unchanged in what they assert, plus focus-on-first-invalid (13) and a refusal that survives typing in another field (3).
  • S2 — apps/e2e accounts.spec (e2e, existing). Covers sign-up and sign-in in a real browser; it stays green unchanged. No new e2e test: no new behaviour.

Implementation Decisions

  • Version. @tanstack/react-form pinned to an exact 2.0.0 alpha (2.0.0-alpha.2, or the latest alpha after reading its release notes and the issues below). latest on npm is still 1.x.
  • One shared form module in lib/, built with createFormHook: a text field component bound once through getFormHookHelpers().fieldComponent.strict, taking the field as a prop; a refusal line and a submit button as form components. Allowed by the rules spec (review rule 4 exemption, @/lib/form in components).
  • Validation. Function validators ({ run, triggers }) that call the existing credential checks and return our refusal unions. Not a zod schema passed directly: zod's issue messages are English, and ADR 0025 wants catalogue copy. The contract schemas stay the source of what is valid (ADR 0040).
  • The Username check. An async validator on change with a 300 ms debounce, reading availability through Query's existing query options, so Query stays the only server-state cache (ADR 0019).
  • Server refusals. onSubmit awaits the mutation, maps the refusal code (and the rate limit) to form and field errors, and returns them with createValidationError({ form, fields }), never throws: a throw is caught and only logged in alpha.2, and issue #2306 will change that.
  • Pending state. The mutation's isPending drives the button (v2 has no submit meta yet, issue #2303); aria-disabled rather than disabled, per the Form docs.
  • Errors carry our union, translated at render, never a string built in a validator.
  • ADRs: 0019, 0025, 0035, 0040, 0041. No new ADR unless the alpha forces one.

Testing Decisions

  • Prior art: sign-up.test.tsx, sign-in.test.tsx, forgot-password.test.tsx, reset-password.test.tsx, account-page.test.tsx; accounts.spec.ts in apps/e2e.
  • A test may change only how it reaches the page, never what it asserts, except the two new cases in S1.
  • Edge and unhappy paths: a Username typed and erased before the debounce (no request); availability answered for an older value than the one shown (ignored); taken Username then a change to email (Username stays refused); rate-limited on every form that has it; a failure that is not a refusal ("something went wrong"); a second press while sending.

Out of Scope

  • The home page's Room-code form and the devtools Guest form (one field each; only if the sign-up ticket shows they read better).
  • Changing any message, rule or flow a Player sees.
  • Moving to TanStack Form v1, or to another library.
  • Base UI's Field component.

Further Notes

  • Audit: docs/research/front-audit.md, section 2.2.1, on branch claude/nestjs-best-practices-audit-716d79.
  • Blocked by #408 (rules and structure): the page-file merge and the review-rule-4 exemption must land first.
  • Read before starting: issue TanStack/form#2341 (blur clears errors) conflicts with story 3; #2306 (exceptions during submit), #2296, #2299, #2360. The alpha branch is ahead of alpha.2 (e.g. schemaOutputs renamed validatorOutputs); v2 docs: https://tanstack.com/form/alpha/docs/framework/react/quick-start.
## Problem Statement The front's forms are hand-rolled, and the largest ones show it: - Sign-up writes its own debounce for the live Username check, its own pre-submit validation, its own map from the server's refusal codes to fields, and its own rule for when a server refusal clears. Its field state is split between two files: the Username where the check needs Query, the email and password in the layout. - Sign-in, forgot-password, reset-password and the Account delete dialog each keep one `useState` per field and validate by hand at submit. - The shared auth field and form components rebuild what a form library binds once: value, change, invalid state, hint, refusal, submit in flight. Source: the front audit, `docs/research/front-audit.md`, sections 2.2 and 2.2.1. The owner chose TanStack Form v2, on the alpha, now (section 5). ## Solution Every auth form runs on TanStack Form v2. The design-system input and the form's refusal and submit button are bound once. Contract schemas and our refusal mapping stay the source of truth for what is valid and what the Player reads. A Player sees the same forms, the same checks and the same messages as today. ## User Stories 1. As a Player signing up, I want the Username checked while I type, once I pause, so that I learn it is taken before I submit. 2. As a Player signing up, I want each field's problem shown under that field, in the words used today, so that I know what to fix. 3. As a Player signing up, I want a Username the server refused to stay marked until I type in that field again, so that the refusal doesn't vanish when I touch another field. 4. As a Player signing up, I want an email the server refused (taken) shown under the email field, so that I know which field the server refused. 5. As a Player signing up, I want "too many attempts" shown for the whole form with the minutes to wait, so that I know when to retry. 6. As a Player signing up with Guest Games, I want the "add my Games" choice kept, ticked by default, so that my history comes with me (ADR 0035). 7. As a Player signing in, I want one refusal for a wrong email or password, so that the form tells nobody which emails have an Account. 8. As a Player signing in, I want credentials no Account could have refused without a request, in the server's words, so that I am answered at once. 9. As a Player asking for a reset link, I want an invalid email refused under the field and the "sent" confirmation as today, so that the flow is unchanged. 10. As a Player choosing a new password from a reset link, I want too-short and too-long refused under the field, and an expired link to show the invalid-link screen, so that the flow is unchanged. 11. As a signed-in Player deleting my Account, I want a wrong password refused inside the dialog, and the dialog to start clean when reopened, so that the flow is unchanged. 12. As a Player, I want the submit button to ignore a second press while the form is sending, so that nothing is sent twice. 13. As a Player using a keyboard or a screen reader, I want focus on the first invalid field after a refused submit, and each field's problem announced, so that I can fix it without hunting. 14. As a French-speaking Player, I want every message in French as today, so that the site reads in my language. 15. As a maintainer, I want the design-system input bound to TanStack Form once, so that a new form is fields and rules, not wiring. 16. As a maintainer, I want every read of a field's state inside the bound components, so that a new v2 alpha touches one file plus each form's validators. ## Seams - **S1 — each auth page with the auth harness and MSW** (front view tests). Covers 1-14: the existing sign-up, sign-in, forgot-password, reset-password and account-page tests stay green unchanged in what they assert, plus focus-on-first-invalid (13) and a refusal that survives typing in another field (3). - **S2 — `apps/e2e` `accounts.spec`** (e2e, existing). Covers sign-up and sign-in in a real browser; it stays green unchanged. No new e2e test: no new behaviour. ## Implementation Decisions - **Version.** `@tanstack/react-form` pinned to an exact 2.0.0 alpha (2.0.0-alpha.2, or the latest alpha after reading its release notes and the issues below). `latest` on npm is still 1.x. - **One shared form module in `lib/`**, built with `createFormHook`: a text field component bound once through `getFormHookHelpers().fieldComponent.strict`, taking the `field` as a prop; a refusal line and a submit button as form components. Allowed by the rules spec (review rule 4 exemption, `@/lib/form` in components). - **Validation.** Function validators (`{ run, triggers }`) that call the existing credential checks and return our refusal unions. Not a zod schema passed directly: zod's issue messages are English, and ADR 0025 wants catalogue copy. The contract schemas stay the source of what is valid (ADR 0040). - **The Username check.** An async validator on change with a 300 ms debounce, reading availability through Query's existing query options, so Query stays the only server-state cache (ADR 0019). - **Server refusals.** `onSubmit` awaits the mutation, maps the refusal code (and the rate limit) to form and field errors, and **returns** them with `createValidationError({ form, fields })`, never throws: a throw is caught and only logged in alpha.2, and issue #2306 will change that. - **Pending state.** The mutation's `isPending` drives the button (v2 has no submit meta yet, issue #2303); `aria-disabled` rather than `disabled`, per the Form docs. - **Errors carry our union, translated at render**, never a string built in a validator. - **ADRs:** 0019, 0025, 0035, 0040, 0041. No new ADR unless the alpha forces one. ## Testing Decisions - Prior art: `sign-up.test.tsx`, `sign-in.test.tsx`, `forgot-password.test.tsx`, `reset-password.test.tsx`, `account-page.test.tsx`; `accounts.spec.ts` in `apps/e2e`. - A test may change only how it reaches the page, never what it asserts, except the two new cases in S1. - Edge and unhappy paths: a Username typed and erased before the debounce (no request); availability answered for an older value than the one shown (ignored); taken Username then a change to email (Username stays refused); rate-limited on every form that has it; a failure that is not a refusal ("something went wrong"); a second press while sending. ## Out of Scope - The home page's Room-code form and the devtools Guest form (one field each; only if the sign-up ticket shows they read better). - Changing any message, rule or flow a Player sees. - Moving to TanStack Form v1, or to another library. - Base UI's `Field` component. ## Further Notes - Audit: `docs/research/front-audit.md`, section 2.2.1, on branch `claude/nestjs-best-practices-audit-716d79`. - **Blocked by #408** (rules and structure): the page-file merge and the review-rule-4 exemption must land first. - Read before starting: issue TanStack/form#2341 (blur clears errors) conflicts with story 3; #2306 (exceptions during submit), #2296, #2299, #2360. The `alpha` branch is ahead of alpha.2 (e.g. `schemaOutputs` renamed `validatorOutputs`); v2 docs: <https://tanstack.com/form/alpha/docs/framework/react/quick-start>.
root commented 2026-10-15 12:32:00 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #408

marked this issue as related to #408
root commented 2026-10-15 12:32:08 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #408

mentioned in issue #408
root commented 2026-10-15 12:34:20 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #418

mentioned in issue #418
root commented 2026-10-15 12:34:20 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #418

marked this issue as related to #418
root commented 2026-10-15 12:34:35 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #419

mentioned in issue #419
root commented 2026-10-15 12:34:35 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #419

marked this issue as related to #419
root commented 2026-10-15 12:34:37 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #420

mentioned in issue #420
root commented 2026-10-15 12:34:37 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #420

marked this issue as related to #420
root commented 2026-10-15 12:58:00 +00:00 (Migrated from gitlab.core-project.space)

mentioned in merge request !533

mentioned in merge request !533
root commented 2026-10-15 22:17:35 +00:00 (Migrated from gitlab.core-project.space)

mentioned in merge request !543

mentioned in merge request !543
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-15 23:25:53 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amaurycoudr/chess-project#410
No description provided.