From e8ef497a1d03e7764ed3b4870bceae4b38daf87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 27 Nov 2025 17:27:21 +0100 Subject: [PATCH 1/3] fix: fix endless loop by using stable `use()` API for suspending --- package.json | 8 ++++---- src/index.tsx | 29 ++++++++++++++++++++++++----- yarn.lock | 35 ++++++++++++++++++----------------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index d664da7..d8190e7 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,13 @@ "freeze" ], "peerDependencies": { - "react": ">=17.0.0" + "react": ">=19.0.0" }, "devDependencies": { "@types/jest": "^25.1.4", "@types/node": "^12.12.38", - "@types/react": "^18.0.15", - "@types/react-test-renderer": "^18.0.0", + "@types/react": "^19.0.0", + "@types/react-test-renderer": "^19.0.0", "@typescript-eslint/eslint-plugin": "^5.3.0", "@typescript-eslint/parser": "^5.3.0", "babel-eslint": "^10.0.3", @@ -51,7 +51,7 @@ "microbundle-crl": "^0.13.10", "npm-run-all": "^4.1.5", "prettier": "^2.0.4", - "react": "^18.2.0", + "react": "^19.2.0", "react-scripts": "^3.4.1", "react-test-renderer": "^18.2.0", "typescript": "^3.7.5" diff --git a/src/index.tsx b/src/index.tsx index b6a2693..9c18c04 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,4 @@ -import React, { Suspense, Fragment } from "react"; - -const infiniteThenable = { then() {} }; +import React, { Suspense, Fragment, use } from "react"; interface SuspenderProps { freeze: boolean; @@ -8,9 +6,30 @@ interface SuspenderProps { } function Suspender({ freeze, children }: SuspenderProps) { - if (freeze) { - throw infiniteThenable; + const resolverRef = React.useRef<(() => void) | undefined>(undefined); + const promiseRef = React.useRef | null>(null); + + if (promiseRef.current === null && freeze) { + promiseRef.current = new Promise((resolve) => { + resolverRef.current = resolve; + }); } + + if (!freeze && resolverRef.current != null) { + resolverRef.current(); + } + + if (promiseRef.current !== null) { + use(promiseRef.current); + } + + if (!freeze) { + // Only reset promise here, as when un-freezing we want to "ping the attached listeners" that the promise is resolved. + // Thats why we call the resolver above when !freeze. + promiseRef.current = null; + resolverRef.current = undefined; + } + return {children}; } diff --git a/yarn.lock b/yarn.lock index a875adf..2dae09b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2734,10 +2734,10 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ== -"@types/react-test-renderer@^18.0.0": - version "18.0.0" - resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz#7b7f69ca98821ea5501b21ba24ea7b6139da2243" - integrity sha512-C7/5FBJ3g3sqUahguGi03O79b8afNeSD6T8/GU50oQrJCU0bVCCGQHaGKUbg2Ce8VQEEqTw8/HiS6lXHHdgkdQ== +"@types/react-test-renderer@^19.0.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== dependencies: "@types/react" "*" @@ -2750,14 +2750,12 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/react@^18.0.15": - version "18.0.15" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" - integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== +"@types/react@^19.0.0": + version "19.2.7" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f" + integrity sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg== dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" + csstype "^3.2.2" "@types/resolve@0.0.8": version "0.0.8" @@ -4957,6 +4955,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b" integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw== +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -10965,12 +10968,10 @@ react-test-renderer@^18.2.0: react-shallow-renderer "^16.15.0" scheduler "^0.23.0" -react@^18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" +react@^19.2.0: + version "19.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" + integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== read-pkg-up@^2.0.0: version "2.0.0" From 9d0a3aa24da69c105dbd910b7938414639d1808e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 27 Nov 2025 17:42:12 +0100 Subject: [PATCH 2/3] comments + using null everywhere --- src/index.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9c18c04..08d12bf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,9 +6,10 @@ interface SuspenderProps { } function Suspender({ freeze, children }: SuspenderProps) { - const resolverRef = React.useRef<(() => void) | undefined>(undefined); - const promiseRef = React.useRef | null>(null); - + // Create a stable promise which we can later use to suspend the component. + const promiseRef = React.useRef>(null); + // Ref to store the promise's resolver function, to be called when un-freezing. + const resolverRef = React.useRef<() => void>(null); if (promiseRef.current === null && freeze) { promiseRef.current = new Promise((resolve) => { resolverRef.current = resolve; @@ -16,10 +17,12 @@ function Suspender({ freeze, children }: SuspenderProps) { } if (!freeze && resolverRef.current != null) { + // Un-freeze: call the resolver to resolve the promise and un-suspend. resolverRef.current(); } if (promiseRef.current !== null) { + // Suspend by using the promise, or when promise resolved it un-suspends. use(promiseRef.current); } @@ -27,7 +30,7 @@ function Suspender({ freeze, children }: SuspenderProps) { // Only reset promise here, as when un-freezing we want to "ping the attached listeners" that the promise is resolved. // Thats why we call the resolver above when !freeze. promiseRef.current = null; - resolverRef.current = undefined; + resolverRef.current = null; } return {children}; From 1f2f3007300e589e9911bf59283356c68e92b4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 27 Nov 2025 17:44:22 +0100 Subject: [PATCH 3/3] only ever call resolve once --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 08d12bf..519ba02 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ function Suspender({ freeze, children }: SuspenderProps) { if (!freeze && resolverRef.current != null) { // Un-freeze: call the resolver to resolve the promise and un-suspend. resolverRef.current(); + resolverRef.current = null; } if (promiseRef.current !== null) { @@ -30,7 +31,6 @@ function Suspender({ freeze, children }: SuspenderProps) { // Only reset promise here, as when un-freezing we want to "ping the attached listeners" that the promise is resolved. // Thats why we call the resolver above when !freeze. promiseRef.current = null; - resolverRef.current = null; } return {children};