Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for Context as JSX #4618

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -60,8 +60,8 @@
"$_processingException": "__",
"$_globalContext": "__n",
"$_context": "c",
"$_defaultValue": "__",
Copy link
Member Author

@JoviDeCroock JoviDeCroock Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One potential issue here is https://github.com/preactjs/prefresh/blob/main/packages/babel/src/index.mjs#L404 where _defaultValue might fail to update as it's not on __ anymore. Instead we'd update the _parent of the Context vnode which sounds... problematic

"$_id": "__c",
"$_defaultValue": "__d",
"$_id": "__l",
"$_contextRef": "__",
"$_parentDom": "__P",
"$_originalParentDom": "__O",
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 />;
Loading