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
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 }).
- Create two tenants, so the selector does not auto-select the only one.
next build && next start, sign in, and clear the tenant selection (delete the payload-tenant cookie, or pick nothing).
- 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
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 devkeeps the values, so this only shows up once the app is built.Cause
TenantSelectionProviderClient(packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx):setTenantis auseCallbackwithtenantOptionsin its dependency list. Saving a document in the tenants collection runssyncTenants(), which callssetTenantOptions(result.tenantOptions)with a freshly parsed array, sosetTenantgets a new identity and this effect runs again. With no tenant selected,initialValueis falsy, so it callssetTenant({ 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):
With a tenant selected, the third line does not happen and the form survives.
Reproduction steps
templates/blankwith@payloadcms/plugin-multi-tenant, atenantscollection whose field always refuses:multiTenantPlugin({ collections: { things: {} }, tenantsSlug: 'tenants', userHasAccessToAllTenants: () => true }).next build && next start, sign in, and clear the tenant selection (delete thepayload-tenantcookie, or pick nothing)./admin/collections/tenants/create, fill in name, slug andcode: 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
undefinedand nothing changed, or keepsetTenantstable across asyncTenants()that returns the same option ids (compare by value before callingsetTenantOptions, as the sibling effect oninitialTenantOptionsalready 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