Change record status: 
Project: 
Introduced in branch: 
9.2.x
Introduced in version: 
9.2.0
Description: 

In order to allow scripts to remove dependency on jQuery the once feature has been implemented in vanilla js. Additionally it is distributed as a npm package under the @drupal/once namespace.

API Additions

  • Declare a new core/once library
  • Deprecate the core/jquery.once library
  • Introduce the once global variable to eslint config
  • A new once library with 4 functions:
    • once (equivalent to jQuery.fn.once)
    • once.filter (equivalent to jQuery.fn.findOnce)
    • once.remove (equivalent to jQuery.fn.removeOnce)
    • once.find (new function not previously possible with the jQuery implementation)

Backwards compatibility

If code is relying on an existing once call from core, an additional BC layer is automatically added when once is used alongside jquery.once. Calls to jQuery.once will honor previous calls to once().

// Core calls once()
once('once-id', '[data-drupal-selector="element"]');

// In a contrib or custom code, jQuery.once will work as expected: 
$('[data-drupal-selector="element"]').once('once-id') // will return an empty set.

It does not work the other way. Calls to jQuery.once will be ignored by once().

once API

The once functions take 2 parameters, a string id without spaces and an array-like object. The recommended way to use it is to call it with the string you would use in a querySelectorAll() call. You can only use once with objects that are instances of Element, which means you can not use it with the document or window object. If you need a page-level element please use 'html' or 'body' as the selector used in the once call: once('my-global-once', 'html').

The full documentation is available on npm package page once API

once(id, elements (CSS selector string, Array, NodeList, jQuery), context (optional))

Selects all the elements that have not already been "onced" and add the "id" to the value of the data-once attribute.

const elements = once('myfeature', '[data-myfeature]');
# changes in the HTML
<div data-myfeature data-once="myfeature">
<div data-myfeature data-once="myfeature another-once-id yet-another-once-id">

once.filter(id, elements, context (optional))

This function filters the elements and returns those for which the once "id" has already been executed.

const filteredElements = once.filter('myfeature', '[data-myfeature]');

once.remove(id, elements, context (optional))

Remove the "id" value from the data-once attribute and returns all elements that matched the once id.

const removedElements = once.remove('myfeature', '[data-myfeature]');

once.find(id (optional), context (optional))

This function query the DOM and return elements for which the once "id" has already been executed, an optional context parameter can be passed to restrict the search of element to a section of the DOM.

const oncedElements = once.find('myfeature');
// Equivalent to `document.querySelectorAll('[data-once~="myfeature"]');`

const oncedElementsInContext = once.find('myfeature', context);
// Equivalent to `context.querySelectorAll('[data-once~="myfeature"]');`

const allOncedElements = once.find();
// Equivalent to `document.querySelectorAll('[data-once]');`

It is now possible to select elements that have been onced with a css selector such as [data-once~="myfeature"] to use in CSS files. On the JavaScript side it is easier to use the previous once.find('myfeature') function.

Code changes needed

Before

# mymodule.libraries.yml
myfeature:
  js: 
    js/myfeature.js: {}
  dependencies:
    - core/drupal
    - core/jquery
    - core/jquery.once
# js/myfeature.js
(function ($, Drupal) {
  Drupal.behaviors.myfeature = {
    attach(context) {
      const $elements = $(context).find('[data-myfeature]').once('myfeature');
      // `$elements` is always a jQuery object.
      $elements.each(processingCallback);
    }
  };

  function processingCallback(index, value) {}
}(jQuery, Drupal));

After (removing jQuery dependency)

# mymodule.libraries.yml
myfeature:
  js: 
    js/myfeature.js: {}
  dependencies:
    - core/drupal
    - core/once
# js/myfeature.js
(function (Drupal, once) {
  Drupal.behaviors.myfeature = {
    attach(context) {
      const elements = once('myfeature', '[data-myfeature]', context);
      // `elements` is always an Array.
      elements.forEach(processingCallback);
    }
  };

  // The parameters are reversed in the callback between jQuery `.each` method 
  // and the native `.forEach` array method.
  function processingCallback(value, index) {}
}(Drupal, once));

After (keeping the jQuery dependency)

If the code is already using jQuery and removing the dependency is not in scope an alternative form can be use to minimize code changes.

# mymodule.libraries.yml
myfeature:
  js: 
    js/myfeature.js: {}
  dependencies:
    - core/drupal
    - core/jquery
    - core/once
# js/myfeature.js

(function (Drupal, $, once) {
  Drupal.behaviors.myfeature = {
    attach(context) {
      // The once call is wrapped in $() to allow the usual jQuery chaining.
      const $elements =  $(once('myfeature', '[data-myfeature]', context));
      // `$elements` is always a jQuery object.
      $elements.each(processingCallback);
    }
  };

  function processingCallback(index, value) {}
}(Drupal, jQuery, once));

More code change examples

Before After (recommended) After (alternate version)
$('body')
  .once('vertical-tabs-fragments')
  .on();
// Events still heavily rely on jQuery so this is the best way to replace for now.
$(once('vertical-tabs-fragments', 'body'))
  .on()
$(window)
  .once('off-canvas')
  .on();




// Can not use `window` or `document` directly.
if (!once('off-canvas', 'html').length) {
  // Early return avoid changing the indentation
  // for the rest of the code.
  return;
}
$(window).on();
if (once('off-canvas', 'html').length) {
  $(window).on();
}




$(context)
  .once('init-once')
  .each(function initOnce() {
    console.log('init-once', this);
  });
// The second argument of once() needs to be an instance of Element, but
// document is an instance of Document, replace it with the html Element.
once('init-once', context === document ? 'html' : context)
  .forEach(function initOnce(doc) {
    console.log('init-once', doc);
  });
// The second argument of once() needs to be an instance of Element, but
// document is an instance of Document, replace it with the html Element.
$(once('init-once', context === document ? 'html' : context))
  .each(function initOnce() {
    console.log('init-once', this);
  });
$(context)
  .find('table')
  .once('tableresponsive')
  .each(function () {
    const $table = $(this);
  });
once('tableresponsive', 'table', context)
  // For D9/IE11 support use the 
  // usual `function(table) {}`.
  .forEach((table) => {
    const $table = $(table);
  });
$(once('tableresponsive', 'table', context))
  .each(function () {
    const $table = $(this);
  });


Impacts: 
Module developers
Themers
Distribution developers
Updates Done (doc team, etc.)
Online documentation: 
Not done
Theming guide: 
Not done
Module developer documentation: 
Not done
Examples project: 
Not done
Coder Review: 
Not done
Coder Upgrade: 
Not done
Other: 
Other updates done

Comments

xenophyle’s picture

camilafl’s picture

In case of use $('element').once()... where the once is empty but your value comes from another module like data-once('tabsClick')
If I change to $(once('myNewId', 'element')), the data-once renders like this data-once('tabsClick myNewId')
Should I keep the data-once with 2 values or Should I use the once.remove() e then $(once('myNewId', 'element'))?

RobLoach’s picture

The default parameter for "id" in the original jQuery Once was "once". So you should probably make your own unique once identifier.

$('element').once()
once('myNewFeature', 'element')

.... And avoid calling the remove() function in general.

DanChadwick’s picture

a) "Deprecate the core/jquery.once library" means that the previously jQuery version of once was been removed from Drupal 10.

b) Changing to the vanilla javascript once() may require re-writing other code. For example, the new once() stores the status in an attribute, meaning that it persists through a jQuery remove() / append() operation, whereas the original jQuery version does not.

dipakmdhrm’s picture

The following regex helped me replace a lot of old once() implementations with new one for D10 upgrade:

\$\((.*)\)\.once\(('.*')\) => $(once($2, $1))

And the following one for instances where no id was defined. Make sure to replace `custom-id` with something specific later.

\$\((.*)\)\.once\(\) => $(once('custom-id', $1))

bryanmanalo’s picture

This regex saved us a lot of time. Thanks!

Hetal chavda’s picture

Thanks for the great support!