Skip to content

Commit 32f7c42

Browse files
mkotelnikovclaude
andcommitted
feat(shared-slots): new package — typed pub/sub slots for extension points
Mirrors @statewalker/shared-intents one-for-one: `Slots` workspace adapter, `newSlot<T>(key) → [provide, observe]` factory, `Slots.getSnapshot` with referentially-stable arrays, plus a `useSlot` React hook on the `/react` subpath. The hook reads the slot key from a hidden symbol attached by `newSlot`, so consumers don't pass the key twice. Implements the foundation primitive for Eclipse-style extension points described in notes/2026-05/2026-05-06/03.dockview-json-render-intents-vision.md. 20/20 vitest tests covering provide/observe/dispose, reference dedupe, observer-error isolation, getSnapshot stability and invalidation, and useSlot re-render counts (related/unrelated/stable-ref). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 66b027c commit 32f7c42

11 files changed

Lines changed: 680 additions & 0 deletions

File tree

packages/shared-slots/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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`.

packages/shared-slots/package.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"name": "@statewalker/shared-slots",
3+
"version": "0.1.0",
4+
"private": false,
5+
"type": "module",
6+
"description": "Typed pub/sub slots for cross-fragment extension points (Eclipse-style).",
7+
"homepage": "https://github.com/statewalker/statewalker-shared",
8+
"author": {
9+
"name": "Mikhail Kotelnikov",
10+
"email": "mikhail.kotelnikov@gmail.com"
11+
},
12+
"license": "MIT",
13+
"repository": {
14+
"type": "git",
15+
"url": "git+ssh://git@github.com/statewalker/statewalker-shared.git"
16+
},
17+
"exports": {
18+
".": "./src/index.ts",
19+
"./react": "./src/react.ts"
20+
},
21+
"files": [
22+
"dist",
23+
"src"
24+
],
25+
"scripts": {
26+
"build": "tsdown",
27+
"dev": "tsdown --watch",
28+
"test": "vitest run --passWithNoTests",
29+
"test:watch": "vitest",
30+
"typecheck": "tsc --noEmit",
31+
"clean": "rimraf dist",
32+
"lint": "biome check --write .",
33+
"format": "biome format --write ."
34+
},
35+
"peerDependencies": {
36+
"react": ">=18"
37+
},
38+
"peerDependenciesMeta": {
39+
"react": {
40+
"optional": true
41+
}
42+
},
43+
"devDependencies": {
44+
"@testing-library/react": "^16.3.0",
45+
"@types/react": "^19.2.5",
46+
"happy-dom": "^17.5.7",
47+
"react": "^19.2.5",
48+
"react-dom": "^19.2.5",
49+
"rimraf": "catalog:",
50+
"tsdown": "catalog:",
51+
"typescript": "catalog:",
52+
"vitest": "catalog:"
53+
},
54+
"sideEffects": false,
55+
"publishConfig": {
56+
"access": "public"
57+
}
58+
}

packages/shared-slots/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type { SlotObserve, SlotProvide } from "./new-slot.js";
2+
export { getSlotKey, newSlot, SLOT_KEY } from "./new-slot.js";
3+
export { Slots } from "./types.js";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Slots } from "./types.js";
2+
3+
export type SlotProvide<T> = (slots: Slots, value: T) => () => void;
4+
export type SlotObserve<T> = (slots: Slots, cb: (values: T[]) => void) => () => void;
5+
6+
/**
7+
* Hidden symbol used to attach the slot's string key to the typed
8+
* `provide` / `observe` functions returned by `newSlot`. The `useSlot`
9+
* React hook reads the key back so it can call `Slots.getSnapshot(key)`
10+
* for `useSyncExternalStore`'s snapshot getter without forcing every
11+
* consumer to pass the key twice.
12+
*/
13+
export const SLOT_KEY: unique symbol = Symbol("statewalker:shared-slots:key");
14+
15+
interface KeyBound {
16+
[SLOT_KEY]?: string;
17+
}
18+
19+
/**
20+
* Declare a typed slot by stable string key. Returns a `[provide, observe]`
21+
* tuple where each function takes the workspace's `Slots` bus as its first
22+
* argument — same shape as `newIntent` from `@statewalker/shared-intents`.
23+
*
24+
* The string key is erased from the consumer's surface; every site that
25+
* contributes to or observes the slot uses the typed `provide` / `observe`
26+
* functions, not the underlying string.
27+
*/
28+
export function newSlot<T>(key: string): [provide: SlotProvide<T>, observe: SlotObserve<T>] {
29+
const provide: SlotProvide<T> = (slots, value) => slots.provide<T>(key, value);
30+
const observe: SlotObserve<T> = (slots, cb) => slots.observe<T>(key, cb);
31+
(provide as unknown as KeyBound)[SLOT_KEY] = key;
32+
(observe as unknown as KeyBound)[SLOT_KEY] = key;
33+
return [provide, observe];
34+
}
35+
36+
/**
37+
* Read the slot key from a `provide` / `observe` function returned by
38+
* `newSlot`. Returns `undefined` for hand-rolled functions that didn't go
39+
* through `newSlot`. Used by the React `useSlot` hook.
40+
*/
41+
export function getSlotKey(fn: SlotProvide<unknown> | SlotObserve<unknown>): string | undefined {
42+
return (fn as unknown as KeyBound)[SLOT_KEY];
43+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { act, cleanup, render } from "@testing-library/react";
2+
import { useRef } from "react";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { newSlot } from "./new-slot.js";
5+
import { useSlot } from "./react.js";
6+
import { Slots } from "./types.js";
7+
8+
interface Thing {
9+
id: string;
10+
}
11+
12+
const [provideThing, observeThing] = newSlot<Thing>("test:thing");
13+
const [provideOther] = newSlot<Thing>("test:other");
14+
15+
afterEach(() => {
16+
cleanup();
17+
});
18+
19+
interface ProbeProps {
20+
slots: Slots;
21+
}
22+
23+
function ThingsProbe({ slots }: ProbeProps): React.ReactElement {
24+
const renderCount = useRef(0);
25+
renderCount.current += 1;
26+
const things = useSlot(slots, observeThing);
27+
const lastRef = useRef<readonly Thing[]>(things);
28+
if (lastRef.current !== things) {
29+
lastRef.current = things;
30+
}
31+
return (
32+
<div data-testid="things" data-render-count={renderCount.current} data-len={things.length} />
33+
);
34+
}
35+
36+
describe("useSlot", () => {
37+
it("re-renders when a related provider registers", () => {
38+
const slots = new Slots();
39+
const { getByTestId } = render(<ThingsProbe slots={slots} />);
40+
expect(getByTestId("things").getAttribute("data-len")).toBe("0");
41+
expect(getByTestId("things").getAttribute("data-render-count")).toBe("1");
42+
43+
act(() => {
44+
provideThing(slots, { id: "a" });
45+
});
46+
expect(getByTestId("things").getAttribute("data-len")).toBe("1");
47+
expect(getByTestId("things").getAttribute("data-render-count")).toBe("2");
48+
});
49+
50+
it("does not re-render when an unrelated provider registers", () => {
51+
const slots = new Slots();
52+
const { getByTestId } = render(<ThingsProbe slots={slots} />);
53+
expect(getByTestId("things").getAttribute("data-render-count")).toBe("1");
54+
55+
act(() => {
56+
provideOther(slots, { id: "z" });
57+
});
58+
expect(getByTestId("things").getAttribute("data-render-count")).toBe("1");
59+
expect(getByTestId("things").getAttribute("data-len")).toBe("0");
60+
});
61+
62+
it("returns a stable array reference between unrelated re-renders", () => {
63+
const slots = new Slots();
64+
let lastSeen: readonly Thing[] | null = null;
65+
let sameRefCount = 0;
66+
67+
function ParentProbe(): React.ReactElement {
68+
const things = useSlot(slots, observeThing);
69+
if (lastSeen !== null && lastSeen === things) {
70+
sameRefCount += 1;
71+
}
72+
lastSeen = things;
73+
return <div />;
74+
}
75+
76+
const { rerender } = render(<ParentProbe />);
77+
// Force a parent re-render with no slot mutation in between.
78+
rerender(<ParentProbe />);
79+
rerender(<ParentProbe />);
80+
expect(sameRefCount).toBeGreaterThanOrEqual(1);
81+
});
82+
83+
it("releases the subscription on unmount", () => {
84+
const slots = new Slots();
85+
const { unmount } = render(<ThingsProbe slots={slots} />);
86+
unmount();
87+
// After unmount there should be no live observers; provide does not throw.
88+
expect(() => provideThing(slots, { id: "after-unmount" })).not.toThrow();
89+
});
90+
91+
it("throws if observe was not produced by newSlot", () => {
92+
const slots = new Slots();
93+
const handRolled = ((s: Slots, cb: (v: Thing[]) => void) =>
94+
s.observe<Thing>("hand-rolled", cb)) as unknown as typeof observeThing;
95+
96+
function BadProbe(): React.ReactElement {
97+
useSlot(slots, handRolled);
98+
return <div />;
99+
}
100+
101+
expect(() => render(<BadProbe />)).toThrow(/observe function was not produced by newSlot/);
102+
});
103+
});

packages/shared-slots/src/react.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useSyncExternalStore } from "react";
2+
import { getSlotKey, type SlotObserve } from "./new-slot.js";
3+
import type { Slots } from "./types.js";
4+
5+
/**
6+
* React hook: subscribe to a slot's contributions on the given `Slots` bus.
7+
* Returns a referentially-stable readonly array — the same reference is
8+
* returned across renders unless the slot has been mutated since the last
9+
* snapshot, so `useSyncExternalStore` does not loop.
10+
*
11+
* Pass the typed `observe` function returned by `newSlot`; `useSlot`
12+
* extracts the slot key from it so callers do not have to pass the key
13+
* twice.
14+
*
15+
* @example
16+
* const [provideThing, observeThing] = newSlot<Thing>("k:thing");
17+
*
18+
* function Component() {
19+
* const slots = useWorkspace().requireAdapter(Slots);
20+
* const things = useSlot(slots, observeThing);
21+
* // things is a readonly Thing[] — re-renders when providers register/dispose
22+
* }
23+
*/
24+
export function useSlot<T>(slots: Slots, observe: SlotObserve<T>): readonly T[] {
25+
const key = getSlotKey(observe);
26+
if (!key) {
27+
throw new Error(
28+
"useSlot: the observe function was not produced by newSlot(...). " +
29+
"useSlot only works with typed slot observers because it needs the " +
30+
"slot key to read a stable getSnapshot() reference.",
31+
);
32+
}
33+
return useSyncExternalStore(
34+
(notify) => observe(slots, () => notify()),
35+
() => slots.getSnapshot<T>(key),
36+
);
37+
}

0 commit comments

Comments
 (0)