2377 Commits

Author SHA1 Message Date
Elliot DeNolf 71be0ca626 chore(release): v3.77.0 [skip ci] 2026-02-18 14:28:29 -05:00
Sasha 0935824011 feat(db-*): add customID arg to db.create (#15653)
This PR adds `customID` argument to `payload.db.create` which can be
used to ensure that a document is created with the provided ID, for
example:
```ts
payload.db.create({ collection: 'posts', customID: 'ce98d6c4-c3ab-45de-9dfc-bf33d94cc941', data: { } })
```
This does not require to have a custom ID field in `posts` collection,
as the following:
```ts
payload.db.create({ collection: 'posts', data: { customID: 'ce98d6c4-c3ab-45de-9dfc-bf33d94cc941' } })
```
Can be used only when you define a custom ID field
2026-02-17 20:45:13 +02:00
Philipp Schneider 9a38469b59 feat: pass local API depth through to req.query.depth for consistency (#15023)
Motivation: Consistency of the local API with the `req` parameter of
requests made via the Rest / GraphQL API.

Before this change, if hooks relied on `req.query.depth`, `req: {query:
{depth: x}}` would have to be manually passed to local API calls such as
`payload.find({})`, in addition to `depth: x`. This was easy to forget,
in which case the local API calls would result in different outcomes
than if the same operation was triggered via Rest / GraphQL. This was
confusing.

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-17 14:01:33 +00:00
Jessica Rynkar 1cc3bb93bc fix: populate previousValue correctly in afterChange hooks for nested lexical fields (#15623)
## What
Fixes `previousValue` being `undefined` in `afterChange` hook for nested
Lexical fields.

## Why
We had this issue previously where the wrong `previousValue` data was
being accessed with nested fields, this introduced the fix of
`previousValData` which properly scopes to sibling-level or full
document data.

When the previous fix was done, the `previousValue` in the rich text
case here was missed.

## How
Changed the `richText` case in
`packages/payload/src/fields/hooks/afterChange/promise.ts` to use the
same `previousValData` pattern as other field types:

```diff
- previousValue: previousDoc?.[field.name],
+ previousValue: getNestedValue(previousValData, pathSegments) ?? previousValData?.[field.name],
```

### Testing
Added to `Lexical > lexical.int.spec.ts`
2026-02-13 23:24:50 +00:00
Elliot DeNolf 3a486c51b4 chore(release): v3.76.1 [skip ci] 2026-02-11 13:30:17 -05:00
Kendell 6937eec1f6 fix: use optional chaining for adminThumbnail size lookup to prevent crash (#15586)
Fixes issue with thumbnail lookups for documents without sizes.

Introduced: https://github.com/payloadcms/payload/pull/15232
2026-02-10 15:50:16 -05:00
Jessica Rynkar a313627a12 fix: non-image files should not recieve 0 bytes with useTempFiles (#15538)
## What?

Fixes non-image files (PDF, TXT, MP3, MP4, etc.) being saved with 0
bytes when `useTempFiles: true` is enabled in the upload config.

## Why?

With `useTempFiles: true`, upload data is written to disk instead of
memory, leaving `file.data` empty. Image uploads work because Sharp
reads from `file.tempFilePath` and outputs a buffer, but non-image files
skip this and relied on `file.data`, resulting in 0-byte files being
saved.

## How?

In `generateFileData.ts` we now conditionally determine the
`bufferToSave` instead of relying on just `file.data`.

#### Testing
Test added to `payload-cloud > int`

Fixes #11832
2026-02-10 08:36:42 -05:00
Jessica Rynkar 8283c25f02 fix: add CSP headers to SVG uploads to prevent XSS (#15506)
### What?
Adds `Content-Security-Policy` headers to all `SVG` uploads, in Payload
core and storage adapters, to prevent 'SVG smuggling' (executable code
secretly embedded in the file).

### Why?
We received a report via email raising the concern that SVG files
containing embedded JavaScript could be uploaded and executed in the
admin panel.

Common approaches in similar frameworks are:
- Blocking SVG uploads entirely, which is highly restrictive
- Leaving enforcement to the user, requires full manual configuration

Payload already includes SVG validation via `validateSvg()` which strips
known malicious patterns. This change adds the CSP header as an
additional layer of defense. It isn’t strictly required, but it is a
simple addition that provides extra protection and has no real downside,
beyond this, handling and validating SVGs remains the user’s
responsibility.

**Note**: Users who need to serve interactive SVGs can override this
behavior using the `modifyResponseHeaders` option in their upload
collection config.

### How?
- Detects SVGs and conditionally adds CSP header in `uploads/getFile.ts`
- Same logic added to all storage adapter `staticHandler.ts` files

#### Testing
Added to `uploads > int`

#### Acknowledgement
Reported by: Dominic Breuker @ gematik
2026-02-10 08:36:05 -05:00
Elliot DeNolf 5909064c14 chore(release): v3.76.0 [skip ci] 2026-02-09 16:00:17 -05:00
Paul 73a96501b7 fix(plugin-import-export): errors when import/export files were stored in a storage adapter such as S3 (#15441)
Relies on https://github.com/payloadcms/payload/pull/15405 first

When using a storage adapter such as S3 to handle files there were
errors in the way the files would be fetched for imports.

This PR fixes that, adding test coverage and changes the job behaviour
so that it only stores the import doc ID and not the full file data -
this avoids many problems down the line and optimises memory and storage
usage.

Fixes https://github.com/payloadcms/payload/issues/15247

Previously attempting to use an import with the jobs queue with S3
storage would result in this error:

```text
[11:46:25] ERROR: Failed to queue import job for document 698628f19aaca7d4f4f18c6e
    err: {
      "type": "Error",
      "message": "ENOENT: no such file or directory, open '/posts-with-s3-import/2026-02-06 114542.csv'",
      "stack":
          Error: ENOENT: no such file or directory, open '/posts-with-s3-import/2026-02-06 114542.csv'
```

Also:
- Exports `getExternalFile` utility from `payload/internals`
2026-02-06 19:51:37 +00:00
Alessio Gravili 61584898e3 fix(next): suppress webpack "Critical dependency" warning in dynamicImport (#15534)
The Vitest-only branch in `dynamicImport` uses a bare
`import(importPath)` which webpack still statically analyzes, even
though it's behind a `process.env.VITEST` condition. This could cause a
`"Critical dependency: the request of a dependency is an expression"`
warning in Next.js projects. Adding `/* webpackIgnore: true */` should
fix this.

Additionally, this hides another sass deprecation warning
2026-02-05 19:29:30 +00:00
Elliot DeNolf 00e2ffb187 chore(release): v3.75.0 [skip ci] 2026-02-05 12:35:59 -05:00
sanbrien 5ca9bd464f fix: handle absolute paths correctly with tempFileDir upload option (#14436)
### What?

This PR fixes `tempFileDir` configuration not respecting absolute paths
when `useTempFiles` is enabled for uploads.

### Why?

1. **Absolute paths ignored**: Setting `tempFileDir: '/tmp'` would still
create temp files under the working directory (e.g.,
`/Users/project/tmp/...`) instead of `/tmp/...`

2. **Path duplication**: The default `tempFileDir` could result in
duplicated working directory segments in the path

### How?
Uses `path.resolve()` to handle both cases:
- Absolute paths (e.g., `/tmp`) are returned as-is
- Relative paths (e.g., `tmp`) are resolved against `process.cwd()`

Fixes #12910

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-05 17:08:36 +00:00
Neha Prasad fa1cd62a52 fix: ungenerated image sizes should not store original URL (#15454)
### What?

Fixes image sizes storing the original document URL in the database when
the size could not be generated (should be `null` instead).

### Why?

When an uploaded image is smaller than a defined image size (or cannot
be resized, e.g., SVG), Payload correctly sets `filename`, `width`,
`height`, `mimeType`, and `filesize` to `null`. However, the `url` field
was incorrectly falling back to the original image URL.

For background, there was a previous issue where the URL for generated
sizes was always being set to the main URL. This was resolved, but still
left the main URL as a fallback - really this should have been
completely removed.

### How?

Removed the fallback to the original filename in the `beforeChange` hook
for image size URL fields. Now when a size cannot be generated, the URL
is `null` (consistent with the other size fields).

Fixes:  #15450

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2026-02-05 13:43:34 +00:00
Jarrod Flesch f23a1dffe8 feat: adds beforeNav and afterNav component slots (#15493)
Allows custom components to be slotted in before the nav, and not
specifically beforeNavLinks. `beforeNavLinks` semantically means before
the links themselves, but for a PR like
https://github.com/payloadcms/payload/pull/15470, we will want to be
able to place things outside of the nav links section. Think about the
multi-tenant selector, you would not want that visible in just the first
tab, but outside of the tabs - i.e. before the nav.
2026-02-03 13:31:09 -05:00
Jarrod Flesch 8422668f58 chore: replace magic strings with centralized constants (#15475)
Replaces hardcoded preference key strings with centralized constants to
prevent typos and improve maintainability. Creates PREFERENCE_KEYS
object in packages/payload/src/preferences/keys.ts and updates all
usages throughout the codebase.

I have another PR that sets preferences and figured it would be nice if
we had a centralized place for the pref constants.

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 10:01:35 -05:00
Jessica Rynkar 1041bb646b fix: add safety check to redirects from external file URL uploads (#15458)
### What
Fixes an `uploads` related security issue where redirects are able
bypass URL validation in external file uploads.

### Why
When uploading files via an external URL, HTTP redirects (3xx responses)
were automatically followed. This meant an potential attacker could
provide a public URL that redirected to internal services, bypassing the
security checks that only ran on the initial URL.

### How
- Added `redirect: 'manual'` to `safeFetch()` to prevent automatic
redirect following
- Modified `getExternalFile()` to manually handle redirects with
re-validation at each step
- Limited redirects to maximum of 3x to prevent infinite loops
- Tests added to `uploads` test suite


#### Acknowledgement
Big thank you to `r3dbrothers1` for the responsible disclosure.
2026-02-03 14:39:09 +00:00
Jarrod Flesch 6c07f3b946 fix(next): export SlugField from the client dir not rsc (#15461)
Fixes https://github.com/payloadcms/payload/issues/15451.

Changes introduced in https://github.com/payloadcms/payload/pull/15392
moved the `SlugField` from client to server exports, throwing the
following runtime error when navigating to the edit view:

```
useServerFunctions must be used within a ServerFunctionsProvider
```

---------

Co-authored-by: Jake <jacobsfletch@gmail.com>
2026-02-02 14:30:11 +00:00
Alessio Gravili 9c8be5cc96 perf(next): avoid re-calculating permissions in some server functions, pass missing args (#15428)
Requires https://github.com/payloadcms/payload/pull/15427 to be merged
first.

This PR avoids re-calculating permissions in certain server functions,
since through #15427, they are now available in server functions due to
an `initReq` that we already ran.

It also passes some missing properties to `serverProps` and render
handles that are now available.
2026-01-30 17:01:11 -05:00
Alessio Gravili d6aa6ccb55 fix: add collection property to auth documents, fixing multi tenancy access control issue (#15404)
This PR ensures that all documents returned from auth-enabled
collections via local API operations include the `collection` property,
which is essential for multi-tenant access control to function
correctly.

## Motivation

A common pattern in Payload applications is to fetch a user from the
database and pass it to subsequent operations:

```typescript
const sean = (await payload.find({
  collection: 'users',
  where: { email: { equals: 'sean@payloadcms.com' } }
})).docs[0]

const result = await payload.find({
  collection: 'diaries',
  overrideAccess: false,
  user: sean, // pass fetched user
})

// result.docs[0]: { title: "sean's diary" }
// result.docs[1]: { title: "jarrod's journal" } // Oh no! This is private!
```

This introduces a security vulnerability. The local API expects
`user.collection` to be present, but user documents returned by
`find`/`findByID` did not include this property. Since the `user`
parameter is loosely typed, TypeScript does not catch this error.

In the `@payloadcms/plugin-multi-tenant` plugin, `useTenantAccess`
checks `req.user.collection` to apply tenant-based access control. This
is not an issue within plugin-multi-tenant, because the Payload types
mark `req.user.collection` as required - thus it's reasonable to expect
this property to be there. When `collection` is `undefined`, the
condition `args.req.user.collection === adminUsersSlug` always evaluates
to `false`, bypassing tenant access restrictions.

## Changes

The `collection` property is now injected into auth collection documents
immediately after data is fetched from the database in all collection
operations (`find`, `findByID`, `create`, `update`, `delete`, etc.).
This makes it available throughout all operation hooks.

Type generation has been updated to include `collection` directly in
auth collection schemas, producing cleaner types where `User` contains
`collection: 'users'` without intersection types. Input types have been
updated to make `collection` optional since it's auto-populated.

As a fallback, `createLocalReq` now sets `user.collection` to the
default admin user collection if missing - this is an additional
safeguard. A TODO has been added to throw an error instead in version
4.0. We cannot throw an error now, as this would be a breaking change.

[[Link to internal Slack
discussion]](https://payloadcms.slack.com/archives/C049BR3QBHC/p1769593583792739)
2026-01-30 14:05:01 -05:00
Alessio Gravili ce13e97243 feat(next): pass full initReq context to server functions and dashboard widgets (#15427)
Currently, we only pass `importMap` and `req` to all server functions.
This PR extends that interface by passing the following **additional
arguments** to every server function and every dashboard widget:

- `cookies`
- `locale`
- `permissions`

This change comes at **no extra cost**, since all of these values are
already available via `initReq`, which we are already calling.

## Why?

I’m working on rendering the list view in a dashboard widget through a
server function, which requires context such as `permissions`.
Recomputing these values would be redundant and inefficient, given that
they’ve already been calculated in `handleServerFunctions` and we can
just pass them through.

By passing them through once, we avoid duplicate work and make server
functions easier to use.
2026-01-30 17:44:13 +00:00
Elliot DeNolf de4fa0c184 chore(release): v3.74.0 [skip ci] 2026-01-30 11:24:24 -05:00
Patrik 2dc2e7c07f fix: isolate payload-preferences by auth collection (#15425)
### What
Fixes preferences being accessible across different auth collections in
multi-auth setups.

### Why
The `preferenceAccess` function only checked user ID without verifying
which auth collection the user belonged to. In setups with multiple auth
collections using sequential IDs, this could allow unintended access to
preferences.

### How
Updated `preferenceAccess` to check both user ID and auth collection,
consistent with the existing operation handlers. Added tests to verify
proper isolation.
2026-01-30 06:07:52 -08:00
Jarrod Flesch 85d5263542 feat: thread override access in doc level hooks (#15421) 2026-01-29 17:37:15 -05:00
Tobias Odendahl 9239164e84 feat: extend strictDraftTypes to all draft operations (#15292)
## What

This PR extends the `typescript.strictDraftTypes` configuration flag to
enforce **draft type safety across all Local API operations** that
accept a `draft` parameter, for both collections and globals.

When `strictDraftTypes: true` is enabled, the `draft` option is now:

- ❌ **Forbidden** for collections/globals without drafts enabled
- ✅ **Allowed** for collections/globals with drafts enabled
- ⚠️ **Conditionally allowed** when the slug is generic and cannot be
statically narrowed

Additionally, in **create operations**:
- `DraftDataFromCollectionSlug` can only be used with **draft-enabled**
collections
- This prevents sending partial draft data to non-draft collections

---

## ⚠️ Important Note on Generic Usage

This change may surface backward-compatibility issues when **generic
collection or global slugs** are used.

When a Local API helper accepts a generic `TSlug`, TypeScript may be
unable to determine whether drafts are enabled. In these cases, an
explicit type assertion may be required:

```ts
function createDocument<TSlug extends CollectionSlug>(slug: TSlug) {
  return payload.create({
    collection: slug,
    data: someData,
    draft: true,
  } as Options<TSlug, SelectType>)
}
```
This is a limitation of TypeScript’s type narrowing and does not affect
runtime behavior.

## Why

PR #14388 introduced the `strictDraftTypes` flag to improve **query
return types** when `draft: true` is used (for example, making required
fields optional).

However, draft usage itself was not enforced, which led to several
issues:

1. **Silent runtime behavior**  
Passing `draft: true` to a collection or global without drafts enabled
is silently ignored

2. **Type safety gaps**  
TypeScript allows partial draft data to be sent to entities that do not
support drafts

3. **Inconsistent API behavior**  
   Some operations enforced draft rules, while others did not

This PR addresses these gaps by enforcing draft constraints at **compile
time**, ensuring invalid draft usage is caught during development.

## How

### 1. Introduced reusable helper types
- `CollectionsWithoutDrafts` / `GlobalsWithoutDrafts`  
  Identify collections and globals that do not support drafts

- `DraftFlagFromCollectionSlug<TSlug>` /
`DraftFlagFromGlobalSlug<TSlug>`
  Conditional types that:
  - Respect the `strictDraftTypes` flag
  - Forbid the `draft` property for non-draft entities
  - Allow the `draft` property for draft-enabled or generic slugs

### 2. Applied helpers across all operations
- Split operation `Options` types into `BaseOptions` plus draft-flag
intersections
- Removed duplicated draft-related type logic across files

### 3. Documentation updates
- Expanded the `strictDraftTypes` documentation to cover draft
enforcement across **all Local API operations**

---

## Backward Compatibility

- The feature is **opt-in**
- `strictDraftTypes` defaults to `false`
- Existing code is unaffected unless the flag is enabled

---

## Related

Builds upon **#14388**, which introduced `strictDraftTypes` for query
return types.

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-01-29 12:59:06 -08:00
Paul 6b45fee13f chore: export getSafeFileName utility (#15424)
Exports the `getSafeFileName` utility from `payload/internal` so we can
reuse it in the storage adapters for client side uploads (there's a bug
fix in a subsequent PR).

Adds some unit tests for this util
2026-01-29 20:58:49 +00:00
Patrik 94254da4ac feat: add support for custom UnpublishButton component (#15400)
### What

Adds the ability to customize the `UnpublishButton` component in
collection and global configs, following the same pattern as
`PublishButton`.

### Why

`UnpublishButton` was hardcoded while other document control buttons
(`PublishButton`, `SaveButton`, etc.) could be customized.

### How

Added type definitions, config options, component resolution in
renderDocumentSlots, and updated DocumentControls to use the
RenderCustomComponent pattern.

---------

Co-authored-by: Alessio Gravili <github@gravili.net>
2026-01-29 10:01:48 -08:00
Georg Schelkshorn 99b051ec67 fix: traverseFields returning wrong parentPath dot notation for non-localised tabs (#15394)
### What?
The `traverseFields` function returns the wrong dot notation for the
`parentPath` argument in `callback` for non-localized tab fields.

### Why?
Dot is missing in the template string.

### How?
Added the missing dot and a unit test for this case. I can extend the
test to cover more of the `traverseFields` function if needed.

### Before
```ts
{
    type: 'tabs',
    tabs: [
      {
        label: 'Tab 1',
        name: 'tab1',
        fields: [
          {
            type: 'tabs',
            tabs: [
              {
                label: 'Ui Only Tab',
                fields: [
                  {
                    type: 'group',
                    name: 'group1',
                    label: 'Group 1',
                    fields: [
                      {
                        type: 'text',
                        name: 'text1',
                        label: 'Text 1',
                      },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      },
    ],
  }
```

The `parentPath` argument in `callback` returns as `tab1group1.`.

### After
Now returns `parentPath` as `tab1.group1.` as expected.
2026-01-28 21:00:24 +00:00
Patrik 5561799188 fix: widgets and other features failing with transitive dependency imports (#15392)
### What

Re-exports UI components from `@payloadcms/next/rsc` and
`@payloadcms/next/client` instead of requiring direct imports from
`@payloadcms/ui` in the generated importMap.

### Why

The generated `importMap.js` was importing from `@payloadcms/ui`, which
isn't in users' `package.json` (it's a transitive dependency through
`@payloadcms/next`). This breaks import resolution with pnpm and can
fail in production builds with any package manager without
`@payloadcms/ui` installed.

### How

- Added re-exports in `@payloadcms/next/rsc` for: `CollectionCards`,
`FolderField`, `FolderTableCell`, `SlugField`
- Added re-exports in `@payloadcms/next/client` for: `FolderTypeField`
and all `QueryPresets*` components
- Updated all component paths in `@payloadcms/payload` to use
`@payloadcms/next/rsc` or `@payloadcms/next/client` instead of
`@payloadcms/ui`

Fixes #15010
2026-01-28 12:38:05 -08:00
Alessio Gravili 7043e3f642 chore(deps): bump tsx (#15363)
Fixes https://github.com/payloadcms/payload/issues/15314

This bumps tsx to address esbuild vulnerability
https://github.com/privatenumber/tsx/pull/698
2026-01-26 14:35:14 -08:00
Elliot DeNolf 2511c022e6 fix: replace deprecated scmp with crypto.timingSafeEqual (#15322)
# Overview

Removes the deprecated `scmp` package and replaces it with Node.js's
native `crypto.timingSafeEqual()` for timing-safe password hash
comparison.

Resolves ECMS-356

## Key Changes

- **Replace scmp with crypto.timingSafeEqual**
- Added length pre-check before comparison (required since
`timingSafeEqual` throws on length mismatch, whereas `scmp` returned
false)
  
- **Remove scmp dependency**
- Eliminates deprecation warning: "scmp@2.1.0: Just use Node.js's
crypto.timingSafeEqual()"

## Design Decisions

The length pre-check (`hashBuffer.length === storedHashBuffer.length`)
preserves the original `scmp` behavior of returning false for mismatched
lengths, rather than throwing an error. This maintains backward
compatibility and security semantics.

## Overall Flow

```mermaid
sequenceDiagram
    participant Client
    participant authenticateLocalStrategy
    participant crypto

    Client->>authenticateLocalStrategy: password + doc (hash, salt)
    authenticateLocalStrategy->>crypto: pbkdf2(password, salt)
    crypto-->>authenticateLocalStrategy: hashBuffer
    authenticateLocalStrategy->>authenticateLocalStrategy: Check buffer lengths match
    alt Lengths match
        authenticateLocalStrategy->>crypto: timingSafeEqual(hashBuffer, storedHashBuffer)
        crypto-->>authenticateLocalStrategy: boolean
    else Lengths differ
        authenticateLocalStrategy-->>Client: null (fail fast)
    end
    authenticateLocalStrategy-->>Client: doc | null
```
2026-01-26 14:24:09 -05:00
Jarrod Flesch 3e271556d3 fix: find afterRead hooks should behave like findByID (#15357)
## Issue
Fixes `find` afterRead operations that should behave like `findByID`
afterRead hooks do.

## Before
The find afterRead hooks were falling back to the `doc` if the hook
returned undefined/null.

## After
The find afterRead hooks now fallback to the `docRef` if the afterRead
hook returns undefined/null.

## Alignment
You can see
[here](https://github.com/payloadcms/payload/blob/main/packages/payload/src/collections/operations/findByID.ts#L310-L319)
that the findByID already behaves this way.
2026-01-26 18:31:53 +00:00
Alessio Gravili dfc1600364 fix: remove depth from count operation types (#15356)
The `payload.count`, `payload.countVersions` and
`payload.countGlobalVersions` operations accepted a `depth` argument,
but this value is never used in the count logic. Having it show up in
auto-suggestions can be confusing for users (as I just experienced),
since it raises the question of why a count operation would need a depth
at all.

This PR removes the unused depth property
2026-01-26 18:14:24 +00:00
Patrik e833fe698e fix: publish button incorrectly shown after saving draft when access denied (#15319)
### What

Fixes publish and unpublish buttons incorrectly appearing for users
without publish permissions after saving a draft.

### Why

The document access endpoints weren't passing request body data to the
permission operations.

This caused the simulated `_status` field (used to check "can this user
publish?") to be ignored. The frontend simulates `_status: 'published'`
to determine if the publish button should be shown, but the backend
would check permissions against the actual draft document instead,
incorrectly returning `true`.

This made the publish button visible even though clicking it would still
fail with an access error.

Additionally, the unpublish button didn't check for publish permissions
at all - if a user can't publish, they shouldn't be able to unpublish
either.

### How

- Pass `data: req.data` in collection and global `docAccess` endpoint
handlers so simulated status is used for permission checks
- Update `useGetDocPermissions` to extract actual document from API
response (`data?.doc || data || {}`) before sending to backend, ensuring
field permissions receive complete document structure
- Add `hasPublishPermission` check to `UnpublishButton` component

Fixes #15312
2026-01-26 05:58:52 -08:00
Alessio Gravili 4181a1235b docs: improve select jsdocs and empty select docs (#15336)
This PR ensures we have proper jsdocs in all public APIs that expose the
`select` property. That way, people can easily grasp how to use select
without having to open the docs page.

Also, we never documented how to select **just** the `id` field
anywhere, or what happens if an empty select property is passed. Today,
this actually confused me, as I wanted to select just the ID and, after
reading the docs, didn't know how. Thus, this PR adds a section about
empty select in the docs, as well as an example in the jsdocs.
2026-01-24 01:39:37 +00:00
Elliot DeNolf b3796f587e chore(release): v3.73.0 [skip ci] 2026-01-23 15:43:40 -05:00
Sasha 1b7b13d178 feat(drizzle): predefined migration for blocksAsJSON: true (#15257)
This PR adds ability to migrate an existing project to use the
`blocksAsJSON` postgres/sqlite property.
https://github.com/payloadcms/payload/pull/12750
Usage: `pnpm payload migrate:create --file
@payloadcms/db-postgres/blocks-as-json`.

Example of a generated migration for the website template:
```ts
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'

import { getBlocksToJsonMigrator } from '@payloadcms/db-postgres/migration-utils'
import { fileURLToPath } from 'url'
import path from 'path'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

// Configure migration options (optional)
const BATCH_SIZE = 100 // Number of entities to process per batch
const TEMP_FOLDER = path.resolve(dirname, '.payload-blocks-migration') // Folder path to store migration batch


export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
  
  const migrator = getBlocksToJsonMigrator(payload)
  migrator.setTempFolder(TEMP_FOLDER)
  await migrator.collectAndSaveEntitiesToBatches(req, { batchSize: BATCH_SIZE })
  
  await db.execute(sql`
   ALTER TABLE "pages_blocks_cta_links" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_cta" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_content_columns" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_content" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_media_block" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_archive" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "pages_blocks_form_block" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_cta_links" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_cta" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_content_columns" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_content" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_media_block" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_archive" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "_pages_v_blocks_form_block" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_checkbox" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_country" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_email" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_message" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_number" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_select_options" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_select" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_state" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_text" DISABLE ROW LEVEL SECURITY;
  ALTER TABLE "forms_blocks_textarea" DISABLE ROW LEVEL SECURITY;
  DROP TABLE "pages_blocks_cta_links" CASCADE;
  DROP TABLE "pages_blocks_cta" CASCADE;
  DROP TABLE "pages_blocks_content_columns" CASCADE;
  DROP TABLE "pages_blocks_content" CASCADE;
  DROP TABLE "pages_blocks_media_block" CASCADE;
  DROP TABLE "pages_blocks_archive" CASCADE;
  DROP TABLE "pages_blocks_form_block" CASCADE;
  DROP TABLE "_pages_v_blocks_cta_links" CASCADE;
  DROP TABLE "_pages_v_blocks_cta" CASCADE;
  DROP TABLE "_pages_v_blocks_content_columns" CASCADE;
  DROP TABLE "_pages_v_blocks_content" CASCADE;
  DROP TABLE "_pages_v_blocks_media_block" CASCADE;
  DROP TABLE "_pages_v_blocks_archive" CASCADE;
  DROP TABLE "_pages_v_blocks_form_block" CASCADE;
  DROP TABLE "forms_blocks_checkbox" CASCADE;
  DROP TABLE "forms_blocks_country" CASCADE;
  DROP TABLE "forms_blocks_email" CASCADE;
  DROP TABLE "forms_blocks_message" CASCADE;
  DROP TABLE "forms_blocks_number" CASCADE;
  DROP TABLE "forms_blocks_select_options" CASCADE;
  DROP TABLE "forms_blocks_select" CASCADE;
  DROP TABLE "forms_blocks_state" CASCADE;
  DROP TABLE "forms_blocks_text" CASCADE;
  DROP TABLE "forms_blocks_textarea" CASCADE;
  ALTER TABLE "pages_rels" DROP CONSTRAINT "pages_rels_categories_fk";
  
  ALTER TABLE "_pages_v_rels" DROP CONSTRAINT "_pages_v_rels_categories_fk";
  
  DROP INDEX "pages_rels_categories_id_idx";
  DROP INDEX "_pages_v_rels_categories_id_idx";
  ALTER TABLE "pages" ADD COLUMN "layout" jsonb;
  ALTER TABLE "_pages_v" ADD COLUMN "version_layout" jsonb;
  ALTER TABLE "forms" ADD COLUMN "fields" jsonb;
  ALTER TABLE "pages_rels" DROP COLUMN "categories_id";
  ALTER TABLE "_pages_v_rels" DROP COLUMN "categories_id";
  DROP TYPE "public"."enum_pages_blocks_cta_links_link_type";
  DROP TYPE "public"."enum_pages_blocks_cta_links_link_appearance";
  DROP TYPE "public"."enum_pages_blocks_content_columns_size";
  DROP TYPE "public"."enum_pages_blocks_content_columns_link_type";
  DROP TYPE "public"."enum_pages_blocks_content_columns_link_appearance";
  DROP TYPE "public"."enum_pages_blocks_archive_populate_by";
  DROP TYPE "public"."enum_pages_blocks_archive_relation_to";
  DROP TYPE "public"."enum__pages_v_blocks_cta_links_link_type";
  DROP TYPE "public"."enum__pages_v_blocks_cta_links_link_appearance";
  DROP TYPE "public"."enum__pages_v_blocks_content_columns_size";
  DROP TYPE "public"."enum__pages_v_blocks_content_columns_link_type";
  DROP TYPE "public"."enum__pages_v_blocks_content_columns_link_appearance";
  DROP TYPE "public"."enum__pages_v_blocks_archive_populate_by";
  DROP TYPE "public"."enum__pages_v_blocks_archive_relation_to";`)
  payload.logger.info("Executed blocks to JSON migration statements.")
  
  await migrator.migrateEntitiesFromTempFolder(req, { clearBatches: true })
    
}

export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
    // Migration code
}
```

The migration when created will automatically update the payload config
file (if one is found) to insert `blocksAsJSON: true`.
Additionally, extends the predefined migration API with `dynamic` to
dynamically build the migration file.
2026-01-23 15:38:50 -05:00
Alessio Gravili 3dbaed807c docs: add jsdocs to query presets api (#15306) 2026-01-21 21:04:58 +00:00
Jarrod Flesch 12b679f65a chore: removes never implemented readDrafts access arg on globals (#15297)
Removes `readDrafts` access property from globals as it has never been
implemented or documented. It never actually did anything but was in the
access type for globals.
2026-01-21 14:44:23 -05:00
Paul 02800b0bb9 chore: export fractional indexing utilities (#15286)
Exports the fractional indexing utilities and also reorders the docs so
that we mostly link to our docs on the topic and from there only link
out once to the article.
2026-01-20 16:16:04 -06:00
Alessio Gravili 18ca83b705 feat: next.js 16 support (#14456)
This PR:
- updates this monorepo to use Next.js 16. Running our test suites
against Next.js 16 is required to guarantee support.
- updates all peerDependencies to support the latest Next.js canary.

Individual Next.js 16–related fixes were made in separate, smaller PRs
(listed below). As a result, the majority of the changes in this PR are
focused on getting the test suite to pass.

Templates will be updated in a separate PR once Next.js 16.2.0 is
released.
2026-01-20 21:58:30 +00:00
Jarrod Flesch f4e8990bab fix: select hasMany prevent duplicate values (#15218)
Fixes https://github.com/payloadcms/payload/issues/15208

Adds duplicate value validation to select `hasMany: true` fields.
2026-01-20 16:31:19 -05:00
Vladyslav Prosolupov ef27ad9bfc fix: orderable fractional indexing case-sensitivity issue with PostgreSQL (#14867)
## fix: orderable fractional indexing case-sensitivity issue with
PostgreSQL

### Problem

When using the `orderable` feature with PostgreSQL, reordering documents
to the "first" position generates keys that break subsequent reordering
operations.

#### Scenario

1. Create two orderable documents → keys are `a0`, `a1`
2. Drag `a1` before `a0` (to make it first)
3. The system generates key `Zz` for the moved document
4. **Result**: Ordering is completely broken - cannot reorder any
documents anymore

#### Root Cause

The [fractional indexing
algorithm](https://observablehq.com/@dgreensp/implementing-fractional-indexing)
uses:
- **Uppercase `A-Z`** for "smaller" integer keys
- **Lowercase `a-z`** for "larger" integer keys

This relies on **ASCII ordering** where `'Z'` (code 90) < `'a'` (code
97), so `Zz < a0`.

**However**, PostgreSQL's default collation (`en_US.UTF-8`) uses
**case-insensitive comparison**, treating `'Z'` as `'z'`. This means:
- ASCII: `Zz < a0` ✓
- PostgreSQL: `Zz` treated as `zz`, so `zz > a0` ✗

This mismatch causes:
1. Database queries return incorrect adjacent documents
2. The `generateKeyBetween` function receives arguments in wrong order
3. Subsequent reorder attempts either error out or create more broken
keys

### Solution

Modified the fractional indexing algorithm to use only characters that
sort consistently across **all** database collations:

| Range | Old Encoding | New Encoding |
|-------|--------------|--------------|
| Small keys | `A-Z` (uppercase) | `0-9` (digits) |
| Large keys | `a-z` (lowercase) | `a-z` (lowercase, unchanged) |

**Key insight**: Digits (`0-9`) **always** sort before letters in both
ASCII ordering and case-insensitive collations.

#### Before (broken)
```
decrementInteger('a0') → 'Zz'
'Zz' < 'a0' in ASCII ✓
'Zz' > 'a0' in PostgreSQL (case-insensitive) ✗
```

#### After (fixed)
```
decrementInteger('a0') → '9z'
'9z' < 'a0' in ASCII ✓
'9z' < 'a0' in PostgreSQL ✓
'9z' < 'a0' in MongoDB ✓
'9z' < 'a0' in SQLite ✓
```

### Changes

**`packages/payload/src/config/orderable/fractional-indexing.js`**
   - Changed integer part encoding to use digits for "small" range
   - Maintains backward compatibility with existing `a-z` keys
- Legacy `A-Z` keys are still parsed (for backward compatibility) but
won't be generated

### Backward Compatibility

- ✅ Existing keys starting with `a-z` continue to work correctly
- ⚠️ Existing keys starting with `A-Z` (uppercase) will be parsed but
may sort incorrectly in case-insensitive databases. Users with such keys
should run a migration to regenerate them.

### Testing

Verified the algorithm produces correct ordering:

```javascript
generateKeyBetween(null, null)     // → 'a0'
generateKeyBetween(null, 'a0')     // → '9z' (previously 'Zz')
generateKeyBetween(null, '9z')     // → '9y'

// Sorting works correctly
['9z', 'a0', 'a1'].sort()          // → ['9z', 'a0', 'a1'] ✓
```

### Related

- Fractional Indexing algorithm:
https://observablehq.com/@dgreensp/implementing-fractional-indexing
- Original library: https://github.com/rocicorp/fractional-indexing

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
2026-01-20 13:45:36 -06:00
Alessio Gravili f5a7a00a19 fix: conditional tabs breaking in Next.js 16 due to unstable tab id (#15270)
This PR fixes broken conditional tabs support in Next.js 16. In Next.js
16, the config is sanitized multiple times during development, with each
run assigning a new UUID to `tab.id`. This causes a mismatch between the
`tab.id` in form state and the `tab.id` in the client config - likely
because the client config is cached, while [config sanitization is
not](https://github.com/payloadcms/payload/pull/9501).

Using UUIDs in form state is also inconsistent with the rest of the
codebase, where every other field uses its path as a key. This PR
replaces the random UUID with the tab's schema path, ensuring a stable
ID regardless of how many times config sanitization runs, and aligning
tabs closer to how other fields are keyed in form state.
2026-01-20 08:14:10 -05:00
Jessica Rynkar b6b6bab465 fix: correct previousValue and value in afterChange when using seo-plugin (#15253)
### What
Fixes issue where `previousValue` and `value` return identical data in
`afterChange` hook when using `tabs` fields.
This bug was surfaced by the `plugin-seo`, which wraps all fields in a
`tabs` field when `tabbedUI` is enabled.

### Why
In `afterChange/promise.ts`, for the `tab` case, we incorrectly assign:

```ts
tabPreviousSiblingDoc = siblingDoc
```

instead of:

```ts
tabPreviousSiblingDoc = {...previousSiblingDoc}
```

This caused hooks to receive the **current document state** for both
`previousValue` and `value`.

### Where
Updates the code in `promise.ts`.

#### Testing
Int test added to `plugin-seo`.
2026-01-20 12:07:16 +00:00
Elliot DeNolf fbf48d2e19 chore(release): v3.72.0 [skip ci] 2026-01-16 15:42:48 -05:00
Jarrod Flesch beeb269244 fix: thumbnailURL hook, virtually populate from thumbnail size (#15232)
Removes beforeChange hook on thumbnailURL. Really should be a virtual
field, but that would be a breaking change. Corrects the way the url is
created by using the thumbnail url in the url generation.
2026-01-16 15:38:47 -05:00
German Jablonski 4f452ac091 fix: isValidID validation (#15217)
## Scripts

### `script:setup-figma`

Sets up the local development environment for testing with content-api:

- Clones `enterprise-plugins` repo if not present (sibling directory)
- Runs `pnpm install` in enterprise-plugins
- Removes `@payloadcms/figma` from `test/package.json` to rely on vitest
alias instead

### `test:int:summary`

Shows less noisy output for integration tests - reports how many tests
failed without detailed error messages. The test suites to run can be
easily modified via a hardcoded array. This script is particularly
useful when many tests fail (e.g., when starting to develop a new
db-adapter). To run it for a specific adapter, prefix with
`PAYLOAD_DATABASE` env var. Can be removed once content-api is complete
if desired.

## Bug Fixes

### Fixed `isValidID` validation

- Now properly checks that value type matches the expected ID type
- Rejects numbers when `type === 'text'` (fixes content-api adapter
validation)
- Only accepts numbers when `type === 'number'`
- Added explicit `return false` for invalid cases

## Test Improvements

### Refactored "Schema generation" tests

Moved conditional logic inside `it()` blocks instead of wrapping them,
preventing "no tests found" errors with new db-adapters.

### Added type casting tests

Tests for automatic type coercion in database adapters:

- String-to-number conversion in `hasMany` number fields
- Date field storage and retrieval as ISO strings
- Unix timestamp to ISO string conversion

See rationale in the comments of [this
PR](https://github.com/payloadcms/enterprise-plugins/pull/300).

### Configured Vitest to run tests from `src/` instead of `dist/`

This was very difficult with Jest and eliminates the need to build in
watch mode during development.

---

**Note:** Current setup is designed for local content-api and db-adapter
development. Small modifications will be made in the future to run these
tests in CI against a deployed staging content-api URL.
2026-01-16 15:19:25 -05:00
Vincent Vu 591f9d22c5 fix(deps): bump undici to 7.18.2 to mitigate chained decompression (#15221)
Addresses a vulnerability in undici fetch decompression handling where
unbounded Content-Encoding chains could cause excessive CPU and memory
usage.

CVE-2026-22036
2026-01-16 13:46:14 -05:00
Sasha 49c9fa9757 fix: select in findByID with draft: true may return a wrong version (#14742)
Fixes https://github.com/payloadcms/payload/issues/14589
This was caused by the fact that our `replacwWithDraftIfAvailable`
function expects to have `createdAt` and `updatedAt` fields and when the
user doesn't select `createdAt` and `updatedAt` fields the logic breaks.
Now, specifically for this case we force to select those fields,
however, you still won't receive `createdAt` and `updatedAt` fields to
the final result if they weren't specified in `select`.

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2026-01-16 13:45:09 -05:00