From 8e0a62c22cebcd6b66cdd8d34e7ee93daa96ad33 Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Tue, 1 Sep 2026 02:33:29 +0100 Subject: [PATCH] feat(react): backport insertion and Effect Event hooks Mirror React 18 useInsertionEffect and React 19.2 useEffectEvent, including commit ordering, Debug Tools support, DEV validation, and upstream-derived coverage. Co-authored-by: Codex --- .../react-18-use-insertion-effect-backport.md | 79 ++ docs/react-19.2-use-effect-event-backport.md | 110 ++ .../react-debug-tools/src/ReactDebugHooks.lua | 57 +- .../ReactHooksInspectionIntegration.spec.lua | 251 +++- .../src/ReactFiberCommitWork.new.lua | 81 +- .../react-reconciler/src/ReactFiberFlags.lua | 5 +- .../src/ReactFiberHooks.new.lua | 218 ++++ .../src/ReactFiberWorkLoop.new.lua | 13 +- .../src/ReactHookEffectTags.lua | 12 +- .../src/ReactInternalTypes.lua | 4 + .../src/__tests__/useEffectEvent.spec.lua | 1006 +++++++++++++++++ .../src/__tests__/useInsertionEffect.spec.lua | 510 +++++++++ modules/react/src/React.lua | 4 + modules/react/src/ReactHooks.lua | 18 + modules/shared/src/ReactFeatureFlags.lua | 3 + .../ReactCurrentDispatcher.lua | 11 + 16 files changed, 2351 insertions(+), 31 deletions(-) create mode 100644 docs/react-18-use-insertion-effect-backport.md create mode 100644 docs/react-19.2-use-effect-event-backport.md create mode 100644 modules/react-reconciler/src/__tests__/useEffectEvent.spec.lua create mode 100644 modules/react-reconciler/src/__tests__/useInsertionEffect.spec.lua diff --git a/docs/react-18-use-insertion-effect-backport.md b/docs/react-18-use-insertion-effect-backport.md new file mode 100644 index 00000000..acfc08ca --- /dev/null +++ b/docs/react-18-use-insertion-effect-backport.md @@ -0,0 +1,79 @@ +# React 18 `useInsertionEffect` backport + +This backport mirrors the stable React 18 client `useInsertionEffect` contract +that can run in React-Luau, ReactNoop, and React Debug Tools. Upstream source +and tests are the contract. Server and renderer packages that React-Luau does +not ship remain explicit capability exclusions. + +## Pin and source history + +- Stable upstream tag: `v18.0.0` +- Stable upstream commit: `34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d` +- React-Luau base: `9351444c2db37caa08b38ad5de90f438db9221ea` +- Source repository: `facebook/react` + +The public seam is `React.useInsertionEffect` rendered through ReactNoop. React +Debug Tools inspection is a second public seam. The behavior-bearing upstream +chain is pinned to: + +- `263cfa6ecb9879ecb629d4e04a8c26422b4c4ff9` — runtime, Debug Tools, and tests +- `02f411578a8e58af8ec28e385f6b0dcb768cdc41` — stable public export +- `34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d` — React 18.0.0 contract snapshot + +## Supported contract + +- create and cleanup in the mutation phase before layout effects +- component-local cleanup/create interleaving +- all insertion effects complete before any layout-effect create +- snapshot lifecycles complete before insertion effects +- pending passive effects flush before a later insertion effect +- dependency-array semantics shared with the other effect hooks +- deletion cleanup and development diagnostics with upstream hook names +- React Debug Tools inspection as `InsertionEffect` + +## Source ledger + +| Upstream source or test | React-Luau target | Port status | Deviation | +| --- | --- | --- | --- | +| `packages/react/src/ReactHooks.js` — `useInsertionEffect` | `modules/react/src/ReactHooks.lua` | Adapted | Luau arrays and cleanup function types replace Flow. | +| `packages/react/src/React.js` and stable index exports | `modules/react/src/React.lua` | Adapted | React-Luau has one Rojo runtime entry point. | +| `packages/react-reconciler/src/ReactHookEffectTags.js` — `Insertion` | `modules/react-reconciler/src/ReactHookEffectTags.lua` | Direct | Existing internal layout and passive bits shift to their React 18 values. | +| `packages/react-reconciler/src/ReactInternalTypes.js` — hook and dispatcher types | `modules/react-reconciler/src/ReactInternalTypes.lua` and `modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua` | Adapted | The dispatcher type lives in Shared to avoid React-Luau's existing require cycle. | +| `packages/react-reconciler/src/ReactFiberHooks.new.js` — mount/update implementation and dispatchers | `modules/react-reconciler/src/ReactFiberHooks.new.lua` | Adapted | Existing Luau dispatcher tables and effect queue mechanics are retained. | +| `packages/react-reconciler/src/ReactFiberCommitWork.new.js` — insertion create/cleanup and diagnostics | `modules/react-reconciler/src/ReactFiberCommitWork.new.lua` | Adapted | Luau protected calls and warning formatting replace JavaScript calls; order is unchanged. | +| `packages/react-debug-tools/src/ReactDebugHooks.js` — primitive inspection | `modules/react-debug-tools/src/ReactDebugHooks.lua` | Adapted | Luau hook-log tables replace JavaScript objects. Stack parsing accepts Roblox's legacy `LoadedCode` and current `[string "..."]` frame formats; a missing expected ancestor is treated as no shared ancestor. A single Luau `useMemo` result is inspected as its scalar value while multiple returns remain packed. | +| React stable and renderer-specific index variants | No additional target | Out of scope | The single `React.lua` export covers every React-Luau build. | +| `packages/react-dom` server implementation and tests | No target | Out of scope | React-Luau has no DOM or server renderer. | +| `packages/react-server` and `packages/react-suspense-test-utils` exports | No target | Out of scope | React-Luau ships neither package. | + +## Test ledger + +The reconciler target suite is +`modules/react-reconciler/src/__tests__/useInsertionEffect.spec.lua`. Tests +retain upstream names and order. + +| Upstream source or test | React-Luau target | Port status | Deviation | +| --- | --- | --- | --- | +| `ReactHooksWithNoopRenderer-test.js` — `fires insertion effects after snapshots on update` | Same-named Jest-Lua case | Adapted | React-Luau class construction and scheduler matchers only. | +| `fires insertion effects before layout effects` | Same-named Jest-Lua case | Adapted | Scheduler and string construction syntax only. | +| `force flushes passive effects before firing new insertion effects` | Same-named Jest-Lua case | Adapted | React 17's available transition-free scheduling seam exercises the same pending-passive flush. | +| `fires all insertion effects (interleaved) before firing any layout effects` | Same-named Jest-Lua case | Adapted | Luau fragments, arrays, and string construction only. | +| `assumes insertion effect destroy function is either a function or undefined` | Same-named Jest-Lua case | Adapted | Jest-Lua console capture and promises replace their JavaScript equivalents. Luau cannot distinguish an omitted return from an explicit `nil`, so React's `null`-only warning is unrepresentable. Roblox reports the invalid cleanup passed to `xpcall` as an attempted nil call. | +| `ReactHooks-test.internal.js` — invalid dependency-array warning pattern and `ReactFiberHooks.new.js` mount validation | `warns if deps is not an array` | Adapted | Upstream applies `checkDepsAreArrayDev` to `useInsertionEffect` but does not add it to the generic warning test. A focused public-hook regression covers that required dispatcher call. | +| `ReactHooksInspectionIntegration-test.js` — `should inspect the current state of all stateful hooks, including useInsertionEffect` | Same-named case in `ReactHooksInspectionIntegration.spec.lua` | Adapted | Existing React-Luau inspector fixture and Jest-Lua tables are retained. | +| `ReactHooksInspectionIntegration-test.js` — `should inspect custom hooks` | Existing same-named case | Adapted | The upstream hook-tree assertion also locks Roblox's current `[string "..."]` stack-frame parser path. The test module disables Luau optimization because inlining can erase the custom-hook frames this stack-based API inspects. | +| `ReactDOMServerIntegrationHooks-test.js` — server warning | No target | Out of scope | React-Luau has no server renderer. | + +## Standalone verification + +- The repository-pinned StyLua `0.18.1` formatting check passes. +- Selene `0.28.0` passes with no errors or warnings. +- Luau compiler `0.731` parses and compiles the changed Luau files. +- `bash bin/testing.sh` stops before discovery because `roblox-cli` is not installed. This is a harness prerequisite failure, not a test failure. + +## Completion criteria + +The backport is complete when every Adapted test passes in an available Roblox +harness, source blocks link to pinned upstream code, standalone formatter and +linter gates pass, unavailable gates have one reproducible prerequisite, and +the React-Luau pull request and working tree are current and clean. diff --git a/docs/react-19.2-use-effect-event-backport.md b/docs/react-19.2-use-effect-event-backport.md new file mode 100644 index 00000000..aa17c4b9 --- /dev/null +++ b/docs/react-19.2-use-effect-event-backport.md @@ -0,0 +1,110 @@ +# React 19.2 `useEffectEvent` backport + +This backport mirrors the client `React.useEffectEvent` behavior that can run +in React-Luau and ReactNoop. Upstream source and tests are the contract. Server, +DOM, lint-plugin, and hooks that React-Luau does not expose remain explicit +capability exclusions rather than reconstructed substitutes. + +## Pin and source history + +- Stable upstream tag: `v19.2.0` +- Stable upstream commit: `ae74234eae6ebd62f19190731278e20bc1c37d51` +- Post-release correctness fix: `6bec011b407fe8a2d4babb363289ccce4bc8fcf3` + from React PR `#34831` +- React-Luau base: `9351444c2db37caa08b38ad5de90f438db9221ea` +- Required API base: React 18 `useInsertionEffect` at + `34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d` +- Source repository: `facebook/react` + +The public seam is `React.useEffectEvent` rendered through ReactNoop. The +behavior-bearing upstream chain is pinned to: + +- `c91a1e03be54733a7dbfcb5663d7a9e8606ab1c1` — initial `useEvent` runtime and tests +- `3517bd9f77dc63189f3bafedf83ba1bf8ae359df` — commit-queue implementation +- `3cc792bfb53c63de34bbb1e8ca131c11faca6cba` — non-stable returned identity +- `84a0a171ea0ecd25e287bd3d3dd30e932beb4677` — `useEffectEvent` rename +- `8bb7241f4c773376893701bfe8b8ff03687342a0` — stable client export +- `6bec011b407fe8a2d4babb363289ccce4bc8fcf3` — `memo` and `forwardRef` commit fix + +## Supported contract + +- a public hook with the callback's arguments and return values +- access to the latest committed props, state, and context +- callback replacement before layout and passive effects +- a fresh wrapper identity on every render +- render-time invocation errors with upstream text +- multiple Effect Events in one component +- use from custom hooks, function components, `memo`, and `forwardRef` +- no receiver preservation when a method is passed as the callback + +## Capability boundary + +React-Luau does not expose Fizz, server rendering, React DOM, or the upstream +`eslint-plugin-react-hooks` package. Their Effect Event tests and restrictions +are outside this runtime backport. The stacked React 18 backport supplies +`useInsertionEffect`, so the complete applicable client suite remains in scope. + +## Source ledger + +| Upstream source or test | React-Luau target | Port status | Deviation | +| --- | --- | --- | --- | +| `packages/react/src/ReactHooks.js` — `useEffectEvent` | `modules/react/src/ReactHooks.lua` | Adapted | Luau variadic type packs replace Flow argument arrays. | +| `packages/react/src/ReactClient.js` and stable index exports | `modules/react/src/React.lua` | Adapted | React-Luau has one Rojo runtime entry point. | +| `packages/shared/ReactFeatureFlags.js` and renderer forks — `enableUseEffectEventHook` | `modules/shared/src/ReactFeatureFlags.lua` | Adapted | React-Luau has one Shared feature-flag module. | +| `packages/react-reconciler/src/ReactInternalTypes.js` — hook and dispatcher types | `modules/react-reconciler/src/ReactInternalTypes.lua` and `modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua` | Adapted | The dispatcher type lives in Shared to avoid React-Luau's existing require cycle. | +| `packages/react-reconciler/src/ReactFiberHooks.js` — event payload, update queue, mount/update hooks, and dispatchers | `modules/react-reconciler/src/ReactFiberHooks.new.lua` | Adapted | Luau arrays, tables, and variadic calls replace JavaScript objects and `Function.apply`. | +| `packages/react-reconciler/src/ReactFiberFlags.js` — `BeforeMutationMask` | `modules/react-reconciler/src/ReactFiberFlags.lua` | Adapted | The single React-Luau build enables Effect Events, so upstream's feature-flagged `Update` bit is baked into the older numeric mask; flag values remain stable. | +| `packages/react-reconciler/src/ReactFiberWorkLoop.js` — invalid render context and before-mutation dispatch | `modules/react-reconciler/src/ReactFiberWorkLoop.new.lua` | Adapted | React 17's commit traversal invokes the existing lifecycle helper for `Update` as well as `Snapshot`. | +| `packages/react-reconciler/src/ReactFiberCommitWork.js` — event payload commit and default snapshot guard | `modules/react-reconciler/src/ReactFiberCommitWork.new.lua` | Adapted | PR `#34831` extends the React 19.2 switch arm to `ForwardRef` and `SimpleMemoComponent`; the obsolete React 17 `Block` tag remains a no-op. The widened before-mutation traversal permits non-snapshot `Update` work on other Fiber tags, matching upstream. | +| `packages/react-debug-tools/src/ReactDebugHooks.js` — Effect Event inspection dispatcher | `modules/react-debug-tools/src/ReactDebugHooks.lua` | Adapted | React-Luau's hook log records the callback with its existing compact entry shape. Stack parsing accepts Roblox's legacy `LoadedCode` and current `[string "..."]` frame formats; a missing expected ancestor is treated as no shared ancestor. A single Luau `useMemo` result is inspected as its scalar value while multiple returns remain packed. | +| `scripts/error-codes/codes.json` — production error `440` | No target | Out of scope | React-Luau ships the full error string and has no production error-code transform. | +| React index build variants | No additional target | Out of scope | The single `React.lua` export covers every React-Luau build. | +| Shared renderer-specific feature-flag forks | No additional target | Out of scope | React-Luau ships one Shared package and one client reconciler. | +| `packages/react-server/src/ReactFizzHooks.js` and Fizz tests | No target | Out of scope | React-Luau does not ship Fizz or a server renderer. | +| `packages/eslint-plugin-react-hooks` Effect Event rules and tests | No target | Out of scope | React-Luau does not ship the JavaScript lint plugin; this is a separate tooling contract. | + +## Test ledger + +The primary target suite is +`modules/react-reconciler/src/__tests__/useEffectEvent.spec.lua`. The +cross-hook visibility regression remains with `useInsertionEffect.spec.lua`. +Tests retain upstream names and order. Port status describes the required +translation. + +| Upstream source or test | React-Luau target | Port status | Deviation | +| --- | --- | --- | --- | +| `useEffectEvent-test.js` — `memoizes basic case correctly` | Same-named Jest-Lua case | Adapted | React-Luau class construction and scheduler matchers only. | +| `can be defined more than once` | Same-named Jest-Lua case | Adapted | React-Luau class construction and scheduler matchers only. | +| `does not preserve \`this\` in event functions` | Same-named Jest-Lua case | Adapted | An extracted Luau method observes a `nil` receiver instead of JavaScript `undefined`. | +| `throws when called in render` | Same-named Jest-Lua case | Adapted | Synchronous scheduler flush replaces `waitForThrow`; error text is unchanged. React-Luau's React 17 recovery renders the sibling in both the concurrent attempt and synchronous retry. | +| `useLayoutEffect shouldn't re-fire when event handlers change` | Same-named Jest-Lua case | Adapted | Scheduler and element construction syntax only. | +| `useEffect shouldn't re-fire when event handlers change` | Same-named Jest-Lua case | Adapted | Scheduler and element construction syntax only. | +| `is stable in a custom hook` | Same-named Jest-Lua case | Adapted | Lua returns the custom-hook pair as multiple values. | +| `is mutated before all other effects` | Same-named Jest-Lua case | Adapted | The stacked React 18 `useInsertionEffect` backport supplies the upstream ordering seam. | +| `doesn't provide a stable identity` | Same-named Jest-Lua case | Adapted | Scheduler and element construction syntax only. | +| `event handlers always see the latest committed value` | Same-named Jest-Lua case | Adapted | ReactNoop's available `act` and output matcher replace async helpers. | +| `integration: implements docs chat room example` | Same-named Jest-Lua case | Adapted | Jest-Lua fake timers and Luau connection table syntax replace JavaScript timers and objects. | +| `integration: implements the docs logVisit example` | Same-named Jest-Lua case | Adapted | Luau arrays and context tables replace JavaScript arrays and JSX. | +| PR `#34831` — `reads the latest context value in memo Components` | Same-named Jest-Lua case | Adapted | Scheduler and element construction syntax only. | +| PR `#34831` — `reads the latest context value in forwardRef Components` | Same-named Jest-Lua case | Adapted | Scheduler and element construction syntax only. | +| `Activity-test.js` — `insertion effects are not disconnected when the visibility changes` | Same-named case in `useInsertionEffect.spec.lua` | Adapted | React-Luau has no Activity API, so `unstable_LegacyHidden` exercises the same Offscreen visibility `Update`. Unlike Activity, LegacyHidden does not retain hidden host output after its deferred children commit an update. | +| React Debug Tools primitive inspection | Existing all-stateful-hooks integration case | Adapted | The React 18 insertion-effect integration case also checks `EffectEvent` and subsequent hook IDs. | +| React Debug Tools custom-hook stack inspection | Existing `should inspect custom hooks` case | Adapted | The test module disables Luau optimization because inlining can erase the custom-hook frames required by the stack-based inspector. | +| `ReactDOMFizzServer-test.js` Effect Event cases | No target | Out of scope | React-Luau has no server rendering or hydration. | + +## Standalone verification + +- The repository-pinned StyLua `0.18.1` and Selene `0.28.0` pass for the changed Luau files. +- Full-tree formatting passes. Full-tree linting retains the pre-existing + `bin/spec.lua` standard-library error. +- Luau compiler `0.731` parses and compiles all 14 changed Luau files. +- The ReactNoop visibility regression fails on the unguarded invariant and passes mount, hide, hidden update, and reveal after the snapshot guard in Roblox Studio. +- `bash bin/testing.sh` stops before discovery because `roblox-cli` is not installed. This is a harness prerequisite failure, not a test failure. + +## Completion criteria + +The backport is complete when every Adapted test passes in an available Roblox +harness, every source block links to pinned upstream code, all standalone +formatter and linter gates pass, unavailable standalone gates have one +reproducible prerequisite, and the React-Luau pull request and working tree are +current and clean. diff --git a/modules/react-debug-tools/src/ReactDebugHooks.lua b/modules/react-debug-tools/src/ReactDebugHooks.lua index 9d0642a0..78747ece 100644 --- a/modules/react-debug-tools/src/ReactDebugHooks.lua +++ b/modules/react-debug-tools/src/ReactDebugHooks.lua @@ -73,6 +73,7 @@ local NoMode = ReconcilerModule.ReactTypeOfMode.NoMode -- ROBLOX deviation END -- ROBLOX deviation START: add inline ErrorStackParser implementation -- local ErrorStackParser = require(Packages["error-stack-parser"]).default +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/ReactDebugHooks.js#L23 type StackFrame = { source: string?, functionName: string?, @@ -85,7 +86,10 @@ local ErrorStackParser = { local filtered = Array.filter( string.split(error_.stack :: string, "\n"), function(line) + -- ROBLOX DEVIATION: Roblox emits both legacy LoadedCode frames and + -- current Luau [string "..."] frames, depending on the runtime. return string.find(line, "^LoadedCode") ~= nil + or string.find(line, '^%[string "') ~= nil end ) return Array.map(filtered, function(stackTraceLine) @@ -162,7 +166,15 @@ local function getPrimitiveStackCache(): Map> -- Dispatcher:useImperativeHandle(nil, function() Dispatcher.useRef(nil) Dispatcher.useLayoutEffect(function() end) + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/ReactDebugHooks.js#L76 + local inspectInsertionEffect = Dispatcher.useInsertionEffect :: any + inspectInsertionEffect(function() end) Dispatcher.useEffect(function() end) + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-debug-tools/src/ReactDebugHooks.js#L131-L133 + local inspectEffectEvent = Dispatcher.useEffectEvent + if inspectEffectEvent ~= nil then + inspectEffectEvent(function() end) + end Dispatcher.useImperativeHandle(nil, function() -- ROBLOX deviation END return nil @@ -335,6 +347,19 @@ local function useLayoutEffect( { primitive = "LayoutEffect", stackError = Error.new(), value = create } ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] end + +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/ReactDebugHooks.js#L190-L200 +local function useInsertionEffect( + create: (() -> ()) | (() -> () -> ()), + inputs: Array | void | nil +): () + nextHook() + table.insert( + hookLog, + { primitive = "InsertionEffect", stackError = Error.new(), value = create } + ) +end + local function useEffect( -- ROBLOX deviation START: Luau needs union type packs for this type to translate idiomatically -- create: () -> () -> () | void, @@ -348,6 +373,19 @@ local function useEffect( { primitive = "Effect", stackError = Error.new(), value = create } ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] end + +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-debug-tools/src/ReactDebugHooks.js#L748-L760 +local function useEffectEvent(callback) + nextHook() + -- ROBLOX DEVIATION: This React 17 HookLogEntry omits the v19 displayName, + -- debugInfo, and dispatcherHookName metadata that its consumers do not expose. + table.insert( + hookLog, + { primitive = "EffectEvent", stackError = Error.new(), value = callback } + ) + return callback +end + local function useImperativeHandle( ref: { current: T | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] @@ -413,7 +451,13 @@ local function useMemo(nextCreate: () -> T..., inputs: Array | nil): local value = if hook ~= nil then hook.memoizedState[1] else { nextCreate() } -- ROBLOX deviation END - table.insert(hookLog, { primitive = "Memo", stackError = Error.new(), value = value }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX DEVIATION: DevTools displays a single Luau memo return as its value, + -- but preserves the packed table when useMemo returns multiple values. + local inspectedValue = if #value <= 1 then value[1] else value + table.insert( + hookLog, + { primitive = "Memo", stackError = Error.new(), value = inspectedValue } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] -- ROBLOX deviation START: unwrap memoized values in a table -- return value return table.unpack(value) @@ -512,12 +556,16 @@ Dispatcher = { useCallback = useCallback, useContext = useContext, useEffect = useEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-debug-tools/src/ReactDebugHooks.js#L783-L788 + useEffectEvent = useEffectEvent, -- ROBLOX deviation START: needs cast -- useImperativeHandle = useImperativeHandle, useImperativeHandle = useImperativeHandle :: any, -- ROBLOX deviation END useDebugValue = useDebugValue, useLayoutEffect = useLayoutEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/ReactDebugHooks.js#L343-L351 + useInsertionEffect = useInsertionEffect, -- ROBLOX deviation START: needs cast -- useMemo = useMemo, useMemo = useMemo :: any, @@ -570,7 +618,12 @@ local function findSharedIndex(hookStack, rootStack, rootIndex: number) -- ROBLOX deviation END -- ROBLOX deviation START: don't use tostring -- local source = rootStack[tostring(rootIndex)].source - local source = rootStack[rootIndex].source + -- ROBLOX DEVIATION: Roblox can omit the ancestor frame from a parsed stack. + local rootFrame = rootStack[rootIndex] + if rootFrame == nil then + return -1 + end + local source = rootFrame.source -- ROBLOX deviation END -- ROBLOX deviation START: implement LabeledStatement -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: LabeledStatement ]] --[[ hookSearch: for (let i = 0; i < hookStack.length; i++) { diff --git a/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua index 360a95b3..dc265988 100644 --- a/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua +++ b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua @@ -1,3 +1,6 @@ +--!optimize 0 +-- ROBLOX DEVIATION: Debug Tools inspection requires custom-hook stack frames +-- that optimized Luau can elide. -- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -298,10 +301,7 @@ describe("ReactHooksInspectionIntegration", function() id = 7, -- ROBLOX deviation END name = "Memo", - -- ROBLOX deviation START: useMemo wraps a value - -- value = "ab", - value = { "ab" }, - -- ROBLOX deviation END + value = "ab", subHooks = {}, }, { @@ -385,10 +385,7 @@ describe("ReactHooksInspectionIntegration", function() id = 7, -- ROBLOX deviation END name = "Memo", - -- ROBLOX deviation START: useMemo wraps a value - -- value = "Ab", - value = { "Ab" }, - -- ROBLOX deviation END + value = "Ab", subHooks = {}, }, { @@ -403,6 +400,218 @@ describe("ReactHooksInspectionIntegration", function() }, }) end) + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js#L271-L445 + it( + "should inspect the current state of all stateful hooks, including useInsertionEffect", + function() + local outsideRef = React.createRef() + local function effect() end + local function effectEvent() end + local function Foo() + local state1, setState = React.useState("a") + local state2, dispatch = React.useReducer(function(_, action) + return action.value + end, "b") + local ref = React.useRef("c") + + React.useInsertionEffect(effect) + React.useLayoutEffect(effect) + React.useEffect(effect) + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-debug-tools/src/ReactDebugHooks.js#L748-L760 + -- ROBLOX DEVIATION: React 19 has no matching Debug Tools integration case, + -- so its Effect Event primitive extends this pinned React 18 hook inventory. + React.useEffectEvent(effectEvent) + + React.useImperativeHandle(outsideRef, function() + return function() end + end, {}) + + React.useMemo(function() + return state1 .. state2 + end, { state1 }) + + local function update() + act(function() + setState("A") + end) + act(function() + dispatch({ value = "B" }) + end) + ref.current = "C" + end + local memoizedUpdate = React.useCallback(update, {}) + return React.createElement( + "Frame", + { onClick = memoizedUpdate }, + state1, + " ", + state2 + ) + end + + local renderer + act(function() + renderer = + ReactTestRenderer.create(React.createElement(Foo, { prop = "prop" })) + end) + + local childFiber = renderer.root:findByType(Foo):_currentFiber() + local updateStates = renderer.root:findByType("Frame").props.onClick + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + expect(tree).toEqual({ + { + isStateEditable = true, + id = 1, + name = "State", + value = "a" :: any, + subHooks = {}, + }, + { + isStateEditable = true, + id = 2, + name = "Reducer", + value = "b", + subHooks = {}, + }, + { + isStateEditable = false, + id = 3, + name = "Ref", + value = "c", + subHooks = {}, + }, + { + isStateEditable = false, + id = 4, + name = "InsertionEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + id = 5, + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + id = 6, + name = "Effect", + value = effect, + subHooks = {}, + }, + -- ROBLOX DEVIATION: React-Luau's one-based IDs are shifted again after + -- the injected Effect Event; all subsequent React 18 IDs move by one. + { + isStateEditable = false, + id = 7, + name = "EffectEvent", + value = effectEvent, + subHooks = {}, + }, + { + isStateEditable = false, + id = 8, + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + id = 9, + name = "Memo", + value = "ab", + subHooks = {}, + }, + { + isStateEditable = false, + id = 10, + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + + updateStates() + childFiber = renderer.root:findByType(Foo):_currentFiber() + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + expect(tree).toEqual({ + { + isStateEditable = true, + id = 1, + name = "State", + value = "A" :: any, + subHooks = {}, + }, + { + isStateEditable = true, + id = 2, + name = "Reducer", + value = "B", + subHooks = {}, + }, + { + isStateEditable = false, + id = 3, + name = "Ref", + value = "C", + subHooks = {}, + }, + { + isStateEditable = false, + id = 4, + name = "InsertionEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + id = 5, + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + id = 6, + name = "Effect", + value = effect, + subHooks = {}, + }, + -- ROBLOX DEVIATION: The injected Effect Event shifts the remaining + -- one-based React 18 hook IDs in this updated inspection as well. + { + isStateEditable = false, + id = 7, + name = "EffectEvent", + value = effectEvent, + subHooks = {}, + }, + { + isStateEditable = false, + id = 8, + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + id = 9, + name = "Memo", + value = "Ab", + subHooks = {}, + }, + { + isStateEditable = false, + id = 10, + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + end + ) it("should inspect the value of the current provider in useContext", function() local MyContext = React.createContext("default") local function Foo(props) @@ -496,6 +705,8 @@ describe("ReactHooksInspectionIntegration", function() }, }) end) + -- ROBLOX DEVIATION: This upstream custom-hook assertion also covers Roblox's + -- current [string "..."] stack-frame format used to build the hook tree. it("should inspect custom hooks", function() local function useCustom() -- ROBLOX deviation START: useState returns 2 values @@ -538,6 +749,30 @@ describe("ReactHooksInspectionIntegration", function() }, }) end) -- @gate experimental + -- ROBLOX DEVIATION: A single nil Luau memo result must not be exposed as its + -- packed return table. + it("should inspect a nil useMemo value", function() + local function Foo() + React.useMemo(function() + return nil + end, {}) + return React.createElement("Frame") + end + + local renderer = ReactTestRenderer.create(React.createElement(Foo)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + + expect(tree).toEqual({ + { + id = 1, + isStateEditable = false, + name = "Memo", + value = nil :: any, + subHooks = {}, + }, + }) + end) -- ROBLOX deviation START: unstable_useTransition is not implemented -- it("should support composite useTransition hook", function() it.skip("should support composite useTransition hook", function() diff --git a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua index f40a053b..38d9e71f 100644 --- a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua @@ -46,6 +46,7 @@ local LuauPolyfill = require(Packages.LuauPolyfill) local Error = LuauPolyfill.Error local Set = LuauPolyfill.Set type Array = { [number]: T } +type Function = (...any) -> ...any local __DEV__ = ReactGlobals.__DEV__ :: boolean local __YOLO__ = ReactGlobals.__YOLO__ :: boolean @@ -82,8 +83,13 @@ type Effect = { deps: Array?, next: Effect, } +type EventFunctionPayload = { + ref: { impl: Function }, + nextImpl: Function, +} type FunctionComponentUpdateQueue = { lastEffect: Effect?, + events: Array?, } local ReactTypes = require(Packages.Shared) @@ -229,6 +235,7 @@ end local NoHookEffect = ReactHookEffectTags.NoFlags local HookHasEffect = ReactHookEffectTags.HasEffect +local HookInsertion = ReactHookEffectTags.Insertion local HookLayout = ReactHookEffectTags.Layout local HookPassive = ReactHookEffectTags.Passive @@ -336,8 +343,22 @@ local function commitBeforeMutationLifeCycles( finishedWork.tag == FunctionComponent or finishedWork.tag == ForwardRef or finishedWork.tag == SimpleMemoComponent - or finishedWork.tag == Block then + -- ROBLOX upstream: https://github.com/facebook/react/blob/6bec011b407fe8a2d4babb363289ccce4bc8fcf3/packages/react-reconciler/src/ReactFiberCommitWork.js#L495-L513 + if + ReactFeatureFlags.enableUseEffectEventHook + and bit32.band(finishedWork.flags, Update) ~= NoFlags + then + local updateQueue: FunctionComponentUpdateQueue? = finishedWork.updateQueue + local eventPayloads = if updateQueue ~= nil then updateQueue.events else nil + if eventPayloads ~= nil then + for _, eventPayload in eventPayloads do + eventPayload.ref.impl = eventPayload.nextImpl + end + end + end + return + elseif finishedWork.tag == Block then return elseif finishedWork.tag == ClassComponent then if bit32.band(finishedWork.flags, Snapshot) ~= 0 then @@ -414,11 +435,14 @@ local function commitBeforeMutationLifeCycles( -- Nothing to do for these component types return end - invariant( - false, - "This unit of work tag should not have side-effects. This error is " - .. "likely caused by a bug in React. Please file an issue." - ) + -- ROBLOX upstream: https://github.com/facebook/react/blob/6bec011b407fe8a2d4babb363289ccce4bc8fcf3/packages/react-reconciler/src/ReactFiberCommitWork.js#L560-L567 + if bit32.band(finishedWork.flags, Snapshot) ~= NoFlags then + invariant( + false, + "This unit of work tag should not have side-effects. This error is " + .. "likely caused by a bug in React. Please file an issue." + ) + end end local function commitHookEffectListUnmount( @@ -465,20 +489,34 @@ local function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) if __DEV__ then local destroy = effect.destroy if destroy ~= nil and typeof(destroy) ~= "function" then + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L583-L626 + local hookName + if bit32.band(effect.tag, HookLayout) ~= NoHookEffect then + hookName = "useLayoutEffect" + elseif bit32.band(effect.tag, HookInsertion) ~= NoHookEffect then + hookName = "useInsertionEffect" + else + hookName = "useEffect" + end + -- ROBLOX DEVIATION: Luau nil represents both JavaScript undefined and + -- null, so nil bypasses this branch and the null-only addendum cannot run. local addendum - if destroy == nil then - addendum = " You returned nil. If your effect does not require clean " - .. "up, return nil (or nothing)." - elseif typeof(destroy.andThen) == "function" then + if + typeof(destroy) == "table" + and typeof(destroy.andThen) == "function" + then + -- ROBLOX DEVIATION: Luau Promises expose andThen rather than JavaScript + -- then, and the diagnostic example uses the corresponding Promise syntax. addendum = -- ROBLOX FIXME: write a real program that does the equivalent and update this example, LUAFDN-754 - "\n\nIt looks like you wrote useEffect(Promise.new(function() --[[...]] end) or returned a Promise. " .. "Instead, write the async function inside your effect " .. "and call it immediately:\n\n" .. "useEffect(function()\n" .. " function fetchData()\n" .. " -- You can await here\n" .. " local response = MyAPI.getData(someId):await()\n" .. " -- ...\n" .. " end\n" .. " fetchData()\n" .. "end, {someId}) -- Or {} if effect doesn't need props or state\n\n" .. "Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching" + "\n\nIt looks like you wrote " .. hookName .. "(Promise.new(function() --[[...]] end) or returned a Promise. " .. "Instead, write the async function inside your effect " .. "and call it immediately:\n\n" .. hookName .. "(function()\n" .. " function fetchData()\n" .. " -- You can await here\n" .. " local response = MyAPI.getData(someId):await()\n" .. " -- ...\n" .. " end\n" .. " fetchData()\n" .. "end, {someId}) -- Or {} if effect doesn't need props or state\n\n" .. "Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching" else - addendum = " You returned: " .. destroy + addendum = " You returned: " .. tostring(destroy) end console.error( - "An effect function must not return anything besides a function, " + "%s must not return anything besides a function, " .. "which is used for clean-up.%s", + hookName, addendum ) end @@ -1195,7 +1233,14 @@ function commitUnmount( local effect = firstEffect repeat if effect.destroy ~= nil then - if bit32.band(effect.tag, HookLayout) ~= NoHookEffect then + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L1226-L1252 + if bit32.band(effect.tag, HookInsertion) ~= NoHookEffect then + safelyCallDestroy( + current, + nearestMountedAncestor, + effect.destroy + ) + elseif bit32.band(effect.tag, HookLayout) ~= NoHookEffect then if enableProfilerTimer and enableProfilerCommitHooks @@ -1865,6 +1910,14 @@ local function commitWork(current: Fiber | nil, finishedWork: Fiber) or finishedWork.tag == SimpleMemoComponent or finishedWork.tag == Block then + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L1893-L1903 + commitHookEffectListUnmount( + bit32.bor(HookInsertion, HookHasEffect), + finishedWork, + finishedWork.return_ + ) + commitHookEffectListMount(bit32.bor(HookInsertion, HookHasEffect), finishedWork) + -- Layout effects are destroyed during the mutation phase so that all -- destroy functions for all fibers are called before any create functions. -- This prevents sibling component effects from interfering with each other, diff --git a/modules/react-reconciler/src/ReactFiberFlags.lua b/modules/react-reconciler/src/ReactFiberFlags.lua index e51c43aa..4db7a6ad 100644 --- a/modules/react-reconciler/src/ReactFiberFlags.lua +++ b/modules/react-reconciler/src/ReactFiberFlags.lua @@ -73,8 +73,11 @@ exports.PassiveStatic = --[[ ]] 0b001000000000000000 -- Union of side effect groupings as pertains to subtreeFlags +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberFlags.js#L95-L108 +-- ROBLOX DEVIATION: The single React-Luau build enables Effect Events, so the +-- upstream feature-flagged Update bit is baked into this older numeric mask. exports.BeforeMutationMask = --[[ ]] - 0b000000001100001010 + 0b000000001100001110 exports.MutationMask = --[[ ]] 0b000000010010011110 exports.LayoutMask = --[[ ]] diff --git a/modules/react-reconciler/src/ReactFiberHooks.new.lua b/modules/react-reconciler/src/ReactFiberHooks.new.lua index 37269d3c..0cb6d671 100644 --- a/modules/react-reconciler/src/ReactFiberHooks.new.lua +++ b/modules/react-reconciler/src/ReactFiberHooks.new.lua @@ -91,6 +91,7 @@ local PassiveStaticEffect = ReactFiberFlags.PassiveStatic local MountLayoutDevEffect = ReactFiberFlags.MountLayoutDev local MountPassiveDevEffect = ReactFiberFlags.MountPassiveDev local HookHasEffect = ReactHookEffectTags.HasEffect +local HookInsertion = ReactHookEffectTags.Insertion local HookLayout = ReactHookEffectTags.Layout local HookPassive = ReactHookEffectTags.Passive local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any @@ -156,6 +157,7 @@ local FFlagReactCleanQueueOnUpdateBailout = -- deviation: common types type Array = { [number]: T } +type Function = (...any) -> ...any type Update = { lane: Lane, @@ -197,8 +199,19 @@ export type Effect = { next: Effect, } +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L239-L250 +-- ROBLOX DEVIATION: The local ref type models the initialized client value; +-- upstream's uninitialized eventFn type field is not read by this runtime. +export type EventFunctionPayload = { + ref: { impl: Function }, + nextImpl: Function, +} + export type FunctionComponentUpdateQueue = { lastEffect: Effect?, + -- ROBLOX DEVIATION: This React 17 queue has no v19 store-consistency or + -- memo-cache slots; Effect Events add only the events slot they consume. + events: Array?, } type BasicStateAction = ((S) -> S) | S @@ -1228,6 +1241,7 @@ local function pushEffect(tag, create, destroy, deps) -- componentUpdateQueue = createFunctionComponentUpdateQueue() componentUpdateQueue = { lastEffect = nil, + events = nil, } currentlyRenderingFiber.updateQueue = componentUpdateQueue effect.next = effect @@ -1362,6 +1376,75 @@ local function updateEffect( updateEffectImpl(PassiveEffect, HookPassive, create, deps) end +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L2703-L2755 +local function useEffectEventImpl(payload: EventFunctionPayload) + currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, UpdateEffect) + local componentUpdateQueue: FunctionComponentUpdateQueue = + currentlyRenderingFiber.updateQueue :: any + if componentUpdateQueue == nil then + -- ROBLOX DEVIATION: The existing React-Luau hot-path optimization inlines + -- createFunctionComponentUpdateQueue while preserving its nil fields. + componentUpdateQueue = { + lastEffect = nil, + events = { payload }, + } + currentlyRenderingFiber.updateQueue = componentUpdateQueue + elseif componentUpdateQueue.events == nil then + componentUpdateQueue.events = { payload } + else + table.insert(componentUpdateQueue.events, payload) + end +end + +local function mountEvent(callback) + local hook = mountWorkInProgressHook() + local ref = { impl = callback } + hook.memoizedState = ref + return function(...) + if ReactFiberWorkLoop.isInvalidExecutionContextForEventFunction() then + error( + Error.new( + "A function wrapped in useEffectEvent can't be called during rendering." + ) + ) + end + return ref.impl(...) + end +end + +local function updateEvent(callback) + local hook = updateWorkInProgressHook() + local ref = hook.memoizedState + useEffectEventImpl({ ref = ref, nextImpl = callback }) + return function(...) + if ReactFiberWorkLoop.isInvalidExecutionContextForEventFunction() then + error( + Error.new( + "A function wrapped in useEffectEvent can't be called during rendering." + ) + ) + end + return ref.impl(...) + end +end + +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L1734-L1746 +local function mountInsertionEffect( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + mountEffectImpl(UpdateEffect, HookInsertion, create, deps) +end + +local function updateInsertionEffect( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + updateEffectImpl(UpdateEffect, HookInsertion, create, deps) +end + local function mountLayoutEffect( -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically create: (() -> ()) | (() -> () -> ()), @@ -1932,6 +2015,8 @@ local ContextOnlyDispatcher: Dispatcher = { useCallback = throwInvalidHookError :: any, useContext = throwInvalidHookError :: any, useEffect = throwInvalidHookError :: any, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2399-L2414 + useInsertionEffect = throwInvalidHookError :: any, useImperativeHandle = throwInvalidHookError :: any, useLayoutEffect = throwInvalidHookError :: any, useMemo = throwInvalidHookError :: any, @@ -1947,6 +2032,10 @@ local ContextOnlyDispatcher: Dispatcher = { unstable_isNewReconciler = enableNewReconciler, } +if ReactFeatureFlags.enableUseEffectEventHook then + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L3890-L3892 + ContextOnlyDispatcher.useEffectEvent = throwInvalidHookError :: any +end exports.ContextOnlyDispatcher = ContextOnlyDispatcher local HooksDispatcherOnMount: Dispatcher = { @@ -1955,6 +2044,8 @@ local HooksDispatcherOnMount: Dispatcher = { useCallback = mountCallback, useContext = readContext, useEffect = mountEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2427-L2443 + useInsertionEffect = mountInsertionEffect, useImperativeHandle = mountImperativeHandle, useLayoutEffect = mountLayoutEffect, -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 @@ -1978,6 +2069,8 @@ local HooksDispatcherOnUpdate: Dispatcher = { useCallback = updateCallback, useContext = readContext, useEffect = updateEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2454-L2470 + useInsertionEffect = updateInsertionEffect, useImperativeHandle = updateImperativeHandle, useLayoutEffect = updateLayoutEffect, -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 @@ -2001,6 +2094,8 @@ local HooksDispatcherOnRerender: Dispatcher = { useCallback = updateCallback, useContext = readContext, useEffect = updateEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2482-L2498 + useInsertionEffect = updateInsertionEffect, useImperativeHandle = updateImperativeHandle, useLayoutEffect = updateLayoutEffect, -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 @@ -2018,6 +2113,18 @@ local HooksDispatcherOnRerender: Dispatcher = { unstable_isNewReconciler = enableNewReconciler, } +if ReactFeatureFlags.enableUseEffectEventHook then + local effectEventDispatcher = HooksDispatcherOnMount + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L3920-L3922 + effectEventDispatcher.useEffectEvent = mountEvent + effectEventDispatcher = HooksDispatcherOnUpdate + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L3950-L3952 + effectEventDispatcher.useEffectEvent = updateEvent + effectEventDispatcher = HooksDispatcherOnRerender + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L3980-L3982 + effectEventDispatcher.useEffectEvent = updateEvent +end + if __DEV__ then local warnInvalidContextAccess = function() console.error( @@ -3130,6 +3237,117 @@ if __DEV__ then unstable_isNewReconciler = enableNewReconciler, } + + -- ROBLOX DEVIATION: React-Luau augments its legacy DEV dispatcher tables + -- after construction rather than duplicating the complete upstream tables. + local insertionEffectDispatcher = HooksDispatcherOnMountInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2571-L2579 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + return mountInsertionEffect(create, deps) + end + insertionEffectDispatcher = HooksDispatcherOnMountWithHookTypesInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2716-L2723 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + updateHookTypesDev() + return mountInsertionEffect(create, deps) + end + insertionEffectDispatcher = HooksDispatcherOnUpdateInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2858-L2865 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + updateHookTypesDev() + return updateInsertionEffect(create, deps) + end + insertionEffectDispatcher = HooksDispatcherOnRerenderInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L3001-L3008 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + updateHookTypesDev() + return updateInsertionEffect(create, deps) + end + insertionEffectDispatcher = InvalidNestedHooksDispatcherOnMountInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L3148-L3156 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + warnInvalidHookAccess() + mountHookTypesDev() + return mountInsertionEffect(create, deps) + end + insertionEffectDispatcher = InvalidNestedHooksDispatcherOnUpdateInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L3307-L3315 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateInsertionEffect(create, deps) + end + insertionEffectDispatcher = InvalidNestedHooksDispatcherOnRerenderInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L3467-L3475 + insertionEffectDispatcher.useInsertionEffect = function(create, deps) + currentHookNameInDev = "useInsertionEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateInsertionEffect(create, deps) + end + + if ReactFeatureFlags.enableUseEffectEventHook then + local effectEventDispatcher = HooksDispatcherOnMountInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L4173-L4182 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + mountHookTypesDev() + return mountEvent(callback) + end + effectEventDispatcher = HooksDispatcherOnMountWithHookTypesInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L4340-L4349 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + updateHookTypesDev() + return mountEvent(callback) + end + effectEventDispatcher = HooksDispatcherOnUpdateInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L4507-L4516 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + updateHookTypesDev() + return updateEvent(callback) + end + effectEventDispatcher = HooksDispatcherOnRerenderInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L4674-L4683 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + updateHookTypesDev() + return updateEvent(callback) + end + effectEventDispatcher = InvalidNestedHooksDispatcherOnMountInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L4865-L4875 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + warnInvalidHookAccess() + mountHookTypesDev() + return mountEvent(callback) + end + effectEventDispatcher = InvalidNestedHooksDispatcherOnUpdateInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L5057-L5067 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + warnInvalidHookAccess() + updateHookTypesDev() + return updateEvent(callback) + end + effectEventDispatcher = InvalidNestedHooksDispatcherOnRerenderInDEV :: Dispatcher + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberHooks.js#L5249-L5259 + effectEventDispatcher.useEffectEvent = function(callback) + currentHookNameInDev = "useEffectEvent" + warnInvalidHookAccess() + updateHookTypesDev() + return updateEvent(callback) + end + end end local function renderWithHooks( diff --git a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua index e1a1429a..e5a7c781 100644 --- a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua +++ b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua @@ -1197,6 +1197,11 @@ exports.getExecutionContext = function(): ExecutionContext return executionContext end +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1871-L1874 +exports.isInvalidExecutionContextForEventFunction = function(): boolean + return bit32.band(executionContext, RenderContext) ~= NoContext +end + exports.flushDiscreteUpdates = function() -- TODO: Should be able to flush inside batchedUpdates, but not inside `act`. -- However, `act` uses `batchedUpdates`, so there's no way to distinguish @@ -2560,7 +2565,13 @@ mod.commitBeforeMutationEffectsImpl = function(fiber: Fiber) end end - if bit32.band(flags, ReactFiberFlags.Snapshot) ~= ReactFiberFlags.NoFlags then + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactFiberFlags.js#L95-L108 + -- ROBLOX DEVIATION: This React 17 work loop retains an explicit per-fiber + -- before-mutation gate, so Effect Event Update work joins Snapshot here. + if + bit32.band(flags, bit32.bor(ReactFiberFlags.Snapshot, ReactFiberFlags.Update)) + ~= ReactFiberFlags.NoFlags + then setCurrentDebugFiberInDEV(fiber) commitBeforeMutationEffectOnFiber(current, fiber) resetCurrentDebugFiberInDEV() diff --git a/modules/react-reconciler/src/ReactHookEffectTags.lua b/modules/react-reconciler/src/ReactHookEffectTags.lua index 2a768be8..ab29a654 100644 --- a/modules/react-reconciler/src/ReactHookEffectTags.lua +++ b/modules/react-reconciler/src/ReactHookEffectTags.lua @@ -1,4 +1,4 @@ --- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactHookEffectTags.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactHookEffectTags.js#L12-L20 --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -13,15 +13,17 @@ export type HookFlags = number return { --[[ ]] - NoFlags = 0b000, + NoFlags = 0b0000, -- Represents whether effect should fire. --[[ ]] - HasEffect = 0b001, + HasEffect = 0b0001, -- Represents the phase in which the effect (not the clean-up) fires. + --[[ ]] + Insertion = 0b0010, --[[ ]] - Layout = 0b010, + Layout = 0b0100, --[[ ]] - Passive = 0b100, + Passive = 0b1000, } diff --git a/modules/react-reconciler/src/ReactInternalTypes.lua b/modules/react-reconciler/src/ReactInternalTypes.lua index 341a8d1e..00deb49c 100644 --- a/modules/react-reconciler/src/ReactInternalTypes.lua +++ b/modules/react-reconciler/src/ReactInternalTypes.lua @@ -89,6 +89,10 @@ export type HookType = -- ROBLOX deviation: Bindings are a feature unique to Roact | "useBinding" | "useEffect" + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactInternalTypes.js#L51 + | "useEffectEvent" + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactInternalTypes.js#L38 + | "useInsertionEffect" | "useLayoutEffect" | "useCallback" | "useMemo" diff --git a/modules/react-reconciler/src/__tests__/useEffectEvent.spec.lua b/modules/react-reconciler/src/__tests__/useEffectEvent.spec.lua new file mode 100644 index 00000000..58bb5dbe --- /dev/null +++ b/modules/react-reconciler/src/__tests__/useEffectEvent.spec.lua @@ -0,0 +1,1006 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-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 + * @jest-environment node + ]] + +local Packages = script.Parent.Parent.Parent +local React +local LuauPolyfill +local clearTimeout +local setTimeout +local ReactNoop +local Scheduler +local act +local useEffectEvent +local useEffect +local useInsertionEffect +local useLayoutEffect +local useState + +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 + +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + + LuauPolyfill = require(Packages.LuauPolyfill) + clearTimeout = LuauPolyfill.clearTimeout + setTimeout = LuauPolyfill.setTimeout + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + + act = ReactNoop.act + useEffectEvent = React.useEffectEvent + useEffect = React.useEffect + useInsertionEffect = React.useInsertionEffect + useLayoutEffect = React.useLayoutEffect + useState = React.useState +end) + +local function span(prop) + return { type = "span", hidden = false, children = {}, prop = prop } +end + +local function Text(props) + Scheduler.unstable_yieldValue(props.text) + return React.createElement("span", { prop = props.text }) +end + +describe("useEffectEvent", function() + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L56-L130 + it("memoizes basic case correctly", function() + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local onClick = useEffectEvent(function() + updateCount(function(currentCount) + return currentCount + incrementBy + end) + end) + + return React.createElement(React.Fragment, nil, { + React.createElement(IncrementButton, { + key = "button", + onClick = function() + onClick() + end, + ref = button, + }), + React.createElement(Text, { + key = "count", + text = "Count: " .. count, + }), + }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 0"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 1"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 2" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) + + ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) + jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 2" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 12" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 12"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L132-L192 + it("can be defined more than once", function() + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:multiply() + self.props.onMouseEnter() + end + + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local onClick = useEffectEvent(function() + updateCount(function(currentCount) + return currentCount + incrementBy + end) + end) + local onMouseEnter = useEffectEvent(function() + updateCount(function(currentCount) + return currentCount * incrementBy + end) + end) + + return React.createElement(React.Fragment, nil, { + React.createElement(IncrementButton, { + key = "button", + onClick = function() + onClick() + end, + onMouseEnter = function() + onMouseEnter() + end, + ref = button, + }), + React.createElement(Text, { + key = "count", + text = "Count: " .. count, + }), + }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 5 })) + jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 0"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 5" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 5"), + }) + + act(function() + button.current:multiply() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 25" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 25"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L194-L242 + it("does not preserve `this` in event functions", function() + local button = React.createRef() + local GreetButton = React.PureComponent:extend("GreetButton") + + function GreetButton:greet() + self.props.onClick() + end + + function GreetButton:render() + return React.createElement(Text, { text = "Say " .. self.props.hello }) + end + + local function Greeter(props) + local hello = props.hello + local greeting, updateGreeting = useState("Seb says " .. hello) + local person = setmetatable({ + greet = function(self) + updateGreeting(tostring(self) .. " says " .. hello) + end, + }, { + __tostring = function() + return "Jane" + end, + }) + local onClick = useEffectEvent(person.greet) + + return React.createElement(React.Fragment, nil, { + React.createElement(GreetButton, { + key = "button", + hello = hello, + onClick = function() + onClick() + end, + ref = button, + }), + React.createElement(Text, { + key = "greeting", + text = "Greeting: " .. greeting, + }), + }) + end + + ReactNoop.render(React.createElement(Greeter, { hello = "hej" })) + jestExpect(Scheduler).toFlushAndYield({ "Say hej", "Greeting: Seb says hej" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Say hej"), + span("Greeting: Seb says hej"), + }) + + act(function() + button.current:greet() + end) + -- ROBLOX deviation: Luau's receiver-less value is nil rather than JavaScript's undefined. + jestExpect(Scheduler).toHaveYielded({ "Say hej", "Greeting: nil says hej" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Say hej"), + span("Greeting: nil says hej"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L244-L276 + it("throws when called in render", function() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:render() + self.props.onClick() + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local onClick = useEffectEvent(function() + updateCount(function(currentCount) + return currentCount + incrementBy + end) + end) + + return React.createElement( + React.Fragment, + nil, + React.createElement(IncrementButton, { + onClick = function() + onClick() + end, + }), + React.createElement(Text, { text = "Count: " .. count }) + ) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndThrow( + "A function wrapped in useEffectEvent can't be called during rendering." + ) + -- ROBLOX DEVIATION: React-Luau's React 17 recovery renders the sibling + -- during the concurrent attempt and synchronous retry before surfacing + -- the uncaught root error. + jestExpect(Scheduler).toHaveYielded({ "Count: 0", "Count: 0" }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L278-L376 + it("useLayoutEffect shouldn't re-fire when event handlers change", function() + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local increment = useEffectEvent(function(amount) + updateCount(function(currentCount) + local resolvedAmount = if amount == nil or amount == 0 + then incrementBy + else amount + return currentCount + resolvedAmount + end) + end) + + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Effect: by " .. incrementBy * 2) + increment(incrementBy * 2) + end, { incrementBy }) + + return React.createElement(React.Fragment, nil, { + React.createElement(IncrementButton, { + key = "button", + onClick = function() + increment() + end, + ref = button, + }), + React.createElement(Text, { + key = "count", + text = "Count: " .. count, + }), + }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 0", + "Effect: by 2", + "Increment", + "Count: 2", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 3" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 3"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 4" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 4"), + }) + + ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 4", + "Effect: by 20", + "Increment", + "Count: 24", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 24"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 34" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 34"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L378-L475 + it("useEffect shouldn't re-fire when event handlers change", function() + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local increment = useEffectEvent(function(amount) + updateCount(function(currentCount) + local resolvedAmount = if amount == nil or amount == 0 + then incrementBy + else amount + return currentCount + resolvedAmount + end) + end) + + useEffect(function() + Scheduler.unstable_yieldValue("Effect: by " .. incrementBy * 2) + increment(incrementBy * 2) + end, { incrementBy }) + + return React.createElement(React.Fragment, nil, { + React.createElement(IncrementButton, { + key = "button", + onClick = function() + increment() + end, + ref = button, + }), + React.createElement(Text, { + key = "count", + text = "Count: " .. count, + }), + }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 0", + "Effect: by 2", + "Increment", + "Count: 2", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 3" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 3"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 4" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 4"), + }) + + ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 4", + "Effect: by 20", + "Increment", + "Count: 24", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 24"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 34" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 34"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L477-L580 + it("is stable in a custom hook", function() + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + + function IncrementButton:increment() + self.props.onClick() + end + + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + local function useCount(incrementBy) + local count, updateCount = useState(0) + local increment = useEffectEvent(function(amount) + updateCount(function(currentCount) + local resolvedAmount = if amount == nil or amount == 0 + then incrementBy + else amount + return currentCount + resolvedAmount + end) + end) + return count, increment + end + + local function Counter(props) + local incrementBy = props.incrementBy + local count, increment = useCount(incrementBy) + + useEffect(function() + Scheduler.unstable_yieldValue("Effect: by " .. incrementBy * 2) + increment(incrementBy * 2) + end, { incrementBy }) + + return React.createElement(React.Fragment, nil, { + React.createElement(IncrementButton, { + key = "button", + onClick = function() + increment() + end, + ref = button, + }), + React.createElement(Text, { + key = "count", + text = "Count: " .. count, + }), + }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 0", + "Effect: by 2", + "Increment", + "Count: 2", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 3" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 3"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 4" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 4"), + }) + + ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) + jestExpect(Scheduler).toFlushAndYield({ + "Increment", + "Count: 4", + "Effect: by 20", + "Increment", + "Count: 24", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 24"), + }) + + act(function() + button.current:increment() + end) + jestExpect(Scheduler).toHaveYielded({ "Increment", "Count: 34" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 34"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L582-L604 + it("is mutated before all other effects", function() + local function Counter(props) + local increment + useInsertionEffect(function() + Scheduler.unstable_yieldValue("Effect value: " .. props.value) + increment() + end, { props.value }) + + increment = useEffectEvent(function() + Scheduler.unstable_yieldValue("Event value: " .. props.value) + end) + + return React.createElement(React.Fragment) + end + + ReactNoop.render(React.createElement(Counter, { value = 1 })) + jestExpect(Scheduler).toFlushAndYield({ "Effect value: 1", "Event value: 1" }) + + act(function() + ReactNoop.render(React.createElement(Counter, { value = 2 })) + end) + jestExpect(Scheduler).toHaveYielded({ "Effect value: 2", "Event value: 2" }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L606-L643 + it("doesn't provide a stable identity", function() + local function Counter(props) + local onClick = useEffectEvent(function() + Scheduler.unstable_yieldValue( + "onClick, shouldRender=" + .. tostring(props.shouldRender) + .. ", value=" + .. props.value + ) + end) + + useEffect(function() + onClick() + end, { onClick }) + + useEffect(function() + onClick() + end, { props.shouldRender }) + + return React.createElement(React.Fragment) + end + + ReactNoop.render(React.createElement(Counter, { shouldRender = true, value = 0 })) + jestExpect(Scheduler).toFlushAndYield({ + "onClick, shouldRender=true, value=0", + "onClick, shouldRender=true, value=0", + }) + + ReactNoop.render(React.createElement(Counter, { shouldRender = true, value = 1 })) + jestExpect(Scheduler).toFlushAndYield({ "onClick, shouldRender=true, value=1" }) + + ReactNoop.render( + React.createElement(Counter, { shouldRender = false, value = 2 }) + ) + jestExpect(Scheduler).toFlushAndYield({ + "onClick, shouldRender=false, value=2", + "onClick, shouldRender=false, value=2", + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L645-L693 + it("event handlers always see the latest committed value", function() + local committedEventHandler + + local function App(props) + local event = useEffectEvent(function() + return "Value seen by useEffectEvent: " .. props.value + end) + + useEffect(function() + Scheduler.unstable_yieldValue("Commit new event handler") + committedEventHandler = event + return function() + committedEventHandler = nil + end + end, {}) + return "Latest rendered value " .. props.value + end + + local root = ReactNoop.createRoot() + act(function() + root.render(React.createElement(App, { value = 1 })) + end) + jestExpect(Scheduler).toHaveYielded({ "Commit new event handler" }) + jestExpect(root).toMatchRenderedOutput("Latest rendered value 1") + jestExpect(committedEventHandler()).toBe("Value seen by useEffectEvent: 1") + + act(function() + root.render(React.createElement(App, { value = 2 })) + end) + jestExpect(Scheduler).toHaveYielded({}) + jestExpect(root).toMatchRenderedOutput("Latest rendered value 2") + jestExpect(committedEventHandler()).toBe("Value seen by useEffectEvent: 2") + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L695-L782 + it("integration: implements docs chat room example", function() + local function createConnection() + local connectedCallback + local timeout + return { + connect = function() + timeout = setTimeout(function() + if connectedCallback ~= nil then + connectedCallback() + end + end, 100) + end, + on = function(event, callback) + if connectedCallback ~= nil then + error("Cannot add the handler twice.") + end + if event ~= "connected" then + error('Only "connected" event is supported.') + end + connectedCallback = callback + end, + disconnect = function() + clearTimeout(timeout) + end, + } + end + + local function ChatRoom(props) + local onConnected = useEffectEvent(function() + Scheduler.unstable_yieldValue("Connected! theme: " .. props.theme) + end) + + useEffect(function() + local connection = createConnection() + connection.on("connected", function() + onConnected() + end) + connection.connect() + return function() + connection.disconnect() + end + end, { props.roomId }) + + return React.createElement(Text, { + text = "Welcome to the " .. props.roomId .. " room!", + }) + end + + act(function() + ReactNoop.render(React.createElement(ChatRoom, { + roomId = "general", + theme = "light", + })) + end) + jest.advanceTimersByTime(100) + jestExpect(Scheduler).toHaveYielded({ + "Welcome to the general room!", + "Connected! theme: light", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Welcome to the general room!"), + }) + + act(function() + ReactNoop.render(React.createElement(ChatRoom, { + roomId = "music", + theme = "light", + })) + end) + jest.advanceTimersByTime(100) + jestExpect(Scheduler).toHaveYielded({ + "Welcome to the music room!", + "Connected! theme: light", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Welcome to the music room!"), + }) + + act(function() + ReactNoop.render(React.createElement(ChatRoom, { + roomId = "music", + theme = "dark", + })) + end) + jest.advanceTimersByTime(100) + jestExpect(Scheduler).toHaveYielded({ "Welcome to the music room!" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Welcome to the music room!"), + }) + + act(function() + ReactNoop.render(React.createElement(ChatRoom, { + roomId = "travel", + theme = "dark", + })) + end) + jest.advanceTimersByTime(100) + jestExpect(Scheduler).toHaveYielded({ + "Welcome to the travel room!", + "Connected! theme: dark", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Welcome to the travel room!"), + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L784-L852 + it("integration: implements the docs logVisit example", function() + local button = React.createRef() + local AddToCartButton = React.PureComponent:extend("AddToCartButton") + + function AddToCartButton:addToCart() + self.props.onClick() + end + + function AddToCartButton:render() + return React.createElement(Text, { text = "Add to cart" }) + end + + local ShoppingCartContext = React.createContext(nil) + + local function AppShell(props) + local items, updateItems = useState({}) + local value = React.useMemo(function() + return { items = items, updateItems = updateItems } + end, { items, updateItems }) + + return React.createElement( + ShoppingCartContext.Provider, + { value = value }, + props.children + ) + end + + local function Page(props) + local cart = React.useContext(ShoppingCartContext) + local items = cart.items + local updateItems = cart.updateItems + local onClick = useEffectEvent(function() + local nextItems = table.clone(items) + table.insert(nextItems, 1) + updateItems(nextItems) + end) + local numberOfItems = #items + + local onVisit = useEffectEvent(function(visitedUrl) + Scheduler.unstable_yieldValue( + "url: " .. visitedUrl .. ", numberOfItems: " .. numberOfItems + ) + end) + + useEffect(function() + onVisit(props.url) + end, { props.url }) + + return React.createElement(AddToCartButton, { + onClick = function() + onClick() + end, + ref = button, + }) + end + + act(function() + ReactNoop.render( + React.createElement( + AppShell, + nil, + React.createElement(Page, { url = "/shop/1" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({ + "Add to cart", + "url: /shop/1, numberOfItems: 0", + }) + + act(function() + button.current:addToCart() + end) + jestExpect(Scheduler).toHaveYielded({ "Add to cart" }) + + act(function() + ReactNoop.render( + React.createElement( + AppShell, + nil, + React.createElement(Page, { url = "/shop/2" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({ + "Add to cart", + "url: /shop/2, numberOfItems: 1", + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/6bec011b407fe8a2d4babb363289ccce4bc8fcf3/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L854-L891 + it("reads the latest context value in memo Components", function() + local MyContext = React.createContext("default") + local logContextValue + local ContextReader = React.memo(function() + local value = React.useContext(MyContext) + Scheduler.unstable_yieldValue("ContextReader: " .. value) + local fireLogContextValue = useEffectEvent(function() + Scheduler.unstable_yieldValue("ContextReader (Effect event): " .. value) + end) + useEffect(function() + logContextValue = fireLogContextValue + end, {}) + return nil + end) + + local function App(props) + return React.createElement( + MyContext.Provider, + { value = props.value }, + React.createElement(ContextReader) + ) + end + + local root = ReactNoop.createRoot() + act(function() + root.render(React.createElement(App, { value = "first" })) + end) + jestExpect(Scheduler).toHaveYielded({ "ContextReader: first" }) + + logContextValue() + jestExpect(Scheduler).toHaveYielded({ "ContextReader (Effect event): first" }) + + act(function() + root.render(React.createElement(App, { value = "second" })) + end) + jestExpect(Scheduler).toHaveYielded({ "ContextReader: second" }) + + logContextValue() + jestExpect(Scheduler).toHaveYielded({ "ContextReader (Effect event): second" }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/6bec011b407fe8a2d4babb363289ccce4bc8fcf3/packages/react-reconciler/src/__tests__/useEffectEvent-test.js#L893-L930 + it("reads the latest context value in forwardRef Components", function() + local MyContext = React.createContext("default") + local logContextValue + local ContextReader = React.forwardRef(function(_props, _ref) + local value = React.useContext(MyContext) + Scheduler.unstable_yieldValue("ContextReader: " .. value) + local fireLogContextValue = useEffectEvent(function() + Scheduler.unstable_yieldValue("ContextReader (Effect event): " .. value) + end) + useEffect(function() + logContextValue = fireLogContextValue + end, {}) + return nil + end) + + local function App(props) + return React.createElement( + MyContext.Provider, + { value = props.value }, + React.createElement(ContextReader) + ) + end + + local root = ReactNoop.createRoot() + act(function() + root.render(React.createElement(App, { value = "first" })) + end) + jestExpect(Scheduler).toHaveYielded({ "ContextReader: first" }) + + logContextValue() + jestExpect(Scheduler).toHaveYielded({ "ContextReader (Effect event): first" }) + + act(function() + root.render(React.createElement(App, { value = "second" })) + end) + jestExpect(Scheduler).toHaveYielded({ "ContextReader: second" }) + + logContextValue() + jestExpect(Scheduler).toHaveYielded({ "ContextReader (Effect event): second" }) + end) +end) diff --git a/modules/react-reconciler/src/__tests__/useInsertionEffect.spec.lua b/modules/react-reconciler/src/__tests__/useInsertionEffect.spec.lua new file mode 100644 index 00000000..8bc77f10 --- /dev/null +++ b/modules/react-reconciler/src/__tests__/useInsertionEffect.spec.lua @@ -0,0 +1,510 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L2691-L3093 +--[[* + * Copyright (c) Facebook, Inc. and its 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 + * @jest-environment node + ]] + +local Packages = script.Parent.Parent.Parent +local Promise = require(Packages.Promise) +local React +local ReactNoop +local Scheduler +local act +local LegacyHidden +local useEffect +local useInsertionEffect +local useLayoutEffect +local useMemo + +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 + +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + + act = ReactNoop.act + LegacyHidden = React.unstable_LegacyHidden + useEffect = React.useEffect + useInsertionEffect = React.useInsertionEffect + useLayoutEffect = React.useLayoutEffect + useMemo = React.useMemo +end) + +local function span(prop, hidden) + return React.createElement( + "span", + if hidden == true then { prop = prop, hidden = true } else { prop = prop } + ) +end + +local function Text(props) + Scheduler.unstable_yieldValue(props.text) + return React.createElement("span", { prop = props.text }) +end + +describe("useInsertionEffect", function() + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L2692-L2749 + it("fires insertion effects after snapshots on update", function() + local function CounterA() + useInsertionEffect(function() + Scheduler.unstable_yieldValue("Create insertion") + return function() + Scheduler.unstable_yieldValue("Destroy insertion") + end + end) + return nil + end + + local CounterB = React.Component:extend("CounterB") + + function CounterB:getSnapshotBeforeUpdate() + Scheduler.unstable_yieldValue("Get Snapshot") + return nil + end + + function CounterB:componentDidUpdate() end + + function CounterB:render() + return nil + end + + local function renderCounters() + return React.createElement( + React.Fragment, + nil, + React.createElement(CounterA), + React.createElement(CounterB) + ) + end + + act(function() + ReactNoop.render(renderCounters()) + jestExpect(Scheduler).toFlushAndYield({ "Create insertion" }) + end) + + act(function() + ReactNoop.render(renderCounters()) + jestExpect(Scheduler).toFlushAndYield({ + "Get Snapshot", + "Destroy insertion", + "Create insertion", + }) + end) + + act(function() + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYield({ "Destroy insertion" }) + end) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L2751-L2811 + it("fires insertion effects before layout effects", function() + local committedText = "(empty)" + + local function Counter(props) + useInsertionEffect(function() + Scheduler.unstable_yieldValue( + "Create insertion [current: " .. committedText .. "]" + ) + committedText = tostring(props.count) + return function() + Scheduler.unstable_yieldValue( + "Destroy insertion [current: " .. committedText .. "]" + ) + end + end) + useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "Create layout [current: " .. committedText .. "]" + ) + return function() + Scheduler.unstable_yieldValue( + "Destroy layout [current: " .. committedText .. "]" + ) + end + end) + useEffect(function() + Scheduler.unstable_yieldValue( + "Create passive [current: " .. committedText .. "]" + ) + return function() + Scheduler.unstable_yieldValue( + "Destroy passive [current: " .. committedText .. "]" + ) + end + end) + return nil + end + + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 })) + jestExpect(Scheduler).toFlushUntilNextPaint({ + "Create insertion [current: (empty)]", + "Create layout [current: 0]", + }) + jestExpect(committedText).toEqual("0") + end) + + jestExpect(Scheduler).toHaveYielded({ "Create passive [current: 0]" }) + + act(function() + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushUntilNextPaint({ + "Destroy insertion [current: 0]", + "Destroy layout [current: 0]", + }) + end) + + jestExpect(Scheduler).toHaveYielded({ "Destroy passive [current: 0]" }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L2813-L2878 + it("force flushes passive effects before firing new insertion effects", function() + local committedText = "(empty)" + local root = ReactNoop.createRoot() + + local function Counter(props) + useInsertionEffect(function() + Scheduler.unstable_yieldValue( + "Create insertion [current: " .. committedText .. "]" + ) + committedText = tostring(props.count) + return function() + Scheduler.unstable_yieldValue( + "Destroy insertion [current: " .. committedText .. "]" + ) + end + end) + useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "Create layout [current: " .. committedText .. "]" + ) + committedText = tostring(props.count) + return function() + Scheduler.unstable_yieldValue( + "Destroy layout [current: " .. committedText .. "]" + ) + end + end) + useEffect(function() + Scheduler.unstable_yieldValue( + "Create passive [current: " .. committedText .. "]" + ) + return function() + Scheduler.unstable_yieldValue( + "Destroy passive [current: " .. committedText .. "]" + ) + end + end) + return nil + end + + act(function() + -- ROBLOX DEVIATION: A concurrent root supplies the asynchronous update + -- window that upstream creates with startTransition, which is a separate + -- React-Luau backport. + root.render(React.createElement(Counter, { count = 0 })) + jestExpect(Scheduler).toFlushUntilNextPaint({ + "Create insertion [current: (empty)]", + "Create layout [current: 0]", + }) + jestExpect(committedText).toEqual("0") + + root.render(React.createElement(Counter, { count = 1 })) + jestExpect(Scheduler).toFlushUntilNextPaint({ + "Create passive [current: 0]", + "Destroy insertion [current: 0]", + "Create insertion [current: 0]", + "Destroy layout [current: 1]", + "Create layout [current: 1]", + }) + jestExpect(committedText).toEqual("1") + end) + + jestExpect(Scheduler).toHaveYielded({ + "Destroy passive [current: 1]", + "Create passive [current: 1]", + }) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L2880-L3044 + it( + "fires all insertion effects (interleaved) before firing any layout effects", + function() + local committedA = "(empty)" + local committedB = "(empty)" + + -- ROBLOX DEVIATION: This helper translates upstream template literals. + local function logState(message) + Scheduler.unstable_yieldValue( + message .. " [A: " .. committedA .. ", B: " .. committedB .. "]" + ) + end + + local function CounterA(props) + useInsertionEffect(function() + logState("Create Insertion 1 for Component A") + committedA = tostring(props.count) + return function() + logState("Destroy Insertion 1 for Component A") + end + end) + useInsertionEffect(function() + logState("Create Insertion 2 for Component A") + committedA = tostring(props.count) + return function() + logState("Destroy Insertion 2 for Component A") + end + end) + useLayoutEffect(function() + logState("Create Layout 1 for Component A") + return function() + logState("Destroy Layout 1 for Component A") + end + end) + useLayoutEffect(function() + logState("Create Layout 2 for Component A") + return function() + logState("Destroy Layout 2 for Component A") + end + end) + return nil + end + + local function CounterB(props) + useInsertionEffect(function() + logState("Create Insertion 1 for Component B") + committedB = tostring(props.count) + return function() + logState("Destroy Insertion 1 for Component B") + end + end) + useInsertionEffect(function() + logState("Create Insertion 2 for Component B") + committedB = tostring(props.count) + return function() + logState("Destroy Insertion 2 for Component B") + end + end) + useLayoutEffect(function() + logState("Create Layout 1 for Component B") + return function() + logState("Destroy Layout 1 for Component B") + end + end) + useLayoutEffect(function() + logState("Create Layout 2 for Component B") + return function() + logState("Destroy Layout 2 for Component B") + end + end) + return nil + end + + local function renderCounters(count) + return React.createElement( + React.Fragment, + nil, + React.createElement(CounterA, { count = count }), + React.createElement(CounterB, { count = count }) + ) + end + + act(function() + ReactNoop.render(renderCounters(0)) + jestExpect(Scheduler).toFlushAndYield({ + "Create Insertion 1 for Component A [A: (empty), B: (empty)]", + "Create Insertion 2 for Component A [A: 0, B: (empty)]", + "Create Insertion 1 for Component B [A: 0, B: (empty)]", + "Create Insertion 2 for Component B [A: 0, B: 0]", + "Create Layout 1 for Component A [A: 0, B: 0]", + "Create Layout 2 for Component A [A: 0, B: 0]", + "Create Layout 1 for Component B [A: 0, B: 0]", + "Create Layout 2 for Component B [A: 0, B: 0]", + }) + jestExpect({ committedA, committedB }).toEqual({ "0", "0" }) + end) + + act(function() + ReactNoop.render(renderCounters(1)) + jestExpect(Scheduler).toFlushAndYield({ + "Destroy Insertion 1 for Component A [A: 0, B: 0]", + "Destroy Insertion 2 for Component A [A: 0, B: 0]", + "Create Insertion 1 for Component A [A: 0, B: 0]", + "Create Insertion 2 for Component A [A: 1, B: 0]", + "Destroy Layout 1 for Component A [A: 1, B: 0]", + "Destroy Layout 2 for Component A [A: 1, B: 0]", + "Destroy Insertion 1 for Component B [A: 1, B: 0]", + "Destroy Insertion 2 for Component B [A: 1, B: 0]", + "Create Insertion 1 for Component B [A: 1, B: 0]", + "Create Insertion 2 for Component B [A: 1, B: 1]", + "Destroy Layout 1 for Component B [A: 1, B: 1]", + "Destroy Layout 2 for Component B [A: 1, B: 1]", + "Create Layout 1 for Component A [A: 1, B: 1]", + "Create Layout 2 for Component A [A: 1, B: 1]", + "Create Layout 1 for Component B [A: 1, B: 1]", + "Create Layout 2 for Component B [A: 1, B: 1]", + }) + jestExpect({ committedA, committedB }).toEqual({ "1", "1" }) + + act(function() + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYield({ + "Destroy Insertion 1 for Component A [A: 1, B: 1]", + "Destroy Insertion 2 for Component A [A: 1, B: 1]", + "Destroy Layout 1 for Component A [A: 1, B: 1]", + "Destroy Layout 2 for Component A [A: 1, B: 1]", + "Destroy Insertion 1 for Component B [A: 1, B: 1]", + "Destroy Insertion 2 for Component B [A: 1, B: 1]", + "Destroy Layout 1 for Component B [A: 1, B: 1]", + "Destroy Layout 2 for Component B [A: 1, B: 1]", + }) + end) + end) + end + ) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js#L3046-L3092 + it( + "assumes insertion effect destroy function is either a function or undefined", + function() + local function App(props) + useInsertionEffect(function() + return props.returnValue + end) + return nil + end + + local root1 = ReactNoop.createRoot() + jestExpect(function() + act(function() + root1.render(React.createElement(App, { returnValue = 17 })) + end) + end).toErrorDev( + "useInsertionEffect must not return anything besides a function, " + .. "which is used for clean-up. You returned: 17" + ) + + -- ROBLOX DEVIATION: Luau cannot distinguish an omitted return value from + -- an explicit nil return, so upstream's null-only warning is unrepresentable. + + local root3 = ReactNoop.createRoot() + -- ROBLOX DEVIATION: Luau Promises and their diagnostic examples use + -- Promise.new/andThen instead of JavaScript async functions/thenables. + jestExpect(function() + act(function() + root3.render( + React.createElement(App, { returnValue = Promise.resolve() }) + ) + end) + end).toErrorDev( + "useInsertionEffect must not return anything besides a function, " + .. "which is used for clean-up.\n\n" + .. "It looks like you wrote useInsertionEffect(Promise.new(function() --[[...]] end) " + .. "or returned a Promise." + ) + + -- ROBLOX DEVIATION: React-Luau passes cleanup directly to xpcall. Roblox + -- reports a non-function cleanup as "attempt to call a nil value" instead + -- of JavaScript's "is not a function". + jestExpect(function() + act(function() + root3.unmount() + end) + end).toThrow("attempt to call a nil value") + end + ) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactFiberHooks.new.js#L2571-L2579 + -- ROBLOX upstream test pattern: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js#L650-L709 + -- ROBLOX DEVIATION: Upstream requires useInsertionEffect to share the mount-time + -- validation but does not include it in the generic public-hook regression. + it("warns if deps is not an array", function() + local function App() + useInsertionEffect(function() end, "not-an-array" :: any) + return nil + end + + jestExpect(function() + act(function() + ReactNoop.render(React.createElement(App)) + end) + end).toErrorDev( + "Warning: useInsertionEffect received a final argument that is not an array " + .. "(instead, received `string`). When specified, the final argument " + .. "must be an array." + ) + end) + + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/__tests__/Activity-test.js#L1428-L1482 + it("insertion effects are not disconnected when the visibility changes", function() + local function Child(props) + local step = props.step + useInsertionEffect(function() + Scheduler.unstable_yieldValue("Commit mount [" .. step .. "]") + return function() + Scheduler.unstable_yieldValue("Commit unmount [" .. step .. "]") + end + end, { step }) + return React.createElement(Text, { text = step }) + end + + local function App(props) + -- ROBLOX DEVIATION: React-Luau does not expose Activity. LegacyHidden + -- uses the same Offscreen visibility update exercised by this test. + return React.createElement( + LegacyHidden, + { mode = if props.show then "visible" else "hidden" }, + useMemo(function() + return React.createElement(Child, { step = props.step }) + end, { props.step }) + ) + end + + local root = ReactNoop.createRoot() + act(function() + root.render(React.createElement(App, { show = true, step = 1 })) + end) + jestExpect(Scheduler).toHaveYielded({ 1, "Commit mount [1]" }) + jestExpect(root).toMatchRenderedOutput(span(1)) + + act(function() + root.render(React.createElement(App, { show = false, step = 1 })) + end) + jestExpect(Scheduler).toHaveYielded({}) + jestExpect(root).toMatchRenderedOutput(span(1, true)) + + act(function() + root.render(React.createElement(App, { show = false, step = 2 })) + end) + jestExpect(Scheduler).toHaveYielded({ + 2, + "Commit unmount [1]", + "Commit mount [2]", + }) + -- ROBLOX DEVIATION: LegacyHidden does not retain hidden host output when + -- its deferred children commit an update. Activity retains it upstream. + jestExpect(root).toMatchRenderedOutput(span(2)) + + act(function() + root.render(React.createElement(App, { show = true, step = 2 })) + end) + jestExpect(Scheduler).toHaveYielded({}) + jestExpect(root).toMatchRenderedOutput(span(2)) + end) +end) diff --git a/modules/react/src/React.lua b/modules/react/src/React.lua index 23f94f2c..bd76d386 100644 --- a/modules/react/src/React.lua +++ b/modules/react/src/React.lua @@ -97,6 +97,10 @@ return { useCallback = ReactHooks.useCallback, useContext = ReactHooks.useContext, useEffect = ReactHooks.useEffect, + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react/src/ReactClient.js#L91 + useEffectEvent = ReactHooks.useEffectEvent, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react/src/React.js#L99 + useInsertionEffect = ReactHooks.useInsertionEffect, useImperativeHandle = ReactHooks.useImperativeHandle, useDebugValue = ReactHooks.useDebugValue, useLayoutEffect = ReactHooks.useLayoutEffect, diff --git a/modules/react/src/ReactHooks.lua b/modules/react/src/ReactHooks.lua index 1a7dae7a..43b27629 100644 --- a/modules/react/src/ReactHooks.lua +++ b/modules/react/src/ReactHooks.lua @@ -202,6 +202,24 @@ local function useEffect( end exports.useEffect = useEffect +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react/src/ReactHooks.js#L218-L224 +local function useEffectEvent(callback: (Args...) -> Return...): (Args...) -> Return... + local dispatcher = resolveDispatcher() + return (dispatcher.useEffectEvent :: any)(callback) +end +exports.useEffectEvent = useEffectEvent + +-- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react/src/ReactHooks.js#L109-L115 +local function useInsertionEffect( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil +): () + local dispatcher = resolveDispatcher() + return (dispatcher.useInsertionEffect :: any)(create, deps) +end +exports.useInsertionEffect = useInsertionEffect + --[[ The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations. Use this to read layout from the DOM and synchronously diff --git a/modules/shared/src/ReactFeatureFlags.lua b/modules/shared/src/ReactFeatureFlags.lua index 1f3ef49d..21b6d75d 100644 --- a/modules/shared/src/ReactFeatureFlags.lua +++ b/modules/shared/src/ReactFeatureFlags.lua @@ -99,6 +99,9 @@ exports.warnAboutSpreadingKeyToJSX = true exports.enableComponentStackLocations = true +-- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/shared/ReactFeatureFlags.js#L121 +exports.enableUseEffectEventHook = true + exports.enableNewReconciler = true -- Errors that are thrown while unmounting (or after in the case of passive effects) diff --git a/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua b/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua index 1ca4bdcf..d93459d6 100644 --- a/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua +++ b/modules/shared/src/ReactSharedInternals/ReactCurrentDispatcher.lua @@ -64,6 +64,17 @@ export type Dispatcher = { create: (() -> ()) | (() -> () -> ()), deps: Array | nil ) -> (), + -- ROBLOX upstream: https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-reconciler/src/ReactInternalTypes.js#L412-L413 + useEffectEvent: (( + callback: (Args...) -> Return... + ) -> (Args...) -> Return...)?, + -- ROBLOX upstream: https://github.com/facebook/react/blob/34aa5cfe0d9b6ec4667e02bf46ab34d83dfb2d6d/packages/react-reconciler/src/ReactInternalTypes.js#L356-L359 + -- ROBLOX DEVIATION: This field stays optional because the legacy DEV + -- dispatcher tables are augmented after construction in ReactFiberHooks. + useInsertionEffect: (( + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ) -> ())?, useLayoutEffect: ( -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically create: (() -> ()) | (() -> () -> ()),