Sugar
Some logic belongs to a living component: a notification center owns its queue and its DOM; a dialog service owns the dialog. Sugar makes one mounted instance’s declared api available to the whole application:
html`<${Notifications} expose />` // the instance publishes its api
// anywhere else, in any module, any component:
import { Notifications } from './notifications';
use(Notifications)?.notify('saved!'); // typed by the import itself
use() is keyed by the component function. The import is the discovery
mechanism: no string tokens, no injector, no provider tree.
Application-wide services
Application-wide services need to reach any part of the application while
keeping their internals private and their lifetime tied to a real instance.
A module-level singleton exposes everything, carries no lifecycle, and
provides no signal of whether it exists yet. v5’s lemonade.set('name', fn) / get / dispatch improved on that, but remained keyed by strings,
with the rename and typo fragility of stringly-typed tokens.
v6’s answer is one attribute (expose) and one function (use()), each
guarantee structural rather than conventional:
import { component, html, use } from 'lemonadejs';
export const Notifications = component('notifications', {
api: { notify: Function, clear: Function }, // the declared boundary
}, (props, { state }) => {
const queue = state([]); // PRIVATE, closed over
props.ref?.({
notify: (msg) => (queue.value = [...queue.value, msg]),
clear: () => (queue.value = []),
});
return html`<ul>${() => queue.value.map((m) => html`<li>${m}</li>`)}</ul>`;
});
- Only the declared
apicrosses the boundary.exposecopies exactly the contract’sapikeys from whatever the component passes toprops.ref; an undeclared method never appears inuse()(the suite passes asecretmethod throughrefand asserts it does not cross). Internals are not hidden by convention; they are closed over and unreachable. - The key is the function reference.
use(Notifications)cannot typo, survives renames, and is typed by the same import that locates it. Two different component functions can never collide. - Lifecycle-bound. The api is published when the exposed instance
fulfills its
refand withdrawn on unmount;use()returnsnullbefore the first and after the second. Existence is a checkable fact, not an assumption:use(X)?.method()is the idiom. - Singletons by definition, guarded. A second
exposeof the same component warnsLJS-501(last one wins, almost always a bug), andexposewithout a declaredapiwarns too: an exposure with nothing to expose is always a mistake. A caller’s ownrefstill runs alongsideexpose.
When not to use it. Sugar is for component-owned services:
methods that need a living instance’s private reactive state and DOM. For
shared data there is no component to own it: use
store() with exported actions. A telling fact: none of the
40 Studio blocks uses expose, because blocks are products a host application
composes, and claiming an app-wide singleton slot is the application’s
decision, not a library component’s.
In practice
No Studio block exposes itself (see above), so the working examples live
in the engine’s own suite (tests/contract.test.ts, tests/memory.test.ts),
which pins every claim this chapter makes:
// the boundary: declared api only
const api = use(Notifications);
typeof api.notify // 'function'
typeof api.count // 'function'
api.secret // undefined: passed to ref, NOT declared, never crosses
// driving a mounted instance from outside the tree
api.notify('saved!');
api.notify('again');
// the exposing instance rendered both: <li>saved!</li><li>again</li>
// lifecycle: published while alive, null otherwise
const app = mount(App, root); // App contains <${Notifications} expose />
use(Notifications) // { notify, clear }
app.unmount();
use(Notifications) // null: withdrawn, nothing retained
The memory suite repeats that last cycle and asserts the singleton map holds nothing after unmount; an exposed service cannot pin its dead component (the broader guarantees live in Destroy).
Reference
import { component, html, use } from 'lemonadejs';
const Service = component(name, {
api: { method: Function, ... }, // REQUIRED for expose: the boundary
}, (props, tools) => {
props.ref?.({ method, ... }); // fulfill the api (extras never cross)
return html`...`;
});
html`<${Service} expose />` // publish: one instance, app-wide
use(Service) // the api object, or null
use(Service)?.method() // the consuming idiom
use()returnsnulluntil the exposed instance is mounted, and again after it unmounts.- Re-exposing warns
LJS-501(last wins);exposewithoutapiin the contract warnsLJS-501. - A caller-supplied
refruns in addition toexpose. - Shared application data belongs in store(); sugar is for services owned by a living component.