Declarative Custom Element Definition with defineWithFeatures
May 22, 2026 · View on GitHub
defineWithFeatures enables declarative custom element definition from JSON-serializable configuration. It resolves async feature implementations, creates a subclass, wires up features, and registers the element — all from a simple config object.
Use Case
A base "abstract" custom element declares support for many features (with async fallback spawns for lazy loading). Concrete elements are defined declaratively — picking which features to activate and providing per-element configuration — without writing any JavaScript class code.
This is designed to work with mount-observer's cede scripts, but can be used standalone.
API
import { defineWithFeatures } from 'assign-gingerly/defineWithFeatures.js';
await defineWithFeatures(
tagName, // Custom element tag name to define (e.g., 'time-ticker')
baseTagName, // Tag name of the base class to extend (e.g., 'el-maker')
config, // JSON-serializable feature configuration
registry?, // Optional scoped CustomElementRegistry (defaults to global)
options? // Optional { onSubclassCreated(NewCtr) {} }
);
Example
The base class (defined once, shared by many elements)
class ElMaker extends HTMLElement {
propagator = new EventTarget();
#internals;
static supportedFeatures = {
roundabout: {
// Async fallback — only loaded when a derived element uses it
fallbackSpawn: () => import('roundabout-lib/RoundaboutFeature.js')
.then(m => m.RoundaboutFeature),
callbackForwarding: ['connectedCallback'],
getSharedContext(instance) {
return { internals: instance.#internals, hostPropagator: instance.propagator };
}
},
truthSourcer: {
fallbackSpawn: () => import('truth-sourcer/TruthSourcer.js')
.then(m => m.TruthSourcer),
callbackForwarding: ['connectedCallback', 'attributeChangedCallback']
},
faceUp: {
fallbackSpawn: () => import('face-up/FaceUp.js').then(m => m.FaceUp),
callbackForwarding: ['connectedCallback', 'disconnectedCallback']
}
};
constructor() {
super();
this.#internals = this.attachInternals();
}
}
customElements.define('el-maker', ElMaker);
Declarative definition (from a cede script or JS)
await defineWithFeatures('time-ticker', 'el-maker', {
assignFeatures: {
roundabout: {
customData: { template: myTemplate, bindings: myBindings },
withAttrs: { base: 'ra', mode: '${base}-mode' },
callbackForwarding: ['connectedCallback']
},
truthSourcer: {
callbackForwarding: ['connectedCallback', 'attributeChangedCallback']
}
}
});
// 'time-ticker' is now a fully defined custom element
// with roundabout and truthSourcer features activated
From a cede script in HTML
<time-ticker>
<script type="cede" data-extends="el-maker">{
"assignFeatures": {
"roundabout": {
"customData": {...},
"withAttrs": {...},
"callbackForwarding": ["connectedCallback"]
},
"truthSourcer": {
"callbackForwarding": ["connectedCallback", "attributeChangedCallback"]
}
}
}</script>
</time-ticker>
mount-observer parses this script tag and calls defineWithFeatures('time-ticker', 'el-maker', parsedJSON).
How It Works
-
Waits for the base class — if
'el-maker'isn't defined yet, awaitscustomElements.whenDefined('el-maker'). -
Resolves async fallback spawns — for each feature key in the config, resolves the base class's
fallbackSpawn(if async). All spawns are resolved in parallel. Results are cached per base class so repeated definitions don't re-import. -
Creates a subclass — dynamically extends the base class. Inherits
supportedFeatures,getSharedContext, etc. -
Calls
assignFeatures— passes the resolved spawns + the JSON config. SequentialonAssignedruns, inter-feature communication works. -
Defines the element — registers in the provided (or global) registry.
Spawn Caching
Resolved fallback spawns are cached per base class. If you define 10 elements extending 'el-maker', the async imports for each feature only happen once:
// First call — imports roundabout, truthSourcer, faceUp
await defineWithFeatures('element-a', 'el-maker', configA);
// Second call — uses cached spawns (no re-import)
await defineWithFeatures('element-b', 'el-maker', configB);
Scoped Registry Support
Pass a scoped registry as the fourth argument:
const scopedRegistry = new CustomElementRegistry();
await defineWithFeatures('my-ticker', 'el-maker', config, scopedRegistry);
Options: onSubclassCreated callback
The fifth parameter accepts an options object with an onSubclassCreated callback. This fires after the subclass is created but before registry.define() — the right moment to set static properties that the element's constructor or connectedCallback might need during synchronous upgrade:
await defineWithFeatures('time-ticker', 'el-maker', config, registry, {
onSubclassCreated(NewCtr) {
// Set static properties before any instances are created
NewCtr.seedRef = new WeakRef(scriptEl);
}
});
Why this matters: When registry.define(tagName, NewCtr) is called, the browser synchronously upgrades any existing elements of that tag in the DOM. If the class's connectedCallback reads a static property (like this.constructor.seedRef), it must already be set. The callback guarantees this timing.
What's NOT in the JSON config
spawn— always comes from the base class'sfallbackSpawn(resolved automatically).getSharedContext— defined on the base class (has access to#privatefields).validateShape— defined on the base class.onAssigned— defined on the spawn class itself.
The JSON config only contains deployment-time settings: customData, withAttrs, callbackForwarding.