Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

isotropic-state-context

npm version License

A subclass of isotropic-state that arranges state objects into a tree. Events published by a descendant bubble up to its ancestors, and any descendant can resolve context that an ancestor provides without knowing how deep in the tree it sits.

Why Use This?

  • Parent References: Every object gets an optional parent state property that is validated, change-observable, and protected against circular ancestry
  • Event Bubbling: Events published by a child are automatically distributed to its parent, its parent's parent, and so on to the root
  • Ancestor Context: An object declares data by name. Any descendant reads it by that name without walking the tree itself
  • Depth Independence: A consumer never references parent.parent.parent, so the same class works at any depth and can be moved without changing its code
  • Declarative or Imperative: Context can be declared statically on the class with _context, passed to the constructor, or provided at runtime with provideContext()
  • Values, Properties, and Functions: Provide a static value, a live reference to a state property, or a function to call on access
  • Reactive by Default: Property and function contexts participate in isotropic-state's cross-object dependency tracking, so computed properties on descendants recompute when the provider changes
  • Automatic Cleanup: When a parent is destroyed, its children detach from it

Installation

npm install isotropic-state-context

Basic Usage

import _make from 'isotropic-make';
import _StateContext from 'isotropic-state-context';

// A class that provides context to its descendants.
const _Application = _make('Application', _StateContext, {}, {
        _context: {
            theme: 'themeName'
        },
        _state: {
            themeName: {
                initFunction: () => 'light'
            }
        }
    }),

    // A class that consumes it, at any depth.
    _Widget = _make('Widget', _StateContext, {}, {
        _computed: {
            theme () {
                return this.getContext({
                    key: 'theme'
                });
            }
        }
    });

{
    const application = _Application(),
        panel = _Widget({
            parent: application
        }),

        button = _Widget({
            parent: panel
        });

    console.log(button.theme); // light

    button.on('themeChange', event => {
        console.log(`button theme is now ${event.data.newValue}`);
    });

    application.themeName = 'dark';
    // button theme is now dark
}

The button never mentions application. It asks for theme and gets it from the nearest ancestor that provides it, and because the context points at a state property, the button's computed property recomputes when that property changes.

The parent Property

parent is an ordinary isotropic-state property, so everything you already know about state properties applies. It defaults to null, publishes a parentChange event, and can be set in the constructor config or reassigned later.

const parent = _StateContext(),
    child = _StateContext({
        parent
    });

child.on('parentChange', event => {
    console.log('reparented', event.data.oldValue, '->', event.data.newValue);
});

child.parent = null; // detaches from the parent

Validation

A parent must be a State instance or null. undefined is normalized to null.

stateContext.parent = 'nope'; // throws

While a parent can be any State instance, it can only provide context if it is a StateContext instance.

Circular Ancestry

Assigning a parent that already has the object somewhere in its ancestry throws, so the tree can never become a ring.

const a = _StateContext(),
    b = _StateContext({
        parent: a
    });

a.parent = b; // throws: parent would create a circular ancestry
a.parent = a; // throws

The traversal methods are defensive about this anyway. If a plain State instance with its own unvalidated parent property sits in the ancestry and closes a loop, getContext() and the rest visit each object once and then stop.

Detaching

Assigning null to parent is the ordinary way to detach, but removing the parent as a distributor detaches it too.

child.removeDistributor(parent);

console.log(child.parent); // null

This publishes a parentChange event exactly as child.parent = null would. The two operations are interchangeable, and there is no way to end up holding a parent that no longer distributes events.

Destruction

When a parent is destroyed, each of its children sets parent to null and detaches. Destroying a parent does not destroy its children.

const parent = _StateContext(),
    child = _StateContext({
        parent
    });

parent.destroy();

console.log(child.parent); // null
console.log(child.destroyed); // false

Destroying a child unsubscribes it from its parent so the parent does not retain a reference to it.

Walking the Ancestry

stateContextChain() is a generator that yields the instance, then the parent, then the grandparent, and so on to the root.

for (const ancestor of widget.stateContextChain()) {
    console.log(ancestor);
}

const path = Array.from(widget.stateContextChain());

The root Property

root is a computed property holding the furthest ancestor: the last object stateContextChain() yields. An object with no parent is its own root, so root is never null on a live instance.

const application = _Application(),
    panel = _Widget({
        parent: application
    }),

    button = _Widget({
        parent: panel
    });

console.log(button.root === application); // true
console.log(application.root === application); // true

Because it is an ordinary eager computed property, it publishes rootChange and participates in the dependency graph. Computing it reads the parent of every object between the instance and the root, so reparenting anything on that path recomputes it, not just reparenting the instance itself.

button.on('rootChange', event => {
    console.log('moved to a new tree', event.data.newValue);
});

panel.parent = _Application(); // button's root changes too

Destroying a parent detaches its children, so a detached object's root becomes itself.

Being eager means every instance computes root during initialization and registers a dependency on each ancestor's parent. A subclass that does not want the cost can redeclare the property as lazy, which drops rootChange but keeps the value correct on read.

Event Distribution

Setting parent registers the parent as an event distributor of the child, using the mechanism documented in isotropic-pubsub. Distribution is transitive, so an event published anywhere in the tree is seen by subscribers on every ancestor up to the root.

const application = _Application(),
    panel = _Widget({
        parent: application
    }),

    button = _Widget({
        parent: panel
    });

application.on('activate', event => {
    console.log(`${event.publisher} activated`);
});

button.publish('activate'); // the subscription on application runs

Two properties on the event tell you where it came from:

  • event.publisher is the object that published the event, which is the button in the example above
  • event.distributor is the object whose subscription is currently running, which is the application

This applies to every event, including the change batch event, the ${propertyName}Change events generated by state properties, and the lifecycle events from isotropic-initializable and isotropic-pubsub.

Because state change events are named after the property, an ancestor that has a name property of its own will see nameChange events published by descendants that also have a name property. That is intended: multiple objects in a tree can publish the same event name, and a subscriber that only cares about its own changes should check the publisher.

application.on('nameChange', event => {
    if (event.publisher !== application) {
        return;
    }

    // only this object's own change
});

Subscribers can stop an event from travelling further with event.stopDistribution().

Providing Context

There are two ways for an object to provide context.

Declaring Context on the Class

_context is a static property chain, like _state and _computed. Every instance of the class provides everything the class declares, and subclasses inherit declarations from their ancestors and can override them.

const _Application = _make('Application', _StateContext, {
    _getRequestHeaders () {
        return {
            authorization: `Bearer ${this.token}`
        };
    }
}, {
    _context: {
        // Shorthand: a property name.
        theme: 'themeName',

        // Shorthand: an inline function, called with the provider as `this`.
        title () {
            return `${this.applicationName} (${this.environment})`;
        },

        // A method name to call on access.
        requestHeaders: {
            getFunction: '_getRequestHeaders'
        },

        // A fixed value.
        version: {
            value: '1.0.0'
        }
    },
    _state: {
        applicationName: {
            initFunction: () => 'Example'
        },
        environment: {
            initFunction: () => 'production'
        },
        themeName: {
            initFunction: () => 'light'
        },
        token: {
            initFunction: () => ''
        }
    }
});

This is the preferred form. Because the declaration exists on the class, the context is available from the moment an instance exists, which matters when descendants have eager computed properties that read it during their own construction.

Providing Context in the Constructor

provideContext is a reserved constructor config key holding an object of context configurations, keyed by context key. It takes the same forms _context does.

const application = _Application({
    provideContext: {
        currentUser: {
            host: session,
            propertyName: 'user'
        },
        requestHeaders: {
            getFunction: '_getRequestHeaders'
        },
        [_serviceKey]: {
            value: service
        }
    }
});

This runs before the instance initializes, so the context is in place before its own initFunctions and eager computed properties evaluate, and before any descendant can be constructed. That ordering is the reason to prefer it over calling provideContext() afterward. See Provide Before Descendants Are Constructed.

An object literal can only be keyed by a string or symbol. Pass a Map when the key is anything else.

const application = _Application({
    provideContext: new Map([[
        someObjectKey,
        {
            value: 1
        }
    ]])
});

An invalid configuration throws from the constructor rather than publishing initializeError.

Providing Context at Runtime

provideContext() takes the same configuration in an object, plus the key.

// A live reference to one of this object's state or computed properties.
application.provideContext({
    key: 'theme',
    propertyName: 'themeName'
});

// A function to call on access. Accepts a function, or a method name.
application.provideContext({
    getFunction: '_getRequestHeaders',
    key: 'requestHeaders'
});

// A fixed value.
application.provideContext({
    key: 'version',
    value: '1.0.0'
});

Exactly one of getFunction, propertyName, or value must be present. Declaring none of them, or more than one, throws.

Because value is its own configuration key, providing a function as a value is unambiguous. It is stored and returned as-is, never called.

application.provideContext({
    key: 'formatCurrency',
    value: amount => `$${amount.toFixed(2)}`
});

console.log(typeof button.getContext({
    key: 'formatCurrency'
})); // function

Context provided through provideContext() takes precedence over a declaration of the same key in _context on the same object. revokeContext() removes the runtime context and the class declaration becomes visible again. Continuing with the _Application class above, which declares version in _context:

application.provideContext({
    key: 'version',
    value: '2.0.0'
});

console.log(application.getContext({
    key: 'version'
})); // 2.0.0

application.revokeContext({
    key: 'version'
});

console.log(application.getContext({
    key: 'version'
})); // 1.0.0

Scoped Context

provideContext() returns a handle object. The handle carries the key it was provided for, a revoked flag, a revoke() method, and Symbol.dispose, which means a context can be bound to a block with using and cleaned up even if the block throws.

{
    using theme = application.provideContext({
        key: 'theme',
        value: 'high-contrast'
    });

    renderPreview(application); // sees high-contrast
} // revoked here

A handle only revokes the context it provided. Providing the same key again replaces it and hands ownership to the newer handle, so revoking a stale one leaves the current context alone.

const first = application.provideContext({
        key: 'theme',
        value: 'light'
    }),
    second = application.provideContext({
        key: 'theme',
        value: 'dark'
    });

first.revoke();

console.log(application.getContext({
    key: 'theme'
})); // dark, not the _context declaration

Revoking twice does nothing the second time, and revokeContext() remains available for revoking by key without holding a handle.

Providing context on a destroyed instance is a no-op. The call still returns a handle, already revoked, so a using declaration on a destroyed object is safe.

Providing on Behalf of Another Object

host changes what propertyName is read from and what getFunction is called on. It defaults to the providing object.

application.provideContext({
    host: session,
    key: 'currentUser',
    propertyName: 'user'
});

The context is still provided by application and still resolved by position in the tree, but the value comes from session. This is useful for exposing a collaborator's state without making that collaborator part of the tree.

Context Keys

Keys are stored in a Map, so a runtime key can be any value, including symbols and objects. Keys declared in _context are property keys, so they can be strings or symbols.

String keys are convenient but two packages that both provide 'config' will collide, and the nearer one wins.

// keys.js
export const configKey = Symbol('config');

Consuming Context

getContext

getContext() walks from the object itself outward through its ancestors and returns the value from the first one that provides the key.

const theme = button.getContext({
    key: 'theme'
});

An object sees the context it provides itself. Resolution starts at this, and continues through this.parent.

If no object in the ancestry provides the key, getContext() returns defaultValue, or undefined when no default is given.

const theme = button.getContext({
    defaultValue: 'light',
    key: 'theme'
});

defaultValue is only used when nothing provides the key. A provider that supplies undefined is still a provider, and undefined is returned.

hasContext

if (button.hasContext({
    key: 'theme'
})) {
    // something in the ancestry provides it
}

Use this to tell "nothing provides this" apart from "something provides this and its value is undefined".

getContextProvider

Returns the object that would resolve the key, or null. Useful for debugging and for subscribing directly to a provider.

const provider = button.getContextProvider({
    key: 'theme'
});

console.log(`theme comes from ${provider}`);

Reactivity

isotropic-state tracks computed property dependencies across object boundaries. Context resolution is built out of ordinary property reads, so that tracking flows through it without any additional machinery.

The recommended way to consume context is to mirror it into a computed property:

const _Widget = _make('Widget', _StateContext, {}, {
    _computed: {
        theme () {
            return this.getContext({
                key: 'theme'
            });
        }
    }
});

That single declaration gives the widget three things:

  1. widget.theme reads the current value
  2. widget.themeChange fires when the value changes, no matter how far up the tree the change happened
  3. Any other computed property that reads widget.theme recomputes too

Recomputation is triggered by:

  • A change to a provided property. getContext() reads the property from the provider, which records the dependency.
  • A change to any state property read by a provided getFunction. The function runs inside the computation, so its reads are tracked as well.
  • A change to the ancestry. Walking from the consumer to the provider reads the parent property of every object along the way, so re-parenting anything on that path invalidates the computed value.
const rootA = _Application({
        themeName: 'light'
    }),
    rootB = _Application({
        themeName: 'dark'
    }),

    widget = _Widget({
        parent: rootA
    });

console.log(widget.theme); // light

widget.parent = rootB;

console.log(widget.theme); // dark

What Is Not Reactive

A value context is a plain reference. Nothing observes it, and re-providing the key does not invalidate computed properties that already read it. Calling provideContext() or revokeContext() after a descendant has computed a value does not invalidate that descendant either, even when the change means a nearer object now provides the key.

const _Snapshot = _make('Snapshot', _StateContext, {}, {
    _computed: {
        snapshot () {
            return this.getContext({
                key: 'snapshot'
            });
        }
    }
});

{
    const application = _Application();

    application.provideContext({
        key: 'snapshot',
        value: 1
    });

    const widget = _Snapshot({
        parent: application
    });

    console.log(widget.snapshot); // 1

    application.provideContext({
        key: 'snapshot',
        value: 2
    });

    console.log(widget.snapshot); // still 1
    console.log(widget.getContext({
        key: 'snapshot'
    })); // 2
}

This is deliberate. A value context facilitates a reference to something. It does not promise to watch it. When you want the consumer to follow changes, provide a property or a function instead, and let the value change rather than the context.

If you do need to re-provide a value after the fact, force dependents to recompute:

widget.snapshot = _Widget.recompute;

Provide Before Descendants Are Constructed

Eager computed properties compute during initialization. A descendant constructed before its provider has provided will cache the unresolved value.

const application = _Application(),
    widget = _Widget({
        parent: application
    });

// Too late for widget's eager computed property.
application.provideContext({
    key: 'lateKey',
    value: 'lateValue'
});

There are three ways to provide context early:

  1. Declare it in _context. A class declaration always exists before any instance does, so nothing can be constructed too soon to see it.
  2. Pass it to the constructor. The provideContext config key is applied before the instance initializes, which is before its own eager computed properties evaluate and before it can be anyone's parent. This is the answer when the context depends on runtime information.
const application = _Application({
        provideContext: {
            lateKey: {
                value: 'lateValue'
            }
        }
    }),
    widget = _Widget({
        parent: application
    });

// widget resolves it during its own construction.
  1. Call provideContext() from an initFunction or _initialize on the provider, before descendants are built.

Marking the consumer's computed property lazy also defers its first computation until something reads it, which sidesteps the ordering question rather than answering it.

Configuration Reference

Constructor Configuration

Beyond the keys inherited from isotropic-state, isotropic-initializable, and isotropic-pubsub, one key is reserved here.

Option Type Description
provideContext Object or Map Context configurations keyed by context key, applied before initialization. Values take the same forms as _context. Use a Map for keys that are not strings or symbols.

parent is an ordinary state property, so it is supplied by name like any other.

const widget = _Widget({
    parent: application,
    provideContext: {
        scope: {
            value: 'panel'
        }
    },
    subscribe: {
        action: handleAction
    }
});

Context Configuration

Option Type Description
getFunction Function, String, or Symbol A function to call on access, or the name of a method on the host. Its return value is the context value.
host Object The object propertyName is read from and getFunction is called on. Defaults to the providing object.
propertyName String or Symbol A property of the host, read on each access.
value Any A fixed value, returned as-is.

Exactly one of getFunction, propertyName, or value is required.

Shorthand Forms

Only available in _context, where the declaration is a class-level literal.

Written As Equivalent To
key: 'propertyName' key: { propertyName: 'propertyName' }
key: someSymbol key: { propertyName: someSymbol }
key () { ... } key: { getFunction () { ... } }

A string in the shorthand form is a property name. A method name is only understood in the long form under getFunction.

API Reference

State Properties

  • parent: The parent object, or null. Publishes parentChange.

Computed Properties

  • root: The furthest ancestor, or the instance itself when it has no parent. Never null on a live instance. Eager, so it publishes rootChange.

Instance Methods

  • getContext({ defaultValue, key }): The value for key from the nearest provider, or defaultValue
  • getContextProvider({ key }): The object that provides key, or null
  • hasContext({ key }): Whether anything in the ancestry provides key
  • provideContext({ getFunction, host, key, propertyName, value }): Provide context for key. Returns a context handle
  • removeDistributor(distributor): As inherited from isotropic-pubsub, and additionally sets parent to null when the removed distributor is the current parent. Returns this
  • revokeContext({ key }): Remove a runtime context for key. Returns this
  • stateContextChain(): A generator yielding this instance followed by each parent up to the root

Everything else from isotropic-state, isotropic-initializable, and isotropic-pubsub is inherited unchanged.

Context Handle

The object provideContext() returns.

  • key: The context key this handle was provided for
  • revoke(): Revoke this handle's context, unless it has already been revoked or replaced. Returns the handle
  • revoked: Whether revoke() has run, or the instance was destroyed when the context was provided
  • [Symbol.dispose](): Calls revoke(), so the handle works with using

Static Properties

  • _context: A property chain of context declarations, inherited and overridable by subclasses

Static Methods

  • stateContextChain(stateContext): A generator yielding the given StateContext instance followed by each parent up to the root

Using as a Mixin

StateContext is a subclass of State, and it also works as a mixin for a class that extends State by another route.

const _Model = _make('Model', _State, [
    _StateContext
], {}, {
    _state: {
        id: {
            initFunction: () => null
        }
    }
});

The mixed class gets the parent property, the context methods, and its own _context chain. Instances are recognized as valid parents by other objects in the tree.

Examples

A Component Tree

import _make from 'isotropic-make';
import _StateContext from 'isotropic-state-context';

const _Component = _make('Component', _StateContext, {
        _initialize () {
            this._subscribe('on', 'action', event => {
                if (event.publisher !== this) {
                    console.log(`${this.id} saw an action from ${event.publisher.id}`);
                }
            });
        }
    }, {
        _state: {
            id: {
                initFunction: () => ''
            }
        }
    });

{
    const application = _Component({
            id: 'application'
        }),
        header = _Component({
            id: 'header',
            parent: application
        }),

        menu = _Component({
            id: 'menu',
            parent: header
        });

    menu.publish('action', {
        name: 'logout'
    });
    // header saw an action from menu
    // application saw an action from menu
}

Dependency Injection

An object provides a collaborator. Descendants ask for it by name and never see how it was constructed or where it lives.

const _serviceKey = Symbol('service'),

    _Application = _make('Application', _StateContext, {}, {
        _context: {
            [_serviceKey]: '_service'
        },
        _state: {
            _service: {
                initFunction () {
                    return {
                        load: id => `record ${id}`
                    };
                }
            }
        }
    }),

    _Panel = _make('Panel', _StateContext, {
        load (id) {
            return this.getContext({
                key: _serviceKey
            }).load(id);
        }
    });

{
    const application = _Application(),
        panel = _Panel({
            parent: _Panel({
                parent: application
            })
        });

    console.log(panel.load(7)); // record 7
}

Substituting a test double is a matter of providing a different value at the root.

const application = _Application({
    provideContext: {
        [_serviceKey]: {
            value: {
                load: id => `stub ${id}`
            }
        }
    }
});

console.log(_Panel({
    parent: application
}).load(7)); // stub 7

Overriding Context for a Subtree

Any object can shadow a key for everything below it, without affecting its siblings.

const application = _Application({
        themeName: 'light'
    }),
    sidebar = _Widget({
        parent: application
    }),
    modal = _Widget({
        parent: application
    }),

    sidebarButton = _Widget({
        parent: sidebar
    }),
    modalButton = _Widget({
        parent: modal
    });

modal.provideContext({
    key: 'theme',
    value: 'high-contrast'
});

console.log(sidebarButton.getContext({
    key: 'theme'
})); // light
console.log(modalButton.getContext({
    key: 'theme'
})); // high-contrast

Best Practices

Declare Context on the Class

Reach for _context first. It reads as part of the class's interface, it exists before any instance does, and the configuration objects are shared rather than rebuilt per instance. When the context genuinely depends on runtime information, pass it to the constructor. Use provideContext() for context that appears after construction, or that needs to be revoked.

Consume Through a Computed Property

Calling getContext() directly in a method is fine for one-off reads, but a computed property gives you caching, change events, and participation in the dependency graph for the same amount of code.

Provide Properties Rather Than Values

propertyName and getFunction contexts stay correct as the provider changes. A value context is a snapshot. Prefer the former unless the thing really is immutable.

Use Symbols for Keys That Cross Package Boundaries

A string key is a global name in the tree. Export a symbol from a module and the collision becomes impossible.

Check the Publisher

An ancestor sees every event from its whole subtree. When a subscription is only interested in the object's own events, compare event.publisher to the object.

Debugging

A consumer resolves the wrong value. Something nearer than you expect provides the key. getContextProvider() tells you which object it is.

console.log(widget.getContextProvider({
    key: 'theme'
}));

A consumer resolves undefined. Either nothing provides the key or the provider is not in the ancestry. hasContext() distinguishes the two, and Array.from(widget.stateContextChain()) shows the path that was searched.

A computed property does not update. Check that the context is a propertyName or getFunction rather than a value, and that the provider provided before the consumer first computed. See Reactivity.

An event fires more than expected. Distribution is transitive, so ancestors see the whole subtree. Compare event.publisher to the subscriber.

parent became null on its own. Something detached it. The parent was destroyed, or removeDistributor was called with it. Subscribe to parentChange to catch the moment it happens.

root is the object itself when you expected an ancestor. The object is detached. Either parent was never assigned or something detached it, and an object with no parent is its own root by definition.

A context disappeared unexpectedly. A handle was revoked, or disposed at the end of a block it was scoped to. getContextProvider() confirms nothing provides the key any more.

Integration with Other Isotropic Modules

  • isotropic-state: The base class, supplying observable properties, computed properties, and cross-object dependency tracking
  • isotropic-pubsub: Supplies the distributor mechanism that event bubbling is built on
  • isotropic-make: Constructs the class and supports using it as a mixin
  • isotropic-instance-of: Recognizes state objects for parent validation, including through mixins
  • isotropic-error: Produces structured errors for invalid parents, circular ancestry, and invalid context configurations

Contributing

Please refer to CONTRIBUTING.md for information on how to contribute.

Issues

Please refer to the issue tracker.

License

BSD-3-Clause

About

Hierarchical state objects with parent-child event bubbling and ancestor context resolution

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages