Skip to content

Conversation

@AlessioGr
Copy link
Member

@AlessioGr AlessioGr commented Nov 17, 2025

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. 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:

Screenshot.2025-11-19.at.00.31.34.mp4

After:

Screenshot.2025-11-19.at.00.33.24.mp4

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

📊 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

📊 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)

@github-actions
Copy link
Contributor

github-actions bot commented Nov 17, 2025

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 764.49 KB ✅ -38 B (-0.0%)
packages/payload/meta_index.json esbuild/index.js 1.23 MB ⚠️ +1.40 KB (+0.1%)
packages/payload/meta_shared.json esbuild/exports/shared.js 163.07 KB ✅ No change
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 279.81 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.15 MB ✅ No change
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 14.39 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████ }}}$ 80.2%, 609.13 KB
dist/views/Version ${{\color{Goldenrod}{ █▋ }}}$ 6.6%, 50.19 KB
dist/views/Document ${{\color{Goldenrod}{ ▌ }}}$ 2.0%, 15.51 KB
dist/views/List ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 11.27 KB
dist/views/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 8.97 KB
dist/views/API ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 5.98 KB
dist/views/Versions ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 5.96 KB
dist/elements/Nav ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 5.53 KB
dist/views/Account ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 5.32 KB
dist/elements/DocumentHeader ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 4.81 KB
dist/views/Login ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 4.39 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 3.69 KB
dist/views/ForgotPassword ${{\color{Goldenrod}{ }}}$ 0.4%, 3.09 KB
dist/layouts/Root ${{\color{Goldenrod}{ }}}$ 0.4%, 2.91 KB
dist/templates/Default ${{\color{Goldenrod}{ }}}$ 0.4%, 2.83 KB
dist/views/CreateFirstUser ${{\color{Goldenrod}{ }}}$ 0.4%, 2.76 KB
dist/views/BrowseByFolder ${{\color{Goldenrod}{ }}}$ 0.3%, 2.60 KB
dist/views/CollectionFolders ${{\color{Goldenrod}{ }}}$ 0.3%, 2.46 KB
dist/views/ResetPassword ${{\color{Goldenrod}{ }}}$ 0.3%, 2.41 KB
dist/views/Logout ${{\color{Goldenrod}{ }}}$ 0.3%, 1.92 KB
(other) ${{\color{Goldenrod}{ ████▉ }}}$ 19.8%, 150.68 KB

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▏ }}}$ 68.7%, 841.09 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 43.31 KB
dist/collections/operations ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 37.27 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.32 KB
dist/queues/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 12.11 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 12.07 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 11.95 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 11.83 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.21 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.38 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.07 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.88 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.74 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.59 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 6.27 KB
dist/collections/endpoints ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 6.00 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 5.54 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.50 KB
dist/auth/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 5.42 KB
dist/utilities/telemetry ${{\color{Goldenrod}{ }}}$ 0.4%, 5.31 KB
(other) ${{\color{Goldenrod}{ ███████▊ }}}$ 31.3%, 382.97 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▉ }}}$ 79.5%, 126.93 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▌ }}}$ 6.4%, 10.21 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 2.48 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.28 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 943 B
dist/folders/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 916 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.4%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.4%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.4%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 525 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.3%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.3%, 413 B
dist/utilities/formatLabels.js ${{\color{Goldenrod}{ }}}$ 0.2%, 380 B
dist/utilities/appendUploadSelectFields.js ${{\color{Goldenrod}{ }}}$ 0.2%, 360 B
dist/utilities/transformColumnPreferences.js ${{\color{Goldenrod}{ }}}$ 0.2%, 348 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.5%, 32.80 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▏ }}}$ 12.6%, 34.91 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.5%, 31.68 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▏ }}}$ 8.8%, 24.36 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▏ }}}$ 8.6%, 23.70 KB
dist/packages/@lexical ${{\color{Goldenrod}{ █▋ }}}$ 6.9%, 18.99 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.5%, 18.04 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▌ }}}$ 6.4%, 17.75 KB
dist/features/upload ${{\color{Goldenrod}{ █▎ }}}$ 5.0%, 13.69 KB
dist/features/textState ${{\color{Goldenrod}{ █ }}}$ 4.0%, 11.08 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 8.96 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 8.22 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 7.39 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.12 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.04 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.00 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.46 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.17 KB
dist/lexical/theme ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.62 KB
dist/features/indent ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.50 KB
(other) ${{\color{Goldenrod}{ █████████████████████▊ }}}$ 87.4%, 241.64 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████▌ }}}$ 50.0%, 572.85 KB
dist/elements/FolderView ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 29.18 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 27.14 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 16.85 KB
dist/views/Edit ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 16.08 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 15.74 KB
dist/elements/Table ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.46 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 15.13 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 14.06 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 12.89 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 8.75 KB
dist/providers/Folders ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.49 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.46 KB
dist/elements/LivePreview ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 8.38 KB
dist/elements/ListHeader ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 7.83 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 7.81 KB
dist/fields/Array ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 7.55 KB
dist/views/CollectionFolder ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.37 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.31 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 6.96 KB
(other) ${{\color{Goldenrod}{ ████████████▌ }}}$ 50.0%, 572.58 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ █████▋ }}}$ 22.6%, 3.12 KB
../../node_modules ${{\color{Goldenrod}{ ████▊ }}}$ 19.2%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██▊ }}}$ 11.0%, 1.52 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ ██▍ }}}$ 9.6%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▌ }}}$ 6.2%, 862 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █▍ }}}$ 5.9%, 814 B
dist/utilities/api.js ${{\color{Goldenrod}{ █▍ }}}$ 5.5%, 756 B
dist/elements/Translation ${{\color{Goldenrod}{ ▉ }}}$ 3.6%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 440 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 339 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 168 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 159 B
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 147 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 146 B
dist/utilities/hasSavePermission.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 136 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 129 B
dist/utilities/findLocaleFromCode.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 84 B
dist/utilities/sanitizeID.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 77 B
dist/utilities/isEditing.js ${{\color{Goldenrod}{ }}}$ 0.4%, 59 B
(other) ${{\color{Goldenrod}{ ███████████████████▎ }}}$ 77.4%, 10.68 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

AlessioGr added a commit that referenced this pull request Nov 18, 2025
…lel operations (#14652)

## Description

Fixes `MongoExpiredSessionError` caused by transaction state mutation
leaking between parallel operations in `renderDocument`.

## Problem

When `renderDocument` runs `getDocumentPermissions` and `getIsLocked` in
parallel via `Promise.all`, they share the same `req` object. When
`getDocumentPermissions` calls `docAccessOperation` →
`initTransaction()`, it **mutates** `req.transactionID`. This mutation
is visible to `getIsLocked`, which then tries to use the same
transaction. When the permission check completes and commits its
transaction, `getIsLocked` fails with an expired session error.

This is because `getIsLocked` may still be trying to use the
transactionID it (involuntarily) received from `getDocumentPermissions`,
even though `getDocumentPermissions` already closed it.

## Solution

Use `isolateObjectProperty(req, 'transactionID')` to give each parallel
operation its own isolated transaction state. This creates a Proxy where
`transactionID` mutations go to a delegate object instead of the shared
parent req.

```ts
// Before - shared req, mutations leak
await Promise.all([
  getDocumentPermissions({ req }),  // Mutates req.transactionID
  getIsLocked({ req }),             // Sees the mutation → ERROR
])
```

```ts
// After - isolated transactionID
const reqForPermissions = isolateObjectProperty(req, 'transactionID')
const reqForLockCheck = isolateObjectProperty(req, 'transactionID')
await Promise.all([
  getDocumentPermissions({ req: reqForPermissions }),  // Isolated
  getIsLocked({ req: reqForLockCheck }),               // Isolated
])
```

If parent `req` already has a transaction, it's preserved (no isolation
applied).

## Testing

This PR does not include tests, as it's very difficult to reliably test
for this race condition. I reproduced this issue using this PR:
#14631 as it just happened to
speed up `getDocumentPermissions` just enough to trigger this race
condition more often.
@AlessioGr AlessioGr changed the title perf: rewrite permissions calculation perf: up to 5000% faster permissions calculation Nov 19, 2025
@AlessioGr AlessioGr marked this pull request as ready for review November 19, 2025 08:09
/**
* Recursively remove empty objects and false values from an object.
*
* @internal - this function may change or be removed in a minor release.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im not sure this is going to be a pattern we should follow. Not everyone is going to read the jsdocs and I'm not sure we should expect them to for things like this. We should probably be naming our functions differently or something.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the point of export we could rename it as something like INTERNAL_

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that is what I was thinking as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it to a new payload/internal export, which should make it explicit and keep the jsdocs + function names clean

// may cause `shouldNotShowInClientConfigUnlessAuthenticated` to be included in the bundle,
// even though we're never actually sending it to the client.
// We'll need to run this test in production to ensure it passes.
test.skip('should protect field schemas behind authentication', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that we are removing a test that worked(?) before. Should we remove the entire test for now? What are the next steps here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test assumes no sensitive info leaking on the client. But Next.js intentionally leaks sensitive info during development.

=> the way this test worked was never sustainable. If you're unlucky, adding a button to a page could have caused that hidden field to show up on the client

We should add it back, which is why I'm only skipping it, but that would require setting up prebuild again our our test suites (it's broken right now), which wouldn't be easy and is out of scope for this PR

@AlessioGr AlessioGr merged commit 0d9ec91 into main Nov 20, 2025
93 checks passed
@AlessioGr AlessioGr deleted the fix/update-field-access-control-after-save branch November 20, 2025 19:06
@github-actions
Copy link
Contributor

🚀 This is included in version v3.65.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants