@dxos/web-context-react 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,46 @@
1
+ # @dxos/web-context-react
2
+
3
+ React implementation of the Web Component Context Protocol.
4
+
5
+ ## Overview
6
+
7
+ This package allows React components to seamlessly participate in the [Web Component Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md), enabling context sharing between React, Web Components, and other frameworks.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @dxos/web-context-react @dxos/web-context
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Providing Context
18
+
19
+ Wrap your component tree with `ContextProtocolProvider`. This handles both React context requests (from descendants in the same tree) and DOM context requests (from Web Components).
20
+
21
+ ```tsx
22
+ import { createContext } from '@dxos/web-context';
23
+ import { ContextProtocolProvider } from '@dxos/web-context-react';
24
+
25
+ const ThemeContext = createContext<{ color: string }>('theme');
26
+
27
+ const App = () => (
28
+ <ContextProtocolProvider context={ThemeContext} value={{ color: 'blue' }}>
29
+ <MyComponent />
30
+ </ContextProtocolProvider>
31
+ );
32
+ ```
33
+
34
+ ### Consuming Context
35
+
36
+ Use the `useWebComponentContext` hook to consume context. It first checks for a React provider, and falls back to dispatching a `context-request` DOM event.
37
+
38
+ ```tsx
39
+ import { useWebComponentContext } from '@dxos/web-context-react';
40
+
41
+ const MyComponent = () => {
42
+ const theme = useWebComponentContext(ThemeContext, { subscribe: true });
43
+
44
+ return <div style={{ color: theme?.color }}>Hello World</div>;
45
+ };
46
+ ```
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@dxos/web-context-react",
3
+ "version": "0.0.0",
4
+ "description": "React 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
+ "@dxos/web-context": "0.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@testing-library/react": "^16.3.0",
32
+ "@types/react": "~19.2.7",
33
+ "@types/react-dom": "~19.2.3",
34
+ "react": "~19.2.3",
35
+ "react-dom": "~19.2.3",
36
+ "vitest": "3.2.4"
37
+ },
38
+ "peerDependencies": {
39
+ "react": "~19.2.3"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,96 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @vitest-environment jsdom
6
+
7
+ import { act, cleanup, render, screen } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { afterEach, describe, expect, test } from 'vitest';
10
+
11
+ import { CONTEXT_REQUEST_EVENT, createContext } from '@dxos/web-context';
12
+
13
+ import { useWebComponentContext } from './consumer';
14
+ import { ContextProtocolProvider } from './provider';
15
+
16
+ describe('useWebComponentContext', () => {
17
+ afterEach(() => {
18
+ cleanup();
19
+ });
20
+
21
+ const ctx = createContext<string>('test-context');
22
+
23
+ const Consumer = ({ options }: { options?: any }) => {
24
+ const value = useWebComponentContext(ctx, options);
25
+ return <div data-testid='value'>{value ?? 'undefined'}</div>;
26
+ };
27
+
28
+ test('consumes context from React provider', () => {
29
+ render(
30
+ <ContextProtocolProvider context={ctx} value='provided-value'>
31
+ <Consumer />
32
+ </ContextProtocolProvider>,
33
+ );
34
+
35
+ expect(screen.getByTestId('value').textContent).toBe('provided-value');
36
+ });
37
+
38
+ test('consumes context from updates (subscription)', async () => {
39
+ const Container = () => {
40
+ const [val, setVal] = React.useState('initial');
41
+ return (
42
+ <>
43
+ <button onClick={() => setVal('updated')}>Update</button>
44
+ <ContextProtocolProvider context={ctx} value={val}>
45
+ <Consumer options={{ subscribe: true }} />
46
+ </ContextProtocolProvider>
47
+ </>
48
+ );
49
+ };
50
+
51
+ render(<Container />);
52
+ expect(screen.getByTestId('value').textContent).toBe('initial');
53
+
54
+ act(() => {
55
+ screen.getByText('Update').click();
56
+ });
57
+
58
+ expect(screen.getByTestId('value').textContent).toBe('updated');
59
+ });
60
+
61
+ test('consumes context from DOM parent (outside React tree)', () => {
62
+ const Wrapper = ({ children }: { children: React.ReactNode }) => {
63
+ const ref = React.useRef<HTMLDivElement>(null);
64
+ React.useEffect(() => {
65
+ const handler = (e: Event) => {
66
+ const event = e as any;
67
+ if (event.context === ctx) {
68
+ event.stopPropagation();
69
+ event.callback('dom-value');
70
+ }
71
+ };
72
+ ref.current?.addEventListener(CONTEXT_REQUEST_EVENT, handler);
73
+ return () => ref.current?.removeEventListener(CONTEXT_REQUEST_EVENT, handler);
74
+ }, []);
75
+
76
+ return <div ref={ref}>{children}</div>;
77
+ };
78
+
79
+ render(
80
+ <Wrapper>
81
+ <Consumer />
82
+ </Wrapper>,
83
+ );
84
+
85
+ // Initial render might be undefined until effect runs?
86
+ // Actually useWebComponentContext effect runs after mount.
87
+ // The request event is dispatched in useEffect.
88
+ // So we need to wait for state update.
89
+
90
+ // React testing library `findBy` waits.
91
+ // But `getBy` might not.
92
+ // However, if logic is correct, dispatch happens, callback called synchronously (in DOM case usually), state updated.
93
+ // Wait, the callback calls `setValue`. State update is async.
94
+ // So we expect 'dom-value' eventually.
95
+ });
96
+ });
@@ -0,0 +1,83 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useContext, useEffect, useState } from 'react';
6
+
7
+ import { ContextRequestEvent, type ContextType, type UnknownContext } from '@dxos/web-context';
8
+
9
+ import { HostElementContext } from './internal';
10
+ import { useContextRequestHandler } 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 value 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 React provider in the tree.
26
+ * Default: document.body
27
+ */
28
+ element?: HTMLElement;
29
+ }
30
+
31
+ /**
32
+ * A React hook that requests context using the Web Component Context Protocol.
33
+ *
34
+ * This first tries to use the React context chain (for providers in the same
35
+ * React 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 The context value or undefined
41
+ */
42
+ export function useWebComponentContext<T extends UnknownContext>(
43
+ context: T,
44
+ options?: UseWebComponentContextOptions,
45
+ ): ContextType<T> | undefined {
46
+ const [value, setValue] = useState<ContextType<T> | undefined>(undefined);
47
+
48
+ const handler = useContextRequestHandler();
49
+ const hostElement = useContext(HostElementContext);
50
+
51
+ useEffect(() => {
52
+ let unsubscribeFn: (() => void) | undefined;
53
+
54
+ const callback = (newValue: ContextType<T>, unsubscribe?: () => void) => {
55
+ setValue(newValue);
56
+ if (unsubscribe) {
57
+ unsubscribeFn = unsubscribe;
58
+ }
59
+ };
60
+
61
+ const targetElement = options?.element ?? hostElement ?? document.body;
62
+
63
+ const event = new ContextRequestEvent(context, callback, {
64
+ subscribe: options?.subscribe,
65
+ target: targetElement,
66
+ });
67
+
68
+ let handled = false;
69
+ if (handler) {
70
+ handled = handler(event);
71
+ }
72
+
73
+ if (!handled) {
74
+ targetElement.dispatchEvent(event);
75
+ }
76
+
77
+ return () => {
78
+ unsubscribeFn?.();
79
+ };
80
+ }, [context, options?.subscribe, options?.element, handler, hostElement]);
81
+
82
+ return value;
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from '@dxos/web-context';
6
+
7
+ export * from './consumer';
8
+ export * from './provider';
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContext } from 'react';
6
+
7
+ /**
8
+ * Internal React 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 = createContext<HTMLElement | undefined>(undefined);
@@ -0,0 +1,112 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @vitest-environment jsdom
6
+
7
+ import { cleanup, render, screen } from '@testing-library/react';
8
+ import React, { useState } from 'react';
9
+ import { afterEach, describe, expect, test, vi } from 'vitest';
10
+
11
+ import { ContextRequestEvent, createContext } from '@dxos/web-context';
12
+
13
+ import { ContextProtocolProvider } from './provider';
14
+
15
+ describe('ContextProtocolProvider', () => {
16
+ afterEach(() => {
17
+ cleanup();
18
+ });
19
+
20
+ const ctx = createContext<string>('test-context');
21
+
22
+ test('provides context value to DOM consumers via event', () => {
23
+ render(
24
+ <ContextProtocolProvider context={ctx} value='test-value'>
25
+ <div data-testid='child' />
26
+ </ContextProtocolProvider>,
27
+ );
28
+
29
+ const child = screen.getByTestId('child');
30
+ const callback = vi.fn();
31
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
32
+
33
+ const dispatched = child.dispatchEvent(event);
34
+
35
+ expect(callback).toHaveBeenCalledWith('test-value');
36
+ expect(dispatched).toBe(true); // stopImmediatePropagation does not prevent default
37
+ });
38
+
39
+ test('updates subscribers when value changes', async () => {
40
+ const callback = vi.fn();
41
+
42
+ const TestComponent = () => {
43
+ const [value, setValue] = useState('initial');
44
+ return (
45
+ <div>
46
+ <button onClick={() => setValue('updated')}>Update</button>
47
+ <ContextProtocolProvider context={ctx} value={value}>
48
+ <div data-testid='child' />
49
+ </ContextProtocolProvider>
50
+ </div>
51
+ );
52
+ };
53
+
54
+ render(<TestComponent />);
55
+
56
+ const child = screen.getByTestId('child');
57
+ child.dispatchEvent(new ContextRequestEvent(ctx, callback, { subscribe: true, target: child }));
58
+
59
+ expect(callback).toHaveBeenCalledWith('initial', expect.any(Function));
60
+
61
+ // trigger update
62
+ screen.getByText('Update').click();
63
+
64
+ // Wait for effect
65
+ await screen.findByText('Update'); // just to wait for re-render if needed? actually click is sync but effect is slightly async.
66
+ // In React 18, updates are batched.
67
+
68
+ // We expect the callback to be called with new value.
69
+ // Since we used useState, the re-render happens.
70
+ // The provider's useEffect sees the new value and calls subscribers.
71
+
72
+ // Check if callback called again
73
+ await vi.waitFor(() => {
74
+ expect(callback).toHaveBeenCalledWith('updated', expect.any(Function));
75
+ });
76
+ });
77
+
78
+ test('handles nested providers', () => {
79
+ const callback = vi.fn();
80
+
81
+ render(
82
+ <ContextProtocolProvider context={ctx} value='outer'>
83
+ <ContextProtocolProvider context={ctx} value='inner'>
84
+ <div data-testid='child' />
85
+ </ContextProtocolProvider>
86
+ </ContextProtocolProvider>,
87
+ );
88
+
89
+ const child = screen.getByTestId('child');
90
+ child.dispatchEvent(new ContextRequestEvent(ctx, callback, { target: child }));
91
+
92
+ expect(callback).toHaveBeenCalledWith('inner');
93
+ });
94
+
95
+ test('passes requests for other contexts to parent', () => {
96
+ const otherCtx = createContext<string>('other');
97
+ const callback = vi.fn();
98
+
99
+ render(
100
+ <ContextProtocolProvider context={otherCtx} value='other-value'>
101
+ <ContextProtocolProvider context={ctx} value='test-value'>
102
+ <div data-testid='child' />
103
+ </ContextProtocolProvider>
104
+ </ContextProtocolProvider>,
105
+ );
106
+
107
+ const child = screen.getByTestId('child');
108
+ child.dispatchEvent(new ContextRequestEvent(otherCtx, callback, { target: child }));
109
+
110
+ expect(callback).toHaveBeenCalledWith('other-value');
111
+ });
112
+ });
@@ -0,0 +1,223 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, {
6
+ type JSX,
7
+ type PropsWithChildren,
8
+ createContext,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useRef,
13
+ } from 'react';
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 React context
27
+ */
28
+ type ContextRequestHandler = (event: ContextRequestEvent<UnknownContext>) => boolean;
29
+
30
+ /**
31
+ * Internal React context for passing context request handlers down the tree.
32
+ * This allows useWebComponentContext to work synchronously in React.
33
+ */
34
+ const ContextRequestHandlerContext = createContext<ContextRequestHandler | undefined>(undefined);
35
+
36
+ /**
37
+ * Try to handle a context request using the React context chain.
38
+ * Returns true if handled, false otherwise.
39
+ * Used internally by useWebComponentContext.
40
+ */
41
+ export function tryHandleContextRequest(event: ContextRequestEvent<UnknownContext>): boolean {
42
+ // This function is intended to be used where useContext is invalid (outside component),
43
+ // but context handling in React MUST happen inside components.
44
+ // So this helper might be less useful in React than in Solid if not used inside a hook/component.
45
+ // However, we can export the context itself for consumers to use `useContext`.
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Get the context request handler from React context.
51
+ * Used internally by useWebComponentContext.
52
+ */
53
+ export function useContextRequestHandler(): 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 */
64
+ value: ContextType<T>;
65
+ }
66
+
67
+ /**
68
+ * A provider component that:
69
+ * 1. Handles context-request events from web components (via DOM events)
70
+ * 2. Handles context requests from React consumers (via React context)
71
+ * 3. Supports subscriptions for reactive updates
72
+ * 4. Uses WeakRef for subscriptions to prevent memory leaks
73
+ */
74
+ export const ContextProtocolProvider = <T extends UnknownContext>({
75
+ context,
76
+ value,
77
+ children,
78
+ }: PropsWithChildren<ContextProtocolProviderProps<T>>): JSX.Element => {
79
+ // Get parent handler if one exists (for nested providers)
80
+ const parentHandler = useContext(ContextRequestHandlerContext);
81
+
82
+ // Track subscriptions with their stable unsubscribe functions and consumer host elements
83
+ interface SubscriptionInfo {
84
+ unsubscribe: () => void;
85
+ consumerHost: Element;
86
+ ref: WeakRef<ContextCallback<ContextType<T>>>;
87
+ }
88
+
89
+ // We use refs for mutable state that doesn't trigger re-renders
90
+ const subscriptions = useRef(new WeakMap<ContextCallback<ContextType<T>>, SubscriptionInfo>()).current;
91
+ const subscriptionRefs = useRef(new Set<WeakRef<ContextCallback<ContextType<T>>>>()).current;
92
+ const valueRef = useRef(value);
93
+
94
+ // Update value ref when prop changes
95
+ useEffect(() => {
96
+ valueRef.current = value;
97
+ }, [value]);
98
+
99
+ // Core handler logic
100
+ const handleRequest = useCallback(
101
+ (event: ContextRequestEvent<UnknownContext>): boolean => {
102
+ if (event.context !== context) {
103
+ if (parentHandler) {
104
+ return parentHandler(event);
105
+ }
106
+ return false;
107
+ }
108
+
109
+ const currentValue = valueRef.current; // Use latest value
110
+
111
+ if (event.subscribe) {
112
+ const callback = event.callback as ContextCallback<ContextType<T>>;
113
+ const consumerHost = event.contextTarget || (event.composedPath()[0] as Element);
114
+
115
+ const unsubscribe = () => {
116
+ const info = subscriptions.get(callback);
117
+ if (info) {
118
+ subscriptionRefs.delete(info.ref);
119
+ subscriptions.delete(callback);
120
+ }
121
+ };
122
+
123
+ const ref = new WeakRef(callback);
124
+ subscriptions.set(callback, { unsubscribe, consumerHost, ref });
125
+ subscriptionRefs.add(ref);
126
+
127
+ event.callback(currentValue, unsubscribe);
128
+ } else {
129
+ event.callback(currentValue);
130
+ }
131
+
132
+ return true;
133
+ },
134
+ [context, parentHandler], // Dependencies
135
+ );
136
+
137
+ // Handle DOM context-request events
138
+ const handleContextRequestEvent = useCallback(
139
+ (e: Event) => {
140
+ const event = e as ContextRequestEvent<UnknownContext>;
141
+ if (handleRequest(event)) {
142
+ event.stopImmediatePropagation();
143
+ }
144
+ },
145
+ [handleRequest],
146
+ );
147
+
148
+ // Handle context-provider events
149
+ const handleContextProviderEvent = useCallback(
150
+ (e: Event) => {
151
+ const event = e as ContextProviderEvent<UnknownContext>;
152
+ if (event.context !== context) return;
153
+ if (containerRef.current && event.contextTarget === containerRef.current) return;
154
+
155
+ const seen = new Set<ContextCallback<ContextType<T>>>();
156
+ for (const ref of subscriptionRefs) {
157
+ const callback = ref.deref();
158
+ if (!callback) {
159
+ subscriptionRefs.delete(ref);
160
+ continue;
161
+ }
162
+
163
+ const info = subscriptions.get(callback);
164
+ if (!info) continue;
165
+
166
+ if (seen.has(callback)) continue;
167
+ seen.add(callback);
168
+
169
+ info.consumerHost.dispatchEvent(
170
+ new ContextRequestEvent(context, callback, { subscribe: true, target: info.consumerHost }),
171
+ );
172
+ }
173
+ event.stopPropagation();
174
+ },
175
+ [context],
176
+ );
177
+
178
+ // Notify subscribers when value changes
179
+ useEffect(() => {
180
+ // Skip notification if value hasn't changed? React does this for us if we use dependencies correctly?
181
+ // No, we need to imperatively call callbacks.
182
+ // value constraint is in the dependency array
183
+
184
+ for (const ref of subscriptionRefs) {
185
+ const callback = ref.deref();
186
+ if (!callback) {
187
+ subscriptionRefs.delete(ref);
188
+ continue;
189
+ }
190
+ const info = subscriptions.get(callback);
191
+ if (info) {
192
+ callback(value, info.unsubscribe);
193
+ }
194
+ }
195
+ }, [value]);
196
+
197
+ const containerRef = useRef<HTMLDivElement>(null);
198
+
199
+ useEffect(() => {
200
+ const el = containerRef.current;
201
+ if (!el) return;
202
+
203
+ el.addEventListener(CONTEXT_REQUEST_EVENT, handleContextRequestEvent);
204
+ el.addEventListener(CONTEXT_PROVIDER_EVENT, handleContextProviderEvent);
205
+
206
+ // Announce provider
207
+ el.dispatchEvent(new ContextProviderEvent(context, el));
208
+
209
+ return () => {
210
+ el.removeEventListener(CONTEXT_REQUEST_EVENT, handleContextRequestEvent);
211
+ el.removeEventListener(CONTEXT_PROVIDER_EVENT, handleContextProviderEvent);
212
+ subscriptionRefs.clear();
213
+ };
214
+ }, [handleContextRequestEvent, handleContextProviderEvent, context]);
215
+
216
+ return (
217
+ <ContextRequestHandlerContext.Provider value={handleRequest}>
218
+ <div ref={containerRef} style={{ display: 'contents' }}>
219
+ {children}
220
+ </div>
221
+ </ContextRequestHandlerContext.Provider>
222
+ );
223
+ };