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:
theme.registry(Drupal\Core\Theme\Registry) destructs first. Itsdestruct()callsdestruct()on each runtime instance, which since 11.4 callsreset()and leaves storage empty. But the emptied instances remain referenced inRegistry::$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 }search_api.post_request_indexingdestructs next (PostRequestIndexing::destruct()), indexing content and, with theRenderedItemprocessor, rendering entities after the theme registry has already been emptied.- 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()]; } - With an empty storage,
has()returnsFALSEfor every hook, andThemeManager::render()logsTheme hook %hook not foundfor 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
- Install Drupal 11.4.x with the Search API module enabled, configured to index content on cron/post-request with the
RenderedItemprocessor enabled on at least one field. - 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).
- Observe the watchdog/dblog log (
/admin/reports/dblog) for entries such asTheme hook field not found,Theme hook username not found,Theme hook time not found,Theme hook image_formatter not found. - Inspect the rendered/indexed output for the affected entity: fields render empty because the theme hooks failed to resolve.
- 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
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
Comment #2
eduardo morales albertiComment #4
eduardo morales albertiCreated MR https://git.drupalcode.org/project/drupal/-/merge_requests/16251/diffs
Comment #6
eduardo morales albertiComment #7
eduardo morales albertiComment #8
eduardo morales albertiComment #9
eduardo morales albertiComment #10
eduardo morales albertiFailed PHPUnit test, probably not related:
Comment #11
eduardo morales albertiTests are now stable on MR
Comment #12
quietone commentedHi, 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.
Comment #13
eduardo morales albertiHi @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.
Comment #16
eduardo morales albertiCreate MR and update the description as suggested
Comment #17
eduardo morales albertiUpdate 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...
Comment #19
eduardo morales albertiPHPUnit tests not working against main branch
Comment #20
eduardo morales albertiSeems like the same error as before, rerunning pipeline, waiting for tests
Comment #21
eduardo morales albertiThe pipeline passed with warnings (not related to these changes), ready to review
Comment #22
nagy.balint commentedI ran into the same issue today.
MR #16253 fixed the issue for me.
Comment #24
nicxvan commentedI 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.