npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

assign-gingerly

v0.0.51

Published

This package provides a utility function for carefully merging one object into another.

Downloads

2,743

Readme

assign-gingerly and assign-tentatively

Playwright Tests NPM version How big is this package in your project?

Introduction

This package starts out innocently enough -- it provides two utility functions for carefully merging one object into another. This is a primitive sorely lacking in the web, and this package is a polyfill for what we (me with a lot of help from AI) would like to see built into the platform. We make no apologies about adding these features directly to the underlying API's, as it is part of a proposal which is sitting there gathering dust, with no apparent alternatives under consideration. In particular the reference:

import 'assign-gingerly/object-extension.js';

has the "side effect" of enhancing the platform API in a way that this proposal can only hope the platform chooses to adopt in the future (or some variation).

One can achieve the same functionality with a little more work, and "playing nicer" with the platform by importing assign-gingerly.js and/or assign-tentatively.js, which has no such side effects.

Object Extension Pattern

Not only does this polyfill package allow merging data properties onto objects that are expecting them, this polyfill also provides the ability to merge augmented behavior onto run-time objects without sub classing all such objects of the same type. This includes the ability to spawn an instance of a class and "merge" it into the API of the original object in an elegant way that is easy to wrap one's brain around, without ever blocking access to the original object or breaking it.

So we are providing a form of the "Decorator Pattern" or perhaps more accurately the Extension Object Pattern as tailored for the quirks of the web.

Custom Registries

On top of that, this polyfill package builds on the newly minted Custom Element Registry, adding additional sub-registries:

  1. enhancementRegistry object on top of the customElementRegistry object associated with all elements, to be able to lazy load object extensions on demand while avoiding namespace conflicts, and, importantly, as a basis for defining custom attributes associated with the enhancements.

  2. itemscopeRegistry for Itemscope Managers to automatically associate a function prototype or class instance with the itemscope attribute of an HTMLElement.

  3. featuresRegistry for Custom Element Features to support dependency injection of composable feature classes or function prototypes onto custom element prototypes via lazy getters.

So in our view this package helps fill the void left by not supporting the "is" attribute for built-in elements (but is not a complete solution, just a critical building block). Mount-observer and custom enhancements builds on top of the critical role that assign-gingerly plays.

Anyway, let's start out detailing the more innocent features of this package / polyfill.

The two utility functions are:

assignGingerly

assignGingerly builds on Object.assign. Like Object.assign, the object getting assigned can often be a JSON stringified object. Some of the unusual syntax we see with assignGingerly is there to continue to support JSON deserialized objects as a viable argument to be passed.

assign-gingerly adds support for:

  1. Carefully merging in nested properties.
  2. Dependency injection based on a mapping protocol.

The second fundamental utility function is:

assignTentatively

assignTentatively provides a far more limited subset of functionality compared to assignGingerly. The tradeoff is that assignTentatively can do something important assignGingerly cannot do -- be "reversed". This can be quite useful for some scenarios. Think of how css "turns on" visual effects while conditions are met, then reverts to how things were before the conditions were met when the conditions are no longer met, as if nothing happened. Another example is allowing user edits to be rolled back as they repeatedly hit "ctrl+z".

Example 1 - assignGingerly as a "superset" of Object.assign:

const sourceObj = {hello: 'world'};
sourceObj.assignGingerly({hello: 'Venus', foo: 'bar'});
// Because none of the keys of the second parameter start with "?.", 
// nor includes any symbols keys,
// assign gingerly produces identical results 
// as Object.assign,  and is synchronous:
console.log(sourceObj);
//{hello: 'Venus', foo: 'bar'}

Example 2 Merging into an existing sub object

<body>
    <input id=myInput>
</body>
const oInput = document.querySelector('#myInput');
oInput.assignGingerly({'?.style?.height': '15px'});
console.log(oInput.style.height);
// 15px

This can go many levels deep.

Example 3 Deeply nested

const obj = {};
assignGingerly(obj, {
    '?.style?.height': '15px',
    '?.a?.b?.c': {
        d: 'hello',
        e: 'world'
    }
});
console.log(obj);
// {
//   a: {b: c: {d: 'hello', e: 'world'}},
//   style: {height: '15px'}
// }

When the right hand side of an expression is an object, assignGingerly behavior depends on the context:

  • For nested paths (starting with ?.): recursively merges into nested objects, creating them if needed
  • For plain keys: performs simple assignment (like Object.assign), unless the target property is readonly, an accessor, or the current value's class defines static assignTo (see Examples 3a, 3b, and the assignTo section below)

Of course, just as Object.assign led to object spread notation, assignGingerly could lead to some sort of deep structural JavaScript syntax, but that is outside the scope of this polyfill package.

Example 3-plain - Plain Key Object Assignment

For plain keys (without ?. prefix), assignGingerly performs simple assignment, just like Object.assign:

const obj = {};
const template = document.createElement('template');
template.innerHTML = '<div>Hello</div>';

assignGingerly(obj, {
    template: template,
    config: { theme: 'dark', lang: 'en' }
});

console.log(obj.template === template); // true - direct assignment
console.log(obj.config); // { theme: 'dark', lang: 'en' } - direct assignment

This is different from nested paths, which create intermediate objects:

const obj = {};
assignGingerly(obj, {
    '?.config?.theme': 'dark'
});
console.log(obj.config); // { theme: 'dark' } - intermediate object created

Example 3a - Automatic Readonly Property Detection

assignGingerly automatically detects readonly properties and merges into them instead of attempting to replace them. This makes working with DOM properties like dataset ergonomic:

const div = document.createElement('div');
assignGingerly(div, {
    dataset: {
        userId: '123',
        userName: 'Alice'
    }
});
console.log(div.dataset.userId);   // '123'
console.log(div.dataset.userName); // 'Alice'

How it works:

When assignGingerly encounters an object value being assigned to an existing property, it checks if that property is readonly:

  • Data properties with writable: false
  • Accessor properties with a getter but no setter (e.g., dataset, shadowRoot)

If the property is readonly and its current value is an object, assignGingerly automatically merges into it recursively.

Note on element.style: The style property has both a getter and a setter, so it is not treated as readonly. Use nested path syntax instead:

// Use nested path syntax for style
assignGingerly(div, {
    '?.style?.height': '15px',
    '?.style?.width': '20px'
});

Examples of readonly properties that trigger merging:

  • HTMLElement.dataset - getter only, no setter
  • Custom objects with Object.defineProperty(obj, 'prop', { value: {}, writable: false })
  • Accessor properties with getter only: Object.defineProperty(obj, 'prop', { get() { return {}; } })

Error handling:

If you try to merge an object into a readonly property whose current value is a primitive, assignGingerly throws a descriptive error:

const obj = {};
Object.defineProperty(obj, 'readonlyString', {
    value: 'immutable',
    writable: false
});

assignGingerly(obj, {
    readonlyString: { nested: 'value' }
});
// Error: Cannot merge object into readonly primitive property 'readonlyString'

Additional examples:

// Dataset property
const div = document.createElement('div');
assignGingerly(div, {
    dataset: {
        userId: '123',
        userName: 'Alice'
    }
});
console.log(div.dataset.userId);   // '123'
console.log(div.dataset.userName); // 'Alice'

// Custom readonly property
const config = {};
Object.defineProperty(config, 'settings', {
    value: {},
    writable: false
});
assignGingerly(config, {
    settings: {
        theme: 'dark',
        lang: 'en'
    }
});
console.log(config.settings.theme); // 'dark'

Example 3b - Class Instances Are Normally Replaced

Unlike readonly/accessor properties, class instances on writable properties are replaced by simple assignment, just like plain objects. This allows you to swap one object for another without unexpected merging:

class FakeDocumentFragment {
  constructor() {
    this.nodeType = 11;
    this.childNodes = [];
  }
}

const obj = {
  clone: new FakeDocumentFragment()
};

const element = document.createElement('div');

// Replace the DocumentFragment with the actual element
assignGingerly(obj, {
  clone: element
});

console.log(obj.clone === element); // true - replaced, not merged

Why replacement instead of merging?

In real-world use cases, you often need to replace one object with another of a completely different type. For example, replacing a cloned DocumentFragment with the actual web component element. Automatic merging would corrupt the target by mixing properties from incompatible types.

Exception: classes with static assignTo

If the current value is an instance of a class that defines static assignTo, that method is called instead of replacing. This allows classes to opt into custom assignment behavior (e.g., reactive models, validated records, iterable collections with private lists):

class TodoList {
    #items = [];
    *[Symbol.iterator]() { yield* this.#items; }
    static assignTo(instance, rhs) {
        if (Array.isArray(rhs)) instance.#items = [...rhs];
        else Object.assign(instance, rhs);
    }
}

const app = { todos: new TodoList() };
assignGingerly(app, { todos: ['Buy milk', 'Walk dog'] });
// TodoList.assignTo is called — replaces internal list, not the instance
console.log([...app.todos]); // ['Buy milk', 'Walk dog']
console.log(app.todos instanceof TodoList); // true — instance preserved

Readonly/accessor properties are still merged:

The distinction is clear:

  • Writable data properties: replaced (unless class defines static assignTo)
  • Readonly data properties (writable: false): merged into
  • Getter-only accessor properties (no setter): merged into
  • Getter+setter accessor properties (e.g., style): setter runs with the value as-is
const div = document.createElement('div');

assignGingerly(div, {
  dataset: { userId: '123' },       // Getter-only - merged
  '?.style?.height': '100px'        // Use nested path for style
});

console.log(div.dataset.userId);  // '123'
console.log(div.style.height);    // '100px'

Example 3c - Method Calls with withMethods

The withMethods option allows you to call methods as part of property assignment, which is particularly useful for DOM APIs like classList and part:

import assignGingerly from 'assign-gingerly';

const element = document.createElement('div');

// Simple method calls
assignGingerly(element, {
  '?.classList?.add': 'myClass',
  '?.part?.add': 'myPart'
}, { withMethods: ['add'] });

console.log(element.classList.contains('myClass')); // true
console.log(element.part.contains('myPart'));       // true

How it works:

When a path segment matches a name in the withMethods array/set:

  • If it's the last segment: the method is called with the RHS value as an argument
  • If it's a middle segment and the next segment is also a method: called with no arguments
  • If it's a middle segment and the next segment is NOT a method: called with the next segment as a string argument
  • If the property is not a function: silently skipped

Array arguments:

Arrays are spread as multiple arguments:

assignGingerly(element, {
  '?.setAttribute': ['data-id', '123']
}, { withMethods: ['setAttribute'] });

// Equivalent to: element.setAttribute('data-id', '123')

Chained method calls:

Methods can be chained to navigate through object hierarchies:

const elementRef = {
  deref() { return this.element; },
  element: document.createElement('div')
};

assignGingerly(elementRef, {
  '?.deref?.classList?.add': 'active'
}, { withMethods: ['deref', 'add'] });

// Equivalent to: elementRef.deref().classList.add('active')

Complex chaining with real DOM elements:

Methods are called on the objects found through chained accessors, not just on the root object:

const div = document.createElement('div');
div.innerHTML = `
  <my-element>
    <your-element></your-element>
  </my-element>
`;

assignGingerly(div, {
  '?.querySelector?.my-element?.querySelector?.your-element?.classList?.add': 'highlighted'
}, { withMethods: ['querySelector', 'add'] });

// Equivalent to:
// div.querySelector('my-element').querySelector('your-element').classList.add('highlighted')

const yourElement = div.querySelector('my-element')?.querySelector('your-element');
console.log(yourElement?.classList.contains('highlighted')); // true

The key insight: querySelector is called on each intermediate result in the chain. First on div, then on the my-element result, demonstrating that methods work naturally with the object hierarchy you're navigating.

Using Set for withMethods:

For better performance with many methods, use a Set:

const methods = new Set(['add', 'remove', 'toggle', 'setAttribute']);

assignGingerly(element, {
  '?.classList?.add': 'class1',
  '?.classList?.remove': 'class2',
  '?.setAttribute': ['data-value', '42']
}, { withMethods: methods });

Mixing methods and normal assignments:

assignGingerly(element, {
  '?.classList?.add': 'active',
  '?.dataset?.userId': '123',
  '?.style?.height': '100px'
}, { withMethods: ['add'] });

// classList.add() is called
// dataset.userId and style.height are assigned normally

Benefits:

  • Cleaner syntax for DOM manipulation
  • Works with any object methods, not just DOM APIs
  • Silent failure for non-existent methods (garbage in, garbage out)
  • Supports method chaining and complex navigation patterns

Example 3d - Aliasing with aka

The aka option allows you to define custom shortcuts (aliases) for property and method names, reducing verbosity in repetitive patterns. This is inspired by jQuery's $ shortcut for querySelectorAll, but fully customizable.

import assignGingerly from 'assign-gingerly';

const div = document.createElement('div');
div.innerHTML = `
  <my-element>
    <your-element></your-element>
  </my-element>
`;

// Without aliases (verbose)
assignGingerly(div, {
  '?.querySelector?.my-element?.classList?.add': 'highlighted',
  '?.querySelector?.your-element?.classList?.add': 'active'
}, { withMethods: ['querySelector', 'add'] });

// With aliases (concise)
assignGingerly(div, {
  '?.$?.my-element?.c?.+': 'highlighted',
  '?.$?.your-element?.c?.+': 'active'
}, { 
  withMethods: ['querySelector', 'add'],
  aka: { '$': 'querySelector', 'c': 'classList', '+': 'add' }
});

How it works:

  • Aliases are substituted before path evaluation
  • Matches complete tokens between ?. delimiters (not substrings)
  • Works for both properties and methods
  • Single or multi-character aliases supported

Reserved characters:

Cannot be used in aliases: space ( ), backtick (`)

Multi-character aliases:

assignGingerly(element, {
  '?.qs?.my-element?.cl?.add': 'highlighted'
}, { 
  withMethods: ['querySelector', 'add'],
  aka: { 'qs': 'querySelector', 'cl': 'classList' }
});

Multiple aliases in one path:

assignGingerly(element, {
  '?.c?.+': 'class1',
  '?.p?.+': 'part1',
  '?.ds?.userId': '123'
}, { 
  withMethods: ['add'],
  aka: { 
    'c': 'classList', 
    'p': 'part',
    'ds': 'dataset',
    '+': 'add'
  }
});

// Equivalent to:
// element.classList.add('class1')
// element.part.add('part1')
// element.dataset.userId = '123'

Benefits:

  • Reduces verbosity in repetitive patterns
  • Fully customizable shortcuts
  • Improves readability when you have many similar operations
  • Works seamlessly with withMethods

Example 3e - ForEach with @each

The @each symbol allows you to iterate over collections and apply operations to each item. This works with any iterable including Arrays, NodeList, HTMLCollection, and more.

import assignGingerly from 'assign-gingerly';

const div = document.createElement('div');
div.innerHTML = `
  <my-element></my-element>
  <my-element></my-element>
  <my-element></my-element>
`;

// Apply to each element in the collection
assignGingerly(div, {
  '?.querySelectorAll?.my-element?.@each?.classList?.add': 'highlighted'
}, { withMethods: ['querySelectorAll', 'add'] });

// All my-element elements now have the 'highlighted' class

How it works:

  • @each marks the point where iteration begins
  • Everything before @each navigates to the iterable
  • Everything after @each is applied to each item in the collection
  • Empty collections are handled gracefully (no errors)

With regular arrays:

const obj = {
  items: [
    { value: null },
    { value: null },
    { value: null }
  ]
};

assignGingerly(obj, {
  '?.items?.@each?.value': 'test'
});

// All items now have value: 'test'

Nested forEach:

const obj = {
  groups: [
    { items: [{ value: null }, { value: null }] },
    { items: [{ value: null }, { value: null }] }
  ]
};

assignGingerly(obj, {
  '?.groups?.@each?.items?.@each?.value': 'nested'
});

// All nested items now have value: 'nested'

With aliases:

assignGingerly(div, {
  '?.qsa?.my-element?.*?.c?.+': 'highlighted'
}, { 
  withMethods: ['querySelectorAll', 'add'],
  aka: { 
    'qsa': 'querySelectorAll',
    'c': 'classList',
    '+': 'add',
    '*': '@each'  // Alias * to @each for brevity
  }
});

Method calls on each item:

assignGingerly(div, {
  '?.querySelectorAll?.div?.@each?.setAttribute': ['data-id', '123']
}, { withMethods: ['querySelectorAll', 'setAttribute'] });

// All div elements now have data-id="123"

Accessing iterable properties:

When you omit @each, you access properties on the iterable itself, not its items:

const obj = {
  items: [1, 2, 3],
  customProp: null
};

// Set property on the array itself
assignGingerly(obj, {
  '?.items?.customProp': 'test'
});

console.log(obj.items.customProp); // 'test'

Benefits:

  • Works with any iterable (Arrays, NodeList, HTMLCollection, etc.)
  • Supports nested iterations
  • Integrates seamlessly with withMethods and aka
  • Clear distinction between iterating and accessing iterable properties
  • Graceful handling of empty collections

Example 3f - Reactive Iteration with @eachTime

The @eachTime symbol enables reactive iteration over elements as they mount or appear dynamically. Unlike @each which operates on static collections, @eachTime subscribes to events and applies operations to elements as they arrive over time.

Important: This feature requires an AbortSignal for cleanup and is designed to work with EventTarget objects that emit 'mount' events (such as mount-observer).

import assignGingerly from 'assign-gingerly';

const controller = new AbortController();
const div = document.createElement('div');

// Assume mountObserver is an IMountObserver instance that emits 'mount' events
// when new elements matching 'my-element' are added to the DOM

assignGingerly(div, {
  '?.mountObserver?.@eachTime?.classList?.add': 'highlighted'
}, { 
  withMethods: ['add'],
  signal: controller.signal  // Required for cleanup
});

// As elements mount, they automatically get the 'highlighted' class
// Later, cleanup all listeners:
controller.abort();

How it works:

  • @eachTime marks the point where reactive iteration begins
  • Everything before @eachTime must navigate to an EventTarget
  • The EventTarget must emit 'mount' events with a mountedElement property
  • Everything after @eachTime is applied to each mounted element
  • Event listeners are automatically cleaned up when the AbortSignal is aborted

With method calls:

const controller = new AbortController();

assignGingerly(div, {
  '?.mountObserver?.@eachTime?.setAttribute': ['data-mounted', 'true']
}, { 
  withMethods: ['setAttribute'],
  signal: controller.signal
});

// Each mounted element gets data-mounted="true"

With aliases:

const controller = new AbortController();

assignGingerly(div, {
  '?.mo?.@*?.c?.+': 'active'
}, { 
  withMethods: ['add'],
  aka: { 
    'mo': 'mountObserver',
    '@*': '@eachTime',
    'c': 'classList',
    '+': 'add'
  },
  signal: controller.signal
});

Cleanup is required:

const controller = new AbortController();

// Setup reactive iteration
assignGingerly(div, {
  '?.mountObserver?.@eachTime?.classList?.add': 'mounted'
}, { 
  withMethods: ['add'],
  signal: controller.signal
});

// Later, when you're done observing:
controller.abort();  // Removes all event listeners

// Attempting to use @eachTime without a signal throws an error:
assignGingerly(div, {
  '?.mountObserver?.@eachTime?.classList?.add': 'mounted'
}, { withMethods: ['add'] });
// Error: @eachTime requires an AbortSignal in options.signal for cleanup

Key differences from @each:

| Feature | @each | @eachTime | |---------|-------|-----------| | Type | Static iteration | Reactive iteration | | Timing | Immediate (synchronous) | Over time (asynchronous) | | Use case | Existing collections | Elements appearing dynamically | | Cleanup | Not needed | Required (AbortSignal) | | Requirements | Any iterable | EventTarget with 'mount' events |

Benefits:

  • Declarative reactive programming without RxJS complexity
  • Automatic cleanup via standard AbortSignal API
  • JSON-serializable configuration (behavior is in implementation)
  • Fire-and-forget async pattern (doesn't block)
  • Minimal weight impact (~3% when not used, dynamically loaded when needed)

Limitations:

  • Requires EventTarget that emits 'mount' events
  • AbortSignal is mandatory for cleanup
  • Testing is done in mount-observer package (no tests in assign-gingerly)
  • Single @eachTime per path (nested @eachTime not currently supported)

While we are in the business of passing values of object A into object B, we might as well add some extremely common behavior that allows updating properties of object B based on the current values of object B -- things like incrementing, toggling, and deleting. Deleting is critical for assignTentatively, but is included with both functions.

Example 4 - Incrementing values with += command

The += command allows us to increment numeric values and concatenate string values:

const obj = {
    a: {
        b: {
            c: 2
        }
    }
};
assignGingerly(obj, {
    '?.a?.b?.c +=': 3,
    '?.a?.d?.e +=': -2
});
console.log(obj);
// {
//   a: {
//     b: { c: 5 },      // 2 + 3 = 5
//     d: { e: -2 }      // non-existent path created with value -2
//   }
// }

The += command syntax is <path> += where the path uses the ?. nested notation for nested properties, or a plain key for direct properties. The right-hand side value is added to the existing value using +=. If the path doesn't exist, it's created and set directly to the value.

Behavior by type:

| LHS type | RHS type | Result | |----------|----------|--------| | number | number | addition (2 += 35) | | string | any | string concatenation ("hello" += 3"hello3") | | array | array | array concatenation ([1,2] += [3,4][1,2,3,4]) | | array | non-array | push single item ([1,2] += 3[1,2,3]) | | undefined/missing | any | direct assignment |

const obj = {
    tags: ['a', 'b'],
    name: 'hello'
};
assignGingerly(obj, {
    '?.tags +=': ['c', 'd'],   // array concat: ['a', 'b', 'c', 'd']
    '?.name +=': ' world'      // string concat: 'hello world'
});

// Push a single item
assignGingerly(obj, { '?.tags +=': 'e' }); // ['a', 'b', 'c', 'd', 'e']

Example 5 - Toggling boolean values and negating

The =! command allows us to toggle boolean values:

const obj = {
    a: {
        b: {
            c: true
        }
    }
};
assignGingerly(obj, {
    '?.a?.b?.c =!': '.',      // Toggle itself
    // Negates another property.  
    // The RHS doesn't spawn new objects
    // and evaluates to true if it doesn't exist
    '?.a?.d?.c =!': '?.a?.d?.e'       
});
console.log(obj);
// {
//   a: {
//     b: { c: false }           // Toggled immediately
//     // d doesn't exist yet
//   }
// }

The =! command syntax is <path> =! where the path uses the ?. nested notation for nested properties, or a plain key for direct properties.

For existing values, the toggle is performed using JavaScript's logical NOT operator (!value), regardless of what type it is.

Example 6 - Deleting properties with -= command

The -= command allows us to delete properties from objects:

const obj = {
    a: {
        b: {
            c: true,
            d: 'hello'
        }
    }
};
assignGingerly(obj, {
    //deletes obj.a.b.c if it exists
    '?.a?.b -=': 'c',      
});
console.log(obj);
// {
//   a: {
//     b: { d: 'hello' }          // c deleted 
//   }
// }

The -= command syntax is <path> -= where the path points to the parent object. The right-hand side value specifies what to delete:

  • String: Delete a single property
  • Array: Delete multiple properties
const obj = {
    data: {
        keep: 'this',
        remove1: 'delete',
        remove2: 'delete',
        remove3: 'delete'
    }
};

// Delete single property
assignGingerly(obj, { '?.data -=': 'remove1' });

// Delete multiple properties
assignGingerly(obj, { '?.data -=': ['remove2', 'remove3'] });

console.log(obj);
// {
//   data: { keep: 'this' }
// }

Important notes:

  • The path specifies the parent object, not the property to delete
  • Non-existent properties are silently skipped
  • If the parent path doesn't exist, the command is silently skipped
  • For root-level deletion, use -= (space before -=)

Example 7 - Reversible assignments with assignTentatively

The assignTentatively function works like assignGingerly but with a powerful addition: reversibility. It tracks changes and generates a reversal object that can undo all modifications:

import assignTentatively from 'assign-gingerly/assignTentatively';

const obj = { f: { g: 'hello' } };
const reversal = {};

assignTentatively(obj, {
    '?.style?.height': '15px',
    '?.a?.b?.c': {
        d: 'hello',
        e: 'world'
    },
    '?.f?.g': 'bye'
}, { reversal });

console.log(obj);
// {
//   f: { g: 'bye' },
//   style: { height: '15px' },
//   a: { b: { c: { d: 'hello', e: 'world' } } }
// }

console.log(reversal);
// {
//   ' -=': 'a',
//   ' -=': 'style',
//   '?.f?.g': 'hello'
// }

// Later, restore to original state:
assignTentatively(obj, reversal);
console.log(obj);
// {
//   f: { g: 'hello' }
// }

Key differences from assignGingerly:

  • No registry/DI support: Dependency injection features are not available (pass it in and it will be ignored). Dependency injection is discussed below.
  • Reversal tracking: Maintains a reversal object that records:
    • Original values of modified existing properties
    • -= commands for newly created top-level paths (e.g., -=: 'a' for paths created under a)
    • Original values for deleted properties

Reversal guarantee:

const reversal = {};
const obj = {...originalObj};
const string1 = JSON.stringify(obj);

assignTentatively(obj, sourceChanges, { reversal });
assignTentatively(obj, reversal);

const string2 = JSON.stringify(obj);
console.log(string1 === string2); // true

This guarantees that applying the reversal object restores the object to its exact original state.

Object and Element Enhancements via assign-gingerly

Dependency injection based on a registry object and a Symbolic reference mapping

interface EnhancementConfig<T = any, TObjToExtend = any> {
    spawn: {new(objToExtend: TObjToExtend, ctx: SpawnContext, initVals: Partial<T>): T}
    symlinks?: {[key: symbol]: keyof T}
    // Optional: for element enhancement access
    enhKey?: string | symbol
    // Optional: automatic attribute parsing 
    withAttrs?: AttrPatterns<T>  
}

export const isHappy = Symbol.for('TFWsx0YH5E6eSfhE7zfLxA');
class MyEnhancement{
    //optional
    constructor(augmentedObj?: Object){}
    get isHappy(){}
    set isHappy(nv){}
}

export const isMellow = Symbol.for('BqnnTPWRHkWdVGWcGQoAiw');
class YourEnhancement{
    get isMellow(){}
    set isMellow(nv){}
    get madAboutFourteen(){}
    set madAboutFourteen(nv){}
}

class EnhancementRegistry{
    push(EnhancementConfig | EnhancementConfig[]){
        ...
    }
}

//Here's where the dependency injection mapping takes place
const EnhancementRegistry = new EnhancementRegistry;
EnhancementRegistry.push([
    {
        symlinks: {
            [isHappy]: 'isHappy'
        },
        spawn: MyEnhancement,
    },{
       enhKey: 'mellowYellow',
       symlinks: {
           [isMellow]: 'isMellow'
       },
       spawn: YourEnhancement,
    }
]);
//end of dependency injection

const result = assignGingerly({}, {
    [isHappy]: true,
    [isMellow]: true,
    style:{
      height: '40px',
    }, 
    enh: {
      '?.mellowYellow?.madAboutFourteen': true
    }
    
}, {
    registry: EnhancementRegistry
});
//result.set[isMellow] = false;

The assignGingerly function searches the registry for any items that has a mapping with a matching symbol of isHappy and isMellow, and if found, sees if it already has an instance of the spawn class associated with the first passed in parameter. If no such instance is found, it instantiates one, associates the instance with the first parameter, then sets the property value.

It also adds a lazy property to the first passed in parameter, "set", which returns a proxy, and that proxy watches for symbol references passed in a value, and sets the value from that spawned instance. Again, if the spawned instance is not found, it re-spawns it.

The suggestion to use Symbol.for with a guid, as opposed to just Symbol(), is based on some negative experiences I've had with multiple versions of the same library being referenced, but is not required. Regular symbols could also be used when that risk can be avoided.

To ensure instance uniqueness even when multiple versions of this package are loaded, spawned instances are stored in a global WeakMap at globalThis['HDBhTPLuIUyooMxK88m68Q']. This guarantees that:

  • Same instance across versions: Different versions of the package will share the same instance map
  • Memory safety: Using WeakMap allows garbage collection when objects are no longer referenced
  • No conflicts: The GUID-based key prevents collisions with other libraries
  • Registry item keying: Instances are keyed by registry item (not by symbol), ensuring that multiple symbols mapped to the same registry item share the same spawned instance
  • Shared between assignGingerly and enh.set: Both assignGingerly() and element.enh.set use the same global instance map, ensuring only one instance per registry item per element

This is particularly important in complex applications where different dependencies might bundle different versions of assign-gingerly.

Example of shared instances:

const symbol1 = Symbol.for('prop1');
const symbol2 = Symbol.for('prop2');

class MyEnhancement {
  element;
  ctx;
  prop1 = null;
  prop2 = null;
  instanceId = Math.random();

  constructor(oElement, ctx, initVals) {
    this.element = oElement;
    this.ctx = ctx;
    if (initVals) {
      Object.assign(this, initVals);
    }
  }
}

const registryItem = {
  spawn: MyEnhancement,
  symlinks: {
    [symbol1]: 'prop1',
    [symbol2]: 'prop2'
  },
  enhKey: 'myEnh'
};

const registry = new EnhancementRegistry();
registry.push(registryItem);

const element = document.createElement('div');
element.customElementRegistry.enhancementRegistry.push(registryItem);

// Use assignGingerly first
assignGingerly(element, { [symbol1]: 'value1' }, { registry });
const id1 = element.enh.myEnh.instanceId;

// Use enh.set - gets the SAME instance
element.enh.set.myEnh.prop2 = 'value2';
const id2 = element.enh.myEnh.instanceId;

console.log(id1 === id2); // true - same instance!
console.log(element.enh.myEnh.prop1); // 'value1'
console.log(element.enh.myEnh.prop2); // 'value2'

Example of registry item keying:

const symbol1 = Symbol.for('prop1');
const symbol2 = Symbol.for('prop2');

class MyEnhancement {
  prop1 = null;
  prop2 = null;
}

const registryItem = {
  spawn: MyEnhancement,
  symlinks: {
    [symbol1]: 'prop1',
    [symbol2]: 'prop2'
  }
};

const registry = new EnhancementRegistry();
registry.push(registryItem);

const target = {};

// Both symbols use the SAME instance because they're in the same registry item
assignGingerly(target, { [symbol1]: 'value1' }, { registry });
assignGingerly(target, { [symbol2]: 'value2' }, { registry });

// Both properties are set on the same instance
console.log(target.set[symbol1] === target.set[symbol2]); // true
console.log(target.set[symbol1].prop1); // 'value1'
console.log(target.set[symbol1].prop2); // 'value2'
const result = assignGingerly({}, {
    "[Symbol.for('TFWsx0YH5E6eSfhE7zfLxA')]": true,
    "[Symbol.for('BqnnTPWRHkWdVGWcGQoAiw')]": true,
    style: {
      height: '40px'
    }
    enh: {
      mellowYellow?.madAboutFourteen': true
    }
}, {
    registry: EnhancementRegistry
});

Enhancement Registry Addendum to the Custom Element Registry

This package polyfill adds an "enhancementRegistry" registry on the CustomElementRegistry prototype.

In this way, we achieve dependency injection in harmony with scoped custom elements DOM registry scopes.

[!NOTE] Safari/WebKit played a critical role in pushing scoped custom element registries forward, and announced with little fanfare or documentation that Safari 26 supports it. However, the Playwright test machinery's cross platform Safari test browser doesn't yet support it. For now, only Chrome 146+ has been tested / vetted for this functionality.

For more information about scoped custom element registries, see Chrome's announcement and guide.

When assignGingerly or assignTentatively is called on an Element instance without providing an explicit registry option, it automatically uses the registry from element.customElementRegistry.enhancementRegistry:

import 'assign-gingerly/object-extension.js';

// Set up a registry on the custom element registry
const myElement = document.createElement('div');
const registry = myElement.customElementRegistry.enhancementRegistry;

const mySymbol = Symbol.for('myProperty');
class MyEnhancement {
  value = null;
}

registry.push({
  spawn: MyEnhancement,
  symlinks: { [mySymbol]: 'value' }
});

// No need to pass registry option - it's automatically used!
myElement.assignGingerly({
  [mySymbol]: 'hello world'
});

Each CustomElementRegistry instance gets its own enhancementRegistry property via a lazy getter. The EnhancementRegistry instance is created on first access and cached for subsequent uses:

const element1 = document.createElement('div');
const element2 = document.createElement('span');

// Each element's customElementRegistry gets its own registry
const registry1 = element1.customElementRegistry.enhancementRegistry;
const registry2 = element2.customElementRegistry.enhancementRegistry;

// Multiple accesses return the same instance
console.log(registry1 === element1.customElementRegistry.enhancementRegistry); // true

You can still provide an explicit registry option to override the automatic behavior:

const customRegistry = new EnhancementRegistry();
// ... configure customRegistry ...

myElement.assignGingerly({
  [mySymbol]: 'value'
}, { registry: customRegistry }); // Uses customRegistry instead of customElementRegistry.enhancementRegistry

Browser Support: This feature requires Chrome 146+ with scoped custom element registry support. The implementation is designed as a polyfill for the web standards proposal and does not include fallback behavior for older browsers.

Enhanced Element Property Assignment with enh.set Proxy (Chrome 146+)

Building on the Custom Element Registry integration, this package provides a powerful enh.set proxy on all Element instances that enables automatic enhancement spawning and simplified property assignment syntax. The enh property serves as a dedicated namespace for enhancements, preventing conflicts with future platform properties.

Basic Usage

The enh.set proxy allows us to assign properties to enhancements using a clean, chainable syntax:

import 'assign-gingerly/object-extension.js';
//import { EnhancementRegistry } from 'assign-gingerly';

// Define an enhancement class
class MyEnhancement {
  element;
  ctx;
  myProp = null;
  anotherProp = null;

  constructor(oElement, ctx, initVals) {
    this.element = oElement;
    this.ctx = ctx;
    if (initVals) {
      Object.assign(this, initVals);
    }
  }
}

// Register the enhancement with an enhKey
const myElement = document.createElement('div');
const registry = myElement.customElementRegistry.enhancementRegistry;

registry.push({
  spawn: MyEnhancement,
  enhKey: 'myEnh'  // Key identifier for this enhancement
});

// Use the enh.set proxy to automatically spawn and assign properties
myElement.enh.set.myEnh.myProp = 'hello';
myElement.enh.set.myEnh.anotherProp = 'world';

console.log(myElement.enh.myEnh instanceof MyEnhancement); // true
console.log(myElement.enh.myEnh.myProp); // 'hello'
console.log(myElement.enh.myEnh.element === myElement); // true

When you access element.enh.set.enhKey.property, the proxy:

  1. Checks the registry: Looks for a registry item with enhKey matching the property name
  2. Spawns if needed: If found and the enhancement doesn't exist or is the wrong type:
    • Creates a SpawnContext with { config: registryItem }
    • Calls the constructor with (element, ctx, initVals)
    • If a non-matching object already exists at element.enh[enhKey], it's passed as initVals
    • Stores the spawned instance at element.enh[enhKey]
  3. Reuses existing instances: If the enhancement already exists and is the correct type, it reuses it
  4. Falls back to plain objects: If no registry item is found, creates a plain object at element.enh[enhKey]

Why the enh Namespace?

The enh property provides a dedicated namespace for enhancements, similar to how dataset provides a namespace for data attributes. This prevents conflicts with:

  • Future platform properties that might be added to Element
  • Existing element properties and methods
  • Other libraries that might extend HTMLElement

This approach is part of a proposal to WHATWG for standardizing element enhancements.

Constructor Signature

Element enhancement classes should follow this constructor signature:

interface SpawnContext<T, TMountContext = any> {
  config: EnhancementConfig<T>;
  mountCtx?: TMountContext;  // Optional custom context passed by caller
}

class Enhancement<T> {
  constructor(
    oElement?: Element,      // The element being enhanced
    ctx?: SpawnContext,      // Context with registry item info and optional mountCtx
    initVals?: Partial<T>    // Initial values if property existed
  ) {
    // Your initialization logic
    // Access custom context via ctx.mountCtx if provided
  }
}

All parameters are optional for backward compatibility with existing code.

Note that the class need not extend any base class or leverage any mixins. In fact, ES5 prototype functions can be used, and in both cases are instantiated using new .... Arrow functions cannot be used.

You can pass custom context when calling enh.get() or enh.whenResolved() (discussed in detail below):

// Pass custom context to the spawned instance
const myContext = { userId: 123, permissions: ['read', 'write'] };
const instance = element.enh.get(registryItem, myContext);

// The constructor receives it via ctx.mountCtx
class MyEnhancement {
  constructor(oElement, ctx, initVals) {
    console.log(ctx.mountCtx.userId);        // 123
    console.log(ctx.mountCtx.permissions);   // ['read', 'write']
  }
}

This is useful for:

  • Passing authentication/authorization context
  • Providing configuration that varies per invocation
  • Sharing state between caller and enhancement
  • Dependency injection of services or utilities

Note: The mountCtx is only available when explicitly calling enh.get() or enh.whenResolved(). It's not available when accessing via the enh.set proxy (since that's a property getter with no way to pass parameters).

Registry Item with enhKey

In addition to spawn and symlinks, registry items support optional properties enhKey, withAttrs, canSpawn, and lifecycleKeys:

interface EnhancementConfig<T, TObj = Element> {
  spawn: { 
    new (obj?: TObj, ctx?: SpawnContext<T>, initVals?: Partial<T>): T;
    canSpawn?: (obj: TObj, ctx?: SpawnContext<T>) => boolean;  // Optional spawn guard
  };
  symlinks?: { [key: string | symbol]: keyof T };
  enhKey?: string;  // String identifier for set proxy access
  withAttrs?: AttrPatterns<T>;  // Automatic attribute parsing during spawn
  lifecycleKeys?: 
    | true  // Use standard names: "dispose" method, "resolved" property/event
    | {
        dispose?: string | symbol;  // Method name to call on disposal
        resolved?: string | symbol;  // Property name and event name for async resolution
      };
}

The withAttrs property enables automatic attribute parsing when the enhancement is spawned. See the Parsing Attributes with parseWithAttrs section for details.

It also tips off extending polyfills / libraries, in particular mount-observer, to be on te lookout for the attributes specified by withAttrs. But assign-gingerly, by itself, performs no DOM observing to automatically spawn the class instance. It expects consumers of the polyfill to programmatically attach such behavior/enhancements, and/or rely on alternative, higher level packages to be vigilant for enhancement opportunities.

The canSpawn static method allows enhancement classes to conditionally block spawning based on the target object. See the Conditional Spawning with canSpawn section for details.

The lifecycleKeys property configures lifecycle integration without requiring base classes. See the Lifecycle Keys: Configuration vs Convention section for details.

Multiple Enhancements:

class StyleEnhancement {
  constructor(oElement, ctx, initVals) {
    this.element = oElement;
  }
  height = null;
  width = null;
}

class DataEnhancement {
  constructor(oElement, ctx, initVals) {
    this.element = oElement;
  }
  value = null;
}

const element = document.createElement('div');
const registry = element.customElementRegistry.enhancementRegistry;

registry.push([
  { spawn: StyleEnhancement, enhKey: 'styles' },
  { spawn: DataEnhancement, enhKey: 'data' }
]);

element.enh.set.styles.height = '100px';
element.enh.set.data.value = 'test';

console.log(element.enh.styles instanceof StyleEnhancement); // true
console.log(element.enh.data instanceof DataEnhancement); // true

Preserving Existing Data with initVals:

const element = document.createElement('div');
const registry = element.customElementRegistry.enhancementRegistry;

registry.push({
  spawn: MyEnhancement,
  enhKey: 'config'
});

// Set a plain object first
element.config = { existingProp: 'preserved', anotherProp: 'also preserved' };

// Access via enh.set proxy - spawns enhancement with initVals
element.enh.set.config.newProp = 'added';

console.log(element.enh.config instanceof MyEnhancement); // true
console.log(element.enh.config.existingProp); // 'preserved'
console.log(element.enh.config.newProp); // 'added'

Plain Objects Without Registry:

const element = document.createElement('div');

// No registry item for 'plainData' - creates plain object
element.enh.set.plainData.prop1 = 'value1';
element.enh.set.plainData.prop2 = 'value2';

console.log(element.enh.plainData); // { prop1: 'value1', prop2: 'value2' }

The EnhancementRegistry class includes a findByEnhKey method:

const registry = new EnhancementRegistry();
registry.push({
  spawn: MyEnhancement,
  enhKey: 'myEnh'
});

const item = registry.findByEnhKey('myEnh');
console.log(item.enhKey); // 'myEnh'

Programmatic Instance Spawning with enh.get()

The enh.get(registryItem) method provides a programmatic way to spawn or retrieve previously instantiated enhancement instances:

const registryItem = {
  spawn: MyEnhancement,
  enhKey: 'myEnh'
};

// Get or spawn the instance
const instance = element.enh.get(registryItem);

console.log(instance instanceof MyEnhancement); // true
console.log(element.enh.myEnh === instance); // true

How enh.get() works:

  1. Adds to registry: If the registry item isn't already in element.customElementRegistry.enhancementRegistry, it's automatically added
  2. Spawns if needed: If no instance exists for this registry item, it spawns one (passing element, context, and initVals if applicable)
  3. Stores on enh: If the registry item has an enhKey, the instance is stored at element.enh[enhKey]
  4. Returns instance: Returns the spawned or existing instance

Benefits:

  • Explicit control: Spawn instances programmatically without needing to use symbols or property assignment
  • Shared instances: Uses the same global instance map as assignGingerly and enh.set, ensuring only one instance per registry item
  • Auto-registration: Automatically adds registry items to the element's registry if not present

Lookup by enhKey (string or symbol):

Instead of passing the full registry item object, you can pass a string or symbol matching the enhKey of a previously registered enhancement:

// First, register the enhancement (e.g., via mount-observer or manually)
registry.push({
  spawn: MyEnhancement,
  enhKey: 'myEnh'
});

// Later, retrieve by enhKey string
const instance = element.enh.get('myEnh');

// Or by symbol enhKey
const enhSym = Symbol.for('myEnh');
const instance2 = element.enh.get(enhSym);

If the enhKey is not found in the registry, an error is thrown: "myEnh not in registry".

This also works with enh.dispose() and enh.whenResolved():

// Dispose by enhKey
element.enh.dispose('myEnh');

// Wait for resolution by enhKey
const resolved = await element.enh.whenResolved('myEnh');
const registryItem = {
  spawn: MyEnhancement,
  symlinks: { [mySymbol]: 'value' },
  enhKey: 'myEnh'
};

// Get instance programmatically
const instance1 = element.enh.get(registryItem);
instance1.prop1 = 'from get()';

// Use enh.set - gets the SAME instance
element.enh.set.myEnh.prop2 = 'from set';

// Use assignGingerly - still the SAME instance
assignGingerly(element, { [mySymbol]: 'from assign' }, { registry });

console.log(element.enh.myEnh.prop1); // 'from get()'
console.log(element.enh.myEnh.prop2); // 'from set'
console.log(element.enh.myEnh.value); // 'from assign'

Enhancement classes can integrate with the lifecycle system through configurable method/property names, avoiding the need for base classes or mixins.

Why configurable lifecycle keys?

  1. Zero coupling: Enhancement classes remain plain classes with no framework dependencies
  2. Framework agnostic: Works with classes from any source - your own, third-party libraries, generated code, legacy code
  3. Naming freedom: Avoids debates over standard names. One team's dispose() is another's cleanup(), destroy(), or teardown()
  4. Multiple patterns: Different enhancement libraries can coexist with different conventions
  5. Gradual adoption: Integrate with existing classes without refactoring
  6. Testability: Enhancement classes remain simple POJOs (Plain Old JavaScript Objects) that are easy to test in isolation

The shortcut: lifecycleKeys: true

For convenience, you can use lifecycleKeys: true to adopt standard naming conventions:

const registryItem = {
  spawn: MyEnhancement,
  enhKey: 'myEnh',
  lifecycleKeys: true  // Uses standard names: "dispose" and "resolved"
};

This is equivalent to:

lifecycleKeys: {
  dispose: 'dispose',
  resolved: 'resolved'
}

Custom lifecycle keys:

When you need different names (for legacy code, team conventions, or avoiding conflicts):

lifecycleKeys: {
  dispose: 'cleanup',      // Call cleanup() method on disposal
  resolved: 'isReady'      // Watch isReady property and dispatch "isReady" event
}

Symbol support:

Lifecycle keys can be symbols to avoid naming collisions:

const DISPOSE = Symbol('dispose');
const RESOLVED = Symbol('resolved');

class MyEnhancement {
  [DISPOSE]() {
    // Cleanup code
  }
  
  [RESOLVED] = false;
}

const registryItem = {
  spawn: MyEnhancement,
  enhKey: 'myEnh',
  lifecycleKeys: {
    dispose: DISPOSE,
    resolved: RESOLVED
  }
};

Note: Symbol event names are not yet supported by the platform but have been requested. When supported, the resolved key will work as both property name and event name.

Disposing Enhancement Instances with enh.dispose(regItem)

The enh.dispose(regItem) method provides a way to clean up and remove enhancement instances:

class MyEnhancement {
  element;
  ctx;
  
  constructor(oElement, ctx, initVals) {
    this.element = oElement;
    this.ctx = ctx;
    // Setup code...
  }
  
  cleanup(registryItem) {
    // Cleanup code - remove event listeners, clear timers, etc.
    console.log('Disposing enhancement');
  }
}

const registryItem = {
  spawn: MyEnhancement,
  enhKey: 'myEnh',
  lifecycleKeys: true  // Standard: calls dispose() method
};

// Or with custom name:
const customRegistryItem = {
  spawn: MyEnhancement,
  enhKey: 'myEnh',
  lifecycleKeys: {
    dispose: 'cleanup'  // Custom: calls cleanup() method
  }
};

// Get instance
const instance = element.enh.get(registryItem);

// Later, dispose of it
element.enh.dispose(registryItem);

How enh.dispose(regItem) works:

  1. Retrieves instance: Gets the spawned instance from the global instance map
  2. Calls lifecycle method: If lifecycleKeys.dispose is specified, calls that method on the instance (passing the registry item)
  3. Removes from map: Removes the instance from the global instance map
  4. Removes from enh: If the registry item has an enhKey, removes the property from the enh container

Benefits:

  • Proper cleanup: Allows enhancements to clean up resources (event listeners, timers, etc.)
  • Memory management: Removes references to allow garbage collection
  • Safe: Safely handles non-existent instances without errors
  • Isolated: Only affects the specified instance, leaving others intact

Example with lifecycle cleanup:

class TimerEnhancement {
  element;
  timerId = null;
  
  constructor(oElement, ctx) {
    this.element = oElement;
    this.timerId = setInterval(() => {
      console.log('Timer tick');
    }, 1000);
  }
  
  dispose() {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
      console.log('Timer cleaned up');
    }
  }
}

const registryItem = {
  spawn: TimerEnhancement,
  enhKey: 'timer',
  lifecycleKeys: true  // Standard: calls dispose() method
};

element.enh.get(registryItem); // Starts timer

// Later...
element.enh.dispose(registryItem); // Stops timer and cleans up

After disposal:

  • The instance is removed from the global instance map
  • Calling enh.get() again will create a new instance
  • The enhancement property is removed from the enh container

Memory Management and When to Call Dispose

Important: Understanding automatic vs manual cleanup

The enhancement storage system uses a WeakMap to prevent memory leaks:

// Global storage: WeakMap<Element, Map<EnhancementConfig, Instance>>

What this means for memory:

Automatic cleanup when elements are garbage collected:

  • When an element is GC'd, the WeakMap entry is automatically removed
  • Both enhKey references (element.enh[enhKey]) and WeakMap entries are cleaned up
  • No memory leak from the storage mechanism itself

⚠️ Manual cleanup needed for enhancement internals:

  • Event listeners on global objects (window, document)
  • Timers (setInterval, setTimeout)
  • External registries or caches
  • Network connections or subscriptions

The challenge: Knowing WHEN to dispose

JavaScript provides no way to detect when an element is about to be garbage collected. Additionally, DOM disconnection doesn't reliably indicate disposal:

// Element disconnected - but should we dispose?
element.remove();

// Case 1: Temporarily removed, will be re-added
setTimeout(() => document.body.append(element), 1000);
// ? Don't dispose - enhancement should persist

// Case 2: Moved to another location
otherContainer.append(element);
// ? Don't dispose - enhancement should persist

// Case 3: Cached for reuse
elementCache.set('myElement', element);
// ? Don't dispose - enhancement should persist

// Case 4: Truly done, ready for GC
element = null;
// ? Should dispose, but no way to detect this automatically

Practical disposal strategies:

  1. Short-lived elements: Don't worry about disposal - WeakMap handles cleanup automatically when elements are GC'd

  2. Long-lived applications: Implement manual disposal at logical boundaries:

    // On route change
    router.beforeLeave(() => {
      oldRouteElements.forEach(el => el.enh.dispose(registryItem));
    });
       
    // On explicit user action
    closeButton.onclick = () => {
      dialog.enh.dispose(registryItem);
      dialog.remove();
    };
  3. Framework integration: Use framework lifecycle hooks:

    // React
    useEffect(() => {
      return () => elementRef.current?.enh.dispose(registryItem);
    }, []);
       
    // Vue
    onUnmounted(() => {
      element.value?.enh.dispose(registryItem);
    });
  4. MutationObserver heuristic: Watch for disconnection + timeout (imperfect but practical):

    const observer = new MutationObserver(() => {
      if (!element.isConnected) {
        setTimeout(() => {
          if (!element.isConnected) {
            element.enh.dispose(registryItem);
          }
        }, 5000); // If still disconnected after 5s, probably done
      }
    });

Best practices for enhancement authors:

Always implement proper cleanup in your dispose method:

class MyEnhancement {
  element;
  timerId = null;
  boundHandler = null;
  
  constructor(element, ctx) {
    this.element = element;
    this.boundHandler = this.handleClick.bind(this);
    
    // Local listener - OK, will be GC'd with element
    element.addEventListener('click', this.boundHandler);
    
    // Global listener - MUST clean up manually
    window.addEventListener('resize', this.boundHandler);
    
    // Timer - MUST clean up manually
    this.timerId = setInterval(() => this.update(), 1000);
  }
  
  dispose() {
    // Clean up global listener
    if (this.boundHandler) {
      window.removeEventListener('resize', this.boundHandler);
    }
    
    // Clean up timer
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
    
    // Clear references
    this.element = null;
    this.boundHandler = null;
  }
  
  handleClick() { /* ... */ }
  update() { /* ... */ }
}

Summary:

  • ? Storage mechanism prevents memory leaks via WeakMap
  • ?? Enhancement internals need manual cleanup via dispose()
  • ? No automatic way to detect when disposal should happen
  • ?? Choose disposal strategy based on your application's lifecycle

Waiting for Async Initialization with enh.whenResolved(regItem)

The enh.whenResolved(regItem) method provides a way to wait for asynchronous enhancement initialization:

class AsyncEnhancement extends EventTarget {
  element;
  ctx;
  isResolved = false;
  data = null;
  
  constructor(oElement, ctx) {
    super();
    this.element = oElement;
    this.ctx = ctx;
    this.initialize();
  }
  
  async initialize() {
    // Simulate async operation (fetch data, load resources, etc.)
    const response = await fetch('/api/data');
    this.data = await response.json();
    
    // Mark as resolved and dispatch event
    this.resolved = true;
    this.dispatchEvent(new Event('resolved'));
  }
}

const registryItem = {
  spawn: AsyncEnhancement,
  enhKey: 'asyncEnh',
  lifecycleKeys: true  // Standard: watches "resolved" property and event
};

// Or with custom name:
const customRegistryItem = {
  spawn: AsyncEnhancement,
  enhKey: 'asyncEnh',
  lifecycleKeys: {
    resolved: 'isReady'  // Custom: watches "isReady" property and event
  }
};

// Wait for the enhancement to be fully initialized
const instance = await element.enh.whenResolved(registryItem);
console.log(instance.data); // Data is loaded and ready

// With custom context
const authContext = { token: 'abc123', userId: 456 };
const instanceWithContext = await element.enh.whenResolved(registryItem, authContext);
// The constructor receives authContext via ctx.mountCtx
  1. Validates configuration: Throws error if lifecycleKeys.resolved is not specified
  2. Gets instance: Calls enh.get() to get or spawn the instance
  3. Checks if resolved: If the resolved property is already true, returns immediately
  4. Validates EventTarget: Throws error if instance is not an EventTarget
  5. Waits for event: Lazy loads the waitForEvent module and waits for the resolved event (using the same name as the property)
  6. Returns or rejects: Returns the instance if resolved flag is set, otherwise throws

Requirements:

  • Enhancement class must extend EventTarget
  • Must specify lifecycleKeys.resolved property name (or use lifecycleKeys: true for standard "resolved")
  • Instance must dispatch an event with the same name as the resolved property when ready
  • Instance must set the resolved property to a truthy value

Note: The resolved key serves dual purpose - it's both the property name to check AND the event name to listen for. When lifecycleKeys: true, both use "resolved".

Benefits:

  • Async-aware: Properly handles asynchronous initialization
  • Lazy loading: The waitForEvent module is only loaded when needed
  • Early return: Returns immediately if already resolved (no waiting)
  • Type safety: Validates that instance can dispatch events
  • Clean API: Simple promise-based interface

Example with multiple async operations:

class DataEnhancement extends EventTarget {