Skip to content

Your plugin's own store

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: persistence-ports. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.

Your plugin has one store that all of its surfaces share: ctx.state, namespaced to your plugin id and visible in every dock, every instance and every browser window. You need it when several of your surfaces have to agree on something. One example is a wizard whose step form is popped out into a second window while the main window has to see what the user types. The VIEW_STATE handle (View state that survives), in which a docked surface keeps its own filters and scroll position, cannot carry that: it belongs to one mounted view instance.

interface Wizard { readonly customer: string; }
const step = ctx.state.watch<Wizard>('wizard/step-1');
if (step.loaded()) { // check before applying your default
input.value = step.value()?.customer ?? '';
}
input.addEventListener('input', () => step.set({ customer: input.value }));
// when the surface goes away
step.dispose();

Every surface of your plugin sees the same store, in any dock, in any number of instances and in every browser window, so it is both your persistence and the only channel between your own surfaces. The host prefixes every key with your plugin id and you cannot leave that namespace, which is why there is no capability to grant: there is nothing foreign to reach.

Four rules

  • Check loaded() before you apply a default. With a local store it is true at once. With a network-backed one there is a real window in which the store has not answered, and a default applied in that window is overwritten the moment the value lands, after the user has started typing. Where you cannot read reactively, as in activate(), register onChange instead (below).
  • set replaces the whole value; nothing is merged. Use one key per unit of editing: a wizard step, not the whole form. Where your surface can exist more than once, key by instance too (a sandboxed surface receives its instanceId with its pushed state). Two windows writing two keys converge; two windows replacing one key means last write wins, which costs the user’s typing.
  • It holds working state, not settings. Settings have their own path precisely because the user can see and change them in the settings dialog; a free-form settings store would be a back door around that. Uninstalling your plugin deletes this store: a settings section survives, an abandoned draft is litter.
  • Values are JSON and writes are debounced. Siblings in the same window see a change at once; other windows see it once the debounced write lands. A write waits for 400 ms of quiet and never longer than two seconds, and a reload or a closed window sends what was still waiting. With the built-in local store a value you set just before a reload is therefore there afterwards. Where the product backs the store with its server, the write is sent, and whether it completes while the page unloads is that store’s part. There is a size cap per value and a count cap per plugin, with a development warning at half of each, so no plugin can flood the user’s storage.

Whose store it is

The store belongs to the person using the application, not to the browser. Where the product supplies an identity, the platform keeps each person’s state apart, and yours with it. Two people who sign in on the same browser each find the value they left under the same key, and neither sees the other’s. You do nothing for that, and two things follow.

  • Do not clear the store on sign-out. The next person cannot read it, and clearing it throws away what the first person would have found on their return.
  • Do not look for the person’s identity to build keys from. ctx.session tells you whether someone is signed in and which roles they hold. It names no subject, on purpose: a plugin has no use for one that this store does not already serve.

A product that supplies no identity has one store per browser, shared by everyone who uses it. That is the product’s decision, and a plugin cannot repair it. See Persistence stores for the distribution’s side.

Waiting for the store in activate()

value() and loaded() are reactive, but an effect needs an injection context, and activate() runs in one only the first time; after the user switches your plugin off and on again, an effect there fails. onChange needs none. It is called with the value and loaded whenever the value arrives or changes, and at once where the value is already there, so one listener covers a local store and a network-backed one alike:

// in activate(): welcome a first visit, once the store has answered
const welcomed = ctx.state.watch<boolean>('welcomed');
welcomed.onChange((value, loaded) => {
if (!loaded) {
return;
}
if (value !== true) {
welcomed.set(true);
openWelcome();
}
welcomed.dispose();
});

dispose() ends every listener of the handle, and flushes the write first.

The same store in a sandboxed plugin

A sandboxed plugin gets the same store on both of its channels. Its logic document calls stateWatch / stateSet / stateClear / stateUnwatch on the ctx it already has, and the host pushes every change back as stateChanged(key, value, loaded). A surface has the same four methods on its own channel. That matters, because a surface holds no ctx at all and this is the only way two surfaces of one sandboxed plugin can agree on anything. The kit reassembles the pushes into the handle shape above, so the code reads the same as on the trusted rung:

view.js
const shared = LwFrame.state.watch('wizard/step-1');
shared.onChange(render); // re-render when the host pushes
const connection = Penpal.connect({
messenger,
methods: {
render(state) { LwFrame.applySurfaceState(state); render(); },
stateChanged: (key, value, loaded) => LwFrame.state.apply(key, value, loaded),
},
});
connection.promise.then((host) => LwFrame.connectState(host));
input.addEventListener('input', () => shared.set({ customer: input.value }));

Where next