Deprecate and replace all instances of jQuery.once in core

HOW TO REVIEW

There are 2 branches:

  • The branch 3183149-jquery-once-review-friendly is the one to commit to. This holds all the change on top of the automated replacements, The diff between auto fix and manual changes.
  • The branch 3183149-auto-replace holds all the automated replacements (no human intervention on the code of this branch, see below for the list of replacement that are made automatically). It is used for reference, so please do not commit to it.

The human code is on the branch 3183149-jquery-once-review-friendly and the majority of manual changes are in this commit: https://git.drupalcode.org/project/drupal/-/merge_requests/513/diffs?com...

The Merge request for core is !513


The majority of the patch was made thanks to a custom codemod, automated replacement are:
Before After
$('body')
  .once('detailsAria')
  .on('click.detailsAria', function () {});
$(once('detailsAria', 'body'))
  .on('click.detailsAria', function () {});
const $collapsibleDetails = $(context)
  .find('details')
  .once('collapse')
  .addClass('collapse-processed');
const $collapsibleDetails = $(once('collapse', 'details', context))
  .addClass('collapse-processed');
const $progress = $('[data-drupal-progress]').once('batch');
const $progress = $(once('batch', '[data-drupal-progress]'));
$(context)
  .find('th.select-all')
  .closest('table')
  .once('table-select')
  .each(Drupal.tableSelect);
$(once('table-select', $(context)
  .find('th.select-all')
  .closest('table')))
  .each(Drupal.tableSelect);
const $timezone = $(context).find('.timezone-detect').once('timezone');
const $timezone = $(once('timezone', '.timezone-detect', context));
$(this).removeOnce('big-pipe');
$(once.remove('big-pipe', $(this)));
const $configurationForm = $(context)
  .find('.ckeditor-toolbar-configuration')
  .findOnce('ckeditor-configuration');
const $configurationForm = $(
  once.filter('ckeditor-configuration', '.ckeditor-toolbar-configuration', context)
);
$('<div class="color-placeholder"></div>').once('color').prependTo(form);
$(once('color', $('<div class="color-placeholder"></div>'))).prependTo(form);
$('.color-preview')
  .once('color')
  .append(`<div id="gradient-${i}"></div>`);
$(once('color', '.color-preview'))
  .append(`<div id="gradient-${i}"></div>`);
if ($('body').once('contextualToolbar-init').length) {
  initContextualToolbar(context);
}
if ($(once('contextualToolbar-init', 'body')).length) {
  initContextualToolbar(context);
}
$context
  .find('#filters-status-wrapper input.form-checkbox')
  .once('filter-editor-status')
  .each(function () {});
$(once(
  'filter-editor-status',
  '#filters-status-wrapper input.form-checkbox',
  context
))
  .each(function () {});
editors = $(context).find('[data-editor-for]').findOnce('editor');
editors = $(context).find('[data-editor-for]').removeOnce('editor');
editors = $(once.filter('editor', '[data-editor-for]', context));
editors = $(once.remove('editor', '[data-editor-for]', context));
$context
  .find(selector)
  .removeOnce('fileValidate')
  .off('change.fileValidate', Drupal.file.validateExtension);
$(once.remove('fileValidate', $context
  .find(selector)))
  .off('change.fileValidate', Drupal.file.validateExtension);
$(context)
  .find('.js-filter-guidelines')
  .once('filter-guidelines')
  .find(':header')
  .hide()
  .closest('.js-filter-wrapper')
  .find('select.js-filter-list')
  .on('change.filterGuidelines', updateFilterGuidelines)
  // Need to trigger the namespaced event to avoid triggering formUpdated
  // when initializing the select.
  .trigger('change.filterGuidelines');
$(once('filter-guidelines', '.js-filter-guidelines', context))
  .find(':header')
  .hide()
  .closest('.js-filter-wrapper')
  .find('select.js-filter-list')
  .on('change.filterGuidelines', updateFilterGuidelines)
  // Need to trigger the namespaced event to avoid triggering formUpdated
  // when initializing the select.
  .trigger('change.filterGuidelines');
// Keep the jQuery find because of sizzle selector.
$(context)
  .find('table .bundle-settings .translatable :input')
  .once('translation-entity-admin-hide')
  .each(function () {});
// Keep the jQuery find because of sizzle selector.
$(once('translation-entity-admin-hide', $(context)
  .find('table .bundle-settings .translatable :input')))
  .each(function () {});
$('.js-click-to-select-trigger', context)
  .once('media-library-click-to-select')
  .on('click', (event) => {});
$(
  once('media-library-click-to-select', '.js-click-to-select-trigger', context)
)
  .on('click', (event) => {});
const $view = $(
  '.js-media-library-view[data-view-display-id="page"]',
  context,
).once('media-library-select-all');
const $view = $(once(
  'media-library-select-all',
  '.js-media-library-view[data-view-display-id="page"]',
  context
));
$('.js-media-library-item-weight', context)
  .once('media-library-toggle')
  .parent()
  .hide();
$(once('media-library-toggle', '.js-media-library-item-weight', context))
  .parent()
  .hide();
$('body').removeOnce('copy-field-values').off('value:copy');
$(once.remove('copy-field-values', 'body')).off('value:copy');
$(`#${ids.join(', #')}`)
  .removeOnce('copy-field-values')
  .off('blur');
$(once.remove('copy-field-values', `#${ids.join(', #')}`))
  .off('blur');
this.$el
  .find(`#toolbar-link-${id}`)
  .once('toolbar-subtrees')
  .after(subtrees[id]);
$(once('toolbar-subtrees', this.$el
  .find(`#toolbar-link-${id}`)))
  .after(subtrees[id]);
initTableDrag($(context).find(`#${base}`).once('tabledrag'), base);
initTableDrag($(once('tabledrag', `#${base}`, context)), base);
$('table')
  .findOnce('tabledrag')
  .trigger('columnschange', !!displayWeight);
$(once.filter('tabledrag', 'table'))
  .trigger('columnschange', !!displayWeight);
const $forms = (contextIsForm ? $context : $context.find('form')).once('form-updated');
// Replace
const $forms = $(once('form-updated', contextIsForm ? $context : $context.find('form')));
const $source = $context
  .find(sourceId)
  .addClass('machine-name-source')
  .once('machine-name');
const $source = $(once('machine-name', $context
  .find(sourceId)
  .addClass('machine-name-source')));
// Don't replace.
_.once(() => {});
CKEDITOR.once('instanceReady', (e) => {});
// Don't replace.
_.once(() => {});
CKEDITOR.once('instanceReady', (e) => {});

The patch in #21 show all the automated replacements. Then a manual step was necessary to replace the once calls on $(window) and $(document) as well as replacing calls to $.each by .forEach when possible. The interdiff in #23 show the manual changes.

From there the creation of the merge request was based on patch #29 with updates to the once lib files and a fix for the library dependency of autocomplete.

Issue fork drupal-3183149

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

nod_ created an issue. See original summary.

nod_’s picture

Status: Active » Needs review
StatusFileSize
new552 bytes

Tests failures needs to go to 0 :)

Status: Needs review » Needs work

The last submitted patch, 2: core-once-3183149-2.patch, failed testing. View results

nod_’s picture

StatusFileSize
new429.67 KB

Let's try this.

The patch is not clean, it includes the parent patch and the codemod I thrown in there to do the update. We should put the codemods in a better place and make it easy for contrib to use (like the upgrader module).

There are a few uses that can't be automatically updated.

There are optimisations we can do for certain usual forms like $(context).find('selector').once('toto').each(callback); let's just see how much it's breaking things.

nod_’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 4: core-once-3183149-4.bis_.patch, failed testing. View results

nod_’s picture

Mainly deprecation, a couple of actual regressions

droplet’s picture

You're my hero.

Codemods should kick to Github (as your own project? or something else). You always need a faster patch & newer version for your older Drupal version. And that's more possible to accept other libs' codemods.. (eg. Zepto)

nod_’s picture

Yeah I was planning on putting something over on GitHub to start with. Kinda short on time these days :p

Ideally we'd have other people do npx drupal-upgrade and that makes the magic.

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new491.79 KB
new11.71 KB

no more deprecations, let's see what we broke.

Status: Needs review » Needs work

The last submitted patch, 10: core-once-3183149-10.patch, failed testing. View results

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new762 bytes
new437.8 KB

that went well :) small workaround in the interdiff until I update the lib: https://github.com/theodoreb/once/pull/3

Status: Needs review » Needs work

The last submitted patch, 12: core-once-3183149-12.patch, failed testing. View results

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new398.47 KB
new235.71 KB

update after the change to once.filter interdiff not so useful but it's there in case.

Status: Needs review » Needs work

The last submitted patch, 14: core-once-3183149-14.patch, failed testing. View results

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new36.29 KB
new400.52 KB

subltly broken things fix on the node preview.

still an issue with the media widget. There is a strange gray background around media fields, no idea where it comes from. Haven't looked closely at the media JS.

Status: Needs review » Needs work

The last submitted patch, 16: core-once-3183149-16.patch, failed testing. View results

droplet’s picture

I didn't perform any real test but i think error around this line (missing $menu on patched version):
https://git.drupalcode.org/project/drupal/-/blob/9.2.x/core/modules/medi...

that can't pass `context` to $.fn.find BTW
https://api.jquery.com/find/

also, query `a` is a bad practice, should assign `.class` to links (I will file the issue soon when I have time to perform a UI test. Or anyone can help?)

droplet’s picture

Acutally what I afraid is the below style became a bad example for contributors.

Don't:
$(once('toolbar', '#toolbar-administration', context)).each

Do:
once('toolbar', '#toolbar-administration', context).forEach

Custom ESLint rule will work. Just not sure how much the community can handle...
#3178115: [policy, no patch] Maintenance of JS in core
#3176918: [policy, no patch] Publishing / Maintaining JS libraries produced by Drupal that do not have a dependency on Drupal

nod_’s picture

Agreed, was going to replace them manually later on. Started with the bigpipe one in the last interdiff. Wanted to have green tests before I change things manually too much.

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new1.27 KB
new400.52 KB

thanks for the find, let's try this.

nod_’s picture

Looking good!

nod_’s picture

StatusFileSize
new454.26 KB
new141.97 KB

Did a first manual pass at fixing obviously inefficient replacements.

// From
$(once()).each((index, value) => {});
// To
once.forEach((value) => {});

# -----

// From
$(once()).each(function () { const $this = $(this); }));
// To
once().forEach((value) => { const $this = $(value); });

# -----

// From
const $els = $(once());
if (!$els.length) {
  return
}
const $other = $els.find();
// To
const els = once();
if (!els.length) {
  return
}
const $els = $(els);
const $other = $els.find();

I didn't touch the $(once()).on() because we don't have anything better to replace it with. Didn't mess too much with the selectors either because sometimes it's not sure that the jQuery object is a single element

// If we want to search for all 'selector' in those 3 elements: 
const els = [element1, element2, element3];
// it's trivial in jQuery
$(els).find('selector');
// In js that means doing something like
els.flatMap(el => Array.from(el.querySelectorAll('selector')));

I'd rather deal with that later on.

Status: Needs review » Needs work

The last submitted patch, 23: core-once-3183149-23.patch, failed testing. View results

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new1.16 KB
new454.4 KB

forgot to replace a "this"

Status: Needs review » Needs work

The last submitted patch, 25: core-once-3183149-25.patch, failed testing. View results

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new454.36 KB
new1.51 KB

last this missing.

Checked the loading perfs of before/after the patch. Thanks to the benchmark we already know once is faster than the jquery version, it holds up in real life even with quickedit doing a lot of once calls. I don't have formal numbers, its just slightly faster.

nod_’s picture

StatusFileSize
new454.89 KB
new33.81 KB

Updating the lib after some hardening on parameter validation.

nod_’s picture

Title: Deprecate jquery.once and use the new once lib » [PP-1] Deprecate jquery.once and use the new once lib
Status: Needs review » Needs work
StatusFileSize
new345.23 KB

Patch without the once lib. Should be green once parent patch is in (remove all the vendor/assets files from this patch)
Had to reroll for #3083051: Refactor tabledrag when core issues are resolved

other than that, no changes.

I still have the jscodeshift deps in this patch, we probably won't keep it on commit though.

nod_’s picture

Title: [PP-1] Deprecate jquery.once and use the new once lib » Deprecate jquery.once and use the new once lib
Status: Needs work » Needs review

Moving to a merge request for easier review/feedback.

nod_’s picture

Issue summary: View changes
nod_’s picture

Issue summary: View changes
nod_’s picture

Issue tags: +Need tests

Tagging because we talked about having integration tests for the once library. Leaving as need review because there is still a lot of code to go through independently of the tests

droplet’s picture

I'm thinking of...

// eaiser for this patch review, contrib won't copy our style
const $collapsibleDetails = $(once('collapse', 'details', context)).addClass('collapse-processed');

// better for future jQuery removal or future additional code using non-jQuery.
const collapsibleDetails = once('collapse', 'details', context)
const $collapsibleDetails = $(collapsibleDetails).addClass('collapse-processed');

nod_’s picture

Issue summary: View changes

HOW TO REVIEW

There are 2 branches:

  • The branch 3183149-manual-adjustments is the one to commit to. This holds all the change on top of the automated replacements.
    Merge request from fork:3183149-manual-adjustments → fork:3183149-auto-replace (for the diff between auto fix and manual changes)
    Merge request from fork:3183149-manual-adjustments → core:9.2.x (for core commit)
  • The branch 3183149-auto-replace holds all the automated replacements (no human intervention on the code of this branch, see below for the list of replacement that are made automatically). It is used to show the diff between automated fixes and manual changes, so please do not commit to it.

The human code is on the branch 3183149-manual-adjustments There is a merge request created between 2 branches in the fork to facilitate discussion on what additional changes are needed on top of automated replacements: https://git.drupalcode.org/issue/drupal-3183149/-/merge_requests/1/diffs

droplet’s picture

nod_’s picture

Issue summary: View changes
nod_’s picture

taken care of #42 as part of this issue

nod_’s picture

Issue summary: View changes
droplet’s picture

Tips for review:

- You could turn off `Show whitespace changes`
- non-ES6 file is easier for review. (better diff view)

droplet’s picture

I don't know if I can do it or not. I submitted reviews on gitlab directly.
https://git.drupalcode.org/project/drupal/-/merge_requests/391

nod_’s picture

nod_’s picture

Any takers for a review? let me know if you need time to talk it through directly or need more details about something.

nod_’s picture

Issue summary: View changes
nod_’s picture

Issue summary: View changes

nod_’s picture

Issue summary: View changes
nod_’s picture

Issue summary: View changes
nod_’s picture

Opened #3207782: Add BC layer between @drupal/once and jQuery.once to address BC concerns I wouldn't postpone this one, we already tried a few things for BC in the different once issues, there are several solutions that work, just a matter of agreeing on one.

nod_’s picture

going at it step by steps. Addressing #3208051: Use @drupal/once for the block module first to add some integration testing for the once library.

droplet’s picture

this patch ready to go I think..

files without forEach, 101% safe to go
file with forEach, 99% safe to go.

nod_’s picture

I feel it's ready too, as you may have guessed it's not a technical issue that is holding this patch up. It's just too big for someone that hasn't followed it from the start, so after talking with other core committer we decided to split a few pieces of "reviewable" size to let others get a sens of what happened here.

I'll spend time with justafish to go through the changes, and I'll answer questions until the rest of the core committers are comfortable with committing this patch. Large JS changes are still somewhat scary because we don't have many tests and most people are not familiar with the whole codebase, so it's an opportunity to get more people onboard :)

I know it's not going as fast as we'd like but looking back at the last 6 months, there's been some pretty significant changes already (on a personal note, I'll soon be able to contribute to core full-time so that could help). We're getting there, slowly but surely, going slowly here will make it easier for the other big patches that are sleeping in the queue :D

droplet’s picture

@nod_

You've already done a great job!

Everywhere (, not just Drupal) suggested not send large patches, I can understand it. But sometimes, to split issues and reviewed by the SAME person or people who don't really understand code. We just making ourselves feel good.

If we have snapshot tests, we may able to perform `Drupal.attachBehaviors(document, drupalSettings);` twice and compare the DOM. It should be no changes for the second call.
(only worked for CORE & good usages BTW)

I'm a bad guy as usual. I always suggested not to teach reviewers how to review a patch. Because we will lead them into our path (into the trap). We need blind and smoke tests. However, the below hints may help you to get a quick start:

This patch is big but to review it is easier than you think.

Do:
- Understanding of how the jQuery.fn.each & [].forEach works first. (IMPORTANT)
- turn off `Show whitespace changes` on Gitlab or your editors
- review non-ES6 file (less diff)
- review non-forEach first (almost zero code logic changes)
- drop notes for unclear points and go back (don't block your flow)

chi’s picture

Given that Drupal 10 is dropping support for IE11 will we eventually switch to event listeners with once option and deprecate once.js?

// jQuery.once
$('body')
  .once('detailsAria')
  .on('click.detailsAria', function () {});

// Once.js
$(once('detailsAria', 'body'))
  .on('click.detailsAria', function () {});

// Vanila js
document.body
  .addEventListener('click', function() {}, {once: true})
nod_’s picture

It's not strictly the same, once uses are not always about event listeners.

In your example they don't do the same thing, with once you bind an event listener once but it triggers every time you click on the element, with the code using the once option from the event listener, the event can be bound multiple times and will only trigger once.

So I don't see us retiring the once library as long as we rely on behaviors to initialize JS code.

chi’s picture

@node_ you are right, the last example does a whole different thing.

I wonder if it's possible to migrate from behaviours to event based JS initialization. That would make custom JS code less verbose and less Drupalized.

Something like this.

// In Drupal.attachBehaviors().
let event = new CustomEvent('DrupalContentLoaded', {detail: {context}});
document.dispatchEvent(event);

// In user script.
document.addEventListener(
  'DrupalContentLoaded',
  e => console.log(e.detail.context),
  {once: true} // Optional.
);
nod_’s picture

have a look at #2367655: Control the list of behaviors run by Drupal.attachBehaviors and #1446166: Use JS events instead of Drupal.behaviors to see previous discussions. This is starting to become off topic though, so let's stick with the once thing here and discuss this in a separate issue, thanks.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

nod_’s picture

updated to 9.3.x

mglaman’s picture

@nod_ would the next step be identifying all of the child issues that need to be created, like #3208051: Use @drupal/once for the block module?

Maybe we could have a list in issue summary so folks to create them? I missed the block issue at a quick glance.

nod_’s picture

Issue tags: -Need tests

the block issue was a spin-off to make onboarding easier on this patch. The plan is not to create one issue per module otherwise it'll never end. This patch has been green for 7 months already so just trying to make it happen ASAP :)

Tests are in #3207782: Add BC layer between @drupal/once and jQuery.once

nod_’s picture

Let me know if you want me to walk through the patch. Currently I need someone who'll be able to RTBC this so happy to spend time with folks to talk it through.

jptaranto’s picture

I'm about 70% through. I've been going through each .es6 file change. There's a huge amount here and so far so good.

Dare I ask how much of this can/has been tested?

nod_’s picture

Dare I ask how much of this can/has been tested?

Well what you see in the IS, I wrote the codemod to automate the change, and each transformation has been written carefully to not replace things that shouldn't be replaced.

As far as manual changes go, I did test every change to check that the result was similar to previously (that's how I caught the problems with this in the view and ajax files).

There is also the fact that a bad patch does break some tests so there is at least some form of coverage. A few months ago I ran the Drupal tests for the JS coverage, the result is here: https://lonely-fifth.surge.sh/index.html it's surprisingly good, more than I expected. We added some more tests lately so coverage is even better.

jptaranto’s picture

Ok! Look I've gone through each change line by line basically. Again, It's a mammoth effort.

Visually the code looks fine and it's a 1:1 replacement across the board, which is the right approach.

+ It terrifies me how embedded jQuery is in absolutely everything.

jptaranto’s picture

Status: Needs review » Reviewed & tested by the community
droplet’s picture

Finally, we've seen another review :) @jptaranto!!!

I haven't reviewed the latest changes since my last comment. All other files were fine at that point. Hope it will give a little bit more faith to maintainers also. Cheers.

nod_’s picture

Addressed lauriii comments.

droplet’s picture

I go back to review the changes since my last review.

Code Logic looks fine to me. Other formatting or docs issues are not my first priority.

lauriii’s picture

Status: Reviewed & tested by the community » Fixed

Committed 8a40e42 and pushed to 9.3.x. Thanks!

  • lauriii committed 8a40e42 on 9.3.x
    Issue #3183149 by nod_, droplet, jptaranto: Deprecate jquery.once and...
nod_’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

cilefen’s picture

chetansonawane’s picture

chetansonawane’s picture