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 api crosses the boundary. expose copies exactly the contract’s api keys from whatever the component passes to props.ref; an undeclared method never appears in use() (the suite passes a secret method through ref and 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 ref and withdrawn on unmount; use() returns null before 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 expose of the same component warns LJS-501 (last one wins, almost always a bug), and expose without a declared api warns too: an exposure with nothing to expose is always a mistake. A caller’s own ref still runs alongside expose.

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() returns null until the exposed instance is mounted, and again after it unmounts.
  • Re-exposing warns LJS-501 (last wins); expose without api in the contract warns LJS-501.
  • A caller-supplied ref runs in addition to expose.
  • Shared application data belongs in store(); sugar is for services owned by a living component.