From 0510f02cee6b13f0e80427c0e2cf6ab51fe972cc Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 19 Jan 2026 14:35:52 +0100 Subject: [PATCH 1/2] Fix suspense crash --- compat/src/suspense.js | 1 + compat/test/browser/suspense.test.js | 123 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/compat/src/suspense.js b/compat/src/suspense.js index 6d5333a3b7..d6fe20aa97 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -236,6 +236,7 @@ Suspense.prototype.render = function (props, state) { * @returns {((unsuspend: () => void) => void)?} */ export function suspended(vnode) { + if (!vnode._parent) return null; /** @type {import('./internal').Component} */ let component = vnode._parent._component; return component && component._suspended && component._suspended(vnode); diff --git a/compat/test/browser/suspense.test.js b/compat/test/browser/suspense.test.js index 2998d80f30..0740084d55 100644 --- a/compat/test/browser/suspense.test.js +++ b/compat/test/browser/suspense.test.js @@ -2188,6 +2188,129 @@ describe('suspense', () => { }); }); + it('should not crash when suspended child updates after unmount', () => { + let childInstance = null; + const neverResolvingPromise = new Promise(() => {}); + + class ThrowingChild extends Component { + constructor(props) { + super(props); + this.state = { suspend: false, value: 0 }; + childInstance = this; + } + + render(props, state) { + if (state.suspend) { + throw neverResolvingPromise; + } + return
value:{state.value}
; + } + } + + render( + Suspended...}> + + , + scratch + ); + + expect(scratch.innerHTML).to.equal('
value:0
'); + + childInstance.setState({ suspend: true }); + rerender(); + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + render(null, scratch); + expect(scratch.innerHTML).to.equal(''); + + childInstance.setState({ value: 1 }); + rerender(); + + expect(scratch.innerHTML).to.equal(''); + }); + + it('should not crash when suspense promise resolves after unmount', () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + + class ThrowingChild extends Component { + render() { + throw promise; + } + } + + render( + Suspended...}> + + , + scratch + ); + rerender(); + + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + render(null, scratch); + expect(scratch.innerHTML).to.equal(''); + + resolve(); + + return promise.then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); + }); + + it('should not crash when useContext is used in a suspending component', () => { + const TestContext = createContext('default'); + let resolve; + let shouldSuspend = false; + const promise = new Promise(r => { + resolve = r; + }); + + function ContextUser() { + const value = React.useContext(TestContext); + if (shouldSuspend) { + throw promise; + } + return
Context: {value}
; + } + + render( + + Suspended...}> + + + , + scratch + ); + + expect(scratch.innerHTML).to.equal('
Context: test-value
'); + + shouldSuspend = true; + render( + + Suspended...}> + + + , + scratch + ); + rerender(); + + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + shouldSuspend = false; + resolve(); + + return promise.then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('
Context: test-value
'); + }); + }); + it('should not crash if fallback has same DOM as suspended nodes', () => { const [Lazy, resolveLazy] = createLazy(); From c4514f6ef8cb8086489eb71e3d19cb3728931448 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Mon, 19 Jan 2026 15:53:41 +0100 Subject: [PATCH 2/2] Prevent unmounted suspended vnodes from rendering Before this when we unmounted a vnode which was in a suspended state it was possible to cause this to render while being unmounted. One disturbing thing is that I wanted to check vnode._component._onResolve in the options.unmount hook, this however was not possible because somehow the reference to `_component` isn't being updated on the vnode that's actually unmounting. For this reason I opted to use a separate flag. --- compat/src/suspense.js | 3 +- compat/test/browser/suspense.test.js | 154 +++++++++++++++++++++++++++ mangle.json | 1 + 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/compat/src/suspense.js b/compat/src/suspense.js index d6fe20aa97..8a55b0971e 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -27,6 +27,7 @@ const oldUnmount = options.unmount; options.unmount = function (vnode) { /** @type {import('./internal').Component} */ const component = vnode._component; + if (component) component._unmounted = true; if (component && component._onResolve) { component._onResolve(); } @@ -129,7 +130,7 @@ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { let resolved = false; const onResolved = () => { - if (resolved) return; + if (resolved || c._unmounted) return; resolved = true; suspendingComponent._onResolve = null; diff --git a/compat/test/browser/suspense.test.js b/compat/test/browser/suspense.test.js index 0740084d55..9eaaf326fb 100644 --- a/compat/test/browser/suspense.test.js +++ b/compat/test/browser/suspense.test.js @@ -14,6 +14,7 @@ import React, { } from 'preact/compat'; import { setupScratch, teardown } from '../../../test/_util/helpers'; import { createLazy, createSuspender } from './suspense-utils'; +import { expect } from 'chai'; const h = React.createElement; /* eslint-env browser, mocha */ @@ -2229,6 +2230,159 @@ describe('suspense', () => { expect(scratch.innerHTML).to.equal(''); }); + it('should not crash when suspended child updates after diffed unmount', () => { + let childInstance = null; + const neverResolvingPromise = new Promise(() => {}); + + class ThrowingChild extends Component { + constructor(props) { + super(props); + this.state = { suspend: false, value: 0 }; + childInstance = this; + } + + render(props, state) { + if (state.suspend) { + throw neverResolvingPromise; + } + return
value:{state.value}
; + } + } + + const HelloWorld = () =>

Hello world

; + + let set; + const App = () => { + const [show, setShow] = useState(true); + set = setShow; + return show ? ( + Suspended...}> + + + ) : ( + + ); + }; + + render(, scratch); + + expect(scratch.innerHTML).to.equal('
value:0
'); + + childInstance.setState({ suspend: true }); + rerender(); + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + set(false); + rerender(); + expect(scratch.innerHTML).to.equal('

Hello world

'); + + childInstance.setState({ value: 1 }); + rerender(); + + expect(scratch.innerHTML).to.equal('

Hello world

'); + }); + + it('should not crash when suspended child resolves after unmount', async () => { + let childInstance = null, + res; + const neverResolvingPromise = new Promise(r => { + res = r; + }); + + class ThrowingChild extends Component { + constructor(props) { + super(props); + this.state = { suspend: false, value: 0 }; + childInstance = this; + } + + render(props, state) { + if (state.suspend) { + throw neverResolvingPromise; + } + return
value:{state.value}
; + } + } + + render( + Suspended...}> + + , + scratch + ); + + expect(scratch.innerHTML).to.equal('
value:0
'); + + childInstance.setState({ suspend: true }); + rerender(); + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + render(null, scratch); + expect(scratch.innerHTML).to.equal(''); + + res(); + return neverResolvingPromise.then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); + }); + + it('should not crash when suspended child resolves after diffed unmount', async () => { + let childInstance = null, + res; + const neverResolvingPromise = new Promise(r => { + res = r; + }); + + class ThrowingChild extends Component { + constructor(props) { + super(props); + this.state = { suspend: false, value: 0 }; + childInstance = this; + } + + render(props, state) { + if (state.suspend) { + throw neverResolvingPromise; + } + return
value:{state.value}
; + } + } + + const HelloWorld = () =>

Hello world

; + + let set; + const App = () => { + const [show, setShow] = useState(true); + set = setShow; + return show ? ( + Suspended...}> + + + ) : ( + + ); + }; + + render(, scratch); + + expect(scratch.innerHTML).to.equal('
value:0
'); + + childInstance.setState({ suspend: true }); + rerender(); + expect(scratch.innerHTML).to.equal('
Suspended...
'); + + set(false); + rerender(); + expect(scratch.innerHTML).to.equal('

Hello world

'); + + res(); + return neverResolvingPromise.then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('

Hello world

'); + }); + }); + it('should not crash when suspense promise resolves after unmount', () => { let resolve; const promise = new Promise(r => { diff --git a/mangle.json b/mangle.json index 2d15afef53..0de7512031 100644 --- a/mangle.json +++ b/mangle.json @@ -49,6 +49,7 @@ "$_children": "__k", "$_pendingSuspensionCount": "__u", "$_childDidSuspend": "__c", + "$_unmounted": "__z", "$_onResolve": "__R", "$_suspended": "__a", "$_dom": "__e",