Skip to content

Commit c3b4f0f

Browse files
committed
Fix hydration-suspense crash due to sCU bailout
Assisted-By: devx/f79ddc2d-9f3b-44d1-85dc-4cb2c75c5ad5
1 parent 65b32cb commit c3b4f0f

4 files changed

Lines changed: 109 additions & 12 deletions

File tree

compat/src/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@ export interface SuspenseComponent extends PreactComponent<
4848
_pendingSuspensionCount: number;
4949
_suspenders: Component[];
5050
_detachOnNextRender: null | VNode<any>;
51+
_mask?: [number, number];
5152
}

compat/src/suspense.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,12 @@ Suspense.prototype.componentWillUnmount = function () {
199199
*/
200200
Suspense.prototype.render = function (props, state) {
201201
let vnode = this._vnode;
202-
if (!vnode._mask) {
202+
if (!vnode._mask && !(vnode._mask = this._mask)) {
203203
let root = vnode;
204204
while (root._parent) root = root._parent;
205205

206206
root = root._mask || (root._mask = [0, 0]);
207-
vnode._mask = [root[1]++, 0];
207+
vnode._mask = this._mask = [root[1]++, 0];
208208
}
209209

210210
if (this._detachOnNextRender) {

compat/test/browser/suspense-hydration.test.jsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,60 @@ describe('suspense hydration', () => {
266266
expect(ids[1]).to.equal('P1-0');
267267
});
268268

269+
it('keeps Suspense useId masks stable across updates while hydration is suspended', async () => {
270+
let shouldSuspend = false;
271+
let resolvePromise;
272+
let suspendPromise;
273+
let update;
274+
let renderedIds = [];
275+
276+
function Field() {
277+
const id = useId();
278+
renderedIds.push(id);
279+
return <form id={id}>Pay</form>;
280+
}
281+
282+
function MaybeSuspend() {
283+
if (shouldSuspend) {
284+
throw suspendPromise;
285+
}
286+
return <Field />;
287+
}
288+
289+
function App() {
290+
const [tick, setTick] = useState(0);
291+
update = () => setTick(tick + 1);
292+
return (
293+
<Suspense fallback={null}>
294+
<MaybeSuspend tick={tick} />
295+
</Suspense>
296+
);
297+
}
298+
299+
const html = renderToString(<App />);
300+
const serverId = /id="([^"]+)"/.exec(html)[1];
301+
scratch.innerHTML = html;
302+
303+
renderedIds = [];
304+
shouldSuspend = true;
305+
suspendPromise = new Promise(resolve => {
306+
resolvePromise = resolve;
307+
});
308+
hydrate(<App />, scratch);
309+
rerender();
310+
311+
update();
312+
rerender();
313+
314+
shouldSuspend = false;
315+
resolvePromise();
316+
await Promise.resolve();
317+
await new Promise(resolve => setTimeout(resolve));
318+
rerender();
319+
320+
expect(renderedIds).to.deep.equal([serverId]);
321+
});
322+
269323
it('should leave DOM untouched when suspending while hydrating', () => {
270324
scratch.innerHTML = '<div>Hello</div>';
271325
clearLog();
@@ -348,6 +402,49 @@ describe('suspense hydration', () => {
348402
});
349403
});
350404

405+
it('does not crash when a hydrated suspended component bails out with shouldComponentUpdate', () => {
406+
scratch.innerHTML = '<div>ssr</div>';
407+
clearLog();
408+
409+
const promise = new Promise(() => {});
410+
let update;
411+
412+
class Suspender extends React.Component {
413+
shouldComponentUpdate() {
414+
return false;
415+
}
416+
417+
render() {
418+
throw promise;
419+
}
420+
}
421+
422+
class App extends React.Component {
423+
constructor(props) {
424+
super(props);
425+
this.state = { tick: 0 };
426+
update = () => this.setState({ tick: this.state.tick + 1 });
427+
}
428+
429+
render() {
430+
return (
431+
<Suspense fallback={<div>loading</div>}>
432+
<Suspender tick={this.state.tick} />
433+
</Suspense>
434+
);
435+
}
436+
}
437+
438+
hydrate(<App />, scratch);
439+
expect(scratch.innerHTML).to.equal('<div>ssr</div>');
440+
441+
update();
442+
expect(() => rerender()).not.to.throw();
443+
expect(scratch.innerHTML).to.equal('<div>ssr</div>');
444+
expect(getLog()).to.deep.equal([]);
445+
clearLog();
446+
});
447+
351448
it('should leave DOM untouched when suspending while hydrating', () => {
352449
scratch.innerHTML = '<!-- test --><div>Hello</div>';
353450
clearLog();

src/diff/index.js

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -303,21 +303,20 @@ export function diff(
303303
excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL;
304304
}
305305
newVNode._dom = oldDom;
306-
} else {
307-
if (excessDomChildren != NULL) {
308-
for (let i = excessDomChildren.length; i--; ) {
309-
removeNode(excessDomChildren[i]);
310-
}
306+
} else if (excessDomChildren != NULL) {
307+
for (let i = excessDomChildren.length; i--; ) {
308+
removeNode(excessDomChildren[i]);
311309
}
312-
markAsForce(newVNode);
313310
}
314311
} else {
315312
newVNode._dom = oldVNode._dom;
316-
if (!newVNode._children && oldVNode._children) {
317-
newVNode._children = oldVNode._children;
318-
}
319-
if (!e.then) markAsForce(newVNode);
320313
}
314+
315+
if (newVNode._children == NULL) {
316+
newVNode._children = oldVNode._children || [];
317+
}
318+
319+
if (!e.then) markAsForce(newVNode);
321320
options._catchError(e, newVNode, oldVNode);
322321
}
323322
} else if (

0 commit comments

Comments
 (0)