5.0.0-beta.4 · Published beta · f4165f14 npm archive. Reading source: f4165f14198da115cc998c842fbf4b6a4886fe45. Match APIs to your installed version.
ConsumerOwn effects explicitly#
Application stateEvents, radioEvents, radioRequests, and ordinary listenTo
bindings have object lifetime. Stop does not remove them; destroy releases the
framework-owned subscriptions. Use them for deliberately persistent behavior.
For a restartable feature, give subscriptions and asynchronous work an explicit owner. The following application module uses one small effects scope. It is application code, not a Marionette export or a new framework lifecycle.
Choose when effects end#
This example activates effects during startup so loading-time state and Radio
notifications can be observed. Initial state is seeded before subscriptions.
onStart reads the current state instead of replaying notifications.
Effects remain active while asynchronous stop permission is pending. Successful
stop disposes them in onStop. Rejected stop permission leaves them intact, so no missed
notifications need to be replayed. If pending stop must freeze interaction, disable
that feature's controls while asking permission; do not silently discard source
changes. Starting from stopped creates a fresh scope.
Canceling pending startup or rejecting its loader disposes the scope immediately.
Terminal destruction
also disposes effects. A readiness signal cancels only its pending phase; it is
not a signal for the entire subsequent run. The scope below owns that longer
lifetime and passes its signal to the loader. Every awaited continuation checks
cancellation before committing its result, including providers that ignore abort.
A complete feature#
Save this module as status-feature.js. Supply an element, an @mnjs/data Model,
a Radio channel dedicated to this feature, and load({ signal }) returning
{ label }. beforeStop(options, { signal }) may await a permission decision and
reject to keep the feature running. Its own asynchronous work must honor cancellation.
When several features need the same scope, move createEffects into one shared
application module and import it in those features. Keep one implementation and
one set of scope tests; each feature chooses when to create and dispose its own
scope through its lifecycle hooks.
The initial request fetches metadata independent of the filter. Reading the latest
filter after loading is correct here. A server request that captures a filter needs
a separate refresh operation that owns replacement requests; see
refresh without restarting.
Do not map filter changes to restart to implement latest-request-wins behavior.
import { Application, View } from 'marionette';
export function createEffects() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
add(cleanup) {
if (controller.signal.aborted) { cleanup(); }
else { cleanups.push(cleanup); }
},
dispose() {
if (controller.signal.aborted) { return; }
controller.abort();
for (const cleanup of cleanups.splice(0).reverse()) { cleanup(); }
}
};
}
const StatusView = View.extend({
template: false,
update(label, filter) { this.el.textContent = `${label}: ${filter}`; }
});
// load reads feature metadata, independently of the current filter.
export function createStatusFeature({ el, state, channel, load, beforeStop = async() => {}, tick = () => {} }) {
const Feature = Application.extend({
updateDisplay() {
this.getView()?.update(this.label, this.getState().get('filter'));
},
async prepareStart(options, { signal }) {
this.effects?.dispose();
// Seed before subscribing. The source is borrowed and survives each run.
if (state.get('filter') === undefined) { state.set('filter', 'open'); }
const effects = createEffects();
this.effects = effects;
const cancel = () => effects.dispose();
signal.addEventListener('abort', cancel, { once: true });
this.releaseReadiness = () => signal.removeEventListener('abort', cancel);
effects.add(this.releaseReadiness);
const update = () => this.updateDisplay();
state.on('change:filter', update);
effects.add(() => state.off('change:filter', update));
channel.on('refresh:display', update);
effects.add(() => channel.off('refresh:display', update));
const currentFilter = () => state.get('filter');
channel.reply('current:filter', currentFilter);
effects.add(() => channel.stopReplying('current:filter', currentFilter));
const interval = setInterval(tick, 1000);
effects.add(() => clearInterval(interval));
try {
const metadata = await load({ signal: effects.signal });
if (effects.signal.aborted) { return; }
this.label = metadata.label;
} catch (error) {
const canceled = effects.signal.aborted;
effects.dispose();
if (!canceled) { throw error; }
}
},
onStart() {
// Successful readiness ends; the effect scope continues until deactivation.
this.releaseReadiness();
this.showView(new StatusView());
this.updateDisplay();
},
prepareStop(options, context) {
// A rejected permission leaves the running feature and its effects intact.
return beforeStop(options, context);
},
onStop() { this.effects?.dispose(); },
onBeforeDestroy() { this.effects?.dispose(); }
});
return new Feature({ region: { el }, state });
}
Call and await the returned Application's start, stop, restart, and destroy
methods normally. The hooks also run when an owning Application stops or destroys
this feature. No wrapper must intercept those calls. updateDisplay uses a public
View method and reads state at the time of display; the borrowed Model is never
recreated or disposed by this example.
The dedicated Radio channel has one owner for current:filter. Disposal removes
only this scope's callbacks, preserving independent observers. The interval and
its work are synchronous; an asynchronous tick would need its own rejection and
cancellation handling. Resources acquired after disposal are released immediately
when added to the scope.
Cleanup follows the synchronous failure contract:
registration, rendering, and cleanup callbacks must work. Synchronous setup or
onStart failures have no automatic rollback; destroy the feature to release
its effect scope. A throwing cleanup in onStop rejects the operation after
its stopped state has committed; later cleanups need not run, and another dispose
call does not retry them. This helper does not promise recovery. It also does not make
asynchronous onStart work part of readiness.
No production-library import, subscription wrapper, per-instance allocation, or runtime registry is added by this documentation pattern. Only applications that copy and use the helper pay for its controller and registered cleanup callbacks.