Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions mixin/component-js@1/mixin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?php

/**
* Allow an extension to autoload WebComponents (*.js, *.mjs).
*
* Components are loaded from eponymous files. For example:
*
* <hello-world> --> "$EXTENSION/js/component/hello-world.js"
*
* All files are treated as ECMAScript Modules, so they may use `import` statements.
*
* If you need to register components with different file-names, use hook_civicrm_componentJsPaths(&$paths).
*
* If you're doing funny business (like generating JS files) and need to refresh the index, call:
*
* Civi::service('component-js@1')->flush();
*
* @mixinName component-js
* @mixinVersion 1.0.0
* @since 6.11
*
* Note: Requires hook_esmImportMap (v5.63+).
* Note: Requires core mixin-loader (v5.45+; built-in version-handling).
*/
namespace Civi\Mixin\ComponentJsV1;

use Civi;
use CRM_Utils_String;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;

class ComponentJs {

const ASSET = 'component.js';

const SUBDIR = 'js/component';

protected $registered = FALSE;

public static function instance(): ComponentJs {
if (!isset(Civi::$statics[static::CLASS])) {
Civi::$statics[static::CLASS] = new ComponentJs();
}
return Civi::$statics[static::CLASS];
}

public function register(): void {
if ($this->registered) {
return;
}
$this->registered = TRUE;

Civi::dispatcher()->addListener('&hook_civicrm_container', [$this, 'container']);
Civi::dispatcher()->addListener('&hook_civicrm_buildAsset', [$this, 'buildAsset']);
Civi::dispatcher()->addListener('&hook_civicrm_alterBundle', [$this, 'alterBundle']);
}

public function container(ContainerBuilder $container): void {
$container->setDefinition('component-js@1', new Definition(static::CLASS))
->setFactory([static::CLASS, 'instance'])
->setPublic(TRUE);
}

/**
* @internal
* @see \CRM_Utils_Hook::buildAsset()
*/
public function buildAsset($asset, $params, &$mimeType, &$content) {
if ($asset !== static::ASSET) {
return;
}

$mimeType = 'text/javascript';
$staticPaths = $this->getStaticPaths();
$content = strtr($this->getTemplate(), [
'COMPONENT_JS_STATIC_PATHS' => json_encode($staticPaths, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
]);
}

/**
* @internal
* @see \CRM_Utils_Hook::alterBundle()
*/
public function alterBundle(\CRM_Core_Resources_Bundle $bundle) {
if ($bundle->name === 'coreResources') {
$url = Civi::service('asset_builder')->getUrl(static::ASSET, ['id' => $this->getId()]);
$bundle->addModuleUrl($url);
}
}

/**
* @internal
* @return array
*/
public function getStaticPaths(): array {
$componentPaths = [];
$event = Civi\Core\Event\GenericHookEvent::create(['componentPaths' => &$componentPaths]);
Civi::dispatcher()->dispatch('hook_civicrm_componentJsPaths', $event);
return $componentPaths;
}

/**
* @return string
*/
public function getId(): string {
$id = Civi::cache('long')->get('component-js-id');
if ($id === NULL) {
$id = CRM_Utils_String::createRandom(8, CRM_Utils_String::ALPHANUMERIC);
Civi::cache('long')->set('component-js-id', $id, 7 * 24 * 60 * 60);
}
return $id;
}

public function flush(): void {
Civi::cache('long')->delete('component-js-id');
}

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

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)


}

/**
* As a mixin, we receive a notification for each extension that enables `component-js@1`.
*
* @param \CRM_Extension_MixInfo $mixInfo
* @param \CRM_Extension_BootCache $bootCache
*/
return function ($mixInfo, $bootCache) {

ComponentJs::instance()->register();

/**
* Register this extension with the ESM import-map ('my-extension/' => '/var/www/sites/default/civicrm/ext/my-extension').
*
* @see \CRM_Utils_Hook::esmImportMap()
*/
Civi::dispatcher()->addListener('&hook_civicrm_esmImportMap', function(\Civi\Esm\ImportMap $importMap) use ($mixInfo) {
if ($mixInfo->isActive()) {
$importMap->addPrefix($mixInfo->longName . '/', $mixInfo->longName);
}
}, 500);

/**
* Statically register each *.js or *.mjs file from $EXTENSION/js/component/.
*/
Civi::dispatcher()->addListener('&hook_civicrm_componentJsPaths', function(array &$components) use ($mixInfo) {
if ($mixInfo->isActive()) {
$files = array_merge(
// Only JS files that look like valid tag-names. Allows you to (eg) create `_bundle.js` which is handled separately.
(array) glob($mixInfo->getPath(ComponentJs::SUBDIR . '/[a-z]*-*.js')),
(array) glob($mixInfo->getPath(ComponentJs::SUBDIR . '/[a-z]*-*.mjs')),
);
foreach ($files as $file) {
$tag = preg_replace('/\.(js|mjs)$/', '', basename($file));
$components[$tag] = $mixInfo->longName . '/' . ComponentJs::SUBDIR . '/' . basename($file) . '?ts=' . filemtime($file);
}
}
}, 1000);

};

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

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

__HALT_COMPILER();
const $ = window.CRM.$;
const loading = new Set();
const registry = window.CRM.componentJs = COMPONENT_JS_STATIC_PATHS;

function maybeLoad(tagName) {
if (!registry[tagName] || customElements.get(tagName) || loading.has(tagName)) {
return;
}

loading.add(tagName);
import(registry[tagName]).catch(err => {
console.error(`Failed to load ${tagName}`, err);
loading.delete(tagName);
});
}

$(()=>{

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.


// Scan initial DOM
document.querySelectorAll('*').forEach(el => {
maybeLoad(el.tagName.toLowerCase());
});


// Watch for new nodes
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) {
maybeLoad(node.tagName.toLowerCase());
node.querySelectorAll?.('*').forEach(el =>
maybeLoad(el.tagName.toLowerCase())
);
}
}
}
});

observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
});