From b5eecc21eb060ffbe0b22fa2e7396327ea757e6e Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Tue, 14 Jan 2025 15:58:52 +0100 Subject: [PATCH] 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 --- compat/test/browser/render.test.js | 34 +++++++++ compat/test/ts/index.tsx | 23 ++++++ hooks/test/browser/useContext.test.js | 32 +++++++++ mangle.json | 4 +- src/create-context.js | 100 ++++++++++++-------------- src/index.d.ts | 7 +- test/browser/createContext.test.js | 37 +++++++++- test/ts/custom-elements.tsx | 27 +++++-- 8 files changed, 200 insertions(+), 64 deletions(-) diff --git a/compat/test/browser/render.test.js b/compat/test/browser/render.test.js index 6245fc39fa..7781acb6d8 100644 --- a/compat/test/browser/render.test.js +++ b/compat/test/browser/render.test.js @@ -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
{props.a}
; + } + } + + sinon.spy(Inner.prototype, 'render'); + + render( + +
+ + {data => { + receivedContext = data; + return ; + }} + +
+
, + 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('
a
'); + }); + 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) { diff --git a/compat/test/ts/index.tsx b/compat/test/ts/index.tsx index f636ce6d5f..2e322931d9 100644 --- a/compat/test/ts/index.tsx +++ b/compat/test/ts/index.tsx @@ -15,3 +15,26 @@ React.unmountComponentAtNode(document.body.shadowRoot!); React.createPortal(
, document.createElement('div')); React.createPortal(
, document.createDocumentFragment()); React.createPortal(
, 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 ( + +
+ {/* Ensure context still works */} + + {({ contextValue }) => contextValue.toLowerCase()} + +
+
+ ); + } +} + +React.render( + , + document.createElement('div') +); diff --git a/hooks/test/browser/useContext.test.js b/hooks/test/browser/useContext.test.js index 67b7851406..6a47b107d8 100644 --- a/hooks/test/browser/useContext.test.js +++ b/hooks/test/browser/useContext.test.js @@ -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(, scratch); + + render( + (provider = p)} value={42}> + + , + scratch + ); + subSpy = sinon.spy(provider, 'sub'); + + render( + + + , + 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; diff --git a/mangle.json b/mangle.json index dfbca39c3d..8c518f89fe 100644 --- a/mangle.json +++ b/mangle.json @@ -62,8 +62,8 @@ "$_globalContext": "__n", "$_context": "c", "$_defaultValue": "__", - "$_id": "__c", - "$_contextRef": "__", + "$_id": "__l", + "$_contextRef": "__c", "$_parentDom": "__P", "$_originalParentDom": "__O", "$_prevState": "__u", diff --git a/src/create-context.js b/src/create-context.js index 1bb9868f6d..8ecaec9ea5 100644 --- a/src/create-context.js +++ b/src/create-context.js @@ -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 | 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 | 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; } diff --git a/src/index.d.ts b/src/index.d.ts index 279af93ab2..8ab4570dc5 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -388,11 +388,12 @@ export type ContextType> = C extends Context ? T : never; -export interface Context { - Consumer: Consumer; - Provider: Provider; +export interface Context extends preact.Provider { + Consumer: preact.Consumer; + Provider: preact.Provider; displayName?: string; } + export interface PreactContext extends Context {} export function createContext(defaultValue: T): Context; diff --git a/test/browser/createContext.test.js b/test/browser/createContext.test.js index f5af75db58..41850aad90 100644 --- a/test/browser/createContext.test.js +++ b/test/browser/createContext.test.js @@ -57,6 +57,40 @@ describe('createContext', () => { expect(scratch.innerHTML).to.equal('
a
'); }); + 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
{props.a}
; + } + } + + sinon.spy(Inner.prototype, 'render'); + + render( + +
+ + {data => { + receivedContext = data; + return ; + }} + +
+
, + 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('
a
'); + }); + // This optimization helps // to prevent a Provider from rerendering the children, this means // we only propagate to children. @@ -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' }; diff --git a/test/ts/custom-elements.tsx b/test/ts/custom-elements.tsx index c606d6a7f8..f943b7c930 100644 --- a/test/ts/custom-elements.tsx +++ b/test/ts/custom-elements.tsx @@ -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 @@ -50,7 +50,7 @@ class SimpleComponent extends Component { render() { // Render inside div to ensure standard JSX elements still work return ( - +
{ @@ -73,13 +73,30 @@ class SimpleComponent extends Component { > {/* Ensure context still works */} - + {({ contextValue }) => contextValue.toLowerCase()} - +
-
+ ); } } const component = ; +class SimpleComponentWithContextAsProvider extends Component { + componentProp = 'componentProp'; + render() { + // Render inside div to ensure standard JSX elements still work + return ( + +
+ {/* Ensure context still works */} + + {({ contextValue }) => contextValue.toLowerCase()} + +
+
+ ); + } +} +const component2 = ;