roundabout-lib
v0.0.36
Published
[](https://github.com/bahrus/roundabout/actions/workflows/CI.yml) [](http://badge.fury.io/js/roundabout
Downloads
2,331
Readme
roundabout
Signals Vs Roundabouts
The world needs both traffic signals and roundabouts. This shouldn't be an either or.
Signals:
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
effect(() => element.innerText = parity.get());
// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);Roundabouts:
const rxns = [({counter}) => ({isEven: counter & 1 === 0}),
({isEven}) => ({parity: isEven ? 'even' : 'odd'}),
({parity}) => ({'?.element?.innerText': parity})];
const [vm] = await roundabout({propagate: {count: 0}}, rxns);
// Simulate external updates to counter...
setInterval(() => vm.count++, 1000);Somewhat biased(?) comparison
Both examples above require about the same number of lines of code. But most statements, where the developer spends more eyeball time, are smaller with roundabouts, are easier to test, and involve less distracting binding noise. One statement is admittedly a bit larger.
For both examples, all the functions are side effect free and don't do any state mutation at all. They are purely functional.
As we will see below, roundabout can JSON serialize much of the logic, making parsing the instructions easier on the browser.
In general, signals involve "busier" syntax that seems to be less declarative, especially less JSON serializable. On the plus side, the developer can be far less disciplined.
Roundabouts encourage small, loosely coupled functions, which are easy to test (but may suffer from more bouncing around), and the code is far more "clean", in the sense that there are no api calls required to worry about. Just focus on what output you want merged into the view model, and leave it at that.
It requires more disciplined patience from the developer, but it allows for a large solution space of code-free declarative solutions.
While the argument against signals weakens if it becomes part of the underlying platform (in particular, escaping the charge of getting stuck in proprietary vendor lock-in land), I still think the argument of requiring the code to integrate with signals has a kind of "coupling" cost.
roundabout "guesses" when the developer wants to call the functions to compute new values, if not specified, based on the lhs of the arrow expressions. But developers can take hold of the reigns, and be more explicit:
const [vm, propagator] = await roundabout(
{
vm: {element, isEven, parity, effect},
propagator,
compacts: {
when_count_changes_call_isEven: 0,
when_isEven_changes_call_parity: 0
},
actions:{
effect: {
ifAllOf: ['count', 'isEven', 'parity']
}
},
}
);I suspect roundabouts also require less run time analysis.
It certainly benefits from fewer (nested) parenthesis.
State is all in one place -- the vm (view model), which could also be the custom element class instance.
In my view, roundabouts require a lower learning curve.
For both roundabouts and signals, they don't execute code if the field value is unchanged, so they are on par as far as that concern goes.
Neither requires pub/sub.
No creation of getters/setters required (other than count for roundabouts, so that count++ works).
Basically, what roundabout does is it looks at what subset of properties of the view model is returned from the action methods (isEven, parity, effect), and directs traffic accordingly after doing an Object.assignGingerly.
propagator is an EventTarget, that publishes events when the propagate properties are changed (just count).
Design Philosophy: Declarative First
The core goal of roundabout is to maximize declarative, JSON-serializable configuration and minimize imperative code. If you find yourself writing lots of imperative glue code around roundabout, that's a signal that roundabout isn't being used to its full potential.
Roundabout is also designed to be non-invasive. If the view model already provides a propagator, property getter/setters, or other members of the RoundaboutReady interface, roundabout uses what's there and only fills in the gaps. Libraries can implement their own reactive property system and still benefit from roundabout's declarative processors.
What "declarative" means here
The roundabout configuration object should describe what happens, not how. Action methods should be pure functions: receive the view model state, return the new state to merge. Roundabout handles the wiring — when to call what, how to merge results, how to propagate changes.
Action methods are pure functions
Action methods receive self (the view model) and return a partial object to merge back:
updateStatus(self) {
const { count } = self;
if (count < 10) return { status: 'low', statusMessage: 'Low count' };
if (count < 20) return { status: 'medium', statusMessage: 'Medium count' };
return { status: 'high', statusMessage: 'High count!' };
}No this.status = ... assignments. No manual event dispatching. Just return what changed and roundabout handles the rest.
Handlers replace imperative event wiring
Instead of manually adding event listeners to buttons, use handlers:
// ❌ Imperative: manual event listener setup
this.querySelector('.increment').addEventListener('click', () => this.increment());
// ✅ Declarative: roundabout wires it up
handlers: {
incrementButton_to_increment_on: 'click',
decrementButton_to_decrement_on: 'click',
resetButton_to_reset_on: 'click',
}The handler methods follow the same pattern — receive self, return partial state:
increment(self) {
return { count: self.count + 1 };
}assignGingerly enables declarative DOM updates
Roundabout uses assignGingerly to merge action results back into the view model. assignGingerly supports optional-chaining-in-reverse syntax and method invocation, which means DOM updates can be expressed declaratively in action return values:
// With assignOptions: { withMethods: ['querySelector'], aka: { q: 'querySelector' } }
updateCountDisplay(self) {
return {
'?.clone?.q?..count-value?.textContent': self.count,
};
}
updateStatusDisplay(self) {
return {
'?.clone?.q?..status?.className': `status ${self.status}`,
'?.clone?.q?..status-text?.textContent': self.statusMessage || self.status,
};
}Key assignGingerly features used with roundabout:
- Optional chaining in reverse (
?.prop?.subProp): safely navigates nested properties, creating intermediates if needed - Method invocation (
withMethods): allows calling methods likequerySelectororappendChildthrough the declarative syntax - Aliases (
aka): shortens verbose method names (e.g.,qforquerySelector) - Class selector shorthand (
q?..className): the?..syntax passes the next segment as an argument to the preceding method (e.g.,querySelector('.className'))
Pass these options via assignOptions in the roundabout config:
const [vm, propagator] = await roundabout({
vm: this,
assignOptions: {
withMethods: ['querySelector', 'appendChild'],
aka: { q: 'querySelector' }
},
// ... actions, compacts, etc.
});Web Component Example
Here is a complete example showing how roundabout enables a mostly-declarative web component. The configuration is JSON-serializable and can be shared or parsed independently of the class:
// JSON-serializable configuration — the "what"
const raConfig = {
weakRef: {
properties: ['incrementButton', 'decrementButton', 'resetButton'],
logIfCollected: 'warn'
},
actions: {
createClone: { ifAllOf: ['template'] },
updateStatus: { ifKeyIn: ['count'] },
updateStatusDisplay: { ifKeyIn: ['status', 'statusMessage'], ifAllOf: ['clone'] },
updateUsernameDisplay: { ifKeyIn: ['username'], ifAllOf: ['clone'] },
updateCountDisplay: { ifKeyIn: ['count'], ifAllOf: ['clone'] },
render: { ifAllOf: ['renderCount'] },
},
handlers: {
incrementButton_to_increment_on: 'click',
decrementButton_to_decrement_on: 'click',
resetButton_to_reset_on: 'click',
},
assignOptions: {
withMethods: ['querySelector', 'appendChild'],
aka: { q: 'querySelector' }
},
customData: {
innerHTML: `
<div class="header">User: <span class="username"></span></div>
<div class="count">Count: <span class="count-value"></span></div>
<div class="status">Status: <span class="status-text"></span></div>
<div class="controls">
<button class="increment">+1</button>
<button class="decrement">-1</button>
<button class="reset">Reset</button>
</div>`
}
};
const template = document.createElement('template');
template.innerHTML = raConfig.customData.innerHTML;
// The class — pure methods, minimal lifecycle glue
class UserCounter extends HTMLElement {
async connectedCallback() {
const [vm, propagator] = await roundabout({ vm: this, ...raConfig });
// Set initial state — roundabout's getter/setters are already in place,
// so actions fire reactively as properties are assigned
this.count = 0;
this.username = 'User';
this.status = 'low';
this.statusMessage = '';
this.template = template; // triggers createClone → render chain
if (this.hasAttribute('username')) this.username = this.getAttribute('username');
if (this.hasAttribute('initial-count'))
this.count = parseInt(this.getAttribute('initial-count'), 10) || 0;
}
createClone(self) {
const clone = self.template.content.cloneNode(true);
return {
incrementButton: clone.querySelector('.increment'),
decrementButton: clone.querySelector('.decrement'),
resetButton: clone.querySelector('.reset'),
clone,
};
}
render(self) {
return { '?.appendChild': self.clone, clone: self };
}
updateStatus(self) {
const { count } = self;
if (count < 10) return { status: 'low', statusMessage: 'Low count' };
if (count < 20) return { status: 'medium', statusMessage: 'Medium count' };
return { status: 'high', statusMessage: 'High count!' };
}
increment(self) { return { count: self.count + 1 }; }
decrement(self) { return { count: self.count - 1 }; }
reset(self) { return { count: 0 }; }
updateCountDisplay(self) {
return {
'?.clone?.q?..count-value?.textContent': self.count,
renderCount: 1,
};
}
updateStatusDisplay(self) {
return {
'?.clone?.q?..status?.className': `status ${self.status}`,
'?.clone?.q?..status-text?.textContent': self.statusMessage || self.status,
};
}
updateUsernameDisplay(self) {
return { '?.clone?.q?..username?.textContent': self.username };
}
}Notice what's absent: no manual addEventListener calls, no this.querySelector(...) in lifecycle code, no imperative this.status = ... assignments inside action methods. Every method is a pure function that receives state and returns new state. Roundabout handles the reactive wiring.
Going fully declarative with merges and compacts
The example above still has methods for increment, decrement, reset, createClone, render, updateCountDisplay, updateStatusDisplay, and updateUsernameDisplay. Most of these are simple enough to express declaratively. Using merges (JSON-serializable reactive assignments) and the on_EVENT_of_X compact patterns, we can eliminate all these methods. Every single one:
const raConfig = {
weakRef: {
properties: ['incrementButton', 'decrementButton', 'resetButton'],
logIfCollected: 'warn'
},
compacts: {
on_click_of_incrementButton_inc_count_by: 1,
on_click_of_decrementButton_inc_count_by: -1,
on_click_of_resetButton_set_count_to: 0,
},
assignOptions: {
akaMethods,
withMethods: ['appendChild'],
aka: {
...aka,
'🔎': 'clone?.querySelector'
},
handlers: builtInEmoji,
},
merges: [
{
ifKeyIn: ['count'],
assign: {
'?. =>': {
do: '📊',
get: {
value: '?.count',
when: [
{'<': 10, merge: {status: 'low', statusMessage: 'Low count'}},
{'<': 20, merge: {status: 'medium', statusMessage: 'Medium count'}},
{merge: {status: 'high', statusMessage: 'High count!'}}
]
}
}
}
},
{
ifKeyIn: ['status'],
ifAllOf: ['status'],
assign: {
'?.statusClassName =>': {
do: '🔗',
get: {
separator: ' ',
value: ['status', '?.status']
}
}
}
},
{
ifKeyIn: ['statusMessage', 'status'],
assign: {
'?.statusMessageText ?=': ['?.statusMessage', '?.statusMessage', '?.status']
}
},
{
ifAllOf: ['template'],
assign: {
clone: '?.template?.©️'
},
},
{
ifAllOf: ['clone'],
assign: {
incrementButton: '?.🔎?..increment',
decrementButton: '?.🔎?..decrement',
resetButton: '?.🔎?..reset'
}
},
{
ifKeyIn: ['username'],
ifAllOf: ['clone'],
assign: {
'?.🔎?..username?.🔤': '?.username'
}
},
{
ifKeyIn: ['statusMessageText'],
ifAllOf: ['clone', 'statusMessageText'],
assign: {
'?.🔎?..status-text?.🔤': '?.statusMessageText',
}
},
{
ifKeyIn: ['statusClassName'],
ifAllOf: ['clone', 'statusClassName'],
assign: {
'?.🔎?..status?.classList': '?.statusClassName',
}
},
{
ifKeyIn: ['count'],
ifAllOf: ['clone'],
assign: {
'?.🔎?..count-value?.🔤': '?.count',
renderCount: 1,
}
},
{
ifAllOf: ['renderCount'],
assign: {
'?.appendChild': '?.clone',
clone: '?.',
}
}
],
defaultPropVals: {
status: 'low',
statusMessage: '',
renderCount: 0,
},
};
// withAttrs configuration for parsing element attributes
const withAttrs = {
base: 'user-counter',
count: '${base}-count',
_count: {
instanceOf: 'Number',
valIfNull: 0,
},
username: '${base}-username',
};
The class shrinks to just lifecycle glue and the one method with real logic:
/**
* UserCounterFeature — uses RoundaboutFeature for reactive property management.
*
* Key differences from the other examples:
* - No async connectedCallback
* - No direct roundabout/roundaboutSync import
* - Attribute parsing handled by the feature system
* - Feature is accessed via lazy getter (this.roundabout)
*/
class UserCounterFeature extends HTMLElement {
static supportedFeatures = {
roundabout: {
fallbackSpawn: RoundaboutFeature,
}
};
connectedCallback() {
// Access the feature getter — triggers RoundaboutFeature constructor
// which calls roundaboutSync internally
const ra = this.roundabout;
// template is a runtime object, can't be in defaultPropVals
this.template = template;
}
}
// assignFeatures calls RoundaboutFeature.onAssigned → makeRoundaboutReady
// This installs prototype getter/setters and pre-loads processor modules
await customElements.assignFeatures(UserCounterFeature, {
roundabout: {
spawn: RoundaboutFeature,
customData: {
raConfig,
},
withAttrs,
}
});
// Now define — connectedCallback will be synchronous
customElements.define('user-counter-feature', UserCounterFeature);
What changed:
increment,decrement,reset→ replaced byon_click_of_X_inc_Y_byandon_click_of_X_set_Y_tocompactscreateClone,render→ replaced by merges using assignGingerly'scloneNodeandappendChildmethod invocationupdateCountDisplay,updateUsernameDisplay,updateStatusDisplay→ replaced by merges that push vm properties into the DOMupdateStatusstays as an action — it contains branching logic (if/else) that can't be expressed in JSON
The entire raConfig object is JSON-serializable. The only imperative code left is connectedCallback (lifecycle glue) and updateStatus (real logic).
Synchronous Setup with makeRoundaboutReady + roundaboutSync
The examples above use async connectedCallback() because roundabout() dynamically imports processor modules. This works fine, but for custom elements it means:
connectedCallbackmust be async (or use fire-and-forget)- Prototype getter/setters are installed at instance-creation time rather than class-definition time
If you'd prefer a synchronous connectedCallback, roundabout provides a two-step alternative:
import { makeRoundaboutReady, roundaboutSync } from 'roundabout-lib';
const raConfig = { /* same config as before */ };
class UserCounter extends HTMLElement {
// No async needed!
connectedCallback() {
const [vm, propagator] = roundaboutSync({ vm: this, ...raConfig });
this.status = 'low';
this.template = template;
// ... set initial state
}
updateStatus(self) {
const { count } = self;
if (count < 10) return { status: 'low', statusMessage: 'Low count' };
if (count < 20) return { status: 'medium', statusMessage: 'Medium count' };
return { status: 'high', statusMessage: 'High count!' };
}
}
// One-time async setup — call before customElements.define()
await makeRoundaboutReady(UserCounter, raConfig);
customElements.define('user-counter', UserCounter);How it works
makeRoundaboutReady(Constructor, config) does the async work once, up front:
- Pre-imports all processor modules the config requires (compacts, actions, merges, etc.)
- Infers which properties need monitoring from the config
- Installs getter/setters on
Constructor.prototype - Caches everything so
roundaboutSynccan use it without any imports
roundaboutSync(options) then runs fully synchronously per instance:
- Initializes per-instance storage
- Creates the propagator EventTarget
- Wires up property-change listeners and processors using the cached modules
- Returns
[vm, propagator]immediately
Fallback behavior
If roundaboutSync is called without a prior makeRoundaboutReady, it still works — getter/setters are installed inline (synchronous), and processor module loading is deferred to a microtask. The return value is always [vm, propagator] synchronously either way. This means you can use roundaboutSync as a drop-in replacement for roundabout without the makeRoundaboutReady call — you just won't get the benefit of pre-loaded processors on the first tick.
When to use which
| Approach | Use when |
|----------|----------|
| roundabout() | Simple cases, plain objects, one-off VMs, or when async connectedCallback is acceptable |
| makeRoundaboutReady + roundaboutSync | Custom elements where you want synchronous lifecycle, or when multiple instances share the same config |
| RoundaboutFeature + assignFeatures | Custom elements using assign-gingerly's feature system, attribute parsing, or dependency injection |
Note on roundabout-ready event
roundaboutSync does not dispatch the roundabout-ready event. If external parties need to detect when the element is initialized, check for element.propagator directly — it's available immediately after roundaboutSync returns.
Using RoundaboutFeature with assignFeatures
For the cleanest integration with custom elements, roundabout provides a feature class that plugs into assign-gingerly's assignFeatures dependency injection system. This eliminates the need to import roundabout directly in your element file — the feature system handles everything.
import 'assign-gingerly/assignFeatures.js';
import { RoundaboutFeature } from 'roundabout-lib/roundaboutFeature.js';
const raConfig = {
actions: { updateStatus: { ifKeyIn: ['count'] } },
compacts: {
on_click_of_incrementButton_inc_count_by: 1,
on_click_of_decrementButton_inc_count_by: -1,
on_click_of_resetButton_set_count_to: 0,
},
merges: [ /* ... */ ],
assignOptions: { withMethods: ['querySelector', 'appendChild'], aka: { q: 'querySelector' } }
};
class UserCounter extends HTMLElement {
static supportedFeatures = {
roundabout: { fallbackSpawn: RoundaboutFeature }
};
connectedCallback() {
this.roundabout; // access the lazy getter — triggers roundaboutSync
this.template = template;
}
// One-time async setup — calls makeRoundaboutReady via static onAssigned
await customElements.assignFeatures(UserCounter, {
roundabout: {
spawn: RoundaboutFeature,
customData: { raConfig },
withAttrs: {
base: 'user-counter',
count: '${base}-count',
_count: { instanceOf: 'Number', valIfNull: 0 },
username: '${base}-username',
}
}
});
customElements.define('user-counter', UserCounter);<user-counter user-counter-username="Alice" user-counter-count="5"></user-counter>How it works
assignFeaturesinstalls a lazy getter forthis.roundabouton the prototype.RoundaboutFeature.onAssignedis called automatically — it runsmakeRoundaboutReady(Constructor, raConfig)to install prototype getter/setters and pre-load processor modules.withAttrs(top-level in the feature config) is handled by assign-gingerly — it parses element attributes and passes the result asinitValsto the feature constructor.- On first access (
this.roundaboutinconnectedCallback), the feature constructor runsroundaboutSyncto wire up the propagator and processors, then applies the parsed attribute values viaassignGingerly.
What goes where
| Config key | Purpose | Who reads it |
|-----------|---------|-------------|
| customData.raConfig | Roundabout config (actions, compacts, merges, etc.) | RoundaboutFeature |
| withAttrs (top-level) | Attribute parsing patterns | assign-gingerly's feature system |
Benefits over direct roundaboutSync usage
- No roundabout imports needed in the element file
- Attribute parsing handled automatically by the feature system
- Swappable for mocks in tests (standard
assignFeaturespattern) connectedCallbackis synchronous and minimal- Works with
callbackForwardingif you want auto-spawn on connect
How to be roundabout ready
For a class to be optimized to work most effectively with roundabouts, it should implement interface RoundaboutReady.
Libraries and frameworks can provide their own implementations of the propagator, property getter/setters, and other RoundaboutReady members. Roundabout checks for existing implementations and only adds its own defaults where none are found. This means you can bring your own reactive property system — as long as property changes dispatch events on the propagator, roundabout's processors will work with it.
interface RoundaboutReady{
/**
* Allow for assigning to read only props via the "backdoor"
* Bypasses getters / setters, sets directly to (private) memory slots
* Doesn't do any notification
* Allows for nested property setting via assignGingerly
*/
covertAssignment(obj: any): void;
/**
* fires event with name matching the name of the property when the value changes (but not via covertAssignment)
* when property is set via public interface, not via an action method's return object
*/
get propagator() : EventTarget;
/**
* Only useful if there are scenarios where you need to reactively
* respond to deeply nested prop modifications [TODO]
*/
set gingerLog(log: Set<string>);
/**
* https://github.com/whatwg/dom/issues/1296
*
*/
get disconnectedSignal(): AbortSignal;
/**
* During this time, queues/buses continue to perform "bookkeeping"
* but doesn't process the queue until sleep property becomes falsy.
* If truthy, can call await awake() before processing should resume
*/
get sleep(): any;
async awake();
//make the value sleep 1 step closer to be falsy
nudge();
//make the value of sleep 1 step further away from being falsy
rock();
}So yes, we are still "clinging" to the notion that EventTargets are useful, despite the forewarning:
Unfortunately, not only has our boilerplate code exploded, but we're stuck with a ton of bookkeeping of subscriptions, and a potential memory leak disaster if we don't properly clean everything up in the right way.
So to make concern seem, perhaps, overly alarmist, we add one more "soft" requirement to make the view model be roundabout ready -- the interface should provide a disconnectedSignal abort signal, as recommended by this proposal.
Detecting when roundabout is ready
When roundabout initializes a view model that is an EventTarget (such as a custom element), it dispatches a roundabout-ready event on the element once initialization is complete — propagator created, all processors wired up, initial evaluations run.
This is useful for external parties (like element extensions or binding libraries) that need to access the propagator:
const counter = document.querySelector('user-counter');
if (counter.propagator) {
// Already initialized
usePropagator(counter.propagator);
} else {
counter.addEventListener('roundabout-ready', () => {
usePropagator(counter.propagator);
}, { once: true });
}For a promise-based approach, the dependency assign-gingerly exports a waitForEvent utility:
import { waitForEvent } from 'assign-gingerly/waitForEvent.js';
const counter = document.querySelector('user-counter');
if (!counter.propagator) {
await waitForEvent(counter, 'roundabout-ready');
}
// counter.propagator is guaranteed to exist hereImportant: Always check
counter.propagatorfirst. If roundabout already initialized, theroundabout-readyevent has already fired and won't fire again —waitForEventwould hang forever without the guard.
The event name is also available as a constant:
import { ROUNDABOUT_READY_EVENT } from 'roundabout-lib/core/Events.js';
// ROUNDABOUT_READY_EVENT === 'roundabout-ready'Note: The event is a plain
Event, not aCustomEvent. The propagator is accessible directly aselement.propagator— no need for event detail.
Note: If the vm already provides its own propagator and getter/setters (i.e., a library implements the RoundaboutReady interface), roundabout respects those and skips its own setup. The
roundabout-readyevent still fires once all processors are wired up, regardless of who provided the propagator.
RoundAbout Options
In addition to the class definition or object that needs managing in a roundabout way need to implement the RoundaboutReady interface, when we invoke the RoundAbout manager, we can pass in a configuration objection, containing multiple declarative settings.
The sections below discuss these settings
Compacts
"Compacts" refers to one-way "agreements" between two members of the view model.
Let's say our view model looks like this:
interface MoodStoneProps{
isHappy: boolean,
isNotHappy: boolean,
data: Array<UppersAndDowners>,
dataLength: number,
someOtherLength: number,
readyToPartyTonight: boolean,
age: number,
ageChanged: boolean,
ageChangeCount: number,
}
interface MoodStoneActions{
throwBirthdayParty(self: this): Partial<MoodStoneProps>
}"compacts" look as follows:
const raConfig = {
...
compacts:{
//rhs indicates delay if any
when_age_changes_call_throwBirthdayParty: 0
//rhs indicates delay if any
negate_isHappy_to_isNotHappy: 0,
// if data is falsy, set dataLength to the rhs value
pass_length_of_data_to_dataLength: 0,
//rhs indicates delay if any before echoing the value
echo_dataLength_to_someOtherLength: 20,
//rhs is a property that specifies how long to wait
echo_inputCount_to_inputCountEcho_after: debounceInterval,
// the number on the rhs is the delay to apply, if any
when_age_changes_toggle_ageChangedToggle: 0,
//rhs indicates amount to decrement, which could even be negative!
when_age_changes_inc_ageChangeCount_by: 1,
// When an EventTarget property fires an event, apply a declarative assignFrom pattern
on_click_of_submitButton_assign: {
'?.submitCount +=': 1,
'?.lastSubmitTime': '?.currentTime'
}
}
};
export class MoodStone extends O implements IMoodStoneActions {
async connectedCallback() {
const [vm, propagator] = await roundabout({ vm: this, ...raConfig });
...
}
}[!NOTE] The
on_EVENT_of_X_assigncompact usesassignFromunder the hood, so the pattern value is resolved against the view model (from: vm). Event data is not directly accessible in the pattern; use a handler if you need to read from the event object.
Event-driven compacts
In addition to reactive compacts (when property A changes, do something to property B), you can react to DOM events on EventTarget properties:
compacts: {
// Increment count each time the button is clicked
on_click_of_incrementButton_inc_count_by: 1,
// Set count to zero each time the reset button is clicked
on_click_of_resetButton_set_count_to: 0,
// Apply a full assignFrom pattern when the button is clicked
on_click_of_submitButton_assign: {
'?.submitCount +=': 1,
'?.lastSubmitTime': '?.currentTime'
},
// Apply an assignFrom pattern resolved against the event object
on_input_of_searchInput_assignFromEvent: {
'?.searchText': '?.target?.value'
}
}For on_EVENT_of_X_assign, the value is a pattern object passed to assignFrom. It supports all the usual assignGingerly features: optional-chaining-in-reverse paths (?.prop), += increments, method invocation via withMethods, aliases, etc. The pattern is resolved against the view model, not the event or the element.
For on_EVENT_of_X_assignFromEvent, the pattern is also passed to assignFrom, but it is resolved against the event object instead of the view model. This lets you read event-specific data such as ?.target?.value, ?.detail, ?.key, etc. The results are still merged into the view model.
[!NOTE] Compacts that invoke a method, like the first example, can't be mixed with actions that are tied to the same method, as it creates too much ambiguity, and would thus defeat the purpose of providing better developer ergonomics.
Fully configurable actions
On the opposite extreme of compacts are actions, where we can fine tune exactly when and how to invoke an action. The action key names the method to call on the view model — updateStatus: { ifKeyIn: ['count'] } calls vm.updateStatus(self) when count changes. Actions can pretty much do what all the other configurable settings described in this page can do, but we need to be explicit, so it is a bit more time consuming to set up.
We can specify lists of properties that are required to be truthy before invoking the action, or properties none of which should be truthy, etc.
Handlers - Wiring up EventTarget properties to methods
One example of the kind of complexity that roundabouts can handle cleanly is creating subscriptions between one property that is an instance of an EventTarget (or a weak reference to said instance), and a method of the class we want to call when that eventTarget instance changes, again merging in what the action method returns into the view model. Once again, the signals proposal warns us about the complexity and danger of using pub/sub (such as EventTargets). This library sees it as a challenge that using declarative syntax can rise to, because it will be sure to do what is needed to avoid the real disaster that that proposal warns us about.
Pattern
handlers: {
timeEmitter_to_incTicks_on: 'value-changed'
}This declares: When property timeEmitter is set to an EventTarget (or WeakRef), add an event listener for value-changed, and when that event fires, invoke method incTicks. The method result is automatically merged back into the view model.
Key Features
- Automatic Listener Management: Handlers automatically attach and detach event listeners as the EventTarget property changes
- WeakRef Support: Supports both direct EventTarget references and
WeakRef<EventTarget>for memory safety - Result Merging: Method results are automatically merged back into the view model using assignGingerly
- Proper Cleanup: All event listeners are properly cleaned up when the roundabout is disconnected
- Dynamic Updates: When the EventTarget property changes, the old listener is removed and a new one is attached
Example
const model = {
timeEmitter: new EventTarget(),
tickCount: 0,
incTicks(self, event) {
return {
tickCount: self.tickCount + 1
};
}
};
const [vm, propagator] = await roundabout({
vm: model,
handlers: {
timeEmitter_to_incTicks_on: 'value-changed'
}
});
// Emit event - incTicks will be called automatically
model.timeEmitter.dispatchEvent(new CustomEvent('value-changed'));WeakRef Support
For memory safety, handlers support WeakRef:
const emitter = new EventTarget();
const model = {
emitterRef: new WeakRef(emitter),
eventCount: 0,
handleEvent(self, event) {
return { eventCount: self.eventCount + 1 };
}
};
const [vm, propagator] = await roundabout({
vm: model,
handlers: {
emitterRef_to_handleEvent_on: 'custom-event'
}
});Dynamic EventTarget Changes
When the EventTarget property changes, handlers automatically update:
const emitter1 = new EventTarget();
const emitter2 = new EventTarget();
const model = {
currentEmitter: emitter1,
messageCount: 0,
onMessage(self, event) {
return { messageCount: self.messageCount + 1 };
}
};
const [vm, propagator] = await roundabout({
vm: model,
handlers: {
currentEmitter_to_onMessage_on: 'message'
}
});
// Events from emitter1 trigger the handler
emitter1.dispatchEvent(new CustomEvent('message'));
// Change to emitter2 - old listener removed, new one attached
vm.currentEmitter = emitter2;
// Now only emitter2 events trigger the handler
emitter2.dispatchEvent(new CustomEvent('message'));This is demonstrated by the first web component in the universe to use roundabout.
WeakRef
Roundabout can store DOM element (or other object) references weakly to help avoid memory leaks. When a weakly-held value is garbage collected, the getter returns undefined.
const raConfig = {
weakRef: {
properties: ['incrementButton', 'decrementButton', 'resetButton'],
logIfCollected: 'warn'
},
...
};Single properties
weakRef.properties lists property names whose values should be wrapped in WeakRef. Reading the property transparently dereferences the value.
List properties
weakRef.listProperties lists array-valued properties whose elements should be wrapped in WeakRef. This is useful for holding the results of a querySelectorAll without keeping every element alive forever.
const raConfig = {
weakRef: {
properties: ['hamburgerButton', 'closeButton', 'overlay', 'drawer'],
listProperties: ['divs', 'spans'],
logIfCollected: 'warn'
},
...
};When a list property is read, roundabout returns a new array with each stored WeakRef dereferenced. Collected elements appear as undefined in their original slots so array indices remain stable.
Shorthand
For single properties only, you can also pass an array:
weakRef: ['trigger', 'enhancedElement']Logging
logIfCollected controls what happens when a WeakRef target has been collected:
'error'(default): logs an error'warn': logs a warning'silent': no loggingfunction: custom logging callback
For list properties, the log message reports how many elements were collected and at which indices.
Hitches
Whereas "Compacts" allow us to connect two members of the view model together, hitches allow us to coordinate three members.
const model = {
enhancedElement: HTMLElement | WeakRef<HTMLElement>,
eventProp: 'click',
ageCount: 23
};
...
hitches:{
when_enhancedElement_emits_eventProp_inc_ageCount_by: 1,
}Infractions and Positractions
Infractions and Positractions don't open anything up that couldn't be done with the highly configurable but verbose Actions. Infractions and Positractions just specialize in some common scenarios, and strive to eliminate boilerplate while continuing to encourage JSON driven configuration (easier to parse) and highly performant reactive analysis, without calling code unnecessarily.
Infractions
Infractions is a portmanteau of "inferred reactions", where we "parse" the left hand side of the arrow function or method, in order to determine which parameters it depends on.
const calcAgePlus10: PropsToPartialProps<IMoodStoneProps> = ({age}: IMoodStoneProps) => ({agePlus10: age + 10});
const raConfig = {
infractions: [calcAgePlus10, 'doSearch']
}
export class MoodStone extends HTMLElement implements IMoodStoneActions {
async connectedCallback() {
await roundabout({ vm: this, ...raConfig });
}
doSearch({searchString}){
return {
foundIt: true,
hereItIs: element
}
}
}Making it JSON Serializable
It was briefly mentioned before that one of the goals of roundabouts is that they accept as much JSON serializable information as possible. The config property above isn't serializable as it currently stands. So to make it JSON serializable, we must burden the developer with an extra step:
const raConfig = {
infractions: ['calcAgePlus10']
}
const calcAgePlus10: PropsToPartialProps<IMoodStoneProps> = ({age}: IMoodStoneProps) => ({agePlus10: age + 10});
export class MoodStone extends HTMLElement implements IMoodStoneActions {
calcAgePlus10 = calcAgePlus10;
async connectedCallback() {
await roundabout({ vm: this, ...raConfig });
}
}Instant gratification
We can go in the opposite direction, away from a disciplined approach of making things JSON serializable, but in the direction of "locality of behavior", and inline the infraction:
const raConfig = {
infractions: [({age}: IMoodStoneProps) => ({agePlus10: age + 10})]
};
export class MoodStone extends HTMLElement implements IMoodStoneActions {
async connectedCallback() {
await roundabout({ vm: this, ...raConfig });
}
}Positractions
Another class of arrow functions roundabout recognizes are "positractions" -- a portmanteau of "positional" and "reactions". The examples above have relied on linking to functionality that is intimately aware of the structure of the view model.
But much functionality we want to share within an application and even across applications can be written in a purely generic manner, completely viewModel neutral. For example, suppose we want to reuse a function that takes the maximum of two values and applies it to a third value? We do so as follows:
export interface IMoodStoneProps{
age: number,
heightInInches: number,
maxOfAgeAndHeightInInches: number,
}
const raConfig = {
positractions: [
{
ifKeyIn: ['age', 'heightInInches'],
do: Math.max,
assignTo: ['maxOfAgeAndHeightInInches']
}
]
}
export class MoodStone extends HTML implements IMoodStoneActions {
async connectedCallback() {
await roundabout({ vm: this, ...raConfig });
}
}
export interface MoodStone extends IMoodStoneProps{}The "positional" part of the name comes from our mapping approach -- the function is expected to return an array of unnamed results (a "tuple"), which we then map to various properties of our view model to assign the result to, based on the position in the assignTo array. (Note that in this case the function doesn't return an array. In that case, we treat it as the first element of an imaginary array, for mapping purposes). If a returned element of the tuple can be ignored, simply place a null in that spot of the assignTo array.
By default, the "ifKeyIn" array of property names is passed into the function. An additional option ("pass"), not shown here, allows us to explicitly list the properties to pass, which may be different from the dependencies we want to trigger the function call on.
Making it JSON serializable
Once again, the problem here is we are trying to make our config as JSON serializable as possible. To make it serializable, the developer must add a few steps:
export interface IMoodStoneProps{
age: number,
heightInInches: number,
maxOfAgeAndHeightInInches: number,
}
const raConfig = {
positractions: [
{
ifKeyIn: ['age', 'heightInInches'],
do: 'max',
//pass: ['age', 'heightInInches'],
assignTo: ['maxOfAgeAndHeightInInches']
}
]
}
export class MoodStone extends HTMLElement implements IMoodStoneActions {
max = Math.max;
async connectedCallback() {
await roundabout({ vm: this, ...raConfig });
}
}
export interface MoodStone extends IMoodStoneProps{}More complex example: Looping counter
const getNextValOfLoop = (currentVal: number, from: number, to: number, step=1, loopIfMax=false)
: [number | undefined | null, number, number, number, boolean] => {
let hitMax = false, nextVal = currentVal, startedLoop = false;
if(currentVal === undefined || currentVal === null || currentVal < from){
nextVal = from;
startedLoop = true;
}else{
const possibleNextVal = currentVal + step;
if(possibleNextVal > to){
if(loopIfMax){
nextVal = from;
}else{
hitMax = true;
}
}else{
nextVal = possibleNextVal;
}
}
return [nextVal, hitMax, startedLoop];
}
interface TimeTickerEndUserProps{
/**
* Loop the time ticker.
*/
loop: boolean;
/**
* Upper bound for idx before being reset to 0
*/
repeat: boolean;
enabled: boolean;
disabled: boolean;
}
interface TimeTickerAllProps extends TimeTickerEndUserProps{
ticks: number,
idx: number,
}
export class TimeTicker{
getNextValOfLoop = getNextValOfLoop;
static override config: OConfig<TimeTickerAllProps> = {
positractions: [
{
ifAllOf: ['ticks'],
do: 'getNextValOfLoop',
pass: ['idx', 0, 'repeat', 1, true],
assignTo: ['idx', 'disabled', 'enabled']
}
]
}
}For string members of the pass array, if the string resolves to a member of the class, it dynamically passes that value. Otherwise, it passes the string literal. To pass a string literal even if there is a member of the class with that name, wrap the string in a template literal: 'hello'
To pass self, use '$0'. Exception: If working with enhancements, which also use roundabouts, use $0 to pass in the element being enhanced, but $0+ to pass in the enhancement.
Merging Traffic via assignGingerly
The function assignGingerly allows for safe, nested, recursive property setting, and allows for notifying the object containing the nested property that a change was made, no matter how deep.
It does optional chaining access, but in reverse.
The syntax looks like:
const log = assignGingerly(destObj, {
myProp1: 'hello',
'?.myProp2?.mySubProp3': 'goodbye'
});The second setter prop shown above does the equivalent of:
let log = undefined;
if('assignGingerlyLog' in destObj.assignGingerly){
log = new Set();
}
if(log){
log.add('myProp2')
}
if(destObj.myProp2 === undefined){
destObj.myProp2 = {};
}
if(log){
log.add('myProp2.mySubProp3');
}
destObj.myProp2.mySubProp3 = 'goodbye';
if(log){
destObj.assignGingerlyLog = log;
}Specifying a class to instantiate from when undefined via naming convention
Suppose instead of creating an empty object prototype when referencing a property, we want to instead instantiate a class? I.e. we want to be able to configure what to do when a property is undefined, in the case that we are merging the object into a custom element, or a custom enhancement, or some other JS class instance. I.e. we can tweak this part of the code above:
if(destObj.myProp2 === undefined){
destObj.myProp2 = {};
}Here's how we can do this. Suppose destObj is an instance of class DestObj.
We can define myProp2 thusly:
class DestObj{
async newMyProp2(): MyPropClass{
//do some asynchronous work if nessary;
const returnObj = new MyPropClass();
await returnObj.doSomeInitializationIfNecessary();
return returnObj;
}
#myProp2 : MyPropClass | undefined;
get myProp2(){
return this.#myProp2;
}
set myProp2(newVal){
this.#myProp2 = newVal;
}
}
The thing to note here is that we must (in the absence of a standard decorator built into the platform for this) rely on a specific naming convention between the name of the prop and the method used to instantiate a new instance if that prop is undefined:
myProp2 => newMyProp2.
So this gets translated to:
if(destObj.myProp2 === undefined){
let newInstance;
if(typeof destObj['newMyProp2'] === 'function'){
newInstance = await destObj.newMyProp2();
}else{
newInstance = {};
}
//might have gotten a value during the await
if(destObj.myProp2 === undefined){
destObj.myProp2 = newInstance;
}else{
assignGingerly(destObj.myProp2, newInstance)
}
}Reference
Compacts Reference
Compacts provide a declarative way to connect properties in your view model using naming conventions. The right-hand side value is always the delay in milliseconds (or a property name for echo_X_to_Y_after).
Property Transformation Compacts
negate_X_to_Y
Negates a boolean value from property X to property Y.
compacts: {
negate_isHappy_to_isNotHappy: 0 // When isHappy changes, isNotHappy = !isHappy
}Example:
vm.isHappy = false; // → vm.isNotHappy becomes truepass_length_of_X_to_Y
Passes the .length property of X (array or string) to Y.
compacts: {
pass_length_of_data_to_dataLength: 0 // When data changes, dataLength = data.length
}Example:
vm.data = ['a', 'b', 'c']; // → vm.dataLength becomes 3echo_X_to_Y
Copies/echoes the value from X to Y with optional delay.
compacts: {
echo_inputValue_to_outputValue: 0, // Immediate echo
echo_searchText_to_debouncedSearch: 300 // Echo after 300ms delay
}Example:
vm.inputValue = 'hello'; // → vm.outputValue becomes 'hello'echo_X_to_Y_after
Echoes value from X to Y, with delay specified by another property.
compacts: {
echo_inputCount_to_inputCountEcho_after: 'debounceInterval' // Delay from vm.debounceInterval
}Example:
vm.debounceInterval = 500;
vm.inputCount = 5; // → vm.inputCountEcho becomes 5 after 500msAction Invocation Compacts
when_X_changes_call_Y
Calls method Y whenever property X changes. The method receives self as parameter and can return a partial object to merge back into the view model.
compacts: {
when_age_changes_call_throwBirthdayParty: 0 // Call method when age changes
}Example:
// Method definition
throwBirthdayParty(self) {
return { partyCount: self.partyCount + 1 };
}
vm.age = 26; // → throwBirthdayParty() is called, partyCount incrementsState Mutation Compacts
when_X_changes_toggle_Y
Toggles boolean property Y whenever X changes.
compacts: {
when_age_changes_toggle_ageChangedToggle: 0 // Toggle on each age change
}Example:
vm.ageChangedToggle = false;
vm.age = 26; // → vm.ageChangedToggle becomes true
vm.age = 27; // → vm.ageChangedToggle becomes falsewhen_X_changes_inc_Y_by
Increments (or decrements) property Y by the specified amount whenever X changes.
compacts: {
when_age_changes_inc_ageChangeCount_by: 1, // Increment by 1
when_errors_changes_inc_errorTotal_by: -1 // Decrement by 1 (negative increment)
}Example:
vm.ageChangeCount = 0;
vm.age = 26; // → vm.ageChangeCount becomes 1
vm.age = 27; // → vm.ageChangeCount becomes 2
vm.age = 28; // → vm.ageChangeCount becomes 3when_X_changes_dispatch
Dispatches a custom event on the propagator when X changes.
compacts: {
when_status_changes_dispatch: 'status-changed' // Event name
}Example:
propagator.addEventListener('status-changed', (e) => {
console.log('Status changed to:', e.value);
});
vm.status = 'active'; // → 'status-changed' event is dispatched on the propagatorRHS value: The event name to dispatch. If the RHS is a non-empty string, that string is used as the event name. If omitted or falsy, the source property name is used as the event name.
Event type: The dispatched event is a CompactDispatchEvent with properties:
eventName— the event name stringvalue— the current value of the source property
Event Listener Compacts
on_EVENT_of_X_inc_Y_by
Listens for a DOM event on an EventTarget property and increments a target property. Supports both live references and WeakRef-wrapped references. The listener is automatically attached when the element property is set and cleaned up when it changes or is removed.
compacts: {
on_click_of_incrementButton_inc_count_by: 1, // Increment by 1 on click
on_click_of_decrementButton_inc_count_by: -1, // Decrement by 1 on click
}Example:
vm.incrementButton = document.querySelector('.increment');
// Now clicking the button increments vm.count by 1
vm.incrementButton = anotherButton;
// Old listener removed, new listener attached to anotherButtonon_EVENT_of_X_set_Y_to
Listens for a DOM event on an EventTarget property and sets a target property to a fixed value. Supports both live references and WeakRef-wrapped references.
compacts: {
on_click_of_resetButton_set_count_to: 0, // Reset to 0 on click
on_click_of_clearButton_set_searchText_to: '', // Clear text on click
}Example:
vm.resetButton = document.querySelector('.reset');
// Now clicking the button sets vm.count to 0on_EVENT_of_X_assign
Listens for a DOM event on an EventTarget property and applies an assignFrom pattern to the view model. Supports all assignGingerly features such as optional-chaining-in-reverse paths (?.prop), += increments, method invocation, and aliases. The pattern is resolved against the view model (from: vm), not the event or the element.
compacts: {
on_click_of_submitButton_assign: {
'?.submitCount +=': 1,
'?.lastSubmitTime': '?.currentTime'
}
}Example:
vm.submitButton = document.querySelector('.submit');
vm.currentTime = Date.now();
// Now clicking the button increments vm.submitCount and copies vm.currentTime to vm.lastSubmitTimeon_EVENT_of_X_assignFromEvent
Listens for a DOM event on an EventTarget property and applies an assignFrom pattern to the view model. The pattern is resolved against the event object (from: event), making event-specific data such as target.value, detail, or key accessible. The assignment results are still merged into the view model.
compacts: {
on_input_of_searchInput_assignFromEvent: {
'?.searchText': '?.target?.value'
}
}Example:
vm.searchInput = document.querySelector('input[type="search"]');
// Now typing into the input copies event.target.value into vm.searchTextQuick Reference Table
| Pattern | Purpose | RHS Value | Example |
|---------|---------|-----------|---------|
| negate_X_to_Y | Boolean negation | Delay (ms) | negate_isOpen_to_isClosed: 0 |
| pass_length_of_X_to_Y | Array/string length | Delay (ms) | pass_length_of_items_to_count: 0 |
| echo_X_to_Y | Copy value | Delay (ms) | echo_input_to_output: 0 |
| echo_X_to_Y_after | Copy with dynamic delay | Property name | echo_value_to_delayed_after: 'debounce' |
| when_X_changes_call_Y | Invoke method | Delay (ms) | when_data_changes_call_process: 0 |
| when_X_changes_toggle_Y | Toggle boolean | Delay (ms) | when_click_changes_toggle_active: 0 |
| when_X_changes_inc_Y_by | Increment counter | Amount | when_event_changes_inc_count_by: 1 |
| when_X_changes_dispatch | Fire event | Event name | when_state_changes_dispatch: 'changed' |
| on_EVENT_of_X_inc_Y_by | Increment on DOM event | Amount | on_click_of_button_inc_count_by: 1 |
| on_EVENT_of_X_set_Y_to | Set value on DOM event | Value to set | on_click_of_reset_set_count_to: 0 |
| on_EVENT_of_X_assign | Apply assignFrom pattern on DOM event | Pattern object | on_click_of_button_assign: { '?.clicked': true } |
| on_EVENT_of_X_assignFromEvent | Apply assignFrom pattern resolved against event | Pattern object | on_input_of_input_assignFromEvent: { '?.text': '?.target?.value' } |
Tips
- Delays: Use
0for immediate execution, or specify milliseconds for debouncing - Method calls: Methods receive
selfas parameter and should returnPartial<Props>to merge - Chaining: Multiple compacts can work together - one compact's output can trigger another
- Testing: Each compact type has test examples in
tests/compacts/
Hitches Reference
Hitches coordinate three members of the view model: an EventTarget element, an event type property, and a target property to modify. They're perfect for connecting DOM events to view model state.
Pattern
hitches: {
when_X_emits_Y_inc_Z_by: number
}- X: Property containing an EventTarget (element or WeakRef)
- Y: Property containing the event name (string)
- Z: Property to increment
- Value: The increment amount (number)
Basic Example
const myObject = {
button: document.querySelector('#myButton'),
eventName: 'click',
clickCount: 0
};
const [vm] = await roundabout({
vm: myObject,
propagate: ['clickCount'],
hitches: {
when_button_emits_eventName_inc_clickCount_by: 1
}
});
// Now clicking the button increments clickCountDynamic Element Changes
Hitches automatically handle element changes:
const myObject = {
activeButton: button1, // Start with button1
eventName: 'click',
count: 0
};
const [vm] = await roundabout({
vm: myObject,
propagate: ['activeButton', 'count'],
hitches: {
when_activeButton_emits_eventName_inc_count_by: 1
}
});
// Clicks on button1 increment count
button1.click(); // count = 1
// Change to button2
vm.activeButton = button2;
// Now clicks on button2 increment count
button2.click(); // count = 2
// button1 clicks no longer affect count
button1.click(); // count still = 2Behavior:
- When element property changes, old listener is automatically removed
- New listener is attached to the new element
- If element becomes falsy, listener is removed (no error)
Dynamic Event Type Changes
Hitches also handle event type changes:
const myObject = {
button: document.querySelector('#myButton'),
eventType: 'click', // Start with click
eventCount: 0
};
const [vm] = await roundabout({
vm: myObject,
propagate: ['eventType', 'eventCount'],
hitches: {
when_button_emits_eventType_inc_eventCount_by: 1
}
});
// Clicks increment count
button.click(); // eventCount = 1
// Change to mouseenter
vm.eventType = 'mouseenter';
// Now mouseenter increments count
button.dispatchEvent(new MouseEvent('mouseenter')); // eventCount = 2
// Clicks no longer affect count
button.click(); // eventCount still = 2WeakRef Support
Hitches support WeakRef for elements to prevent memory leaks:
const myObject = {
elementRef: new WeakRef(document.querySelector('#myElement')),
eventName: 'click',
count: 0
};
const [vm] = await roundabout({
vm: myObject,
hitches: {
when_elementRef_emits_eventName_inc_count_by: 1
}
});
// WeakRef is automatically dereferenced
// If element is garbage collected, listener is cleaned upCleanup
Hitches automatically clean up listeners:
const [vm, propagator] = await roundabout({
vm: myObject,
hitches: { /* ... */ }
});
// Later, when done:
vm.RAController.abort(); // All hitch listeners are removedUse Cases
Click counters:
hitches: {
when_button_emits_click_inc_clickCount_by: 1
}Multi-button interfaces:
// Track which button is active
hitches: {
when_activeButton_emits_click_inc_actionCount_by: 1
}Different event types:
// Switch between click, mouseenter, focus, etc.
hitches: {
when_element_emits_eventType_inc_interactionCount_by: 1
}Custom increments:
// Increment by different amounts
hitches: {
when_button_emits_click_inc_score_by: 10
}Error Handling
Hitches handle edge cases gracefully:
- Element is falsy: Listener removed, no error
- Element is not EventTarget: Error logged to console, no crash
- Event type is falsy: Listener removed, no error
- Target property not a number: Initialized to increment value
Quick Reference
| Component | Type | Purpose | |-----------|------|---------| | X (element) | EventTarget or WeakRef | Element to listen to | | Y (event) | string | Event type name | | Z (target) | number | Property to increment | | Value | number | Increment amount |
Pattern: when_X_emits_Y_inc_Z_by: number
Testing: See tests/hitches/ for comprehensive examples.
Yields Reference
Yields derive a value from a collection using an index (or in the future, a key). When the source collection or the selector property changes, the target property is automatically recomputed.
Scenario I: Single Selection by Index
Select one item from an array by index. When either the array or the index changes, the target is updated.
const [vm] = await roundabout({
vm: {
items: ['apple', 'banana', 'cherry'],
idx: 0,
item: undefined,
},
yields: {
item: { from: 'items', atIndex: 'idx' }
}
});
// item is immediately computed as 'apple' (items[0])
vm.idx = 2;
// → vm.item becomes 'cherry'
vm.items = ['x', 'y', 'z'];
// → vm.item becomes 'z' (items[2])
vm.idx = 10;
// → vm.item becomes undefined (out of bounds)Configuration
yields: {
[targetProp: string]: {
from: string; // Source array property name
atIndex?: string; // Index property name
outOfBounds?: 'undefined' // What to do when index is out of bounds:
| 'clamp'; // 'undefined' (default): set target to undefined
// 'clamp': reset index to 0, select first item
// Future: atKey, atIndices, keyProp, etc.
}
}Behavior
- Initial computation: The target is computed immediately when yields are processed (no need to trigger a change first).
- One-way: Changing the target property directly does NOT update the index. The flow is strictly `array + index →


