payloadcms / payloadcms/payload

bug: multi-tenant tenants create form wiped on failed validation

Open Beginner friendly
#16,953 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

created-by: Contributor plugin: multi-tenant
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

When creating a document in the tenants collection (the collection registered as the multi-tenant plugin's tenants collection), submitting the form with a value that fails field validation:

  1. correctly shows the field-level validation error, then
  2. immediately resets the entire form back to its empty initial state, discarding every value the user had entered.

The user has to start the whole form over. This only happens on the tenants collection, because that is the only collection where the plugin mounts WatchTenantCollection. The same flow on any normal (tenant-scoped) collection behaves correctly: the validation error shows and the entered values are preserved.

Root cause

A failed-validation submit still flips the form's submitted flag to true. The plugin's WatchTenantCollection treats submitted as "document was saved" and triggers a tenant re-sync, which ends in a router.refresh(). The refresh feeds the document <Form> a new initialState reference, and <Form> reacts to that by replacing its entire state — wiping the in-progress create form.

Step-by-step chain
  1. submitted flips to true even on a failed validation. In @payloadcms/ui the submit handler sets submitted = true on the client-side-validation-failure path (it does not gate it on a successful save):

    // @payloadcms/ui/dist/forms/Form/index.js  (~L283-291)
    const isValid_2 = skipValidation || disableValidationOnSubmit ? true : await contextRef.current.validateForm();
    setIsValid(isValid_2);
    if (!isValid_2) {
      errorToast(t('error:correctInvalidFields'));
      setProcessing(false);
      setSubmitted(true);   // <-- true even though nothing was saved
      setDisabled(false);
      return;
    }
    
  2. WatchTenantCollection reacts to submitted on create and calls syncTenants(). It assumes submitted means "saved", but it is also true after a failed validation. There is no guard on id (which is still undefined on a failed create):

    // @payloadcms/plugin-multi-tenant/dist/components/WatchTenantCollection/index.js  (L34-43)
    React.useEffect(() => {
      if (operation === 'create' && submitted) {
        void syncTenants();
      }
    }, [operation, submitted, syncTenants, id]);
    
  3. syncTenants() mutates tenant-selection state. It refetches the options and calls setTenantOptions(...), which changes the identity of the setTenant callback (it depends on tenantOptions):

    // @payloadcms/plugin-multi-tenant/dist/providers/TenantSelectionProvider/index.client.js  (L112-138)
    const syncTenants = React.useCallback(async () => {
      const req = await fetch(/* .../populate-tenant-options */);
      const result = await req.json();
      if (result.tenantOptions && userID) {
        setTenantOptions(result.tenantOptions);   // <-- state change
        // ...
      }
    }, [config.routes.api, tenantsCollectionSlug, userID]);
    
  4. The "no selected tenant" effect re-fires and calls router.refresh(). On the tenants create view there is no selected-tenant cookie, so initialValue is falsy and this effect is armed. When setTenant's identity changes (step 3), it re-runs and refreshes the route:

    // TenantSelectionProvider/index.client.js  (L203-213)
    React.useEffect(() => {
      if (!initialValue) {
        setTenant({ id: undefined, refresh: true });   // -> router.refresh()
      }
    }, [initialValue, setTenant]);
    
    // setTenantAndCookie  (L65-79)
    const setTenantAndCookie = React.useCallback(({ id, refresh }) => {
      setSelectedTenantID(id);
      /* set/delete cookie */
      if (refresh) {
        router.refresh();   // <-- re-renders the server tree
      }
    }, [router]);
    
  5. router.refresh() hands <Form> a new initialState, and <Form> resets. On re-render the document view recomputes form state and passes a new initialState reference. <Form> replaces its entire state from it — for a create form that is empty, so all entered values are lost:

    // @payloadcms/ui/dist/forms/Form/index.js  (L637-649)
    useEffect(() => {
      if (initialState) {
        contextRef.current = { ...initContextState };
        dispatchFields({ type: 'REPLACE_STATE', optimize: false, sanitize: true, state: initialState });
      }
    }, [initialState, dispatchFields]);
    

The normal validation-error path (ADD_SERVER_ERRORS) preserves field values; the data loss here comes entirely from the out-of-band router.refresh(), not from the validation response.

Suggested fix

Gate the create-time sync in WatchTenantCollection on the document actually having been created — i.e. only sync once an id exists, since a failed create leaves id === undefined:

React.useEffect(() => {
  if (operation === 'create' && submitted && id) {   // <-- add `&& id`
    void syncTenants();
  }
}, [operation, submitted, syncTenants, id]);

This keeps the intended behavior (refresh the tenant selector after a tenant is actually created) while no longer firing on a failed-validation submit.

Link to the code that reproduces this issue

https://github.com/jhb-dev/payload-multi-tenant-create-form-reset

Reproduction Steps
  1. Clone the reproduction repository and run the development server (pnpm dev).
  2. Log in to /admin with the seeded user demo@payloadcms.com / demo. This user has no tenant auto-selected, so the selected-tenant cookie / initialValue is empty.
  3. Go to the tenants create view: /admin/collections/tenants/create.
  4. Fill in Name = My First Tenant and Slug = Invalid Slug! (the slug fails the field validate, which only allows ^[a-z0-9-]+$).
  5. Click Save.

Expected: the validation error is shown and all entered values are preserved (standard Payload behavior).

Actual: the validation error flashes, then the whole form resets to empty and the user has to start over.

For contrast, repeat the same steps on the tenant-scoped Pages collection (/admin/collections/pages/create) with Title = My First Page and Slug = Invalid Slug!. There the validation error is shown and the entered values are correctly preserved — confirming the data loss is specific to the tenants collection where WatchTenantCollection is mounted.

Which area(s) are affected?

plugin: multi-tenant

Environment Info
Binaries:
  Node: 22.19.0
  npm: 10.9.3
  pnpm: 11.5.1
Relevant Packages:
  payload: 3.85.1
  next: 16.2.6
  @payloadcms/db-mongodb: 3.85.1
  @payloadcms/graphql: 3.85.1
  @payloadcms/next/utilities: 3.85.1
  @payloadcms/plugin-multi-tenant: 3.85.1
  @payloadcms/richtext-lexical: 3.85.1
  @payloadcms/translations: 3.85.1
  @payloadcms/ui/shared: 3.85.1
  react: 19.2.6
  react-dom: 19.2.6
Operating System:
  Platform: darwin
  Arch: arm64

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx and reproduce the issue with the linked repository using pnpm dev. Verify that failed validation on the tenants create form preserves entered values, while a successfully created tenant still refreshes tenant options; compare the normal Pages flow for expected behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
nextjs, react, typescript
Domain
frontend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.