@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 +8 -0
- package/README.md +46 -0
- package/package.json +44 -0
- package/src/consumer.test.tsx +96 -0
- package/src/consumer.ts +83 -0
- package/src/index.ts +8 -0
- package/src/internal.ts +11 -0
- package/src/provider.test.tsx +112 -0
- package/src/provider.tsx +223 -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,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
|
+
});
|
package/src/consumer.ts
ADDED
|
@@ -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
package/src/internal.ts
ADDED
|
@@ -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
|
+
});
|
package/src/provider.tsx
ADDED
|
@@ -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
|
+
};
|