Skip to content

component-js@1 - Auto-load web-components from extensions - #34325

Closed
totten wants to merge 1 commit into
civicrm:masterfrom
totten:master-component-js
Closed

component-js@1 - Auto-load web-components from extensions#34325
totten wants to merge 1 commit into
civicrm:masterfrom
totten:master-component-js

Conversation

@totten

@totten totten commented Dec 19, 2025

Copy link
Copy Markdown
Member

Overview

Define a file-naming convention for WebComponents (*.js or *.mjs) implemented within CiviCRM. Implement lazy-loading with ECMAScript module (ESM) support.

ping @ufundo @colemanw

Technical Details: Consumers

As a consumer of a WebComponent, you simply use the tag... somewhere. It can be Quickform or AngularJS or jQuery or whatever. For example:

<hello-world></hello-world>

But how is this new tag defined?

Technical Details: Providers

As the provider of a WebComponent, you will do the following:

  1. Update your info.xml and enable <mixin>component-js@1</mixin>.
  2. Create a file in your extension called js/component/TAG-NAME.js.

For example, here is an implementation of <hello-world>:

// FILE: js/component/hello-world.js

class HelloWorld extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Hello world! yo yo';
  }
}

customElements.define('hello-world', HelloWorld);

Since this supports ECMAScript modules, you can import helpers from other files. Untested examples:

import { myHelper } from '../myHelper.js';
import { otherHelper } from 'org.civicrm.otherext/js/otherHelper.js';

// Note: To have this kind of sharing between the extensions, it's best if both use component-js@1.

This is implemented as a mixin, and it should be amenable for backporting on 5.63+.

Comments

The general approach is this:

  • Use AssetBuilder to create a semi-static file, component.js, which stores an index of all these WebComponents.
  • On every pageload, include this index file.
  • Use MutationObserver to determine when new components are needed.
  • This index shouldn't be too big (at least, with our kind of usage/scale). For example, with 100 WebComponents, the index should be ~10kb. (The indexing mechanism is encapsulated, so it can be changed if size becomes an issue.)
  • There is one MutationObserver shared by all these WebComponents.

Limitations worth considering:

  • It uses lazy-loading (i.e. on-demand). This means you may perceive some flashing/delay as components render.
    • (I imagine some convention could help manage that... but I'm not sure what such a convention should be...)
  • Bundling may be a more involved topic.
    • There might be some room for bundling without any extra libraries, but it's probably pretty weak.
    • Each extension could do some build-steps on its own to produce bundles.
    • My guess is -- for stronger bundling support -- we'd eventually pull-in another library, like newer es-module-shims with virtual sources or else php es6 bundler.
  • Translations - We probably need some more logic to pull in strings for these import()d JS files.

@civibot

civibot Bot commented Dec 19, 2025

Copy link
Copy Markdown

🤖 Thank you for contributing to CiviCRM! ❤️ We will need to test and review this PR. 👷

Introduction for new contributors...
  • If this is your first PR, an admin will greenlight automated testing with the command ok to test or add to whitelist.
  • A series of tests will automatically run. You can see the results at the bottom of this page (if there are any problems, it will include a link to see what went wrong).
  • A demo site will be built where anyone can try out a version of CiviCRM that includes your changes.
  • If this process needs to be repeated, an admin will issue the command test this please to rerun tests and build a new demo site.
  • Before this PR can be merged, it needs to be reviewed. Please keep in mind that reviewers are volunteers, and their response time can vary from a few hours to a few weeks depending on their availability and their knowledge of this particular part of CiviCRM.
  • A great way to speed up this process is to "trade reviews" with someone - find an open PR that you feel able to review, and leave a comment like "I'm reviewing this now, could you please review mine?" (include a link to yours). You don't have to wait for a response to get started (and you don't have to stop at one!) the more you review, the faster this process goes for everyone 😄
  • To ensure that you are credited properly in the final release notes, please add yourself to contributor-key.yml
  • For more information about contributing, see CONTRIBUTING.md.
Quick links for reviewers...

➡️ Online demo of this PR 🔗

@civibot civibot Bot added the master label Dec 19, 2025
@ufundo

ufundo commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

This is very very cool @totten !

Won't get to testing until after the break but very very keen on the principle.

Comment on lines +171 to +173
###############################################################################
## Below, we stop executing PHP. The rest of the file contains the template
## for "component.js".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't this be in a separate file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixin backports/versioning are generally based on one-file/one-mixin.

From POV of civicrm-core and PHP interpreter, I suppose one could do more files. But it’s definitely messier on civix side. I’d probably want to have a few more needs before biting off that task

Comment on lines +118 to +127
/**
* @return string
*/
private function getTemplate(): string {
$fp = fopen(__FILE__, 'r');
fseek($fp, __COMPILER_HALT_OFFSET__);
$template = stream_get_contents($fp);
fclose($fp);
return $template;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems very odd to me?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fun fact… we actually use __HALT_COMPILER stuff for composer.phar, phpunit.phar, drush.phar, ad nauseam. That’s how you get a CLI executable (top part) combined with an archive (bottom part). You just don’t see it :)

But this is essentially aesthetic. It could use a <<<HEREDOC or a $buffer instead. I thought the payload looked easier to read at the bottom (without extra indentation or escaping stuff).

@ufundo ufundo Dec 19, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have just expected it in a separate file. I guess it's tightly bundled and unlikely to need much development, so I don't mind much, but yes aesthetically it seems a bit over-clever to me.

(Ah sorry read the other response about work to support multiple files. Fair enough I guess)

@ufundo

ufundo commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

One thought: it might be nice to be able to fetch a css file for each component.

});
}

$(()=>{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is the jQuery here?

@totten totten Dec 19, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be needed. My thinking was to defer init until page-start so that the downstream files wouldn’t need to. (Huge portions of Civi JS do this - as a way of saying, “Don’t do anything until all our little addons for jQuery+CRM are registered.”)

Of course, theoretically, the component.js is loading in a mid/late part of coreResources (so a fair amount is already preloaded). And/or, the deferral could be done with vanilla JS.

In aggregate, pages might perform better if this part doesn’t try to constrain load-order. But I think it will put the onus on downstream files to think more about ordering.

Aside: When using ESM, the static imports have to be in the top-level of each file; ESM has a certain load-ordering concept. But stuff coming from CRM.* has a different load-ordering concept. Aesthetically, the downstream ESMs can look a bit messier if they have to abide both — e.g. first you do all your imports at top-level; and if you also use CRM.*, then you wrap your main logic in a deferred function. We could get used to that. (God bless hedonic adaptation…)

@totten totten Dec 28, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading a bit more about modules, it turns out that addModule() (aka <script type=module>) has loading rules very similar to $(function...), so the jQuery isn't doing much.

@totten

totten commented Dec 19, 2025

Copy link
Copy Markdown
Member Author

One thought: it might be nice to be able to fetch a css file for each component.

I believe that Chrome allows ESM’s to load CSS modules. Alas, Firefox doesn’t. So if we wanted to use that exact notation, then we would need to use the newer shim.

Or define a different (and portable) notation for loading CSS modules. (It’s probably not too hard? Would be nice to preserve compat w/import-maps. Might want to look at how polyfill works on Firefox. Maybe something with import.meta.resolve() and a dynamic <link rel=stylesheet>…)

@ufundo

ufundo commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

Or define a different (and portable) notation for loading CSS modules

I was thinking something like:

  • whenever the extension has js/component/my-component.js, check also for the existence of css/component/my-component.css
  • if it exists, include it in the import map, and lazy load it at the same time as my-component.js

@totten
totten force-pushed the master-component-js branch from 0cbe8e2 to cecd2b0 Compare December 19, 2025 23:25
@totten

totten commented Dec 20, 2025

Copy link
Copy Markdown
Member Author

Bundling may be a more involved topic....

I've been playing around with this today... and there are a few angles...

  • One angle is to have PHP do a simple concatenation of the component files. (example implementation as component-bundle-json@1 mixin) This is actually easier than full JS-bundling because WebComponents don't need to be exported. (They might import, but they don't export.)
    • Pro: Minimal code for extensions. No extra requirements for runtime or dev.
    • Con: Can't use relative imports (import * from "../foo.js"). But it does handle virtual-path imports (import * from 'my-extension/js/foo.js')
  • If we had bash or PHP doing simple concatenation during development (publish with extension's .zip file), then we could still support virtual-paths. (We just need to hook into hook_componentJsPaths.)
  • If we had NodeJS doing full bundling during development (webpack or vite or whatever; publish with extension's .zip file), then it can work - with a special proviso for cross-extension imports.
    • Proviso: Need to hook into hook_componentJsPaths to declare tag-name<=>js-file mappings.
    • Proviso: If you import from an extension, then you need to tell the bundler that these are externals.
    • Pro: More sophisticated bundling features
    • Con: More sophisticated setup

@ufundo

ufundo commented Dec 21, 2025

Copy link
Copy Markdown
Contributor

FWIW I wouldn't complicate this with bundling.

For most cases, Http3 gives us decent performance with separate requests; and if a given extension has many many components, it is likely the extension author can do better with a non-generic solution.

Also there are costs in terms of reduced scrutability of console errors; and it generally being a prod/dev divergence, leading to missed bugs.

@totten

totten commented Dec 27, 2025

Copy link
Copy Markdown
Member Author

FWIW I wouldn't complicate this with bundling.

Yeah, component-js@1 doesn't need to solve bundling (at least, don't need a full solution now).

The performance+availability of HTTP/3 (versus bundling) is an empirical question that can be deferred.

IMHO, the main thing is that we have this mapping (["tag-name" => "file-name"]), which is actually a many-to-one relation. (Many tag-names can point to the same file-name, and the file is only loaded one time.) As long as the autoloader continues permitting that kind of relation, we'll still have some options for bundling.

One thought: it might be nice to be able to fetch a css file for each component.

I believe that Chrome allows ESM’s to load CSS modules. Alas, Firefox.. (...shim... or define a ... notation...)

...whenever the extension has js/component/my-component.js, check also for the existence of css/component/my-component.css

OK, it's nice to have a convention; and it's not hard to implement something along those lines (basically, createElement('style')); and if we define the convention, then we can make that compatible with 5.63+ (so it can go live quickly).

To my way of reading, it would be tidier with the my-component.js and my-component.css in adjacent files (same folder). Though the folder-names are more bikesheddy. (If it's a point of contention, I guess we could do a straw-poll on split-files vs adjacent-files...)

Timing-wise, it does make sense to address CSS now -- it affects the shape of the hook-data (i.e. hook_civicrm_componentJsPaths is currently+specifically *.js files), and it affects some naming (component-js@1, componetJsPaths).

Just playing with some of names/phrases for this functionality ("web components", "custom elements", etc):

Layout Comments
Mixin: web-components@1
Hook: hook_civicrm_webComponents
JS: web-component/NAME.js
CSS: web-component/NAME.css
Evokes "Web Component" from web-dev literature.
More verbose.
Adjacent files.
Mixin: components@1
Hook: hook_civicrm_components
JS: js/component/NAME.js
CSS: css/component/NAME.css
Evokes "Web Component" from web-dev literature.
Split files.
Less verbose.
Some confusion -- evokes "CiviCRM Component" (CiviEvent, CiviMail).
Mixin: components@1
Hook: hook_civicrm_components
JS: component/NAME.js
CSS: component/NAME.css
Evokes "Web Component" from web-dev literature.
Adjacent files.
Less verbose.
Some confusion -- evokes "CiviCRM Component" (CiviEvent, CiviMail).
Mixin: components@1
Hook: hook_civicrm_components
JS: comp/NAME.js
CSS: comp/NAME.css
Evokes "Web Component" from web-dev literature.
Adjacent files.
Very short (abbreviated).
Slightly less evocative of "CiviCRM Component" (but still somewhat)
Mixin: custom-elements@1
Hook: hook_civicrm_customElements
JS: custom-element/NAME.js
CSS: custom-element/NAME.css
Evokes "customElement" from browser literature.
Evokes "element" from DOM.
A bit verbose.
No naming conflict in Civi-land.
Mixin: elements@1
Hook: hook_civicrm_elements
JS: element/NAME.js
CSS: element/NAME.css
Evokes "customElement" from browser literature.
Evokes "element" from DOM.
Less verbose.
No naming conflict in Civi-land.
Mixin: tags@1
Hook: hook_civicrm_tags
JS: tag/NAME.js
CSS: tag/NAME.css
Evokes "tag" from HTML literature.
Very short.
Some confusion -- lots of Civi things have "tags".

After looking at that rundown, element is surprising.

  • Initially, it feels too simple (almost presumptuous)... like it should have some major problem.
  • It hits a lot high notes (evoking the generalist terminology; short) without any major drawback (not abbreviated, not conflicted).
  • And if we succeed in switching more things from AngularJS to WebComponents/CustomElements, then (at the end) a name like element/ (or component/) would be proportionate to its importance...

@totten

totten commented Dec 28, 2025

Copy link
Copy Markdown
Member Author

Closing in favor of #34348.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants