Skip to content
48 changes: 48 additions & 0 deletions docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,54 @@ If you only used `useDocumentInfo` for `title` / `setDocumentTitle`, drop the im

Run `npx @payloadcms/codemod --transform migrate-document-title-context` to migrate automatically. The codemod splits mixed destructures, removes the `useDocumentInfo` import when no other properties are still used, and preserves any rename aliases (e.g. `{ title: docTitle }`).

### `@payloadcms/plugin-multi-tenant`

#### `useBaseListFilter` renamed to `useBaseFilter`

The `useBaseListFilter` property on per-collection config inside `multiTenantPlugin` has been removed. Use `useBaseFilter` instead.

```diff
multiTenantPlugin({
collections: {
posts: {
- useBaseListFilter: false,
+ useBaseFilter: false,
},
},
})
```

Run `npx @payloadcms/codemod --transform migrate-multi-tenant-use-base-list-filter` to migrate automatically.

#### `tenantSelectorLabel` removed — use `i18n.translations` instead

The `tenantSelectorLabel` shorthand has been removed. Customise the tenant selector label through `i18n.translations` using the `nav-tenantSelector-label` key.

If you previously passed a locale-keyed object:

```diff
multiTenantPlugin({
- tenantSelectorLabel: {
- en: 'Filter by Site',
- fr: 'Filtrer par site',
- },
+ i18n: {
+ translations: {
+ en: { 'nav-tenantSelector-label': 'Filter by Site' },
+ fr: { 'nav-tenantSelector-label': 'Filtrer par site' },
+ },
+ },
})
```

If you previously passed a plain string, you need to decide which locale it applies to and add it under the corresponding locale key in `i18n.translations`.

Run `npx @payloadcms/codemod --transform migrate-multi-tenant-tenant-selector-label` to migrate locale-keyed objects automatically. String values and configs that already have an `i18n` property require manual migration — the codemod removes the property and emits a note with the file path.

#### `getGlobalViewRedirect`: `basePath` argument removed

The `basePath` parameter on `getGlobalViewRedirect` was deprecated and is now removed. The redirect path is now derived internally from the Payload config. Remove any `basePath` argument from direct calls to this utility.

### RichText adapter `i18n` property removed

The `i18n` property on `RichTextAdapter` (and its return type from adapter provider functions) has been removed. It was deprecated in v3 in favour of merging translations directly into `config.i18n.translations` inside the adapter provider.
Expand Down
3 changes: 3 additions & 0 deletions packages/codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t
- `migrate-hide-api-url` — migrates `admin.hideAPIURL: true` to `admin.components.views.edit.api.tab.condition: () => false` on collection and global configs.
- `migrate-aliased-exports` — rewrites imports of types and utilities that used to be re-exported from `@payloadcms/ui` and `@payloadcms/next/utilities` to their canonical sources in `payload` / `payload/shared`.
- `migrate-document-title-context` — migrates `title` and `setDocumentTitle` destructured from `useDocumentInfo()` to `useDocumentTitle()`. They were removed from `DocumentInfoContext` in v4 and now live on `DocumentTitleContext`.
- `migrate-multi-tenant-use-base-list-filter` — renames `useBaseListFilter` to `useBaseFilter` in `@payloadcms/plugin-multi-tenant` collection config. Only renames the property when it appears inside a `collections` sub-object; unrelated occurrences elsewhere in the file are left untouched.
- `migrate-multi-tenant-tenant-selector-label` — migrates `tenantSelectorLabel: { [locale]: string }` in `@payloadcms/plugin-multi-tenant` config to `i18n.translations[locale]['nav-tenantSelector-label']`. Object values with locale keys are auto-migrated when no sibling `i18n` property exists; string values and configs with an existing `i18n` property are removed with a note for manual migration.
- `migrate-storage-adapters-to-config` — moves storage adapter factory calls (`s3Storage`, `gcsStorage`, `azureStorage`, `r2Storage`, `vercelBlobStorage`, `uploadthingStorage`) from `plugins` to the new top-level `storage` array. Removes `plugins` if it becomes empty after the move. **Limitations:** aliased imports (e.g. `import { s3Storage as myS3 }`) are not detected; rename any aliases to the canonical factory name before running, or migrate those calls manually. The transform preserves AST structure but does not re-format output — run `prettier --write` (or your project's formatter) after applying.
- `rename-storage-adapters-to-storage` — renames the top-level `storageAdapters` config property to `storage`. Skips any object that already has a `storage` property. Run this if you previously ran `migrate-storage-adapters-to-config` and need to update the property name.
- `migrate-import-export-hooks` — migrates the deprecated `toCSV` and `fromCSV` field options in `custom['plugin-import-export']` to `hooks.beforeExport` and `hooks.beforeImport`. If a `hooks` object already exists it is merged into; if `hooks.beforeExport`/`hooks.beforeImport` already exist the deprecated sibling is dropped without overwriting. Review argument shapes after migration: `beforeExport` uses `siblingData` (not `row`) and `data` is the top-level document (previously `doc`).
- `migrate-db-types-subpath` — rewrites imports from the removed `/types` subpath exports of `@payloadcms/drizzle`, `@payloadcms/db-postgres`, `@payloadcms/db-sqlite`, `@payloadcms/db-vercel-postgres`, and `@payloadcms/db-d1-sqlite` to their main entry points. Also handles re-export declarations and `declare module` augmentations.
- `migrate-next-subpath-exports` — rewrites imports, re-exports, and string-literal component paths from the removed `@payloadcms/next/client`, `@payloadcms/next/rsc`, and `@payloadcms/next/templates` subpaths to their canonical `@payloadcms/ui` or `@payloadcms/ui/rsc` sources. After running, regenerate the import map with `payload generate:importmap`.
- `migrate-lexical-is-html-element` — rewrites imports of the removed `isHTMLElement` utility from `@payloadcms/richtext-lexical` (and `/client`) to its canonical source, `lexical`, splitting it out of mixed imports and merging into an existing `lexical` import when present. Surfaces a note reminding you that `lexical` is now a required dependency (`pnpm add lexical`).
- `migrate-versions-default` — adds `versions: false` to every `CollectionConfig` or `GlobalConfig` object that does not already have a `versions` property. Preserves the previous opt-in behaviour now that `versions` defaults to `true` for both collections and globals. Detects the three common annotation forms: `: CollectionConfig`, `satisfies GlobalConfig`, and `as CollectionConfig`.
- `remove-versions-true` — removes the now-redundant `versions: true` property from `CollectionConfig` and `GlobalConfig` objects. Only removes the bare boolean `true`; object-form configs (e.g. `versions: { drafts: true }`) are left untouched.
- `remove-group-by-true` — removes `admin.groupBy` from `CollectionConfig` objects. The experimental `groupBy` flag has been removed; groupBy is now an always-available per-user UI preference.
Expand Down
6 changes: 6 additions & 0 deletions packages/codemod/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { migrateDocumentTitleContext } from './transforms/migrate-document-title
import { migrateForceSelect } from './transforms/migrate-force-select/index.js'
import { migrateHideAPIURL } from './transforms/migrate-hide-api-url/index.js'
import { migrateImportExportHooks } from './transforms/migrate-import-export-hooks/index.js'
import { migrateLexicalIsHTMLElement } from './transforms/migrate-lexical-is-html-element/index.js'
import { migrateListViewSelectAPI } from './transforms/migrate-list-view-select-api/index.js'
import { migrateMultiTenantTenantSelectorLabel } from './transforms/migrate-multi-tenant-tenant-selector-label/index.js'
import { migrateMultiTenantUseBaseListFilter } from './transforms/migrate-multi-tenant-use-base-list-filter/index.js'
import { migrateNextSubpathExports } from './transforms/migrate-next-subpath-exports/index.js'
import { migrateStorageAdaptersToConfig } from './transforms/migrate-storage-adapters-to-config/index.js'
import { migrateVersionsDefault } from './transforms/migrate-versions-default/index.js'
Expand All @@ -33,10 +36,13 @@ export const transforms: Transform[] = [
migrateBlockReferencesToBlocks,
migrateBuildScript,
migrateDocumentTitleContext,
migrateMultiTenantUseBaseListFilter,
migrateMultiTenantTenantSelectorLabel,
migrateStorageAdaptersToConfig,
renameStorageAdaptersToStorage,
migrateImportExportHooks,
migrateDbTypesSubpath,
migrateLexicalIsHTMLElement,
migrateNextSubpathExports,
migrateVersionsDefault,
removeGroupByTrue,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { isHTMLElement } from '@payloadcms/richtext-lexical/client'

export function check(node: unknown) {
return isHTMLElement(node)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { isHTMLElement } from 'lexical'

export function check(node: unknown) {
return isHTMLElement(node)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Project } from 'ts-morph'
import { describe, expect, it } from 'vitest'

import { runTransform } from '../../utils/test-helpers.js'
import { migrateLexicalIsHTMLElement } from './index.js'

const here = dirname(fileURLToPath(import.meta.url))
const fixture = (name: string) => readFile(join(here, name), 'utf8')

describe('migrate-lexical-is-html-element', () => {
it('rewrites a sole isHTMLElement import to lexical', async () => {
const input = await fixture('basic.input.ts')
const output = await fixture('basic.output.ts')

const result = await runTransform({ source: input, transform: migrateLexicalIsHTMLElement })

expect(result).toBe(output)
})

it('moves isHTMLElement out of a mixed import and merges into existing lexical import', async () => {
const input = await fixture('mixed.input.ts')
const output = await fixture('mixed.output.ts')

const result = await runTransform({ source: input, transform: migrateLexicalIsHTMLElement })

expect(result).toBe(output)
})

it('warns that lexical must be installed', async () => {
const input = await fixture('basic.input.ts')
const project = new Project({ useInMemoryFileSystem: true })
project.createSourceFile('input.ts', input)

const result = await migrateLexicalIsHTMLElement.apply({ packageJsons: [], project })

expect(result.notes).toEqual([expect.stringContaining('pnpm add lexical')])
})

it('is idempotent on already-migrated source', async () => {
const output = await fixture('basic.output.ts')

const result = await runTransform({ source: output, transform: migrateLexicalIsHTMLElement })

expect(result).toBe(output)
})

it('leaves unrelated imports untouched', async () => {
const input = await fixture('non-matching.input.ts')

const result = await runTransform({ source: input, transform: migrateLexicalIsHTMLElement })

expect(result).toBe(input)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { ImportDeclaration, SourceFile } from 'ts-morph'

import type { Transform } from '../../types.js'

const SYMBOL = 'isHTMLElement'
const TARGET = 'lexical'

/** Sources that used to re-export `isHTMLElement` before it was removed in v4. */
const SOURCES = new Set(['@payloadcms/richtext-lexical', '@payloadcms/richtext-lexical/client'])

const INSTALL_WARNING = `\`${TARGET}\` is now a required dependency for \`${SYMBOL}\`. Install it with \`pnpm add ${TARGET}\` (or your package manager's equivalent).`

export const migrateLexicalIsHTMLElement: Transform = {
name: 'migrate-lexical-is-html-element',
apply: ({ project }) => {
const filesChanged = new Set<string>()
let movedAny = false

for (const file of project.getSourceFiles()) {
let mutated = false

for (const importDecl of [...file.getImportDeclarations()]) {
if (!SOURCES.has(importDecl.getModuleSpecifierValue())) {
continue
}

const spec = importDecl.getNamedImports().find((named) => named.getName() === SYMBOL)
if (!spec) {
continue
}

const isTypeOnly = importDecl.isTypeOnly() || spec.isTypeOnly()
const alias = spec.getAliasNode()?.getText()
const existingLexical = findLexicalImport({ file, isTypeOnly })

// When the import already contains nothing but `isHTMLElement` and there
// is no `lexical` import to merge into, rewrite the specifier in place to
// preserve the surrounding formatting.
const isSoleImport =
importDecl.getNamedImports().length === 1 &&
!importDecl.getDefaultImport() &&
!importDecl.getNamespaceImport()

if (isSoleImport && !existingLexical) {
importDecl.setModuleSpecifier(TARGET)
} else {
spec.remove()
attachToLexicalImport({ alias, existing: existingLexical, file, isTypeOnly })
removeIfEmpty(importDecl)
}

mutated = true
movedAny = true
}

if (mutated) {
filesChanged.add(file.getFilePath())
}
}

return {
filesChanged: [...filesChanged],
...(movedAny ? { notes: [INSTALL_WARNING] } : {}),
}
},
description:
'Rewrites imports of the removed `isHTMLElement` utility from `@payloadcms/richtext-lexical` (and `/client`) to its canonical source, `lexical`. Surfaces a note reminding users to install `lexical` as a dependency.',
}

function removeIfEmpty(importDecl: ImportDeclaration): void {
if (
importDecl.getNamedImports().length === 0 &&
!importDecl.getDefaultImport() &&
!importDecl.getNamespaceImport()
) {
importDecl.remove()
}
}

type FindLexicalArgs = {
file: SourceFile
isTypeOnly: boolean
}

function findLexicalImport({ file, isTypeOnly }: FindLexicalArgs): ImportDeclaration | undefined {
return file
.getImportDeclarations()
.find((decl) => decl.getModuleSpecifierValue() === TARGET && decl.isTypeOnly() === isTypeOnly)
}

type AttachArgs = {
alias?: string
existing?: ImportDeclaration
file: SourceFile
isTypeOnly: boolean
}

function attachToLexicalImport({ alias, existing, file, isTypeOnly }: AttachArgs): void {
const localName = alias ?? SYMBOL

if (existing) {
const alreadyHas = existing
.getNamedImports()
.some((named) => (named.getAliasNode()?.getText() ?? named.getName()) === localName)
if (!alreadyHas) {
existing.addNamedImport({ name: SYMBOL, alias })
}
return
}

file.addImportDeclaration({
isTypeOnly,
moduleSpecifier: TARGET,
namedImports: [{ name: SYMBOL, alias }],
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { $getRoot } from 'lexical'
import { isHTMLElement, joinClasses } from '@payloadcms/richtext-lexical/client'

export function check(node: unknown) {
return isHTMLElement(node) && joinClasses(['a']) && $getRoot
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { $getRoot, isHTMLElement } from 'lexical'
import { joinClasses } from '@payloadcms/richtext-lexical/client'

export function check(node: unknown) {
return isHTMLElement(node) && joinClasses(['a']) && $getRoot
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { isHTMLElement } from 'lexical'
import { joinClasses } from '@payloadcms/richtext-lexical/client'

export function check(node: unknown) {
return isHTMLElement(node) && joinClasses(['a'])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

import { runTransform } from '../../utils/test-helpers.js'
import { migrateMultiTenantTenantSelectorLabel } from './index.js'

const here = dirname(fileURLToPath(import.meta.url))
const fixture = (name: string) => readFile(join(here, name), 'utf8')

describe('migrate-multi-tenant-tenant-selector-label', () => {
it('migrates object tenantSelectorLabel to i18n.translations', async () => {
const input = await fixture('object.input.ts')
const output = await fixture('object.output.ts')

const result = await runTransform({
source: input,
transform: migrateMultiTenantTenantSelectorLabel,
})

expect(result).toBe(output)
})

it('removes string tenantSelectorLabel and emits a note', async () => {
const input = await fixture('string.input.ts')
const output = await fixture('string.output.ts')

const result = await runTransform({
source: input,
transform: migrateMultiTenantTenantSelectorLabel,
})

expect(result).toBe(output)
})

it('is idempotent on object output', async () => {
const output = await fixture('object.output.ts')

const result = await runTransform({
source: output,
transform: migrateMultiTenantTenantSelectorLabel,
})

expect(result).toBe(output)
})

it('is idempotent on string output', async () => {
const output = await fixture('string.output.ts')

const result = await runTransform({
source: output,
transform: migrateMultiTenantTenantSelectorLabel,
})

expect(result).toBe(output)
})

it('leaves config without tenantSelectorLabel untouched', async () => {
const input = await fixture('no-match.input.ts')

const result = await runTransform({
source: input,
transform: migrateMultiTenantTenantSelectorLabel,
})

expect(result).toBe(input)
})
})
Loading
Loading