Repository: backdrop/backdrop
Pull request #5050: Issue #6859: Allow attaching assets with hook_theme.
Author: login: laryn
Created: 2025-04-16T18:20:32Z | Merged: 2025-05-03T02:14:31Z

Fixes https://github.com/backdrop/backdrop-issues/issues/6859


Diff:
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 0433cdf8b90..49e0c5ee236 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1167,6 +1167,11 @@ function theme($hook, $variables = array()) {
     }
   }
 
+  // Attach any assets that were specified in the theme hook.
+  if (!empty($info['attached'])) {
+    $elements['#attached'] = $info['attached'];
+    backdrop_process_attached($elements);
+  }
   // Generate the output using either a function or a template.
   $output = '';
   if (isset($info['function'])) {
diff --git a/core/modules/date/date.module b/core/modules/date/date.module
index 63e5a33f71c..5d9a5132295 100644
--- a/core/modules/date/date.module
+++ b/core/modules/date/date.module
@@ -229,6 +229,11 @@ function date_theme($existing, $type, $theme, $path) {
       // Register a pattern so that it can work like all views templates.
       'pattern' => 'date_views_pager__',
       'template' => 'templates/date-views-pager',
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'date') . "/css/date-views.css" => array(),
+        ),
+      ),
     ) + $base,
   );
 
diff --git a/core/modules/date/date.theme.inc b/core/modules/date/date.theme.inc
index 96c0dbf7047..11cc305f8ee 100644
--- a/core/modules/date/date.theme.inc
+++ b/core/modules/date/date.theme.inc
@@ -508,8 +508,6 @@ function date_preprocess_views_view(&$variables) {
  * Preprocess function for Date pager template.
  */
 function template_preprocess_date_views_pager(&$variables) {
-  backdrop_add_css(backdrop_get_path('module', 'date') . "/css/date-views.css");
-
   $plugin = $variables['plugin'];
   $input = $variables['input'];
   $view = $plugin->view;
diff --git a/core/modules/installer/installer.module b/core/modules/installer/installer.module
index ec046aa1623..25e983889ff 100644
--- a/core/modules/installer/installer.module
+++ b/core/modules/installer/installer.module
@@ -179,6 +179,11 @@ function installer_theme($existing, $type, $theme, $path) {
     'installer_browser_list' => array(
       'variables' => array('projects_list' => NULL, 'type' => NULL),
       'template' => 'templates/installer-list',
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'installer') . '/css/installer.css' => array(),
+        ),
+      ),
     ) + $base,
     // Template for block.
     'installer_browser_block' => array(
diff --git a/core/modules/installer/installer.theme.inc b/core/modules/installer/installer.theme.inc
index deb2cfeb05a..437bcf0e243 100644
--- a/core/modules/installer/installer.theme.inc
+++ b/core/modules/installer/installer.theme.inc
@@ -62,7 +62,6 @@ function template_preprocess_installer_browser_block(&$variables) {
  */
 function template_preprocess_installer_browser_list(&$variables) {
   module_load_include('inc', 'installer', 'installer.browser');
-  backdrop_add_css(backdrop_get_path('module', 'installer') . '/css/installer.css');
 
   if (is_array($variables['projects_list']) && !empty($variables['projects_list'])) {
     $content = '';
diff --git a/core/modules/path/path.admin.inc b/core/modules/path/path.admin.inc
index cb574bfdfd6..928473ec957 100644
--- a/core/modules/path/path.admin.inc
+++ b/core/modules/path/path.admin.inc
@@ -736,8 +736,6 @@ function path_bulk_update_form($form, &$form_state) {
  * @ingroup themeable
  */
 function theme_path_bulk_update_form($variables) {
-  backdrop_add_css(backdrop_get_path('module', 'path') . '/css/path.admin.css');
-  backdrop_add_js(backdrop_get_path('module', 'path') . '/js/path.admin.js');
   $form = $variables['form'];
 
   foreach (element_children($form['update']) as $base) {
diff --git a/core/modules/path/path.module b/core/modules/path/path.module
index 43be829c759..3d6aacb1daa 100644
--- a/core/modules/path/path.module
+++ b/core/modules/path/path.module
@@ -178,6 +178,7 @@ function path_menu() {
  * Implements hook_theme().
  */
 function path_theme() {
+  $module_path = backdrop_get_path('module', 'path');
   $base = array(
     'file' => 'path.admin.inc',
   );
@@ -185,6 +186,10 @@ function path_theme() {
   return array(
     'path_bulk_update_form' => array(
       'render element' => 'form',
+      'attached' => array(
+        'css' => array($module_path . '/css/path.admin.css' => array()),
+        'js' => array($module_path . '/js/path.admin.js' => array()),
+      ),
     ) + $base,
   );
 }
diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module
index 73a481ea290..2b01d5f8bc3 100644
--- a/core/modules/simpletest/simpletest.module
+++ b/core/modules/simpletest/simpletest.module
@@ -57,6 +57,7 @@ function simpletest_permission() {
  * Implements hook_theme().
  */
 function simpletest_theme() {
+  $module_path = backdrop_get_path('module', 'simpletest');
   $base = array(
     'file' => 'simpletest.theme.inc',
   );
@@ -64,6 +65,15 @@ function simpletest_theme() {
   return array(
     'simpletest_test_table' => array(
       'render element' => 'table',
+      'attached' => array(
+        'css' => array(
+          $module_path . '/css/simpletest.css' => array(),
+        ),
+        'js' => array(
+          $module_path . '/js/simpletest.js' => array(),
+          'core/misc/tableselect.js' => array(),
+        ),
+      ),
     ) + $base,
     'simpletest_result_summary' => array(
       'render element' => 'form',
diff --git a/core/modules/simpletest/simpletest.theme.inc b/core/modules/simpletest/simpletest.theme.inc
index cac0e7c5b1b..49e1ddb732e 100644
--- a/core/modules/simpletest/simpletest.theme.inc
+++ b/core/modules/simpletest/simpletest.theme.inc
@@ -16,10 +16,6 @@
 function theme_simpletest_test_table($variables) {
   $table = $variables['table'];
 
-  backdrop_add_css(backdrop_get_path('module', 'simpletest') . '/css/simpletest.css');
-  backdrop_add_js(backdrop_get_path('module', 'simpletest') . '/js/simpletest.js');
-  backdrop_add_js('core/misc/tableselect.js');
-
   // Create header for test selection table.
   $header = array(
     array('class' => array('select-all')),
diff --git a/core/modules/simpletest/tests/common.test b/core/modules/simpletest/tests/common.test
index 3d7573cfb42..bfda5902bee 100644
--- a/core/modules/simpletest/tests/common.test
+++ b/core/modules/simpletest/tests/common.test
@@ -1916,6 +1916,24 @@ class CommonJavaScriptTestCase extends BackdropWebTestCase {
     $this->assertTrue(strpos($scripts, 'core/misc/farbtastic/farbtastic.js'), 'The attached_library property adds the additional libraries.');
   }
 
+  /**
+   * Tests the addition of assets via hook_theme(). These are all random assets
+   * that would not normally be added to the page.
+   *
+   * @see common_test_theme()
+   */
+  public function testAssetsAttachedViaHookTheme() {
+    $config = config('system.core');
+    $config->set('preprocess_css', FALSE);
+    $config->set('preprocess_js', FALSE);
+    $config->save();
+    $this->backdropGet('common-test/assets-attached-via-hook-theme');
+    $this->assertRaw('core/themes/basis/css/component/maintenance.css', 'CSS attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/themes/seven/js/script.js', 'JS attached via hook_theme() is added to the page.');
+    $this->assertRaw('"globe-stand-fill":"\/core\/misc\/icons\/globe-stand-fill.svg"', 'Icon attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/misc/ajax.js', 'Library attached via hook_theme() is added to the page.');
+  }
+
   /**
    * Tests retrieval of libraries via backdrop_get_library().
    */
diff --git a/core/modules/simpletest/tests/common_test.module b/core/modules/simpletest/tests/common_test.module
index 1f3bbdbb826..e274dcb3551 100644
--- a/core/modules/simpletest/tests/common_test.module
+++ b/core/modules/simpletest/tests/common_test.module
@@ -63,6 +63,12 @@ function common_test_menu() {
     'access callback' => TRUE,
     'type' => MENU_CALLBACK,
   );
+  $items['common-test/assets-attached-via-hook-theme'] = array(
+    'title' => 'Tests assets attached via hook_theme().',
+    'page callback' => 'common_test_assets_attached_via_hook_theme',
+    'access callback' => TRUE,
+    'type' => MENU_CALLBACK,
+  );
   return $items;
 }
 
@@ -257,6 +263,20 @@ function common_test_theme() {
     'common_test_foo' => array(
       'variables' => array('foo' => 'foo', 'bar' => 'bar'),
       'file' => 'common_test.theme.inc',
+      'attached' => array(
+        'js' => array(
+          backdrop_get_path('theme', 'seven') . '/js/script.js',
+        ),
+        'css' => array(
+          backdrop_get_path('theme', 'basis') . '/css/component/maintenance.css',
+        ),
+        'icons' => array(
+          'globe-stand-fill' => array(),
+        ),
+        'library' => array(
+          array('system', 'backdrop.ajax'),
+        ),
+      ),
     ),
   );
 }
@@ -316,3 +336,11 @@ function common_test_js_and_css_querystring() {
 function common_test_cron() {
   throw new Exception(t('Uncaught exception'));
 }
+
+/**
+ * Landing page to test for assets attached via hook_theme().
+ */
+function common_test_assets_attached_via_hook_theme() {
+  $output = theme('common_test_foo', array('foo' => 'foo', 'bar' => 'bar'));
+  return $output;
+}
diff --git a/core/modules/system/system.api.php b/core/modules/system/system.api.php
index 64317978351..a0df2e11315 100644
--- a/core/modules/system/system.api.php
+++ b/core/modules/system/system.api.php
@@ -1793,6 +1793,9 @@ function hook_permission() {
  *   - base hook: A string declaring the base theme hook if this theme
  *     implementation is actually implementing a suggestion for another theme
  *     hook.
+ *   - attached: If specified, the designated library, icons, CSS, or JS file
+ *     will be attached to the page when this theme implementation is used. This
+ *     is intended as an alternative to adding these in preprocess functions.
  *   - pattern: A regular expression pattern to be used to allow this theme
  *     implementation to have a dynamic name. The convention is to use __ to
  *     differentiate the dynamic portion of the theme. For example, to allow
@@ -1820,6 +1823,8 @@ function hook_permission() {
  *     module, so that it doesn't need to be looked up.
  *
  * @see hook_theme_registry_alter()
+ *
+ * @since 1.31.0 Added optional "attached" key.
  */
 function hook_theme($existing, $type, $theme, $path) {
   return array(
@@ -1830,6 +1835,24 @@ function hook_theme($existing, $type, $theme, $path) {
       'render element' => 'requirements',
       'file' => 'system.admin.inc',
     ),
+    'my_module_item' => array(
+      'template' => 'templates/my-module-item',
+      'file' => 'my_module.theme.inc',
+      'attached' => array(
+        'library' => array(
+          array('my_module', 'library-name'),
+        ),
+        'js' => array(
+          'core/misc/ajax.js' => array(),
+        ),
+        'css' => array(
+          'css/my-module.css' => array(),
+        ),
+        'icons' => array(
+          'acorn-fill' => array(),
+        ),
+      ),
+    ),
   );
 }
 
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index 41b9e1012ef..4f1aeee3a0d 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -85,6 +85,11 @@ function system_theme() {
     ) + $base,
     'status_report' => array(
       'render element' => 'requirements',
+      'attached' => array(
+        'js' => array(
+          backdrop_get_path('module', 'system') . '/js/status.js' => array(),
+        ),
+      ),
     ) + $base,
     'admin_page' => array(
       'variables' => array('blocks' => NULL),
diff --git a/core/modules/system/system.theme.inc b/core/modules/system/system.theme.inc
index e900fd9eecf..496c0414f7e 100644
--- a/core/modules/system/system.theme.inc
+++ b/core/modules/system/system.theme.inc
@@ -430,8 +430,6 @@ function theme_status_report($variables) {
   }
 
   $system_status_report_table['rows'] = $rows;
-  $path = backdrop_get_path('module', 'system');
-  backdrop_add_js($path . '/js/status.js');
 
   return theme_table($system_status_report_table);
 }
diff --git a/core/modules/update/update.module b/core/modules/update/update.module
index 3eb8e6b8151..5353fde0b84 100644
--- a/core/modules/update/update.module
+++ b/core/modules/update/update.module
@@ -135,6 +135,11 @@ function update_theme($existing, $type, $theme, $path) {
     ) + $base,
     'update_report' => array(
       'variables' => array('data' => NULL),
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'update') . '/css/update.css' => array(),
+        ),
+      ),
     ) + $base,
     'update_version' => array(
       'variables' => array('version' => NULL, 'tag' => NULL, 'class' => array()),
diff --git a/core/modules/update/update.theme.inc b/core/modules/update/update.theme.inc
index 9e158a6437a..ad21d6e545f 100755
--- a/core/modules/update/update.theme.inc
+++ b/core/modules/update/update.theme.inc
@@ -312,7 +312,6 @@ function theme_update_report($variables) {
       $output .= theme('table', array('header' => $header, 'rows' => $rows[$type_name], 'attributes' => array('class' => array('update'))));
     }
   }
-  backdrop_add_css(backdrop_get_path('module', 'update') . '/css/update.css');
   return $output;
 }
 
diff --git a/core/modules/user/tests/user.test b/core/modules/user/tests/user.test
index 1c9aab0c795..a4315e8b433 100644
--- a/core/modules/user/tests/user.test
+++ b/core/modules/user/tests/user.test
@@ -2905,6 +2905,7 @@ class UserLoginAppearanceTestCase extends BackdropWebTestCase {
       $this->assertEqual(count($tabs), 0, 'The tabs nav element was not found.');
       $links = $this->xpath('(//ul[contains(@class, "login-links")])');
       $this->assertEqual(count($links), 1, 'The links element was found.');
+      $this->assertRaw('user/css/user-simplified-page.css');
     }
 
     // Set login appearance to tabs.
diff --git a/core/modules/user/user.module b/core/modules/user/user.module
index fce5cfd0dbe..0f4bd002de6 100644
--- a/core/modules/user/user.module
+++ b/core/modules/user/user.module
@@ -114,6 +114,11 @@ function user_theme() {
     'user_simplified_page' => array(
       'variables' => array('form' => array(), 'links' => array()),
       'template' => 'templates/user-simplified-page',
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'user') . '/css/user-simplified-page.css' => array(),
+        ),
+      ),
     ) + $base,
     'user_links_page' => array(
       'variables' => array('form' => array(), 'links' => array()),
diff --git a/core/modules/user/user.theme.inc b/core/modules/user/user.theme.inc
index bb60d87a622..4075096e49d 100644
--- a/core/modules/user/user.theme.inc
+++ b/core/modules/user/user.theme.inc
@@ -411,7 +411,6 @@ function template_preprocess_user_simplified_page(&$variables) {
   $variables['front_page'] = url();
   $variables['page_title'] = $page_title;
   $variables['messages'] = theme('status_messages');
-  backdrop_add_css(backdrop_get_path('module', 'user') . '/css/user-simplified-page.css');
 }
 
 /**


Reviews:
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:33:24Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:33:37Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:33:52Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:34:14Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:34:43Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:34:58Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:37:51Z
user: login: quicksketch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T23:38:55Z

Review comments:
diff_hunk: @@ -179,6 +179,11 @@ function installer_theme($existing, $type, $theme, $path) {
     'installer_browser_list' => array(
       'variables' => array('projects_list' => NULL, 'type' => NULL),
       'template' => 'templates/installer-list',
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'installer') . '/css/installer.css' => array(),
+        ),
+      )
path: core/modules/installer/installer.module
user: login: quicksketch
body: ```suggestion
      ),
```
created_at: 2025-05-02T23:33:24Z
author_association: MEMBER
diff_hunk: @@ -178,13 +178,18 @@ function path_menu() {
  * Implements hook_theme().
  */
 function path_theme() {
+  $module_path = backdrop_get_path('module', 'path');
   $base = array(
     'file' => 'path.admin.inc',
   );
 
   return array(
     'path_bulk_update_form' => array(
       'render element' => 'form',
+      'attached' => array(
+        'css' => array($module_path . '/css/path.admin.css' => array()),
+        'js' => array($module_path . '/js/path.admin.js' => array()),
+      )
path: core/modules/path/path.module
user: login: quicksketch
body: ```suggestion
      ),
```
created_at: 2025-05-02T23:33:37Z
author_association: MEMBER
diff_hunk: @@ -57,13 +57,23 @@ function simpletest_permission() {
  * Implements hook_theme().
  */
 function simpletest_theme() {
+  $module_path = backdrop_get_path('module', 'simpletest');
   $base = array(
     'file' => 'simpletest.theme.inc',
   );
 
   return array(
     'simpletest_test_table' => array(
       'render element' => 'table',
+      'attached' => array(
+        'css' => array(
+          $module_path . '/css/simpletest.css' => array(),
+        ),
+        'js' => array(
+          $module_path . '/js/simpletest.js' => array(),
+          'core/misc/tableselect.js' => array(),
+        ),
+      )
path: core/modules/simpletest/simpletest.module
user: login: quicksketch
body: ```suggestion
      ),
```
created_at: 2025-05-02T23:33:52Z
author_association: MEMBER
diff_hunk: @@ -257,6 +263,20 @@ function common_test_theme() {
     'common_test_foo' => array(
       'variables' => array('foo' => 'foo', 'bar' => 'bar'),
       'file' => 'common_test.theme.inc',
+      'attached' => array(
+        'js' => array(
+          backdrop_get_path('theme', 'seven') . '/js/script.js',
+        ),
+        'css' => array(
+          backdrop_get_path('theme', 'basis') . '/css/component/maintenance.css',
+        ),
+        'icons' => array(
+          'globe-stand-fill' => array(),
+        ),
+        'library' => array(
+          array('system', 'backdrop.ajax'),
+        )
path: core/modules/simpletest/tests/common_test.module
user: login: quicksketch
body: ```suggestion
        ),
```
created_at: 2025-05-02T23:34:14Z
author_association: MEMBER
diff_hunk: @@ -85,6 +85,11 @@ function system_theme() {
     ) + $base,
     'status_report' => array(
       'render element' => 'requirements',
+      'attached' => array(
+        'js' => array(
+          backdrop_get_path('module', 'system') . '/js/status.js' => array(),
+        ),
+      )
path: core/modules/system/system.module
user: login: quicksketch
body: ```suggestion
      ),
```
created_at: 2025-05-02T23:34:43Z
author_association: MEMBER
diff_hunk: @@ -135,6 +135,11 @@ function update_theme($existing, $type, $theme, $path) {
     ) + $base,
     'update_report' => array(
       'variables' => array('data' => NULL),
+      'attached' => array(
+        'css' => array(
+          backdrop_get_path('module', 'update') . '/css/update.css' => array(),
+        ),
+      )
path: core/modules/update/update.module
user: login: quicksketch
body: ```suggestion
      ),
```
created_at: 2025-05-02T23:34:58Z
author_association: MEMBER
diff_hunk: @@ -1916,6 +1916,27 @@ class CommonJavaScriptTestCase extends BackdropWebTestCase {
     $this->assertTrue(strpos($scripts, 'core/misc/farbtastic/farbtastic.js'), 'The attached_library property adds the additional libraries.');
   }
 
+  /**
+   * Tests the addition of assets via hook_theme(). These are all random assets
+   * that would not normally be added to the page.
+   *
+   * @see common_test_theme()
+   */
+  public function testAssetsAttachedViaHookTheme() {
+    $config = config('system.core');
+    $config->set('preprocess_css', FALSE);
+    $config->set('preprocess_js', FALSE);
+    $config->save();
+    $this->backdropGet('common-test/assets-attached-via-hook-theme');
+    $this->assertRaw('core/themes/basis/css/component/maintenance.css', 'CSS attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/themes/seven/js/script.js', 'JS attached via hook_theme() is added to the page.');
+    $this->assertRaw('"globe-stand-fill":"\/core\/misc\/icons\/globe-stand-fill.svg"', 'Icon attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/misc/ajax.js', 'Library attached via hook_theme() is added to the page.');
+    $config->set('preprocess_css', TRUE);
+    $config->set('preprocess_js', TRUE);
+    $config->save();
path: core/modules/simpletest/tests/common.test
user: login: quicksketch
body: This clean-up is not necessary as each test starts with fresh config.
```suggestion
```
created_at: 2025-05-02T23:37:51Z
author_association: MEMBER
diff_hunk: @@ -1916,6 +1916,24 @@ class CommonJavaScriptTestCase extends BackdropWebTestCase {
     $this->assertTrue(strpos($scripts, 'core/misc/farbtastic/farbtastic.js'), 'The attached_library property adds the additional libraries.');
   }
 
+  /**
+   * Tests the addition of assets via hook_theme(). These are all random assets
+   * that would not normally be added to the page.
+   *
+   * @see common_test_theme()
+   */
+  public function testAssetsAttachedViaHookTheme() {
+    $config = config('system.core');
+    $config->set('preprocess_css', FALSE);
+    $config->set('preprocess_js', FALSE);
+    $config->save();
+    $this->backdropGet('common-test/assets-attached-via-hook-theme');
+    $this->assertRaw('core/themes/basis/css/component/maintenance.css', 'CSS attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/themes/seven/js/script.js', 'JS attached via hook_theme() is added to the page.');
+    $this->assertRaw('"globe-stand-fill":"\/core\/misc\/icons\/globe-stand-fill.svg"', 'Icon attached via hook_theme() is added to the page.');
+    $this->assertRaw('core/misc/ajax.js', 'Library attached via hook_theme() is added to the page.');
path: core/modules/simpletest/tests/common.test
user: login: quicksketch
body: I appreciate that you tested every type of asset here! This looks good.
created_at: 2025-05-02T23:38:55Z
author_association: MEMBER

PR comments:
user: login: backdrop-ci
created_at: 2025-04-16T18:20:35Z
body: Related to: backdrop/backdrop-issues#6859 
author_association: COLLABORATOR
user: login: dragonbot
created_at: 2025-05-02T23:38:22Z
body: Tugboat has finished building a preview for this pull request!

Website: https://pr5050-xeokaavoirx4smk7abnekdk43r8jvbds.tugboatqa.com/
Username: admin
Password: 86eb173b7c08


This preview will automatically expire on the 2nd of July, 2025.
author_association: COLLABORATOR
user: login: quicksketch
created_at: 2025-05-03T02:14:49Z
body: Merged into 1.x for 1.31.0. Thanks @layrn!
author_association: MEMBER