From 0ed7f4c561055ce6f20355967ea31f51eadee869 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Tue, 11 Aug 2026 15:23:01 +0200 Subject: [PATCH] Improve compat feature tree-shaking --- compat/src/render.js | 108 +++++++++-------- compat/src/suspense.js | 264 +++++++++++++++++++++-------------------- 2 files changed, 195 insertions(+), 177 deletions(-) diff --git a/compat/src/render.js b/compat/src/render.js index 5e8941f0d2..500f2f066b 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -24,6 +24,7 @@ import { assign, IS_NON_DIMENSIONAL } from './util'; export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); const MODE_HYDRATE = 1 << 5; +let currentComponent, hydrationRoot, renderTrackingInitialized; const CAMEL_PROPS = /^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image(!S)|letter|lighting|marker(?!H|W|U)|overline|paint|pointer|shape|stop|strikethrough|stroke|text(?!L)|transform|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/; @@ -35,47 +36,45 @@ const IS_DOM = typeof document != 'undefined'; * on a high level this cuts out the warnings, ... and attempts a smaller implementation * @typedef {{ _value: any; _getSnapshot: () => any }} Store */ -export function useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot -) { - const serverRendering = options._skipEffects || hydrationRoot; - const value = serverRendering - ? (getServerSnapshot || getSnapshot)() - : getSnapshot(); - - /** - * @typedef {{ _instance: Store }} StoreRef - * @type {[StoreRef, (store: StoreRef) => void]} - */ - const [{ _instance }, forceUpdate] = useState({ - _instance: { _value: value, _getSnapshot: getSnapshot } - }); - - useLayoutEffect(() => { - _instance._value = value; - _instance._getSnapshot = getSnapshot; +export const useSyncExternalStore = /* @__PURE__ */ initRenderTracking( + function useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) { + const serverRendering = options._skipEffects || hydrationRoot; + const value = serverRendering + ? (getServerSnapshot || getSnapshot)() + : getSnapshot(); + + /** + * @typedef {{ _instance: Store }} StoreRef + * @type {[StoreRef, (store: StoreRef) => void]} + */ + const [{ _instance }, forceUpdate] = useState({ + _instance: { _value: value, _getSnapshot: getSnapshot } + }); - if (didSnapshotChange(_instance)) { - forceUpdate({ _instance }); - } - }, [subscribe, value, getSnapshot]); + useLayoutEffect(() => { + _instance._value = value; + _instance._getSnapshot = getSnapshot; - useEffect(() => { - if (didSnapshotChange(_instance)) { - forceUpdate({ _instance }); - } + if (didSnapshotChange(_instance)) { + forceUpdate({ _instance }); + } + }, [subscribe, value, getSnapshot]); - return subscribe(() => { + useEffect(() => { if (didSnapshotChange(_instance)) { forceUpdate({ _instance }); } - }); - }, [subscribe]); - return value; -} + return subscribe(() => { + if (didSnapshotChange(_instance)) { + forceUpdate({ _instance }); + } + }); + }, [subscribe]); + + return value; + } +); /** @type {(inst: Store) => boolean} */ function didSnapshotChange(inst) { @@ -310,17 +309,6 @@ options.vnode = vnode => { if (oldVNodeHook) oldVNodeHook(vnode); }; -// Only needed for react-relay -let currentComponent, hydrationRoot; -const oldBeforeRender = options._render; -options._render = function (vnode) { - if (oldBeforeRender) { - oldBeforeRender(vnode); - } - if (vnode._flags & MODE_HYDRATE) hydrationRoot = vnode; - currentComponent = vnode._component; -}; - const oldDiffed = options.diffed; /** @type {(vnode: import('./internal').VNode) => void} */ options.diffed = function (vnode) { @@ -339,11 +327,31 @@ options.diffed = function (vnode) { ) { dom.value = props.value == null ? '' : props.value; } - - currentComponent = null; - if (hydrationRoot == vnode) hydrationRoot = null; }; +// Only needed for react-relay and useSyncExternalStore hydration. +function initRenderTracking(value) { + if (!renderTrackingInitialized) { + renderTrackingInitialized = true; + + const oldBeforeRender = options._render; + options._render = vnode => { + if (oldBeforeRender) oldBeforeRender(vnode); + if (vnode._flags & MODE_HYDRATE) hydrationRoot = vnode; + currentComponent = vnode._component; + }; + + const oldDiffed = options.diffed; + options.diffed = vnode => { + if (oldDiffed) oldDiffed(vnode); + currentComponent = null; + if (hydrationRoot == vnode) hydrationRoot = null; + }; + } + + return value; +} + /** * Read the value of a Promise (suspending while pending) or a Context. * Unlike other hooks, `use` may be called conditionally. @@ -351,7 +359,7 @@ options.diffed = function (vnode) { * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource * @returns {T} */ -export const use = resource => { +export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { // A Context is a function without a `then`, a thenable has one. if (resource.then) { if (resource.status == 'fulfilled') return resource.value; @@ -383,7 +391,7 @@ export const use = resource => { provider.sub(currentComponent); } return provider.props.value; -}; +}); // This is a very very private internal function for React it // is used to sort-of do runtime dependency injection. diff --git a/compat/src/suspense.js b/compat/src/suspense.js index afbb2de016..ce41fb7af0 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -7,38 +7,40 @@ import { } from '../../src/constants'; import { assign } from './util'; -const oldCatchError = options._catchError; -options._catchError = (error, newVNode, oldVNode, errorInfo) => { - if (error.then) { - /** @type {import('./internal').Component} */ - let component; - let vnode = newVNode; - - while ((vnode = vnode._parent)) { - if ((component = vnode._component) && component._childDidSuspend) { - if (newVNode._dom == null) { - newVNode._dom = oldVNode._dom; - newVNode._children = oldVNode._children || []; +function initSuspenseHooks() { + const oldCatchError = options._catchError; + options._catchError = (error, newVNode, oldVNode, errorInfo) => { + if (error.then) { + /** @type {import('./internal').Component} */ + let component; + let vnode = newVNode; + + while ((vnode = vnode._parent)) { + if ((component = vnode._component) && component._childDidSuspend) { + if (newVNode._dom == null) { + newVNode._dom = oldVNode._dom; + newVNode._children = oldVNode._children || []; + } + // Don't call oldCatchError if we found a Suspense + return component._childDidSuspend(error, newVNode); } - // Don't call oldCatchError if we found a Suspense - return component._childDidSuspend(error, newVNode); } } - } - oldCatchError(error, newVNode, oldVNode, errorInfo); -}; - -const oldUnmount = options.unmount; -options.unmount = vnode => { - /** @type {import('./internal').Component} */ - const component = vnode._component; - if (component) component._unmounted = true; - if (component && component._onResolve) { - component._onResolve(); - } + oldCatchError(error, newVNode, oldVNode, errorInfo); + }; - if (oldUnmount) oldUnmount(vnode); -}; + const oldUnmount = options.unmount; + options.unmount = vnode => { + /** @type {import('./internal').Component} */ + const component = vnode._component; + if (component) component._unmounted = true; + if (component && component._onResolve) { + component._onResolve(); + } + + if (oldUnmount) oldUnmount(vnode); + }; +} function detachedClone(vnode, detachedParent, parentDom) { if (vnode) { @@ -99,121 +101,129 @@ function removeOriginal(vnode, detachedParent, originalParent) { } // having custom inheritance instead of a class here saves a lot of bytes -export function Suspense() { - // we do not call super here to golf some bytes... - this._pendingSuspensionCount = 0; - this._suspenders = null; - this._detachOnNextRender = null; -} - -// Things we do here to save some bytes but are not proper JS inheritance: -// - call `new Component()` as the prototype -// - do not set `Suspense.prototype.constructor` to `Suspense` -Suspense.prototype = new Component(); - -/** - * @this {import('./internal').SuspenseComponent} - * @param {Promise} promise The thrown promise - * @param {import('./internal').VNode} suspendingVNode The suspending component - */ -Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { - const suspendingComponent = suspendingVNode._component; +function createSuspense() { + initSuspenseHooks(); - if (this._suspenders == null) { - this._suspenders = []; + function Suspense() { + // we do not call super here to golf some bytes... + this._pendingSuspensionCount = 0; + this._suspenders = null; + this._detachOnNextRender = null; } - this._suspenders.push(suspendingComponent); - let resolved = false; - const onResolved = () => { - if (resolved || this._unmounted) return; + // Things we do here to save some bytes but are not proper JS inheritance: + // - call `new Component()` as the prototype + // - do not set `Suspense.prototype.constructor` to `Suspense` + Suspense.prototype = new Component(); - resolved = true; - suspendingComponent._onResolve = null; - - onSuspensionComplete(); - }; + /** + * @this {import('./internal').SuspenseComponent} + * @param {Promise} promise The thrown promise + * @param {import('./internal').VNode} suspendingVNode The suspending component + */ + Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { + const suspendingComponent = suspendingVNode._component; - suspendingComponent._onResolve = onResolved; - - // Store and null _parentDom to prevent setState/forceUpdate from - // scheduling renders while suspended. Render would be a no-op anyway - // since renderComponent checks _parentDom, but this avoids queue churn. - const originalParentDom = suspendingComponent._parentDom; - suspendingComponent._parentDom = null; - - const onSuspensionComplete = () => { - if (!--this._pendingSuspensionCount) { - // If the suspension was during hydration we don't need to restore the - // suspended children into the _children array - if (this.state._suspended) { - const suspendedVNode = this.state._suspended; - this._vnode._children[0] = removeOriginal( - suspendedVNode, - suspendedVNode._component._parentDom, - suspendedVNode._component._originalParentDom - ); - } + if (this._suspenders == null) { + this._suspenders = []; + } + this._suspenders.push(suspendingComponent); + + let resolved = false; + const onResolved = () => { + if (resolved || this._unmounted) return; + + resolved = true; + suspendingComponent._onResolve = null; + + onSuspensionComplete(); + }; + + suspendingComponent._onResolve = onResolved; + + // Store and null _parentDom to prevent setState/forceUpdate from + // scheduling renders while suspended. Render would be a no-op anyway + // since renderComponent checks _parentDom, but this avoids queue churn. + const originalParentDom = suspendingComponent._parentDom; + suspendingComponent._parentDom = null; + + const onSuspensionComplete = () => { + if (!--this._pendingSuspensionCount) { + // If the suspension was during hydration we don't need to restore the + // suspended children into the _children array + if (this.state._suspended) { + const suspendedVNode = this.state._suspended; + this._vnode._children[0] = removeOriginal( + suspendedVNode, + suspendedVNode._component._parentDom, + suspendedVNode._component._originalParentDom + ); + } - this.setState({ _suspended: (this._detachOnNextRender = null) }); + this.setState({ _suspended: (this._detachOnNextRender = null) }); - let suspended; - while ((suspended = this._suspenders.pop())) { - // Restore _parentDom before forceUpdate so render can proceed - suspended._parentDom = originalParentDom; - suspended.forceUpdate(); + let suspended; + while ((suspended = this._suspenders.pop())) { + // Restore _parentDom before forceUpdate so render can proceed + suspended._parentDom = originalParentDom; + suspended.forceUpdate(); + } } + }; + + /** + * We do not set `suspended: true` during hydration because we want the actual markup + * to remain on screen and hydrate it when the suspense actually gets resolved. + * While in non-hydration cases the usual fallback -> component flow would occour. + */ + if ( + !this._pendingSuspensionCount++ && + !(suspendingVNode._flags & MODE_HYDRATE) + ) { + this.setState({ + _suspended: (this._detachOnNextRender = this._vnode._children[0]) + }); } + promise.then(onResolved, onResolved); + }; + + Suspense.prototype.componentWillUnmount = function () { + this._suspenders = []; }; /** - * We do not set `suspended: true` during hydration because we want the actual markup - * to remain on screen and hydrate it when the suspense actually gets resolved. - * While in non-hydration cases the usual fallback -> component flow would occour. + * @this {import('./internal').SuspenseComponent} + * @param {import('./internal').SuspenseComponent["props"]} props + * @param {import('./internal').SuspenseState} state */ - if ( - !this._pendingSuspensionCount++ && - !(suspendingVNode._flags & MODE_HYDRATE) - ) { - this.setState({ - _suspended: (this._detachOnNextRender = this._vnode._children[0]) - }); - } - promise.then(onResolved, onResolved); -}; - -Suspense.prototype.componentWillUnmount = function () { - this._suspenders = []; -}; - -/** - * @this {import('./internal').SuspenseComponent} - * @param {import('./internal').SuspenseComponent["props"]} props - * @param {import('./internal').SuspenseState} state - */ -Suspense.prototype.render = function (props, state) { - if (this._detachOnNextRender) { - // When the Suspense's _vnode was created by a call to createVNode - // (i.e. due to a setState further up in the tree) - // it's _children prop is null, in this case we "forget" about the parked vnodes to detach - if (this._vnode._children) { - const detachedParent = document.createElement('div'); - const detachedComponent = this._vnode._children[0]._component; - this._vnode._children[0] = detachedClone( - this._detachOnNextRender, - detachedParent, - (detachedComponent._originalParentDom = detachedComponent._parentDom) - ); + Suspense.prototype.render = function (props, state) { + if (this._detachOnNextRender) { + // When the Suspense's _vnode was created by a call to createVNode + // (i.e. due to a setState further up in the tree) + // it's _children prop is null, in this case we "forget" about the parked vnodes to detach + if (this._vnode._children) { + const detachedParent = document.createElement('div'); + const detachedComponent = this._vnode._children[0]._component; + this._vnode._children[0] = detachedClone( + this._detachOnNextRender, + detachedParent, + (detachedComponent._originalParentDom = detachedComponent._parentDom) + ); + } + + this._detachOnNextRender = null; } - this._detachOnNextRender = null; - } + return [ + createElement(Fragment, null, state._suspended ? null : props.children), + state._suspended && createElement(Fragment, null, props.fallback) + ]; + }; + + return Suspense; +} - return [ - createElement(Fragment, null, state._suspended ? null : props.children), - state._suspended && createElement(Fragment, null, props.fallback) - ]; -}; +export const Suspense = /* @__PURE__ */ createSuspense(); export function lazy(loader) { let prom;