Skip to content

Commit 3d58db8

Browse files
committed
Commit pending hook state in options._render
`useReducer` installed two closures on every hook-using component: a `shouldComponentUpdate` that both decided the bail-out and committed `_nextValue` -> `_value`, and a `componentWillUpdate` that existed only to run that same commit on the forced-update path, where core skips sCU. It did so by temporarily nulling `prevScu` and re-entering the sCU, smuggling a mutation through a predicate. The commit belongs in neither. `options._render` runs immediately before every render, forced or not, and already ran this exact loop for the same-component re-render branch. Hoisting it out unconditionally makes `componentWillUpdate` unnecessary and leaves sCU a pure predicate. Clearing `_pendingArgs` unconditionally is safe because `options.diffed` promotes them after every successful diff, so leftovers belong to a render that never committed and this render recomputes them. On bail-out `_nextValue` simply stays pending; the dispatcher already reads `_nextValue[0]` when present. hooks: 1387 -> 1331 B brotli (-4.0%). Also drops a closure per component instance, and stops compat's UNSAFE_* accessor setter from running `Object.defineProperty` on every hook component. The forced-update path had no coverage, so add a test that drives it through a context provider.
1 parent 47110cb commit 3d58db8

2 files changed

Lines changed: 68 additions & 48 deletions

File tree

hooks/src/index.js

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { options as _options } from 'preact';
2-
import { COMPONENT_FORCE } from '../../src/constants';
32

43
const ObjectIs = Object.is;
54

@@ -58,20 +57,26 @@ options._render = vnode => {
5857
const hooks = currentComponent.__hooks;
5958
if (hooks) {
6059
if (previousComponent === currentComponent) {
61-
hooks._pendingEffects = [];
6260
currentComponent._renderCallbacks = [];
63-
hooks._list.some(hookItem => {
64-
if (hookItem._nextValue) {
65-
hookItem._value = hookItem._nextValue;
66-
}
67-
hookItem._pendingArgs = hookItem._nextValue = undefined;
68-
});
6961
} else {
7062
hooks._pendingEffects.some(invokeCleanup);
7163
hooks._pendingEffects.some(invokeEffect);
72-
hooks._pendingEffects = [];
7364
currentIndex = 0;
7465
}
66+
hooks._pendingEffects = [];
67+
68+
// Committing pending state here — right before every render, forced or
69+
// not — is what lets `shouldComponentUpdate` below stay a pure predicate
70+
// and lets us skip installing a `componentWillUpdate` entirely. Pending
71+
// args are always stale by this point: `options.diffed` promotes them
72+
// after each successful diff, so anything left over belongs to a render
73+
// that never committed and this render recomputes it.
74+
hooks._list.some(hookItem => {
75+
if (hookItem._nextValue) {
76+
hookItem._value = hookItem._nextValue;
77+
}
78+
hookItem._pendingArgs = hookItem._nextValue = undefined;
79+
});
7580
}
7681
previousComponent = currentComponent;
7782
};
@@ -207,52 +212,30 @@ export function useReducer(reducer, initialState, init) {
207212

208213
if (!currentComponent._hasScuFromHooks) {
209214
currentComponent._hasScuFromHooks = true;
210-
let prevScu = currentComponent.shouldComponentUpdate;
211-
const prevCWU = currentComponent.componentWillUpdate;
212-
213-
// If we're dealing with a forced update `shouldComponentUpdate` will
214-
// not be called. But we use that to update the hook values, so we
215-
// need to call it.
216-
currentComponent.componentWillUpdate = function (p, s, c) {
217-
if (this._bits & COMPONENT_FORCE) {
218-
let tmp = prevScu;
219-
// Clear to avoid other sCU hooks from being called
220-
prevScu = undefined;
221-
updateHookState(p, s, c);
222-
prevScu = tmp;
223-
}
224-
225-
if (prevCWU) prevCWU.call(this, p, s, c);
226-
};
215+
const prevScu = currentComponent.shouldComponentUpdate;
227216

228217
// This SCU has the purpose of bailing out after repeated updates
229-
// to stateful hooks.
230-
// we store the next value in _nextValue[0] and keep doing that for all
231-
// state setters, if we have next states and
232-
// all next states within a component end up being equal to their original state
233-
// we are safe to bail out for this specific component.
234-
/**
235-
*
236-
* @type {import('./internal').Component["shouldComponentUpdate"]}
237-
*/
238-
// @ts-ignore - We don't use TS to downtranspile
239-
// eslint-disable-next-line no-inner-declarations
240-
function updateHookState(p, s, c) {
241-
if (!hookState._component.__hooks) return true;
218+
// to stateful hooks. We store the next value in `_nextValue[0]` and
219+
// keep doing that for all state setters; if we have next states and
220+
// all next states within a component end up being equal to their
221+
// original state we are safe to bail out for this specific component.
222+
// Applying those next values is `options._render`'s job, so this only
223+
// has to answer the question, never mutate.
224+
currentComponent.shouldComponentUpdate = function (p, s, c) {
225+
const hooks = this.__hooks;
226+
if (!hooks) return true;
242227

243228
// We check whether we have components with a nextValue set that
244229
// have values that aren't equal to one another this pushes
245230
// us to update further down the tree
246231
let updatedHook = false;
247-
let shouldUpdate = hookState._component.props !== p;
248-
hookState._component.__hooks._list.some(hookItem => {
232+
let shouldUpdate = this.props !== p;
233+
hooks._list.some(hookItem => {
249234
if (hookItem._nextValue) {
250235
updatedHook = true;
251-
const currentValue = hookItem._value[0];
252-
hookItem._value = hookItem._nextValue;
253-
hookItem._nextValue = undefined;
254-
if (!ObjectIs(currentValue, hookItem._value[0]))
236+
if (!ObjectIs(hookItem._value[0], hookItem._nextValue[0])) {
255237
shouldUpdate = true;
238+
}
256239
}
257240
});
258241

@@ -262,9 +245,7 @@ export function useReducer(reducer, initialState, init) {
262245
}
263246

264247
return !updatedHook || shouldUpdate;
265-
}
266-
267-
currentComponent.shouldComponentUpdate = updateHookState;
248+
};
268249
}
269250
}
270251

hooks/test/browser/useState.test.jsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,43 @@ describe('useState', () => {
537537
});
538538
expect(scratch.innerHTML).to.equal('<p>1-1</p>');
539539
});
540+
541+
it('applies pending state on a forced update', () => {
542+
const ctx = createContext(0);
543+
let setValue, setCount;
544+
545+
function Provider({ children }) {
546+
const [value, _setValue] = useState(0);
547+
setValue = _setValue;
548+
return <ctx.Provider value={value}>{children}</ctx.Provider>;
549+
}
550+
551+
function Child() {
552+
const value = useContext(ctx);
553+
const [count, _setCount] = useState(0);
554+
setCount = _setCount;
555+
return (
556+
<p>
557+
{value}-{count}
558+
</p>
559+
);
560+
}
561+
562+
render(
563+
<Provider>
564+
<Child />
565+
</Provider>,
566+
scratch
567+
);
568+
expect(scratch.innerHTML).to.equal('<p>0-0</p>');
569+
570+
act(() => {
571+
// A context change force-updates its subscribers, which means Child
572+
// renders with `shouldComponentUpdate` skipped entirely. Its own
573+
// pending state still has to be applied by that render.
574+
setValue(1);
575+
setCount(1);
576+
});
577+
expect(scratch.innerHTML).to.equal('<p>1-1</p>');
578+
});
540579
});

0 commit comments

Comments
 (0)