@dxos/web-context-solid 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ MIT License
2
+ Copyright (c) 2025 DXOS
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @dxos/web-context-solid
2
+
3
+ SolidJS implementation of the Web Component Context Protocol.
4
+
5
+ ## Overview
6
+
7
+ This package allows SolidJS components to seamlessly participate in the [Web Component Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @dxos/web-context-solid @dxos/web-context
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Providing Context
18
+
19
+ ```tsx
20
+ import { createContext } from '@dxos/web-context';
21
+ import { ContextProtocolProvider } from '@dxos/web-context-solid';
22
+
23
+ const ThemeContext = createContext<{ color: string }>('theme');
24
+
25
+ const App = () => (
26
+ <ContextProtocolProvider context={ThemeContext} value={{ color: 'blue' }}>
27
+ <MyComponent />
28
+ </ContextProtocolProvider>
29
+ );
30
+ ```
31
+
32
+ ### Consuming Context
33
+
34
+ ```tsx
35
+ import { useWebComponentContext } from '@dxos/web-context-solid';
36
+
37
+ const MyComponent = () => {
38
+ const theme = useWebComponentContext(ThemeContext, { subscribe: true });
39
+
40
+ return <div style={{ color: theme()?.color }}>Hello World</div>;
41
+ };
42
+ ```
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@dxos/web-context-solid",
3
+ "version": "0.0.0",
4
+ "description": "Solid.js integration with web context protocol",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "DXOS.org",
9
+ "sideEffects": true,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "browser": "./dist/lib/browser/index.mjs",
16
+ "node": "./dist/lib/node-esm/index.mjs"
17
+ }
18
+ },
19
+ "types": "dist/types/src/index.d.ts",
20
+ "typesVersions": {
21
+ "*": {}
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "dependencies": {
28
+ "solid-element": "^1.9.1",
29
+ "@dxos/web-context": "0.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@solidjs/testing-library": "^0.8.10",
33
+ "solid-js": "^1.9.9",
34
+ "vite-plugin-solid": "^2.11.10"
35
+ },
36
+ "peerDependencies": {
37
+ "solid-js": "^1.9.9"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,255 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { cleanup, render, waitFor } from '@solidjs/testing-library';
6
+ import { createSignal } from 'solid-js';
7
+ import { afterEach, describe, expect, test } from 'vitest';
8
+
9
+ import { CONTEXT_REQUEST_EVENT, createContext } from '@dxos/web-context';
10
+
11
+ import { useWebComponentContext } from './consumer';
12
+ import { ContextProtocolProvider } from './provider';
13
+
14
+ describe('useWebComponentContext', () => {
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
18
+
19
+ test('returns undefined when no provider exists', () => {
20
+ const ctx = createContext<string>('test');
21
+ let contextValue: string | undefined;
22
+
23
+ render(() => {
24
+ const value = useWebComponentContext(ctx);
25
+ contextValue = value();
26
+ return <div>Test</div>;
27
+ });
28
+
29
+ expect(contextValue).toBeUndefined();
30
+ });
31
+
32
+ test('receives value from provider', () => {
33
+ const ctx = createContext<string>('test');
34
+ let contextValue: string | undefined;
35
+
36
+ render(() => (
37
+ <ContextProtocolProvider context={ctx} value='hello'>
38
+ {(() => {
39
+ const value = useWebComponentContext(ctx);
40
+ contextValue = value();
41
+ return <div>Test</div>;
42
+ })()}
43
+ </ContextProtocolProvider>
44
+ ));
45
+
46
+ expect(contextValue).toBe('hello');
47
+ });
48
+
49
+ test('receives updates when subscribed', async () => {
50
+ const ctx = createContext<number>('counter');
51
+ const [count, setCount] = createSignal(0);
52
+ const values: number[] = [];
53
+
54
+ render(() => (
55
+ <ContextProtocolProvider context={ctx} value={count}>
56
+ {(() => {
57
+ const value = useWebComponentContext(ctx, { subscribe: true });
58
+ // Track all values we receive
59
+ values.push(value() ?? -1);
60
+ return <div>{value()}</div>;
61
+ })()}
62
+ </ContextProtocolProvider>
63
+ ));
64
+
65
+ expect(values).toContain(0);
66
+
67
+ // Update the value
68
+ setCount(1);
69
+ await Promise.resolve();
70
+
71
+ // Re-render will have picked up the new value via the signal
72
+ // Since SolidJS is fine-grained, we need to access the value in an effect
73
+ });
74
+
75
+ test('does not receive updates when not subscribed', async () => {
76
+ const ctx = createContext<number>('counter');
77
+ const [count, setCount] = createSignal(0);
78
+ const receivedValues: number[] = [];
79
+
80
+ // Use a proper component pattern
81
+ const Consumer = () => {
82
+ const value = useWebComponentContext(ctx, { subscribe: false });
83
+ // Track what values the signal returns when accessed
84
+ receivedValues.push(value() ?? -1);
85
+ return <div data-testid='display'>{value()}</div>;
86
+ };
87
+
88
+ const { findByTestId } = render(() => (
89
+ <ContextProtocolProvider context={ctx} value={count}>
90
+ <Consumer />
91
+ </ContextProtocolProvider>
92
+ ));
93
+
94
+ const display = await findByTestId('display');
95
+ expect(display.textContent).toBe('0');
96
+
97
+ // Clear to track only updates after initial render
98
+ const initialValues = [...receivedValues];
99
+ receivedValues.length = 0;
100
+
101
+ // Update the provider value
102
+ setCount(1);
103
+ await new Promise((resolve) => setTimeout(resolve, 50));
104
+
105
+ // Without subscription, the consumer's signal should NOT update
106
+ // The display should still show the initial value
107
+ expect(display.textContent).toBe('0');
108
+
109
+ // The consumer should not have received any new values
110
+ // (some frameworks might re-render, but the value shouldn't change)
111
+ expect(receivedValues.every((v) => v === 0 || v === -1)).toBe(true);
112
+ });
113
+
114
+ test('can dispatch from custom element', () => {
115
+ const ctx = createContext<string>('test');
116
+ let contextValue: string | undefined;
117
+
118
+ // Create a custom element to dispatch from
119
+ const customEl = document.createElement('div');
120
+ document.body.appendChild(customEl);
121
+
122
+ // Set up provider on body
123
+ const handler = (e: Event) => {
124
+ const event = e as any;
125
+ if (event.context === ctx) {
126
+ event.stopImmediatePropagation();
127
+ event.callback('custom-element-value');
128
+ }
129
+ };
130
+ document.body.addEventListener(CONTEXT_REQUEST_EVENT, handler);
131
+
132
+ render(() => {
133
+ const value = useWebComponentContext(ctx, { element: customEl });
134
+ contextValue = value();
135
+ return <div>Test</div>;
136
+ });
137
+
138
+ expect(contextValue).toBe('custom-element-value');
139
+
140
+ // Cleanup
141
+ document.body.removeEventListener(CONTEXT_REQUEST_EVENT, handler);
142
+ document.body.removeChild(customEl);
143
+ });
144
+
145
+ test('returns accessor that can be called multiple times', () => {
146
+ const ctx = createContext<string>('test');
147
+
148
+ render(() => (
149
+ <ContextProtocolProvider context={ctx} value='test-value'>
150
+ {(() => {
151
+ const value = useWebComponentContext(ctx);
152
+
153
+ // Call the accessor multiple times
154
+ const v1 = value();
155
+ const v2 = value();
156
+ const v3 = value();
157
+
158
+ expect(v1).toBe('test-value');
159
+ expect(v2).toBe('test-value');
160
+ expect(v3).toBe('test-value');
161
+
162
+ return <div>Test</div>;
163
+ })()}
164
+ </ContextProtocolProvider>
165
+ ));
166
+ });
167
+
168
+ test('works with object values', () => {
169
+ const ctx = createContext<{ name: string; count: number }>('user');
170
+ let contextValue: { name: string; count: number } | undefined;
171
+
172
+ render(() => (
173
+ <ContextProtocolProvider context={ctx} value={{ name: 'Alice', count: 42 }}>
174
+ {(() => {
175
+ const value = useWebComponentContext(ctx);
176
+ contextValue = value();
177
+ return <div>Test</div>;
178
+ })()}
179
+ </ContextProtocolProvider>
180
+ ));
181
+
182
+ expect(contextValue).toEqual({ name: 'Alice', count: 42 });
183
+ });
184
+
185
+ test('works with nested providers (gets nearest)', () => {
186
+ const ctx = createContext<string>('test');
187
+ let contextValue: string | undefined;
188
+
189
+ render(() => (
190
+ <ContextProtocolProvider context={ctx} value='outer'>
191
+ <ContextProtocolProvider context={ctx} value='inner'>
192
+ {(() => {
193
+ const value = useWebComponentContext(ctx);
194
+ contextValue = value();
195
+ return <div>Test</div>;
196
+ })()}
197
+ </ContextProtocolProvider>
198
+ </ContextProtocolProvider>
199
+ ));
200
+
201
+ expect(contextValue).toBe('inner');
202
+ });
203
+
204
+ test('different contexts do not interfere', () => {
205
+ const ctx1 = createContext<string>('ctx1');
206
+ const ctx2 = createContext<number>('ctx2');
207
+ let value1: string | undefined;
208
+ let value2: number | undefined;
209
+
210
+ render(() => (
211
+ <ContextProtocolProvider context={ctx1} value='string-value'>
212
+ <ContextProtocolProvider context={ctx2} value={123}>
213
+ {(() => {
214
+ const v1 = useWebComponentContext(ctx1);
215
+ const v2 = useWebComponentContext(ctx2);
216
+ value1 = v1();
217
+ value2 = v2();
218
+ return <div>Test</div>;
219
+ })()}
220
+ </ContextProtocolProvider>
221
+ </ContextProtocolProvider>
222
+ ));
223
+
224
+ expect(value1).toBe('string-value');
225
+ expect(value2).toBe(123);
226
+ });
227
+
228
+ test('signal updates reactively in JSX', async () => {
229
+ const ctx = createContext<number>('counter');
230
+ const [count, setCount] = createSignal(0);
231
+
232
+ // Use a proper component pattern instead of IIFE
233
+ const Consumer = () => {
234
+ const value = useWebComponentContext(ctx, { subscribe: true });
235
+ return <div data-testid='display'>{value()}</div>;
236
+ };
237
+
238
+ const { findByTestId } = render(() => (
239
+ <ContextProtocolProvider context={ctx} value={count}>
240
+ <Consumer />
241
+ </ContextProtocolProvider>
242
+ ));
243
+
244
+ const display = await findByTestId('display');
245
+ expect(display.textContent).toBe('0');
246
+
247
+ // Update the value
248
+ setCount(42);
249
+
250
+ // Wait for the DOM to update
251
+ await waitFor(() => {
252
+ expect(display.textContent).toBe('42');
253
+ });
254
+ });
255
+ });
@@ -0,0 +1,102 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Accessor, createSignal, onCleanup } from 'solid-js';
6
+
7
+ import { ContextRequestEvent, type ContextType, type UnknownContext } from '@dxos/web-context';
8
+
9
+ import { getHostElement } from './internal';
10
+ import { getContextRequestHandler } from './provider';
11
+
12
+ /**
13
+ * Options for useWebComponentContext hook
14
+ */
15
+ export interface UseWebComponentContextOptions {
16
+ /**
17
+ * Whether to subscribe to context updates.
18
+ * If true, the returned signal will update when the provider's value changes.
19
+ * Default: false
20
+ */
21
+ subscribe?: boolean;
22
+
23
+ /**
24
+ * The element to dispatch the context-request event from.
25
+ * This is only used when there's no SolidJS provider in the tree.
26
+ * Default: document.body
27
+ */
28
+ element?: HTMLElement;
29
+ }
30
+
31
+ /**
32
+ * A SolidJS hook that requests context using the Web Component Context Protocol.
33
+ *
34
+ * This first tries to use the SolidJS context chain (for providers in the same
35
+ * SolidJS tree), then falls back to dispatching a DOM event (for web component
36
+ * providers).
37
+ *
38
+ * @param context - The context key to request
39
+ * @param options - Optional configuration
40
+ * @returns An accessor that returns the context value or undefined
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const theme = useWebComponentContext(themeContext);
45
+ * return <div style={{ color: theme()?.primary }}>Hello</div>;
46
+ * ```
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * // Subscribe to updates
51
+ * const theme = useWebComponentContext(themeContext, { subscribe: true });
52
+ * return <div style={{ color: theme()?.primary }}>Hello</div>;
53
+ * ```
54
+ */
55
+ export function useWebComponentContext<T extends UnknownContext>(
56
+ context: T,
57
+ options?: UseWebComponentContextOptions,
58
+ ): Accessor<ContextType<T> | undefined> {
59
+ const [value, setValue] = createSignal<ContextType<T> | undefined>(undefined);
60
+ let unsubscribeFn: (() => void) | undefined;
61
+
62
+ // Create callback that updates our signal
63
+ const callback = (newValue: ContextType<T>, unsubscribe?: () => void): void => {
64
+ setValue(() => newValue);
65
+ // Store the latest unsubscribe function
66
+ if (unsubscribe) {
67
+ unsubscribeFn = unsubscribe;
68
+ }
69
+ };
70
+
71
+ // Determine the target element for the context request
72
+ // Use: 1) explicit element option, 2) host element from custom element context, 3) document.body
73
+ const hostElement = getHostElement();
74
+ const targetElement = options?.element ?? hostElement ?? document.body;
75
+
76
+ // Create the context request event with contextTarget for proper re-parenting support
77
+ const event = new ContextRequestEvent(context, callback, {
78
+ subscribe: options?.subscribe,
79
+ target: targetElement,
80
+ });
81
+
82
+ // First, try to handle via SolidJS context chain (synchronous)
83
+ const handler = getContextRequestHandler();
84
+ let handled = false;
85
+
86
+ if (handler) {
87
+ handled = handler(event);
88
+ }
89
+
90
+ // If not handled by SolidJS providers, try DOM event dispatch
91
+ if (!handled) {
92
+ targetElement.dispatchEvent(event);
93
+ }
94
+
95
+ // Cleanup: unsubscribe when component unmounts
96
+ // Cleanup: unsubscribe when component unmounts
97
+ onCleanup(() => {
98
+ unsubscribeFn?.();
99
+ });
100
+
101
+ return value;
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from '@dxos/web-context';
6
+
7
+ export * from './consumer';
8
+ export * from './provider';
9
+ export * from './solid-element';
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContext as createSolidContext, useContext } from 'solid-js';
6
+
7
+ /**
8
+ * Internal SolidJS context for passing the host element to nested components.
9
+ * This allows useWebComponentContext to dispatch events from the custom element.
10
+ */
11
+ export const HostElementContext = createSolidContext<HTMLElement | undefined>();
12
+
13
+ /**
14
+ * Get the host custom element from SolidJS context.
15
+ * Used internally by useWebComponentContext when called from a custom element.
16
+ */
17
+ export function getHostElement(): HTMLElement | undefined {
18
+ return useContext(HostElementContext);
19
+ }
@@ -0,0 +1,254 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { cleanup, render } from '@solidjs/testing-library';
6
+ import { createSignal } from 'solid-js';
7
+ import { afterEach, describe, expect, test, vi } from 'vitest';
8
+
9
+ import { CONTEXT_REQUEST_EVENT, ContextRequestEvent, createContext } from '@dxos/web-context';
10
+
11
+ import { ContextProtocolProvider } from './provider';
12
+
13
+ describe('ContextProtocolProvider', () => {
14
+ // Clean up after each test
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
18
+
19
+ test('renders children', () => {
20
+ const ctx = createContext<string>('test');
21
+
22
+ const { getByText } = render(() => (
23
+ <ContextProtocolProvider context={ctx} value='hello'>
24
+ <span>Child Content</span>
25
+ </ContextProtocolProvider>
26
+ ));
27
+
28
+ expect(getByText('Child Content')).toBeInTheDocument();
29
+ });
30
+
31
+ test('responds to context-request events with matching context', async () => {
32
+ const ctx = createContext<string>('test');
33
+ const callback = vi.fn();
34
+
35
+ const { container } = render(() => (
36
+ <ContextProtocolProvider context={ctx} value='provided-value'>
37
+ <div data-testid='child'>Child</div>
38
+ </ContextProtocolProvider>
39
+ ));
40
+
41
+ const child = container.querySelector('[data-testid="child"]')!;
42
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
43
+ child.dispatchEvent(event);
44
+
45
+ expect(callback).toHaveBeenCalledWith('provided-value');
46
+ expect(callback).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ test('ignores context-request events with non-matching context', () => {
50
+ const ctx1 = createContext<string>('ctx1');
51
+ const ctx2 = createContext<string>('ctx2');
52
+ const callback = vi.fn();
53
+
54
+ const { container } = render(() => (
55
+ <ContextProtocolProvider context={ctx1} value='value1'>
56
+ <div data-testid='child'>Child</div>
57
+ </ContextProtocolProvider>
58
+ ));
59
+
60
+ const child = container.querySelector('[data-testid="child"]')!;
61
+ const event = new ContextRequestEvent(ctx2, callback, { target: child });
62
+ child.dispatchEvent(event);
63
+
64
+ expect(callback).not.toHaveBeenCalled();
65
+ });
66
+
67
+ test('stops immediate propagation when handling request', () => {
68
+ const ctx = createContext<string>('test');
69
+ const callback = vi.fn();
70
+ const outerHandler = vi.fn();
71
+
72
+ const { container } = render(() => (
73
+ <div>
74
+ <ContextProtocolProvider context={ctx} value='inner-value'>
75
+ <div data-testid='child'>Child</div>
76
+ </ContextProtocolProvider>
77
+ </div>
78
+ ));
79
+
80
+ // Add outer listener
81
+ container.addEventListener(CONTEXT_REQUEST_EVENT, outerHandler);
82
+
83
+ const child = container.querySelector('[data-testid="child"]')!;
84
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
85
+ child.dispatchEvent(event);
86
+
87
+ expect(callback).toHaveBeenCalledWith('inner-value');
88
+ expect(outerHandler).not.toHaveBeenCalled();
89
+
90
+ container.removeEventListener(CONTEXT_REQUEST_EVENT, outerHandler);
91
+ });
92
+
93
+ test('provides unsubscribe callback for subscriptions', () => {
94
+ const ctx = createContext<number>('counter');
95
+ const callback = vi.fn();
96
+
97
+ const { container } = render(() => (
98
+ <ContextProtocolProvider context={ctx} value={42}>
99
+ <div data-testid='child'>Child</div>
100
+ </ContextProtocolProvider>
101
+ ));
102
+
103
+ const child = container.querySelector('[data-testid="child"]')!;
104
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: true, target: child });
105
+ child.dispatchEvent(event);
106
+
107
+ expect(callback).toHaveBeenCalledWith(42, expect.any(Function));
108
+ });
109
+
110
+ test('does not provide unsubscribe for non-subscription requests', () => {
111
+ const ctx = createContext<number>('counter');
112
+ const callback = vi.fn();
113
+
114
+ const { container } = render(() => (
115
+ <ContextProtocolProvider context={ctx} value={42}>
116
+ <div data-testid='child'>Child</div>
117
+ </ContextProtocolProvider>
118
+ ));
119
+
120
+ const child = container.querySelector('[data-testid="child"]')!;
121
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: false, target: child });
122
+ child.dispatchEvent(event);
123
+
124
+ // Should be called with just the value (no unsubscribe)
125
+ expect(callback).toHaveBeenCalledWith(42);
126
+ expect(callback.mock.calls[0].length).toBe(1);
127
+ });
128
+
129
+ test('notifies subscribers when value changes (reactive accessor)', async () => {
130
+ const ctx = createContext<number>('counter');
131
+ const callback = vi.fn();
132
+ const [count, setCount] = createSignal(0);
133
+
134
+ const { container } = render(() => (
135
+ <ContextProtocolProvider context={ctx} value={count}>
136
+ <div data-testid='child'>Child</div>
137
+ </ContextProtocolProvider>
138
+ ));
139
+
140
+ const child = container.querySelector('[data-testid="child"]')!;
141
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: true, target: child });
142
+ child.dispatchEvent(event);
143
+
144
+ expect(callback).toHaveBeenCalledWith(0, expect.any(Function));
145
+ expect(callback).toHaveBeenCalledTimes(1);
146
+
147
+ // Update the value
148
+ setCount(1);
149
+
150
+ // Wait for effect to run
151
+ await Promise.resolve();
152
+
153
+ expect(callback).toHaveBeenCalledWith(1, expect.any(Function));
154
+ expect(callback).toHaveBeenCalledTimes(2);
155
+
156
+ // Update again
157
+ setCount(2);
158
+ await Promise.resolve();
159
+
160
+ expect(callback).toHaveBeenCalledWith(2, expect.any(Function));
161
+ expect(callback).toHaveBeenCalledTimes(3);
162
+ });
163
+
164
+ test('unsubscribe stops updates', async () => {
165
+ const ctx = createContext<number>('counter');
166
+ const callback = vi.fn();
167
+ const [count, setCount] = createSignal(0);
168
+
169
+ const { container } = render(() => (
170
+ <ContextProtocolProvider context={ctx} value={count}>
171
+ <div data-testid='child'>Child</div>
172
+ </ContextProtocolProvider>
173
+ ));
174
+
175
+ const child = container.querySelector('[data-testid="child"]')!;
176
+
177
+ let unsubscribeFn: (() => void) | undefined;
178
+ const wrappedCallback = (value: number, unsubscribe?: () => void) => {
179
+ callback(value, unsubscribe);
180
+ unsubscribeFn = unsubscribe;
181
+ };
182
+
183
+ const event = new ContextRequestEvent(ctx, wrappedCallback, { subscribe: true, target: child });
184
+ child.dispatchEvent(event);
185
+
186
+ expect(callback).toHaveBeenCalledTimes(1);
187
+
188
+ // Unsubscribe
189
+ unsubscribeFn!();
190
+
191
+ // Update the value
192
+ setCount(1);
193
+ await Promise.resolve();
194
+
195
+ // Should not have received update
196
+ expect(callback).toHaveBeenCalledTimes(1);
197
+ });
198
+
199
+ test('nested providers - inner provider handles matching context', () => {
200
+ const ctx = createContext<string>('test');
201
+ const callback = vi.fn();
202
+
203
+ const { container } = render(() => (
204
+ <ContextProtocolProvider context={ctx} value='outer'>
205
+ <ContextProtocolProvider context={ctx} value='inner'>
206
+ <div data-testid='child'>Child</div>
207
+ </ContextProtocolProvider>
208
+ </ContextProtocolProvider>
209
+ ));
210
+
211
+ const child = container.querySelector('[data-testid="child"]')!;
212
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
213
+ child.dispatchEvent(event);
214
+
215
+ expect(callback).toHaveBeenCalledWith('inner');
216
+ expect(callback).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ test('multiple contexts can be provided simultaneously', () => {
220
+ const themeCtx = createContext<string>('theme');
221
+ const userCtx = createContext<{ name: string }>('user');
222
+ const themeCallback = vi.fn();
223
+ const userCallback = vi.fn();
224
+
225
+ const { container } = render(() => (
226
+ <ContextProtocolProvider context={themeCtx} value='dark'>
227
+ <ContextProtocolProvider context={userCtx} value={{ name: 'Alice' }}>
228
+ <div data-testid='child'>Child</div>
229
+ </ContextProtocolProvider>
230
+ </ContextProtocolProvider>
231
+ ));
232
+
233
+ const child = container.querySelector('[data-testid="child"]')!;
234
+
235
+ child.dispatchEvent(new ContextRequestEvent(themeCtx, themeCallback, { target: child }));
236
+ child.dispatchEvent(new ContextRequestEvent(userCtx, userCallback, { target: child }));
237
+
238
+ expect(themeCallback).toHaveBeenCalledWith('dark');
239
+ expect(userCallback).toHaveBeenCalledWith({ name: 'Alice' });
240
+ });
241
+
242
+ test('wrapper div uses display: contents', () => {
243
+ const ctx = createContext<string>('test');
244
+
245
+ const { container } = render(() => (
246
+ <ContextProtocolProvider context={ctx} value='value'>
247
+ <span>Child</span>
248
+ </ContextProtocolProvider>
249
+ ));
250
+
251
+ const wrapperDiv = container.querySelector('div');
252
+ expect(wrapperDiv).toHaveStyle({ display: 'contents' });
253
+ });
254
+ });
@@ -0,0 +1,272 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import {
6
+ type Accessor,
7
+ type JSX,
8
+ createEffect,
9
+ createContext as createSolidContext,
10
+ onCleanup,
11
+ onMount,
12
+ useContext,
13
+ } from 'solid-js';
14
+
15
+ import {
16
+ CONTEXT_PROVIDER_EVENT,
17
+ CONTEXT_REQUEST_EVENT,
18
+ type ContextCallback,
19
+ ContextProviderEvent,
20
+ ContextRequestEvent,
21
+ type ContextType,
22
+ type UnknownContext,
23
+ } from '@dxos/web-context';
24
+
25
+ /**
26
+ * Handler function type for context requests passed via SolidJS context
27
+ */
28
+ type ContextRequestHandler = (event: ContextRequestEvent<UnknownContext>) => boolean;
29
+
30
+ /**
31
+ * Internal SolidJS context for passing context request handlers down the tree.
32
+ * This allows useWebComponentContext to work synchronously in SolidJS.
33
+ */
34
+ const ContextRequestHandlerContext = createSolidContext<ContextRequestHandler | undefined>();
35
+
36
+ /**
37
+ * Try to handle a context request using the SolidJS context chain.
38
+ * Returns true if handled, false otherwise.
39
+ * Used internally by useWebComponentContext.
40
+ */
41
+ export function tryHandleContextRequest(event: ContextRequestEvent<UnknownContext>): boolean {
42
+ const handler = useContext(ContextRequestHandlerContext);
43
+ if (handler) {
44
+ return handler(event);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Get the context request handler from SolidJS context.
51
+ * Used internally by useWebComponentContext.
52
+ */
53
+ export function getContextRequestHandler(): ContextRequestHandler | undefined {
54
+ return useContext(ContextRequestHandlerContext);
55
+ }
56
+
57
+ /**
58
+ * Props for the ContextProtocolProvider component
59
+ */
60
+ export interface ContextProtocolProviderProps<T extends UnknownContext> {
61
+ /** The context key to provide */
62
+ context: T;
63
+ /** The value to provide - can be a static value or an accessor for reactive updates */
64
+ value: ContextType<T> | Accessor<ContextType<T>>;
65
+ /** Child elements */
66
+ children: JSX.Element;
67
+ }
68
+
69
+ /**
70
+ * A provider component that:
71
+ * 1. Handles context-request events from web components (via DOM events)
72
+ * 2. Handles context requests from SolidJS consumers (via SolidJS context)
73
+ * 3. Supports subscriptions for reactive updates
74
+ * 4. Uses WeakRef for subscriptions to prevent memory leaks
75
+ */
76
+ export function ContextProtocolProvider<T extends UnknownContext>(props: ContextProtocolProviderProps<T>): JSX.Element {
77
+ // Get parent handler if one exists (for nested providers)
78
+ const parentHandler = useContext(ContextRequestHandlerContext);
79
+
80
+ // Track subscriptions with their stable unsubscribe functions and consumer host elements
81
+ // We use WeakMap to hold callbacks weakly. This ensures that if a consumer
82
+ // drops the callback, we don't leak memory.
83
+ //
84
+ // NOTE: This means consumers MUST retain the callback reference as long as they
85
+ // want to receive updates (e.g. implicitly via closure in a retained component).
86
+ interface SubscriptionInfo {
87
+ unsubscribe: () => void;
88
+ consumerHost: Element;
89
+ // Store ref to allow cleaning up the Set when unsubscribing
90
+ ref: WeakRef<ContextCallback<ContextType<T>>>;
91
+ }
92
+ const subscriptions = new WeakMap<ContextCallback<ContextType<T>>, SubscriptionInfo>();
93
+ const subscriptionRefs = new Set<WeakRef<ContextCallback<ContextType<T>>>>();
94
+
95
+ // Helper to get current value (handles both static and accessor)
96
+ const getValue = (): ContextType<T> => {
97
+ const v = props.value;
98
+ return typeof v === 'function' ? (v as Accessor<ContextType<T>>)() : v;
99
+ };
100
+
101
+ // Core handler logic - used by both DOM events and SolidJS context
102
+ const handleRequest = (event: ContextRequestEvent<UnknownContext>): boolean => {
103
+ // Check if this provider handles this context (strict equality per spec)
104
+ if (event.context !== props.context) {
105
+ // Pass to parent handler if we don't handle this context
106
+ if (parentHandler) {
107
+ return parentHandler(event);
108
+ }
109
+ return false;
110
+ }
111
+
112
+ const currentValue = getValue();
113
+
114
+ if (event.subscribe) {
115
+ // Store the callback for future updates
116
+ const callback = event.callback as ContextCallback<ContextType<T>>;
117
+
118
+ // Get the consumer host element from the event
119
+ // Fallback to composedPath()[0] if contextTarget is missing (standard compliance)
120
+ const consumerHost = event.contextTarget || (event.composedPath()[0] as Element);
121
+
122
+ // Create a stable unsubscribe function for this callback
123
+ // IMPORTANT: We must pass the SAME unsubscribe function each time we call the callback
124
+ // Lit's ContextConsumer compares unsubscribe functions and calls the old one if different
125
+ const unsubscribe = () => {
126
+ const info = subscriptions.get(callback);
127
+ if (info) {
128
+ subscriptionRefs.delete(info.ref);
129
+ subscriptions.delete(callback);
130
+ }
131
+ };
132
+
133
+ const ref = new WeakRef(callback);
134
+ subscriptions.set(callback, { unsubscribe, consumerHost, ref });
135
+ subscriptionRefs.add(ref);
136
+
137
+ // Invoke callback with current value and unsubscribe function
138
+ event.callback(currentValue, unsubscribe);
139
+ } else {
140
+ // One-time request - just provide the value
141
+ event.callback(currentValue);
142
+ }
143
+
144
+ return true;
145
+ };
146
+
147
+ // Handle DOM context-request events (for web components)
148
+ const handleContextRequestEvent = (e: Event) => {
149
+ const event = e as ContextRequestEvent<UnknownContext>;
150
+ if (handleRequest(event)) {
151
+ // Stop propagation per spec recommendation
152
+ event.stopImmediatePropagation();
153
+ }
154
+ };
155
+
156
+ // Handle context-provider events from child providers
157
+ // When a new provider appears below us, we re-dispatch our subscriptions
158
+ // so consumers can re-parent to the closer provider
159
+ const handleContextProviderEvent = (e: Event) => {
160
+ const event = e as ContextProviderEvent<UnknownContext>;
161
+
162
+ // Only handle events for our context
163
+ if (event.context !== props.context) {
164
+ return;
165
+ }
166
+
167
+ // Don't handle our own event
168
+ if (containerRef && event.contextTarget === containerRef) {
169
+ return;
170
+ }
171
+
172
+ // Re-dispatch context requests from our subscribers
173
+ // They may now have a closer provider
174
+ // Iterate over weak refs to re-dispatch
175
+ // We use a separate Set of WeakRefs because WeakMap is not iterable.
176
+ // This allows us to re-parent subscriptions when a new provider appears.
177
+ const seen = new Set<ContextCallback<ContextType<T>>>();
178
+ for (const ref of subscriptionRefs) {
179
+ const callback = ref.deref();
180
+ if (!callback) {
181
+ subscriptionRefs.delete(ref);
182
+ continue;
183
+ }
184
+
185
+ const info = subscriptions.get(callback);
186
+ if (!info) continue;
187
+
188
+ const { consumerHost } = info;
189
+
190
+ // Prevent infinite loops with duplicate callbacks
191
+ if (seen.has(callback)) {
192
+ continue;
193
+ }
194
+ seen.add(callback);
195
+
196
+ // Re-dispatch the context request from the consumer
197
+ // We explicitly pass the original consumerHost as the target to preserve the causal chain
198
+ consumerHost.dispatchEvent(
199
+ new ContextRequestEvent(props.context, callback, { subscribe: true, target: consumerHost }),
200
+ );
201
+ }
202
+
203
+ // Stop propagation - we've handled the re-parenting
204
+ event.stopPropagation();
205
+ };
206
+
207
+ // Set up effect to notify subscribers when value changes
208
+ let isFirstRun = true;
209
+ createEffect(() => {
210
+ // IMPORTANT: We must call the accessor DIRECTLY inside the effect to establish tracking
211
+ // Reading props.value gives us the accessor, then we must call it to track the signal
212
+ const v = props.value;
213
+ const newValue = typeof v === 'function' ? (v as Accessor<ContextType<T>>)() : v;
214
+
215
+ // Skip first run - only notify on changes after initial subscription
216
+ if (isFirstRun) {
217
+ isFirstRun = false;
218
+ return;
219
+ }
220
+
221
+ // Notify all subscribers with their stable unsubscribe functions
222
+ for (const ref of subscriptionRefs) {
223
+ const callback = ref.deref();
224
+ if (!callback) {
225
+ subscriptionRefs.delete(ref);
226
+ continue;
227
+ }
228
+
229
+ const info = subscriptions.get(callback);
230
+ if (info) {
231
+ callback(newValue, info.unsubscribe);
232
+ }
233
+ }
234
+ });
235
+
236
+ // Reference to container element for event listener
237
+ let containerRef: HTMLDivElement | undefined;
238
+
239
+ // Set up event listeners when element is created
240
+ const setupListeners = (el: HTMLDivElement) => {
241
+ containerRef = el;
242
+ el.addEventListener(CONTEXT_REQUEST_EVENT, handleContextRequestEvent);
243
+ el.addEventListener(CONTEXT_PROVIDER_EVENT, handleContextProviderEvent);
244
+ };
245
+
246
+ // Announce this provider when mounted
247
+ // This allows ContextRoot implementations to replay pending requests
248
+ // and allows parent providers to re-parent their subscriptions
249
+ onMount(() => {
250
+ if (containerRef) {
251
+ containerRef.dispatchEvent(new ContextProviderEvent(props.context, containerRef));
252
+ }
253
+ });
254
+
255
+ // Cleanup on unmount
256
+ onCleanup(() => {
257
+ if (containerRef) {
258
+ containerRef.removeEventListener(CONTEXT_REQUEST_EVENT, handleContextRequestEvent);
259
+ containerRef.removeEventListener(CONTEXT_PROVIDER_EVENT, handleContextProviderEvent);
260
+ }
261
+ // WeakMap clears itself, but we should clear the Set of refs
262
+ subscriptionRefs.clear();
263
+ });
264
+
265
+ return (
266
+ <ContextRequestHandlerContext.Provider value={handleRequest}>
267
+ <div ref={setupListeners} style={{ display: 'contents' }}>
268
+ {props.children}
269
+ </div>
270
+ </ContextRequestHandlerContext.Provider>
271
+ );
272
+ }
@@ -0,0 +1,84 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type { ComponentType } from 'solid-element';
6
+ import type { JSX } from 'solid-js';
7
+
8
+ import { HostElementContext } from './internal';
9
+
10
+ /**
11
+ * Integration utilities for using Web Component Context Protocol with solid-element.
12
+ *
13
+ * This module provides utilities to integrate our context protocol implementation
14
+ * with the solid-element library for creating web components.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * import { customElement } from 'solid-element';
19
+ * import { withContextProvider } from './context/solid-element';
20
+ *
21
+ * customElement('my-button', {}, withContextProvider((props, { element }) => {
22
+ * const theme = useWebComponentContext(themeContext, { subscribe: true });
23
+ * return <button style={{ background: theme()?.primary }}>Click me</button>;
24
+ * }));
25
+ * ```
26
+ */
27
+
28
+ /**
29
+ * Options passed by solid-element to component functions.
30
+ * The element extends HTMLElement with additional custom element methods.
31
+ */
32
+ export interface SolidElementOptions {
33
+ element: HTMLElement & {
34
+ renderRoot: Element | Document | ShadowRoot | DocumentFragment;
35
+ addReleaseCallback(fn: () => void): void;
36
+ addPropertyChangedCallback(fn: (name: string, value: unknown) => void): void;
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Function component type for solid-element.
42
+ * Receives props and an options object containing the host element.
43
+ */
44
+ export type SolidElementComponent<P extends object = object> = (props: P, options: SolidElementOptions) => JSX.Element;
45
+
46
+ /**
47
+ * Wraps a solid-element component to provide the host element context.
48
+ * This enables useWebComponentContext to dispatch events from the custom element
49
+ * rather than document.body.
50
+ *
51
+ * @param component - The solid-element component function
52
+ * @returns A wrapped component that provides HostElementContext
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * import { customElement } from 'solid-element';
57
+ * import { withContextProvider, useWebComponentContext } from './context';
58
+ *
59
+ * customElement('themed-button', {}, withContextProvider((props, { element }) => {
60
+ * const theme = useWebComponentContext(themeContext, { subscribe: true });
61
+ * return (
62
+ * <button style={{ background: theme()?.primary }}>
63
+ * Themed Button
64
+ * </button>
65
+ * );
66
+ * }));
67
+ * ```
68
+ */
69
+ export function withContextProvider<P extends object>(component: SolidElementComponent<P>): ComponentType<P> {
70
+ // Return a new component function that wraps the original with HostElementContext
71
+ // This enables useWebComponentContext to dispatch events from the custom element
72
+ const wrappedComponent = (props: P, options: SolidElementOptions) => {
73
+ // Create a child component that renders within the context
74
+ const WrappedContent = () => component(props, options);
75
+
76
+ return (
77
+ <HostElementContext.Provider value={options.element}>
78
+ <WrappedContent />
79
+ </HostElementContext.Provider>
80
+ );
81
+ };
82
+
83
+ return wrappedComponent as unknown as ComponentType<P>;
84
+ }