Skip to content

Commit

Permalink
Allow for Context as JSX (#4618)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
JoviDeCroock authored Jan 14, 2025
1 parent d16a34e commit b5eecc2
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 64 deletions.
34 changes: 34 additions & 0 deletions compat/test/browser/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,40 @@ describe('compat render', () => {
expect(scratch.textContent).to.equal('foo');
});

it('should allow context as a component', () => {
const Context = createContext(null);
const CONTEXT = { a: 'a' };

let receivedContext;

class Inner extends Component {
render(props) {
return <div>{props.a}</div>;
}
}

sinon.spy(Inner.prototype, 'render');

render(
<Context value={CONTEXT}>
<div>
<Context.Consumer>
{data => {
receivedContext = data;
return <Inner {...data} />;
}}
</Context.Consumer>
</div>
</Context>,
scratch
);

// initial render does not invoke anything but render():
expect(Inner.prototype.render).to.have.been.calledWithMatch(CONTEXT);
expect(receivedContext).to.equal(CONTEXT);
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
});

it("should support recoils's usage of __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED", () => {
// Simplified version of: https://github.com/facebookexperimental/Recoil/blob/c1b97f3a0117cad76cbc6ab3cb06d89a9ce717af/packages/recoil/core/Recoil_ReactMode.js#L36-L44
function useStateWrapper(init) {
Expand Down
23 changes: 23 additions & 0 deletions compat/test/ts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,26 @@ React.unmountComponentAtNode(document.body.shadowRoot!);
React.createPortal(<div />, document.createElement('div'));
React.createPortal(<div />, document.createDocumentFragment());
React.createPortal(<div />, document.body.shadowRoot!);

const Ctx = React.createContext({ contextValue: '' });
class SimpleComponentWithContextAsProvider extends React.Component {
componentProp = 'componentProp';
render() {
// Render inside div to ensure standard JSX elements still work
return (
<Ctx value={{ contextValue: 'value' }}>
<div>
{/* Ensure context still works */}
<Ctx.Consumer>
{({ contextValue }) => contextValue.toLowerCase()}
</Ctx.Consumer>
</div>
</Ctx>
);
}
}

React.render(
<SimpleComponentWithContextAsProvider />,
document.createElement('div')
);
32 changes: 32 additions & 0 deletions hooks/test/browser/useContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,38 @@ describe('useContext', () => {
expect(values).to.deep.equal([13, 42, 69]);
});

it('should only subscribe a component once (non-provider)', () => {
const values = [];
const Context = createContext(13);
let provider, subSpy;

function Comp() {
const value = useContext(Context);
values.push(value);
return null;
}

render(<Comp />, scratch);

render(
<Context ref={p => (provider = p)} value={42}>
<Comp />
</Context>,
scratch
);
subSpy = sinon.spy(provider, 'sub');

render(
<Context value={69}>
<Comp />
</Context>,
scratch
);
expect(subSpy).to.not.have.been.called;

expect(values).to.deep.equal([13, 42, 69]);
});

it('should maintain context', done => {
const context = createContext(null);
const { Provider } = context;
Expand Down
4 changes: 2 additions & 2 deletions mangle.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
"$_globalContext": "__n",
"$_context": "c",
"$_defaultValue": "__",
"$_id": "__c",
"$_contextRef": "__",
"$_id": "__l",
"$_contextRef": "__c",
"$_parentDom": "__P",
"$_originalParentDom": "__O",
"$_prevState": "__u",
Expand Down
100 changes: 47 additions & 53 deletions src/create-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,58 @@ import { enqueueRender } from './component';

export let i = 0;

export function createContext(defaultValue, contextId) {
contextId = '__cC' + i++;

const context = {
_id: contextId,
_defaultValue: defaultValue,
/** @type {import('./internal').FunctionComponent} */
Consumer(props, contextValue) {
// return props.children(
// context[contextId] ? context[contextId].props.value : defaultValue
// );
return props.children(contextValue);
},
/** @type {import('./internal').FunctionComponent} */
Provider(props) {
if (!this.getChildContext) {
/** @type {Set<import('./internal').Component> | null} */
let subs = new Set();
let ctx = {};
ctx[contextId] = this;

this.getChildContext = () => ctx;

this.componentWillUnmount = () => {
subs = null;
};

this.shouldComponentUpdate = function (_props) {
if (this.props.value !== _props.value) {
subs.forEach(c => {
c._force = true;
enqueueRender(c);
});
export function createContext(defaultValue) {
function Context(props) {
if (!this.getChildContext) {
/** @type {Set<import('./internal').Component> | null} */
let subs = new Set();
let ctx = {};
ctx[Context._id] = this;

this.getChildContext = () => ctx;

this.componentWillUnmount = () => {
subs = null;
};

this.shouldComponentUpdate = function (_props) {
// @ts-expect-error even
if (this.props.value !== _props.value) {
subs.forEach(c => {
c._force = true;
enqueueRender(c);
});
}
};

this.sub = c => {
subs.add(c);
let old = c.componentWillUnmount;
c.componentWillUnmount = () => {
if (subs) {
subs.delete(c);
}
if (old) old.call(c);
};
};
}

this.sub = c => {
subs.add(c);
let old = c.componentWillUnmount;
c.componentWillUnmount = () => {
if (subs) {
subs.delete(c);
}
if (old) old.call(c);
};
};
}
return props.children;
}

return props.children;
}
Context._id = '__cC' + i++;
Context._defaultValue = defaultValue;

/** @type {import('./internal').FunctionComponent} */
Context.Consumer = (props, contextValue) => {
return props.children(contextValue);
};

// Devtools needs access to the context object when it
// encounters a Provider. This is necessary to support
// setting `displayName` on the context object instead
// of on the component itself. See:
// https://reactjs.org/docs/context.html#contextdisplayname
// we could also get rid of _contextRef entirely
Context.Provider =
Context._contextRef =
Context.Consumer.contextType =
Context;

return (context.Provider._contextRef = context.Consumer.contextType =
context);
return Context;
}
7 changes: 4 additions & 3 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,11 +388,12 @@ export type ContextType<C extends Context<any>> = C extends Context<infer T>
? T
: never;

export interface Context<T> {
Consumer: Consumer<T>;
Provider: Provider<T>;
export interface Context<T> extends preact.Provider<T> {
Consumer: preact.Consumer<T>;
Provider: preact.Provider<T>;
displayName?: string;
}

export interface PreactContext<T> extends Context<T> {}

export function createContext<T>(defaultValue: T): Context<T>;
37 changes: 36 additions & 1 deletion test/browser/createContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ describe('createContext', () => {
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
});

it('should pass context to a consumer (non-provider)', () => {
const Ctx = createContext(null);
const CONTEXT = { a: 'a' };

let receivedContext;

class Inner extends Component {
render(props) {
return <div>{props.a}</div>;
}
}

sinon.spy(Inner.prototype, 'render');

render(
<Ctx value={CONTEXT}>
<div>
<Ctx.Consumer>
{data => {
receivedContext = data;
return <Inner {...data} />;
}}
</Ctx.Consumer>
</div>
</Ctx>,
scratch
);

// initial render does not invoke anything but render():
expect(Inner.prototype.render).to.have.been.calledWithMatch(CONTEXT);
expect(receivedContext).to.equal(CONTEXT);
expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
});

// This optimization helps
// to prevent a Provider from rerendering the children, this means
// we only propagate to children.
Expand Down Expand Up @@ -152,7 +186,8 @@ describe('createContext', () => {
it('should preserve provider context between different providers', () => {
const { Provider: ThemeProvider, Consumer: ThemeConsumer } =
createContext(null);
const { Provider: DataProvider, Consumer: DataConsumer } = createContext(null);
const { Provider: DataProvider, Consumer: DataConsumer } =
createContext(null);
const THEME_CONTEXT = { theme: 'black' };
const DATA_CONTEXT = { global: 'a' };

Expand Down
27 changes: 22 additions & 5 deletions test/ts/custom-elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface WhateveElAttributes extends createElement.JSX.HTMLAttributes {
}

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

// Sample component that uses custom elements

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

{/* Ensure context still works */}
<Consumer>
<Ctx.Consumer>
{({ contextValue }) => contextValue.toLowerCase()}
</Consumer>
</Ctx.Consumer>
</div>
</Provider>
</Ctx.Provider>
);
}
}

const component = <SimpleComponent />;
class SimpleComponentWithContextAsProvider extends Component {
componentProp = 'componentProp';
render() {
// Render inside div to ensure standard JSX elements still work
return (
<Ctx value={{ contextValue: 'value' }}>
<div>
{/* Ensure context still works */}
<Ctx.Consumer>
{({ contextValue }) => contextValue.toLowerCase()}
</Ctx.Consumer>
</div>
</Ctx>
);
}
}
const component2 = <SimpleComponentWithContextAsProvider />;

0 comments on commit b5eecc2

Please sign in to comment.