AdvancedCustomFields / AdvancedCustomFields/acf

`acf/load_field_groups` is applied on every `acf_get_field_groups()` call, outside the field group cache

Open
#1,037 0 comments 0 reactions 0 assignees View on GitHub

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

  1. 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.
  2. Add the snippet under Code below as a mu-plugin.
  3. Open any post in the block editor, /wp-admin/post.php?post=<id>&action=edit.
  4. 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 curl and 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 million count() 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.