Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ export const PostsCollection: CollectionConfig = {

Run `npx @payloadcms/codemod --transform remove-hide-api-url` to migrate automatically.

### Aliased type and utility re-exports from `@payloadcms/ui` and `@payloadcms/next`

Several types and utilities that were re-exported from `@payloadcms/ui` and `@payloadcms/next/utilities` for backwards compatibility have been removed. The canonical exports live in `payload` and `payload/shared`; import directly from there.

**Pass-through re-exports** — same name, new source:

| Symbol | Old source | New source |
| ----------------------------- | ---------------------------- | ---------------- |
| `Column` | `@payloadcms/ui` | `payload` |
| `ListViewSlots` | `@payloadcms/ui` | `payload` |
| `ListViewClientProps` | `@payloadcms/ui` | `payload` |
| `EntityType` | `@payloadcms/ui/shared` | `payload/shared` |
| `formatAdminURL` | `@payloadcms/ui/shared` | `payload/shared` |
| `mergeListSearchAndWhere` | `@payloadcms/ui/shared` | `payload/shared` |
| `mergeHeaders` | `@payloadcms/next/utilities` | `payload` |
| `headersWithCors` | `@payloadcms/next/utilities` | `payload` |
| `createPayloadRequest` | `@payloadcms/next/utilities` | `payload` |
| `addDataAndFileToRequest` | `@payloadcms/next/utilities` | `payload` |
| `sanitizeLocales` | `@payloadcms/next/utilities` | `payload` |
| `addLocalesToRequestFromData` | `@payloadcms/next/utilities` | `payload` |

**Renamed types** — use the new canonical name from `payload`:

| Old name | New name | Source |
| -------------------------- | ----------------------- | --------- |
| `ListPreferences` | `CollectionPreferences` | `payload` |
| `ListComponentClientProps` | `ListViewClientProps` | `payload` |
| `ListComponentServerProps` | `ListViewServerProps` | `payload` |

```diff
- import type { Column, ListViewSlots, ListPreferences } from '@payloadcms/ui'
- import { EntityType, formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
- import { headersWithCors, mergeHeaders } from '@payloadcms/next/utilities'
+ import type { Column, ListViewSlots, CollectionPreferences } from 'payload'
+ import { EntityType, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared'
+ import { headersWithCors, mergeHeaders } from 'payload'
```

Run `npx @payloadcms/codemod --transform migrate-aliased-exports` to migrate automatically. Renamed types are imported using an `as` alias (e.g. `import type { CollectionPreferences as ListPreferences } from 'payload'`) so existing usages keep compiling — drop the alias and rename usages manually if you want to fully commit to the new name.

### `title` and `setDocumentTitle` removed from `useDocumentInfo`

For performance reasons, the document title state has been split out of `DocumentInfoContext` into its own `DocumentTitleContext`. Access it through the `useDocumentTitle` hook, which exposes the same `title` and `setDocumentTitle` API. Components that subscribed to `useDocumentInfo` solely for the title will no longer re-render when unrelated document state changes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Collection, Endpoint } from 'payload'

import { headersWithCors } from '@payloadcms/next/utilities'
import { APIError, generatePayloadCookie } from 'payload'
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'

// A custom endpoint that can be reached by POST request
// at: /api/users/external-users/login
Expand Down
1 change: 1 addition & 0 deletions packages/codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t
- `globals-components-edit` — Globals: rename `admin.components.elements` to `admin.components.edit` and hoist `Description` to top-level `admin.components.Description` to match Collection conventions.
- `migrate-force-select` — migrates `forceSelect: { ... }` on Collection/Global configs to a `select` function that augments the caller's `select` when present and returns `undefined` (preserving full-document reads) when not. Shallow values become a spread (`{ ...select, ... }`); nested values use `deepMergeSimple` from `payload/shared` (auto-imported) to preserve the previous deep-merge semantics. Non-literal values, sibling `select` already present, and unsupported member kinds are surfaced as notes for manual review.
- `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`.

## Contributing
Expand Down
2 changes: 2 additions & 0 deletions packages/codemod/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Transform } from './types.js'

import { exampleNoop } from './transforms/example-noop/index.js'
import { globalsComponentsEdit } from './transforms/globals-components-edit/index.js'
import { migrateAliasedExports } from './transforms/migrate-aliased-exports/index.js'
import { migrateDisabledFields } from './transforms/migrate-disabled-fields/index.js'
import { migrateDocumentTitleContext } from './transforms/migrate-document-title-context/index.js'
import { migrateForceSelect } from './transforms/migrate-force-select/index.js'
Expand All @@ -15,5 +16,6 @@ export const transforms: Transform[] = [
migrateListViewSelectAPI,
migrateDisabledFields,
migrateForceSelect,
migrateAliasedExports,
migrateDocumentTitleContext,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Column, ListViewSlots } from '@payloadcms/ui'

import { EntityType, formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
import { headersWithCors, mergeHeaders } from '@payloadcms/next/utilities'

export type Args = {
column: Column
slots: ListViewSlots
}

export const buildHeaders = (a: Headers, b: Headers) => mergeHeaders(a, b)
export const cors = headersWithCors
export const buildWhere = mergeListSearchAndWhere
export const buildURL = formatAdminURL
export const collectionType = EntityType.collection
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Column, ListViewSlots } from 'payload'

import { EntityType, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared'
import { headersWithCors, mergeHeaders } from 'payload'

export type Args = {
column: Column
slots: ListViewSlots
}

export const buildHeaders = (a: Headers, b: Headers) => mergeHeaders(a, b)
export const cors = headersWithCors
export const buildWhere = mergeListSearchAndWhere
export const buildURL = formatAdminURL
export const collectionType = EntityType.collection
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 { migrateAliasedExports } from './index.js'

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

describe('migrate-aliased-exports', () => {
it('moves pass-through re-exports to canonical sources', async () => {
const input = await fixture('basic.input.ts')
const output = await fixture('basic.output.ts')

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

expect(result).toBe(output)
})

it('rewrites renamed types using `as` alias to preserve usages', async () => {
const input = await fixture('rename.input.ts')
const output = await fixture('rename.output.ts')

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

expect(result).toBe(output)
})

it('merges into existing import declaration from canonical source', async () => {
const input = await fixture('merge.input.ts')
const output = await fixture('merge.output.ts')

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

expect(result).toBe(output)
})

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

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

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: migrateAliasedExports })

expect(result).toBe(input)
})
})
228 changes: 228 additions & 0 deletions packages/codemod/src/transforms/migrate-aliased-exports/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import type { ImportDeclaration, ImportSpecifier, SourceFile } from 'ts-morph'

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

type Migration = {
rename?: string
to: string
}

const SOURCE_MAP: Record<string, Record<string, Migration>> = {
'@payloadcms/next/utilities': {
addDataAndFileToRequest: { to: 'payload' },
addLocalesToRequestFromData: { to: 'payload' },
createPayloadRequest: { to: 'payload' },
headersWithCors: { to: 'payload' },
mergeHeaders: { to: 'payload' },
sanitizeLocales: { to: 'payload' },
},
'@payloadcms/ui': {
Column: { to: 'payload' },
ListComponentClientProps: { rename: 'ListViewClientProps', to: 'payload' },
ListComponentServerProps: { rename: 'ListViewServerProps', to: 'payload' },
ListPreferences: { rename: 'CollectionPreferences', to: 'payload' },
ListViewClientProps: { to: 'payload' },
ListViewSlots: { to: 'payload' },
},
'@payloadcms/ui/shared': {
EntityType: { to: 'payload/shared' },
formatAdminURL: { to: 'payload/shared' },
mergeListSearchAndWhere: { to: 'payload/shared' },
},
}

type MigratingSpec = {
migration: Migration
named: ImportSpecifier
}

export const migrateAliasedExports: Transform = {
name: 'migrate-aliased-exports',
apply: ({ project }) => {
const filesChanged = new Set<string>()
const notes: string[] = []

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

for (const importDecl of [...file.getImportDeclarations()]) {
const migrations = SOURCE_MAP[importDecl.getModuleSpecifierValue()]
if (!migrations) {
continue
}

const named = importDecl.getNamedImports()
const migrating: MigratingSpec[] = []
let kept = 0

for (const spec of named) {
const migration = migrations[spec.getName()]
if (migration) {
migrating.push({ migration, named: spec })
} else {
kept++
}
}

if (migrating.length === 0) {
continue
}

const targets = new Set(migrating.map(({ migration }) => migration.to))

if (kept === 0 && targets.size === 1) {
const [target] = [...targets]
importDecl.setModuleSpecifier(target!)
for (const { migration, named: spec } of migrating) {
applyRename({ migration, notes, sourceFile: file, spec, target: target! })
}
mutated = true
continue
}

for (const { migration, named: spec } of migrating) {
const isTypeOnly = importDecl.isTypeOnly() || spec.isTypeOnly()
const aliasNode = spec.getAliasNode()
const localName = aliasNode?.getText() ?? spec.getName()
const canonicalName = migration.rename ?? spec.getName()
const alias = localName !== canonicalName ? localName : undefined

spec.remove()

attachToTargetImport({
file,
isTypeOnly,
named: { name: canonicalName, alias },
target: migration.to,
})

if (migration.rename) {
notes.push(
renameNote({ filePath: file.getFilePath(), migration, originalName: spec.getName() }),
)
}
}

const remaining = importDecl.getNamedImports()
if (
remaining.length === 0 &&
!importDecl.getDefaultImport() &&
!importDecl.getNamespaceImport()
) {
importDecl.remove()
}

mutated = true
}

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

return {
filesChanged: [...filesChanged],
...(notes.length > 0 ? { notes } : {}),
}
},
description:
'Move imports of aliased re-exports from @payloadcms/ui and @payloadcms/next/utilities to their canonical sources in `payload` / `payload/shared`. Renamed names (`ListPreferences` → `CollectionPreferences`, `ListComponentClientProps` → `ListViewClientProps`, `ListComponentServerProps` → `ListViewServerProps`) are imported using an `as` alias so existing usages keep compiling.',
}

type ApplyRenameArgs = {
migration: Migration
notes: string[]
sourceFile: SourceFile
spec: ImportSpecifier
target: string
}

function applyRename({ migration, notes, sourceFile, spec, target }: ApplyRenameArgs): void {
if (!migration.rename) {
return
}

const originalName = spec.getName()
const aliasNode = spec.getAliasNode()
const localName = aliasNode?.getText() ?? originalName

spec.setName(migration.rename)

if (localName !== migration.rename) {
spec.setAlias(localName)
} else {
spec.removeAlias()
}

notes.push(renameNote({ filePath: sourceFile.getFilePath(), migration, originalName }))
}

type RenameNoteArgs = {
filePath: string
migration: Migration
originalName: string
}

function renameNote({ filePath, migration, originalName }: RenameNoteArgs): string {
return `${filePath}: \`${originalName}\` is now \`${migration.rename}\` in \`${migration.to}\`. Codemod imported as \`${migration.rename} as ${originalName}\`; rename usages manually if you want to drop the alias.`
}

type AttachToTargetArgs = {
file: SourceFile
isTypeOnly: boolean
named: { alias?: string; name: string }
target: string
}

function attachToTargetImport({ file, isTypeOnly, named, target }: AttachToTargetArgs): void {
const localName = named.alias ?? named.name
const existing = findExistingTarget({ file, isTypeOnly, target })

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

file.addImportDeclaration({
isTypeOnly,
moduleSpecifier: target,
namedImports: [{ name: named.name, alias: named.alias }],
})
}

type FindExistingTargetArgs = {
file: SourceFile
isTypeOnly: boolean
target: string
}

function findExistingTarget({
file,
isTypeOnly,
target,
}: FindExistingTargetArgs): ImportDeclaration | undefined {
const candidates = file
.getImportDeclarations()
.filter((decl) => decl.getModuleSpecifierValue() === target)

if (candidates.length === 0) {
return undefined
}

if (isTypeOnly) {
return candidates.find((decl) => decl.isTypeOnly()) ?? candidates[0]
}

return candidates.find((decl) => !decl.isTypeOnly()) ?? candidates[0]
}
Loading
Loading