diff --git a/compat/src/render.js b/compat/src/render.js
index 9807c0fc0b..c11c4e4773 100644
--- a/compat/src/render.js
+++ b/compat/src/render.js
@@ -40,6 +40,33 @@ const onChangeInputType = type => /fil|che|rad/.test(type);
// Some libraries like `react-virtualized` explicitly check for this.
Component.prototype.isReactComponent = {};
+// `UNSAFE_*` lifecycle hooks
+// Preact only ever invokes the unprefixed methods.
+// Here we provide a base "fallback" implementation that calls any defined UNSAFE_ prefixed method.
+// - If a component defines its own `componentDidMount()` (including via defineProperty), use that.
+// - If a component defines `UNSAFE_componentDidMount()`, `componentDidMount` is the alias getter/setter.
+// - If anything assigns to an `UNSAFE_*` property, the assignment is forwarded to the unprefixed property.
+// See https://github.com/preactjs/preact/issues/1941
+[
+ 'componentWillMount',
+ 'componentWillReceiveProps',
+ 'componentWillUpdate'
+].forEach(key => {
+ Object.defineProperty(Component.prototype, key, {
+ configurable: true,
+ get() {
+ return this['UNSAFE_' + key];
+ },
+ set(v) {
+ Object.defineProperty(this, key, {
+ configurable: true,
+ writable: true,
+ value: v
+ });
+ }
+ });
+});
+
/**
* Proxy render() since React returns a Component reference.
* @param {import('./internal').VNode} vnode VNode tree to render
diff --git a/compat/test/browser/component.test.js b/compat/test/browser/component.test.js
index 9aecf754bc..5abe301cc0 100644
--- a/compat/test/browser/component.test.js
+++ b/compat/test/browser/component.test.js
@@ -1,6 +1,7 @@
import { setupRerender } from 'preact/test-utils';
import { setupScratch, teardown } from '../../../test/_util/helpers';
-import React, { createElement } from 'preact/compat';
+import React, { createElement, Component } from 'preact/compat';
+import { vi } from 'vitest';
describe('components', () => {
/** @type {HTMLDivElement} */
@@ -75,4 +76,252 @@ describe('components', () => {
children: 'second'
});
});
+
+ describe('UNSAFE_* lifecycle methods', () => {
+ it('should support UNSAFE_componentWillMount', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillMount() {
+ spy();
+ }
+
+ render() {
+ return
foo
;
+ }
+ }
+
+ React.render(, scratch);
+
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should support UNSAFE_componentWillMount #2', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ render() {
+ return foo
;
+ }
+ }
+
+ Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillMount', {
+ value: spy
+ });
+
+ React.render(, scratch);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should support UNSAFE_componentWillReceiveProps', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillReceiveProps() {
+ spy();
+ }
+
+ render() {
+ return foo
;
+ }
+ }
+
+ React.render(, scratch);
+ // Trigger an update
+ React.render(, scratch);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should support UNSAFE_componentWillReceiveProps #2', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ render() {
+ return foo
;
+ }
+ }
+
+ Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillReceiveProps', {
+ value: spy
+ });
+
+ React.render(, scratch);
+ // Trigger an update
+ React.render(, scratch);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should support UNSAFE_componentWillUpdate', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillUpdate() {
+ spy();
+ }
+
+ render() {
+ return foo
;
+ }
+ }
+
+ React.render(, scratch);
+ // Trigger an update
+ React.render(, scratch);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should support UNSAFE_componentWillUpdate #2', () => {
+ let spy = vi.fn();
+
+ class Foo extends React.Component {
+ render() {
+ return foo
;
+ }
+ }
+
+ Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillUpdate', {
+ value: spy
+ });
+
+ React.render(, scratch);
+ // Trigger an update
+ React.render(, scratch);
+ expect(spy).toHaveBeenCalledOnce();
+ });
+
+ it('should alias UNSAFE_* method to non-prefixed variant', () => {
+ let inst;
+ class Foo extends React.Component {
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillMount() {}
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillReceiveProps() {}
+ // eslint-disable-next-line camelcase
+ UNSAFE_componentWillUpdate() {}
+ render() {
+ inst = this;
+ return foo
;
+ }
+ }
+
+ React.render(, scratch);
+
+ expect(inst.UNSAFE_componentWillMount).to.equal(inst.componentWillMount);
+ expect(inst.UNSAFE_componentWillReceiveProps).to.equal(
+ inst.UNSAFE_componentWillReceiveProps
+ );
+ expect(inst.UNSAFE_componentWillUpdate).to.equal(
+ inst.UNSAFE_componentWillUpdate
+ );
+ });
+
+ it('should call UNSAFE_* methods through Suspense with wrapper component #2525', () => {
+ class Page extends React.Component {
+ UNSAFE_componentWillMount() {}
+ render() {
+ return Example
;
+ }
+ }
+
+ const Wrapper = () => ;
+
+ vi.spyOn(Page.prototype, 'UNSAFE_componentWillMount');
+
+ React.render(
+ fallback}>
+
+ ,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('Example
');
+ expect(Page.prototype.UNSAFE_componentWillMount).toHaveBeenCalled();
+ });
+ });
+
+ describe('defaultProps', () => {
+ it('should apply default props on initial render', () => {
+ class WithDefaultProps extends Component {
+ constructor(props, context) {
+ super(props, context);
+ expect(props).to.be.deep.equal({
+ fieldA: 1,
+ fieldB: 2,
+ fieldC: 1,
+ fieldD: 2
+ });
+ }
+ render() {
+ return ;
+ }
+ }
+ WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 };
+ React.render(
+ ,
+ scratch
+ );
+ });
+
+ it('should apply default props on rerender', () => {
+ let doRender;
+ class Outer extends Component {
+ constructor() {
+ super();
+ this.state = { i: 1 };
+ }
+ componentDidMount() {
+ doRender = () => this.setState({ i: 2 });
+ }
+ render(props, { i }) {
+ return ;
+ }
+ }
+ class WithDefaultProps extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.ctor(props, context);
+ }
+ ctor() {}
+ componentWillReceiveProps() {}
+ render() {
+ return ;
+ }
+ }
+ WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 };
+
+ let proto = WithDefaultProps.prototype;
+ vi.spyOn(proto, 'ctor');
+ vi.spyOn(proto, 'componentWillReceiveProps');
+ vi.spyOn(proto, 'render');
+
+ React.render(, scratch);
+ doRender();
+
+ const PROPS1 = {
+ fieldA: 1,
+ fieldB: 1,
+ fieldC: 1,
+ fieldD: 1
+ };
+
+ const PROPS2 = {
+ fieldA: 1,
+ fieldB: 2,
+ fieldC: 1,
+ fieldD: 2
+ };
+
+ expect(proto.ctor).toHaveBeenCalledWith(PROPS1, {});
+ expect(proto.render).toHaveBeenCalledWith(PROPS1, {}, {});
+
+ rerender();
+
+ // expect(proto.ctor).to.have.been.calledWith(PROPS2);
+ expect(proto.componentWillReceiveProps).toHaveBeenCalledWith(PROPS2, {});
+ expect(proto.render).toHaveBeenCalledWith(PROPS2, {}, {});
+ });
+ });
});
diff --git a/hooks/src/index.js b/hooks/src/index.js
index eb52ae8e51..d6e66dfe7f 100644
--- a/hooks/src/index.js
+++ b/hooks/src/index.js
@@ -1,5 +1,4 @@
import { options as _options } from 'preact';
-import { SKIP_CHILDREN } from '../../src/constants';
const ObjectIs = Object.is;
@@ -27,7 +26,6 @@ let oldAfterDiff = options.diffed;
let oldCommit = options._commit;
let oldBeforeUnmount = options.unmount;
let oldRoot = options._root;
-let oldAfterRender = options._afterRender;
// We take the minimum timeout for requestAnimationFrame to ensure that
// the callback is invoked after the next frame. 35ms is based on a 30hz
@@ -62,7 +60,10 @@ options._render = vnode => {
hooks._pendingEffects = [];
currentComponent._renderCallbacks = [];
hooks._list.forEach(hookItem => {
- hookItem._pendingArgs = undefined;
+ if (hookItem._nextValue) {
+ hookItem._value = hookItem._nextValue;
+ }
+ hookItem._pendingArgs = hookItem._nextValue = undefined;
});
} else {
hooks._pendingEffects.forEach(invokeCleanup);
@@ -185,13 +186,19 @@ export function useReducer(reducer, initialState, init) {
const hookState = getHookState(currentIndex++, 2);
hookState._reducer = reducer;
if (!hookState._component) {
- hookState._actions = [];
hookState._value = [
!init ? invokeOrReturn(undefined, initialState) : init(initialState),
action => {
- hookState._actions.push(action);
- hookState._component.setState({});
+ const currentValue = hookState._nextValue
+ ? hookState._nextValue[0]
+ : hookState._value[0];
+ const nextValue = hookState._reducer(currentValue, action);
+
+ if (!ObjectIs(currentValue, nextValue)) {
+ hookState._nextValue = [nextValue, hookState._value[1]];
+ hookState._component.setState({});
+ }
}
];
@@ -200,55 +207,75 @@ export function useReducer(reducer, initialState, init) {
if (!currentComponent._hasScuFromHooks) {
currentComponent._hasScuFromHooks = true;
let prevScu = currentComponent.shouldComponentUpdate;
-
- currentComponent.shouldComponentUpdate = function (p, s, c) {
- return prevScu
- ? prevScu.call(this, p, s, c) || hookState._actions.length
- : hookState._actions.length;
+ const prevCWU = currentComponent.componentWillUpdate;
+
+ // If we're dealing with a forced update `shouldComponentUpdate` will
+ // not be called. But we use that to update the hook values, so we
+ // need to call it.
+ currentComponent.componentWillUpdate = function (p, s, c) {
+ if (this._force) {
+ let tmp = prevScu;
+ // Clear to avoid other sCU hooks from being called
+ prevScu = undefined;
+ updateHookState(p, s, c);
+ prevScu = tmp;
+ }
+
+ if (prevCWU) prevCWU.call(this, p, s, c);
};
- }
- }
-
- if (hookState._actions.length) {
- const initialValue = hookState._value[0];
- hookState._actions.some(action => {
- hookState._value[0] = hookState._reducer(hookState._value[0], action);
- });
-
- hookState._didUpdate = !ObjectIs(initialValue, hookState._value[0]);
- hookState._value = [hookState._value[0], hookState._value[1]];
- hookState._didExecute = true;
- hookState._actions = [];
- }
- return hookState._value;
-}
+ // This SCU has the purpose of bailing out after repeated updates
+ // to stateful hooks.
+ // we store the next value in _nextValue[0] and keep doing that for all
+ // state setters, if we have next states and
+ // all next states within a component end up being equal to their original state
+ // we are safe to bail out for this specific component.
+ /**
+ *
+ * @type {import('./internal').Component["shouldComponentUpdate"]}
+ */
+ // @ts-ignore - We don't use TS to downtranspile
+ // eslint-disable-next-line no-inner-declarations
+ function updateHookState(p, s, c) {
+ if (!hookState._component.__hooks) return true;
+
+ /** @type {(x: import('./internal').HookState) => x is import('./internal').ReducerHookState} */
+ const isStateHook = x => !!x._component;
+ const stateHooks =
+ hookState._component.__hooks._list.filter(isStateHook);
+
+ const allHooksEmpty = stateHooks.every(x => !x._nextValue);
+ // When we have no updated hooks in the component we invoke the previous SCU or
+ // traverse the VDOM tree further.
+ if (allHooksEmpty) {
+ return prevScu ? prevScu.call(this, p, s, c) : true;
+ }
+
+ // We check whether we have components with a nextValue set that
+ // have values that aren't equal to one another this pushes
+ // us to update further down the tree
+ let shouldUpdate = hookState._component.props !== p;
+ stateHooks.forEach(hookItem => {
+ if (hookItem._nextValue) {
+ const currentValue = hookItem._value[0];
+ hookItem._value = hookItem._nextValue;
+ hookItem._nextValue = undefined;
+ if (!ObjectIs(currentValue, hookItem._value[0]))
+ shouldUpdate = true;
+ }
+ });
-options._afterRender = (newVNode, oldVNode) => {
- if (newVNode._component && newVNode._component.__hooks) {
- const hooks = newVNode._component.__hooks._list;
- const stateHooksThatExecuted = hooks.filter(
- /** @type {(x: import('./internal').HookState) => x is import('./internal').ReducerHookState} */
- // @ts-expect-error
- x => x._component && x._didExecute
- );
+ return prevScu
+ ? prevScu.call(this, p, s, c) || shouldUpdate
+ : shouldUpdate;
+ }
- if (
- stateHooksThatExecuted.length &&
- !stateHooksThatExecuted.some(x => x._didUpdate) &&
- oldVNode.props === newVNode.props
- ) {
- newVNode._component.__hooks._pendingEffects = [];
- newVNode._flags |= SKIP_CHILDREN;
+ currentComponent.shouldComponentUpdate = updateHookState;
}
-
- stateHooksThatExecuted.some(hook => {
- hook._didExecute = hook._didUpdate = false;
- });
}
- if (oldAfterRender) oldAfterRender(newVNode, oldVNode);
-};
+ return hookState._nextValue || hookState._value;
+}
/**
* @param {import('./internal').Effect} callback
diff --git a/hooks/src/internal.d.ts b/hooks/src/internal.d.ts
index b5b6d66c99..c51fc13a50 100644
--- a/hooks/src/internal.d.ts
+++ b/hooks/src/internal.d.ts
@@ -55,6 +55,8 @@ export type HookState =
interface BaseHookState {
_value?: unknown;
+ _nextValue?: unknown;
+ _pendingValue?: unknown;
_args?: unknown;
_pendingArgs?: unknown;
_component?: unknown;
@@ -73,6 +75,7 @@ export interface EffectHookState extends BaseHookState {
export interface MemoHookState extends BaseHookState {
_value?: T;
+ _pendingValue?: T;
_args?: unknown[];
_pendingArgs?: unknown[];
_factory?: () => T;
@@ -80,12 +83,10 @@ export interface MemoHookState extends BaseHookState {
export interface ReducerHookState
extends BaseHookState {
+ _nextValue?: [S, StateUpdater];
_value?: [S, StateUpdater];
- _actions?: any[];
_component?: Component;
_reducer?: Reducer;
- _didExecute?: boolean;
- _didUpdate?: boolean;
}
export interface ContextHookState extends BaseHookState {
diff --git a/hooks/test/browser/useState.test.js b/hooks/test/browser/useState.test.js
index bf179ca38f..4d391c209d 100644
--- a/hooks/test/browser/useState.test.js
+++ b/hooks/test/browser/useState.test.js
@@ -1,14 +1,7 @@
import { setupRerender, act } from 'preact/test-utils';
import { createElement, render, createContext, Component } from 'preact';
-import { afterAll, beforeAll, expect, vi } from 'vitest';
-import {
- useState,
- useContext,
- useEffect,
- useLayoutEffect,
- useReducer,
- useRef
-} from 'preact/hooks';
+import { vi } from 'vitest';
+import { useState, useContext, useEffect } from 'preact/hooks';
import { setupScratch, teardown } from '../../../test/_util/helpers';
/** @jsx createElement */
@@ -77,12 +70,12 @@ describe('useState', () => {
doSetState(0);
rerender();
expect(lastState).to.equal(0);
- expect(Comp).toHaveBeenCalledTimes(2);
+ expect(Comp).toHaveBeenCalledOnce();
doSetState(() => 0);
rerender();
expect(lastState).to.equal(0);
- expect(Comp).toHaveBeenCalledTimes(3);
+ expect(Comp).toHaveBeenCalledOnce();
});
it('rerenders when setting the state', () => {
@@ -352,7 +345,7 @@ describe('useState', () => {
render(, scratch);
});
- expect(renderSpy).toHaveBeenCalledTimes(3);
+ expect(renderSpy).toHaveBeenCalledTimes(2);
});
it('Cancels effect invocations correctly when bailing', () => {
@@ -388,7 +381,7 @@ describe('useState', () => {
set('initial');
});
- expect(renderSpy).toHaveBeenCalledTimes(2);
+ expect(renderSpy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledOnce();
expect(cleanupSpy).not.toHaveBeenCalled();
});
@@ -418,7 +411,7 @@ describe('useState', () => {
expect(scratch.innerHTML).to.equal('hello world!!!
');
});
- it('should exhaust renders when NaN state is set as a result of a props update', () => {
+ it('should limit rerenders when setting state to NaN', () => {
const calls = [];
const App = ({ i }) => {
calls.push('rendering' + i);
@@ -440,55 +433,8 @@ describe('useState', () => {
act(() => {
render(, scratch);
});
- expect(calls.length).to.equal(27);
- expect(calls.slice(1).every(c => c === 'rendering2')).to.equal(true);
- });
-
- it('should bail correctly when setting NaN twice', () => {
- const calls = [];
- let set;
- const Greeting = ({ greeting }) => {
- calls.push('rendering ' + greeting);
- return {greeting}
;
- };
- const App = () => {
- const [greeting, setGreeting] = useState(0);
- set = setGreeting;
-
- return ;
- };
-
- act(() => {
- render(, scratch);
- });
- expect(calls.length).to.equal(1);
- expect(calls).to.deep.equal(['rendering 0']);
-
- act(() => {
- set(1);
- });
- expect(calls.length).to.equal(2);
- expect(calls).to.deep.equal(['rendering 0', 'rendering 1']);
-
- act(() => {
- set(NaN);
- });
- expect(calls.length).to.equal(3);
- expect(calls).to.deep.equal([
- 'rendering 0',
- 'rendering 1',
- 'rendering NaN'
- ]);
-
- act(() => {
- set(NaN);
- });
expect(calls.length).to.equal(3);
- expect(calls).to.deep.equal([
- 'rendering 0',
- 'rendering 1',
- 'rendering NaN'
- ]);
+ expect(calls.slice(1).every(c => c === 'rendering2')).to.equal(true);
});
describe('Global sCU', () => {
@@ -532,94 +478,4 @@ describe('useState', () => {
expect(renders).to.equal(2);
});
});
-
- it('Should capture the closure in the reducer', () => {
- function createContext2() {
- const context = createContext();
-
- const ProviderOrig = context.Provider;
- context.Provider = ({ value, children }) => {
- const valueRef = useRef(value);
- const contextValue = useRef();
-
- if (!contextValue.current) {
- contextValue.current = {
- value: valueRef,
- listener: null
- };
- }
-
- useLayoutEffect(() => {
- valueRef.current = value;
- if (contextValue.current.listener) {
- contextValue.current.listener([value]);
- }
- }, [value]);
- return (
- {children}
- );
- };
-
- return context;
- }
-
- function useContextSelector(context) {
- const contextValue = useContext(context);
- const {
- value: { current: value }
- } = contextValue;
- const [state, dispatch] = useReducer(
- () => {
- return {
- value
- };
- },
- {
- value
- }
- );
- useLayoutEffect(() => {
- contextValue.listener = dispatch;
- }, []);
- return state.value;
- }
-
- const context = createContext2();
- let set;
-
- function Child() {
- const [count, setState] = useContextSelector(context);
- const [c, setC] = useState(0);
- set = () => {
- setC(s => s + 1);
- setState(s => s + 1);
- };
- return (
-
-
Context count: {count}
-
Local count: {c}
-
- );
- }
-
- // Render this
- function App() {
- const [state, setState] = useState(0);
- return (
-
-
-
- );
- }
-
- act(() => {
- render(, scratch);
- });
- expect(scratch.textContent).to.equal('Context count: 0Local count: 0');
-
- act(() => {
- set();
- });
- expect(scratch.textContent).to.equal('Context count: 1Local count: 1');
- });
});
diff --git a/mangle.json b/mangle.json
index bfe7a0b10b..d999d593f9 100644
--- a/mangle.json
+++ b/mangle.json
@@ -33,14 +33,12 @@
"$_list": "__",
"$_pendingEffects": "__h",
"$_value": "__",
- "$_didExecute": "__N",
- "$_didUpdate": "__U",
+ "$_nextValue": "__N",
"$_original": "__v",
"$_args": "__H",
"$_factory": "__h",
"$_depth": "__b",
"$_dirty": "__d",
- "$_afterRender": "__d",
"$_mask": "__m",
"$_detachOnNextRender": "__b",
"$_force": "__e",
diff --git a/src/constants.js b/src/constants.js
index 57d56d98c6..2fe00830be 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -6,8 +6,6 @@ export const MODE_SUSPENDED = 1 << 7;
export const INSERT_VNODE = 1 << 2;
/** Indicates a VNode has been matched with another VNode in the diff */
export const MATCHED = 1 << 1;
-/** Indicates that children should not be diffed */
-export const SKIP_CHILDREN = 1 << 3;
/** Reset all mode flags */
export const RESET_MODE = ~(MODE_HYDRATE | MODE_SUSPENDED);
diff --git a/src/diff/index.js b/src/diff/index.js
index d9a62cbae5..30eed07d62 100644
--- a/src/diff/index.js
+++ b/src/diff/index.js
@@ -5,7 +5,6 @@ import {
MODE_SUSPENDED,
NULL,
RESET_MODE,
- SKIP_CHILDREN,
SVG_NAMESPACE,
UNDEFINED,
XHTML_NAMESPACE
@@ -224,7 +223,6 @@ export function diff(
c._force = false;
let renderHook = options._render,
- afterRender = options._afterRender,
count = 0;
if (isClassComponent) {
c.state = c._nextState;
@@ -233,7 +231,6 @@ export function diff(
if (renderHook) renderHook(newVNode);
tmp = c.render(c.props, c.state, c.context);
- if (afterRender) afterRender(newVNode, oldVNode);
for (let i = 0; i < c._stateCallbacks.length; i++) {
c._renderCallbacks.push(c._stateCallbacks[i]);
@@ -245,18 +242,6 @@ export function diff(
if (renderHook) renderHook(newVNode);
tmp = c.render(c.props, c.state, c.context);
- if (afterRender) afterRender(newVNode, oldVNode);
-
- if (newVNode._flags & SKIP_CHILDREN) {
- c._dirty = false;
- c._renderCallbacks = [];
- newVNode._dom = oldVNode._dom;
- newVNode._children = oldVNode._children;
- newVNode._children.some(vnode => {
- if (vnode) vnode._parent = newVNode;
- });
- break outer;
- }
// Handle setState called in render, see #2553
c.state = c._nextState;
diff --git a/src/internal.d.ts b/src/internal.d.ts
index 403fe37dbb..72215d066a 100644
--- a/src/internal.d.ts
+++ b/src/internal.d.ts
@@ -27,8 +27,6 @@ export interface ErrorInfo {
}
export interface Options extends preact.Options {
- /** Attach a hook that is invoked after a vnode has rendered. */
- _afterRender?(vnode: VNode, oldVNode: VNode): void;
/** Attach a hook that is invoked before render, mainly to check the arguments. */
_root?(vnode: ComponentChild, parent: preact.ContainerNode): void;
/** Attach a hook that is invoked before a vnode is diffed. */