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

  1. Waits for the base class — if 'el-maker' isn't defined yet, awaits customElements.whenDefined('el-maker').

  2. 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.

  3. Creates a subclass — dynamically extends the base class. Inherits supportedFeatures, getSharedContext, etc.

  4. Calls assignFeatures — passes the resolved spawns + the JSON config. Sequential onAssigned runs, inter-feature communication works.

  5. 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's fallbackSpawn (resolved automatically).
  • getSharedContext — defined on the base class (has access to #private fields).
  • validateShape — defined on the base class.
  • onAssigned — defined on the spawn class itself.

The JSON config only contains deployment-time settings: customData, withAttrs, callbackForwarding.