Problem/Motivation

Upgrading from Drupal 11.3 to 11.4 introduces a regression where theme hooks intermittently stop resolving, producing log entries such as:

[theme] Theme hook field not found.
[theme] Theme hook username not found.
[theme] Theme hook time not found.
[theme] Theme hook image_formatter not found.

This happens for content rendered after the theme registry has already been destructed for the current request, most commonly triggered by search_api post-request indexing (PostRequestIndexing::destruct()) rendering entities with the RenderedItem processor.

Root cause

The regression comes from #3486503 ("Add a file parsing cache collector to replace some uses of FileCache"), core commit fd5a6fdad51 (cherry-pick of 0be9248e5c0). It added a call to reset() inside the shared destruct() method of all cache collectors:

core/lib/Drupal/Core/Cache/CacheCollector.php (~line 312)
// Drupal 11.3.13:

public function destruct() {
  $this->updateCache();
}

// Drupal 11.4.x:

public function destruct() {
  $this->updateCache();
  $this->reset();   // <-- new line: empties $this->storage
}

reset() empties the collector (core/lib/Drupal/Core/Cache/CacheCollector.php:289):

public function reset() {
  $this->storage = [];
  $this->keysToPersist = [];
  $this->keysToRemove = [];
  $this->cacheLoaded = FALSE;
}

Note: #3587707 (lazy-loading of the ThemeRegistry, also landed in 11.4) initially looked like the likely culprit because it rewrote that class, but instrumentation ruled it out: every construction of the registry still ends up with the full storage populated.

Why this breaks the theme registry

Drupal maintains a "runtime theme registry" (Drupal\Core\Utility\ThemeRegistry), which is a CacheCollector. Its invariant is that the constructor always leaves $this->storage populated with every theme hook key, so its has() and get() methods read storage directly, without self-healing:

core/lib/Drupal/Core/Utility/ThemeRegistry.php (~line 90)

public function has($key) {
  // Direct check: trusts that the constructor already filled storage.
  return \array_key_exists($key, $this->storage);
}

The base CacheCollector does self-heal after a reset() (its get() calls lazyLoadCache() and resolveCacheMiss()), but ThemeRegistry overrides both methods without that safety net.

The failure sequence happens at the end of the request, in DrupalKernel::terminate(), which calls destruct() on "destructable" services in order:

  1. theme.registry (Drupal\Core\Theme\Registry) destructs first. Its destruct() calls destruct() on each runtime instance, which since 11.4 calls reset() and leaves storage empty. But the emptied instances remain referenced in Registry::$runtimeRegistry:
    core/lib/Drupal/Core/Theme/Registry.php (~line 968)
    public function destruct() {
      foreach ($this->runtimeRegistry as $runtime_registry) {
        $runtime_registry->destruct();   // updateCache() + reset() -> storage = []
      }
      // ...nothing clears $this->runtimeRegistry here
    }
  2. search_api.post_request_indexing destructs next (PostRequestIndexing::destruct()), indexing content and, with the RenderedItem processor, rendering entities after the theme registry has already been emptied.
  3. That render requests the registry via Registry::getRuntime(), which returns the old, emptied instance instead of building a new one:
    // core/lib/Drupal/Core/Theme/Registry.php (~line 365)
    public function getRuntime() {
      $this->init($this->themeName);
      if (!isset($this->runtimeRegistry[$this->theme->getName()])) {  // still set -> not rebuilt
        $this->runtimeRegistry[...] = new ThemeRegistry(...);
      }
      return $this->runtimeRegistry[$this->theme->getName()];
    }
  4. With an empty storage, has() returns FALSE for every hook, and ThemeManager::render() logs Theme hook %hook not found for each one (core/lib/Drupal/Core/Theme/ThemeManager.php:171), and the indexed content is rendered empty.

On 11.3.13 the same sequence was harmless because destruct() never emptied anything: the instance stayed complete even when reused.

Steps to reproduce

  1. Install Drupal 11.4.x with the Search API module enabled, configured to index content on cron/post-request with the RenderedItem processor enabled on at least one field.
  2. Trigger a request that both renders the "main" theme registry (e.g. loading any page) and causes Search API post-request indexing to run at the end of that same request (e.g. saving/editing an indexed entity).
  3. Observe the watchdog/dblog log (/admin/reports/dblog) for entries such as Theme hook field not found, Theme hook username not found, Theme hook time not found, Theme hook image_formatter not found.
  4. Inspect the rendered/indexed output for the affected entity: fields render empty because the theme hooks failed to resolve.
  5. Downgrade to 11.3.13 (or revert the reset() call added in #3486503) and repeat: the warnings disappear and rendering is correct.

Proposed resolution

Minimal fix in Registry::destruct(): after destructing the runtime instances, drop them from the map so that any later render rebuilds a complete registry (cheap, since the full registry definition is still cached in memory). This mirrors what Registry::reset() already does:

core/lib/Drupal/Core/Theme/Registry.php

public function destruct() {
  foreach ($this->runtimeRegistry as $runtime_registry) {
    $runtime_registry->destruct();
  }
  // The instances were emptied by CacheCollector::destruct();
  // drop them so getRuntime() builds a fresh, complete registry.
  $this->runtimeRegistry = [];
}

This is safe because no code caches the instance itself: all three core consumers (ThemeManager::render(), ThemeRegistryLoader, EntityViewBuilder) call getRuntime() on every use.

Why the alternative fix (fallback in has()) doesn't work

Making has() fall back to checking the complete registry removes the warnings, but only moves the problem: get() and getPreprocessInvokes() would still read the empty storage and return NULL, causing TypeError: getPreprocessInvokes(): Return value must be of type array, null returned. The invariant needs to be restored at construction time, not patched read by read.

User interface changes

None.

Introduced terminology

None.

API changes

Drupal\Core\Theme\Registry::destruct() now clears $this->runtimeRegistry after destructing its runtime instances, so a subsequent call to getRuntime() within the same request rebuilds a fresh instance instead of reusing an emptied one.

Data model changes

None.

Description and MR helped by AI (Claude)

Issue fork drupal-3609212

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

eduardo morales alberti’s picture

Version: 11.4.x-dev » 11.x-dev

eduardo morales alberti changed the visibility of the branch 3609212-runtime-themeregistry-left to hidden.

eduardo morales alberti’s picture

eduardo morales alberti’s picture

Status: Active » Needs review
eduardo morales alberti’s picture

Issue summary: View changes
eduardo morales alberti’s picture

Issue summary: View changes
eduardo morales alberti’s picture

Issue summary: View changes
eduardo morales alberti’s picture

Failed PHPUnit test, probably not related:

--------------------------------------------------------------------------------------------------------
Error         4.999s testInstallProfileConfigOverwrite                                               
Failure              *** Process execution output ***                                                
    PHPUnit 11.5.55 by Sebastian Bergmann and contributors.
    
    Runtime:       PHP 8.5.8
    Configuration: /builds/issue/drupal-3609212/core/phpunit.xml.dist
    
    E                                                                   1 / 1 (100%)
    
    Time: 00:05.356, Memory: 10.00 MB
    
    Config Install Profile Override (Drupal\Tests\config\Functional\ConfigInstallProfileOverride)
     ✘ Install profile config overwrite
       ┐
       ├ Error: Call to a member function label() on null
       │
       │ /builds/issue/drupal-3609212/core/modules/config/tests/src/Functional/ConfigInstallProfileOverrideTest.php:141
       ┴
    
    ERRORS!
    Tests: 1, Assertions: 23, Errors: 1.
---- Drupal\Tests\config_translation\Functional\ConfigTranslationInstallTest ----
eduardo morales alberti’s picture

Tests are now stable on MR

quietone’s picture

Version: 11.x-dev » main
Assigned: eduardo morales alberti » Unassigned

Hi, thanks for bringing this up and making an MR!

I've only read the issue summary and the issue meta data.

Hi, the issue summary reads like it was generated using an AI tool, and maybe the MR was as well. The policy on the use of AI when contributing to Drupal includes that the use of AI must be disclosed. I suggest reading that to be aware of the expectations of the Drupal community and the consequences for violations of the policy.

Also, issues for Drupal core should be targeted to the 'main' branch, our primary development branch. Changes are committed on the main branch first, and are then back ported as needed according to the Core change policies. The details of the version this was discovered on should be given in the issue summary.

A release note snippet isn't needed here. They are typically for major and critical issues. It is the text that will be in the release notes when a release is created that includes the fix. So, that content can be removed from the issue summary.

Un-assigning per Assigning ownership of a Drupal core issue.

eduardo morales alberti’s picture

Issue summary: View changes

Hi @quietone, yes, IA was used to analyze the regression discovered on our CI after the upgrade to D 11.4, and write the issue description to be as accurate as possible, but it was reviewed and tested manually before posting it.

eduardo morales alberti’s picture

Create MR and update the description as suggested

eduardo morales alberti’s picture

Issue summary: View changes

Update the summary as it was helped by Claude Code to follow the policy from https://www.drupal.org/docs/develop/issues/issue-procedures-and-etiquett...

eduardo morales alberti’s picture

Status: Needs review » Needs work

PHPUnit tests not working against main branch

eduardo morales alberti’s picture

Status: Needs work » Needs review

Seems like the same error as before, rerunning pipeline, waiting for tests

eduardo morales alberti’s picture

The pipeline passed with warnings (not related to these changes), ready to review

nagy.balint’s picture

I ran into the same issue today.
MR #16253 fixed the issue for me.

nicxvan changed the visibility of the branch 3609212-runtime-themeregistry to hidden.

nicxvan’s picture

Status: Needs review » Needs work

I hid the extra MR.

I'm a little confused by the suggested cause.

I reviewed https://git.drupalcode.org/project/drupal/-/commit/dfa3792d4781d727154bc... again, the cache destruct calls reset, but reset clears that property already.

I think this is the correct fix, but I'm not putting together the pieces.