Skip to content

Commit aa8d57b

Browse files
authored
Backports x (#3707)
* backport #3615 * backport #3633 * backport #3643 * backport #3655 * backport #3663 * backport #3690 * backport useState optim
1 parent add8c52 commit aa8d57b

File tree

9 files changed

+335
-53
lines changed

9 files changed

+335
-53
lines changed

compat/src/index.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,39 @@ export function useTransition() {
137137
// styles/... before it attaches
138138
export const useInsertionEffect = useLayoutEffect;
139139

140+
/**
141+
* This is taken from https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L84
142+
* on a high level this cuts out the warnings, ... and attempts a smaller implementation
143+
*/
140144
export function useSyncExternalStore(subscribe, getSnapshot) {
141-
const [state, setState] = useState(getSnapshot);
145+
const value = getSnapshot();
146+
147+
const [{ _instance }, forceUpdate] = useState({
148+
_instance: { _value: value, _getSnapshot: getSnapshot }
149+
});
142150

143-
// TODO: in suspense for data we could have a discrepancy here because Preact won't re-init the "useState"
144-
// when this unsuspends which could lead to stale state as the subscription is torn down.
151+
useLayoutEffect(() => {
152+
_instance._value = value;
153+
_instance._getSnapshot = getSnapshot;
154+
155+
if (_instance._value !== getSnapshot()) {
156+
forceUpdate({ _instance });
157+
}
158+
}, [subscribe, value, getSnapshot]);
145159

146160
useEffect(() => {
161+
if (_instance._value !== _instance._getSnapshot()) {
162+
forceUpdate({ _instance });
163+
}
164+
147165
return subscribe(() => {
148-
setState(getSnapshot());
166+
if (_instance._value !== _instance._getSnapshot()) {
167+
forceUpdate({ _instance });
168+
}
149169
});
150-
}, [subscribe, getSnapshot]);
170+
}, [subscribe]);
151171

152-
return state;
172+
return value;
153173
}
154174

155175
export * from 'preact/hooks';

compat/src/render.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { IS_NON_DIMENSIONAL } from './util';
1010

1111
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');
1212

13-
const CAMEL_PROPS = /^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|shape|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/;
13+
const CAMEL_PROPS = /^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image|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]/;
1414
const IS_DOM = typeof document !== 'undefined';
1515

1616
// type="file|checkbox|radio".
@@ -161,7 +161,7 @@ options.vnode = vnode => {
161161
} else if (/^on(Ani|Tra|Tou|BeforeInp|Compo)/.test(i)) {
162162
i = i.toLowerCase();
163163
} else if (nonCustomElement && CAMEL_PROPS.test(i)) {
164-
i = i.replace(/[A-Z0-9]/, '-$&').toLowerCase();
164+
i = i.replace(/[A-Z0-9]/g, '-$&').toLowerCase();
165165
} else if (value === null) {
166166
value = undefined;
167167
}

compat/test/browser/hooks.test.js

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import React, {
44
useInsertionEffect,
55
useSyncExternalStore,
66
useTransition,
7-
render
7+
render,
8+
useState,
9+
useCallback
810
} from 'preact/compat';
911
import { setupRerender, act } from 'preact/test-utils';
1012
import { setupScratch, teardown } from '../../../test/_util/helpers';
@@ -91,7 +93,7 @@ describe('React-18-hooks', () => {
9193
});
9294
expect(scratch.innerHTML).to.equal('<p>hello world</p>');
9395
expect(subscribe).to.be.calledOnce;
94-
expect(getSnapshot).to.be.calledOnce;
96+
expect(getSnapshot).to.be.calledThrice;
9597
});
9698

9799
it('subscribes and rerenders when called', () => {
@@ -119,13 +121,100 @@ describe('React-18-hooks', () => {
119121
});
120122
expect(scratch.innerHTML).to.equal('<p>hello world</p>');
121123
expect(subscribe).to.be.calledOnce;
122-
expect(getSnapshot).to.be.calledOnce;
124+
expect(getSnapshot).to.be.calledThrice;
123125

124126
called = true;
125127
flush();
126128
rerender();
127129

128130
expect(scratch.innerHTML).to.equal('<p>hello new world</p>');
129131
});
132+
133+
it('should not call function values on subscription', () => {
134+
let flush;
135+
const subscribe = sinon.spy(cb => {
136+
flush = cb;
137+
return () => {};
138+
});
139+
140+
const func = () => 'value: ' + i++;
141+
142+
let i = 0;
143+
const getSnapshot = sinon.spy(() => {
144+
return func;
145+
});
146+
147+
const App = () => {
148+
const value = useSyncExternalStore(subscribe, getSnapshot);
149+
return <p>{value()}</p>;
150+
};
151+
152+
act(() => {
153+
render(<App />, scratch);
154+
});
155+
expect(scratch.innerHTML).to.equal('<p>value: 0</p>');
156+
expect(subscribe).to.be.calledOnce;
157+
expect(getSnapshot).to.be.calledThrice;
158+
159+
flush();
160+
rerender();
161+
162+
expect(scratch.innerHTML).to.equal('<p>value: 0</p>');
163+
});
164+
165+
it('should work with changing getSnapshot', () => {
166+
let flush;
167+
const subscribe = sinon.spy(cb => {
168+
flush = cb;
169+
return () => {};
170+
});
171+
172+
let i = 0;
173+
const App = () => {
174+
const value = useSyncExternalStore(subscribe, () => {
175+
return i;
176+
});
177+
return <p>value: {value}</p>;
178+
};
179+
180+
act(() => {
181+
render(<App />, scratch);
182+
});
183+
expect(scratch.innerHTML).to.equal('<p>value: 0</p>');
184+
expect(subscribe).to.be.calledOnce;
185+
186+
i++;
187+
flush();
188+
rerender();
189+
190+
expect(scratch.innerHTML).to.equal('<p>value: 1</p>');
191+
});
192+
193+
it('works with useCallback', () => {
194+
let toggle;
195+
const App = () => {
196+
const [state, setState] = useState(true);
197+
toggle = setState.bind(this, () => false);
198+
199+
const value = useSyncExternalStore(
200+
useCallback(() => {
201+
return () => {};
202+
}, [state]),
203+
() => (state ? 'yep' : 'nope')
204+
);
205+
206+
return <p>{value}</p>;
207+
};
208+
209+
act(() => {
210+
render(<App />, scratch);
211+
});
212+
expect(scratch.innerHTML).to.equal('<p>yep</p>');
213+
214+
toggle();
215+
rerender();
216+
217+
expect(scratch.innerHTML).to.equal('<p>nope</p>');
218+
});
130219
});
131220
});

compat/test/browser/svg.test.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,22 @@ describe('svg', () => {
7373
clipPath="value"
7474
clipRule="value"
7575
clipPathUnits="value"
76+
colorInterpolationFilters="auto"
77+
fontSizeAdjust="value"
7678
glyphOrientationHorizontal="value"
79+
glyphOrientationVertical="value"
7780
shapeRendering="crispEdges"
7881
glyphRef="value"
82+
horizAdvX="value"
83+
horizOriginX="value"
7984
markerStart="value"
8085
markerHeight="value"
8186
markerUnits="value"
8287
markerWidth="value"
88+
unitsPerEm="value"
89+
vertAdvY="value"
90+
vertOriginX="value"
91+
vertOriginY="value"
8392
x1="value"
8493
xChannelSelector="value"
8594
/>,
@@ -88,7 +97,7 @@ describe('svg', () => {
8897

8998
expect(serializeHtml(scratch)).to.eql(
9099
sortAttributes(
91-
'<svg clip-path="value" clip-rule="value" clipPathUnits="value" glyph-orientationhorizontal="value" shape-rendering="crispEdges" glyphRef="value" marker-start="value" markerHeight="value" markerUnits="value" markerWidth="value" x1="value" xChannelSelector="value"></svg>'
100+
'<svg clip-path="value" clip-rule="value" clipPathUnits="value" color-interpolation-filters="auto" font-size-adjust="value" glyph-orientation-horizontal="value" glyph-orientation-vertical="value" shape-rendering="crispEdges" glyphRef="value" horiz-adv-x="value" horiz-origin-x="value" marker-start="value" markerHeight="value" markerUnits="value" markerWidth="value" units-per-em="value" vert-adv-y="value" vert-origin-x="value" vert-origin-y="value" x1="value" xChannelSelector="value"></svg>'
92101
)
93102
);
94103
});

follow-ups.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ PR's that weren't backported yet, do they work?
2121
- https://github.com/preactjs/preact/pull/3280 Not merged yet need some input
2222
- https://github.com/preactjs/preact/pull/3222 Same as above
2323
- Make this work https://github.com/preactjs/preact/pull/3306
24+
- https://github.com/preactjs/preact/pull/3696
2425

2526
## Root node follow ups
2627

hooks/src/index.js

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ let previousInternal;
1414
/** @type {number} */
1515
let currentHook = 0;
1616

17-
/** @type {Array<import('./internal').Component>} */
17+
/** @type {Array<import('./internal').Internal>} */
1818
let afterPaintEffects = [];
1919

2020
let EMPTY = [];
@@ -180,16 +180,49 @@ export function useReducer(reducer, initialState, init) {
180180
];
181181

182182
hookState._internal = currentInternal;
183-
currentInternal._component.shouldComponentUpdate = () => {
184-
if (!hookState._nextValue) return true;
185-
186-
const currentValue = hookState._value[0];
187-
hookState._value = hookState._nextValue;
188-
hookState._nextValue = undefined;
189-
190-
return currentValue !== hookState._value[0];
191-
};
183+
if (!currentInternal.data._hasScuFromHooks) {
184+
currentInternal.data._hasScuFromHooks = true;
185+
const prevScu = currentInternal._component.shouldComponentUpdate;
186+
187+
// This SCU has the purpose of bailing out after repeated updates
188+
// to stateful hooks.
189+
// we store the next value in _nextValue[0] and keep doing that for all
190+
// state setters, if we have next states and
191+
// all next states within a component end up being equal to their original state
192+
// we are safe to bail out for this specific component.
193+
currentInternal._component.shouldComponentUpdate = function(p, s, c) {
194+
if (!hookState._internal.data.__hooks) return true;
195+
196+
const stateHooks = hookState._internal.data.__hooks._list.filter(
197+
x => x._internal
198+
);
199+
const allHooksEmpty = stateHooks.every(x => !x._nextValue);
200+
// When we have no updated hooks in the component we invoke the previous SCU or
201+
// traverse the VDOM tree further.
202+
if (allHooksEmpty) {
203+
return prevScu ? prevScu.call(this, p, s, c) : true;
204+
}
192205

206+
// We check whether we have components with a nextValue set that
207+
// have values that aren't equal to one another this pushes
208+
// us to update further down the tree
209+
let shouldUpdate = false;
210+
stateHooks.forEach(hookItem => {
211+
if (hookItem._nextValue) {
212+
const currentValue = hookItem._value[0];
213+
hookItem._value = hookItem._nextValue;
214+
hookItem._nextValue = undefined;
215+
if (currentValue !== hookItem._value[0]) shouldUpdate = true;
216+
}
217+
});
218+
219+
return shouldUpdate
220+
? prevScu
221+
? prevScu.call(this, p, s, c)
222+
: true
223+
: false;
224+
};
225+
}
193226
}
194227

195228
return hookState._nextValue || hookState._value;
@@ -359,6 +392,7 @@ export function useErrorBoundary(cb) {
359392
function flushAfterPaintEffects() {
360393
let internal;
361394
while ((internal = afterPaintEffects.shift())) {
395+
if (!internal.data.__hooks) continue;
362396
if (~internal.flags & MODE_UNMOUNTING) {
363397
try {
364398
internal.data.__hooks._pendingEffects.forEach(invokeCleanup);

0 commit comments

Comments
 (0)