Skip to content

Commit d68b207

Browse files
committed
feat(core): add Context Protocol and Task controller
Context: createContext, ContextProvider, ContextConsumer for cross- component data sharing without prop drilling. Uses W3C Context Protocol (DOM events, crosses shadow DOM). Task: async data controller with INITIAL/PENDING/COMPLETE/ERROR states, AbortSignal, autoRun, render() helper. Standardizes async data fetching in components.
1 parent 231bc4c commit d68b207

4 files changed

Lines changed: 1271 additions & 0 deletions

File tree

packages/core/src/context.js

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* Context Protocol — cross-component data sharing without prop drilling.
3+
*
4+
* Implements the W3C Community Group Context Protocol so any web component
5+
* (not just webjs components) can participate as a provider or consumer.
6+
*
7+
* ## When to use
8+
*
9+
* Use Context when you need to share data (theme, auth state, locale,
10+
* config, feature flags) across deeply nested components without passing
11+
* props through every intermediate level.
12+
*
13+
* Prefer Context over module-level globals when the shared data is **scoped
14+
* to a subtree** — e.g. a specific page, panel, or dialog — and different
15+
* subtrees may hold different values for the same context key.
16+
*
17+
* ## When NOT to use
18+
*
19+
* - For page-level data loading, use async page functions or server actions
20+
* instead. Context is a client-side primitive.
21+
* - If only a parent and its direct child need to communicate, plain
22+
* properties/attributes are simpler.
23+
* - If every component in the app needs the same single value (e.g. a
24+
* singleton API client), a module-level export may be simpler than
25+
* context.
26+
*
27+
* ## Quick example
28+
*
29+
* ```js
30+
* import { WebComponent, html } from 'webjs';
31+
* import { createContext, ContextProvider, ContextConsumer } from 'webjs/context';
32+
*
33+
* const ThemeCtx = createContext('theme');
34+
*
35+
* class MyApp extends WebComponent {
36+
* static tag = 'my-app';
37+
* _theme = new ContextProvider(this, { context: ThemeCtx, initialValue: 'light' });
38+
* render() {
39+
* return html`
40+
* <button @click=${() => this._theme.setValue(
41+
* this._theme.value === 'light' ? 'dark' : 'light'
42+
* )}>Toggle</button>
43+
* <slot></slot>
44+
* `;
45+
* }
46+
* }
47+
* MyApp.register();
48+
*
49+
* class ThemedCard extends WebComponent {
50+
* static tag = 'themed-card';
51+
* _theme = new ContextConsumer(this, { context: ThemeCtx, subscribe: true });
52+
* render() {
53+
* return html`<div class=${this._theme.value}>Card content</div>`;
54+
* }
55+
* }
56+
* ThemedCard.register();
57+
* ```
58+
*
59+
* `<themed-card>` can live at any depth under `<my-app>` — it finds the
60+
* provider automatically via a bubbling `context-request` event that
61+
* crosses shadow-DOM boundaries (`composed: true`).
62+
*
63+
* @module
64+
*/
65+
66+
// ---------------------------------------------------------------------------
67+
// Context key
68+
// ---------------------------------------------------------------------------
69+
70+
/**
71+
* @template T
72+
* @typedef {{ __context__: typeof CONTEXT_KEY, name: string }} Context
73+
*/
74+
75+
const CONTEXT_KEY = Symbol.for('webjs.context');
76+
77+
/**
78+
* Create a typed context key.
79+
*
80+
* The returned object is used as an identity token — two calls to
81+
* `createContext('theme')` produce **different** contexts even though
82+
* the debug name is the same. Store the key in a shared module and
83+
* import it from both provider and consumer.
84+
*
85+
* @template T
86+
* @param {string} name Human-readable name (used in error messages and
87+
* devtools, not for matching).
88+
* @returns {Context<T>}
89+
*/
90+
export function createContext(name) {
91+
return { __context__: CONTEXT_KEY, name };
92+
}
93+
94+
// ---------------------------------------------------------------------------
95+
// ContextRequestEvent
96+
// ---------------------------------------------------------------------------
97+
98+
/**
99+
* Fired by a consumer to locate a provider higher in the DOM.
100+
*
101+
* - `bubbles: true` so it walks up the DOM tree.
102+
* - `composed: true` so it crosses shadow-DOM boundaries.
103+
*
104+
* Providers listen for this event on their host element and respond by
105+
* calling the event's `callback` with the current value.
106+
*
107+
* @template T
108+
*/
109+
export class ContextRequestEvent extends Event {
110+
/**
111+
* @param {Context<T>} context The context key to request.
112+
* @param {(value: T, unsubscribe?: () => void) => void} callback
113+
* Called by the provider with the current value. If `subscribe` is
114+
* true the provider retains the callback and calls it again whenever
115+
* the value changes (passing an `unsubscribe` function on each call).
116+
* @param {boolean} subscribe Whether the consumer wants ongoing updates.
117+
*/
118+
constructor(context, callback, subscribe = false) {
119+
super('context-request', { bubbles: true, composed: true });
120+
/** @type {Context<T>} */
121+
this.context = context;
122+
/** @type {(value: T, unsubscribe?: () => void) => void} */
123+
this.callback = callback;
124+
/** @type {boolean} */
125+
this.subscribe = subscribe;
126+
}
127+
}
128+
129+
// ---------------------------------------------------------------------------
130+
// ContextProvider
131+
// ---------------------------------------------------------------------------
132+
133+
/**
134+
* @template T
135+
* @typedef {{ callback: (value: T, unsubscribe?: () => void) => void }} Subscription
136+
*/
137+
138+
/**
139+
* A ReactiveController that provides a context value to descendant
140+
* components.
141+
*
142+
* The provider listens for `context-request` events on its host element.
143+
* When a matching request arrives it delivers the current value (and,
144+
* for subscribing consumers, retains the callback for future updates).
145+
*
146+
* ## AI guidance
147+
*
148+
* - Create one `ContextProvider` per context key per component.
149+
* - Call `setValue()` to push updates to all subscribing consumers.
150+
* - The provider **must** be a DOM ancestor of its consumers (shadow-DOM
151+
* depth does not matter thanks to `composed: true`).
152+
*
153+
* @template T
154+
*/
155+
export class ContextProvider {
156+
/**
157+
* @param {import('./component.js').WebComponent} host
158+
* The host component that owns this provider.
159+
* @param {{ context: Context<T>, initialValue?: T }} options
160+
*/
161+
constructor(host, { context, initialValue }) {
162+
/** @type {import('./component.js').WebComponent} */
163+
this._host = host;
164+
/** @type {Context<T>} */
165+
this._context = context;
166+
/** @type {T} */
167+
this._value = /** @type {T} */ (initialValue);
168+
/** @type {Set<Subscription<T>>} */
169+
this._subscriptions = new Set();
170+
171+
/** @type {(e: Event) => void} */
172+
this._onRequest = (e) => {
173+
const evt = /** @type {ContextRequestEvent<T>} */ (e);
174+
if (evt.context !== this._context) return;
175+
176+
// Prevent further providers higher in the tree from also responding.
177+
e.stopPropagation();
178+
179+
if (evt.subscribe) {
180+
/** @type {Subscription<T>} */
181+
const sub = { callback: evt.callback };
182+
const unsubscribe = () => { this._subscriptions.delete(sub); };
183+
this._subscriptions.add(sub);
184+
evt.callback(this._value, unsubscribe);
185+
} else {
186+
evt.callback(this._value);
187+
}
188+
};
189+
190+
// Follow the ReactiveController protocol.
191+
if (typeof host.addController === 'function') {
192+
host.addController(this);
193+
}
194+
}
195+
196+
/** @returns {T} The current provided value. */
197+
get value() {
198+
return this._value;
199+
}
200+
201+
/**
202+
* Update the provided value and notify all subscribers.
203+
*
204+
* Every subscribing consumer's callback is invoked synchronously with
205+
* the new value. Each consumer then calls `host.requestUpdate()` to
206+
* schedule a re-render — so one `setValue` batches all downstream
207+
* re-renders via the microtask queue.
208+
*
209+
* @param {T} newValue
210+
*/
211+
setValue(newValue) {
212+
if (Object.is(this._value, newValue)) return;
213+
this._value = newValue;
214+
for (const sub of this._subscriptions) {
215+
const unsubscribe = () => { this._subscriptions.delete(sub); };
216+
sub.callback(newValue, unsubscribe);
217+
}
218+
}
219+
220+
/** Called by the host's controller lifecycle when the element connects. */
221+
hostConnected() {
222+
this._host.addEventListener('context-request', this._onRequest);
223+
}
224+
225+
/** Called by the host's controller lifecycle when the element disconnects. */
226+
hostDisconnected() {
227+
this._host.removeEventListener('context-request', this._onRequest);
228+
this._subscriptions.clear();
229+
}
230+
}
231+
232+
// ---------------------------------------------------------------------------
233+
// ContextConsumer
234+
// ---------------------------------------------------------------------------
235+
236+
/**
237+
* A ReactiveController that consumes a context value from an ancestor
238+
* provider.
239+
*
240+
* On `hostConnected` it dispatches a `ContextRequestEvent`. If a provider
241+
* for the matching context key exists higher in the DOM tree, the consumer
242+
* receives the current value immediately (and, if `subscribe: true`, all
243+
* future updates as well).
244+
*
245+
* ## AI guidance
246+
*
247+
* - Use `subscribe: true` (the default) when you want the consumer to
248+
* update automatically whenever the provider calls `setValue()`. This is
249+
* the common case for reactive data like theme, auth, or locale.
250+
* - Use `subscribe: false` for one-shot reads where you only need the
251+
* value at connection time (e.g. reading a static config once).
252+
* - Access the value via `consumer.value`. It is `undefined` until a
253+
* provider responds.
254+
* - If no provider exists in the ancestor chain, `value` stays
255+
* `undefined` — design your render method to handle that case.
256+
*
257+
* @template T
258+
*/
259+
export class ContextConsumer {
260+
/**
261+
* @param {import('./component.js').WebComponent} host
262+
* The host component that owns this consumer.
263+
* @param {{ context: Context<T>, subscribe?: boolean }} options
264+
* `subscribe` defaults to `true`.
265+
*/
266+
constructor(host, { context, subscribe = true }) {
267+
/** @type {import('./component.js').WebComponent} */
268+
this._host = host;
269+
/** @type {Context<T>} */
270+
this._context = context;
271+
/** @type {boolean} */
272+
this._subscribe = subscribe;
273+
/** @type {T | undefined} */
274+
this._value = undefined;
275+
/** @type {(() => void) | undefined} */
276+
this._unsubscribe = undefined;
277+
278+
if (typeof host.addController === 'function') {
279+
host.addController(this);
280+
}
281+
}
282+
283+
/**
284+
* The current context value. `undefined` if no provider has responded
285+
* yet.
286+
* @returns {T | undefined}
287+
*/
288+
get value() {
289+
return this._value;
290+
}
291+
292+
/** Called by the host's controller lifecycle when the element connects. */
293+
hostConnected() {
294+
this._dispatchRequest();
295+
}
296+
297+
/** Called by the host's controller lifecycle when the element disconnects. */
298+
hostDisconnected() {
299+
if (this._unsubscribe) {
300+
this._unsubscribe();
301+
this._unsubscribe = undefined;
302+
}
303+
}
304+
305+
/** @private */
306+
_dispatchRequest() {
307+
const event = new ContextRequestEvent(
308+
this._context,
309+
(value, unsubscribe) => {
310+
// Guard against duplicate updates with the same value.
311+
const old = this._value;
312+
this._value = value;
313+
if (unsubscribe) {
314+
this._unsubscribe = unsubscribe;
315+
}
316+
// Trigger a re-render of the consuming component when the value
317+
// changes (skip on the very first delivery during connection,
318+
// since the host will render after connectedCallback anyway —
319+
// but we still update to be safe with various host lifecycles).
320+
if (!Object.is(old, value) && typeof this._host.requestUpdate === 'function') {
321+
this._host.requestUpdate();
322+
}
323+
},
324+
this._subscribe,
325+
);
326+
327+
this._host.dispatchEvent(event);
328+
}
329+
}

0 commit comments

Comments
 (0)