|
| 1 | +# @statewalker/shared-slots |
| 2 | + |
| 3 | +Typed pub/sub slots for cross-fragment extension points (Eclipse-style). |
| 4 | + |
| 5 | +## Installation |
| 6 | + |
| 7 | +```sh |
| 8 | +pnpm add @statewalker/shared-slots |
| 9 | +``` |
| 10 | + |
| 11 | +## Why this exists |
| 12 | + |
| 13 | +Slots are the umbrella's primitive for **declared, reference-keyed |
| 14 | +extension points**. A fragment that owns a contract declares a slot; |
| 15 | +other fragments contribute values into it; the consumer iterates the |
| 16 | +contributions. The shape mirrors `@statewalker/shared-intents` |
| 17 | +exactly — one workspace = one bus, accessed via |
| 18 | +`workspace.requireAdapter(Slots)`. |
| 19 | + |
| 20 | +## Usage |
| 21 | + |
| 22 | +```ts |
| 23 | +import { newSlot, Slots } from "@statewalker/shared-slots"; |
| 24 | + |
| 25 | +// Declaration site (the contract): |
| 26 | +interface MimeRenderer { |
| 27 | + match: (mime: string) => number; |
| 28 | + catalogId: string; |
| 29 | +} |
| 30 | + |
| 31 | +export const [provideMimeRenderer, observeMimeRenderers] = |
| 32 | + newSlot<MimeRenderer>("files:mime-renderers"); |
| 33 | + |
| 34 | +// Provider (any fragment): |
| 35 | +import { provideMimeRenderer } from "@my-app/files"; |
| 36 | +const dispose = provideMimeRenderer(slots, { |
| 37 | + match: (m) => (m === "text/markdown" ? 1 : 0), |
| 38 | + catalogId: "markdown-viewer", |
| 39 | +}); |
| 40 | + |
| 41 | +// Consumer (the files fragment, iterating contributions): |
| 42 | +const renderers = slots.getSnapshot<MimeRenderer>("files:mime-renderers"); |
| 43 | +const best = renderers |
| 44 | + .map((r) => ({ score: r.match(mime), id: r.catalogId })) |
| 45 | + .sort((a, b) => b.score - a.score)[0]; |
| 46 | +``` |
| 47 | + |
| 48 | +## React |
| 49 | + |
| 50 | +```tsx |
| 51 | +import { useSlot } from "@statewalker/shared-slots/react"; |
| 52 | +import { observeMimeRenderers } from "@my-app/files"; |
| 53 | + |
| 54 | +function MyComponent({ slots }: { slots: Slots }) { |
| 55 | + const renderers = useSlot(slots, observeMimeRenderers); |
| 56 | + // re-renders when providers register/dispose; stable reference otherwise |
| 57 | + return <ul>{renderers.map((r) => <li key={r.catalogId}>{r.catalogId}</li>)}</ul>; |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +`useSlot` extracts the slot key from the `observe` function it |
| 62 | +receives (attached via a hidden symbol when `newSlot` builds it), |
| 63 | +so callers don't pass the key twice. Hand-rolled observers that |
| 64 | +didn't go through `newSlot` won't work with `useSlot` — by design. |
| 65 | + |
| 66 | +## API |
| 67 | + |
| 68 | +- `Slots` — the bus class. One workspace = one bus. |
| 69 | + - `provide<T>(key, value): () => void` |
| 70 | + - `observe<T>(key, cb): () => void` (synchronous immediate snapshot |
| 71 | + + sync notifications) |
| 72 | + - `getSnapshot<T>(key): readonly T[]` (referentially stable until |
| 73 | + next `provide`/dispose for that key) |
| 74 | +- `newSlot<T>(key) → [provide, observe]` — typed declaration. The |
| 75 | + returned tuple matches `newIntent` in shape. |
| 76 | +- `useSlot<T>(slots, observe): readonly T[]` — React hook |
| 77 | + (subpath `/react`). |
| 78 | + |
| 79 | +## Identity & dependency direction |
| 80 | + |
| 81 | +**Reference identity.** Values are stored in a `Set`, deduped by |
| 82 | +reference. Providing the same object twice = one entry. Two |
| 83 | +structurally-equal-but-distinct objects = two entries. If you need |
| 84 | +identity-by-data, dedupe at provision time. |
| 85 | + |
| 86 | +**Dependency direction (the rule slots enforce).** The slot's |
| 87 | +declaring module is the contract owner. The owner must not depend |
| 88 | +on any specific provider or observer. Providers and observers may |
| 89 | +freely import the contract. This is the one-way arrow that makes |
| 90 | +slots Eclipse-style — a third-party plug-in can declare its own |
| 91 | +slot and other plug-ins can contribute without touching either the |
| 92 | +plug-in or the host. |
| 93 | + |
| 94 | +The asymmetry vs. `Intents`: intents are RPC (bidirectional |
| 95 | +dispatch is the point); slots are pub/sub containers (the |
| 96 | +declaring module reads its contents, so contents-readers being |
| 97 | +independent of contents-providers is what makes the slot |
| 98 | +extensible). |
| 99 | + |
| 100 | +## Related |
| 101 | + |
| 102 | +- `@statewalker/shared-intents` — the sibling RPC bus. |
| 103 | +- `@statewalker/shared-registry` — LIFO cleanup for |
| 104 | + `provide` / `observe` disposers. |
| 105 | +- `@statewalker/workspace-api` — the `Workspace` adapter host. |
| 106 | + |
| 107 | +## License |
| 108 | + |
| 109 | +MIT — see the monorepo root `LICENSE`. |
0 commit comments