This PR deprecates returning `{ state: 'failed' }`, which unnecessarily
inflates the API.
**Reasons:**
- It duplicates existing behavior, making the API and docs more
confusing.
- It creates ambiguity about which approach developers should use.
- It increases maintenance cost.
- Throwing errors is more flexible: for example, cancelling a job from a
task handler currently requires throwing `JobCancelledError`, which
cannot be done via a return value.
# Migration Example
## Before
```ts
import type { TaskConfig } from 'payload'
export const ReturnCustomErrorTask: TaskConfig<'ReturnCustomError'> = {
retries: 0,
slug: 'ReturnCustomError',
inputSchema: [],
outputSchema: [],
handler: ({ input }) => {
return {
state: 'failed',
errorMessage: 'oh no! this failed'
}
},
}
```
## After
```ts
import type { TaskConfig } from 'payload'
export const ReturnCustomErrorTask: TaskConfig<'ReturnCustomError'> = {
retries: 0,
slug: 'ReturnCustomError',
inputSchema: [],
outputSchema: [],
handler: ({ input }) => {
throw new Error('oh no! this failed')
},
}
```
### What?
This PR adds an opt-in `typescript.strictDraftTypes` config flag that
enables strict type safety for draft mode queries. When enabled, `find`
operations with `draft: true` will correctly type required fields as
optional.
```typescript
export default buildConfig({
typescript: {
strictDraftTypes: true, // <-- defaults to false
},
// ...
})
```
### Why?
Support for correct types when creating a draft document was added in
#14271, however, the types returned when querying a document (e.g. via
Local API `find`) with `draft: true` still return incorrect types—many
required fields are possibly null but typed as non-null.
Making this change without a flag would be a breaking change for
existing user code, as any queries using a dynamic `draft` parameter
would start showing required fields as optional, requiring null checks
throughout their codebase. By introducing this behind a flag, users can
opt-in when ready, and it will become the default behavior in v4.0.
### How?
- Added `strictDraftTypes` config flag to `typescript` config object
(defaults to `false`)
- Added a generic `TDraft extends boolean` parameter to `find()` and
`findLocal()`
- Added conditional return types that check both `TDraft` and
`GeneratedTypes extends { strictDraftTypes: true }`
- Created `QueryDraftDataFromCollection` type for draft query results
where:
- `id` is required (always present in query results)
- User-defined required fields are optional (validation skipped for
drafts)
- System fields like `createdAt`, `updatedAt` are optional
- Updated `DraftTransformCollectionWithSelect` to use the new type for
draft queries
- Modified type generation to add `strictDraftTypes: true` to
`GeneratedTypes` when enabled
---------
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
### What?
Support qs-esm array in sort parameter to sort by multiple columns in
REST API
### Why?
To align input of Local API and REST API so that REST API also accepts
array like Local API do.
### How?
Extract parsing/sanitizing of sort input to it's own method which also
accept array.
Added unit and integration test. Updated docs with example.
Fixes#15052
### What?
When adding `beforeDocumentControls` as a custom component to a global,
it does not get picked up by the import map, even when running
`generate:importmap`.
### Why?
`beforeDocumentControls` is missing from `addToImportMap` in
`iterateGlobals`.
### How?
Adds `beforeDocumentControls` to `addToImportMap` in `iterateGlobals`.
While doing this I've used the opportunity to add `Description` to the
globals custom component documentation as it was missing.
Fixes#15035
Co-authored-by: Riley Langbein <rilrom2@hotmail.com>
Fixes https://github.com/payloadcms/payload/issues/15006https://github.com/payloadcms/payload/pull/14337 introduced a next build
warning, because the `eval` around `await import` statements has been
removed.
Unlike jest, vitest is fine with it. But during Next.js build, the
bundler will throw a `"Critical dependency: the request of a dependency
is an expression"` warning.
This PR fixes this warning by adding back the eval statement around the
import. It does not add back the conditional require statement - that
part was only necessary to appease jest.
For vitest, we have to directly call `await import()`. Using eval in
vitest will throw an `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` error.
## Preventing this regression in the future
This PR adds a comment above the eval explaining why it's needed.
Previously this was missing, and we only had a comment explaining why
the `require` was needed.
Additionally, the reason this was not caught in CI was because warnings
during next build did not fail the build script. This PR adds an
additional check in `build-template-with-local-pkgs.ts` that throws an
error if a warning was detected during next build. You can find the CI
failure here:
https://github.com/payloadcms/payload/actions/runs/20402288354/job/58626594247?pr=15007
(CI run from commit that added this check, before the commit that fixed
it)
Now, if this ever happens again, our CI will fail and prevent a
regression.
## More Tests
This PR adds additional database and job queue tests that verify that
the job queue handler and migration file imports work correctly.
This PR adds a base-path test suite, updates the formatAdminURL helper
function.
The helper function allows you to create routes inside the admin panel
that take `basePath` into account when set.
There are a lot of touched files here, most have the same treatment.
## API URL construction changes
Previously we were appending the basePath in the default api
configuration. That is no longer the case and is more predictable to use
the helper function that will generate a correct path/url.
## Admin URL construction changes
The default admin route differed from the default api route, the default
admin route did not prepend the basePath. The reason it did not/should
not, is because `usePathname` from NextJS excludes basePath.
So if we were to prepend it to `routes.admin` and then for example use
`pathname.startsWith(config.routes.admin)` it would always return false
when using a basePath, since `/route` will never start with
`/basePath/route`.
Also, when you do something like `router.push(/adminRoute)` NextJS will
prepend the basePath and push you to `/basePath/adminRoute`. If we
prepended it in the config it would push you to
`/basePath/basePath/adminRoute`.
## Helper function usage
The helper is especially useful for plugin work, this way your plugin
will generate correct urls for users using basePath.
Before we were doing:
```ts
// worked because we prepended basePath to apiRoute
const apiURL = `${serverURL}${apiRoute}/collections/${slug}/${id}`
// never worked since basePath was never prepended to adminRoute
const adminURL = `${serverURL}${adminRoute}/collections/${slug}/${id}`
```
Now we can do:
```ts
import { formatAdminURL } from 'payload/shared'
// Admin url example
const adminURL = formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${slug}/${id}`,
})
// API url example
const apiURL = formatAdminURL({
apiRoute: config.routes.api,
path: `/collections/${slug}/${id}`,
// serverURL, when supplied returns a full url
})
```
If you pass `serverURL` the result will be a full URL. If excluded the
result will be a relative path.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
[RFC Here](https://github.com/payloadcms/payload/discussions/11862).
You can test the feature by running `pnpm dev dashboard` on this branch.
<details>
<summary>Old (obsolete) example</summary>
See the [comment
below](https://github.com/payloadcms/payload/pull/13683#issuecomment-3581457901)
explaining the change in approach we took
https://github.com/user-attachments/assets/96157f83-c5d7-4350-9f31-c014daedb2a8
</details>
### New demo
https://github.com/user-attachments/assets/6c08d8d6-c989-4845-b56f-6d3fbd30b1af
## Future Work
The following improvements are planned but will be added in the future:
- fields: You'll be able to define `fields` that a widget receives,
which will serve as props in the component. Why might this be useful?
Imagine a chart widget that can have a weekly, daily, or yearly view. Or
a "count" widget that shows how many documents there are in a collection
(the collection could be a field).
- A11y (EDITED): Okay, I finally went the extra mile here, and you can
reorder and resize it with the keyboard. The screen reader works,
although there's room for improvement.
- Dashboard presets: we're planning to add the ability to create and
share dashboard presets, similar to how [query
presets](https://payloadcms.com/docs/query-presets/overview) work today.
For example, you could build dashboards that adjust based on a variable,
such as a "daily, weekly, or monthly" interval. You could also create
dashboards tailored to different focus areas, like "marketing, sales, or
product."
It's currently not possible to use `getFieldByPath` to find a field
nested within a block.
For example:
```ts
// This return undefined
const fieldInBlock = getFieldByPath({
path: 'blocks.block2.text2' ,
fields: [
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block1',
fields: [
{
name: 'text1',
type: 'text',
},
],
},
{
slug: 'block2',
fields: [
{
name: 'text2',
type: 'text',
},
],
},
],
}
]
})
```
This is because the function is ignoring block slugs and looping over
each block's fields until one is found. Except they are never found
because the path is not trimmed for the next iteration. If the same
field exists across different blocks, I would also imagine the wrong
schema could get matched.
#### What?
Improves upload security by closing validation gaps for `PDF` and `SVG`
files, preventing malicious files from bypassing MIME type restrictions.
#### Why?
- **PDFs**: Corrupted PDFs with a manipulated content type could bypass
detection when no `fileTypeFromBuffer` was undefined, allowing
unauthorized file types pass.
- **SVGs**: SVGs can contain malicious scripts that execute when
rendered, currently we do not check the SVG for anything potentially
harmful
#### How?
- Added extension validation: If the PDF returns no buffer, we have an
additional check on the `ext` which closes the validation gap
- Added SVG sanitization: New `validateSvg` utility checks for attack
elements including scripts, event handlers, foreign objects, iframes etc
## Summary
Adds HTTP Range request support (RFC 7233) to Payload's core file
serving, enabling video streaming with scrubbing/seeking capabilities in
browsers.
## Changes
- **Added `Accept-Ranges: bytes` header** to all file responses
- **Created `parseRangeHeader` utility** for parsing Range headers using
`range-parser` package
- **Enhanced `streamFile` function** to support byte range streaming via
`fs.createReadStream` options
- **Added integration tests** covering various range request scenarios
## Technical Details
- Uses `range-parser` library for RFC 7233 compliant parsing
- Supports single byte ranges (e.g., `bytes=0-1023`)
- Handles open-ended ranges (e.g., `bytes=1024-`)
- Handles suffix ranges (e.g., `bytes=-512`)
- Multi-range requests return first range only (standard simplification)
## Testing
Added comprehensive integration test suite covering:
- Full file requests with Accept-Ranges header
- Partial content requests (206 responses)
- Invalid range handling (416 responses)
- Response body size verification
- Edge cases (out-of-bounds, malformed headers)
### Before
https://github.com/user-attachments/assets/065060ae-35db-4c3d-bc72-cfb976b57349
### After
https://github.com/user-attachments/assets/b70caa49-e055-47b2-87f8-31f02a42c86a
Adds a `relative` flag to the `formatAdminURL` util.
There are cases where this function can produce a fully qualified URL,
like for client-side routing, and other times when it must remain
relative, like when matching routes.
This flag differentiates these two behaviors declaratively so we don't
have to rely on the omission of `serverURL`.
```ts
const result = formatAdminURL({
adminRoute: '/admin',
basePath: '/v1',
path: '/collections/posts',
serverURL: 'http://payloadcms.com',
relative: true,
})
// returns '/v1/admin/collections/posts'
```
Related: https://github.com/payloadcms/payload/pull/14919 and
https://github.com/payloadcms/payload/pull/14907
### What?
Updates the `previousValue` data sent to the `afterChange` hook to
accomodate **lexical nested** paths.
### Why?
Currently the `afterChange` hook uses the previousDoc:
```ts
previousValue: getNestedValue(previousDoc, pathSegments) ?? previousDoc?.[field.name],
```
However, with fields from within Lexical, the data on the full
`previousDoc` has a complex nesting structure. In this case, we should
be using the `previousSiblingDoc` to only return the sibling data and
easily de-structure the data.
### How?
Checks if sibling data exists, and uses it if it does:
```ts
const previousValData =
previousSiblingDoc && Object.keys(previousSiblingDoc).length > 0
? previousSiblingDoc
: previousDoc
```
**Reported by client.**
Related PR: https://github.com/payloadcms/payload/pull/14582
---------
Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
Fixes issues with basePath and NextJS. Many of the links created in the
admin panel were not passing a `serverURL` through to the
`formatAdminURL` helper.
This PR adds all the missing `serverURL` instances and adds a new helper
`formatApiURL` that allows for similar route creation but for api
routes.
Refactors all auth checks to throw `UnauthorizedError` instead of the
generic `Error('Unauthorized')`. This ensures consistent unauthorized
errors being thrown
Replaces direct checks of `collectionConfig.versions?.drafts` with the
`hasDraftsEnabled` utility function for consistency across document
permissions, update operations, and duplicate document functionality.
## Overview
Fixes filename collision detection to respect storage adapter
prefix/suffix patterns. When using cloud storage with prefixes (e.g.,
multi-tenant scenarios), files with identical names under different
prefixes were incorrectly
triggering deduplication.
Resolves#14561
## Changes
### Core Upload Logic
- **`docWithFilenameExists`**: Now accepts and checks `prefix` parameter
in WHERE clause, scoping collision detection to prefix namespace
- **`getSafeFileName`**: Accepts `prefix` parameter and passes to
collision check
- **`generateFileData`**: Extracts `prefix` from data (set by storage
plugins) and threads through to collision detection
### Test Coverage
- **New collection `MediaWithDynamicPrefix`**: Tests multi-tenant
scenario with hook-based prefix assignment
- **Updated `MediaWithPrefix`**: Added `prefix` field and
`filenameCompoundIndex: true`
- **Comprehensive test suite**: Validates collision within prefix, no
collision across prefixes, backward compatibility for collections
without prefix, and dynamic prefix from hooks
### What?
Fixes a bug where saving an upload document would fail with `"[object
Object]" is not valid JSON` error after changing the file and then
modifying another field.
### Why?
When a file was changed in an upload document and saved, the File object
would persist in the client-side form state.
On the next save (when modifying a different field), the form state
would be deep copied and serialized before submission. This
serialization converted the File object into a plain object `{name,
size, type, ...}`, which was then stringified to `"[object Object]"` and
sent to the server, causing a JSON parsing error.
### How?
1. **Preserve File objects during serialization**
- Added instanceof File check in
`deepCopyObjectSimpleWithoutReactComponents` to return File objects
as-is instead of serializing them to plain objects
- Similar to how Date objects are already handled
2. **Clear file field after successful save**
- Delete the `file` field from form state after save in upload
collections
- Prevents stale File objects from persisting between saves
I could of just added an instanceof File check in
[createFormData](https://github.com/payloadcms/payload/blob/1340818097c90904ff9e0facb5b30329bbeb430e/packages/ui/src/forms/Form/index.tsx#L528)
but it would be just a bandaid fix. If a non-File object reaches that
code, it indicates a bug elsewhere that should fail loudly. The root
causes were:
- File objects being incorrectly serialized during form submission
- File objects persisting in form state after they should be cleared
Fixes#14814
### What?
Adds `groupBy` field support to query presets. Presets now save, apply,
and clear groupBy state when switching between presets.
### Why?
Previously, `groupBy` settings would persist when switching between
presets. GroupBy was not being saved as part of the preset
configuration, so users couldn't save their preferred grouping with a
preset.
### How?
Added `groupBy?: string` to the `QueryPreset` type and updated the
preset collection config to include the field.
Modified `QueryPresetBar` to handle groupBy when selecting, deselecting,
resetting, saving, or creating presets.
Created custom Field and Cell components to display groupBy values in
the admin UI.
#### Preset creation with group-by added
<img width="711" height="684" alt="Screenshot 2025-12-03 at 3 15 03 PM"
src="https://github.com/user-attachments/assets/5fe6ee88-6ed7-40f6-b190-df6b2e89460c"
/>
#### Preset collection list view with group-by preset
<img width="2233" height="537" alt="Screenshot 2025-12-03 at 3 15 26 PM"
src="https://github.com/user-attachments/assets/a56645fc-9106-4885-8c54-0e000c429499"
/>
#### Preset creation without group-by
<img width="1250" height="704" alt="Screenshot 2025-12-03 at 3 15 48 PM"
src="https://github.com/user-attachments/assets/3438585b-7aa9-4336-9101-2fab59733480"
/>
### What?
This PR adds the missing `beforeInput` and `afterInput` properties to
the `UploadAdmin` type so that the type definition reflects the actual
supported admin props for upload fields.
### Why?
Although upload fields [already support `beforeInput` and
`afterInput`](https://github.com/payloadcms/payload/blob/fb93cd13bcd66dca3777826fc420a941d3cb75ab/packages/ui/src/fields/Upload/index.tsx#L51)
in practice, these props are currently not included in the `UploadAdmin`
TypeScript type. This leads to type errors or the need for type
assertions when trying to use them. Aligning the type definition with
the existing runtime behavior improves DX and keeps the admin field
types consistent, similar to what was done for RichTextField in #12889.
### How?
Extended the UploadAdmin type to include the `beforeInput` and
`afterInput` properties.
Fixes https://github.com/payloadcms/payload/issues/14726
Refactors how we call beforeOperation hooks more similarly to how we
call afterOperation hooks.
Adds beforeOperations and afterOperations functions to operations.
Both were missing in `unlock`, `findVersions` and `findVersionByID`
Updates the config.versions (on the sanitized config) to be proper.
#### Before
```ts
versions: SanitizedCollectionVersions // not accurate
```
#### After
```ts
versions?: SanitizedCollectionVersions
```
Previously sanitization did not remove the versions key when it was set
to false so the actual type should have been `versions:
SanitizedCollectionVersions | false`.
I chose to remove the versions key if it is false in the sanitization
function so it is easier to work with, i.e. optional chaining.
Also adds importable helper functions:
```ts
import {
getAutosaveInterval,
getVersionsMax,
hasAutosaveEnabled,
hasDraftsEnabled,
hasDraftValidationEnabled,
hasScheduledPublishEnabled,
} from 'payload/shared'
```
Continuation of #14007.
Supports overriding the default slugify function of the slug field. This
is necessary if the slug requires special treatment, such as character
encoding, additional language support, etc.
For example, if you wanted to use the
[`slugify`](https://www.npmjs.com/package/slugify) package, you could do
something like this:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
import slugify from 'slugify';
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField({
slugify: ({ valueToSlugify }) => slugify(valueToSlugify, {
// ...additional `slugify` options here
})
})
]
}
```
This PR also deprecates the old `fieldToUse` arg in favor of `useAsSlug`
which is more semantically clear, following the same convention as
`useAsTitle`.
Example:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField({
useAsSlug: 'myCustomTitle'
})
]
}
```
In follow-up PRs, we should also:
- Improve the default slugify function to better handle special
characters on its own, etc.
- [Support nested
slugs](https://github.com/payloadcms/payload/pull/14783)
### What?
Fix bug of `svg` files throwing an error when `mimeTypes: ['image/*']`
is set.
### Why?
We recently added an additional mime type check on the buffer however
the `file-buffer` package that we use to detect file type does not
support svgs.
### How?
Updates `expectsDetectableType` logic to exclude svgs.
Fixes#14743
## Description
Enables the use of external JSON schema file references in type
generation by adding the `cwd` parameter to
`json-schema-to-typescript`'s `compile()` function.
## Changes
- Added `cwd: process.cwd()` parameter to `generateTypes()` compile call
- Enables `$ref` pointers to external `.json` schema files in
`typescriptSchema` field config
- Added test case with external schema file reference
- Documented external schema reference usage in generating-types.mdx
## Test Coverage
✅ New test: `resolves external schema file references`
- Created `test/types/schemas/custom-type.json` as external schema
- Added field with `typescriptSchema` referencing the external file
- Verified generated types include the external type correctly
## Example Usage
```typescript
// payload.config.ts
{
typescript: {
schema: [
({ jsonSchema }) => {
jsonSchema.definitions.MyType = {
$ref: './schemas/my-type.json'
}
return jsonSchema
},
]
}
}
```
External references are resolved relative to `process.cwd()`.
## Related Issue
This was identified during investigation of JSON Schema parser
capabilities. The `cwd` parameter is a standard
`json-schema-to-typescript` option that was not being passed.
### What?
Adds augmentable interfaces for the `custom` property on collections and
globals:
- `CollectionCustom`
- `CollectionAdminCustom`
- `GlobalCustom`
- `GlobalAdminCustom`
Also updates `plugin-import-export` to use `CollectionAdminCustom` for
its internal storage.
### Why?
Fields already have `FieldCustom` which plugins can augment for
type-safe custom config. Collections and globals were still using
`Record<string, any>` directly, so there was no way to get proper types
when building plugins that need collection/global-level configuration.
This made it annoying to build well-typed plugins since you'd lose all
type safety and autocomplete at the collection/global level.
### How?
- Added the four interfaces (same pattern as `FieldCustom` - empty
interfaces extending `Record<string, any>`)
- Updated the type definitions in `collections/config/types.ts` and
`globals/config/types.ts` to use them
- Updated `plugin-import-export` to augment `CollectionAdminCustom` so
it gets proper types for the `disabledFields` array it stores
Fixes#14724
### What?
Added execution of `beforeLogin` and `afterLogin` hooks during the
password reset operation in `resetPasswordOperation()`. These hooks now
execute with the same parameters and flow as they do in the standard
login operation, ensuring consistent behavior across both authentication
paths.
- `packages/payload/src/auth/operations/resetPassword.ts` - Added hook
execution before and after JWT token generation.
### Why?
When a user resets their password, the operation performs two phases:
updating the password in the database, then automatically logging the
user in with a new session and JWT token. However, `beforeLogin` and
`afterLogin` hooks were not being triggered during this auto-login
phase, creating inconsistent behavior compared to standard login.
This caused issues for applications that uses `afterLogin` hooks to
attach additional cookie (for example).
### How?
Triggered `beforeLogin` and `afterLogin` in `resetPassword.ts`
### What?
Updates the `restoreVersion` logic to ensure restoring as a draft does
not overwrite the currently published data.
### Why?
If you have a published doc, then save a draft, you expect to be able to
access the published data by `draft: false` and the draft by
`draft:true`. When you restore a version as a draft, the same behavior
is expected.
However, in the `restoreVersion` operation, we are updating the main doc
even if you are restoring as a draft. This overwrites the published
data, so now you will see draft data returned regardless of querying
with `draft: true/false`.
### How?
When restoring a version as a draft, we only need to save a new version.
The main doc should only be updated when restoring as published.
This change checks `(!draftArg)` before running the `updateOne` on the
main doc.
#### Reported by client.
`payload.countVersions` was not passing the locale through, so it was
not possible to query on localized fields without querying on the
localized path. This differed from how the find operation works.
In large projects, calculating the `permissions` object is one of the
slowest parts of Payload. This PR completely rewrites the permission
calculation system to make it significantly faster and more reliable.
The permissions object is calculated multiple times throughout Payload's
lifecycle: for the entire config every time we load or navigate to any
admin panel page, on every API endpoint call, and as part of query path
validation when calling any Payload operation.
This is done by `getAccessResults` without any document data, and in
large configs can be one of the slowest operations, noticeably slowing
down admin panel navigation.
After this rewrite, **speed improvements in permissions calculation
without data range from 5.3% (access-control test suite) to 74% (fields
test suite - our largest config)**. The larger the config, the more
noticeable these improvements become. These performance gains apply to
both the Payload Admin Panel and API requests.
---
The `permissions` object can also be calculated WITH document data,
which triggers evaluation of `Where` conditions in
collection/global-level access control and may fetch the document to
pass to access control functions.
This has been heavily optimized with **speed improvements for
permissions calculation with data ranging between 75% and 5138%** (if I
craft a collection config that excessively uses aspects this PR
improves) in the benchmarks. This optimization affects:
- Loading any document (collection/global edit view or drawer)
- Saving a document
- Loading the list view with query presets applied
- Bulk upload initialization (runs twice)
These improvements are most noticeable when navigating _to_ documents in
the admin panel.
## What's Fixed
In addition to performance improvements, the new function is now
cleaner, easier to understand and works more reliably. I specifically
wanna call out two issues that were fixed:
**Field access control data bug**: Added a new e2e test that was
previously failing - when saving a document, the data object passed to
field-level update access control functions had the wrong shape (or in
some cases did not exist at all). This caused fields to show incorrect
`readOnly` states after save. The new implementation properly passes
document data through all access control checks.
**Where query validation**: The old implementation sometimes skipped
validating `Where` queries for collections when `req.data` existed,
potentially granting incorrect access. The new version always validates
where queries correctly when `fetchData: true`.
**Global Where queries:**: Where queries were _never_ executed for
globals. Instead, [we were just checking if the global existed in
general](https://github.com/payloadcms/payload/blob/main/packages/payload/src/utilities/getEntityPolicies.ts#L59).
This PR ensures that `Where` queries returned from global access control
are respected.
## What's Faster (and Why)
**Where query caching:** When multiple operations return identical where
queries (common pattern), we now cache the result. Instead of 3-4
separate DB calls, we make 1 call and reuse it.
**Parallel execution:** All `Where` query evaluations and async access
control functions (for both collections and fields) now execute
concurrently with maximum parallelism, rather than sequentially.
**Optimized DB operations**: When evaluating returned `Where` queries,
we now use direct database `db.count()` queries instead of
`payload.find()` operations. This is much faster (especially on
Postgres).
**Synchronous tree traversal**: The entire field permission tree is now
built synchronously with all async work collected and executed in
parallel at the end, rather than cascading await calls through each
nesting level. This eliminates sequential bottlenecks in deeply nested
field structures.
## Benchmarks
### Admin Panel
- Fields test suite with access control added
- 1000 blocks added to the blocks collection. This is not as excessive
as you would expect:
- most Payload apps do not run on an M3 Max. CPU/DB is often much, much
slower
- this is all local, with a local DB
- a lot of projects accumulate a huge amount of blocks if you multiply
blocks x block fields. They can definitely reach 1000 total blocks
- each block is simple - only one text field per block. In real
projects, blocks are usually a lot more complex
**Before:**
https://github.com/user-attachments/assets/37b55f02-6dbc-4005-9da6-d7cd4bfdc925
**After:**
https://github.com/user-attachments/assets/8dd796dd-66ed-4d32-a688-27b4169577c6
Branch:
https://github.com/payloadcms/payload/tree/fix/update-field-access-control-after-save-benchmarks
### Modified Access-Control Test Suite
`cd test && pnpm payload run access-control/benchmark-permissions.ts`
```md
📊 Benchmark 1: getAccessResults (all collections + globals)
──────────────────────────────────────────────────────────────────────
┌─────────┬───────────────────┬─────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼───────────────────┼─────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (optimized)' │ '16.91' │ '59203.986' │ '±0.52%' │
│ 1 │ 'OLD (previous)' │ '16.07' │ '62323.953' │ '±0.57%' │
└─────────┴───────────────────┴─────────┴───────────────────┴──────────┘
⚡ Speedup: +5.3% 🚀📊 DB Calls per operation (across all collections + globals):
NEW: 0.0 total (0.0 data, 0.0 where)
OLD: 0.0 total (0.0 data, 0.0 where)
📊 Benchmark 2: docAccessOperation (with fetchData)
──────────────────────────────────────────────────────────────────────
Collection: where-cache-same (same where queries)
┌─────────┬────────────────────┬───────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼────────────────────┼───────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (with cache)' │ '2272.81' │ '442.319' │ '±0.11%' │
│ 1 │ 'OLD (no cache)' │ '1019.72' │ '983.347' │ '±0.12%' │
└─────────┴────────────────────┴───────────┴───────────────────┴──────────┘
⚡ Speedup: +122.9% 🚀📊 DB Calls per operation:
NEW: 2.0 total (1.0 data, 1.0 where)
OLD: 4.0 total (1.0 data, 3.0 where)
📊 Benchmark 3: docAccessOperation (with data passed)
──────────────────────────────────────────────────────────────────────
Collection: where-cache-same (same where queries, no DB fetch)
┌─────────┬────────────────────┬───────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼────────────────────┼───────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (with cache)' │ '4945.43' │ '203.252' │ '±0.11%' │
│ 1 │ 'OLD (no cache)' │ '1023.75' │ '979.898' │ '±0.14%' │
└─────────┴────────────────────┴───────────┴───────────────────┴──────────┘
⚡ Speedup: +383.1% 🚀📊 DB Calls per operation:
NEW: 1.0 total (0.0 data, 1.0 where)
OLD: 4.0 total (1.0 data, 3.0 where)
📊 Benchmark 4: docAccessOperation (unique where queries)
──────────────────────────────────────────────────────────────────────
Collection: where-cache-unique (unique where queries per operation)
┌─────────┬────────────────────┬───────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼────────────────────┼───────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (parallel)' │ '1693.67' │ '599.091' │ '±0.29%' │
│ 1 │ 'OLD (sequential)' │ '970.37' │ '1036.268' │ '±0.20%' │
└─────────┴────────────────────┴───────────┴───────────────────┴──────────┘
⚡ Speedup: +74.5% 🚀📊 DB Calls per operation:
NEW: 4.0 total (1.0 data, 3.0 where)
OLD: 4.0 total (1.0 data, 3.0 where)
📊 Benchmark 5: Complex Collection (async access, nested blocks, field access)
──────────────────────────────────────────────────────────────────────
Collection: complex-content (stress test)
┌─────────┬────────────────────┬──────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼────────────────────┼──────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (optimized)' │ '133.17' │ '7683.346' │ '±0.83%' │
│ 1 │ 'OLD (sequential)' │ '72.86' │ '13901.616' │ '±0.82%' │
└─────────┴────────────────────┴──────────┴───────────────────┴──────────┘
⚡ Speedup: +82.8% 🚀📊 DB Calls per operation:
NEW: 2.0 total (1.0 data, 1.0 where)
OLD: 2.0 total (1.0 data, 1.0 where)
📊 Benchmark 6: Sync-Heavy Collection (same where, many sync field access)
──────────────────────────────────────────────────────────────────────
Collection: sync-heavy (where cache + field access)
┌─────────┬────────────────────┬───────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼────────────────────┼───────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (with cache)' │ '3607.72' │ '279.559' │ '±0.15%' │
│ 1 │ 'OLD (no cache)' │ '68.87' │ '15136.557' │ '±1.44%' │
└─────────┴────────────────────┴───────────┴───────────────────┴──────────┘
⚡ Speedup: +5138.5% 🚀📊 DB Calls per operation:
NEW: 1.0 total (0.0 data, 1.0 where)
OLD: 5.0 total (1.0 data, 4.0 where)
══════════════════════════════════════════════════════════════════════
📈 Summary:
══════════════════════════════════════════════════════════════════════
1. getAccessResults: (see above)
2. docAccessOperation (with fetchData): (see above)
3. docAccessOperation (with data passed): (see above)
4. docAccessOperation (unique where): (see above)
5. Complex collection (async + nested): (see above)
6. Sync-heavy (where cache + fields): (see above)
══════════════════════════════════════════════════════════════════════
```
### Fields Test Suite
`cd test && pnpm payload run fields/benchmark-getAccessResults.ts`
```md
📊 Benchmark: getAccessResults (all fields test collections + globals)
──────────────────────────────────────────────────────────────────────
┌─────────┬───────────────────┬───────────┬───────────────────┬──────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ms) │ Margin │
├─────────┼───────────────────┼───────────┼───────────────────┼──────────┤
│ 0 │ 'NEW (optimized)' │ '2104.06' │ '478.382' │ '±0.18%' │
│ 1 │ 'OLD (previous)' │ '1208.14' │ '834.974' │ '±0.21%' │
└─────────┴───────────────────┴───────────┴───────────────────┴──────────┘
⚡ Speedup: +74.2% 🚀📊 DB Calls per operation (across all collections + globals):
NEW: 0.0 total (0.0 data, 0.0 where)
OLD: 0.0 total (0.0 data, 0.0 where)
```
### What?
Prevents unrelated MIME type error being thrown during upload when
`useTempFiles: true`.
### Why?
In [v3.64.0](https://github.com/payloadcms/payload/releases/tag/v3.64.0)
we released a change to check the mimetype on the file buffer. However,
when `useTempFiles` is true, no buffer is passed and this check fails.
### How?
Checks for `!useTempFiles` before returning an error for no buffer data.
Reported by client.
### What?
Fixes `autoRefresh` functionality that was causing "Stay logged in"
modal to appear instead of automatically refreshing the token.
### Why?
Two issues were preventing `autoRefresh` from working:
1. `autoRefresh` was not exposed in the client config, causing it to be
`undefined` in the browser
2. The Auth provider's reminder timeout callback was capturing a stale
`autoRefresh` value due to closure timing during initial component
render
### How?
- Added `autoRefresh` to client config creation to expose it to the
client
- Used `useEffectEvent` in Auth provider to ensure the reminder timeout
handler always captures the latest `autoRefresh` value
### Fixes
#14613
---------
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
### What?
Adds the missing `latest?: boolean` property to the `TypeWithVersion<T>`
type definition.
### Why?
The `latest` property is set by database adapters when creating and
updating versions (both collection and global versions), and is included
in the version field schema when drafts are enabled. However, it was
missing from the TypeScript type definition, causing type errors when
consumers try to access this property on version documents.
### How?
Added `latest?: boolean` as an optional property to the
`TypeWithVersion<T>` type. The property is optional because it's only
present when drafts are enabled in the version configuration.
Fixes#14624
### What?
Ensures `getLocalizedPaths` doesn't falsely detect fields with
locale-like names as a localized field.
### Why?
In the function we are determining if the next segment is a locale based
on whether the segment of the field path matches a locale:
```ts
const nextSegmentIsLocale = localizationConfig && localizationConfig.localeCodes.includes(nextSegment)
```
If you have locale `en` and a nested field with `name: 'en'`, the
function will mistake it for a locale key regardless of whether the
field is localized.
### How?
Uses the existing `fieldShouldBeLocalized` function to check whether the
field is localized in addition to the other checks.
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Relationship fields would fallback even if the parent of the request set
`fallbackLocale: false`. This was because of the logic in
sanitizeFallbackLocale which would transform `false` value into `null`,
and when a relationship populate function ran, it would transform the
`null` into the `localization.defaultLocale` value.
Added int test that failed before and passes after to cover.
When updating a document from a document drawer that isn’t the one
associated with a relationship field, the relationship’s label doesn’t
update correctly.
## Reproduction
This issue is easily reproducible when two relationship fields link to
the same document. If you update the document from the drawer of one
field, the other field will not reflect the update. This happens because
each field only listens to the `onSave` event of its own document
drawer.
## Fix
Use the `useDocumentEvents` hook instead of the `onSave` hook.
`useDocumentEvents` listens for updates to all saved documents, ensuring
that all relationship fields stay in sync regardless of which drawer the
update originated from.
**Before:**
https://github.com/user-attachments/assets/eda152fe-3cda-4111-b0f3-9f0d5a48f37b
**After:**
https://github.com/user-attachments/assets/da252d3d-c0a9-43ee-91d8-508e302d323e
When duplicating documents it should duplicate as draft when drafts are
enabled. It is odd behavior to duplicate a published document and have
the copy immediately be published.
Fixes https://github.com/payloadcms/payload/issues/14377
## Non-public errors were returned from delete and update endpoints
Previously, non-public errors (`500` errors, or errors without
`isPublic: true` and `status`) were returned from the delete and update
endpoint. For security reason, this PR excludes the error messages from
the response.
This aligns bulk endpoints with others (e.g., deleteByID) that already
gate error details through `routeError.ts`.
## Loosen up isPublic check when throwing API error
`APIError` now infers `isPublic` from the status code unless explicitly
provided:
- Non-500 (e.g., 4xx): `isPublic` defaults to `true`.
- 500 or missing status: `isPublic` defaults to `false`.
You can still override by passing a boolean `isPublic` explicitly.
Previously, the only way to mark the error as public was:
`throw new APIError('error message', 401, null ,false)`
which feels unnecessary explicit.
Now, you can do this:
`throw new APIError('error message', 401)`
which previously resulted in `isPublic: false` and now results in
`isPublic: true`.
## Stricter error check in routeError
`routeError` now uses `isErrorPublic(err, config)` to decide whether to
expose the error message. Compared to the old condition (`!config.debug
&& !err.isPublic && status === 500)`, this is a bit stricter and easier
to understand. Errors with `isPublic === false` are now hidden even for
non-500 statuses (previously they were shown).
Adds support for 'UTC' as a value for timezones. This was not previously
supported because we run a check on the values used against the runtime
of Intl API.
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: {
timezones: {
supportedTimezones: [
{
label: 'UTC',
value: 'UTC',
},
// ...other timezones
],
defaultTimezone: 'UTC',
},
},
})
```
Also fixes an issue when having only one timezone making it selected by
default.
### What?
Updates the `previousValue` data sent to the `afterChange` hook to
accomodate nested paths.
### Why?
Currently the `afterChange` hook returns this:
```ts
previousValue: previousDoc?.[field.name],
```
For any nested fields, this breaks because it does not match the
structure of the incoming data.
### How?
Uses the path segments to correctly extract the relevant data.
**Reported by client.**
### What?
Default values were incorrectly appearing (or not appearing) depending
on access control configuration. When access control denied access to a
global or collection document, default values would sometimes still
populate, and conversely, when access was allowed, defaults would
sometimes be blocked.
### Why?
The previous logic checked `args.data` before querying the database,
making it impossible to distinguish between:
1. A document that doesn't exist and access is allowed (should populate
defaults for globals)
2. A document that was filtered by access control (should NOT populate
defaults)
This caused default values like `_status: 'draft'` to appear when access
control should have returned an empty response, and also blocked
legitimate defaults on uncreated globals without access restrictions.
### How?
- **Query first**: Both globals and collections now query the database
before checking `args.data`
- **Detect access denial**: Only return early when `!docFromDB &&
!args.data && !overrideAccess && accessResult !== true`
- **Correct fallback**: Use `args.data ?? docFromDB ?? {}` (globals) or
`args.data ?? docFromDB` (collections)
- **Simplified**: Removed `shouldSkipDefaults` logic from `promise.ts`
since it's now handled at the operation level
Fixes#14493
Fixes https://github.com/payloadcms/payload/issues/14476