AdvancedCustomFields / AdvancedCustomFields/acf
`acf/load_field_groups` is applied on every `acf_get_field_groups()` call, outside the field group cache
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 945
- Forks
- 197
- PR merge metrics
- No merged PRs in 30d
Description
Describe the bug
ACF_Internal_Post_Type::get_posts() applies acf/load_{$hook_name_plural} outside its cache, so every acf_get_field_groups() call re-runs every callback on that filter over every registered field group. The singular path does the opposite: get_post() checks the store first, applies acf/load_{$hook_name} once, and stores the filtered result.
includes/class-acf-internal-post-type.php in 6.8.10, line 381:
public function get_posts( $filter = array() ) {
$posts = array();
// Each of these is already cached, and already filtered, by get_post().
$raw_posts = $this->get_raw_posts();
if ( $raw_posts ) {
foreach ( $raw_posts as $raw_post ) {
$posts[] = $this->get_post( $raw_post['ID'] );
}
}
$posts = apply_filters( "acf/load_{$this->hook_name_plural}", $posts, $this->post_type ); // runs on every call
against line 96, which caches the filtered result:
// Check store.
$store = acf_get_store( $this->store );
if ( $store->has( $id ) ) {
return $store->get( $id ); // filtered result, returned without re-filtering
}
...
$post = apply_filters( "acf/load_{$this->hook_name}", $post );
$store->set( $post['key'], $post );
So the per-group work is cached and the whole-set work is not, even though the whole-set filter is the more expensive place to be.
To Reproduce
- Install ACF on a site with some field groups. No other plugins are needed, and no particular field group content: the number of groups scales the cost, it does not cause it.
- Add the snippet under Code below as a mu-plugin.
- Open any post in the block editor,
/wp-admin/post.php?post=<id>&action=edit. - Read the log:
acf/load_field_groups applied 13 times.
With ACF PRO 6.8.10 as the only active plugin, and 75 field groups, I get 13 applications on that screen. The callers are mostly ACF_Rest_Api::get_field_groups_by_object_type() and get_field_groups_by_id() during the block editor's REST preload, plus ACF_Form_Post::add_meta_boxes(). The exact count varies by screen and by what else is registered, but it is consistently well above one.
Expected behavior
acf/load_field_groups is applied once per request per internal post type, and repeat calls to acf_get_field_groups() reuse the filtered array, in the way get_post() already reuses the filtered single field group.
Screenshots or Video
Not applicable: this is a performance defect rather than a visual one. Profiler figures are under Additional context.
Code
No field group export is needed to reproduce this, but I am happy to attach one if it helps. This is the counting snippet from step 2:
<?php
add_filter( 'acf/load_field_groups', function ( $field_groups ) {
$GLOBALS['n'] = ( $GLOBALS['n'] ?? 0 ) + 1;
return $field_groups;
}, 1 );
add_action( 'shutdown', function () {
error_log( sprintf( '%s: acf/load_field_groups applied %d times', $_SERVER['REQUEST_URI'], $GLOBALS['n'] ?? 0 ) );
} );
Version Information:
- WordPress Version 7.1
- PHP Version 8.1
- ACF Version: ACF PRO 6.8.10
- Browser: not browser-dependent, reproduced with
curland WP-CLI
Additional context
The filter's documented contract invites consumers to do real work in it, and they do. ACFML, WPML's ACF integration, translates every field group's strings on acf/load_field_groups. Because the filter re-runs, that translation runs once per application rather than once per request, and on a list screen the applications scale with the number of rows:
| Rows per page | acf/load_field_groups applications |
|---|---|
| 5 | 10 |
| 10 | 20 |
| 20 (WordPress default) | 40 |
| 50 | 100 |
Two per row, so the cost of a screen is bounded only by the editor's own Screen Options setting. Profiled with Xdebug on a posts list screen:
WPML\FP\Obj::__callStatic: 290,628 calls in a single request, plus 1.14 millioncount()calls, nearly all of it reached through this filter.acf_get_field_groups()inclusive cost: 2,711ms profiled, from 10 calls.
Memoising the filter result for the duration of the request, in a site mu-plugin wrapping ACFML's callback, took that to 681ms profiled, and on the wall clock:
| Screen | Before | After | Change |
|---|---|---|---|
edit.php |
0.723s | 0.497s | −31% |
post.php |
0.848s | 0.607s | −28% |
nav-menus.php |
1.725s | 0.846s | −51% |
Interleaved across three rounds, nine requests per state, against a noise floor of ~0.01s. Rendered output is byte-identical before and after across ten admin and front-end pages in two languages, which is the evidence that the repeated applications were redundant: same input, same output, thrown away and recomputed.
That is a site-level workaround for something only ACF can fix properly, and it only helps sites that have someone maintaining a mu-plugin.
A possible fix: cache the filtered array the way get_post() caches the filtered post, in a request-scoped acf_get_store() keyed by post type, cleared in flush_post_cache(), which already deletes the plural cache key and is already called from update, delete, trash and untrash.
Request-scoped rather than the persistent object cache, because a callback's output can legitimately vary within a request: ACFML's varies by the current language. For that case the key needs to be extensible, something like an acf/load_field_groups/cache_key filter defaulting to the post type, so a multilingual integration can add the language rather than having to opt out of caching altogether. $filter is applied after the hook, in filter_posts(), so it does not belong in the key.
Related: #1028, ACF_Local_JSON::scan_files() re-reading acf-json/ four times per request, is the same class of defect in the same subsystem but a different code path. The two do not compound: with the memoisation above in place, the scan count is unchanged at four.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in includes/class-acf-internal-post-type.php by comparing get_posts() with get_post(), then trace acf_get_field_groups() and flush_post_cache(). Reproduce with the supplied counting mu-plugin and verify that the filtered field-group array is reused within a request, cache invalidation still occurs on updates, and the existing filter behavior remains correct.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php, wordpress
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 64/100