`acf/load_field_groups` is applied on every `acf_get_field_groups()` call, outside the field group cache
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 64/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Domain
- backend, performance
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.
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.
- Dominant language
- PHP
- Stars
- 947
- Forks
- 198
- PR merge metrics
- No merged PRs in 30d
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
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.
More from AdvancedCustomFields/acf
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
AdvancedCustomFields/acf#1056 ·
-
PHP 8.5: "Using null as an array offset" deprecation in ACF_Data::is() when rendering a block with empty data (local-meta.php is_request)Possibly taken @faisalahammad claimed this 30 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
AdvancedCustomFields/acf#1044 ·
-
is_preview was called incorrectly notice on every early get_field() since 6.8.10 (acf_revisions::acf_validate_post_id)Possibly taken @faisalahammad claimed this 30 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
AdvancedCustomFields/acf#1041 · 1 comment ·
-
True / False ACF control bug on RTL layoutsPossibly taken @elicohenator claimed this 53 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
AdvancedCustomFields/acf#1038 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
AdvancedCustomFields/acf#1034 · 3 comments ·
All issues in AdvancedCustomFields/acf
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 3 days
-
sync-en
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
[Sync EN] Formatting improvements to "Large Objects (LOBs)" docs (#6056)Possibly taken A pull request linked to this issue is open or already merged. Opensync-en
Difficulty 1/5 Under an hour Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
Перевод устарел
Difficulty 2/5 1-3 hours Newbie friendliness 73/100
Maintainers usually reply within 1 day
-
repo:cms
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
craftcms/cms#20613 · 1 comment ·
Maintainers usually reply within 1 day