From 301dd931a84e5ec31e8942c0ee993533210d12f4 Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Sun, 9 Aug 2026 23:37:34 +0100 Subject: [PATCH] Backport useSyncExternalStore --- .../react-debug-tools/src/ReactDebugHooks.lua | 27 + .../ReactHooksInspectionIntegration.spec.lua | 34 + .../react-reconciler/src/ReactFiberFlags.lua | 9 +- .../src/ReactFiberHooks.new.lua | 279 ++++++- .../src/ReactFiberWorkLoop.new.lua | 151 +++- .../src/ReactInternalTypes.lua | 1 + .../__tests__/useSyncExternalStore.spec.lua | 748 ++++++++++++++++++ modules/react/src/React.lua | 1 + modules/react/src/ReactHooks.lua | 10 + .../ReactCurrentDispatcher.lua | 5 + 10 files changed, 1245 insertions(+), 20 deletions(-) create mode 100644 modules/react-reconciler/src/__tests__/useSyncExternalStore.spec.lua diff --git a/modules/react-debug-tools/src/ReactDebugHooks.lua b/modules/react-debug-tools/src/ReactDebugHooks.lua index 9d0642a0..e895f7ec 100644 --- a/modules/react-debug-tools/src/ReactDebugHooks.lua +++ b/modules/react-debug-tools/src/ReactDebugHooks.lua @@ -173,6 +173,14 @@ local function getPrimitiveStackCache(): Map> -- Dispatcher:useMemo(function() Dispatcher.useDebugValue(nil) Dispatcher.useCallback(function() end) + -- ROBLOX upstream: https://github.com/facebook/react/pull/28399 + Dispatcher.useSyncExternalStore(function() + return function() end + end, function() + return nil + end, function() + return nil + end) Dispatcher.useMemo(function() -- ROBLOX deviation END return nil @@ -444,6 +452,24 @@ local function useMutableSource( ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] return value end +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/ReactDebugHooks.js#L276-L294 +local function useSyncExternalStore( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? +): T + -- useSyncExternalStore() composes multiple hooks internally. + -- Advance the current hook index the same number of times + -- so that subsequent hooks have the right memoized state. + nextHook() -- SyncExternalStore + nextHook() -- Effect + local value = getSnapshot() + table.insert( + hookLog, + { primitive = "SyncExternalStore", stackError = Error.new(), value = value } + ) + return value +end -- ROBLOX deviation START: enable these once they are fully enabled in the Dispatcher type and in ReactFiberHooks' myriad dispatchers -- local function useTransition( -- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [(() => void) => void, boolean] ]] @@ -535,6 +561,7 @@ Dispatcher = { -- useTransition = useTransition, -- ROBLOX deviation END useMutableSource = useMutableSource, + useSyncExternalStore = useSyncExternalStore, -- ROBLOX deviation START: not implemented -- useDeferredValue = useDeferredValue, -- ROBLOX deviation END diff --git a/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua index 360a95b3..f9500d0e 100644 --- a/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua +++ b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua @@ -1264,6 +1264,40 @@ describe("ReactHooksInspectionIntegration", function() }, }) end) + it("should support composite useSyncExternalStore hook", function() + local useSyncExternalStore = React.useSyncExternalStore + local function Foo() + local value = useSyncExternalStore(function() + return function() end + end, function() + return "snapshot" + end) + React.useMemo(function() + return "memo" + end, {}) + return value + end + + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + expect(tree).toEqual({ + { + id = 1, + isStateEditable = false, + name = "SyncExternalStore", + value = "snapshot", + subHooks = {}, + }, + { + id = 2, + isStateEditable = false, + name = "Memo", + value = "memo", + subHooks = {}, + }, + }) + end) -- ROBLOX deviation START: no experimental features -- if Boolean.toJSBoolean(__EXPERIMENTAL__) then -- it("should support composite useMutableSource hook", function() diff --git a/modules/react-reconciler/src/ReactFiberFlags.lua b/modules/react-reconciler/src/ReactFiberFlags.lua index e51c43aa..4c0bf91d 100644 --- a/modules/react-reconciler/src/ReactFiberFlags.lua +++ b/modules/react-reconciler/src/ReactFiberFlags.lua @@ -47,14 +47,17 @@ exports.Hydrating = --[[ ]] 0b000000010000000000 exports.HydratingAndUpdate = --[[ ]] 0b000000010000000100 +-- ROBLOX deviation: appended to preserve the values of existing flags. +exports.StoreConsistency = --[[ ]] + 0b1000000000000000000 --- Passive & Update & Callback & Ref & Snapshot +-- Passive & Update & Callback & Ref & Snapshot & StoreConsistency exports.LifecycleEffectMask = --[[ ]] - 0b000000001110100100 + 0b1000000001110100100 -- Union of all host effects exports.HostEffectMask = --[[ ]] - 0b000000011111111111 + 0b1000000011111111111 -- These are not really side effects, but we still reuse this field. exports.Incomplete = --[[ ]] diff --git a/modules/react-reconciler/src/ReactFiberHooks.new.lua b/modules/react-reconciler/src/ReactFiberHooks.new.lua index 37269d3c..248cdf49 100644 --- a/modules/react-reconciler/src/ReactFiberHooks.new.lua +++ b/modules/react-reconciler/src/ReactFiberHooks.new.lua @@ -72,6 +72,8 @@ local enableDoubleInvokingEffects = ReactFeatureFlags.enableDoubleInvokingEffect local DebugTracingMode = require(script.Parent.ReactTypeOfMode).DebugTracingMode local NoLane = ReactFiberLane.NoLane local NoLanes = ReactFiberLane.NoLanes +local SyncLane = ReactFiberLane.SyncLane +local NoTimestamp = ReactFiberLane.NoTimestamp -- local InputContinuousLanePriority = ReactFiberLane.InputContinuousLanePriority local isSubsetOfLanes = ReactFiberLane.isSubsetOfLanes local mergeLanes = ReactFiberLane.mergeLanes @@ -88,6 +90,7 @@ local ReactFiberFlags = require(script.Parent.ReactFiberFlags) local UpdateEffect = ReactFiberFlags.Update local PassiveEffect = ReactFiberFlags.Passive local PassiveStaticEffect = ReactFiberFlags.PassiveStatic +local StoreConsistency = ReactFiberFlags.StoreConsistency local MountLayoutDevEffect = ReactFiberFlags.MountLayoutDev local MountPassiveDevEffect = ReactFiberFlags.MountPassiveDev local HookHasEffect = ReactHookEffectTags.HasEffect @@ -174,6 +177,7 @@ type UpdateQueue = { } local didWarnAboutMismatchedHooksForComponent +local didWarnUncachedGetSnapshot = false local _didWarnAboutUseOpaqueIdentifier if __DEV__ then _didWarnAboutUseOpaqueIdentifier = {} @@ -184,7 +188,7 @@ export type Hook = { memoizedState: any, baseState: any, baseQueue: Update | nil, - queue: UpdateQueue | nil, + queue: any, next: Hook?, } @@ -197,8 +201,19 @@ export type Effect = { next: Effect, } +type StoreInstance = { + value: T, + getSnapshot: () -> T, +} + +type StoreConsistencyCheck = { + value: T, + getSnapshot: () -> T, +} + export type FunctionComponentUpdateQueue = { lastEffect: Effect?, + stores: Array>?, } type BasicStateAction = ((S) -> S) | S @@ -622,6 +637,7 @@ end -- local function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue -- return { -- lastEffect = nil, +-- stores = nil, -- } -- end @@ -1228,6 +1244,7 @@ local function pushEffect(tag, create, destroy, deps) -- componentUpdateQueue = createFunctionComponentUpdateQueue() componentUpdateQueue = { lastEffect = nil, + stores = nil, } currentlyRenderingFiber.updateQueue = componentUpdateQueue effect.next = effect @@ -1362,6 +1379,196 @@ local function updateEffect( updateEffectImpl(PassiveEffect, HookPassive, create, deps) end +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L1263-L1500 +-- ROBLOX deviation: external store helpers follow effect helpers because Lua local functions are not hoisted. +local function forceStoreRerender(fiber: Fiber): () + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) +end + +local function checkIfSnapshotChanged(inst: StoreInstance): boolean + local latestGetSnapshot = inst.getSnapshot + local prevValue = inst.value + local ok, nextValue = pcall(latestGetSnapshot) + if not ok then + return true + end + return not is(prevValue, nextValue) +end + +local function subscribeToStore( + fiber: Fiber, + inst: StoreInstance, + subscribe: (() -> ()) -> () -> () +): () -> () + local handleStoreChange = function() + if checkIfSnapshotChanged(inst) then + forceStoreRerender(fiber) + end + end + return subscribe(handleStoreChange) +end + +local function updateStoreInstance( + fiber: Fiber, + inst: StoreInstance, + nextSnapshot: T, + getSnapshot: () -> T +): () + inst.value = nextSnapshot + inst.getSnapshot = getSnapshot + if checkIfSnapshotChanged(inst) then + forceStoreRerender(fiber) + end +end + +local function pushStoreConsistencyCheck( + fiber: Fiber, + getSnapshot: () -> T, + renderedSnapshot: T +): () + fiber.flags = bit32.bor(fiber.flags, StoreConsistency) + local check: StoreConsistencyCheck = { + getSnapshot = getSnapshot, + value = renderedSnapshot, + } + local componentUpdateQueue: FunctionComponentUpdateQueue? = + currentlyRenderingFiber.updateQueue :: any + if componentUpdateQueue == nil then + -- ROBLOX performance: inline simple function in hot path + -- componentUpdateQueue = createFunctionComponentUpdateQueue() + componentUpdateQueue = { + lastEffect = nil, + stores = { check }, + } + currentlyRenderingFiber.updateQueue = componentUpdateQueue + else + local stores = componentUpdateQueue.stores + if stores == nil then + componentUpdateQueue.stores = { check } + else + table.insert(stores, check) + end + end +end + +local function mountSyncExternalStore( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? +): T + local fiber = currentlyRenderingFiber + local hook = mountWorkInProgressHook() + local nextSnapshot: T + if getIsHydrating() then + if getServerSnapshot == nil then + error( + Error.new( + "Missing getServerSnapshot, which is required for " + .. "server-rendered content. Will revert to client rendering." + ) + ) + end + nextSnapshot = getServerSnapshot() + if __DEV__ and not didWarnUncachedGetSnapshot then + local cachedSnapshot = getServerSnapshot() + if not is(nextSnapshot, cachedSnapshot) then + console.error( + "The result of getServerSnapshot should be cached to avoid an infinite loop" + ) + didWarnUncachedGetSnapshot = true + end + end + else + nextSnapshot = getSnapshot() + if __DEV__ and not didWarnUncachedGetSnapshot then + local cachedSnapshot = getSnapshot() + if not is(nextSnapshot, cachedSnapshot) then + console.error( + "The result of getSnapshot should be cached to avoid an infinite loop" + ) + didWarnUncachedGetSnapshot = true + end + end + invariant( + getWorkInProgressRoot() ~= nil, + "Expected a work-in-progress root. This is a bug in React. Please file an issue." + ) + -- ROBLOX deviation: React 17 has no includesBlockingLane, so concurrent completion consumes all non-hydration checks. + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot) + end + hook.memoizedState = nextSnapshot + local inst: StoreInstance = { + value = nextSnapshot, + getSnapshot = getSnapshot, + } + hook.queue = inst + + mountEffect(function() + return subscribeToStore(fiber, inst, subscribe) + end, { subscribe } :: Array) + fiber.flags = bit32.bor(fiber.flags, PassiveEffect) + pushEffect(bit32.bor(HookHasEffect, HookPassive), function() + updateStoreInstance(fiber, inst, nextSnapshot, getSnapshot) + end, nil, nil) + return nextSnapshot +end + +local function updateSyncExternalStore( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? +): T + local fiber = currentlyRenderingFiber + local hook = updateWorkInProgressHook() + local nextSnapshot = getSnapshot() + if __DEV__ and not didWarnUncachedGetSnapshot then + local cachedSnapshot = getSnapshot() + if not is(nextSnapshot, cachedSnapshot) then + console.error( + "The result of getSnapshot should be cached to avoid an infinite loop" + ) + didWarnUncachedGetSnapshot = true + end + end + -- ROBLOX upstream: https://github.com/facebook/react/commit/e9aa33ecc3715ebeacf28d453c6f18244719b359 + local prevSnapshot = (currentHook or hook).memoizedState + local snapshotChanged = not is(prevSnapshot, nextSnapshot) + if snapshotChanged then + hook.memoizedState = nextSnapshot + markWorkInProgressReceivedUpdate() + end + local inst = hook.queue :: StoreInstance + updateEffect(function() + return subscribeToStore(fiber, inst, subscribe) + end, { subscribe } :: Array) + if + inst.getSnapshot ~= getSnapshot + or snapshotChanged + or ( + workInProgressHook ~= nil + and bit32.band( + (workInProgressHook.memoizedState :: Effect).tag, + HookHasEffect + ) + ~= 0 + ) + then + fiber.flags = bit32.bor(fiber.flags, PassiveEffect) + pushEffect(bit32.bor(HookHasEffect, HookPassive), function() + updateStoreInstance(fiber, inst, nextSnapshot, getSnapshot) + end, nil, nil) + if not getIsHydrating() then + invariant( + getWorkInProgressRoot() ~= nil, + "Expected a work-in-progress root. This is a bug in React. Please file an issue." + ) + pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot) + end + end + + return nextSnapshot +end + local function mountLayoutEffect( -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically create: (() -> ()) | (() -> () -> ()), @@ -1943,6 +2150,7 @@ local ContextOnlyDispatcher: Dispatcher = { -- useDeferredValue = throwInvalidHookError, -- useTransition = throwInvalidHookError, useMutableSource = throwInvalidHookError :: any, + useSyncExternalStore = throwInvalidHookError :: any, useOpaqueIdentifier = throwInvalidHookError :: any, unstable_isNewReconciler = enableNewReconciler, @@ -1967,6 +2175,7 @@ local HooksDispatcherOnMount: Dispatcher = { -- useDeferredValue = mountDeferredValue, -- useTransition = mountTransition, useMutableSource = mountMutableSource, + useSyncExternalStore = mountSyncExternalStore, useOpaqueIdentifier = mountOpaqueIdentifier, unstable_isNewReconciler = enableNewReconciler, @@ -1990,6 +2199,7 @@ local HooksDispatcherOnUpdate: Dispatcher = { -- useDeferredValue = updateDeferredValue, -- useTransition = updateTransition, useMutableSource = updateMutableSource, + useSyncExternalStore = updateSyncExternalStore, useOpaqueIdentifier = updateOpaqueIdentifier, unstable_isNewReconciler = enableNewReconciler, @@ -2013,6 +2223,7 @@ local HooksDispatcherOnRerender: Dispatcher = { -- useDeferredValue = rerenderDeferredValue, -- useTransition = rerenderTransition, useMutableSource = updateMutableSource, + useSyncExternalStore = updateSyncExternalStore, useOpaqueIdentifier = rerenderOpaqueIdentifier, unstable_isNewReconciler = enableNewReconciler, @@ -2182,6 +2393,15 @@ if __DEV__ then mountHookTypesDev() return mountMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + mountHookTypesDev() + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function() currentHookNameInDev = "useOpaqueIdentifier" mountHookTypesDev() @@ -2331,6 +2551,15 @@ if __DEV__ then updateHookTypesDev() return mountMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + updateHookTypesDev() + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function() currentHookNameInDev = "useOpaqueIdentifier" updateHookTypesDev() @@ -2479,6 +2708,15 @@ if __DEV__ then updateHookTypesDev() return updateMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + updateHookTypesDev() + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function(): OpaqueIDType currentHookNameInDev = "useOpaqueIdentifier" updateHookTypesDev() @@ -2628,6 +2866,15 @@ if __DEV__ then updateHookTypesDev() return updateMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + updateHookTypesDev() + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function(): OpaqueIDType currentHookNameInDev = "useOpaqueIdentifier" updateHookTypesDev() @@ -2791,6 +3038,16 @@ if __DEV__ then mountHookTypesDev() return mountMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + warnInvalidHookAccess() + mountHookTypesDev() + return mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function(): OpaqueIDType currentHookNameInDev = "useOpaqueIdentifier" warnInvalidHookAccess() @@ -2956,6 +3213,16 @@ if __DEV__ then updateHookTypesDev() return updateMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + warnInvalidHookAccess() + updateHookTypesDev() + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function(): OpaqueIDType currentHookNameInDev = "useOpaqueIdentifier" warnInvalidHookAccess() @@ -3121,6 +3388,16 @@ if __DEV__ then updateHookTypesDev() return updateMutableSource(source, getSnapshot, subscribe) end, + useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ): T + currentHookNameInDev = "useSyncExternalStore" + warnInvalidHookAccess() + updateHookTypesDev() + return updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + end, useOpaqueIdentifier = function(): OpaqueIDType currentHookNameInDev = "useOpaqueIdentifier" warnInvalidHookAccess() diff --git a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua index e1a1429a..49679a06 100644 --- a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua +++ b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua @@ -31,6 +31,14 @@ local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber type FiberRoot = ReactInternalTypes.FiberRoot type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel +-- ROBLOX deviation: duplicate these structural types to avoid a runtime require cycle. +type StoreConsistencyCheck = { + getSnapshot: () -> any, + value: any, +} +type FunctionComponentUpdateQueue = { + stores: Array?, +} local ReactFiberLane = require(script.Parent.ReactFiberLane) type Lanes = ReactFiberLane.Lanes type Lane = ReactFiberLane.Lane @@ -671,10 +679,14 @@ exports.scheduleUpdateOnFiber = function( -- priority as an argument to that function and this one. local priorityLevel = getCurrentPriorityLevel() + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L615-L628 + -- ROBLOX deviation: React 17 has separate legacy-unbatched and ordinary SyncLane flush paths. if lane == SyncLane then if + bit32.band(fiber.mode, ReactTypeOfMode.ConcurrentMode) + == ReactTypeOfMode.NoMode -- Check if we're inside unbatchedUpdates - bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext + and bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext -- Check if we're not already rendering and bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext @@ -689,7 +701,11 @@ exports.scheduleUpdateOnFiber = function( else ensureRootIsScheduled(root, eventTime) mod.schedulePendingInteractions(root, lane) - if executionContext == NoContext then + if + executionContext == NoContext + and bit32.band(fiber.mode, ReactTypeOfMode.ConcurrentMode) + == ReactTypeOfMode.NoMode + then -- Flush the synchronous work now, unless we're already working or inside -- a batch. This is intentionally inside scheduleUpdateOnFiber instead of -- scheduleCallbackForFiber to preserve the ability to schedule a callback @@ -953,9 +969,40 @@ mod.performConcurrentWorkOnRoot = function(root): (() -> ...any) | nil error(fatalError) end + local finishedWork: Fiber = root.current.alternate :: any + if not mod.isRenderConsistentWithExternalStores(finishedWork) then + -- A store was mutated in an interleaved event. Render again, + -- synchronously, to block further mutations. + exitStatus = mod.renderRootSync(root, lanes) + + -- We need to check again if something threw. + if exitStatus == RootExitStatus.Errored then + executionContext = bit32.bor(executionContext, RetryAfterError) + + -- If an error occurred during hydration, + -- discard server response and fall back to client side render. + if root.hydrate then + root.hydrate = false + ReactFiberHostConfig.clearContainer(root.containerInfo) + end + + lanes = getLanesToRetrySynchronouslyOnError(root) + if lanes ~= ReactFiberLane.NoLanes then + exitStatus = mod.renderRootSync(root, lanes) + end + end + + if exitStatus == RootExitStatus.FatalErrored then + local fatalError = workInProgressRootFatalError + mod.prepareFreshStack(root, ReactFiberLane.NoLanes) + mod.markRootSuspended(root, lanes) + ensureRootIsScheduled(root, now()) + error(fatalError) + end + end + -- We now have a consistent tree. The next step is either to commit it, -- or, if something suspended, wait to commit it after a timeout. - local finishedWork: Fiber = root.current.alternate :: any root.finishedWork = finishedWork root.finishedLanes = lanes mod.finishConcurrentRender(root, exitStatus, lanes) @@ -1087,6 +1134,64 @@ mod.finishConcurrentRender = function(root, exitStatus, lanes) end end +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L1160-L1203 +mod.isRenderConsistentWithExternalStores = function(finishedWork: Fiber): boolean + -- Search the rendered tree for external store reads, and check whether the + -- stores were mutated in a concurrent event. Intentionally using an iterative + -- loop instead of recursion so we can exit early. + local node: Fiber = finishedWork + while true do + if + bit32.band(node.flags, ReactFiberFlags.StoreConsistency) + ~= ReactFiberFlags.NoFlags + then + local updateQueue: FunctionComponentUpdateQueue? = node.updateQueue + if updateQueue ~= nil then + local checks = updateQueue.stores + if checks ~= nil then + for _, check in checks do + local getSnapshot = check.getSnapshot + local renderedValue = check.value + local ok, currentValue = xpcall(getSnapshot, describeError) + if + not ok + or not ReactShared.objectIs(currentValue, renderedValue) + then + -- If `getSnapshot` threw, re-render so the error is + -- rethrown during render. + return false + end + end + end + end + end + local child = node.child + if + bit32.band(node.subtreeFlags, ReactFiberFlags.StoreConsistency) + ~= ReactFiberFlags.NoFlags + and child ~= nil + then + child.return_ = node + node = child + continue + end + if node == finishedWork then + return true + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == finishedWork then + return true + end + node = node.return_ + end + -- ROBLOX FIXME Luau: Luau doesn't narrow based on the loop predicate above. + local sibling = node.sibling :: Fiber + sibling.return_ = node.return_ + node = sibling + end + return true +end + mod.markRootSuspended = function(root, suspendedLanes) -- When suspending, we should always exclude lanes that were pinged or (more -- rarely, since we try to avoid it) updated during the render phase. @@ -2433,19 +2538,6 @@ mod.commitRootImpl = function(root: FiberRoot, renderPriorityLevel) end end - if remainingLanes == SyncLane then - -- Count the number of times the root synchronously re-renders without - -- finishing. If there are too many, it indicates an infinite update loop. - if root == rootWithNestedUpdates then - nestedUpdateCount += 1 - else - nestedUpdateCount = 0 - rootWithNestedUpdates = root - end - else - nestedUpdateCount = 0 - end - onCommitRootDevTools(finishedWork.stateNode, renderPriorityLevel) if __DEV__ then @@ -2464,6 +2556,33 @@ mod.commitRootImpl = function(root: FiberRoot, renderPriorityLevel) error(error_) end + -- If the passive effects are the result of a discrete render, flush them + -- synchronously at the end of the current task so that the result is + -- immediately observable. Otherwise, we assume that they are not + -- order-dependent and do not need to be observed by external systems, so we + -- can wait until after paint. + if + includesSomeLane(pendingPassiveEffectsLanes, SyncLane) + and root.tag ~= LegacyRoot + then + exports.flushPassiveEffects() + end + + -- Read this again, since a passive effect might have updated it. + remainingLanes = root.pendingLanes + if includesSomeLane(remainingLanes, SyncLane) then + -- Count the number of times the root synchronously re-renders without + -- finishing. If there are too many, it indicates an infinite update loop. + if root == rootWithNestedUpdates then + nestedUpdateCount += 1 + else + nestedUpdateCount = 0 + rootWithNestedUpdates = root + end + else + nestedUpdateCount = 0 + end + if bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext then if __DEV__ then if enableDebugTracing then diff --git a/modules/react-reconciler/src/ReactInternalTypes.lua b/modules/react-reconciler/src/ReactInternalTypes.lua index 341a8d1e..0216eb76 100644 --- a/modules/react-reconciler/src/ReactInternalTypes.lua +++ b/modules/react-reconciler/src/ReactInternalTypes.lua @@ -97,6 +97,7 @@ export type HookType = | "useDeferredValue" | "useTransition" | "useMutableSource" + | "useSyncExternalStore" | "useOpaqueIdentifier" local ReactRootTags = require(script.Parent.ReactRootTags) type RootTag = ReactRootTags.RootTag diff --git a/modules/react-reconciler/src/__tests__/useSyncExternalStore.spec.lua b/modules/react-reconciler/src/__tests__/useSyncExternalStore.spec.lua new file mode 100644 index 00000000..0aecfedc --- /dev/null +++ b/modules/react-reconciler/src/__tests__/useSyncExternalStore.spec.lua @@ -0,0 +1,748 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/e9aa33ecc3715ebeacf28d453c6f18244719b359/packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +--[[* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core +]] + +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local Error = require(Packages.LuauPolyfill).Error + +local React +local ReactNoop +local Scheduler +local useSyncExternalStore + +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local jest = JestGlobals.jest +local jestExpect = JestGlobals.expect + +type Store = { + getSnapshot: () -> T, + getSubscriberCount: () -> number, + set: (T, (() -> ())?) -> (), + setWithoutNotification: (T) -> (), + subscribe: (() -> ()) -> () -> (), +} + +local function createStore( + initialState: T, + onSubscribe: ((() -> (), (T) -> ()) -> ())? +): Store + local state = initialState + local listeners = {} + + local function getSnapshot(): T + return state + end + + local function getSubscriberCount(): number + return #listeners + end + + local function setWithoutNotification(value: T): () + state = value + end + + local function set(value: T, beforeReturn: (() -> ())?): () + state = value + local currentListeners = table.clone(listeners) + for _, listener in currentListeners do + listener() + end + if beforeReturn ~= nil then + beforeReturn() + end + end + + local function subscribe(listener: () -> ()): () -> () + table.insert(listeners, listener) + if onSubscribe ~= nil then + onSubscribe(listener, setWithoutNotification) + end + return function() + local index = table.find(listeners, listener) + if index ~= nil then + table.remove(listeners, index) + end + end + end + + return { + getSnapshot = getSnapshot, + getSubscriberCount = getSubscriberCount, + set = set, + setWithoutNotification = setWithoutNotification, + subscribe = subscribe, + } +end + +local function Text(props) + Scheduler.unstable_yieldValue(props.text) + return React.createElement("span", { prop = props.text }) +end + +local function loadModules() + jest.resetModules() + jest.useFakeTimers() + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + useSyncExternalStore = React.useSyncExternalStore +end + +local function createErrorBoundary() + local ErrorBoundary = React.Component:extend("ErrorBoundary") + function ErrorBoundary:init() + self.state = { error_ = nil } + end + function ErrorBoundary.getDerivedStateFromError(error_) + return { error_ = error_ } + end + function ErrorBoundary:render() + if self.state.error_ ~= nil then + return React.createElement(Text, { text = self.state.error_.message }) + end + return self.props.children + end + return ErrorBoundary +end + +describe("useSyncExternalStore", function() + beforeEach(loadModules) + + it("subscribes to a store and skips unchanged snapshots", function() + local store = createStore("Initial") + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "Initial" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "Initial" }) + ) + ReactNoop.flushPassiveEffects() + + store.set("Initial") + jestExpect(Scheduler).toFlushAndYield({}) + + store.set("Updated") + jestExpect(Scheduler).toFlushAndYield({ "Updated" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "Updated" }) + ) + end) + + it("switches stores and unsubscribes from the previous store", function() + local storeA = createStore(0) + local storeB = createStore(0) + local setStore + + local function App() + local store, updateStore = React.useState(storeA) + setStore = updateStore + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ 0 }) + ReactNoop.flushPassiveEffects() + + storeA.set(1) + jestExpect(Scheduler).toFlushAndYield({ 1 }) + + ReactNoop.flushSync(function() + storeA.set(2) + setStore(storeB) + end) + jestExpect(Scheduler).toHaveYielded({ 0 }) + ReactNoop.flushPassiveEffects() + + storeA.set(3) + jestExpect(Scheduler).toFlushAndYield({}) + storeB.set(1) + jestExpect(Scheduler).toFlushAndYield({ 1 }) + end) + + it("selects values inside getSnapshot", function() + local store = createStore({ a = 0, b = 0 }) + local function A() + local value = useSyncExternalStore(store.subscribe, function() + return store.getSnapshot().a + end) + return React.createElement(Text, { text = "A" .. value }) + end + local function B() + local value = useSyncExternalStore(store.subscribe, function() + return store.getSnapshot().b + end) + return React.createElement(Text, { text = "B" .. value }) + end + + ReactNoop.render( + React.createElement( + React.Fragment, + nil, + React.createElement(A), + React.createElement(B) + ) + ) + jestExpect(Scheduler).toFlushAndYield({ "A0", "B0" }) + ReactNoop.flushPassiveEffects() + + store.set({ a = 0, b = 1 }) + jestExpect(Scheduler).toFlushAndYield({ "B1" }) + store.set({ a = 1, b = 1 }) + jestExpect(Scheduler).toFlushAndYield({ "A1" }) + end) + + it("uses the latest getSnapshot when it changes with a store update", function() + local store = createStore({ a = 0, b = 0 }) + local function getSnapshotA() + return store.getSnapshot().a + end + local function getSnapshotB() + return store.getSnapshot().b + end + local setGetSnapshot + local function App() + local getSnapshot, updateGetSnapshot = React.useState(function() + return getSnapshotA + end) + setGetSnapshot = updateGetSnapshot + local value = useSyncExternalStore(store.subscribe, getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ 0 }) + ReactNoop.flushPassiveEffects() + + ReactNoop.flushSync(function() + setGetSnapshot(function() + return getSnapshotB + end) + store.set({ a = 1, b = 2 }) + end) + jestExpect(Scheduler).toHaveYielded({ 2 }) + end) + + it("caches the next value across render-phase updates", function() + local store = createStore("value:initial") + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + local sameValue, setSameValue = React.useState(value) + if value ~= sameValue then + setSameValue(value) + end + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "value:initial" }) + ReactNoop.flushPassiveEffects() + + store.set("value:changed") + jestExpect(Scheduler).toFlushAndYield({ "value:changed" }) + store.set("value:initial") + jestExpect(Scheduler).toFlushAndYield({ "value:initial" }) + end) + + it("detects interleaved mutations before layout effects fire", function() + local store1 = createStore(0) + local store2 = createStore(0) + local renderedValues = {} + + local function Child(props) + local value = + useSyncExternalStore(props.store.subscribe, props.store.getSnapshot) + Scheduler.unstable_yieldValue(props.label .. value) + React.useLayoutEffect(function() + renderedValues[props.label] = value + end, { value }) + return nil + end + + local function App(props) + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "layout:A" + .. renderedValues.A + .. "B" + .. renderedValues.B + .. "C" + .. renderedValues.C + ) + end) + return React.createElement( + React.Fragment, + nil, + React.createElement(Child, { label = "A", store = props.store }), + React.createElement(Child, { label = "B", store = props.store }), + React.createElement(Child, { label = "C", store = props.store }) + ) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App, { store = store1 })) + jestExpect(Scheduler).toFlushAndYieldThrough({ "A0", "B0" }) + store1.setWithoutNotification(1) + jestExpect(Scheduler).toFlushAndYield({ + "C1", + "A1", + "B1", + "C1", + "layout:A1B1C1", + }) + + root.render(React.createElement(App, { store = store2 })) + jestExpect(Scheduler).toFlushAndYieldThrough({ "A0", "B0" }) + store2.setWithoutNotification(1) + jestExpect(Scheduler).toFlushAndYield({ + "C1", + "A1", + "B1", + "C1", + "layout:A1B1C1", + }) + end) + + it("catches a silent mutation before the passive subscription", function() + local store = createStore(0) + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushUntilNextPaint({ 0 }) + jestExpect(store.getSubscriberCount()).toBe(0) + + store.setWithoutNotification(1) + jestExpect(Scheduler).toFlushAndYield({ 1 }) + jestExpect(store.getSubscriberCount()).toBe(1) + end) + + it("catches a reentrant mutation while subscribing", function() + local didMutate = false + local store = createStore(0, function(listener, setWithoutNotification) + if not didMutate then + didMutate = true + setWithoutNotification(1) + listener() + end + end) + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushUntilNextPaint({ 0 }) + jestExpect(Scheduler).toFlushAndYield({ 1 }) + jestExpect(store.getSubscriberCount()).toBe(1) + end) + + it("defers unbatched listener updates on a concurrent root", function() + local store = createStore(0) + local committedValues = { A = 0, B = 0, C = 0 } + + local function Consumer(props) + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + Scheduler.unstable_yieldValue(props.label .. value) + React.useLayoutEffect(function() + committedValues[props.label] = value + end, { value }) + return nil + end + + local function App() + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "layout:" + .. committedValues.A + .. committedValues.B + .. committedValues.C + ) + end) + return React.createElement( + React.Fragment, + nil, + React.createElement(Consumer, { label = "A" }), + React.createElement(Consumer, { label = "B" }), + React.createElement(Consumer, { label = "C" }) + ) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "A0", "B0", "C0", "layout:000" }) + ReactNoop.flushPassiveEffects() + + store.set(1) + jestExpect(Scheduler.unstable_clearYields()).toEqual({}) + jestExpect(Scheduler).toFlushAndYield({ "A1", "B1", "C1", "layout:111" }) + end) + + it("flushes store updates inline on a legacy root", function() + local store = createStore(0) + local committedValue = nil + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + Scheduler.unstable_yieldValue("render:" .. value) + React.useLayoutEffect(function() + committedValue = value + end, { value }) + return nil + end + + local root = ReactNoop.createLegacyRoot() + ReactNoop.act(function() + root.render(React.createElement(App)) + end) + jestExpect(Scheduler).toHaveYielded({ "render:0" }) + ReactNoop.flushPassiveEffects() + + local valueBeforeSetterReturn + store.set(1, function() + valueBeforeSetterReturn = committedValue + end) + jestExpect(valueBeforeSetterReturn).toBe(1) + jestExpect(Scheduler).toHaveYielded({ "render:1" }) + end) + + it("flushes store updates inside flushSync on a concurrent root", function() + local store = createStore(0) + local committedValue = nil + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + Scheduler.unstable_yieldValue("render:" .. value) + React.useLayoutEffect(function() + committedValue = value + end, { value }) + return nil + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "render:0" }) + ReactNoop.flushPassiveEffects() + + ReactNoop.flushSync(function() + store.set(1) + end) + jestExpect(committedValue).toBe(1) + jestExpect(Scheduler).toHaveYielded({ "render:1" }) + end) + + it("preserves store updates scheduled from layout effects", function() + local store = createStore(0) + local function App(props) + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + Scheduler.unstable_yieldValue("render:" .. value) + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue("layout:" .. value) + if props.updateStore and value == 0 then + store.set(1) + end + end, { value, props.updateStore }) + return nil + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App, { updateStore = false })) + jestExpect(Scheduler).toFlushAndYield({ "render:0", "layout:0" }) + ReactNoop.flushPassiveEffects() + + root.render(React.createElement(App, { updateStore = true })) + jestExpect(Scheduler).toFlushAndYield({ + "render:0", + "layout:0", + "render:1", + "layout:1", + }) + end) + + it("does not bail out while a previous update is unfinished", function() + local store = createStore(0) + local function First() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + React.useLayoutEffect(function() + if value == 1 then + Scheduler.unstable_yieldValue("reset") + store.set(0) + end + end, { value }) + return React.createElement(Text, { text = "A" .. value }) + end + local function Second() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + return React.createElement(Text, { text = "B" .. value }) + end + + ReactNoop.render( + React.createElement( + React.Fragment, + nil, + React.createElement(First), + React.createElement(Second) + ) + ) + jestExpect(Scheduler).toFlushAndYield({ "A0", "B0" }) + ReactNoop.flushPassiveEffects() + + store.set(1) + jestExpect(Scheduler).toFlushAndYield({ "A1", "B1", "reset", "A0", "B0" }) + end) + + it("uses the new getSnapshot after a commit-phase mutation", function() + local store = createStore({ a = 1, b = 1 }) + local setStep + local function Mutator(props) + local state = useSyncExternalStore(store.subscribe, store.getSnapshot) + React.useLayoutEffect(function() + if props.step == 1 then + Scheduler.unstable_yieldValue("mutate") + store.set({ a = state.a, b = 2 }) + end + end, { props.step }) + return nil + end + local function Reader(props) + local getSnapshot = if props.step == 0 + then function() + return store.getSnapshot().a + end + else function() + return store.getSnapshot().b + end + local value = useSyncExternalStore(store.subscribe, getSnapshot) + return React.createElement(Text, { + text = (if props.step == 0 then "A" else "B") .. value, + }) + end + local function App() + local step, updateStep = React.useState(0) + setStep = updateStep + return React.createElement( + React.Fragment, + nil, + React.createElement(Mutator, { step = step }), + React.createElement(Reader, { step = step }) + ) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "A1" }) + ReactNoop.flushPassiveEffects() + + setStep(1) + jestExpect(Scheduler).toFlushAndYield({ "B1", "mutate", "B2" }) + end) + + it("bails out after a commit-phase mutation when the snapshot is stable", function() + local store = createStore({ a = 1, b = 1 }) + local setStep + local function Mutator(props) + local state = useSyncExternalStore(store.subscribe, store.getSnapshot) + React.useLayoutEffect(function() + if props.step == 1 then + Scheduler.unstable_yieldValue("mutate") + store.set({ a = state.a, b = 2 }) + end + end, { props.step }) + return nil + end + local function Reader() + local value = useSyncExternalStore(store.subscribe, function() + return store.getSnapshot().a + end) + return React.createElement(Text, { text = "A" .. value }) + end + local function App() + local step, updateStep = React.useState(0) + setStep = updateStep + return React.createElement( + React.Fragment, + nil, + React.createElement(Mutator, { step = step }), + React.createElement(Reader) + ) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "A1" }) + ReactNoop.flushPassiveEffects() + + setStep(1) + jestExpect(Scheduler).toFlushAndYield({ "A1", "mutate" }) + end) + + it("handles errors thrown while reading the snapshot", function() + local shouldThrow = false + local store = createStore(0) + local ErrorBoundary = createErrorBoundary() + local function getSnapshot() + if shouldThrow then + error(Error.new("snapshot error")) + end + return store.getSnapshot() + end + local function App() + local value = useSyncExternalStore(store.subscribe, getSnapshot) + return React.createElement(Text, { text = value }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(ErrorBoundary, nil, React.createElement(App))) + jestExpect(Scheduler).toFlushAndYield({ 0 }) + ReactNoop.flushPassiveEffects() + + shouldThrow = true + jestExpect(function() + store.set(1) + Scheduler.unstable_flushAllWithoutAsserting() + end).toErrorDev("The above error occurred in the component:", { + logAllErrors = true, + }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "snapshot error" }) + ) + end) + + it("retries a consistency check that throws through an error boundary", function() + local shouldThrow = false + local store = createStore(0) + local ErrorBoundary = createErrorBoundary() + + local function getSnapshot() + if shouldThrow then + error(Error.new("snapshot error")) + end + return store.getSnapshot() + end + local function App() + local value = useSyncExternalStore(store.subscribe, getSnapshot) + Scheduler.unstable_yieldValue(value) + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue("layout:" .. value) + end, { value }) + return React.createElement(Text, { text = "tail" }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(ErrorBoundary, nil, React.createElement(App))) + jestExpect(Scheduler).toFlushAndYieldThrough({ 0 }) + shouldThrow = true + jestExpect(function() + Scheduler.unstable_flushAllWithoutAsserting() + end).toErrorDev("The above error occurred in the component:", { + logAllErrors = true, + }) + local yields = Scheduler.unstable_clearYields() + jestExpect(yields).never.toContain("layout:0") + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "snapshot error" }) + ) + end) + + it("uses Object.is semantics for NaN and signed zero", function() + local nan = math.huge - math.huge + local store = createStore(nan) + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + local text + if value ~= value then + text = "NaN" + elseif 1 / value == math.huge then + text = "+0" + else + text = "-0" + end + return React.createElement(Text, { text = text }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "NaN" }) + ReactNoop.flushPassiveEffects() + + store.set(nan) + jestExpect(Scheduler).toFlushAndYield({}) + store.set(0) + jestExpect(Scheduler).toFlushAndYield({ "+0" }) + store.set(-1 / math.huge) + jestExpect(Scheduler).toFlushAndYield({ "-0" }) + end) + + local itIfDev = if ReactGlobals.__DEV__ then it else it.skip :: any + itIfDev("warns when getSnapshot is not cached", function() + local readCount = 0 + local function getSnapshot() + readCount += 1 + if readCount > 1000 then + error(Error.new("uncached snapshot test exceeded its read limit")) + end + return {} + end + local function App() + useSyncExternalStore(function() + return function() end + end, getSnapshot) + return nil + end + + local root = ReactNoop.createRoot() + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + root.render(React.createElement(App)) + end) + end).toThrow("Maximum update depth exceeded") + end).toErrorDev( + "The result of getSnapshot should be cached to avoid an infinite loop" + ) + end) + + local itIfProd = if ReactGlobals.__DEV__ then it.skip :: any else it + itIfProd("handles render-phase updates in production", function() + local store = createStore("Initial") + local function App() + local value = useSyncExternalStore(store.subscribe, store.getSnapshot) + local derivedValue, setDerivedValue = React.useState(value) + React.useEffect(function() end, {}) + local upperValue = string.upper(value) + if derivedValue ~= upperValue then + setDerivedValue(upperValue) + end + return React.createElement(Text, { text = derivedValue }) + end + + local root = ReactNoop.createRoot() + root.render(React.createElement(App)) + jestExpect(Scheduler).toFlushAndYield({ "INITIAL" }) + ReactNoop.flushPassiveEffects() + + store.set("Updated") + jestExpect(Scheduler).toFlushAndYield({ "UPDATED" }) + end) +end) diff --git a/modules/react/src/React.lua b/modules/react/src/React.lua index 23f94f2c..5366340b 100644 --- a/modules/react/src/React.lua +++ b/modules/react/src/React.lua @@ -102,6 +102,7 @@ return { useLayoutEffect = ReactHooks.useLayoutEffect, useMemo = ReactHooks.useMemo, useMutableSource = ReactHooks.useMutableSource, + useSyncExternalStore = ReactHooks.useSyncExternalStore, useReducer = ReactHooks.useReducer, useRef = ReactHooks.useRef, -- ROBLOX deviation: bindings support diff --git a/modules/react/src/ReactHooks.lua b/modules/react/src/ReactHooks.lua index 1a7dae7a..2b46bcb7 100644 --- a/modules/react/src/ReactHooks.lua +++ b/modules/react/src/ReactHooks.lua @@ -321,4 +321,14 @@ exports.useMutableSource = function( return dispatcher.useMutableSource(source, getSnapshot, subscribe) end +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react/src/ReactHooks.js#L189-L200 +exports.useSyncExternalStore = function( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? +): T + local dispatcher = resolveDispatcher() + return dispatcher.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +end + return exports diff --git a/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua b/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua index 1ca4bdcf..9dce5350 100644 --- a/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua +++ b/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua @@ -85,6 +85,11 @@ export type Dispatcher = { getSnapshot: MutableSourceGetSnapshotFn, subscribe: MutableSourceSubscribeFn ) -> Snapshot, + useSyncExternalStore: ( + subscribe: (() -> ()) -> () -> (), + getSnapshot: () -> T, + getServerSnapshot: (() -> T)? + ) -> T, useOpaqueIdentifier: () -> any, unstable_isNewReconciler: boolean?,