Skip to content

Commit b5eecc2

Browse files
authored
Allow for Context as JSX (#4618)
* Allow for Context as JSX * Fixes to types * Add ts test * Fix type * Move to compat * Implement in core * add test * Update mangle.json * Real fix * Revert id change * Revert
1 parent d16a34e commit b5eecc2

File tree

8 files changed

+200
-64
lines changed

8 files changed

+200
-64
lines changed

compat/test/browser/render.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,40 @@ describe('compat render', () => {
545545
expect(scratch.textContent).to.equal('foo');
546546
});
547547

548+
it('should allow context as a component', () => {
549+
const Context = createContext(null);
550+
const CONTEXT = { a: 'a' };
551+
552+
let receivedContext;
553+
554+
class Inner extends Component {
555+
render(props) {
556+
return <div>{props.a}</div>;
557+
}
558+
}
559+
560+
sinon.spy(Inner.prototype, 'render');
561+
562+
render(
563+
<Context value={CONTEXT}>
564+
<div>
565+
<Context.Consumer>
566+
{data => {
567+
receivedContext = data;
568+
return <Inner {...data} />;
569+
}}
570+
</Context.Consumer>
571+
</div>
572+
</Context>,
573+
scratch
574+
);
575+
576+
// initial render does not invoke anything but render():
577+
expect(Inner.prototype.render).to.have.been.calledWithMatch(CONTEXT);
578+
expect(receivedContext).to.equal(CONTEXT);
579+
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
580+
});
581+
548582
it("should support recoils's usage of __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED", () => {
549583
// Simplified version of: https://github.com/facebookexperimental/Recoil/blob/c1b97f3a0117cad76cbc6ab3cb06d89a9ce717af/packages/recoil/core/Recoil_ReactMode.js#L36-L44
550584
function useStateWrapper(init) {

compat/test/ts/index.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,26 @@ React.unmountComponentAtNode(document.body.shadowRoot!);
1515
React.createPortal(<div />, document.createElement('div'));
1616
React.createPortal(<div />, document.createDocumentFragment());
1717
React.createPortal(<div />, document.body.shadowRoot!);
18+
19+
const Ctx = React.createContext({ contextValue: '' });
20+
class SimpleComponentWithContextAsProvider extends React.Component {
21+
componentProp = 'componentProp';
22+
render() {
23+
// Render inside div to ensure standard JSX elements still work
24+
return (
25+
<Ctx value={{ contextValue: 'value' }}>
26+
<div>
27+
{/* Ensure context still works */}
28+
<Ctx.Consumer>
29+
{({ contextValue }) => contextValue.toLowerCase()}
30+
</Ctx.Consumer>
31+
</div>
32+
</Ctx>
33+
);
34+
}
35+
}
36+
37+
React.render(
38+
<SimpleComponentWithContextAsProvider />,
39+
document.createElement('div')
40+
);

hooks/test/browser/useContext.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ describe('useContext', () => {
206206
expect(values).to.deep.equal([13, 42, 69]);
207207
});
208208

209+
it('should only subscribe a component once (non-provider)', () => {
210+
const values = [];
211+
const Context = createContext(13);
212+
let provider, subSpy;
213+
214+
function Comp() {
215+
const value = useContext(Context);
216+
values.push(value);
217+
return null;
218+
}
219+
220+
render(<Comp />, scratch);
221+
222+
render(
223+
<Context ref={p => (provider = p)} value={42}>
224+
<Comp />
225+
</Context>,
226+
scratch
227+
);
228+
subSpy = sinon.spy(provider, 'sub');
229+
230+
render(
231+
<Context value={69}>
232+
<Comp />
233+
</Context>,
234+
scratch
235+
);
236+
expect(subSpy).to.not.have.been.called;
237+
238+
expect(values).to.deep.equal([13, 42, 69]);
239+
});
240+
209241
it('should maintain context', done => {
210242
const context = createContext(null);
211243
const { Provider } = context;

mangle.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@
6262
"$_globalContext": "__n",
6363
"$_context": "c",
6464
"$_defaultValue": "__",
65-
"$_id": "__c",
66-
"$_contextRef": "__",
65+
"$_id": "__l",
66+
"$_contextRef": "__c",
6767
"$_parentDom": "__P",
6868
"$_originalParentDom": "__O",
6969
"$_prevState": "__u",

src/create-context.js

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,58 @@ import { enqueueRender } from './component';
22

33
export let i = 0;
44

5-
export function createContext(defaultValue, contextId) {
6-
contextId = '__cC' + i++;
7-
8-
const context = {
9-
_id: contextId,
10-
_defaultValue: defaultValue,
11-
/** @type {import('./internal').FunctionComponent} */
12-
Consumer(props, contextValue) {
13-
// return props.children(
14-
// context[contextId] ? context[contextId].props.value : defaultValue
15-
// );
16-
return props.children(contextValue);
17-
},
18-
/** @type {import('./internal').FunctionComponent} */
19-
Provider(props) {
20-
if (!this.getChildContext) {
21-
/** @type {Set<import('./internal').Component> | null} */
22-
let subs = new Set();
23-
let ctx = {};
24-
ctx[contextId] = this;
25-
26-
this.getChildContext = () => ctx;
27-
28-
this.componentWillUnmount = () => {
29-
subs = null;
30-
};
31-
32-
this.shouldComponentUpdate = function (_props) {
33-
if (this.props.value !== _props.value) {
34-
subs.forEach(c => {
35-
c._force = true;
36-
enqueueRender(c);
37-
});
5+
export function createContext(defaultValue) {
6+
function Context(props) {
7+
if (!this.getChildContext) {
8+
/** @type {Set<import('./internal').Component> | null} */
9+
let subs = new Set();
10+
let ctx = {};
11+
ctx[Context._id] = this;
12+
13+
this.getChildContext = () => ctx;
14+
15+
this.componentWillUnmount = () => {
16+
subs = null;
17+
};
18+
19+
this.shouldComponentUpdate = function (_props) {
20+
// @ts-expect-error even
21+
if (this.props.value !== _props.value) {
22+
subs.forEach(c => {
23+
c._force = true;
24+
enqueueRender(c);
25+
});
26+
}
27+
};
28+
29+
this.sub = c => {
30+
subs.add(c);
31+
let old = c.componentWillUnmount;
32+
c.componentWillUnmount = () => {
33+
if (subs) {
34+
subs.delete(c);
3835
}
36+
if (old) old.call(c);
3937
};
38+
};
39+
}
4040

41-
this.sub = c => {
42-
subs.add(c);
43-
let old = c.componentWillUnmount;
44-
c.componentWillUnmount = () => {
45-
if (subs) {
46-
subs.delete(c);
47-
}
48-
if (old) old.call(c);
49-
};
50-
};
51-
}
41+
return props.children;
42+
}
5243

53-
return props.children;
54-
}
44+
Context._id = '__cC' + i++;
45+
Context._defaultValue = defaultValue;
46+
47+
/** @type {import('./internal').FunctionComponent} */
48+
Context.Consumer = (props, contextValue) => {
49+
return props.children(contextValue);
5550
};
5651

57-
// Devtools needs access to the context object when it
58-
// encounters a Provider. This is necessary to support
59-
// setting `displayName` on the context object instead
60-
// of on the component itself. See:
61-
// https://reactjs.org/docs/context.html#contextdisplayname
52+
// we could also get rid of _contextRef entirely
53+
Context.Provider =
54+
Context._contextRef =
55+
Context.Consumer.contextType =
56+
Context;
6257

63-
return (context.Provider._contextRef = context.Consumer.contextType =
64-
context);
58+
return Context;
6559
}

src/index.d.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -388,11 +388,12 @@ export type ContextType<C extends Context<any>> = C extends Context<infer T>
388388
? T
389389
: never;
390390

391-
export interface Context<T> {
392-
Consumer: Consumer<T>;
393-
Provider: Provider<T>;
391+
export interface Context<T> extends preact.Provider<T> {
392+
Consumer: preact.Consumer<T>;
393+
Provider: preact.Provider<T>;
394394
displayName?: string;
395395
}
396+
396397
export interface PreactContext<T> extends Context<T> {}
397398

398399
export function createContext<T>(defaultValue: T): Context<T>;

test/browser/createContext.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,40 @@ describe('createContext', () => {
5757
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
5858
});
5959

60+
it('should pass context to a consumer (non-provider)', () => {
61+
const Ctx = createContext(null);
62+
const CONTEXT = { a: 'a' };
63+
64+
let receivedContext;
65+
66+
class Inner extends Component {
67+
render(props) {
68+
return <div>{props.a}</div>;
69+
}
70+
}
71+
72+
sinon.spy(Inner.prototype, 'render');
73+
74+
render(
75+
<Ctx value={CONTEXT}>
76+
<div>
77+
<Ctx.Consumer>
78+
{data => {
79+
receivedContext = data;
80+
return <Inner {...data} />;
81+
}}
82+
</Ctx.Consumer>
83+
</div>
84+
</Ctx>,
85+
scratch
86+
);
87+
88+
// initial render does not invoke anything but render():
89+
expect(Inner.prototype.render).to.have.been.calledWithMatch(CONTEXT);
90+
expect(receivedContext).to.equal(CONTEXT);
91+
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
92+
});
93+
6094
// This optimization helps
6195
// to prevent a Provider from rerendering the children, this means
6296
// we only propagate to children.
@@ -152,7 +186,8 @@ describe('createContext', () => {
152186
it('should preserve provider context between different providers', () => {
153187
const { Provider: ThemeProvider, Consumer: ThemeConsumer } =
154188
createContext(null);
155-
const { Provider: DataProvider, Consumer: DataConsumer } = createContext(null);
189+
const { Provider: DataProvider, Consumer: DataConsumer } =
190+
createContext(null);
156191
const THEME_CONTEXT = { theme: 'black' };
157192
const DATA_CONTEXT = { global: 'a' };
158193

test/ts/custom-elements.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface WhateveElAttributes extends createElement.JSX.HTMLAttributes {
4141
}
4242

4343
// Ensure context still works
44-
const { Provider, Consumer } = createContext({ contextValue: '' });
44+
const Ctx = createContext({ contextValue: '' });
4545

4646
// Sample component that uses custom elements
4747

@@ -50,7 +50,7 @@ class SimpleComponent extends Component {
5050
render() {
5151
// Render inside div to ensure standard JSX elements still work
5252
return (
53-
<Provider value={{ contextValue: 'value' }}>
53+
<Ctx.Provider value={{ contextValue: 'value' }}>
5454
<div>
5555
<clickable-ce
5656
onClick={e => {
@@ -73,13 +73,30 @@ class SimpleComponent extends Component {
7373
></custom-whatever>
7474

7575
{/* Ensure context still works */}
76-
<Consumer>
76+
<Ctx.Consumer>
7777
{({ contextValue }) => contextValue.toLowerCase()}
78-
</Consumer>
78+
</Ctx.Consumer>
7979
</div>
80-
</Provider>
80+
</Ctx.Provider>
8181
);
8282
}
8383
}
8484

8585
const component = <SimpleComponent />;
86+
class SimpleComponentWithContextAsProvider extends Component {
87+
componentProp = 'componentProp';
88+
render() {
89+
// Render inside div to ensure standard JSX elements still work
90+
return (
91+
<Ctx value={{ contextValue: 'value' }}>
92+
<div>
93+
{/* Ensure context still works */}
94+
<Ctx.Consumer>
95+
{({ contextValue }) => contextValue.toLowerCase()}
96+
</Ctx.Consumer>
97+
</div>
98+
</Ctx>
99+
);
100+
}
101+
}
102+
const component2 = <SimpleComponentWithContextAsProvider />;

0 commit comments

Comments
 (0)