Intuition: Weird JavaScript Behaviour in SDC

When working on a project, I’ve setup this SDC named test-js in a Radix theme with the following files: (see: test-js-sdc-example-files.zip)

1
2
3
4
theme\component\test-js
                  |_______test-js.twig
                  |_______test-js.component.yml
                  |_______test-js.js

The content of the files are as follows:

1
2
3
4
{# [test-js.twig] #}
<div class="test-js opc:p-0 opc:bg-black opc:text-white opc:mb-[2px]!">
  <p class="opc:pb-0! opc:mb-0!">[Test-JS-SDC]: {{ "now"|date("h:i:s.v A") }}</p>
</div>
1
2
3
4
5
# [test-js.component.yml]
$schema: https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json
name: test-js
status: experimental
description: 'The test-js component auto-generated by drupal-radix-cli'
1
2
3
4
5
6
7
8
9
// [test-js.js] 
//          (OR [_test-js.js] when in Radix Theme)
((Drupal) => {
    // Print current time with milliseconds gainularity
    console.log(
        '[test-js.js] \t',
        new Date().toLocaleTimeString() + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
    );
})(Drupal);

I then did some editing to the page-content.twig component, to replace the default {{content}} block with a multiple of this test-js SDC component like the following, so the SDC I declared earlier can be used (for quick testting purpose):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	{% if page.content %}
    <div class="page__content" id="main-content">
      <div {{ page_content_container_attributes.addClass(page_content_container_classes) }}>
        {% block page_inner_content %}
-            {{ page.content }}
+            <div class="opc:flex opc:flex-row opc:flex-wrap opc:gap-1">
+                {{ include('radix_opc:test-js') }}
+                {{ include('radix_opc:test-js') }}
+                {{ include('radix_opc:test-js') }}
+                {{ include('radix_opc:test-js') }}
+                {# .... #}
+            </div>
        {% endblock %}
      </div>
    </div>

I am expecting to see multiple of the component in the content area of html, as well as multiple console log [test-js] ...; Though I predicted the multiple of components on html, superizingly the JavaScript file in the SDC only get executed once ! And looking at the network history, the JavaScript seems to be parsed right after when the theme library is attached, and is executed once when it is parsed/attached.

2025-07-18T164350

Upon some research, it is found that the better practice is to control the execution of JavaScript via wrapping in Drupal.behavior API , which allows you to attach functions to be executed at certain time during the life-cycle of the page, and to pass in context, and settings (variables) to the JavaScript files.

When you write plain JS without wrapping them in Drupal.behavior , you would have:

  1. No automatic exeuction on “page load”:

    • it might not run once when dom loads, it will immedicately when the code is parsed, and this may happen before hte dom loads in (for instance when attached in the head: example)
  2. **No automatic execution on “page update” **

    • if you are using Drupal.behaviour, JavaScript will automatically re-apply to conent when the content is loaded dynamically using AJAX. For instance, when you change the “show XYZ results in a page” setting in a view, the Drupal.behavior will automacially rerun when the new DOM chunks are attached. (This ensures you script re-applies to new element.
  3. No ability to pass variabels to JavaScript

    • you will not be able to read the context and settings variables from the JavaScript files

Drupal JavaScript API (Drupal Behavior)

According to the official Drupal documentation on: link:

Any object defined as a property of Drupal.behaviors will get its attach() method called when the DOM has loaded both initially and after any AJAX calls. drupal.js has a $(document).ready() function which calls the Drupal.attachBehaviors() function, which in turn cycles through the Drupal.behaviors object calling every one of its properties, these all being functions declared by various modules as above, and passing in the document as the context. On AJAX loads the same thing happens except the context is only the new content that the AJAX call loaded.

Drupal Behaviors are fired whenever attachBehaviors is called. The context variable that is passed in can often give you a better idea of what DOM element is being processed, but it is not a sure way to know if you are processing something again. Using once with “context” is a good practice because then only the given context is searched and not the entire document. This becomes more important when attaching behaviors after an AJAX request.

Basic Example (executed once)

With the same testJS single-directory-component, we will alter its Twig and JavaScript to the following:

(files can also be found at: test-js-behavior-basic-example.zip)

1
2
3
4
5
{# [test-js.twig] #}
{% set random_id = 'test-js-id-' ~ random() %}
<div class="test-js opc:p-0 opc:bg-black opc:text-white opc:mb-[2px]!" data-test-js='{{ random_id }}'>
  <p class="opc:pb-0! opc:mb-0!">[Test-JS-SDC]: {{ "now"|date("h:i:s.v A") }}</p>
</div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/* [test-js.js] */
(($, Drupal, once) => {
  Drupal.behaviors.testJs = {
    attach: (context, settings) => {
        once('testJs', '[data-test-js]', context).forEach(
            (element) => {
                console.log(
                    '[testJs.js]:',
                    element.dataset.testJs,
                    `(Time: ${new Date().toLocaleTimeString() + '.' + new Date().getMilliseconds().toString().padStart(3, '0')})`
                );
            }
        );
    }
  };
})(jQuery, Drupal, once);

2025-07-21T111001

Advanced Example (execute upon update)

Next we will attempt to use drupal behavior to re-run/re-attach JavaScript when a view is updated via AJAX, first we’ll create the view below, with a filter where we can use to change the order of the items in this view via “authored on” date, and pager to display the result in smaller chunks:

2025-07-21T112940

And by default we have the following files to beign with:

  • [node.twig](node.twig (orginal).zip) (using the node-teaser SDC to display the teaser article)
  • [node-teaser](node-teaser (original).zip) (the folder containing the node-teaser SDC component)

Let’s make some edition to include a javascript which will prepend the teaser’s random id next to its title:

1
2
3
4
5
6
7
8
9
{# [node-teaser.twig] #}
+ {% set teaser_id = 'node-teaser-id-' ~ random() %}
...
+ <a href="{{node_url}}" class="opc:no-underline!" data-node-teaser='{{ teaser_id }}'>
- <a href="{{node_url}}" class="opc:no-underline!">
...
+       <span class="teaser-title">{{node_title}}</span>
-        <span>{{node_title}}</span>
...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* [node-teaser.js] */
(function ($, Drupal, once) {
    var run_count = 0;
    Drupal.behaviors.nodeTeaser = {
        attach: (context, settings) => {
            once('nodeTeaser', '[data-node-teaser]', context).forEach(
                (element) => {
                    run_count++;
                    console.log(`[Run Count: ${run_count}]`, element.dataset.nodeTeaser);
                    element.querySelector('.teaser-title').innerHTML += ` (${element.dataset.nodeTeaser})`;
                }
            );
        }
    };
})(jQuery, Drupal, once);

2025-07-21T113720

(full code can be found at: [node-teaser (with behavior).zip](node-teaser (with behavior).zip) )


When you just want to control the first execution of the JavaScript without the need of “re-applying when page updates” and “pass context&settings variables”, it is also fine to just use one of the below:

1
2
3
4
jQuery(document).ready(function(){                    /*DO-SOMETHING*/  });    /* Do-something when DOM is ready   */
$(document).ready(function(){                         /*DO-SOMETHING*/  });    /* Using $ instead of jQuery        */
$(function(){                                         /*DO-SOMETHING*/  });    /* Short-hand version               */
document.addEventListener("DOMContentLoaded",  ()=>{  /*DO-SOMETHING*/  });    /* Vanilla Javascript Version       */

With that said, for finer control over the execution, it is still recommended to always use the Drupal behavior instead. And you can achieve the exact same effect of running on document load using behavior, see below:

1
2
3
4
5
6
7
8
9
(($, Drupal, once) => {
  Drupal.behaviors.myBehavior = {
    attach: (context, settings) => {
    	once('myBehavior', 'html', context).forEach(  (e)=>{/*DO-SOMETHING*/}  );
        /* once(id, selector, [context]) => Array.<Element>
           see: https://www.npmjs.com/package/@drupal/once   */
    }
  };
})(jQuery, Drupal, once);

Read more about this on: https://www.drupal.org/docs/drupal-apis/javascript-api/javascript-api-overview#s-drupalbehaviors-compared-to-jquerydocumentready-for-deferring-execution


Reference