Skip to content

Commit dd09f67

Browse files
authored
fix(plugin-multi-tenant): tenant selector not appearing after login (#15617)
When a custom provider in `admin.components.providers` changes its wrapper component type on login (e.g. `<Context.Provider>` → `<Fragment>`), React remounts `TenantSelectionProviderClient`. On remount, `useState(initialTenantOptions)` gets stale props and `useRef(userID)` re-initializes so `syncTenants()` never fires, leaving the tenant selector empty. This only affects projects with a custom provider that conditionally changes its tree structure based on auth state - default setups are unaffected since no built-in provider does this. The fix adds a `useEffect` that syncs `initialTenantOptions` from the server component into client state whenever the prop changes, making it resilient to remounts. Also fixes `test/dev.ts` not forwarding config overrides to import map generation.
1 parent d57bc22 commit dd09f67

File tree

10 files changed

+252
-189
lines changed

10 files changed

+252
-189
lines changed

.github/workflows/e2e.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export default createE2EConfig([
8484
{ file: 'plugin-form-builder', shards: 1 },
8585
{ file: 'plugin-import-export', shards: 1 },
8686
{ file: 'plugin-multi-tenant', shards: 2 },
87+
{ file: 'plugin-multi-tenant#config.conditionalProvider.ts', shards: 1 },
8788
{ file: 'plugin-nested-docs', shards: 1 },
8889
{ file: 'plugin-redirects', shards: 1 },
8990
{ file: 'plugin-seo', shards: 1 },

.github/workflows/main.yml

Lines changed: 12 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -588,12 +588,6 @@ jobs:
588588
# - template: with-vercel-website
589589
# database: postgres
590590

591-
env:
592-
POSTGRES_USER: postgres
593-
POSTGRES_PASSWORD: postgres
594-
POSTGRES_DB: payloadtests
595-
MONGODB_VERSION: 6.0
596-
597591
steps:
598592
- uses: actions/checkout@v5
599593

@@ -611,45 +605,20 @@ jobs:
611605
key: ${{ github.sha }}
612606
fail-on-cache-miss: true
613607

614-
- name: Start PostgreSQL
615-
uses: CasperWA/postgresql-action@v1.2
616-
with:
617-
postgresql version: "14" # See https://hub.docker.com/_/postgres for available versions
618-
postgresql db: ${{ env.POSTGRES_DB }}
619-
postgresql user: ${{ env.POSTGRES_USER }}
620-
postgresql password: ${{ env.POSTGRES_PASSWORD }}
621-
if: matrix.database == 'postgres'
622-
623-
- name: Wait for PostgreSQL
624-
run: sleep 30
625-
if: matrix.database == 'postgres'
626-
627-
- name: Configure PostgreSQL
628-
run: |
629-
psql "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" -c "CREATE ROLE runner SUPERUSER LOGIN;"
630-
psql "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" -c "SELECT version();"
631-
echo "POSTGRES_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" >> $GITHUB_ENV
632-
if: matrix.database == 'postgres'
633-
634-
# Avoid dockerhub rate-limiting
635-
- name: Cache Docker images
636-
# Use AndreKurait/docker-cache@0.6.0 instead of ScribeMD/docker-cache@0.5.0 to fix the following issue: https://github.com/ScribeMD/docker-cache/issues/837 ("Warning: Failed to restore: Cache service responded with 400")
637-
uses: AndreKurait/docker-cache@0.6.0
638-
with:
639-
key: docker-${{ runner.os }}-mongo-${{ env.MONGODB_VERSION }}
640-
641-
- name: Start MongoDB
642-
uses: supercharge/mongodb-github-action@1.12.0
608+
- name: Start database
609+
id: db
610+
if: matrix.database
611+
uses: ./.github/actions/start-database
643612
with:
644-
mongodb-version: 6.0
645-
if: matrix.database == 'mongodb'
613+
database: ${{ matrix.database }}
646614

647615
- name: Build Template
648616
run: |
649617
pnpm run script:pack --dest templates/${{ matrix.template }}
650-
pnpm run script:build-template-with-local-pkgs ${{ matrix.template }} $POSTGRES_URL
618+
pnpm run script:build-template-with-local-pkgs ${{ matrix.template }} $DB_CONNECTION
651619
env:
652620
NODE_OPTIONS: --max-old-space-size=8096
621+
DB_CONNECTION: ${{ steps.db.outputs.POSTGRES_URL || steps.db.outputs.MONGODB_URL }}
653622

654623
- name: Store Playwright's Version
655624
run: |
@@ -678,16 +647,16 @@ jobs:
678647
env:
679648
NODE_OPTIONS: --max-old-space-size=8096
680649
PAYLOAD_DATABASE: ${{ matrix.database }}
681-
POSTGRES_URL: ${{ env.POSTGRES_URL }}
682-
MONGODB_URL: mongodb://localhost:27017/payloadtests
650+
POSTGRES_URL: ${{ steps.db.outputs.POSTGRES_URL }}
651+
MONGODB_URL: ${{ steps.db.outputs.MONGODB_URL }}
683652

684653
- name: Runs Template E2E Tests
685654
run: PLAYWRIGHT_JSON_OUTPUT_NAME=results_${{ matrix.template }}.json pnpm --filter ${{ matrix.template }} test:e2e
686655
env:
687656
NODE_OPTIONS: --max-old-space-size=8096
688657
PAYLOAD_DATABASE: ${{ matrix.database }}
689-
POSTGRES_URL: ${{ env.POSTGRES_URL }}
690-
MONGODB_URL: mongodb://localhost:27017/payloadtests
658+
POSTGRES_URL: ${{ steps.db.outputs.POSTGRES_URL }}
659+
MONGODB_URL: ${{ steps.db.outputs.MONGODB_URL }}
691660
NEXT_TELEMETRY_DISABLED: 1
692661

693662
tests-type-generation:
@@ -783,4 +752,4 @@ jobs:
783752
if: github.event.pull_request.head.repo.fork == false
784753
uses: exoego/esbuild-bundle-analyzer@v1
785754
with:
786-
metafiles: "packages/payload/meta_index.json,packages/payload/meta_shared.json,packages/ui/meta_client.json,packages/ui/meta_shared.json,packages/next/meta_index.json,packages/richtext-lexical/meta_client.json"
755+
metafiles: 'packages/payload/meta_index.json,packages/payload/meta_shared.json,packages/ui/meta_client.json,packages/ui/meta_shared.json,packages/next/meta_index.json,packages/richtext-lexical/meta_client.json'

.github/workflows/post-release-templates.yml

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,6 @@ jobs:
4848
permissions:
4949
contents: write
5050
pull-requests: write
51-
env:
52-
POSTGRES_USER: postgres
53-
POSTGRES_PASSWORD: postgres
54-
POSTGRES_DB: payloadtests
5551
steps:
5652
- name: Checkout
5753
uses: actions/checkout@v5
@@ -60,35 +56,30 @@ jobs:
6056
uses: ./.github/actions/setup
6157

6258
- name: Start PostgreSQL
63-
uses: CasperWA/postgresql-action@v1.2
59+
id: postgres
60+
uses: ./.github/actions/start-database
6461
with:
65-
postgresql version: '14' # See https://hub.docker.com/_/postgres for available versions
66-
postgresql db: ${{ env.POSTGRES_DB }}
67-
postgresql user: ${{ env.POSTGRES_USER }}
68-
postgresql password: ${{ env.POSTGRES_PASSWORD }}
69-
70-
- name: Wait for PostgreSQL
71-
run: sleep 30
72-
73-
- name: Configure PostgreSQL
74-
run: |
75-
psql "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" -c "CREATE ROLE runner SUPERUSER LOGIN;"
76-
psql "postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" -c "SELECT version();"
77-
echo "POSTGRES_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" >> $GITHUB_ENV
78-
echo "DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" >> $GITHUB_ENV
62+
database: postgres
7963

8064
- name: Start MongoDB
81-
uses: supercharge/mongodb-github-action@1.12.0
65+
id: mongodb
66+
uses: ./.github/actions/start-database
8267
with:
83-
mongodb-version: 6.0
68+
database: mongodb
8469

8570
# The template generation script runs import map generation which needs the built payload bin scripts
8671
- run: pnpm run build:all
8772
env:
8873
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
74+
POSTGRES_URL: ${{ steps.postgres.outputs.POSTGRES_URL }}
75+
DATABASE_URL: ${{ steps.postgres.outputs.POSTGRES_URL }}
8976

9077
- name: Update template lockfiles and migrations
9178
run: pnpm script:gen-templates
79+
env:
80+
POSTGRES_URL: ${{ steps.postgres.outputs.POSTGRES_URL }}
81+
DATABASE_URL: ${{ steps.postgres.outputs.POSTGRES_URL }}
82+
MONGODB_URL: ${{ steps.mongodb.outputs.MONGODB_URL }}
9283

9384
- name: Commit and push changes
9485
id: commit

packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,32 @@ export const TenantSelectionProviderClient = ({
216216
[syncTenants],
217217
)
218218

219+
/**
220+
* Sync server-provided tenant options into client state.
221+
* When the server component re-renders (e.g., after navigation post-login),
222+
* it provides updated initialTenantOptions. Since useState() ignores new
223+
* initial values on re-renders, and re-initializes with stale props on
224+
* remounts, we sync them explicitly via this effect.
225+
*/
226+
React.useEffect(() => {
227+
if (initialTenantOptions.length > 0) {
228+
setTenantOptions((prev) => {
229+
if (
230+
prev.length === initialTenantOptions.length &&
231+
prev.every((opt, i) => opt.value === initialTenantOptions[i]?.value)
232+
) {
233+
return prev
234+
}
235+
return initialTenantOptions
236+
})
237+
238+
if (initialTenantOptions.length === 1 && initialTenantOptions[0]) {
239+
setSelectedTenantID(initialTenantOptions[0].value)
240+
setTenantCookie({ value: String(initialTenantOptions[0].value) })
241+
}
242+
}
243+
}, [initialTenantOptions])
244+
219245
React.useEffect(() => {
220246
if (userChanged || (initialValue && String(initialValue) !== getTenantCookie())) {
221247
if (userID) {

test/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ await beforeTest()
7070

7171
const { rootDir, adminRoute } = getNextRootDir(testSuiteArg)
7272

73-
await runInit(testSuiteArg, true)
73+
await runInit(testSuiteArg, true, false, testSuiteConfigOverride)
7474

7575
// This is needed to forward the environment variables to the next process that were created after loadEnv()
7676
// for example process.env.DATABASE_URL otherwise app.prepare() will clear them
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
import type React from 'react'
3+
4+
import { useAuth } from '@payloadcms/ui'
5+
import { createContext } from 'react'
6+
7+
const DummyContext = createContext<null>(null)
8+
9+
/**
10+
* This provider changes its rendered tree structure when the user authenticates.
11+
* Before login: <DummyContext.Provider>{children}</DummyContext.Provider>
12+
* After login: <>{children}</>
13+
*
14+
* This tree structure change causes React to REMOUNT all children,
15+
* which is a valid pattern that the multi-tenant plugin must handle.
16+
*/
17+
export const ConditionalWrapperProvider: React.FC<{ children: React.ReactNode }> = ({
18+
children,
19+
}) => {
20+
const { user } = useAuth()
21+
22+
if (user) {
23+
return <>{children}</>
24+
}
25+
26+
return <DummyContext value={null}>{children}</DummyContext>
27+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { Config } from 'payload'
2+
3+
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
4+
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
5+
import { fileURLToPath } from 'node:url'
6+
import path from 'path'
7+
const filename = fileURLToPath(import.meta.url)
8+
const dirname = path.dirname(filename)
9+
10+
import type { Config as ConfigType } from './payload-types.js'
11+
12+
import { AutosaveGlobal } from './collections/AutosaveGlobal.js'
13+
import { Menu } from './collections/Menu.js'
14+
import { MenuItems } from './collections/MenuItems.js'
15+
import { Relationships } from './collections/Relationships.js'
16+
import { Tenants } from './collections/Tenants.js'
17+
import { Users } from './collections/Users/index.js'
18+
import { seed } from './seed/index.js'
19+
import { autosaveGlobalSlug, menuItemsSlug, menuSlug, notTenantedSlug } from './shared.js'
20+
21+
export const baseConfig: Partial<Config> = {
22+
collections: [
23+
Tenants,
24+
Users,
25+
MenuItems,
26+
Menu,
27+
AutosaveGlobal,
28+
Relationships,
29+
{
30+
slug: notTenantedSlug,
31+
admin: {
32+
useAsTitle: 'name',
33+
},
34+
fields: [
35+
{
36+
name: 'name',
37+
type: 'text',
38+
},
39+
],
40+
},
41+
],
42+
admin: {
43+
autoLogin: false,
44+
importMap: {
45+
baseDir: path.resolve(dirname),
46+
},
47+
components: {
48+
beforeLogin: ['/components/BeforeLogin/index.js#BeforeLogin'],
49+
graphics: {
50+
Logo: '/components/Logo/index.js#Logo',
51+
Icon: '/components/Icon/index.js#Icon',
52+
},
53+
},
54+
},
55+
onInit: async (payload) => {
56+
// IMPORTANT: This should only seed, not clear the database.
57+
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
58+
await seed(payload)
59+
}
60+
},
61+
plugins: [
62+
multiTenantPlugin<ConfigType>({
63+
// debug: true,
64+
userHasAccessToAllTenants: (user) => Boolean(user.roles?.includes('admin')),
65+
useTenantsCollectionAccess: false,
66+
tenantField: {
67+
access: {},
68+
},
69+
collections: {
70+
[menuItemsSlug]: {
71+
useTenantAccess: false,
72+
},
73+
[menuSlug]: {
74+
isGlobal: true,
75+
},
76+
[autosaveGlobalSlug]: {
77+
isGlobal: true,
78+
},
79+
80+
['relationships']: {},
81+
},
82+
i18n: {
83+
translations: {
84+
en: {
85+
'field-assignedTenant-label': 'Site',
86+
'nav-tenantSelector-label': 'Filter by Site',
87+
'assign-tenant-button-label': 'Assign Site',
88+
},
89+
},
90+
},
91+
}),
92+
],
93+
localization: {
94+
defaultLocale: 'en',
95+
locales: ['en', 'es', 'fr'],
96+
filterAvailableLocales: async ({ locales, req }) => {
97+
const tenant = getTenantFromCookie(req.headers, 'text')
98+
if (tenant) {
99+
const fullTenant = await req.payload.findByID({
100+
collection: 'tenants',
101+
id: tenant,
102+
})
103+
if (
104+
fullTenant &&
105+
Array.isArray(fullTenant.selectedLocales) &&
106+
fullTenant.selectedLocales.length > 0
107+
) {
108+
if (fullTenant.selectedLocales.includes('allLocales')) {
109+
return locales
110+
}
111+
return locales.filter((locale) => fullTenant.selectedLocales?.includes(locale.code))
112+
}
113+
}
114+
return locales
115+
},
116+
},
117+
typescript: {
118+
outputFile: path.resolve(dirname, 'payload-types.ts'),
119+
},
120+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* eslint-disable no-restricted-exports */
2+
3+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
4+
import { baseConfig } from './config.base.js'
5+
6+
/**
7+
* Extends the base multi-tenant config with a ConditionalWrapperProvider
8+
* that changes its tree structure on auth (Context.Provider → Fragment).
9+
* This causes React to remount the TenantSelectionProviderClient subtree,
10+
* exercising the RSC prop sync fix.
11+
*/
12+
export default buildConfigWithDefaults({
13+
...baseConfig,
14+
admin: {
15+
...baseConfig.admin,
16+
components: {
17+
...baseConfig.admin?.components,
18+
providers: ['/components/ConditionalWrapperProvider/index.js#ConditionalWrapperProvider'],
19+
},
20+
},
21+
})

0 commit comments

Comments
 (0)