diff --git a/packages/shared-base-ui/src/hooks/useValueRef.ts b/packages/shared-base-ui/src/hooks/useValueRef.ts index fa722c806bf..3373fa47fae 100644 --- a/packages/shared-base-ui/src/hooks/useValueRef.ts +++ b/packages/shared-base-ui/src/hooks/useValueRef.ts @@ -7,8 +7,12 @@ function getServerSnapshot(): never { } export function useValueRef(ref: ValueRef): T { if ('readyPromise' in ref) { - const ref2 = ref as ValueRefWithReady - if (!ref2.ready) use(ref2.readyPromise) + // Must NOT be conditional on `ref.ready`. When a component suspends here and is replayed + // after the promise resolves, React only switches from the update dispatcher back to the + // mount dispatcher inside `use`. Skipping `use` on replay (because `ready` flipped to true) + // makes the following hooks throw "Update hook called on initial render" (React #467). + // `use` on an already-resolved promise returns synchronously once React has tracked it. + use((ref as ValueRefWithReady).readyPromise) } return useSyncExternalStore( (f) => ref.addListener(f), diff --git a/packages/shared-base/src/helpers/ValueRef.ts b/packages/shared-base/src/helpers/ValueRef.ts index 13df0817a6a..593804dc8e1 100644 --- a/packages/shared-base/src/helpers/ValueRef.ts +++ b/packages/shared-base/src/helpers/ValueRef.ts @@ -36,7 +36,10 @@ export class ValueRefWithReady extends ValueRef { // this is unsafe. we assigned T | undefined to T super(value!, isEqual) const { promise, resolve } = Promise.withResolvers() - this.readyPromise = promise.then(() => this.value) + const readyPromise = promise.then(() => this.value) + // Follow React's thenable protocol so `use(readyPromise)` returns synchronously once ready. + readyPromise.then((value) => Object.assign(readyPromise, { status: 'fulfilled', value })) + this.readyPromise = readyPromise this.#nowReady = resolve }