Skip to content

Implement deferring state updates #4760

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

Merged
merged 2 commits into from
May 29, 2025
Merged
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
123 changes: 48 additions & 75 deletions hooks/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { options as _options } from 'preact';
import { SKIP_CHILDREN } from '../../src/constants';

const ObjectIs = Object.is;

Expand Down Expand Up @@ -26,6 +27,7 @@ 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
Expand Down Expand Up @@ -60,10 +62,7 @@ options._render = vnode => {
hooks._pendingEffects = [];
currentComponent._renderCallbacks = [];
hooks._list.forEach(hookItem => {
if (hookItem._nextValue) {
hookItem._value = hookItem._nextValue;
}
hookItem._pendingArgs = hookItem._nextValue = undefined;
hookItem._pendingArgs = undefined;
});
} else {
hooks._pendingEffects.forEach(invokeCleanup);
Expand Down Expand Up @@ -186,19 +185,13 @@ 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 => {
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({});
}
hookState._actions.push(action);
hookState._component.setState({});
}
];

Expand All @@ -207,75 +200,55 @@ export function useReducer(reducer, initialState, init) {
if (!currentComponent._hasScuFromHooks) {
currentComponent._hasScuFromHooks = true;
let prevScu = currentComponent.shouldComponentUpdate;
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);

currentComponent.shouldComponentUpdate = (p, s, c) => {
return prevScu
? prevScu.call(this, p, s, c) || hookState._actions.length
: hookState._actions.length;
};
}
}

// 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;
}
});
if (hookState._actions.length) {
const initialValue = hookState._value[0];
hookState._actions.some(action => {
hookState._value[0] = hookState._reducer(hookState._value[0], action);
});

return prevScu
? prevScu.call(this, p, s, c) || shouldUpdate
: shouldUpdate;
}
hookState._didUpdate = !ObjectIs(initialValue, hookState._value[0]);
hookState._value = [hookState._value[0], hookState._value[1]];
hookState._didExecute = true;
hookState._actions = [];
}

currentComponent.shouldComponentUpdate = updateHookState;
return hookState._value;
}

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
);

if (
stateHooksThatExecuted.length &&
!stateHooksThatExecuted.some(x => x._didUpdate) &&
oldVNode.props === newVNode.props
) {
newVNode._component.__hooks._pendingEffects = [];
newVNode._flags |= SKIP_CHILDREN;
}

stateHooksThatExecuted.some(hook => {
hook._didExecute = hook._didUpdate = false;
});
}

return hookState._nextValue || hookState._value;
}
if (oldAfterRender) oldAfterRender(newVNode, oldVNode);
};

/**
* @param {import('./internal').Effect} callback
Expand Down
12 changes: 6 additions & 6 deletions hooks/src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
VNode as PreactVNode,
PreactContext,
HookType,
ErrorInfo,
ErrorInfo
} from '../../src/internal';
import { Reducer, StateUpdater } from '.';

Expand Down Expand Up @@ -32,7 +32,8 @@ export interface ComponentHooks {
_pendingEffects: EffectHookState[];
}

export interface Component extends Omit<PreactComponent<any, any>, '_renderCallbacks'> {
export interface Component
extends Omit<PreactComponent<any, any>, '_renderCallbacks'> {
__hooks?: ComponentHooks;
// Extend to include HookStates
_renderCallbacks?: Array<HookState | (() => void)>;
Expand All @@ -54,8 +55,6 @@ export type HookState =

interface BaseHookState {
_value?: unknown;
_nextValue?: unknown;
_pendingValue?: unknown;
_args?: unknown;
_pendingArgs?: unknown;
_component?: unknown;
Expand All @@ -74,18 +73,19 @@ export interface EffectHookState extends BaseHookState {

export interface MemoHookState<T = unknown> extends BaseHookState {
_value?: T;
_pendingValue?: T;
_args?: unknown[];
_pendingArgs?: unknown[];
_factory?: () => T;
}

export interface ReducerHookState<S = unknown, A = unknown>
extends BaseHookState {
_nextValue?: [S, StateUpdater<S>];
_value?: [S, StateUpdater<S>];
_actions?: any[];
_component?: Component;
_reducer?: Reducer<S, A>;
_didExecute?: boolean;
_didUpdate?: boolean;
}

export interface ContextHookState extends BaseHookState {
Expand Down
Loading
Loading