@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 +8 -0
- package/README.md +42 -0
- package/package.json +42 -0
- package/src/consumer.test.tsx +255 -0
- package/src/consumer.ts +102 -0
- package/src/index.ts +9 -0
- package/src/internal.ts +19 -0
- package/src/provider.test.tsx +254 -0
- package/src/provider.tsx +272 -0
- package/src/solid-element.tsx +84 -0
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
|
+
});
|
package/src/consumer.ts
ADDED
|
@@ -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
package/src/internal.ts
ADDED
|
@@ -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
|
+
});
|
package/src/provider.tsx
ADDED
|
@@ -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
|
+
}
|