Skip to content

plugin-multi-tenant: a refused save on the tenants collection clears the form in a production build (when no tenant is selected) #18122

Description

@TimothyHan

Describe the Bug

In a production build, a refused save on the tenants collection clears the form the user just filled in. The server answers 400 correctly and the toast appears, but every value is gone and the create view is back to [Untitled].

It happens only when no tenant is selected in the tenant selector — the normal state for a user who has access to all tenants (a platform admin) and has not picked one. With a tenant selected, the same save keeps every value and marks the field.

next dev keeps the values, so this only shows up once the app is built.

Cause

TenantSelectionProviderClient (packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx):

React.useEffect(() => {
  if (!initialValue) {
    setTenant({ id: undefined, refresh: true })
  }
}, [initialValue, setTenant])

setTenant is a useCallback with tenantOptions in its dependency list. Saving a document in the tenants collection runs syncTenants(), which calls setTenantOptions(result.tenantOptions) with a freshly parsed array, so setTenant gets a new identity and this effect runs again. With no tenant selected, initialValue is falsy, so it calls setTenant({ id: undefined, refresh: true })router.refresh().

On a refused save the user is still on the create view, so that refresh re-renders the route from the server and replaces the filled form with an empty one. The user's input is lost.

Observed request sequence (production build, no tenant selected):

POST /api/tenants?depth=0&fallback-locale=null        → 400   (the refusal)
GET  /api/tenants/populate-tenant-options             → 200   (syncTenants)
GET  /admin/collections/tenants/create?_rsc=…         → 200   (router.refresh → form cleared)

With a tenant selected, the third line does not happen and the form survives.

Reproduction steps

  1. templates/blank with @payloadcms/plugin-multi-tenant, a tenants collection whose field always refuses:
    { name: 'code', type: 'text', validate: (v) => (typeof v === 'string' && v.startsWith('ok') ? true : 'The code must start with "ok".') }
    and multiTenantPlugin({ collections: { things: {} }, tenantsSlug: 'tenants', userHasAccessToAllTenants: () => true }).
  2. Create two tenants, so the selector does not auto-select the only one.
  3. next build && next start, sign in, and clear the tenant selection (delete the payload-tenant cookie, or pick nothing).
  4. Go to /admin/collections/tenants/create, fill in name, slug and code: bad-code, and save.

Expected: 400, the field marked, and the form still holding what was typed — which is what happens when a tenant is selected.
Actual: the toast shows, and name, slug and code are empty; the heading is back to [Untitled].

Suggested fix

A refused save should not trigger a router refresh. Either skip the effect when the selection is already undefined and nothing changed, or keep setTenant stable across a syncTenants() that returns the same option ids (compare by value before calling setTenantOptions, as the sibling effect on initialTenantOptions already does).

Environment

payload 3.88.0 · @payloadcms/plugin-multi-tenant 3.88.0 · @payloadcms/db-sqlite 3.88.0 · next 16.3.3 · react 19.2.6 · node 22 · macOS

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions