From e2ff3a2b58bb7b4ab9f60c48a31e400c762cc918 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Tue, 6 Jul 2021 15:04:42 -0700 Subject: [PATCH 001/289] Create a basic docs setup (#128) Introduce initial documentation template. It resembles how we've approached building docs in other projects like https://github.com/roblox/roact, which has docs hosted at https://roblox.github.io/roact. --- .github/PULL_REQUEST_TEMPLATE.md | 7 +++++++ docs/api-reference.md | 6 ++++++ docs/deviations.md | 2 ++ docs/extra.css | 3 +++ docs/index.md | 3 +++ docs/migrating-from-roact.md | 3 +++ docs/requirements.txt | 3 +++ mkdocs.yml | 28 ++++++++++++++++++++++++++++ 8 files changed, 55 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 docs/api-reference.md create mode 100644 docs/extra.css create mode 100644 docs/index.md create mode 100644 docs/migrating-from-roact.md create mode 100644 docs/requirements.txt create mode 100644 mkdocs.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..28a642d5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +Closes (ISSUES HERE). + +*Put your pull request body here!* + +Checklist before submitting: +* [ ] Added/updated relevant tests +* [ ] Added/updated documentation \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 00000000..f05d2f17 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,6 @@ +## (Placeholder) + +* Document all API members of top-level packages (React/ReactRoblox/ReactIs/???) +* The vast majority of these should link to external documentation +* Any members with deviations (e.g. `useState` returning 2 values instead of one array) should noted briefly, and link to a more detailed explanation in the `deviations.md` page if necessary + diff --git a/docs/deviations.md b/docs/deviations.md index be3766f2..d14f5325 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -1,4 +1,6 @@ # Deviations +**This is a work in progress! Most of these notes are old!** + The Roact alignment effort aims to map as closely to React's API as possible, but there are a few places where language deviations require us to omit functionality or deviate our approach. ## Class Components diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 00000000..79bf679a --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,3 @@ +.md-typeset hr { + border-bottom: 2px solid rgba(0, 0, 0, 0.15); +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..ba8f6f9b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +Roact is a lua port of Facebook's [React](https://reactjs.org) UI library. + +This documentation is a work in progress. By and large, documentation on React applies directly to Roact, but deviations in API, behavior, or best practice will be documented here. diff --git a/docs/migrating-from-roact.md b/docs/migrating-from-roact.md new file mode 100644 index 00000000..92e413ce --- /dev/null +++ b/docs/migrating-from-roact.md @@ -0,0 +1,3 @@ +## (Placeholder) + +A comprehensive guide for upgrading Roact codebases to be compatible with the Roact 17. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..51b61b75 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +mkdocs +mkdocs-material +pymdown-extensions \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..a4400b9b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,28 @@ +site_name: Roact Documentation +site_url: https://roblox.github.io/roact/ +repo_name: Roblox/roact +repo_url: https://github.com/Roblox/roact + +theme: + name: material + palette: + primary: 'indigo' + accent: 'indigo' + scheme: preference + +nav: + - Home: index.md + - Adoption: migrating-from-roact.md + - Deviations: deviations.md + - API Reference: api-reference.md + +extra_css: + - extra.css + +markdown_extensions: + - admonition + - codehilite: + guess_lang: false + - toc: + permalink: true + - pymdownx.superfences From d894174e674fbc67766510af9c090367b7b8eb4f Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 7 Jul 2021 09:38:00 -0700 Subject: [PATCH 002/289] Run StyLua on roact-compat (#126) This PR formats roact-compat/src using StyLua 0.9.3. Tests all pass, no failures noticed. This one is particularly tiny. --- modules/roact-compat/src/None.lua | 2 +- modules/roact-compat/src/RoactTree.lua | 2 +- .../src/__tests__/RoactCompatibility.spec.lua | 59 ++++++++++++++----- .../src/__tests__/warnOnce.spec.lua | 4 +- modules/roact-compat/src/createFragment.lua | 2 +- modules/roact-compat/src/setGlobalConfig.lua | 6 +- 6 files changed, 52 insertions(+), 23 deletions(-) diff --git a/modules/roact-compat/src/None.lua b/modules/roact-compat/src/None.lua index f5d24d13..9a9d6613 100644 --- a/modules/roact-compat/src/None.lua +++ b/modules/roact-compat/src/None.lua @@ -7,4 +7,4 @@ local LuauPolyfill = require(Packages.LuauPolyfill) -- TODO: However, it also requires that downstream users create their own -- dependency on `LuauPolyfill` in order to use it. We should consider whether -- we ought to re-export this ourselves as a true deviation -return LuauPolyfill.Object.None \ No newline at end of file +return LuauPolyfill.Object.None diff --git a/modules/roact-compat/src/RoactTree.lua b/modules/roact-compat/src/RoactTree.lua index 23eef33a..f22a1f62 100644 --- a/modules/roact-compat/src/RoactTree.lua +++ b/modules/roact-compat/src/RoactTree.lua @@ -45,4 +45,4 @@ return { mount = mount, update = update, unmount = unmount, -} \ No newline at end of file +} diff --git a/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua b/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua index d96a95e3..825eaa2f 100644 --- a/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua +++ b/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua @@ -51,21 +51,30 @@ return function() describe("warns about deprecated Roact API features", function() it("warns about createFragment", function() jestExpect(function() - RoactCompat.createFragment({RoactCompat.createElement("div")}) - end).toWarnDev("Warning: The legacy Roact API 'createFragment' is deprecated", {withoutStack = true}) + RoactCompat.createFragment({ RoactCompat.createElement("div") }) + end).toWarnDev( + "Warning: The legacy Roact API 'createFragment' is deprecated", + { withoutStack = true } + ) end) -- FIXME: Underlying ReactChildren API not yet ported xit("warns about oneChild", function() jestExpect(function() - RoactCompat.oneChild({RoactCompat.createElement("div")}) - end).toWarnDev("Warning: The legacy Roact API 'oneChild' is deprecated", {withoutStack = true}) + RoactCompat.oneChild({ RoactCompat.createElement("div") }) + end).toWarnDev( + "Warning: The legacy Roact API 'oneChild' is deprecated", + { withoutStack = true } + ) end) it("warns about setGlobalConfig", function() jestExpect(function() - RoactCompat.setGlobalConfig({propValidation = true}) - end).toWarnDev("Warning: The legacy Roact API 'setGlobalConfig' is deprecated", {withoutStack = true}) + RoactCompat.setGlobalConfig({ propValidation = true }) + end).toWarnDev( + "Warning: The legacy Roact API 'setGlobalConfig' is deprecated", + { withoutStack = true } + ) end) it("warns about Roact.Portal", function() @@ -81,29 +90,49 @@ return function() jestExpect(function() local root = ReactRoblox.createLegacyRoot(Instance.new("ScreenGui")) root:render(RoactCompat.createElement(withPortal)) - end).toWarnDev("Warning: The legacy Roact API 'Roact.Portal' is deprecated") + end).toWarnDev( + "Warning: The legacy Roact API 'Roact.Portal' is deprecated" + ) end) it("warns about mount", function() jestExpect(function() - RoactCompat.mount(RoactCompat.createElement("TextLabel", {Text = "Foo"})) - end).toWarnDev("Warning: The legacy Roact API 'mount' is deprecated", {withoutStack = true}) + RoactCompat.mount( + RoactCompat.createElement("TextLabel", { Text = "Foo" }) + ) + end).toWarnDev( + "Warning: The legacy Roact API 'mount' is deprecated", + { withoutStack = true } + ) end) it("warns about update", function() - local tree = RoactCompat.mount(RoactCompat.createElement("TextLabel", {Text = "Foo"})) + local tree = RoactCompat.mount( + RoactCompat.createElement("TextLabel", { Text = "Foo" }) + ) jestExpect(function() - RoactCompat.update(tree, RoactCompat.createElement("TextLabel", {Text = "Bar"})) - end).toWarnDev("Warning: The legacy Roact API 'update' is deprecated", {withoutStack = true}) + RoactCompat.update( + tree, + RoactCompat.createElement("TextLabel", { Text = "Bar" }) + ) + end).toWarnDev( + "Warning: The legacy Roact API 'update' is deprecated", + { withoutStack = true } + ) end) it("warns about unmount", function() - local tree = RoactCompat.mount(RoactCompat.createElement("TextLabel", {Text = "Foo"})) + local tree = RoactCompat.mount( + RoactCompat.createElement("TextLabel", { Text = "Foo" }) + ) jestExpect(function() RoactCompat.unmount(tree) - end).toWarnDev("Warning: The legacy Roact API 'unmount' is deprecated", {withoutStack = true}) + end).toWarnDev( + "Warning: The legacy Roact API 'unmount' is deprecated", + { withoutStack = true } + ) end) end) -end \ No newline at end of file +end diff --git a/modules/roact-compat/src/__tests__/warnOnce.spec.lua b/modules/roact-compat/src/__tests__/warnOnce.spec.lua index b7b1d4fd..be736c0d 100644 --- a/modules/roact-compat/src/__tests__/warnOnce.spec.lua +++ b/modules/roact-compat/src/__tests__/warnOnce.spec.lua @@ -16,11 +16,11 @@ return function() end).toWarnDev( "Warning: The legacy Roact API 'oldAPI' is deprecated, and will be " .. "removed in a future release.\n\nFoo", - {withoutStack = true} + { withoutStack = true } ) jestExpect(function() warnOnce("oldAPI", "Foo") end).toWarnDev({}) end) -end \ No newline at end of file +end diff --git a/modules/roact-compat/src/createFragment.lua b/modules/roact-compat/src/createFragment.lua index 0578433a..bf3428d8 100644 --- a/modules/roact-compat/src/createFragment.lua +++ b/modules/roact-compat/src/createFragment.lua @@ -11,4 +11,4 @@ return function(elements) ) end return React.createElement(React.Fragment, nil, elements) -end \ No newline at end of file +end diff --git a/modules/roact-compat/src/setGlobalConfig.lua b/modules/roact-compat/src/setGlobalConfig.lua index 170c7d78..87e94a2e 100644 --- a/modules/roact-compat/src/setGlobalConfig.lua +++ b/modules/roact-compat/src/setGlobalConfig.lua @@ -5,9 +5,9 @@ return function(_config) warnOnce( "setGlobalConfig", "Roact 17 uses a `_G.__DEV__` flag to enable development behavior. " - .. "If you're seeing this warning, you already have it enabled. " - .. "Please remove any redundant uses of `setGlobalConfig`." + .. "If you're seeing this warning, you already have it enabled. " + .. "Please remove any redundant uses of `setGlobalConfig`." ) end -- No equivalent behavior can be applied here -end \ No newline at end of file +end From c701905a7c5199410cc8e258d4c2ed345c6832b0 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 7 Jul 2021 10:07:01 -0700 Subject: [PATCH 003/289] LUAFDN-55, LUAFDN-252: Implement Scheduler Profiling (#129) * Do some alignment in preparation for SchedulerProfiling-test. Note that I'm aligning SchedulerProfiling to a post-17.0.1 vesion that excises its use of SharedArrayBuffer. I'll be aligning SchedulerProfiling to the same revision, as it was the only consumer of the interface for its text-based flamegraph UI. * Fix a bug where we weren't returning the scheduler callback result when in profiling mode. * Don't pack the array of events as efficiently as upstream (though we may want to do something like it in the future, depending on scalability). * Fix loop bound mistranslation * Fix a bug where we were using the retryCache as a mix of Set and Array. Use the official Set object from the Luau polyfill. I don't understand how other Suspense tests were passing so well with this bug present, but glad I found/fixed it as part of deeply tracing through dev tools-related test failures. * Two big issues: First, our test envrionment varies wildly from upstream, and it really bites us on some of these very detail-oriented Suspense tests. Second, upstream can specify {fallback = nil} in tests to express the prop is defined but renders nothing -- we can't do that in Lua. reconciler interprets a null fallback and puts an empty array in its place. So I inline that implementation detail here in the test. * Test passes. The last bug was the ternary and/or translation was being used on 0, which is truthy in Lua. Use IIFE pattern instead. Adjust test to conform to evaera Promise's current reject API. * Fix a mistranslation bug where we need to deviate the Lazy logic. This fixes several tests in Lazy. * Expose the already-defined unstable_DebugTracingMode. Align the top-level export file closer to upstream. Add TODOs for things we don't export that upstream does. * Bump timeout for coverage job and minimum coverage number --- .../react-reconciler/src/ReactFiber.new.lua | 12 +- .../src/ReactFiberCommitWork.new.lua | 20 +- .../src/ReactFiberWorkLoop.new.lua | 14 +- .../src/SchedulingProfiler.lua | 8 +- .../src/__tests__/ReactLazy-internal.spec.lua | 253 +++++----- .../SchedulingProfiler-internal.spec.lua | 127 +++-- modules/react/src/React.lua | 52 +- modules/react/src/ReactElement.lua | 4 +- modules/react/src/ReactLazy.lua | 4 +- modules/scheduler/src/Scheduler.lua | 16 +- modules/scheduler/src/SchedulerProfiling.lua | 82 +--- .../src/__tests__/SchedulerProfiling.spec.lua | 449 ++++++++++++++++++ modules/scheduler/src/unstable_mock.lua | 1 + 13 files changed, 767 insertions(+), 275 deletions(-) create mode 100644 modules/scheduler/src/__tests__/SchedulerProfiling.spec.lua diff --git a/modules/react-reconciler/src/ReactFiber.new.lua b/modules/react-reconciler/src/ReactFiber.new.lua index 02ab471c..e894aa0f 100644 --- a/modules/react-reconciler/src/ReactFiber.new.lua +++ b/modules/react-reconciler/src/ReactFiber.new.lua @@ -266,11 +266,15 @@ local function isSimpleFunctionComponent(type: any) -- type.defaultProps == undefined end --- deviation: FIXME: `Component: Function` - lock down component type def local function resolveLazyComponentTag(Component: any): WorkTag - -- ROBLOX FIXME: Need to actually differentiate correctly - if typeof(Component) == "function" then - return shouldConstruct(Component) and ClassComponent or FunctionComponent + if typeof(Component) == "function" + -- ROBLOX deviation: upstream is a function with method on, we use a table and need an alternate route + or (typeof(Component) == "table" and Component.isReactComponent) then + if shouldConstruct(Component) then + return ClassComponent + end + + return FunctionComponent -- ROBLOX deviation: we can only index ["$$typeof"] on a table elseif Component ~= nil and typeof(Component) == 'table' then local __typeof = Component["$$typeof"] diff --git a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua index 2fadf938..d64e0f14 100644 --- a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua @@ -37,6 +37,9 @@ end local Packages = script.Parent.Parent -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object +local Set = LuauPolyfill.Set type Array = { [number]: T } local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) @@ -193,8 +196,6 @@ end -- local captureCommitPhaseError = ReactFiberWorkLoop.captureCommitPhaseError -- local schedulePassiveEffectCallback = ReactFiberWorkLoop.schedulePassiveEffectCallback -local LuauPolyfill = require(Packages.LuauPolyfill) -local Object = LuauPolyfill.Object -- deviation: stub to allow dependency injection that breaks circular dependency local schedulePassiveEffectCallback @@ -2120,7 +2121,7 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) finishedWork.updateQueue = nil local retryCache = finishedWork.stateNode if retryCache == nil then - finishedWork.stateNode = {} + finishedWork.stateNode = Set.new() retryCache = finishedWork.stateNode end for wakeable, _ in pairs((wakeables :: Set)) do @@ -2129,14 +2130,21 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) return resolveRetryWakeable(finishedWork, wakeable) end - if not retryCache[wakeable] then + if not retryCache:has(wakeable) then if enableSchedulerTracing then if wakeable.__reactDoNotTraceInteractions ~= true then retry = Schedule_tracing_wrap(retry) end end - table.insert(retryCache, wakeable) - wakeable:andThen(retry, retry) + retryCache:add(wakeable) + wakeable:andThen( + function() + return retry() + end, + function() + return retry() + end + ) end end end diff --git a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua index 60c71e32..7462ebbd 100644 --- a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua +++ b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua @@ -2740,10 +2740,12 @@ local flushPassiveEffectsImpl exports.flushPassiveEffects = function(): boolean -- Returns whether passive effects were flushed. if pendingPassiveEffectsRenderPriority ~= NoSchedulerPriority then - local priorityLevel = - pendingPassiveEffectsRenderPriority > NormalSchedulerPriority - and NormalSchedulerPriority - or pendingPassiveEffectsRenderPriority + local priorityLevel = (function() + if pendingPassiveEffectsRenderPriority > NormalSchedulerPriority then + return NormalSchedulerPriority + end + return pendingPassiveEffectsRenderPriority + end)() pendingPassiveEffectsRenderPriority = NoSchedulerPriority if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then local previousLanePriority = getCurrentUpdateLanePriority() @@ -3200,7 +3202,7 @@ exports.resolveRetryWakeable = function(boundaryFiber: Fiber, wakeable: Wakeable if retryCache ~= nil then -- The wakeable resolved, so we no longer need to memoize, because it will -- never be thrown again. - retryCache[wakeable] = nil + retryCache:delete(wakeable) end retryTimedOutBoundary(boundaryFiber, retryLane) @@ -3496,7 +3498,7 @@ if _G.__DEV__ and ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallb end) if not ok then local originalError = result - warn(originalError) + if originalError ~= nil and typeof(originalError) == "table" and diff --git a/modules/react-reconciler/src/SchedulingProfiler.lua b/modules/react-reconciler/src/SchedulingProfiler.lua index ac1f53b0..52499ae1 100644 --- a/modules/react-reconciler/src/SchedulingProfiler.lua +++ b/modules/react-reconciler/src/SchedulingProfiler.lua @@ -84,8 +84,12 @@ exports.markComponentSuspended = function(fiber: Fiber, wakeable: Wakeable): () -- TODO Add component stack id performance.mark("--suspense-suspend-" .. tostring(id) .. "-" .. componentName) wakeable:andThen( - function() performance.mark("--suspense-resolved-" .. tostring(id) .. "-" .. componentName) end, - function() performance.mark("--suspense-rejected-" .. tostring(id) .. "-" .. componentName) end + function() + performance.mark("--suspense-resolved-" .. tostring(id) .. "-" .. componentName) + end, + function() + performance.mark("--suspense-rejected-" .. tostring(id) .. "-" .. componentName) + end ) end end diff --git a/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua index 28b47b08..6c8fd06e 100644 --- a/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua @@ -40,7 +40,7 @@ return function() -- return fakeImport(Add) -- end) - -- expect(function() + -- jestExpect(function() -- LazyAdd.propTypes = {} -- end).toErrorDev('React.lazy(...): It is not supported to assign `propTypes` to ' + 'a lazy component import. Either specify them where the component ' + 'is defined, or create a wrapping component around it.', {withoutStack = true}) @@ -53,19 +53,19 @@ return function() -- outer = '2', -- })), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('22') + -- jestExpect(root).not.toMatchRenderedOutput('22') -- return _await(Promise.resolve(), function() - -- expect(function() + -- jestExpect(function() -- Scheduler.unstable_flushAll() -- end).toErrorDev({ -- 'Invalid prop `inner` of type `string` supplied to `Add`, expected `number`.', -- }) - -- expect(root).toMatchRenderedOutput('22') - -- expect(function() + -- jestExpect(root).toMatchRenderedOutput('22') + -- jestExpect(function() -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', @@ -74,9 +74,9 @@ return function() -- inner = false, -- outer = false, -- }))) - -- expect(Scheduler).toFlushWithoutYielding() + -- jestExpect(Scheduler).toFlushWithoutYielding() -- end).toErrorDev('Invalid prop `inner` of type `boolean` supplied to `Add`, expected `number`.') - -- expect(root).toMatchRenderedOutput('0') + -- jestExpect(root).toMatchRenderedOutput('0') -- end) -- end) @@ -265,18 +265,18 @@ return function() -- text = 'Hi', -- })), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Hi') + -- jestExpect(root).not.toMatchRenderedOutput('Hi') -- return _await(Promise.resolve(), function() -- if __DEV__ then - -- expect(console.error).toHaveBeenCalledTimes(1) - -- expect(console.error.calls.argsFor(0)[0]).toContain('Expected the result of a dynamic import() call') + -- jestExpect(console.error).toHaveBeenCalledTimes(1) + -- jestExpect(console.error.calls.argsFor(0)[0]).toContain('Expected the result of a dynamic import() call') -- end - -- expect(Scheduler).toFlushAndThrow('Element type is invalid') + -- jestExpect(Scheduler).toFlushAndThrow('Element type is invalid') -- end) -- end)) it('throws if promise rejects', function() @@ -311,8 +311,7 @@ return function() end) - -- ROBLOX TODO: Error: Element type is invalid. Received a promise that resolves to: Child. - xit('mount and reorder', function() + it('mount and reorder', function() local Child = React.Component:extend("Child") function Child:componentDidMount() @@ -417,16 +416,16 @@ return function() -- }), -- }, React.createElement(LazyText, nil)), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Hi') + -- jestExpect(root).not.toMatchRenderedOutput('Hi') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Hi', -- }) - -- expect(root).toMatchRenderedOutput('Hi') + -- jestExpect(root).toMatchRenderedOutput('Hi') -- T.defaultProps = { -- text = 'Hi again', @@ -437,10 +436,10 @@ return function() -- text = 'Loading...', -- }), -- }, React.createElement(LazyText, nil))) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Hi again', -- }) - -- expect(root).toMatchRenderedOutput('Hi again') + -- jestExpect(root).toMatchRenderedOutput('Hi again') -- end) -- end)) -- it('resolves defaultProps without breaking memoization', _async(function() @@ -481,25 +480,25 @@ return function() -- }), -- }, React.createElement(Lazy, nil, React.createElement(Stateful, {ref = stateful}))), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('SiblingA') + -- jestExpect(root).not.toMatchRenderedOutput('SiblingA') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Lazy', -- 'Sibling', -- 'A', -- }) - -- expect(root).toMatchRenderedOutput('SiblingA') + -- jestExpect(root).toMatchRenderedOutput('SiblingA') -- stateful.current.setState({ -- text = 'B', -- }) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'B', -- }) - -- expect(root).toMatchRenderedOutput('SiblingB') + -- jestExpect(root).toMatchRenderedOutput('SiblingB') -- end) -- end)) -- it('resolves defaultProps without breaking bailout due to unchanged props and state, #17151', _async(function() @@ -531,23 +530,23 @@ return function() -- label = 'Lazy', -- }))), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Not lazy: 0', -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(root).not.toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Lazy: 0', -- }) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- instance1.current.setState(nil) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- instance2.current.setState(nil) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- end) -- end)) -- it('resolves defaultProps without breaking bailout in PureComponent, #17151', _async(function() @@ -585,23 +584,23 @@ return function() -- label = 'Lazy', -- }))), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Not lazy: 0', -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(root).not.toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Lazy: 0', -- }) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- instance1.current.setState({}) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- instance2.current.setState({}) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('Not lazy: 0Lazy: 0') -- end) -- end)) -- it('sets defaultProps for modern lifecycles', _async(function() @@ -658,13 +657,13 @@ return function() -- }), -- }, React.createElement(LazyClass, {num = 1})), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('A1') + -- jestExpect(root).not.toMatchRenderedOutput('A1') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'constructor: A', -- 'getDerivedStateFromProps: A', -- 'A1', @@ -675,27 +674,27 @@ return function() -- text = 'Loading...', -- }), -- }, React.createElement(LazyClass, {num = 2}))) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'getDerivedStateFromProps: A', -- 'shouldComponentUpdate: A -> A', -- 'A2', -- 'getSnapshotBeforeUpdate: A -> A', -- 'componentDidUpdate: A -> A', -- }) - -- expect(root).toMatchRenderedOutput('A2') + -- jestExpect(root).toMatchRenderedOutput('A2') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyClass, {num = 3}))) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'getDerivedStateFromProps: A', -- 'shouldComponentUpdate: A -> A', -- 'A3', -- 'getSnapshotBeforeUpdate: A -> A', -- 'componentDidUpdate: A -> A', -- }) - -- expect(root).toMatchRenderedOutput('A3') + -- jestExpect(root).toMatchRenderedOutput('A3') -- end) -- end)) -- it('sets defaultProps for legacy lifecycles', _async(function() @@ -736,36 +735,36 @@ return function() -- }), -- }, React.createElement(LazyClass, {num = 1}))) - -- expect(Scheduler).toHaveYielded({ + -- jestExpect(Scheduler).toHaveYielded({ -- 'Loading...', -- }) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('Loading...') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('Loading...') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toHaveYielded({}) + -- jestExpect(Scheduler).toHaveYielded({}) -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyClass, {num = 2}))) - -- expect(Scheduler).toHaveYielded({ + -- jestExpect(Scheduler).toHaveYielded({ -- 'UNSAFE_componentWillMount: A', -- 'A2', -- }) - -- expect(root).toMatchRenderedOutput('A2') + -- jestExpect(root).toMatchRenderedOutput('A2') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyClass, {num = 3}))) - -- expect(Scheduler).toHaveYielded({ + -- jestExpect(Scheduler).toHaveYielded({ -- 'UNSAFE_componentWillReceiveProps: A -> A', -- 'UNSAFE_componentWillUpdate: A -> A', -- 'A3', -- }) - -- expect(Scheduler).toFlushAndYield({}) - -- expect(root).toMatchRenderedOutput('A3') + -- jestExpect(Scheduler).toFlushAndYield({}) + -- jestExpect(root).toMatchRenderedOutput('A3') -- end) -- end)) -- it('resolves defaultProps on the outer wrapper but warns', _async(function() @@ -783,7 +782,7 @@ return function() -- return fakeImport(T) -- end) - -- expect(function() + -- jestExpect(function() -- LazyText.defaultProps = { -- outer = 'Bye', -- } @@ -795,16 +794,16 @@ return function() -- }), -- }, React.createElement(LazyText, nil)), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Hi Bye') + -- jestExpect(root).not.toMatchRenderedOutput('Hi Bye') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Hi Bye', -- }) - -- expect(root).toMatchRenderedOutput('Hi Bye') + -- jestExpect(root).toMatchRenderedOutput('Hi Bye') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', @@ -812,10 +811,10 @@ return function() -- }, React.createElement(LazyText, { -- outer = 'World', -- }))) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Hi World', -- }) - -- expect(root).toMatchRenderedOutput('Hi World') + -- jestExpect(root).toMatchRenderedOutput('Hi World') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', @@ -823,10 +822,10 @@ return function() -- }, React.createElement(LazyText, { -- inner = 'Friends', -- }))) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Friends Bye', -- }) - -- expect(root).toMatchRenderedOutput('Friends Bye') + -- jestExpect(root).toMatchRenderedOutput('Friends Bye') -- end) -- end)) it('throws with a useful error when wrapping invalid type with lazy()', function() @@ -907,7 +906,7 @@ return function() end) -- it('respects propTypes on function component with defaultProps', _async(function() -- local function Add(props) - -- expect(props.innerWithDefault).toBe(42) + -- jestExpect(props.innerWithDefault).toBe(42) -- return props.inner + props.outer -- end @@ -936,7 +935,7 @@ return function() -- local AddMetatable = {__index = Add} -- function Add:render() - -- expect(self.props.innerWithDefault).toBe(42) + -- jestExpect(self.props.innerWithDefault).toBe(42) -- return self.props.inner + self.props.outer -- end @@ -965,7 +964,7 @@ return function() -- end)) -- it('respects propTypes on forwardRef component with defaultProps', _async(function() -- local Add = React.forwardRef(function(props, ref) - -- expect(props.innerWithDefault).toBe(42) + -- jestExpect(props.innerWithDefault).toBe(42) -- return props.inner + props.outer -- end) @@ -993,7 +992,7 @@ return function() -- end)) -- it('respects propTypes on outer memo component with defaultProps', _async(function() -- local Add = function(props) - -- expect(props.innerWithDefault).toBe(42) + -- jestExpect(props.innerWithDefault).toBe(42) -- return props.inner + props.outer -- end @@ -1021,7 +1020,7 @@ return function() -- end)) -- it('respects propTypes on inner memo component with defaultProps', _async(function() -- local Add = function(props) - -- expect(props.innerWithDefault).toBe(42) + -- jestExpect(props.innerWithDefault).toBe(42) -- return props.inner + props.outer -- end @@ -1071,27 +1070,27 @@ return function() -- }), -- }, React.createElement(LazyText, nil)), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('Inner default text') + -- jestExpect(root).not.toMatchRenderedOutput('Inner default text') -- return _await(Promise.resolve(), function() - -- expect(function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(function() + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Inner default text', -- }) -- end).toErrorDev('The prop `text` is marked as required in `T`, but its value is `undefined`') - -- expect(root).toMatchRenderedOutput('Inner default text') - -- expect(function() + -- jestExpect(root).toMatchRenderedOutput('Inner default text') + -- jestExpect(function() -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyText, {text = nil}))) - -- expect(Scheduler).toFlushAndYield({nil}) + -- jestExpect(Scheduler).toFlushAndYield({nil}) -- end).toErrorDev('The prop `text` is marked as required in `T`, but its value is `null`') - -- expect(root).toMatchRenderedOutput(nil) + -- jestExpect(root).toMatchRenderedOutput(nil) -- end) -- end)) @@ -1138,8 +1137,8 @@ return function() end).toErrorDev(' in Text (at **)\n' .. ' in Foo (at **)') jestExpect(root).toMatchRenderedOutput(React.createElement('div', nil, 'AB')) end) - -- ROBLOX TODO: Error: Element type is invalid. Received a promise that resolves to: Foo. - xit('supports class and forwardRef components', function() + + it('supports class and forwardRef components', function() local LazyClass = lazy(function() local Foo = React.Component:extend("Foo") @@ -1208,35 +1207,35 @@ return function() -- }), -- }, React.createElement(LazyAdd, {outer = 2})), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('4') + -- jestExpect(root).not.toMatchRenderedOutput('4') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('4') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('4') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, {outer = 2}))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('4') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('4') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, {outer = 3}))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('5') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('5') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, {outer = 3}))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('5') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('5') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', @@ -1245,8 +1244,8 @@ return function() -- outer = 1, -- inner = 1, -- }))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('2') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('2') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', @@ -1255,15 +1254,15 @@ return function() -- outer = 1, -- inner = 1, -- }))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('2') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('2') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, {outer = 1}))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('3') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('3') -- end) -- end)) -- it('merges defaultProps in the correct order', _async(function() @@ -1287,28 +1286,28 @@ return function() -- }), -- }, React.createElement(LazyAdd, {outer = 2})), {unstable_isConcurrent = true}) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Loading...', -- }) - -- expect(root).not.toMatchRenderedOutput('4') + -- jestExpect(root).not.toMatchRenderedOutput('4') -- return _await(Promise.resolve(), function() - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('4') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('4') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, {outer = 3}))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('5') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('5') -- root.update(React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', -- }), -- }, React.createElement(LazyAdd, nil))) - -- expect(Scheduler).toFlushWithoutYielding() - -- expect(root).toMatchRenderedOutput('2') + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- jestExpect(root).toMatchRenderedOutput('2') -- end) -- end)) it('warns about ref on functions for lazy-loaded components', function() @@ -1418,17 +1417,19 @@ return function() -- }, React.createElement(LazyText, { -- text = 'Hi', -- })))) - -- expect(Scheduler).toHaveYielded({}) - -- expect(componentStackMessage).toContain('in Lazy') + -- jestExpect(Scheduler).toHaveYielded({}) + -- jestExpect(componentStackMessage).toContain('in Lazy') -- end) - -- it('mount and reorder lazy elements', function() + + -- @gate enableLazyElements + -- xit('mount and reorder lazy elements', function() -- local Child = React.Component:extend("Child") -- function Child:componentDidMount() - -- Scheduler.unstable_yieldValue('Did mount: ' + self.props.label) + -- Scheduler.unstable_yieldValue('Did mount: ' .. self.props.label) -- end -- function Child:componentDidUpdate() - -- Scheduler.unstable_yieldValue('Did update: ' + self.props.label) + -- Scheduler.unstable_yieldValue('Did update: ' .. self.props.label) -- end -- function Child:render() -- return React.createElement(Text, { @@ -1476,8 +1477,8 @@ return function() -- return React.createElement(Suspense, { -- fallback = React.createElement(Text, { -- text = 'Loading...', - -- }), - -- }, (function() + -- })}, + -- (function() -- if swap then -- return{lazyChildB2, lazyChildA2} -- end @@ -1486,48 +1487,52 @@ return function() -- end)()) -- end + -- local root = ReactTestRenderer.create(React.createElement(Parent, {swap = false}), { + -- unstable_isConcurrent = true + -- }) - -- local root = ReactTestRenderer.create(React.createElement(Parent, {swap = false}), {unstable_isConcurrent = true}) + -- -- ROBLOX FIXME: delay by one frame is current best translation of `await Promise.resolve()` + -- Promise.delay(0):await() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Init A', -- 'Loading...', -- }) - -- expect(root).never.toMatchRenderedOutput('AB') + -- jestExpect(root).never.toMatchRenderedOutput('AB') -- return _await(lazyChildA, function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Init B', -- }) -- return _await(lazyChildB, function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'A', -- 'B', -- 'Did mount: A', -- 'Did mount: B', -- }) - -- expect(root).toMatchRenderedOutput('AB') + -- jestExpect(root).toMatchRenderedOutput('AB') -- root.update(React.createElement(Parent, {swap = true})) - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Init B2', -- 'Loading...', -- }) -- return _await(lazyChildB2, function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'Init A2', -- 'Loading...', -- }) -- return _await(lazyChildA2, function() - -- expect(Scheduler).toFlushAndYield({ + -- jestExpect(Scheduler).toFlushAndYield({ -- 'b', -- 'a', -- 'Did update: b', -- 'Did update: a', -- }) - -- expect(root).toMatchRenderedOutput('ba') + -- jestExpect(root).toMatchRenderedOutput('ba') -- end) -- end) -- end) diff --git a/modules/react-reconciler/src/__tests__/SchedulingProfiler-internal.spec.lua b/modules/react-reconciler/src/__tests__/SchedulingProfiler-internal.spec.lua index 71074857..be176410 100644 --- a/modules/react-reconciler/src/__tests__/SchedulingProfiler-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/SchedulingProfiler-internal.spec.lua @@ -1,3 +1,4 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/8af27aeedbc6b00bc2ef49729fc84f116c70a27c/packages/react-reconciler/src/__tests__/SchedulingProfiler-test.internal.js -- /** -- * Copyright (c) Facebook, Inc. and its affiliates. -- * @@ -42,8 +43,47 @@ return function() marks = {} local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false ReactFeatureFlags.enableSchedulingProfiler = true + ReactFeatureFlags.enableProfilerTimer = true + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = true + ReactFeatureFlags.enableSuspenseServerRenderer = true + ReactFeatureFlags.decoupleUpdatePriorityFromScheduler = true + + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false + ReactFeatureFlags.enableDebugTracing = false + ReactFeatureFlags.warnAboutDeprecatedLifecycles = true + ReactFeatureFlags.enableProfilerCommitHooks = false + ReactFeatureFlags.enableSelectiveHydration = false + ReactFeatureFlags.enableBlocksAPI = false + ReactFeatureFlags.enableLazyElements = false + ReactFeatureFlags.disableJavaScriptURLs = false + ReactFeatureFlags.disableInputAttributeSyncing = false + ReactFeatureFlags.enableSchedulerDebugging = false + ReactFeatureFlags.enableFundamentalAPI = false + ReactFeatureFlags.enableScopeAPI = false + ReactFeatureFlags.enableCreateEventHandleAPI = false + ReactFeatureFlags.warnAboutUnmockedScheduler = false + ReactFeatureFlags.enableSuspenseCallback = false + ReactFeatureFlags.warnAboutDefaultPropsOnFunctionComponents = false + ReactFeatureFlags.warnAboutStringRefs = false + ReactFeatureFlags.disableLegacyContext = false + ReactFeatureFlags.disableSchedulerTimeoutBasedOnReactExpirationTime = false + ReactFeatureFlags.enableTrustedTypesIntegration = false + ReactFeatureFlags.disableTextareaChildren = false + ReactFeatureFlags.disableModulePatternComponents = false + ReactFeatureFlags.warnUnstableRenderSubtreeIntoContainer = false + ReactFeatureFlags.warnAboutSpreadingKeyToJSX = false + ReactFeatureFlags.enableComponentStackLocations = true + ReactFeatureFlags.enableLegacyFBSupport = false + ReactFeatureFlags.enableFilterEmptyStringAttributesDOM = false + ReactFeatureFlags.skipUnmountedBoundaries = false + + ReactFeatureFlags.enableNewReconciler = false + ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch = true + ReactFeatureFlags.enableDiscreteEventFlushingChange = false + ReactFeatureFlags.enableEagerRootListeners = true + + ReactFeatureFlags.enableDoubleInvokingEffects = false React = require(Packages.React) @@ -138,20 +178,21 @@ return function() end) -- @gate enableSchedulingProfiler - -- ROBLOX FIXME: Example suspended while rendering, but no fallback UI was specified - xit("should mark sync render with suspense that resolves", function() - local fakeSuspensePromise = Promise.resolve(true) + it("should mark sync render with suspense that resolves", function() + -- ROBLOX deviation: can't just Promise.resolve() due to evaera Promise issue + local fakeSuspensePromise = Promise.delay(0):andThen(function() + return true + end) local function Example() error(fakeSuspensePromise) end - ReactTestRenderer.create( - React.createElement( - React.Suspense, - { fallback = nil }, - React.createElement(Example) - ) - ) + ReactTestRenderer.create(React.createElement( + React.Suspense, + -- ROBLOX deviation: Lua can't express 'empty' fallback with nil, so we use empty array + { fallback = {} }, + React.createElement(Example) + )) jestExpect(marks).toEqual({ "--react-init-" .. tostring(ReactVersion), @@ -172,20 +213,21 @@ return function() end) -- @gate enableSchedulingProfiler - -- ROBLOX FIXME: Example suspended while rendering, but no fallback UI was specified - xit("should mark sync render with suspense that rejects", function() - local fakeSuspensePromise = Promise.reject(Error.new("error")) + it("should mark sync render with suspense that rejects", function() + -- ROBLOX deviation: can't just Promise.reject() due to evaera Promise issue + local fakeSuspensePromise = Promise.delay(0):andThen(function() + error(Error.new("error")) + end) local function Example() error(fakeSuspensePromise) end - ReactTestRenderer.create( - React.createElement( - React.Suspense, - { fallback = nil }, - React.createElement(Example) - ) - ) + ReactTestRenderer.create(React.createElement( + React.Suspense, + -- ROBLOX deviation: Lua can't express 'empty' fallback with nil, so we use empty array + { fallback = {} }, + React.createElement(Example) + )) jestExpect(marks).toEqual({ "--react-init-" .. tostring(ReactVersion), @@ -201,15 +243,21 @@ return function() Array.splice(marks, 1) - -- ROBLOX TODO: how do we do this? + -- ROBLOX deviation: jest-roblox doesn't support these Promise matchers yet -- await jestExpect(fakeSuspensePromise).rejects.toThrow() + jestExpect(function() + fakeSuspensePromise:expect() + end).toThrow() + jestExpect(marks).toEqual({ "--suspense-rejected-0-Example" }) end) -- @gate enableSchedulingProfiler - -- ROBLOX FIXME: Example suspended while rendering, but no fallback UI was specified - xit("should mark concurrent render with suspense that resolves", function() - local fakeSuspensePromise = Promise.resolve(true) + it("should mark concurrent render with suspense that resolves", function() + -- ROBLOX deviation: can't just Promise.resolve() due to evaera Promise issue + local fakeSuspensePromise = Promise.delay(0):andThen(function() + return true + end) local function Example() error(fakeSuspensePromise) end @@ -217,7 +265,8 @@ return function() ReactTestRenderer.create( React.createElement( React.Suspense, - { fallback = nil }, + -- ROBLOX deviation: Lua can't express 'empty' fallback with nil, so we use empty array + { fallback = {} }, React.createElement(Example) ), { unstable_isConcurrent = true } @@ -244,14 +293,18 @@ return function() Array.splice(marks, 1) - fakeSuspensePromise:await() + fakeSuspensePromise:expect() + jestExpect(marks).toEqual({ "--suspense-resolved-0-Example" }) end) -- @gate enableSchedulingProfiler - -- ROBLOX FIXME: Example suspended while rendering, but no fallback UI was specified - xit("should mark concurrent render with suspense that rejects", function() - local fakeSuspensePromise = Promise.reject(Error.new("error")) + it("should mark concurrent render with suspense that rejects", function() + -- ROBLOX deviation: can't just Promise.reject() due to evaera Promise issue + local fakeSuspensePromise = Promise.delay(0):andThen(function() + error(Error.new("error")) + end) + local function Example() error(fakeSuspensePromise) end @@ -259,7 +312,8 @@ return function() ReactTestRenderer.create( React.createElement( React.Suspense, - { fallback = nil }, + -- ROBLOX deviation: Lua can't express 'empty' fallback with nil, so we use empty array + { fallback = {} }, React.createElement(Example) ), { unstable_isConcurrent = true } @@ -286,6 +340,7 @@ return function() Array.splice(marks, 1) + -- ROBLOX deviation: jest-roblox doesn't support these Promise matchers yet -- await jestExpect(fakeSuspensePromise).rejects.toThrow() jestExpect(function() fakeSuspensePromise:expect() @@ -406,8 +461,8 @@ return function() -- ROBLOX FIXME: no way to gate feature tests like upstream -- gate(({old}) => -- old - -- ? jestExpect(marks).toContain('--schedule-state-update-1024-Example') - -- : jestExpect(marks).toContain('--schedule-state-update-512-Example'), + jestExpect(marks).toContain("--schedule-state-update-1024-Example") + -- jestExpect(marks).toContain('--schedule-state-update-512-Example') -- ) end) @@ -445,8 +500,8 @@ return function() -- ROBLOX TODO: we have no way to gate tests on features like upstream -- gate(({old}) => -- old - -- ? jestExpect(marks).toContain('--schedule-forced-update-1024-Example') - -- : jestExpect(marks).toContain('--schedule-forced-update-512-Example'), + jestExpect(marks).toContain("--schedule-forced-update-1024-Example") + -- jestExpect(marks).toContain('--schedule-forced-update-512-Example') -- ) end) @@ -545,7 +600,7 @@ return function() -- ROBLOX TODO: we don't have a way to gate tests based on features like upstream does -- gate(({old}) => -- old - -- ? jestExpect(marks).toContain('--schedule-state-update-1024-Example') + jestExpect(marks).toContain("--schedule-state-update-1024-Example") -- : jestExpect(marks).toContain('--schedule-state-update-512-Example'), -- ) end) diff --git a/modules/react/src/React.lua b/modules/react/src/React.lua index 62f78385..b7a057fc 100644 --- a/modules/react/src/React.lua +++ b/modules/react/src/React.lua @@ -23,32 +23,50 @@ local cloneElement = _G.__DEV__ and ReactElement.cloneElement return { - __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals, Children = ReactChildren, - Component = ReactBaseClasses.Component, - PureComponent = ReactBaseClasses.PureComponent, - createElement = createElement, - cloneElement = cloneElement, createMutableSource = createMutableSource, - isValidElement = ReactElement. isValidElement, createRef = ReactCreateRef.createRef, + Component = ReactBaseClasses.Component, + PureComponent = ReactBaseClasses.PureComponent, + createContext = ReactContext.createContext, forwardRef = ReactForwardRef.forwardRef, lazy = ReactLazy.lazy, - Fragment = ReactSymbols.REACT_FRAGMENT_TYPE, - Profiler = ReactSymbols.REACT_PROFILER_TYPE, - StrictMode = ReactSymbols.REACT_STRICT_MODE_TYPE, - Suspense = ReactSymbols.REACT_SUSPENSE_TYPE, memo = ReactMemo.memo, - useState = ReactHooks.useState, - useReducer = ReactHooks.useReducer, + useCallback = ReactHooks.useCallback, + useContext = ReactHooks.useContext, useEffect = ReactHooks.useEffect, useImperativeHandle = ReactHooks.useImperativeHandle, + -- ROBLOX TODO: useDebugValue useLayoutEffect = ReactHooks.useLayoutEffect, - useRef = ReactHooks.useRef, useMemo = ReactHooks.useMemo, useMutableSource = ReactHooks.useMutableSource, - useCallback = ReactHooks.useCallback, - useContext = ReactHooks.useContext, - createContext = ReactContext.createContext, - unstable_LegacyHidden = ReactSymbols.REACT_LEGACY_HIDDEN_TYPE + useReducer = ReactHooks.useReducer, + useRef = ReactHooks.useRef, + useState = ReactHooks.useState, + Fragment = ReactSymbols.REACT_FRAGMENT_TYPE, + Profiler = ReactSymbols.REACT_PROFILER_TYPE, + StrictMode = ReactSymbols.REACT_STRICT_MODE_TYPE, + unstable_DebugTracingMode = ReactSymbols.REACT_DEBUG_TRACING_MODE_TYPE, + Suspense = ReactSymbols.REACT_SUSPENSE_TYPE, + createElement = createElement, + cloneElement = cloneElement, + isValidElement = ReactElement.isValidElement, + -- ROBLOX TODO: ReactVersion + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals, + -- Deprecated behind disableCreateFactory + -- ROBLOX TODO: createFactory, + -- Concurrent Mode + -- ROBLOX TODO: useTransition, + -- ROBLOX TODO: startTransition, + -- ROBLOX TODO: useDeferredValue, + -- ROBLOX TODO: REACT_SUSPENSE_LIST_TYPE as SuspenseList, + unstable_LegacyHidden = ReactSymbols.REACT_LEGACY_HIDDEN_TYPE, + -- enableBlocksAPI + -- ROBLOX TODO: block, + -- enableFundamentalAPI + -- ROBLOX TODO: createFundamental as unstable_createFundamental, + -- enableScopeAPI + -- ROBLOX TODO: REACT_SCOPE_TYPE as unstable_Scope, + -- ROBLOX TODO: useOpaqueIdentifier as unstable_useOpaqueIdentifier, + } diff --git a/modules/react/src/ReactElement.lua b/modules/react/src/ReactElement.lua index ccdb386e..62cc4494 100644 --- a/modules/react/src/ReactElement.lua +++ b/modules/react/src/ReactElement.lua @@ -79,7 +79,7 @@ local function defineKeyPropWarningGetter(props, displayName) end end - -- ROBLOX deviation: clear key to ensure metamethod is called, + -- ROBLOX deviation: clear key to ensure metamethod is called, -- then set key getter to call warnAboutAccessingKey props.key = nil setmetatable(props, { @@ -112,7 +112,7 @@ local function defineRefPropWarningGetter(props, displayName) end end - -- ROBLOX deviation: clear key to ensure metamethod is called, + -- ROBLOX deviation: clear key to ensure metamethod is called, -- then set key getter to call warnAboutAccessingKey props.ref = nil setmetatable(props, { diff --git a/modules/react/src/ReactLazy.lua b/modules/react/src/ReactLazy.lua index f8bdd7ce..366a0693 100644 --- a/modules/react/src/ReactLazy.lua +++ b/modules/react/src/ReactLazy.lua @@ -86,8 +86,8 @@ function lazyInitializer(payload: Payload): any .. "Instead received: %s\n\nYour code should look like: \n " -- Break up imports to avoid accidentally parsing them as dependencies. -- ROBLOX deviation: Lua syntax in message - .. "local MyComponent = lazy(function() => req" - .. "quire('script.Parent.MyComponent') end)", + .. "local MyComponent = lazy(function() return req" + .. "quire(script.Parent.MyComponent) end)", moduleObject ) end diff --git a/modules/scheduler/src/Scheduler.lua b/modules/scheduler/src/Scheduler.lua index f9ecd350..06331379 100644 --- a/modules/scheduler/src/Scheduler.lua +++ b/modules/scheduler/src/Scheduler.lua @@ -45,8 +45,8 @@ return function(hostConfig) local markSchedulerSuspended = SchedulerProfiling.markSchedulerSuspended local markSchedulerUnsuspended = SchedulerProfiling.markSchedulerUnsuspended local markTaskStart = SchedulerProfiling.markTaskStart - -- local stopLoggingProfilingEvents = SchedulerProfiling.stopLoggingProfilingEvents - -- local startLoggingProfilingEvents = SchedulerProfiling.startLoggingProfilingEvents + local stopLoggingProfilingEvents = SchedulerProfiling.stopLoggingProfilingEvents + local startLoggingProfilingEvents = SchedulerProfiling.startLoggingProfilingEvents -- Max 31 bit integer. The max integer size in V8 for 32-bit systems. -- Math.pow(2, 30) - 1 @@ -161,8 +161,7 @@ return function(hostConfig) end error(error_) end - -- ROBLOX FIXME: workaround for Luau not understanding error is a no-return - return nil + return enableProfilingResult else -- No catch in prod code path. return workLoop(hasTimeRemaining, initialTime) @@ -485,5 +484,14 @@ return function(hostConfig) unstable_getFirstCallbackNode = unstable_getFirstCallbackNode, unstable_now = getCurrentTime, unstable_forceFrameRate = forceFrameRate, + unstable_Profiling = (function() + if enableProfiling then + return { + startLoggingProfilingEvents = startLoggingProfilingEvents, + stopLoggingProfilingEvents = stopLoggingProfilingEvents, + } + end + return nil + end)() } end diff --git a/modules/scheduler/src/SchedulerProfiling.lua b/modules/scheduler/src/SchedulerProfiling.lua index baed1d37..424bfc3c 100644 --- a/modules/scheduler/src/SchedulerProfiling.lua +++ b/modules/scheduler/src/SchedulerProfiling.lua @@ -1,4 +1,4 @@ --- upstream https://github.com/facebook/react/blob/9abc2785cb070148d64fae81e523246b90b92016/packages/scheduler/src/SchedulerProfiling.js +-- ROBLOX upstream https://github.com/facebook/react/blob/8af27aeedbc6b00bc2ef49729fc84f116c70a27c/packages/scheduler/src/SchedulerProfiling.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -6,10 +6,11 @@ * LICENSE file in the root directory of this source tree. * ]] - +-- ROBLOX NOTE: this file is synced against a post-17.0.1 version that doesn't use SharedArrayBuffer local Packages = script.Parent.Parent -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local exports = {} local SchedulerPriorities = require(script.Parent.SchedulerPriorities) type PriorityLevel = SchedulerPriorities.PriorityLevel @@ -17,51 +18,9 @@ type PriorityLevel = SchedulerPriorities.PriorityLevel local ScheduleFeatureFlags = require(script.Parent.SchedulerFeatureFlags) local enableProfiling = ScheduleFeatureFlags.enableProfiling -local NoPriority = SchedulerPriorities.NoPriority - local runIdCounter: number = 0 local mainThreadIdCounter: number = 0 --- local profilingStateSize = 4 - --- FIXME (roblox): remove this when our unimplemented -local function unimplemented(message) - print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - print("UNIMPLEMENTED ERROR: " .. tostring(message)) - error("FIXME (roblox): " .. message .. " is unimplemented", 2) -end - -local exports = {} - -if enableProfiling then - exports.sharedProfilingBuffer = {} -else - exports.sharedProfilingBuffer = nil -end - --- ROBLOX deviation: just use an array --- local profilingState = --- enableProfiling && sharedProfilingBuffer !== null --- ? new Int32Array(sharedProfilingBuffer) --- : []; // We can't read this but it helps save bytes for null checks - -local profilingState = {} - -local PRIORITY = 0 -local CURRENT_TASK_ID = 1 -local CURRENT_RUN_ID = 2 -local QUEUE_SIZE = 3 - -if enableProfiling then - profilingState[PRIORITY] = NoPriority - -- This is maintained with a counter, because the size of the priority queue - -- array might include canceled tasks. - profilingState[QUEUE_SIZE] = 0 - profilingState[CURRENT_TASK_ID] = 0 -end -- Bytes per element is 4 local INITIAL_EVENT_LOG_SIZE = 131072 @@ -70,7 +29,7 @@ local MAX_EVENT_LOG_SIZE = 524288 -- Equivalent to 2 megabytes local eventLogSize = 0 local eventLogBuffer = nil local eventLog = nil -local eventLogIndex = 0 +local eventLogIndex = 1 local TaskStartEvent = 1 local TaskCompleteEvent = 2 @@ -82,9 +41,8 @@ local SchedulerSuspendEvent = 7 local SchedulerResumeEvent = 8 local function logEvent(entries) - unimplemented("SchedulerProfiling:logEvent") if eventLog ~= nil then - local offset = eventLogIndex + -- ROBLOX deviation: upstream uses a packed array for performance. we do something simpler for now eventLogIndex += #entries if eventLogIndex + 1 > eventLogSize then eventLogSize *= 2 @@ -99,18 +57,18 @@ local function logEvent(entries) end local newEventLog = {} table.insert(newEventLog, eventLog) - eventLogBuffer = newEventLog.buffer + eventLogBuffer = newEventLog eventLog = newEventLog end - table.insert(eventLog, entries, offset) + table.insert(eventLog, entries) end end exports.startLoggingProfilingEvents = function() eventLogSize = INITIAL_EVENT_LOG_SIZE eventLogBuffer = {} - eventLog = {} - eventLogIndex = 0 + eventLog = eventLogBuffer + eventLogIndex = 1 end exports.stopLoggingProfilingEvents = function() @@ -118,14 +76,12 @@ exports.stopLoggingProfilingEvents = function() eventLogSize = 0 eventLogBuffer = nil eventLog = nil - eventLogIndex = 0 + eventLogIndex = 1 return buffer end exports.markTaskStart = function(task, ms: number) if enableProfiling then - profilingState[QUEUE_SIZE] += 1 - if eventLog ~= nil then -- performance.now returns a float, representing milliseconds. When the -- event is logged, it's coerced to an int. Convert to microseconds to @@ -137,10 +93,6 @@ end exports.markTaskCompleted = function(task, ms: number) if enableProfiling then - profilingState[PRIORITY] = NoPriority - profilingState[CURRENT_TASK_ID] = 0 - profilingState[QUEUE_SIZE] -= 1 - if eventLog ~= nil then -- performance.now returns a float, representing milliseconds. When the -- event is logged, it's coerced to an int. Convert to microseconds to @@ -152,8 +104,6 @@ end exports.markTaskCanceled = function(task, ms: number) if enableProfiling then - profilingState[QUEUE_SIZE] -= 1 - if eventLog ~= nil then logEvent({ TaskCancelEvent, ms * 1000, task.id }) end @@ -162,10 +112,6 @@ end exports.markTaskErrored = function(task, ms: number) if enableProfiling then - profilingState[PRIORITY] = NoPriority - profilingState[CURRENT_TASK_ID] = 0 - profilingState[QUEUE_SIZE] -= 1 - if eventLog ~= nil then logEvent({ TaskErrorEvent, ms * 1000, task.id }) end @@ -176,10 +122,6 @@ exports.markTaskRun = function(task, ms: number) if enableProfiling then runIdCounter += 1 - profilingState[PRIORITY] = task.priorityLevel - profilingState[CURRENT_TASK_ID] = task.id - profilingState[CURRENT_RUN_ID] = runIdCounter - if eventLog ~= nil then logEvent({ TaskRunEvent, ms * 1000, task.id, runIdCounter }) end @@ -188,10 +130,6 @@ end exports.markTaskYield = function(task, ms: number) if enableProfiling then - profilingState[PRIORITY] = NoPriority - profilingState[CURRENT_TASK_ID] = 0 - profilingState[CURRENT_RUN_ID] = 0 - if eventLog ~= nil then logEvent({ TaskYieldEvent, ms * 1000, task.id, runIdCounter }) end diff --git a/modules/scheduler/src/__tests__/SchedulerProfiling.spec.lua b/modules/scheduler/src/__tests__/SchedulerProfiling.spec.lua new file mode 100644 index 00000000..38a5ed06 --- /dev/null +++ b/modules/scheduler/src/__tests__/SchedulerProfiling.spec.lua @@ -0,0 +1,449 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/8af27aeedbc6b00bc2ef49729fc84f116c70a27c/packages/scheduler/src/__tests__/SchedulerProfiling-test.js +local Packages = script.Parent.Parent.Parent +local RobloxJest = require(Packages.Dev.RobloxJest) +local JestRoblox = require(Packages.Dev.JestRoblox) +local jestExpect = JestRoblox.Globals.expect +-- ROBLOX note: this uses a post-17.0.1 commit that removes a reliance on SharedArrayBuffer, but remains API compatible with 17.x + +return function() + local Scheduler + local ImmediatePriority + local UserBlockingPriority + local NormalPriority + local LowPriority + local IdlePriority + local scheduleCallback + local cancelCallback + local function priorityLevelToString(priorityLevel) + if priorityLevel == ImmediatePriority then + return "Immediate" + elseif priorityLevel == UserBlockingPriority then + return "User-blocking" + elseif priorityLevel == NormalPriority then + return "Normal" + elseif priorityLevel == LowPriority then + return "Low" + elseif priorityLevel == IdlePriority then + return "Idle" + else + return nil + end + end + describe("Scheduler", function() + it("profiling APIs are not available", function() + local SchedulerFeatureFlags = require( + script.Parent.Parent.SchedulerFeatureFlags + ) + SchedulerFeatureFlags.enableProfiling = false + + Scheduler = require(script.Parent.Parent.Scheduler)() + jestExpect(Scheduler.unstable_Profiling).toBe(nil) + end) + beforeEach(function() + RobloxJest.resetModules() + + RobloxJest.useFakeTimers() + local SchedulerFeatureFlags = require( + script.Parent.Parent.SchedulerFeatureFlags + ) + SchedulerFeatureFlags.enableProfiling = true + + -- ROBLOX deviation: In react, jest mocks Scheduler -> unstable_mock since + -- unstable_mock depends on the real Scheduler, and our mock + -- functionality isn't smart enough to prevent self-requires, we simply + -- require the mock entry point directly for use in tests + Scheduler = require(script.Parent.Parent.unstable_mock) + ImmediatePriority = Scheduler.unstable_ImmediatePriority + UserBlockingPriority = Scheduler.unstable_UserBlockingPriority + NormalPriority = Scheduler.unstable_NormalPriority + LowPriority = Scheduler.unstable_LowPriority + IdlePriority = Scheduler.unstable_IdlePriority + scheduleCallback = Scheduler.unstable_scheduleCallback + cancelCallback = Scheduler.unstable_cancelCallback + end) + local TaskStartEvent = 1 + local TaskCompleteEvent = 2 + local TaskErrorEvent = 3 + local TaskCancelEvent = 4 + local TaskRunEvent = 5 + local TaskYieldEvent = 6 + local SchedulerSuspendEvent = 7 + local SchedulerResumeEvent = 8 + local function stopProfilingAndPrintFlamegraph() + local eventBuffer = Scheduler.unstable_Profiling.stopLoggingProfilingEvents() + if eventBuffer == nil then + return "(empty profile)" + end + local eventLog = { table.unpack(eventBuffer) } + local tasks = {} + local mainThreadRuns = {} + local isSuspended = true + local i = 1 + while i <= #eventLog do + local instruction = eventLog[i][1] + local time_ = eventLog[i][2] + if instruction == 0 then + break + elseif instruction == TaskStartEvent then + local taskId = eventLog[i][3] + local priorityLevel = eventLog[i][4] + local task_ = { + id = taskId, + priorityLevel = priorityLevel, + label = nil, + start = time_, + end_ = -1, + exitStatus = nil, + runs = {}, + } + tasks[taskId] = task_ + i += 1 + elseif instruction == TaskCompleteEvent then + if isSuspended then + error("Task cannot Complete outside the work loop.") + end + local taskId = eventLog[i][3] + local task_ = tasks[taskId] + if task_ == nil then + error("Task does not exist.") + end + task_.end_ = time_ + task_.exitStatus = "completed" + i += 1 + elseif instruction == TaskErrorEvent then + if isSuspended then + error("Task cannot Error outside the work loop.") + end + local taskId = eventLog[i][3] + local task_ = tasks[taskId] + if task_ == nil then + error("Task does not exist.") + end + task_.end_ = time_ + task_.exitStatus = "errored" + i += 1 + elseif instruction == TaskCancelEvent then + local taskId = eventLog[i][3] + local task_ = tasks[taskId] + if task_ == nil then + error("Task does not exist.") + end + task_.end_ = time_ + task_.exitStatus = "canceled" + i += 1 + elseif instruction == TaskRunEvent or instruction == TaskYieldEvent then + if isSuspended then + error("Task cannot Run or Yield outside the work loop.") + end + local taskId = eventLog[i][3] + local task_ = tasks[taskId] + if task_ == nil then + error("Task does not exist.") + end + table.insert(task_.runs, time_) + i += 1 + elseif instruction == SchedulerSuspendEvent then + if isSuspended then + error("Scheduler cannot Suspend outside the work loop.") + end + isSuspended = true + table.insert(mainThreadRuns, time_) + i += 1 + elseif instruction == SchedulerResumeEvent then + if not isSuspended then + error("Scheduler cannot Resume inside the work loop.") + end + isSuspended = false + table.insert(mainThreadRuns, time_) + i += 1 + else + error("Unknown instruction type: " + instruction) + end + end + local labelColumnWidth = 30 + local microsecondsPerChar = 50000 + local result = "" + local mainThreadLabelColumn = "!!! Main thread " + local mainThreadTimelineColumn = "" + local isMainThreadBusy = true + for _, time_ in ipairs(mainThreadRuns) do + local index = time_ / microsecondsPerChar + for i = 1, index - string.len(mainThreadTimelineColumn), 1 do + mainThreadTimelineColumn ..= (function() + if isMainThreadBusy then + return "X" + end + return "_" + end)() + end + isMainThreadBusy = not isMainThreadBusy + end + result ..= mainThreadLabelColumn .. "│" .. mainThreadTimelineColumn .. "\n" + local tasksValues = {} + for _, tasksValue in pairs(tasks) do + table.insert(tasksValues, tasksValue) + end + table.sort(tasksValues, function(t1, t2) + return t2.priorityLevel > t1.priorityLevel + end) + for _, task_ in ipairs(tasksValues) do + local label = task_.label + if label == nil then + label = "Task" + end + local labelColumn = string.format( + "Task %d [%s]", + task_.id, + priorityLevelToString(task_.priorityLevel) + ) + for i = 1, labelColumnWidth - string.len(labelColumn) - 1, 1 do + labelColumn ..= " " + end + + -- Add empty space up until the start mark + local timelineColumn = "" + for i = 1, task_.start / microsecondsPerChar, 1 do + timelineColumn ..= " " + end + + local isRunning = false + for _, time_ in ipairs(task_.runs) do + local index = time_ / microsecondsPerChar + for i = 1, index - string.len(timelineColumn), 1 do + timelineColumn ..= (function() + if isRunning then + return "X" + end + return "_" + end)() + end + + isRunning = not isRunning + end + + local endIndex = task_.end_ / microsecondsPerChar + for i = 1, endIndex - string.len(timelineColumn), 1 do + timelineColumn ..= (function() + if isRunning then + return "X" + end + return "_" + end)() + end + + if task_.exitStatus ~= "completed" then + timelineColumn ..= "O " .. (task_.exitStatus or "") + end + + result ..= labelColumn .. "│" .. timelineColumn .. "\n" + end + return "\n" .. result + end + + it("creates a basic flamegraph", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + Scheduler.unstable_advanceTime(100) + scheduleCallback(NormalPriority, function() + Scheduler.unstable_advanceTime(300) + Scheduler.unstable_yieldValue("Yield 1") + scheduleCallback(UserBlockingPriority, function() + Scheduler.unstable_yieldValue("Yield 2") + Scheduler.unstable_advanceTime(300) + end, { + label = "Bar", + }) + Scheduler.unstable_advanceTime(100) + Scheduler.unstable_yieldValue("Yield 3") + return function() + Scheduler.unstable_yieldValue("Yield 4") + Scheduler.unstable_advanceTime(300) + end + end, { + label = "Foo", + }) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Yield 1", "Yield 3" }) + Scheduler.unstable_advanceTime(100) + jestExpect(Scheduler).toFlushAndYield({ "Yield 2", "Yield 4" }) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │XX________XX____________ +Task 2 [User-blocking] │ ____XXXXXX +Task 1 [Normal] │ XXXXXXXX________XXXXXX +]]) + end) + it("marks when a Task is canceled", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + local task_ = scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("Yield 1") + Scheduler.unstable_advanceTime(300) + Scheduler.unstable_yieldValue("Yield 2") + return function() + Scheduler.unstable_yieldValue("Continuation") + Scheduler.unstable_advanceTime(200) + end + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Yield 1", "Yield 2" }) + Scheduler.unstable_advanceTime(100) + cancelCallback(task_) + Scheduler.unstable_advanceTime(1000) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │______XXXXXXXXXXXXXXXXXXXXXX +Task 1 [Normal] │XXXXXX__O canceled +]]) + end) + it("marks when a task errors", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + scheduleCallback(NormalPriority, function() + Scheduler.unstable_advanceTime(300) + error("Oops") + end) + jestExpect(Scheduler).toFlushAndThrow("Oops") + Scheduler.unstable_advanceTime(100) + Scheduler.unstable_advanceTime(1000) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │______XXXXXXXXXXXXXXXXXXXXXX +Task 1 [Normal] │XXXXXXO errored +]]) + end) + + it("marks when multiple tasks are canceled", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + local task1 = scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("Yield 1") + Scheduler.unstable_advanceTime(300) + Scheduler.unstable_yieldValue("Yield 2") + return function() + Scheduler.unstable_yieldValue("Continuation") + Scheduler.unstable_advanceTime(200) + end + end) + local task2 = scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("Yield 3") + Scheduler.unstable_advanceTime(300) + Scheduler.unstable_yieldValue("Yield 4") + return function() + Scheduler.unstable_yieldValue("Continuation") + Scheduler.unstable_advanceTime(200) + end + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Yield 1", "Yield 2" }) + Scheduler.unstable_advanceTime(100) + cancelCallback(task1) + cancelCallback(task2) + Scheduler.unstable_advanceTime(1000) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │______XXXXXXXXXXXXXXXXXXXXXX +Task 1 [Normal] │XXXXXX__O canceled +Task 2 [Normal] │________O canceled +]]) + end) + it("handles cancelling a task_ that already finished", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + local task_ = scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("A") + Scheduler.unstable_advanceTime(1000) + end) + jestExpect(Scheduler).toFlushAndYield({ "A" }) + cancelCallback(task_) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │____________________ +Task 1 [Normal] │XXXXXXXXXXXXXXXXXXXX +]]) + end) + + it("handles cancelling a task multiple times", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("A") + Scheduler.unstable_advanceTime(1000) + end, { + label = "A", + }) + Scheduler.unstable_advanceTime(200) + local task_ = scheduleCallback(NormalPriority, function() + Scheduler.unstable_yieldValue("B") + Scheduler.unstable_advanceTime(1000) + end, { + label = "B", + }) + Scheduler.unstable_advanceTime(400) + cancelCallback(task_) + cancelCallback(task_) + cancelCallback(task_) + jestExpect(Scheduler).toFlushAndYield({ "A" }) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │XXXXXXXXXXXX____________________ +Task 1 [Normal] │____________XXXXXXXXXXXXXXXXXXXX +Task 2 [Normal] │ ________O canceled +]]) + end) + it("handles delayed tasks", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + scheduleCallback(NormalPriority, function() + Scheduler.unstable_advanceTime(1000) + Scheduler.unstable_yieldValue("A") + end, { + delay = 1000, + }) + jestExpect(Scheduler).toFlushWithoutYielding() + Scheduler.unstable_advanceTime(1000) + jestExpect(Scheduler).toFlushAndYield({ "A" }) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │XXXXXXXXXXXXXXXXXXXX____________________ +Task 1 [Normal] │ XXXXXXXXXXXXXXXXXXXX +]]) + end) + it("handles cancelling a delayed Task", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + local task_ = scheduleCallback(NormalPriority, function() + return Scheduler.unstable_yieldValue("A") + end, { + delay = 1000, + }) + cancelCallback(task_) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │ +]]) + end) + it("automatically stops profiling and warns if event log gets too big", function() + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + -- ROBLOX deviation: use toWarvDev matcher below instead of overriding console global + -- spyOnDevAndProd(console, "error") + -- ROBLOX deviation: any lower than this, and the buffer doesn't overslow and we try to table.unpack() too many elements + local originalMaxIterations = 41000 + local taskId = 1 + jestExpect(function() + while taskId < originalMaxIterations do + taskId += 1 + local task_ = scheduleCallback(NormalPriority, function() + return {} + end) + cancelCallback(task_) + jestExpect(Scheduler).toFlushAndYield({}) + end + end).toErrorDev("Event log exceeded maximum size", {withoutStack = true}) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual("(empty profile)") + Scheduler.unstable_Profiling.startLoggingProfilingEvents() + scheduleCallback(NormalPriority, function() + Scheduler.unstable_advanceTime(1000) + end) + jestExpect(Scheduler).toFlushAndYield({}) + jestExpect(stopProfilingAndPrintFlamegraph()).toEqual([[ + +!!! Main thread │____________________ +Task 41000 [Normal] │XXXXXXXXXXXXXXXXXXXX +]]) + end) + end) +end diff --git a/modules/scheduler/src/unstable_mock.lua b/modules/scheduler/src/unstable_mock.lua index 82154549..cefc7deb 100644 --- a/modules/scheduler/src/unstable_mock.lua +++ b/modules/scheduler/src/unstable_mock.lua @@ -34,5 +34,6 @@ exports.unstable_flushUntilNextPaint = HostConfig.unstable_flushUntilNextPaint exports.unstable_flushAll = HostConfig.unstable_flushAll exports.unstable_yieldValue = HostConfig.unstable_yieldValue exports.unstable_advanceTime = HostConfig.unstable_advanceTime +exports.unstable_Profiling = Scheduler.unstable_Profiling return exports From 9ee1a34b6a18696623297f4895bb7b7159af34a6 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 7 Jul 2021 10:32:22 -0700 Subject: [PATCH 004/289] Run StyLua on react-test-renderer (#127) This PR formats react-test-renderer using StyLua 0.9.3. Tests all pass, no failures noticed. This one is mostly whitespace fix, semicolon removal, and ' to " changes. --- .../src/ReactTestHostConfig.lua | 243 +- .../src/ReactTestRenderer.lua | 1182 ++++---- .../ReactTestRenderer-internal.spec.lua | 2607 +++++++++-------- .../__tests__/ReactTestRendererAct.spec.lua | 275 +- .../ReactTestRendererTraversal.spec.lua | 117 +- 5 files changed, 2298 insertions(+), 2126 deletions(-) diff --git a/modules/react-test-renderer/src/ReactTestHostConfig.lua b/modules/react-test-renderer/src/ReactTestHostConfig.lua index 00b69b19..a283418a 100644 --- a/modules/react-test-renderer/src/ReactTestHostConfig.lua +++ b/modules/react-test-renderer/src/ReactTestHostConfig.lua @@ -23,22 +23,23 @@ local clearTimeout = LuauPolyfill.clearTimeout local console = require(Packages.Shared).console local ReactTypes = require(Packages.Shared) -type ReactFundamentalComponentInstance = ReactTypes.ReactFundamentalComponentInstance +type ReactFundamentalComponentInstance = + ReactTypes.ReactFundamentalComponentInstance local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_OPAQUE_ID_TYPE = ReactSymbols.REACT_OPAQUE_ID_TYPE -type Array = { [number]: T }; -type Object = { [string]: any }; -type Function = (any) -> any; +type Array = { [number]: T } +type Object = { [string]: any } +type Function = (any) -> any -export type Type = string; -export type Props = Object; +export type Type = string +export type Props = Object export type Container = { children: Array, createNodeMock: Function, tag: string, -- ROBLOX deviation: Luau can't specify literals -}; +} export type Instance = { type: string, props: Object, @@ -47,32 +48,32 @@ export type Instance = { internalInstanceHandle: Object, rootContainerInstance: Container, tag: string, -}; +} export type TextInstance = { text: string, isHidden: boolean, tag: string, -}; -export type HydratableInstance = Instance | TextInstance; -export type PublicInstance = Instance | TextInstance; -export type HostContext = Object; -export type UpdatePayload = Object; +} +export type HydratableInstance = Instance | TextInstance +export type PublicInstance = Instance | TextInstance +export type HostContext = Object +export type UpdatePayload = Object -- Unused -- export type ChildSet = void; -- FIXME (roblox): This typically uses a builtin flowtype called 'TimeoutID', we -- should find a common solution for polyfill types with Luau -export type TimeoutHandle = any; -export type NoTimeout = number; -export type EventResponder = any; +export type TimeoutHandle = any +export type NoTimeout = number +export type EventResponder = any -- deviation: explicitly include `$$typeof` in type def -export type OpaqueIDType = string | Object; +export type OpaqueIDType = string | Object -- export type OpaqueIDType = string | { -- toString: () -> string?, -- valueOf: () -> string?, -- }; -export type RendererInspectionConfig = {}; +export type RendererInspectionConfig = {} local ReactFiberHostConfig = require(Packages.Shared).ReactFiberHostConfig local exports = Cryo.Dictionary.join( @@ -90,7 +91,6 @@ if _G.__DEV__ then Object.freeze(UPDATE_SIGNAL) end - exports.getPublicInstance = function(inst: Instance | TextInstance) if inst.tag == "INSTANCE" then -- ROBLOX deviation: Luau won't let us narrow type to Instance, just widen it @@ -109,26 +109,24 @@ exports.getPublicInstance = function(inst: Instance | TextInstance) end end -exports.appendChild = function( - parentInstance: Instance | Container, - child: Instance | TextInstance -) - if _G.__DEV__ then - if not Array.isArray(parentInstance.children) then - console.error( - "An invalid container has been provided. " .. - "This may indicate that another renderer is being used in addition to the test renderer. " .. - "(For example, ReactDOM.createPortal inside of a ReactTestRenderer tree.) " .. - "This is not supported." - ) +exports.appendChild = + function(parentInstance: Instance | Container, child: Instance | TextInstance) + if _G.__DEV__ then + if not Array.isArray(parentInstance.children) then + console.error( + "An invalid container has been provided. " + .. "This may indicate that another renderer is being used in addition to the test renderer. " + .. "(For example, ReactDOM.createPortal inside of a ReactTestRenderer tree.) " + .. "This is not supported." + ) + end end + local index = Array.indexOf(parentInstance.children, child) + if index ~= -1 then + Array.splice(parentInstance.children, index, 1) + end + table.insert(parentInstance.children, child) end - local index = Array.indexOf(parentInstance.children, child) - if index ~= -1 then - Array.splice(parentInstance.children, index, 1) - end - table.insert(parentInstance.children, child) -end exports.insertBefore = function( parentInstance: Instance | Container, @@ -143,21 +141,17 @@ exports.insertBefore = function( Array.splice(parentInstance.children, beforeIndex, 0, child) end -exports.removeChild = function( - parentInstance: Instance | Container, - child: Instance | TextInstance -) - local index = Array.indexOf(parentInstance.children, child) - Array.splice(parentInstance.children, index, 1) -end +exports.removeChild = + function(parentInstance: Instance | Container, child: Instance | TextInstance) + local index = Array.indexOf(parentInstance.children, child) + Array.splice(parentInstance.children, index, 1) + end exports.clearContainer = function(container: Container) Array.splice(container.children, 0) end -exports.getRootHostContext = function( - rootContainerInstance: Container -): HostContext +exports.getRootHostContext = function(rootContainerInstance: Container): HostContext return NO_CONTEXT end @@ -196,16 +190,14 @@ exports.createInstance = function( } end -exports.appendInitialChild = function( - parentInstance: Instance, - child: Instance | TextInstance -) - local index = Array.indexOf(parentInstance.children, child) - if index ~= -1 then - Array.splice(parentInstance.children, index, 1) +exports.appendInitialChild = + function(parentInstance: Instance, child: Instance | TextInstance) + local index = Array.indexOf(parentInstance.children, child) + if index ~= -1 then + Array.splice(parentInstance.children, index, 1) + end + table.insert(parentInstance.children, child) end - table.insert(parentInstance.children, child) -end exports.finalizeInitialChildren = function( testElement: Instance, @@ -270,22 +262,15 @@ exports.commitUpdate = function( instance.props = newProps end -exports.commitMount = function( - instance: Instance, - type: string, - newProps: Props, - internalInstanceHandle: Object -) - -- noop -end +exports.commitMount = + function(instance: Instance, type: string, newProps: Props, internalInstanceHandle: Object) + -- noop + end -exports.commitTextUpdate = function( - textInstance: TextInstance, - oldText: string, - newText: string -) - textInstance.text = newText -end +exports.commitTextUpdate = + function(textInstance: TextInstance, oldText: string, newText: string) + textInstance.text = newText + end exports.resetTextContent = function(testElement: Instance) -- noop @@ -307,75 +292,67 @@ exports.unhideInstance = function(instance: Instance, props: Props) instance.isHidden = false end -exports.unhideTextInstance = function( - textInstance: TextInstance, - text: string -) +exports.unhideTextInstance = function(textInstance: TextInstance, text: string) textInstance.isHidden = false end -exports.getFundamentalComponentInstance = function( - fundamentalInstance: ReactFundamentalComponentInstance -): Instance - local impl = fundamentalInstance.impl - local props = fundamentalInstance.props - local state = fundamentalInstance.state - return impl.getInstance(nil, props, state) -end +exports.getFundamentalComponentInstance = + function(fundamentalInstance: ReactFundamentalComponentInstance): Instance + local impl = fundamentalInstance.impl + local props = fundamentalInstance.props + local state = fundamentalInstance.state + return impl.getInstance(nil, props, state) + end -exports.mountFundamentalComponent = function( - fundamentalInstance: ReactFundamentalComponentInstance -) - local impl = fundamentalInstance.impl - local instance = fundamentalInstance.instance - local props = fundamentalInstance.props - local state = fundamentalInstance.state - local onMount = impl.onMount - if onMount ~= nil then - onMount(nil, instance, props, state) +exports.mountFundamentalComponent = + function(fundamentalInstance: ReactFundamentalComponentInstance) + local impl = fundamentalInstance.impl + local instance = fundamentalInstance.instance + local props = fundamentalInstance.props + local state = fundamentalInstance.state + local onMount = impl.onMount + if onMount ~= nil then + onMount(nil, instance, props, state) + end end -end -exports.shouldUpdateFundamentalComponent = function( - fundamentalInstance: ReactFundamentalComponentInstance -): boolean - local impl = fundamentalInstance.impl - local prevProps = fundamentalInstance.prevProps - local props = fundamentalInstance.props - local state = fundamentalInstance.state - local shouldUpdate = impl.shouldUpdate - if shouldUpdate ~= nil then - return shouldUpdate(nil, prevProps, props, state) +exports.shouldUpdateFundamentalComponent = + function(fundamentalInstance: ReactFundamentalComponentInstance): boolean + local impl = fundamentalInstance.impl + local prevProps = fundamentalInstance.prevProps + local props = fundamentalInstance.props + local state = fundamentalInstance.state + local shouldUpdate = impl.shouldUpdate + if shouldUpdate ~= nil then + return shouldUpdate(nil, prevProps, props, state) + end + return true end - return true -end -exports.updateFundamentalComponent = function( - fundamentalInstance: ReactFundamentalComponentInstance -) - local impl = fundamentalInstance.impl - local instance = fundamentalInstance.instance - local prevProps = fundamentalInstance.prevProps - local props = fundamentalInstance.props - local state = fundamentalInstance.state - local onUpdate = impl.onUpdate - if onUpdate ~= nil then - onUpdate(nil, instance, prevProps, props, state) +exports.updateFundamentalComponent = + function(fundamentalInstance: ReactFundamentalComponentInstance) + local impl = fundamentalInstance.impl + local instance = fundamentalInstance.instance + local prevProps = fundamentalInstance.prevProps + local props = fundamentalInstance.props + local state = fundamentalInstance.state + local onUpdate = impl.onUpdate + if onUpdate ~= nil then + onUpdate(nil, instance, prevProps, props, state) + end end -end -exports.unmountFundamentalComponent = function( - fundamentalInstance: ReactFundamentalComponentInstance -) - local impl = fundamentalInstance.impl - local instance = fundamentalInstance.instance - local props = fundamentalInstance.props - local state = fundamentalInstance.state - local onUnmount = impl.onUnmount - if onUnmount ~= nil then - onUnmount(nil, instance, props, state) +exports.unmountFundamentalComponent = + function(fundamentalInstance: ReactFundamentalComponentInstance) + local impl = fundamentalInstance.impl + local instance = fundamentalInstance.instance + local props = fundamentalInstance.props + local state = fundamentalInstance.state + local onUnmount = impl.onUnmount + if onUnmount ~= nil then + onUnmount(nil, instance, props, state) + end end -end exports.getInstanceFromNode = function(mockNode: Object) local instance = nodeToInstanceMap[mockNode] @@ -412,14 +389,10 @@ exports.makeClientIdInDEV = function(warnOnAccessInDEV: () -> ()): OpaqueIDType end exports.isOpaqueHydratingObject = function(value: any): boolean - return - typeof(value) == "table" and - value["$$typeof"] == REACT_OPAQUE_ID_TYPE + return typeof(value) == "table" and value["$$typeof"] == REACT_OPAQUE_ID_TYPE end -exports.makeOpaqueHydratingObject = function( - attemptToReadValue: () -> () -): OpaqueIDType +exports.makeOpaqueHydratingObject = function(attemptToReadValue: () -> ()): OpaqueIDType return { ["$$typeof"] = REACT_OPAQUE_ID_TYPE, toString = attemptToReadValue, diff --git a/modules/react-test-renderer/src/ReactTestRenderer.lua b/modules/react-test-renderer/src/ReactTestRenderer.lua index 5353b566..99b99031 100644 --- a/modules/react-test-renderer/src/ReactTestRenderer.lua +++ b/modules/react-test-renderer/src/ReactTestRenderer.lua @@ -22,8 +22,8 @@ local setTimeout = LuauPolyfill.setTimeout -- local ReactInternalTypes = require(Packages.ReactReconciler).ReactInternalTypes -- type Fiber = ReactInternalTypes.Fiber -- type FiberRoot = ReactInternalTypes.FiberRoot -type Fiber = any; -type FiberRoot = any; +type Fiber = any +type FiberRoot = any local ReactTypes = require(Packages.Shared) type Thenable = ReactTypes.Thenable @@ -76,23 +76,22 @@ local LegacyRoot = ReactRootTags.LegacyRoot local IsSomeRendererActing = ReactSharedInternals.IsSomeRendererActing local JSON = game:GetService("HttpService") - -- ROBLOX deviation: add type for Array and Object -type Array = { [number]: T }; +type Array = { [number]: T } type Object = { [string]: any } type TestRendererOptions = { - -- createNodeMock: (React$Element) -> any, - createNodeMock: (any) -> any, - unstable_isConcurrent: boolean + -- createNodeMock: (React$Element) -> any, + createNodeMock: (any) -> any, + unstable_isConcurrent: boolean, } type ReactTestRendererJSON = { - type: string, - -- props: {[propName: string]: any, ...}, - props: {[string]: any}, - children: nil | Array, - -- $$typeof?: Symbol, -- Optional because we add it with defineProperty(). + type: string, + -- props: {[propName: string]: any, ...}, + props: { [string]: any }, + children: nil | Array, + -- $$typeof?: Symbol, -- Optional because we add it with defineProperty(). } type ReactTestRendererNode = ReactTestRendererJSON | string @@ -104,563 +103,589 @@ type ReactTestRendererNode = ReactTestRendererJSON | string -- }> type FindOptions = any -export type Predicate = (Object) -> boolean?; +export type Predicate = (Object) -> boolean? local defaultTestOptions = { - createNodeMock = function() - return nil - end, + createNodeMock = function() + return nil + end, } local function toJSON(inst) - if inst.isHidden then - -- Omit timed out children from output entirely. This seems like the least - -- surprising behavior. We could perhaps add a separate API that includes - -- them, if it turns out people need it. - return nil - end - - -- ROBLOX deviation: if/else instead of switch - if inst.tag == 'TEXT' then - return inst.text - elseif inst.tag == 'INSTANCE' then - -- /* eslint-disable no-unused-vars */ - -- We don't include the `children` prop in JSON. - -- Instead, we will include the actual rendered children. - local props = Object.assign({}, inst.props) - props.children = nil - - -- /* eslint-enable */ - local renderedChildren = nil - if inst.children and #inst.children ~= 0 then - for i = 1, #inst.children do - local renderedChild = toJSON(inst.children[i]) - if renderedChild ~= nil then - if renderedChildren == nil then - renderedChildren = {renderedChild} - else - table.insert(renderedChildren, renderedChild) - end - end - end - end - local json: ReactTestRendererJSON = { - type = inst.type, - props = props, - children = renderedChildren, - } - -- ROBLOX TODO: Symbol.for - setmetatable(json, { - __index = function(t, k) - if k == '$$typeof' then - return Symbol.for_('react.test.json') - end - return - end - }) - - return json; - else - error('Unexpected node type in toJSON: ' .. tostring(inst.tag)) - end + if inst.isHidden then + -- Omit timed out children from output entirely. This seems like the least + -- surprising behavior. We could perhaps add a separate API that includes + -- them, if it turns out people need it. + return nil + end + + -- ROBLOX deviation: if/else instead of switch + if inst.tag == "TEXT" then + return inst.text + elseif inst.tag == "INSTANCE" then + -- /* eslint-disable no-unused-vars */ + -- We don't include the `children` prop in JSON. + -- Instead, we will include the actual rendered children. + local props = Object.assign({}, inst.props) + props.children = nil + + -- /* eslint-enable */ + local renderedChildren = nil + if inst.children and #inst.children ~= 0 then + for i = 1, #inst.children do + local renderedChild = toJSON(inst.children[i]) + if renderedChild ~= nil then + if renderedChildren == nil then + renderedChildren = { renderedChild } + else + table.insert(renderedChildren, renderedChild) + end + end + end + end + local json: ReactTestRendererJSON = { + type = inst.type, + props = props, + children = renderedChildren, + } + -- ROBLOX TODO: Symbol.for + setmetatable(json, { + __index = function(t, k) + if k == "$$typeof" then + return Symbol.for_("react.test.json") + end + return + end, + }) + + return json + else + error("Unexpected node type in toJSON: " .. tostring(inst.tag)) + end end local function flatten(arr) - local result = {} - local stack = { - { - i = 1, - array = arr, - }, - } - - while #stack ~= 0 do - local n = table.remove(stack, #stack) - - while n.i <= #n.array do - local el = n.array[n.i] - - n.i = n.i + 1 - - if Array.isArray(el) then - table.insert(stack, n) - table.insert(stack, { - i = 1, - array = el - }) - break - end - - table.insert(result, el) - end - end - - return result + local result = {} + local stack = { + { + i = 1, + array = arr, + }, + } + + while #stack ~= 0 do + local n = table.remove(stack, #stack) + + while n.i <= #n.array do + local el = n.array[n.i] + + n.i = n.i + 1 + + if Array.isArray(el) then + table.insert(stack, n) + table.insert(stack, { + i = 1, + array = el, + }) + break + end + + table.insert(result, el) + end + end + + return result end local function nodeAndSiblingsArray(nodeWithSibling) - local array = {} - local node = nodeWithSibling + local array = {} + local node = nodeWithSibling - while node ~= nil do - table.insert(array, node) - node = node.sibling - end + while node ~= nil do + table.insert(array, node) + node = node.sibling + end - return array + return array end -- ROBLOX deviation: toTree needs to be pre-declared to avoid function call cycle local toTree local function childrenToTree(node) - if not node then - return nil - end + if not node then + return nil + end - local children = nodeAndSiblingsArray(node) + local children = nodeAndSiblingsArray(node) - if #children == 0 then - return nil - elseif #children == 1 then - return toTree(children[1]) - end + if #children == 0 then + return nil + elseif #children == 1 then + return toTree(children[1]) + end - return flatten(Array.map(children, toTree)) + return flatten(Array.map(children, toTree)) end -- ROBLOX deviation: change node variable name to nodeInput so we can keep the node name -- for the majority of the function body after the initial nil check and recast toTree = function(nodeInput: Fiber | nil) - if nodeInput == nil then - return nil - end - - -- ROBLOX deviation: silence analyze by recasting - local node : any = nodeInput - - -- ROBLOX deviation: swtich converted to if/else - if node.tag == HostRoot then - return childrenToTree(node.child) - elseif node.tag == HostPortal then - return childrenToTree(node.child) - elseif node.tag == ClassComponent then - return { - nodeType = 'component', - type = node.type, - -- ROBLOX deviation: Uses Object.assign for shallow copy - props = Object.assign({}, node.memoizedProps), - instance = node.stateNode, - rendered = childrenToTree(node.child) - } - elseif node.tag == SimpleMemoComponent or node.tag == FunctionComponent then - return { - nodeType = 'component', - type = node.type, - -- ROBLOX deviation: Uses Object.assign for shallow copy - props = Object.assign({}, node.memoizedProps), - instance = nil, - rendered = childrenToTree(node.child), - } - elseif node.tag == Block then - return { - nodeType = 'block', - type = node.type, - -- ROBLOX deviation: Uses Object.assign for shallow copy - props = Object.assign({}, node.memoizedProps), - instance = nil, - rendered = childrenToTree(node.child), - } - elseif node.tag == HostComponent then - return { - nodeType = 'host', - type = node.type, - -- ROBLOX deviation: Uses Object.assign for shallow copy - props = Object.assign({}, node.memoizedProps), - instance = nil, -- TODO: use createNodeMock here somehow? - rendered = flatten(Array.map(nodeAndSiblingsArray(node.child), toTree)), - } - elseif node.tag == HostText then - return node.stateNode.text; - elseif node.tag == Fragment or - node.tag == ContextProvider or - node.tag == ContextConsumer or - node.tag == Mode or - node.tag == Profiler or - node.tag == ForwardRef or - node.tag == MemoComponent or - node.tag == IncompleteClassComponent or - node.tag == ScopeComponent then - return childrenToTree(node.child); - else - invariant( - false, - 'toTree() does not yet know how to handle nodes with tag=' .. tostring(node.tag) - ) - end - return + if nodeInput == nil then + return nil + end + + -- ROBLOX deviation: silence analyze by recasting + local node: any = nodeInput + + -- ROBLOX deviation: swtich converted to if/else + if node.tag == HostRoot then + return childrenToTree(node.child) + elseif node.tag == HostPortal then + return childrenToTree(node.child) + elseif node.tag == ClassComponent then + return { + nodeType = "component", + type = node.type, + -- ROBLOX deviation: Uses Object.assign for shallow copy + props = Object.assign({}, node.memoizedProps), + instance = node.stateNode, + rendered = childrenToTree(node.child), + } + elseif node.tag == SimpleMemoComponent or node.tag == FunctionComponent then + return { + nodeType = "component", + type = node.type, + -- ROBLOX deviation: Uses Object.assign for shallow copy + props = Object.assign({}, node.memoizedProps), + instance = nil, + rendered = childrenToTree(node.child), + } + elseif node.tag == Block then + return { + nodeType = "block", + type = node.type, + -- ROBLOX deviation: Uses Object.assign for shallow copy + props = Object.assign({}, node.memoizedProps), + instance = nil, + rendered = childrenToTree(node.child), + } + elseif node.tag == HostComponent then + return { + nodeType = "host", + type = node.type, + -- ROBLOX deviation: Uses Object.assign for shallow copy + props = Object.assign({}, node.memoizedProps), + instance = nil, -- TODO: use createNodeMock here somehow? + rendered = flatten(Array.map(nodeAndSiblingsArray(node.child), toTree)), + } + elseif node.tag == HostText then + return node.stateNode.text + elseif + node.tag == Fragment + or node.tag == ContextProvider + or node.tag == ContextConsumer + or node.tag == Mode + or node.tag == Profiler + or node.tag == ForwardRef + or node.tag == MemoComponent + or node.tag == IncompleteClassComponent + or node.tag == ScopeComponent + then + return childrenToTree(node.child) + else + invariant( + false, + "toTree() does not yet know how to handle nodes with tag=" + .. tostring(node.tag) + ) + end + return end local ReactTestInstance = {} -- ROBLOX deviation: not using Set() local validWrapperTypes = { - [FunctionComponent] = true, - [ClassComponent] = true, - [HostComponent] = true, - [ForwardRef] = true, - [MemoComponent] = true, - [SimpleMemoComponent] = true, - [Block] = true, - -- Normally skipped, but used when there's more than one root child. - [HostRoot] = true, + [FunctionComponent] = true, + [ClassComponent] = true, + [HostComponent] = true, + [ForwardRef] = true, + [MemoComponent] = true, + [SimpleMemoComponent] = true, + [Block] = true, + -- Normally skipped, but used when there's more than one root child. + [HostRoot] = true, } -- ROBLOX deviation: use table in place of WeakMap local fiberToWrapper = {} local function wrapFiber(fiber: Fiber): Object - local wrapper = fiberToWrapper[fiber] + local wrapper = fiberToWrapper[fiber] - if wrapper == nil and fiber.alternate ~= nil then - wrapper = fiberToWrapper[fiber.alternate] - end - if wrapper == nil then - wrapper = ReactTestInstance.new(fiber) - fiberToWrapper["fiber"] = wrapper - end + if wrapper == nil and fiber.alternate ~= nil then + wrapper = fiberToWrapper[fiber.alternate] + end + if wrapper == nil then + wrapper = ReactTestInstance.new(fiber) + fiberToWrapper["fiber"] = wrapper + end - return wrapper + return wrapper end - local function getChildren(parent) - local children = {} - local startingNode = parent - local node = startingNode - - if node.child == nil then - return children - end - - node.child.return_ = node - node = node.child - - -- ROBLOX deviation: use break flag instead of labeled loops - local breakOuter = false - - while true do - local descend = false - if validWrapperTypes[node.tag] ~= nil then - table.insert(children, wrapFiber(node)) - elseif node.tag == HostText then - table.insert(children, '' .. node.memoizedProps) - else - descend = true - end - if descend and node.child ~= nil then - node.child.return_ = node - node = node.child - continue - end - while node.sibling == nil do - if node.return_ == startingNode then - breakOuter = true - break - end - node = node.return_ - end - -- ROBLOX deviation: use break flag instead of labeled loops - if breakOuter then - break - end - node.sibling.return_ = node.return_ - node = node.sibling - end - return children + local children = {} + local startingNode = parent + local node = startingNode + + if node.child == nil then + return children + end + + node.child.return_ = node + node = node.child + + -- ROBLOX deviation: use break flag instead of labeled loops + local breakOuter = false + + while true do + local descend = false + if validWrapperTypes[node.tag] ~= nil then + table.insert(children, wrapFiber(node)) + elseif node.tag == HostText then + table.insert(children, "" .. node.memoizedProps) + else + descend = true + end + if descend and node.child ~= nil then + node.child.return_ = node + node = node.child + continue + end + while node.sibling == nil do + if node.return_ == startingNode then + breakOuter = true + break + end + node = node.return_ + end + -- ROBLOX deviation: use break flag instead of labeled loops + if breakOuter then + break + end + node.sibling.return_ = node.return_ + node = node.sibling + end + return children end local function findAll( - root: Object, - predicate: Predicate, - options: FindOptions?): Array - - -- ROBLOX deviation: ternary split to conditional statement - local deep = true - if options then - deep = options.deep - end - local results = {} - - if predicate(root) then - table.insert(results, root) - if not deep then - return results - end - end - - -- ROBLOX deviation: use for loop instead of forEach - for _, child in ipairs(root.children) do - if typeof(child) == 'string' then - continue - end - -- ROBLOX deviation: use for loop to insert mulltiple elements - local findAllResult = findAll(child, predicate, options) - for i = 1, #findAllResult do - table.insert(results, findAllResult[i]) - end - end - return results + root: Object, + predicate: Predicate, + options: FindOptions? +): Array + -- ROBLOX deviation: ternary split to conditional statement + local deep = true + if options then + deep = options.deep + end + local results = {} + + if predicate(root) then + table.insert(results, root) + if not deep then + return results + end + end + + -- ROBLOX deviation: use for loop instead of forEach + for _, child in ipairs(root.children) do + if typeof(child) == "string" then + continue + end + -- ROBLOX deviation: use for loop to insert mulltiple elements + local findAllResult = findAll(child, predicate, options) + for i = 1, #findAllResult do + table.insert(results, findAllResult[i]) + end + end + return results end local function expectOne(all: Array, message: string): Object - if #all == 1 then - return all[1] - end - - local prefix - if #all == 0 then - prefix = 'No instances found ' - else - prefix = ('Expected 1 but found %s instances '):format(tostring(#all)) - end - - error(prefix .. message) + if #all == 1 then + return all[1] + end + + local prefix + if #all == 0 then + prefix = "No instances found " + else + prefix = ("Expected 1 but found %s instances "):format(tostring(#all)) + end + + error(prefix .. message) end local function propsMatch(props: Object, filter: Object): boolean - for key, _ in pairs(filter) do - if props[key] ~= filter[key] then - return false; - end - end - return true + for key, _ in pairs(filter) do + if props[key] ~= filter[key] then + return false + end + end + return true end -function ReactTestInstance:_currentFiber() : Fiber - -- Throws if this component has been unmounted. - local fiber = findCurrentFiberUsingSlowPath(self._fiber) - invariant(fiber ~= nil, "Can't read from currently-mounting component. This error is likely " .. - 'caused by a bug in React. Please file an issue.') - return fiber +function ReactTestInstance:_currentFiber(): Fiber + -- Throws if this component has been unmounted. + local fiber = findCurrentFiberUsingSlowPath(self._fiber) + invariant( + fiber ~= nil, + "Can't read from currently-mounting component. This error is likely " + .. "caused by a bug in React. Please file an issue." + ) + return fiber end -- ROBLOX deviation: metatable includes upstream -- getter methods and Class methods local function ReactTestInstanceGetters(self, key) - if key == 'instance' then - if self._fiber.tag == HostComponent then - return getPublicInstance(self._fiber.stateNode) - else - return self._fiber.stateNode - end - elseif key == 'type' then - return self._fiber.type - elseif key == 'props' then - return self:_currentFiber().memoizedProps - elseif key == 'parent' then - local parent = self._fiber.return_ - while parent ~= nil do - if validWrapperTypes[parent.tag] ~= nil then - if parent.tag == HostRoot then - -- Special case: we only "materialize" instances for roots - -- if they have more than a single child. So we'll check that now. - if #getChildren(parent) < 2 then - return nil; - end - end - return wrapFiber(parent) - end - parent = parent.return_ - end - return nil - elseif key == 'children' then - return getChildren(self:_currentFiber()) - else - return ReactTestInstance[key] - end + if key == "instance" then + if self._fiber.tag == HostComponent then + return getPublicInstance(self._fiber.stateNode) + else + return self._fiber.stateNode + end + elseif key == "type" then + return self._fiber.type + elseif key == "props" then + return self:_currentFiber().memoizedProps + elseif key == "parent" then + local parent = self._fiber.return_ + while parent ~= nil do + if validWrapperTypes[parent.tag] ~= nil then + if parent.tag == HostRoot then + -- Special case: we only "materialize" instances for roots + -- if they have more than a single child. So we'll check that now. + if #getChildren(parent) < 2 then + return nil + end + end + return wrapFiber(parent) + end + parent = parent.return_ + end + return nil + elseif key == "children" then + return getChildren(self:_currentFiber()) + else + return ReactTestInstance[key] + end end function ReactTestInstance.new(fiber: Fiber) - invariant(validWrapperTypes[fiber.tag] ~= nil, 'Unexpected object passed to ReactTestInstance constructor (tag: %s). ' .. - 'This is probably a bug in React.', fiber.tag) - local testInstance = {} - - -- ROBLOX deviation: set metatable to ReactTestInstanceGetters which includes upstream - -- getter methods and Class methods - setmetatable(testInstance, { - __index = ReactTestInstanceGetters - }) - testInstance._fiber = fiber - return testInstance + invariant( + validWrapperTypes[fiber.tag] ~= nil, + "Unexpected object passed to ReactTestInstance constructor (tag: %s). " + .. "This is probably a bug in React.", + fiber.tag + ) + local testInstance = {} + + -- ROBLOX deviation: set metatable to ReactTestInstanceGetters which includes upstream + -- getter methods and Class methods + setmetatable(testInstance, { + __index = ReactTestInstanceGetters, + }) + testInstance._fiber = fiber + return testInstance end -- Custom search functions function ReactTestInstance:find(predicate: Predicate): Object - return expectOne(self:findAll(predicate, {deep = false}), ('matching custom predicate: %s'):format(tostring(predicate))) + return expectOne( + self:findAll(predicate, { deep = false }), + ("matching custom predicate: %s"):format(tostring(predicate)) + ) end function ReactTestInstance:findByType(type_: any): Object - return expectOne(self:findAllByType(type_, {deep = false}), ('with node type: "%s"'):format(getComponentName(type_) or 'Unknown')) + return expectOne( + self:findAllByType(type_, { deep = false }), + ('with node type: "%s"'):format(getComponentName(type_) or "Unknown") + ) end function ReactTestInstance:findByProps(props: Object): Object - return expectOne(self:findAllByProps(props, {deep = false}), ('with props: %s'):format(JSON:JSONEncode(props))) + return expectOne( + self:findAllByProps(props, { deep = false }), + ("with props: %s"):format(JSON:JSONEncode(props)) + ) end -function ReactTestInstance:findAll(predicate: Predicate, options: FindOptions?): Array - return findAll(self, predicate, options) +function ReactTestInstance:findAll( + predicate: Predicate, + options: FindOptions? +): Array + return findAll(self, predicate, options) end function ReactTestInstance:findAllByType(type_: any, options: FindOptions?): Array - return findAll(self, function(node) - return node.type == type_ - end, options) + return findAll(self, function(node) + return node.type == type_ + end, options) end -function ReactTestInstance:findAllByProps(props: Object, options: FindOptions?): Array - return findAll(self, function(node) - return node.props and propsMatch(node.props, props) - end, options) +function ReactTestInstance:findAllByProps( + props: Object, + options: FindOptions? +): Array + return findAll(self, function(node) + return node.props and propsMatch(node.props, props) + end, options) end - - -- function create(element: React$Element, options: TestRendererOptions) { local function create(element, options: TestRendererOptions) - local createNodeMock = defaultTestOptions.createNodeMock - local isConcurrent = false - - if typeof(options) == 'table' and options ~= nil then - if typeof(options.createNodeMock) == 'function' then - createNodeMock = options.createNodeMock - end - if options.unstable_isConcurrent == true then - isConcurrent = true - end - end - - local container = { - children = {}, - createNodeMock = createNodeMock, - tag = 'CONTAINER', - } - - local rootArg = LegacyRoot - if isConcurrent then - rootArg = ConcurrentRoot - end - - -- ROBLOX deviation: remove Fiber? type to silence analyze - local root = createContainer(container, rootArg, false, nil) - - invariant(root ~= nil, 'something went wrong') - updateContainer(element, root, nil, nil) - - local entry = { - _Scheduler = Scheduler, - root = nil, -- makes flow happy - -- we define a 'getter' for 'root' below using 'Object.defineProperty' - toJSON = function() - if root == nil or root.current == nil or container == nil then - return nil - end - if #container.children == 0 then - return nil - end - if #container.children == 1 then - return toJSON(container.children[1]) - end - if #container.children == 2 and container.children[1].isHidden == true and container.children[2].isHidden == false then - -- Omit timed out children from output entirely, including the fact that we - -- temporarily wrap fallback and timed out children in an array. - return toJSON(container.children[2]) - end - - local renderedChildren = nil - - if container.children and #container.children ~= 0 then - for i = 1, #container.children do - local renderedChild = toJSON(container.children[i]) - - if renderedChild ~= nil then - if renderedChildren == nil then - renderedChildren = {renderedChild} - else - table.insert(renderedChildren, renderedChild) - end - end - end - end - - return renderedChildren - end, - toTree = function() - if root == nil or root.current == nil then - return nil - end - - return toTree(root.current) - end, - update = function(newElement) - if root == nil or root.current == nil then - return - end - - updateContainer(newElement, root, nil, nil) - end, - unmount = function() - if root == nil or root.current == nil then - return - end - - updateContainer(nil, root, nil, nil) - - root = nil - end, - getInstance = function() - if root == nil or root.current == nil then - return nil - end - - return getPublicRootInstance(root) - end, - unstable_flushSync = function(fn) - return flushSync(fn) - end, - } - - setmetatable(entry, { - __index = function(t, k) - if k == 'root' then - if root == nil then - error("Can't access .root on unmounted test renderer") - end - - local children = getChildren(root.current) - - if #children == 0 then - error("Can't access .root on unmounted test renderer") - elseif #children == 1 then - return children[1] - else - return wrapFiber(root.current) - end - end - return - end - }) - - return entry + local createNodeMock = defaultTestOptions.createNodeMock + local isConcurrent = false + + if typeof(options) == "table" and options ~= nil then + if typeof(options.createNodeMock) == "function" then + createNodeMock = options.createNodeMock + end + if options.unstable_isConcurrent == true then + isConcurrent = true + end + end + + local container = { + children = {}, + createNodeMock = createNodeMock, + tag = "CONTAINER", + } + + local rootArg = LegacyRoot + if isConcurrent then + rootArg = ConcurrentRoot + end + + -- ROBLOX deviation: remove Fiber? type to silence analyze + local root = createContainer(container, rootArg, false, nil) + + invariant(root ~= nil, "something went wrong") + updateContainer(element, root, nil, nil) + + local entry = { + _Scheduler = Scheduler, + root = nil, -- makes flow happy + -- we define a 'getter' for 'root' below using 'Object.defineProperty' + toJSON = function() + if root == nil or root.current == nil or container == nil then + return nil + end + if #container.children == 0 then + return nil + end + if #container.children == 1 then + return toJSON(container.children[1]) + end + if + #container.children == 2 + and container.children[1].isHidden == true + and container.children[2].isHidden == false + then + -- Omit timed out children from output entirely, including the fact that we + -- temporarily wrap fallback and timed out children in an array. + return toJSON(container.children[2]) + end + + local renderedChildren = nil + + if container.children and #container.children ~= 0 then + for i = 1, #container.children do + local renderedChild = toJSON(container.children[i]) + + if renderedChild ~= nil then + if renderedChildren == nil then + renderedChildren = { renderedChild } + else + table.insert(renderedChildren, renderedChild) + end + end + end + end + + return renderedChildren + end, + toTree = function() + if root == nil or root.current == nil then + return nil + end + + return toTree(root.current) + end, + update = function(newElement) + if root == nil or root.current == nil then + return + end + + updateContainer(newElement, root, nil, nil) + end, + unmount = function() + if root == nil or root.current == nil then + return + end + + updateContainer(nil, root, nil, nil) + + root = nil + end, + getInstance = function() + if root == nil or root.current == nil then + return nil + end + + return getPublicRootInstance(root) + end, + unstable_flushSync = function(fn) + return flushSync(fn) + end, + } + + setmetatable(entry, { + __index = function(t, k) + if k == "root" then + if root == nil then + error("Can't access .root on unmounted test renderer") + end + + local children = getChildren(root.current) + + if #children == 0 then + error("Can't access .root on unmounted test renderer") + elseif #children == 1 then + return children[1] + else + return wrapFiber(root.current) + end + end + return + end, + }) + + return entry end -- Enable ReactTestRenderer to be used to test DevTools integration. local bundleType = 0 if _G.__DEV__ then - bundleType = 1 + bundleType = 1 end injectIntoDevTools({ - findFiberByHostInstance = function() - error('TestRenderer does not support findFiberByHostInstance()') - end, - bundleType = bundleType, - version = ReactVersion, - rendererPackageName = 'react-test-renderer', + findFiberByHostInstance = function() + error("TestRenderer does not support findFiberByHostInstance()") + end, + bundleType = bundleType, + version = ReactVersion, + rendererPackageName = "react-test-renderer", }) local actingUpdatesScopeDepth = 0 @@ -674,102 +699,109 @@ local actingUpdatesScopeDepth = 0 -- TODO: Migrate our tests to use ReactNoop. Although we would need to figure -- out a solution for Relay, which has some Concurrent Mode tests. local function unstable_concurrentAct(scope: () -> () | Thenable) - if Scheduler.unstable_flushAllWithoutAsserting == nil then - error('This version of `act` requires a special mock build of Scheduler.') - end - if typeof(setTimeout) == "table" and setTimeout._isMockFunction ~= true then - error("This version of `act` requires Jest's timer mocks " .. '(i.e. jest.useFakeTimers).') - end - - local previousActingUpdatesScopeDepth = actingUpdatesScopeDepth - local previousIsSomeRendererActing = IsSomeRendererActing.current - local previousIsThisRendererActing = IsThisRendererActing.current - - IsSomeRendererActing.current = true - IsThisRendererActing.current = true - actingUpdatesScopeDepth = actingUpdatesScopeDepth + 1 - - local unwind = function() - actingUpdatesScopeDepth = actingUpdatesScopeDepth - 1 - IsSomeRendererActing.current = previousIsSomeRendererActing - IsThisRendererActing.current = previousIsThisRendererActing - - if _G.__DEV__ then - if actingUpdatesScopeDepth > previousActingUpdatesScopeDepth then - console.error('You seem to have overlapping act() calls, this is not supported. ' .. 'Be sure to await previous act() calls before making a new one. ') - end - end - end - - -- TODO: This would be way simpler if 1) we required a promise to be - -- returned and 2) we could use async/await. Since it's only our used in - -- our test suite, we should be able to. - local ok, _ = pcall(function() - local thenable = batchedUpdates(scope) - if - typeof(thenable) == 'table' and - thenable ~= nil and - typeof(thenable.andThen) == 'function' then - return function(resolve, reject) - thenable:andThen(function() - flushActWork(function() - unwind() - resolve() - end, function(error_) - unwind() - reject(error_) - end) - end, function(error_) - unwind() - reject(error_) - end) - end - else - local _, _ = pcall(function() - -- TODO: Let's not support non-async scopes at all in our tests. Need to - -- migrate existing tests. - local didFlushWork - repeat - didFlushWork = Scheduler.unstable_flushAllWithoutAsserting() - until not didFlushWork - end) - -- ROBLOX finally - unwind() - end - return - end) - if not ok then - unwind() - error('') - end + if Scheduler.unstable_flushAllWithoutAsserting == nil then + error("This version of `act` requires a special mock build of Scheduler.") + end + if typeof(setTimeout) == "table" and setTimeout._isMockFunction ~= true then + error( + "This version of `act` requires Jest's timer mocks " + .. "(i.e. jest.useFakeTimers)." + ) + end + + local previousActingUpdatesScopeDepth = actingUpdatesScopeDepth + local previousIsSomeRendererActing = IsSomeRendererActing.current + local previousIsThisRendererActing = IsThisRendererActing.current + + IsSomeRendererActing.current = true + IsThisRendererActing.current = true + actingUpdatesScopeDepth = actingUpdatesScopeDepth + 1 + + local unwind = function() + actingUpdatesScopeDepth = actingUpdatesScopeDepth - 1 + IsSomeRendererActing.current = previousIsSomeRendererActing + IsThisRendererActing.current = previousIsThisRendererActing + + if _G.__DEV__ then + if actingUpdatesScopeDepth > previousActingUpdatesScopeDepth then + console.error( + "You seem to have overlapping act() calls, this is not supported. " + .. "Be sure to await previous act() calls before making a new one. " + ) + end + end + end + + -- TODO: This would be way simpler if 1) we required a promise to be + -- returned and 2) we could use async/await. Since it's only our used in + -- our test suite, we should be able to. + local ok, _ = pcall(function() + local thenable = batchedUpdates(scope) + if + typeof(thenable) == "table" + and thenable ~= nil + and typeof(thenable.andThen) == "function" + then + return function(resolve, reject) + thenable:andThen(function() + flushActWork(function() + unwind() + resolve() + end, function(error_) + unwind() + reject(error_) + end) + end, function(error_) + unwind() + reject(error_) + end) + end + else + local _, _ = pcall(function() + -- TODO: Let's not support non-async scopes at all in our tests. Need to + -- migrate existing tests. + local didFlushWork + repeat + didFlushWork = Scheduler.unstable_flushAllWithoutAsserting() + until not didFlushWork + end) + -- ROBLOX finally + unwind() + end + return + end) + if not ok then + unwind() + error("") + end end function flushActWork(resolve, reject) - -- Flush suspended fallbacks - -- $FlowFixMe: Flow doesn't know about global Jest object - - -- ROBLOX TODO: Jest runONlyPendingTimers() not implemented (uncomment line below) - -- jest.runOnlyPendingTimers() - - enqueueTask(function() - local ok, _ = pcall(function() - local didFlushWork = Scheduler.unstable_flushAllWithoutAsserting() - if didFlushWork then - flushActWork(resolve, reject); - else - resolve() - end - end) - if not ok then - reject(error) - end - end) + -- Flush suspended fallbacks + -- $FlowFixMe: Flow doesn't know about global Jest object + + -- ROBLOX TODO: Jest runONlyPendingTimers() not implemented (uncomment line below) + -- jest.runOnlyPendingTimers() + + enqueueTask(function() + local ok, _ = pcall(function() + local didFlushWork = Scheduler.unstable_flushAllWithoutAsserting() + if didFlushWork then + flushActWork(resolve, reject) + else + resolve() + end + end) + if not ok then + reject(error) + end + end) end return { - Scheduler = Scheduler, - create = create, - unstable_batchedUpdates = batchedUpdates, - act = act, - unstable_concurrentAct = unstable_concurrentAct + Scheduler = Scheduler, + create = create, + unstable_batchedUpdates = batchedUpdates, + act = act, + unstable_concurrentAct = unstable_concurrentAct, } diff --git a/modules/react-test-renderer/src/__tests__/ReactTestRenderer-internal.spec.lua b/modules/react-test-renderer/src/__tests__/ReactTestRenderer-internal.spec.lua index 34ea6146..0f244df2 100644 --- a/modules/react-test-renderer/src/__tests__/ReactTestRenderer-internal.spec.lua +++ b/modules/react-test-renderer/src/__tests__/ReactTestRenderer-internal.spec.lua @@ -31,1240 +31,1377 @@ local Symbol = LuauPolyfill.Symbol -- also delete children props because testing them is more annoying and not -- really important to verify. local function cleanNodeOrArray(node) - if not node then - return - end - if Array.isArray(node) then - -- ROBLOX deviation: for loop in place of forEach() - for _, v in ipairs(node) do - cleanNodeOrArray(v) - end - return - end - if node and node.instance then - node.instance = nil - end - if node and node.props and node.props.children then - -- eslint-disable-next-line no-unused-vars - node.props["children"] = nil - end - if Array.isArray(node.rendered) then - -- ROBLOX deviation: for loop in place of forEach() - for _, v in ipairs(node.rendered) do - cleanNodeOrArray(v) - end - elseif typeof(node.rendered) == 'table' then - cleanNodeOrArray(node.rendered) - end + if not node then + return + end + if Array.isArray(node) then + -- ROBLOX deviation: for loop in place of forEach() + for _, v in ipairs(node) do + cleanNodeOrArray(v) + end + return + end + if node and node.instance then + node.instance = nil + end + if node and node.props and node.props.children then + -- eslint-disable-next-line no-unused-vars + node.props["children"] = nil + end + if Array.isArray(node.rendered) then + -- ROBLOX deviation: for loop in place of forEach() + for _, v in ipairs(node.rendered) do + cleanNodeOrArray(v) + end + elseif typeof(node.rendered) == "table" then + cleanNodeOrArray(node.rendered) + end end -return function () - RobloxJest = require(Packages.Dev.RobloxJest) - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - - describe('ReactTestRenderer', function() - beforeEach(function() - RobloxJest.resetModules() - - ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false - - React = require(Packages.React) - ReactTestRenderer = require(Packages.ReactTestRenderer) - -- local prettyFormat = require('pretty-format') - - end) - it('renders a simple component', function() - local function Link() - return React.createElement('a', { - role = 'link', - }) - end - - - local renderer = ReactTestRenderer.create(React.createElement(Link, nil)) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'a', - props = { - role = 'link', - }, - children = nil, - }) - end) - it('renders a top-level empty component', function() - local function Empty() - return nil - end - - local renderer = ReactTestRenderer.create(React.createElement(Empty, nil)) - - jestExpect(renderer.toJSON()).toEqual(nil) - end) - it('exposes a type flag', function() - local function Link() - return React.createElement('a', { - role = 'link', - }) - end - - local renderer = ReactTestRenderer.create(React.createElement(Link, nil)) - local object = renderer.toJSON() - -- ROBLOX FIXME: needs to stringify $$typeof because Symbol module is reset. Un-stringify once we've found a solution. - jestExpect(tostring(object['$$typeof'])).toEqual(tostring(Symbol.for_('react.test.json'))) - - -- $$typeof should not be enumerable. - for key, _ in pairs(object) do - jestExpect(key).never.toEqual('$$typeof') - end - end) - it('can render a composite component', function() - local Component = React.Component:extend("Component") - - local Child = function() - return React.createElement('moo', nil) - end - - function Component:render() - return React.createElement('div', { - className = 'purple', - }, React.createElement(Child, nil)) - end - - local renderer = ReactTestRenderer.create(React.createElement(Component, nil)) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = { - className = 'purple', - }, - children = { - { - type = 'moo', - props = {}, - children = nil, - }, - }, - }) - end) - it('renders some basics with an update', function() - local renders = 0 - local Component = React.Component:extend("Component") - - local Child = function() - renders = renders + 1 - - return React.createElement('moo', nil) - end - local Null = function() - renders = renders + 1 - return nil - end - - function Component:init() - self.state = {x = 3} - return - end - function Component:render() - renders = renders + 1 - - return React.createElement('div', { - className = 'purple', - }, self.state.x, React.createElement(Child, nil), React.createElement(Null, nil)) - end - function Component:componentDidMount() - self:setState({x = 7}) - end - - - local renderer = ReactTestRenderer.create(React.createElement(Component, nil)) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = { - className = 'purple', - }, - children = { - '7', - { - type = 'moo', - props = {}, - children = nil, - }, - }, - }) - jestExpect(renders).toEqual(6) - end) - it('exposes the instance', function() - local Mouse = React.Component:extend("Mouse") - - function Mouse:init() - self.state = { - mouse = 'mouse', - } - end - function Mouse:handleMoose() - self:setState({ - mouse = 'moose', - }) - end - function Mouse:render() - return React.createElement('div', nil, self.state.mouse) - end - - local renderer = ReactTestRenderer.create(React.createElement(Mouse, nil)) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = {}, - children = { - 'mouse', - }, - }) - - local mouse = renderer.getInstance() - - mouse:handleMoose() - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - 'moose', - }, - props = {}, - }) - end) - it('updates types', function() - local renderer = ReactTestRenderer.create(React.createElement('div', nil, 'mouse')) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = {}, - children = { - 'mouse', - }, - }) - renderer.update(React.createElement('span', nil, 'mice')) - jestExpect(renderer.toJSON()).toEqual({ - type = 'span', - props = {}, - children = { - 'mice', - }, - }) - end) - it('updates children', function() - local renderer = ReactTestRenderer.create(React.createElement('div', nil, React.createElement('span', { - key = 'a', - }, 'A'), React.createElement('span', { - key = 'b', - }, 'B'), React.createElement('span', { - key = 'c', - }, 'C'))) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = {}, - children = { - { - type = 'span', - props = {}, - children = { - 'A', - }, - }, - { - type = 'span', - props = {}, - children = { - 'B', - }, - }, - { - type = 'span', - props = {}, - children = { - 'C', - }, - }, - }, - }) - renderer.update(React.createElement('div', nil, React.createElement('span', { - key = 'd', - }, 'D'), React.createElement('span', { - key = 'c', - }, 'C'), React.createElement('span', { - key = 'b', - }, 'B'))) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = {}, - children = { - { - type = 'span', - props = {}, - children = { - 'D', - }, - }, - { - type = 'span', - props = {}, - children = { - 'C', - }, - }, - { - type = 'span', - props = {}, - children = { - 'B', - }, - }, - }, - }) - end) - it('does the full lifecycle', function() - local log = {} - local Log = React.Component:extend("Log") - - function Log:render() - table.insert(log, 'render ' .. self.props.name) - return React.createElement('div', nil) - end - function Log:componentDidMount() - table.insert(log, 'mount ' .. self.props.name) - end - function Log:componentWillUnmount() - table.insert(log, 'unmount ' .. self.props.name) - end - - local renderer = ReactTestRenderer.create(React.createElement(Log, { - key = 'foo', - name = 'Foo', - })) - - renderer.update(React.createElement(Log, { - key = 'bar', - name = 'Bar', - })) - renderer.unmount() - jestExpect(log).toEqual({ - 'render Foo', - 'mount Foo', - 'render Bar', - 'unmount Foo', - 'mount Bar', - 'unmount Bar', - }) - end) - it('gives a ref to native components', function() - local log = {} - - ReactTestRenderer.create(React.createElement('div', { - ref = function(r) - return table.insert(log, r) - end, - })) - jestExpect(log).toEqual({nil}) - end) - it('warns correctly for refs on SFCs', function() - local function Bar() - return React.createElement('div', nil, 'Hello, world') - end - - local Foo = React.Component:extend("Foo") - - function Foo:render() - return React.createElement(Bar, { - ref = 'foo', - }) - end - - local Baz = React.Component:extend("Baz") - - function Baz:render() - return React.createElement('div', { - ref = 'baz', - }) - end - - ReactTestRenderer.create(React.createElement(Baz, nil)) - jestExpect(function() - return ReactTestRenderer.create(React.createElement(Foo, nil)) - end).toErrorDev('Warning: Function components cannot be given refs. Attempts ' .. - 'to access this ref will fail. ' .. - 'Did you mean to use React.forwardRef()?\n\n' .. - 'Check the render method of `Foo`.\n' .. - ' in Bar (at **)\n' .. - ' in Foo (at **)') - end) - it('allows an optional createNodeMock function', function() - local mockDivInstance = { - appendChild = function() end, - } - local mockInputInstance = { - focus = function() end, - } - local mockListItemInstance = { - click = function() end, - } - local mockAnchorInstance = { - hover = function() end, - } - local log = {} - - -- ROBLOX deviation: using createRef in place of string refs - local bar = React.createRef() - local Foo = React.Component:extend("Foo") - - function Foo:componentDidMount() - table.insert(log, bar.current) - end - function Foo:render() - return React.createElement('a', { - ref = bar, - }, 'Hello, world') - end - - local function createNodeMock(element) - if element.type == 'div' then - return mockDivInstance - elseif element.type == 'input' then - return mockInputInstance - elseif element.type == 'li' then - return mockListItemInstance - elseif element.type == 'a' then - return mockAnchorInstance - else - return {} - end - end - - ReactTestRenderer.create(React.createElement('div', { - ref = function(r) - return table.insert(log, r) - end, - }), {createNodeMock = createNodeMock}) - ReactTestRenderer.create(React.createElement('input', { - ref = function(r) - return table.insert(log, r) - end, - }), {createNodeMock = createNodeMock}) - ReactTestRenderer.create(React.createElement('div', nil, React.createElement('span', nil, React.createElement('ul', nil, React.createElement('li', { - ref = function(r) - return table.insert(log, r) - end, - })), React.createElement('ul', nil, React.createElement('li', { - ref = function(r) - return table.insert(log, r) - end, - }), React.createElement('li', { - ref = function(r) - return table.insert(log, r) - end, - })))), { - createNodeMock = createNodeMock, - foobar = true, - }) - ReactTestRenderer.create(React.createElement(Foo, nil), {createNodeMock = createNodeMock}) - ReactTestRenderer.create(React.createElement('div', { - ref = function(r) - return table.insert(log, r) - end, - })) - ReactTestRenderer.create(React.createElement('div', { - ref = function(r) - return table.insert(log, r) - end, - }), {}) - jestExpect(log).toEqual({ - mockDivInstance, - mockInputInstance, - mockListItemInstance, - mockListItemInstance, - mockListItemInstance, - mockAnchorInstance, - }) - end) - it('supports unmounting when using refs', function() - local Foo = React.Component:extend("Foo") - - -- ROBLOX deviation: using createRef in place of string refs - local foo = React.createRef() - - function Foo:render() - return React.createElement('div', { - ref = foo, - }) - end - - local inst = ReactTestRenderer.create(React.createElement(Foo, nil), { - createNodeMock = function() - return foo.current - end, - }) - - jestExpect(function() - return inst.unmount() - end).never.toThrow() - end) - it('supports unmounting inner instances', function() - local count = 0 - local Foo = React.Component:extend("Foo") - - function Foo:componentWillUnmount() - count = count + 1 - end - function Foo:render() - return React.createElement('div', nil) - end - - local inst = ReactTestRenderer.create(React.createElement('div', nil, React.createElement(Foo, nil)), { - createNodeMock = function() - return'foo' - end, - }) - - jestExpect(function() - return inst.unmount() - end).never.toThrow() - jestExpect(count).toEqual(1) - end) - it('supports updates when using refs', function() - local log = {} - local createNodeMock = function(element) - table.insert(log, element.type) - return element.type - end - local Foo = React.Component:extend("Foo") - - function Foo:render() - return(function() - if self.props.useDiv then - return React.createElement('div', { - ref = 'foo', - }) - end - - return React.createElement('span', { - ref = 'foo', - }) - end)() - end - - local inst = ReactTestRenderer.create(React.createElement(Foo, {useDiv = true}), {createNodeMock = createNodeMock}) - - inst.update(React.createElement(Foo, {useDiv = false})) - jestExpect(log).toEqual({ - 'div', - 'span', - }) - end) - it('supports error boundaries', function() - local log = {} - local Angry = React.Component:extend("Angry") - - function Angry:render() - table.insert(log, 'Angry render') - error('Please, do not render me.') - end - function Angry:componentDidMount() - table.insert(log, 'Angry componentDidMount') - end - function Angry:componentWillUnmount() - table.insert(log, 'Angry componentWillUnmount') - end - - local Boundary = React.Component:extend("Boundary") - - function Boundary:init(props) - self.state = {error = false} - end - function Boundary:render() - table.insert(log, 'Boundary render') - - if not self.state.error then - return React.createElement('div', nil, React.createElement('button', { - onClick = self.onClick, - }, 'ClickMe'), React.createElement(Angry, nil)) - else - return React.createElement('div', nil, 'Happy Birthday!') - end - end - function Boundary:componentDidMount() - table.insert(log, 'Boundary componentDidMount') - end - function Boundary:componentWillUnmount() - table.insert(log, 'Boundary componentWillUnmount') - end - function Boundary:onClick() - -- do nothing - return - end - function Boundary:componentDidCatch() - table.insert(log, 'Boundary componentDidCatch') - self:setState({error = true}) - end - - local renderer = ReactTestRenderer.create(React.createElement(Boundary, nil)) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - props = {}, - children = { - 'Happy Birthday!', - }, - }) - jestExpect(log).toEqual({ - 'Boundary render', - 'Angry render', - 'Boundary componentDidMount', - 'Boundary componentDidCatch', - 'Boundary render', - }) - end) - it('can update text nodes', function() - local Component = React.Component:extend("Component") - - function Component:render() - return React.createElement('div', nil, self.props.children) - end - - local renderer = ReactTestRenderer.create(React.createElement(Component, nil, 'Hi')) - - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - 'Hi', - }, - props = {}, - }) - renderer.update(React.createElement(Component, nil, { - 'Hi', - 'Bye', - })) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - 'Hi', - 'Bye', - }, - props = {}, - }) - renderer.update(React.createElement(Component, nil, 'Bye')) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - 'Bye', - }, - props = {}, - }) - renderer.update(React.createElement(Component, nil, 42)) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - '42', - }, - props = {}, - }) - renderer.update(React.createElement(Component, nil, React.createElement('div', nil))) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = { - { - type = 'div', - children = nil, - props = {}, - }, - }, - props = {}, - }) - end) - it('toTree() renders simple components returning host components', function() - local Qoo = function() - return React.createElement('span', { - className = 'Qoo', - }, 'Hello World!') - end - local renderer = ReactTestRenderer.create(React.createElement(Qoo, nil)) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - nodeType = 'component', - type = Qoo, - props = {}, - instance = nil, - rendered = { - nodeType = 'host', - type = 'span', - props = { - className = 'Qoo', - }, - instance = nil, - rendered = { - 'Hello World!', - }, - }, - }) - end) - it('toTree() handles nested Fragments', function() - local Foo = function() - return React.createElement(React.Fragment, nil, React.createElement(React.Fragment, nil, 'foo')) - end - local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - nodeType = 'component', - type = Foo, - instance = nil, - props = {}, - rendered = 'foo', - }) - end) - it('toTree() handles null rendering components', function() - local Foo = React.Component:extend("Foo") - - function Foo:render() - return nil - end - - local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) - local tree = renderer.toTree() - - -- ROBLOX deviation: toBeInstanceOf not yet implemented, workaround by checking elementType - jestExpect(tree.instance._reactInternals.elementType.__componentName).toEqual("Foo") - cleanNodeOrArray(tree) - - -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we - -- move to Roblox Jest - tree.type.__index = nil - jestExpect(tree).toEqual({ - type = Foo, - nodeType = 'component', - props = {}, - instance = nil, - rendered = nil, - }) - end) - it('toTree() handles simple components that return arrays', function() - local Foo = function(_ref) - local children = _ref.children - - return children - end - local renderer = ReactTestRenderer.create(React.createElement(Foo, nil, React.createElement('div', nil, 'One'), React.createElement('div', nil, 'Two'))) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - type = Foo, - nodeType = 'component', - props = {}, - instance = nil, - rendered = { - { - instance = nil, - nodeType = 'host', - props = {}, - rendered = { - 'One', - }, - type = 'div', - }, - { - instance = nil, - nodeType = 'host', - props = {}, - rendered = { - 'Two', - }, - type = 'div', - }, - }, - }) - end) - it('toTree() handles complicated tree of arrays', function() - local Foo = React.Component:extend("Foo") - - function Foo:render() - return self.props.children - end - - local renderer = ReactTestRenderer.create(React.createElement('div', nil, React.createElement(Foo, nil, React.createElement('div', nil, 'One'), React.createElement('div', nil, 'Two'), React.createElement(Foo, nil, React.createElement('div', nil, 'Three'))), React.createElement('div', nil, 'Four'))) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - - -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we - -- move to Roblox Jest - tree.rendered[1].type.__index = nil - - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - type = 'div', - instance = nil, - nodeType = 'host', - props = {}, - rendered = { - { - type = Foo, - nodeType = 'component', - props = {}, - instance = nil, - rendered = { - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'One', - }, - }, - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Two', - }, - }, - { - type = Foo, - nodeType = 'component', - props = {}, - instance = nil, - rendered = { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Three', - }, - }, - }, - }, - }, - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Four', - }, - }, - }, - }) - end) - it('toTree() handles complicated tree of fragments', function() - local renderer = ReactTestRenderer.create( - React.createElement( - React.Fragment, - nil, - React.createElement( - React.Fragment, - nil, - React.createElement('div', nil, 'One'), - React.createElement('div', nil, 'Two'), - React.createElement(React.Fragment, nil, React.createElement('div', nil, 'Three'))), - React.createElement('div', nil, 'Four')) - ) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'One', - }, - }, - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Two', - }, - }, - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Three', - }, - }, - { - type = 'div', - nodeType = 'host', - props = {}, - instance = nil, - rendered = { - 'Four', - }, - }, - }) - end) - it('root instance and createNodeMock ref return the same value', function() - local createNodeMock = function(ref) - return {node = ref} - end - local refInst = nil - local renderer = ReactTestRenderer.create(React.createElement('div', { - ref = function(ref) - refInst = ref - return - end, - }), {createNodeMock = createNodeMock}) - local root = renderer.getInstance() - - jestExpect(root).toEqual(refInst) - end) - it('toTree() renders complicated trees of composites and hosts', function() - -- SFC returning host. no children props. - local Qoo = function() - return React.createElement('span', { - className = 'Qoo', - }, 'Hello World!') - end - - -- SFC returning host. passes through children. - local Foo = function(props) - local className, children = props.className, props.children - - return React.createElement('div', { - className = 'Foo ' .. className, - }, React.createElement('span', { - className = 'Foo2', - }, 'Literal'), children) - end - - -- class composite returning composite. passes through children. - local Bar = React.Component:extend("Bar") - function Bar:render() - local children = self.props.children - local special = self.props.special - - return React.createElement(Foo, { - className = (function() - if special then - return'special' - end - - return'normal' - end)(), - }, children) - end - - -- class composite return composite. no children props. - local Bam = React.Component:extend("Bam") - - function Bam:render() - return React.createElement(Bar, {special = true}, React.createElement(Qoo, nil)) - end - - local renderer = ReactTestRenderer.create(React.createElement(Bam, nil)) - local tree = renderer.toTree() - - -- we test for the presence of instances before nulling them out - -- ROBLOX deviation: toBeInstanceOf not yet implemented, workaround by checking elementType - jestExpect(tree.instance._reactInternals.elementType.__componentName).toEqual("Bam") - jestExpect(tree.rendered.instance._reactInternals.elementType.__componentName).toEqual("Bar") - - cleanNodeOrArray(tree) - - -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we - -- move to Roblox Jest - tree.type.__index = nil - tree.rendered.type.__index = nil - - - jestExpect(tree).toEqual({ - type = Bam, - nodeType = 'component', - props = {}, - instance = nil, - rendered = { - type = Bar, - nodeType = 'component', - props = {special = true}, - instance = nil, - rendered = { - type = Foo, - nodeType = 'component', - props = { - className = 'special', - }, - instance = nil, - rendered = { - type = 'div', - nodeType = 'host', - props = { - className = 'Foo special', - }, - instance = nil, - rendered = { - { - type = 'span', - nodeType = 'host', - props = { - className = 'Foo2', - }, - instance = nil, - rendered = { - 'Literal', - }, - }, - { - type = Qoo, - nodeType = 'component', - props = {}, - instance = nil, - rendered = { - type = 'span', - nodeType = 'host', - props = { - className = 'Qoo', - }, - instance = nil, - rendered = { - 'Hello World!', - }, - }, - }, - }, - }, - }, - }, - }) - end) - it('can update text nodes when rendered as root', function() - local renderer = ReactTestRenderer.create({ - 'Hello', - 'world', - }) - - jestExpect(renderer.toJSON()).toEqual({ - 'Hello', - 'world', - }) - renderer.update(42) - jestExpect(renderer.toJSON()).toEqual('42') - renderer.update({ - 42, - 'world', - }) - jestExpect(renderer.toJSON()).toEqual({ - '42', - 'world', - }) - end) - it('can render and update root fragments', function() - local Component = function(props) - return props.children - end - local renderer = ReactTestRenderer.create({ - React.createElement(Component, { - key = 'a', - }, 'Hi'), - React.createElement(Component, { - key = 'b', - }, 'Bye'), - }) - - jestExpect(renderer.toJSON()).toEqual({ - 'Hi', - 'Bye', - }) - renderer.update(React.createElement('div', nil)) - jestExpect(renderer.toJSON()).toEqual({ - type = 'div', - children = nil, - props = {}, - }) - renderer.update({ - React.createElement('div', { - key = 'a', - }, 'goodbye'), - 'world', - }) - jestExpect(renderer.toJSON()).toEqual({ - { - type = 'div', - children = { - 'goodbye', - }, - props = {}, - }, - 'world', - }) - end) - it('supports context providers and consumers', function() - local context = React.createContext('a') - local Consumer = context.Consumer - local Provider = context.Provider - - local function Child(props) - return props.value - end - local function App() - return React.createElement(Provider, { - value = 'b', - }, React.createElement(Consumer, nil, function(value) - return React.createElement(Child, {value = value}) - end)) - end - - local renderer = ReactTestRenderer.create(React.createElement(App, nil)) - local child = renderer.root:findByType(Child) - - jestExpect(child.children).toEqual({ - 'b', - }) - -- ROBLOX deviation: no need to pretty format - jestExpect(renderer.toTree()).toEqual({ - instance = nil, - nodeType = 'component', - props = {}, - rendered = { - instance = nil, - nodeType = 'component', - props = { - value = 'b', - }, - rendered = 'b', - type = Child, - }, - type = App, - }) - end) - it('supports modes', function() - local function Child(props) - return props.value - end - local function App(props) - return React.createElement(React.StrictMode, nil, React.createElement(Child, { - value = props.value, - })) - end - - local renderer = ReactTestRenderer.create(React.createElement(App, { - value = 'a', - })) - local child = renderer.root:findByType(Child) - - jestExpect(child.children).toEqual({ - 'a', - }) - -- ROBLOX deviation: no need to pretty format - jestExpect(renderer.toTree()).toEqual({ - instance = nil, - nodeType = 'component', - props = { - value = 'a', - }, - rendered = { - instance = nil, - nodeType = 'component', - props = { - value = 'a', - }, - rendered = 'a', - type = Child, - }, - type = App, - }) - end) - it('supports forwardRef', function() - local InnerRefed = React.forwardRef(function(props, ref) - return React.createElement('div', nil, React.createElement('span', {ref = ref})) - end) - local App = React.Component:extend("App") - - function App:render() - - return React.createElement(InnerRefed, { - ref = function(r) - self.ref = r - return - end, - }) - end - - local renderer = ReactTestRenderer.create(React.createElement(App, nil)) - local tree = renderer.toTree() - - cleanNodeOrArray(tree) - - -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we - -- move to Roblox Jest - tree.type.__index = nil - -- ROBLOX deviation: no need to pretty format - jestExpect(tree).toEqual({ - instance = nil, - nodeType = 'component', - props = {}, - rendered = { - instance = nil, - nodeType = 'host', - props = {}, - rendered = { - { - instance = nil, - nodeType = 'host', - props = {}, - rendered = {}, - type = 'span', - }, - }, - type = 'div', - }, - type = App, - }) - end) - -- ROBLOX TODO: set up React Noop in this file - -- xit('can concurrently render context with a "primary" renderer', function() - -- local Context = React.createContext(nil) - -- local Indirection = React.Fragment - -- local App = function() - -- return React.createElement(Context.Provider, {value = nil}, React.createElement(Indirection, nil, React.createElement(Context.Consumer, nil, function( - -- ) - -- return nil - -- end))) - -- end - - -- ReactNoop.render(React.createElement(App, nil)) - -- jestExpect(Scheduler).toFlushWithoutYielding() - -- ReactTestRenderer.create(React.createElement(App, nil)) - -- end) - it('calling findByType() with an invalid component will fall back to "Unknown" for component name', function() - local App = function() - return nil - end - local renderer = ReactTestRenderer.create(React.createElement(App, nil)) - local NonComponent = {} - jestExpect(function() - renderer.root:findByType(NonComponent) - end).toThrow('No instances found with node type: "Unknown"') - end) - end) -end \ No newline at end of file +return function() + RobloxJest = require(Packages.Dev.RobloxJest) + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + + describe("ReactTestRenderer", function() + beforeEach(function() + RobloxJest.resetModules() + + ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false + + React = require(Packages.React) + ReactTestRenderer = require(Packages.ReactTestRenderer) + -- local prettyFormat = require('pretty-format') + end) + it("renders a simple component", function() + local function Link() + return React.createElement("a", { + role = "link", + }) + end + + local renderer = ReactTestRenderer.create(React.createElement(Link, nil)) + + jestExpect(renderer.toJSON()).toEqual({ + type = "a", + props = { + role = "link", + }, + children = nil, + }) + end) + it("renders a top-level empty component", function() + local function Empty() + return nil + end + + local renderer = ReactTestRenderer.create(React.createElement(Empty, nil)) + + jestExpect(renderer.toJSON()).toEqual(nil) + end) + it("exposes a type flag", function() + local function Link() + return React.createElement("a", { + role = "link", + }) + end + + local renderer = ReactTestRenderer.create(React.createElement(Link, nil)) + local object = renderer.toJSON() + -- ROBLOX FIXME: needs to stringify $$typeof because Symbol module is reset. Un-stringify once we've found a solution. + jestExpect(tostring(object["$$typeof"])).toEqual( + tostring(Symbol.for_("react.test.json")) + ) + + -- $$typeof should not be enumerable. + for key, _ in pairs(object) do + jestExpect(key).never.toEqual("$$typeof") + end + end) + it("can render a composite component", function() + local Component = React.Component:extend("Component") + + local Child = function() + return React.createElement("moo", nil) + end + + function Component:render() + return React.createElement("div", { + className = "purple", + }, React.createElement( + Child, + nil + )) + end + + local renderer = ReactTestRenderer.create(React.createElement(Component, nil)) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = { + className = "purple", + }, + children = { + { + type = "moo", + props = {}, + children = nil, + }, + }, + }) + end) + it("renders some basics with an update", function() + local renders = 0 + local Component = React.Component:extend("Component") + + local Child = function() + renders = renders + 1 + + return React.createElement("moo", nil) + end + local Null = function() + renders = renders + 1 + return nil + end + + function Component:init() + self.state = { x = 3 } + return + end + function Component:render() + renders = renders + 1 + + return React.createElement( + "div", + { + className = "purple", + }, + self.state.x, + React.createElement(Child, nil), + React.createElement(Null, nil) + ) + end + function Component:componentDidMount() + self:setState({ x = 7 }) + end + + local renderer = ReactTestRenderer.create(React.createElement(Component, nil)) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = { + className = "purple", + }, + children = { + "7", + { + type = "moo", + props = {}, + children = nil, + }, + }, + }) + jestExpect(renders).toEqual(6) + end) + it("exposes the instance", function() + local Mouse = React.Component:extend("Mouse") + + function Mouse:init() + self.state = { + mouse = "mouse", + } + end + function Mouse:handleMoose() + self:setState({ + mouse = "moose", + }) + end + function Mouse:render() + return React.createElement("div", nil, self.state.mouse) + end + + local renderer = ReactTestRenderer.create(React.createElement(Mouse, nil)) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = {}, + children = { + "mouse", + }, + }) + + local mouse = renderer.getInstance() + + mouse:handleMoose() + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + "moose", + }, + props = {}, + }) + end) + it("updates types", function() + local renderer = ReactTestRenderer.create( + React.createElement("div", nil, "mouse") + ) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = {}, + children = { + "mouse", + }, + }) + renderer.update(React.createElement("span", nil, "mice")) + jestExpect(renderer.toJSON()).toEqual({ + type = "span", + props = {}, + children = { + "mice", + }, + }) + end) + it("updates children", function() + local renderer = ReactTestRenderer.create(React.createElement( + "div", + nil, + React.createElement("span", { + key = "a", + }, "A"), + React.createElement("span", { + key = "b", + }, "B"), + React.createElement("span", { + key = "c", + }, "C") + )) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = {}, + children = { + { + type = "span", + props = {}, + children = { + "A", + }, + }, + { + type = "span", + props = {}, + children = { + "B", + }, + }, + { + type = "span", + props = {}, + children = { + "C", + }, + }, + }, + }) + renderer.update(React.createElement( + "div", + nil, + React.createElement("span", { + key = "d", + }, "D"), + React.createElement("span", { + key = "c", + }, "C"), + React.createElement("span", { + key = "b", + }, "B") + )) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = {}, + children = { + { + type = "span", + props = {}, + children = { + "D", + }, + }, + { + type = "span", + props = {}, + children = { + "C", + }, + }, + { + type = "span", + props = {}, + children = { + "B", + }, + }, + }, + }) + end) + it("does the full lifecycle", function() + local log = {} + local Log = React.Component:extend("Log") + + function Log:render() + table.insert(log, "render " .. self.props.name) + return React.createElement("div", nil) + end + function Log:componentDidMount() + table.insert(log, "mount " .. self.props.name) + end + function Log:componentWillUnmount() + table.insert(log, "unmount " .. self.props.name) + end + + local renderer = ReactTestRenderer.create(React.createElement(Log, { + key = "foo", + name = "Foo", + })) + + renderer.update(React.createElement(Log, { + key = "bar", + name = "Bar", + })) + renderer.unmount() + jestExpect(log).toEqual({ + "render Foo", + "mount Foo", + "render Bar", + "unmount Foo", + "mount Bar", + "unmount Bar", + }) + end) + it("gives a ref to native components", function() + local log = {} + + ReactTestRenderer.create(React.createElement("div", { + ref = function(r) + return table.insert(log, r) + end, + })) + jestExpect(log).toEqual({ nil }) + end) + it("warns correctly for refs on SFCs", function() + local function Bar() + return React.createElement("div", nil, "Hello, world") + end + + local Foo = React.Component:extend("Foo") + + function Foo:render() + return React.createElement(Bar, { + ref = "foo", + }) + end + + local Baz = React.Component:extend("Baz") + + function Baz:render() + return React.createElement("div", { + ref = "baz", + }) + end + + ReactTestRenderer.create(React.createElement(Baz, nil)) + jestExpect(function() + return ReactTestRenderer.create(React.createElement(Foo, nil)) + end).toErrorDev( + "Warning: Function components cannot be given refs. Attempts " + .. "to access this ref will fail. " + .. "Did you mean to use React.forwardRef()?\n\n" + .. "Check the render method of `Foo`.\n" + .. " in Bar (at **)\n" + .. " in Foo (at **)" + ) + end) + it("allows an optional createNodeMock function", function() + local mockDivInstance = { + appendChild = function() end, + } + local mockInputInstance = { + focus = function() end, + } + local mockListItemInstance = { + click = function() end, + } + local mockAnchorInstance = { + hover = function() end, + } + local log = {} + + -- ROBLOX deviation: using createRef in place of string refs + local bar = React.createRef() + local Foo = React.Component:extend("Foo") + + function Foo:componentDidMount() + table.insert(log, bar.current) + end + function Foo:render() + return React.createElement("a", { + ref = bar, + }, "Hello, world") + end + + local function createNodeMock(element) + if element.type == "div" then + return mockDivInstance + elseif element.type == "input" then + return mockInputInstance + elseif element.type == "li" then + return mockListItemInstance + elseif element.type == "a" then + return mockAnchorInstance + else + return {} + end + end + + ReactTestRenderer.create( + React.createElement("div", { + ref = function(r) + return table.insert(log, r) + end, + }), + { createNodeMock = createNodeMock } + ) + ReactTestRenderer.create( + React.createElement("input", { + ref = function(r) + return table.insert(log, r) + end, + }), + { createNodeMock = createNodeMock } + ) + ReactTestRenderer.create( + React.createElement( + "div", + nil, + React.createElement( + "span", + nil, + React.createElement( + "ul", + nil, + React.createElement("li", { + ref = function(r) + return table.insert(log, r) + end, + }) + ), + React.createElement( + "ul", + nil, + React.createElement("li", { + ref = function(r) + return table.insert(log, r) + end, + }), + React.createElement("li", { + ref = function(r) + return table.insert(log, r) + end, + }) + ) + ) + ), + { + createNodeMock = createNodeMock, + foobar = true, + } + ) + ReactTestRenderer.create( + React.createElement(Foo, nil), + { createNodeMock = createNodeMock } + ) + ReactTestRenderer.create(React.createElement("div", { + ref = function(r) + return table.insert(log, r) + end, + })) + ReactTestRenderer.create( + React.createElement("div", { + ref = function(r) + return table.insert(log, r) + end, + }), + {} + ) + jestExpect(log).toEqual({ + mockDivInstance, + mockInputInstance, + mockListItemInstance, + mockListItemInstance, + mockListItemInstance, + mockAnchorInstance, + }) + end) + it("supports unmounting when using refs", function() + local Foo = React.Component:extend("Foo") + + -- ROBLOX deviation: using createRef in place of string refs + local foo = React.createRef() + + function Foo:render() + return React.createElement("div", { + ref = foo, + }) + end + + local inst = ReactTestRenderer.create(React.createElement(Foo, nil), { + createNodeMock = function() + return foo.current + end, + }) + + jestExpect(function() + return inst.unmount() + end).never.toThrow() + end) + it("supports unmounting inner instances", function() + local count = 0 + local Foo = React.Component:extend("Foo") + + function Foo:componentWillUnmount() + count = count + 1 + end + function Foo:render() + return React.createElement("div", nil) + end + + local inst = ReactTestRenderer.create( + React.createElement("div", nil, React.createElement(Foo, nil)), + { + createNodeMock = function() + return "foo" + end, + } + ) + + jestExpect(function() + return inst.unmount() + end).never.toThrow() + jestExpect(count).toEqual(1) + end) + it("supports updates when using refs", function() + local log = {} + local createNodeMock = function(element) + table.insert(log, element.type) + return element.type + end + local Foo = React.Component:extend("Foo") + + function Foo:render() + return (function() + if self.props.useDiv then + return React.createElement("div", { + ref = "foo", + }) + end + + return React.createElement("span", { + ref = "foo", + }) + end)() + end + + local inst = ReactTestRenderer.create( + React.createElement(Foo, { useDiv = true }), + { createNodeMock = createNodeMock } + ) + + inst.update(React.createElement(Foo, { useDiv = false })) + jestExpect(log).toEqual({ + "div", + "span", + }) + end) + it("supports error boundaries", function() + local log = {} + local Angry = React.Component:extend("Angry") + + function Angry:render() + table.insert(log, "Angry render") + error("Please, do not render me.") + end + function Angry:componentDidMount() + table.insert(log, "Angry componentDidMount") + end + function Angry:componentWillUnmount() + table.insert(log, "Angry componentWillUnmount") + end + + local Boundary = React.Component:extend("Boundary") + + function Boundary:init(props) + self.state = { error = false } + end + function Boundary:render() + table.insert(log, "Boundary render") + + if not self.state.error then + return React.createElement( + "div", + nil, + React.createElement("button", { + onClick = self.onClick, + }, "ClickMe"), + React.createElement(Angry, nil) + ) + else + return React.createElement("div", nil, "Happy Birthday!") + end + end + function Boundary:componentDidMount() + table.insert(log, "Boundary componentDidMount") + end + function Boundary:componentWillUnmount() + table.insert(log, "Boundary componentWillUnmount") + end + function Boundary:onClick() + -- do nothing + return + end + function Boundary:componentDidCatch() + table.insert(log, "Boundary componentDidCatch") + self:setState({ error = true }) + end + + local renderer = ReactTestRenderer.create(React.createElement(Boundary, nil)) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + props = {}, + children = { + "Happy Birthday!", + }, + }) + jestExpect(log).toEqual({ + "Boundary render", + "Angry render", + "Boundary componentDidMount", + "Boundary componentDidCatch", + "Boundary render", + }) + end) + it("can update text nodes", function() + local Component = React.Component:extend("Component") + + function Component:render() + return React.createElement("div", nil, self.props.children) + end + + local renderer = ReactTestRenderer.create( + React.createElement(Component, nil, "Hi") + ) + + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + "Hi", + }, + props = {}, + }) + renderer.update(React.createElement(Component, nil, { + "Hi", + "Bye", + })) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + "Hi", + "Bye", + }, + props = {}, + }) + renderer.update(React.createElement(Component, nil, "Bye")) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + "Bye", + }, + props = {}, + }) + renderer.update(React.createElement(Component, nil, 42)) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + "42", + }, + props = {}, + }) + renderer.update( + React.createElement(Component, nil, React.createElement("div", nil)) + ) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = { + { + type = "div", + children = nil, + props = {}, + }, + }, + props = {}, + }) + end) + it("toTree() renders simple components returning host components", function() + local Qoo = function() + return React.createElement("span", { + className = "Qoo", + }, "Hello World!") + end + local renderer = ReactTestRenderer.create(React.createElement(Qoo, nil)) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + nodeType = "component", + type = Qoo, + props = {}, + instance = nil, + rendered = { + nodeType = "host", + type = "span", + props = { + className = "Qoo", + }, + instance = nil, + rendered = { + "Hello World!", + }, + }, + }) + end) + it("toTree() handles nested Fragments", function() + local Foo = function() + return React.createElement( + React.Fragment, + nil, + React.createElement(React.Fragment, nil, "foo") + ) + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + nodeType = "component", + type = Foo, + instance = nil, + props = {}, + rendered = "foo", + }) + end) + it("toTree() handles null rendering components", function() + local Foo = React.Component:extend("Foo") + + function Foo:render() + return nil + end + + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local tree = renderer.toTree() + + -- ROBLOX deviation: toBeInstanceOf not yet implemented, workaround by checking elementType + jestExpect(tree.instance._reactInternals.elementType.__componentName).toEqual( + "Foo" + ) + cleanNodeOrArray(tree) + + -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we + -- move to Roblox Jest + tree.type.__index = nil + jestExpect(tree).toEqual({ + type = Foo, + nodeType = "component", + props = {}, + instance = nil, + rendered = nil, + }) + end) + it("toTree() handles simple components that return arrays", function() + local Foo = function(_ref) + local children = _ref.children + + return children + end + local renderer = ReactTestRenderer.create( + React.createElement( + Foo, + nil, + React.createElement("div", nil, "One"), + React.createElement("div", nil, "Two") + ) + ) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + type = Foo, + nodeType = "component", + props = {}, + instance = nil, + rendered = { + { + instance = nil, + nodeType = "host", + props = {}, + rendered = { + "One", + }, + type = "div", + }, + { + instance = nil, + nodeType = "host", + props = {}, + rendered = { + "Two", + }, + type = "div", + }, + }, + }) + end) + it("toTree() handles complicated tree of arrays", function() + local Foo = React.Component:extend("Foo") + + function Foo:render() + return self.props.children + end + + local renderer = ReactTestRenderer.create( + React.createElement( + "div", + nil, + React.createElement( + Foo, + nil, + React.createElement("div", nil, "One"), + React.createElement("div", nil, "Two"), + React.createElement( + Foo, + nil, + React.createElement("div", nil, "Three") + ) + ), + React.createElement("div", nil, "Four") + ) + ) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + + -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we + -- move to Roblox Jest + tree.rendered[1].type.__index = nil + + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + type = "div", + instance = nil, + nodeType = "host", + props = {}, + rendered = { + { + type = Foo, + nodeType = "component", + props = {}, + instance = nil, + rendered = { + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "One", + }, + }, + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Two", + }, + }, + { + type = Foo, + nodeType = "component", + props = {}, + instance = nil, + rendered = { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Three", + }, + }, + }, + }, + }, + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Four", + }, + }, + }, + }) + end) + it("toTree() handles complicated tree of fragments", function() + local renderer = ReactTestRenderer.create( + React.createElement( + React.Fragment, + nil, + React.createElement( + React.Fragment, + nil, + React.createElement("div", nil, "One"), + React.createElement("div", nil, "Two"), + React.createElement( + React.Fragment, + nil, + React.createElement("div", nil, "Three") + ) + ), + React.createElement("div", nil, "Four") + ) + ) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "One", + }, + }, + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Two", + }, + }, + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Three", + }, + }, + { + type = "div", + nodeType = "host", + props = {}, + instance = nil, + rendered = { + "Four", + }, + }, + }) + end) + it("root instance and createNodeMock ref return the same value", function() + local createNodeMock = function(ref) + return { node = ref } + end + local refInst = nil + local renderer = ReactTestRenderer.create( + React.createElement("div", { + ref = function(ref) + refInst = ref + return + end, + }), + { createNodeMock = createNodeMock } + ) + local root = renderer.getInstance() + + jestExpect(root).toEqual(refInst) + end) + it("toTree() renders complicated trees of composites and hosts", function() + -- SFC returning host. no children props. + local Qoo = function() + return React.createElement("span", { + className = "Qoo", + }, "Hello World!") + end + + -- SFC returning host. passes through children. + local Foo = function(props) + local className, children = props.className, props.children + + return React.createElement( + "div", + { + className = "Foo " .. className, + }, + React.createElement("span", { + className = "Foo2", + }, "Literal"), + children + ) + end + + -- class composite returning composite. passes through children. + local Bar = React.Component:extend("Bar") + function Bar:render() + local children = self.props.children + local special = self.props.special + + return React.createElement(Foo, { + className = (function() + if special then + return "special" + end + + return "normal" + end)(), + }, children) + end + + -- class composite return composite. no children props. + local Bam = React.Component:extend("Bam") + + function Bam:render() + return React.createElement( + Bar, + { special = true }, + React.createElement(Qoo, nil) + ) + end + + local renderer = ReactTestRenderer.create(React.createElement(Bam, nil)) + local tree = renderer.toTree() + + -- we test for the presence of instances before nulling them out + -- ROBLOX deviation: toBeInstanceOf not yet implemented, workaround by checking elementType + jestExpect(tree.instance._reactInternals.elementType.__componentName).toEqual( + "Bam" + ) + jestExpect(tree.rendered.instance._reactInternals.elementType.__componentName).toEqual( + "Bar" + ) + + cleanNodeOrArray(tree) + + -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we + -- move to Roblox Jest + tree.type.__index = nil + tree.rendered.type.__index = nil + + jestExpect(tree).toEqual({ + type = Bam, + nodeType = "component", + props = {}, + instance = nil, + rendered = { + type = Bar, + nodeType = "component", + props = { special = true }, + instance = nil, + rendered = { + type = Foo, + nodeType = "component", + props = { + className = "special", + }, + instance = nil, + rendered = { + type = "div", + nodeType = "host", + props = { + className = "Foo special", + }, + instance = nil, + rendered = { + { + type = "span", + nodeType = "host", + props = { + className = "Foo2", + }, + instance = nil, + rendered = { + "Literal", + }, + }, + { + type = Qoo, + nodeType = "component", + props = {}, + instance = nil, + rendered = { + type = "span", + nodeType = "host", + props = { + className = "Qoo", + }, + instance = nil, + rendered = { + "Hello World!", + }, + }, + }, + }, + }, + }, + }, + }) + end) + it("can update text nodes when rendered as root", function() + local renderer = ReactTestRenderer.create({ + "Hello", + "world", + }) + + jestExpect(renderer.toJSON()).toEqual({ + "Hello", + "world", + }) + renderer.update(42) + jestExpect(renderer.toJSON()).toEqual("42") + renderer.update({ + 42, + "world", + }) + jestExpect(renderer.toJSON()).toEqual({ + "42", + "world", + }) + end) + it("can render and update root fragments", function() + local Component = function(props) + return props.children + end + local renderer = ReactTestRenderer.create({ + React.createElement(Component, { + key = "a", + }, "Hi"), + React.createElement(Component, { + key = "b", + }, "Bye"), + }) + + jestExpect(renderer.toJSON()).toEqual({ + "Hi", + "Bye", + }) + renderer.update(React.createElement("div", nil)) + jestExpect(renderer.toJSON()).toEqual({ + type = "div", + children = nil, + props = {}, + }) + renderer.update({ + React.createElement("div", { + key = "a", + }, "goodbye"), + "world", + }) + jestExpect(renderer.toJSON()).toEqual({ + { + type = "div", + children = { + "goodbye", + }, + props = {}, + }, + "world", + }) + end) + it("supports context providers and consumers", function() + local context = React.createContext("a") + local Consumer = context.Consumer + local Provider = context.Provider + + local function Child(props) + return props.value + end + local function App() + return React.createElement( + Provider, + { + value = "b", + }, + React.createElement(Consumer, nil, function(value) + return React.createElement(Child, { value = value }) + end) + ) + end + + local renderer = ReactTestRenderer.create(React.createElement(App, nil)) + local child = renderer.root:findByType(Child) + + jestExpect(child.children).toEqual({ + "b", + }) + -- ROBLOX deviation: no need to pretty format + jestExpect(renderer.toTree()).toEqual({ + instance = nil, + nodeType = "component", + props = {}, + rendered = { + instance = nil, + nodeType = "component", + props = { + value = "b", + }, + rendered = "b", + type = Child, + }, + type = App, + }) + end) + it("supports modes", function() + local function Child(props) + return props.value + end + local function App(props) + return React.createElement( + React.StrictMode, + nil, + React.createElement(Child, { + value = props.value, + }) + ) + end + + local renderer = ReactTestRenderer.create(React.createElement(App, { + value = "a", + })) + local child = renderer.root:findByType(Child) + + jestExpect(child.children).toEqual({ + "a", + }) + -- ROBLOX deviation: no need to pretty format + jestExpect(renderer.toTree()).toEqual({ + instance = nil, + nodeType = "component", + props = { + value = "a", + }, + rendered = { + instance = nil, + nodeType = "component", + props = { + value = "a", + }, + rendered = "a", + type = Child, + }, + type = App, + }) + end) + it("supports forwardRef", function() + local InnerRefed = React.forwardRef(function(props, ref) + return React.createElement( + "div", + nil, + React.createElement("span", { ref = ref }) + ) + end) + local App = React.Component:extend("App") + + function App:render() + return React.createElement(InnerRefed, { + ref = function(r) + self.ref = r + return + end, + }) + end + + local renderer = ReactTestRenderer.create(React.createElement(App, nil)) + local tree = renderer.toTree() + + cleanNodeOrArray(tree) + + -- ROBLOX FIXME: set metatable value to nil to stop infinite recursion, should be removed once we + -- move to Roblox Jest + tree.type.__index = nil + -- ROBLOX deviation: no need to pretty format + jestExpect(tree).toEqual({ + instance = nil, + nodeType = "component", + props = {}, + rendered = { + instance = nil, + nodeType = "host", + props = {}, + rendered = { + { + instance = nil, + nodeType = "host", + props = {}, + rendered = {}, + type = "span", + }, + }, + type = "div", + }, + type = App, + }) + end) + -- ROBLOX TODO: set up React Noop in this file + -- xit('can concurrently render context with a "primary" renderer', function() + -- local Context = React.createContext(nil) + -- local Indirection = React.Fragment + -- local App = function() + -- return React.createElement(Context.Provider, {value = nil}, React.createElement(Indirection, nil, React.createElement(Context.Consumer, nil, function( + -- ) + -- return nil + -- end))) + -- end + + -- ReactNoop.render(React.createElement(App, nil)) + -- jestExpect(Scheduler).toFlushWithoutYielding() + -- ReactTestRenderer.create(React.createElement(App, nil)) + -- end) + it( + 'calling findByType() with an invalid component will fall back to "Unknown" for component name', + function() + local App = function() + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(App, nil)) + local NonComponent = {} + jestExpect(function() + renderer.root:findByType(NonComponent) + end).toThrow('No instances found with node type: "Unknown"') + end + ) + end) +end diff --git a/modules/react-test-renderer/src/__tests__/ReactTestRendererAct.spec.lua b/modules/react-test-renderer/src/__tests__/ReactTestRendererAct.spec.lua index ef34f3e7..786480ec 100644 --- a/modules/react-test-renderer/src/__tests__/ReactTestRendererAct.spec.lua +++ b/modules/react-test-renderer/src/__tests__/ReactTestRendererAct.spec.lua @@ -11,141 +11,140 @@ local act local useState, useEffect, useReducer return function() - describe('ReactTestRenderer.act()', function() - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useRealTimers() - - React = require(Packages.React) - useState, useEffect, useReducer = React.useState, React.useEffect, React.useReducer - ReactTestRenderer = require(Packages.ReactTestRenderer) - Scheduler = require(Packages.Scheduler) - act = ReactTestRenderer.act - end) - it('can use .act() to flush effects', function() - local function App(props) - local ctr, setCtr = useState(0) - - React.useEffect(function() - props.callback() - setCtr(1) - end, {}) - - return ctr - end - - local calledLog = {} - local root - - act(function() - root = ReactTestRenderer.create(React.createElement(App, { - callback = function() - table.insert(calledLog, #calledLog) - end, - })) - end) - jestExpect(calledLog).toEqual({0}) - jestExpect(root.toJSON()).toEqual('1') - end) - it("warns if you don't use .act", function() - local ctr, setCtr - - local function App(props) - ctr, setCtr = useState(0) - - return ctr - end - - ReactTestRenderer.create(React.createElement(App)) - - jestExpect(function() - setCtr(1) - end).toErrorDev({ - 'An update to App inside a test was not wrapped in act(...).', - }) - end) - describe('async', function() - it('should work with async/await', (function() - local fetch = Promise.promisify(function (url) - return Promise.resolve({ - details = {1, 2, 3}, - }) - end) - local function App() - local details, setDetails = React.useState(0) - - React.useEffect(function() - local fetchDetails = function() - return fetch():andThen(function(response) - setDetails(response.details) - end) - end - - fetchDetails() - end, {}) - - return details - end - - local root - - Promise.try(function() - act(function() - root = ReactTestRenderer.create(React.createElement(App)) - end) - end):await() - - jestExpect(root.toJSON()).toEqual({'1','2','3'}) - end)) - it('should not flush effects without also flushing microtasks', (function() - - local alreadyResolvedPromise = Promise.resolve() - - local function App() - -- This component will keep updating itself until step === 3 - local step, proceed = useReducer(function(s) - if s == 3 then - return 3 - end - - return s + 1 - end, 1) - - useEffect(function() - Scheduler.unstable_yieldValue('Effect') - alreadyResolvedPromise:andThen(function() - Scheduler.unstable_yieldValue('Microtask') - proceed() - end) - end) - - return step - end - - local root = ReactTestRenderer.create(nil) - - Promise.try(function() - act(function() - root.update(React.createElement(App)) - end) - end):await() - - jestExpect(Scheduler).toHaveYielded({ - -- Should not flush effects without also flushing microtasks - -- First render: - 'Effect', - 'Microtask', - -- Second render: - 'Effect', - 'Microtask', - -- Final render: - 'Effect', - 'Microtask', - }) - - jestExpect(root).toMatchRenderedOutput('3') - - end)) - end) - end) -end \ No newline at end of file + describe("ReactTestRenderer.act()", function() + beforeEach(function() + RobloxJest.resetModules() + RobloxJest.useRealTimers() + + React = require(Packages.React) + useState, useEffect, useReducer = + React.useState, React.useEffect, React.useReducer + ReactTestRenderer = require(Packages.ReactTestRenderer) + Scheduler = require(Packages.Scheduler) + act = ReactTestRenderer.act + end) + it("can use .act() to flush effects", function() + local function App(props) + local ctr, setCtr = useState(0) + + React.useEffect(function() + props.callback() + setCtr(1) + end, {}) + + return ctr + end + + local calledLog = {} + local root + + act(function() + root = ReactTestRenderer.create(React.createElement(App, { + callback = function() + table.insert(calledLog, #calledLog) + end, + })) + end) + jestExpect(calledLog).toEqual({ 0 }) + jestExpect(root.toJSON()).toEqual("1") + end) + it("warns if you don't use .act", function() + local ctr, setCtr + + local function App(props) + ctr, setCtr = useState(0) + + return ctr + end + + ReactTestRenderer.create(React.createElement(App)) + + jestExpect(function() + setCtr(1) + end).toErrorDev({ + "An update to App inside a test was not wrapped in act(...).", + }) + end) + describe("async", function() + it("should work with async/await", function() + local fetch = Promise.promisify(function(url) + return Promise.resolve({ + details = { 1, 2, 3 }, + }) + end) + local function App() + local details, setDetails = React.useState(0) + + React.useEffect(function() + local fetchDetails = function() + return fetch():andThen(function(response) + setDetails(response.details) + end) + end + + fetchDetails() + end, {}) + + return details + end + + local root + + Promise.try(function() + act(function() + root = ReactTestRenderer.create(React.createElement(App)) + end) + end):await() + + jestExpect(root.toJSON()).toEqual({ "1", "2", "3" }) + end) + it("should not flush effects without also flushing microtasks", function() + local alreadyResolvedPromise = Promise.resolve() + + local function App() + -- This component will keep updating itself until step === 3 + local step, proceed = useReducer(function(s) + if s == 3 then + return 3 + end + + return s + 1 + end, 1) + + useEffect(function() + Scheduler.unstable_yieldValue("Effect") + alreadyResolvedPromise:andThen(function() + Scheduler.unstable_yieldValue("Microtask") + proceed() + end) + end) + + return step + end + + local root = ReactTestRenderer.create(nil) + + Promise.try(function() + act(function() + root.update(React.createElement(App)) + end) + end):await() + + jestExpect(Scheduler).toHaveYielded({ + -- Should not flush effects without also flushing microtasks + -- First render: + "Effect", + "Microtask", + -- Second render: + "Effect", + "Microtask", + -- Final render: + "Effect", + "Microtask", + }) + + jestExpect(root).toMatchRenderedOutput("3") + end) + end) + end) +end diff --git a/modules/react-test-renderer/src/__tests__/ReactTestRendererTraversal.spec.lua b/modules/react-test-renderer/src/__tests__/ReactTestRendererTraversal.spec.lua index 2c65535f..ae25cd03 100644 --- a/modules/react-test-renderer/src/__tests__/ReactTestRendererTraversal.spec.lua +++ b/modules/react-test-renderer/src/__tests__/ReactTestRendererTraversal.spec.lua @@ -61,8 +61,8 @@ return function() }), React.createElement(View), React.createElement(ExampleSpread, { - bar = "bar", - }), + bar = "bar", + }), React.createElement(ExampleFn, { bar = "bar", bing = "bing", @@ -91,8 +91,8 @@ return function() end, }, React.createElement(ExampleForwardRef, { - qux = "qux", - }) + qux = "qux", + }) ), React.createElement( React.Fragment, @@ -104,8 +104,8 @@ return function() Context.Provider, { value = Object.None }, React.createElement(Context.Consumer, nil, function() - return React.createElement(View, { nested = true }) - end) + return React.createElement(View, { nested = true }) + end) ) ), React.createElement(View, { nested = true }), @@ -214,7 +214,9 @@ return function() jestExpect(render.root:findAll(hasBingProp, { deep = false })).toHaveLength(1) jestExpect(render.root:findAll(hasNullProp, { deep = false })).toHaveLength(1) jestExpect(render.root:findAll(hasVoidProp, { deep = false })).toHaveLength(0) - jestExpect(render.root:findAll(hasNestedProp, { deep = false })).toHaveLength(3) + jestExpect(render.root:findAll(hasNestedProp, { deep = false })).toHaveLength( + 3 + ) -- note: with {deep: true}, :findAll() will continue to -- search children, even after finding a match @@ -224,7 +226,9 @@ return function() jestExpect(render.root:findAll(hasBingProp)).toHaveLength(1) -- no spread jestExpect(render.root:findAll(hasNullProp)).toHaveLength(1) -- no spread jestExpect(render.root:findAll(hasVoidProp)).toHaveLength(0) - jestExpect(render.root:findAll(hasNestedProp, { deep = false })).toHaveLength(3) + jestExpect(render.root:findAll(hasNestedProp, { deep = false })).toHaveLength( + 3 + ) local bing = render.root:find(hasBingProp) @@ -301,10 +305,18 @@ return function() return render.root:findByProps({ qux = qux }) end).never.toThrow() -- 1 match - jestExpect(render.root:findAllByProps({ foo = foo }, { deep = false })).toHaveLength(1) - jestExpect(render.root:findAllByProps({ bar = bar }, { deep = false })).toHaveLength(5) - jestExpect(render.root:findAllByProps({ baz = baz }, { deep = false })).toHaveLength(2) - jestExpect(render.root:findAllByProps({ qux = qux }, { deep = false })).toHaveLength(1) + jestExpect(render.root:findAllByProps({ foo = foo }, { deep = false })).toHaveLength( + 1 + ) + jestExpect(render.root:findAllByProps({ bar = bar }, { deep = false })).toHaveLength( + 5 + ) + jestExpect(render.root:findAllByProps({ baz = baz }, { deep = false })).toHaveLength( + 2 + ) + jestExpect(render.root:findAllByProps({ qux = qux }, { deep = false })).toHaveLength( + 1 + ) jestExpect(render.root:findAllByProps({ foo = foo })).toHaveLength(2) jestExpect(render.root:findAllByProps({ bar = bar })).toHaveLength(9) @@ -341,38 +353,57 @@ return function() return React.createElement("section", props) end) - jestExpect(#ReactTestRenderer.create(React.createElement( - FR, - nil, - React.createElement("div", nil), - React.createElement("div", nil) - )).root:findAllByType("div")).toEqual(2) - jestExpect(#ReactTestRenderer.create(React.createElement( - React.Fragment, - nil, - React.createElement("div", nil), - React.createElement("div", nil) - )).root:findAllByType("div")).toEqual(2) - jestExpect(#ReactTestRenderer.create(React.createElement( - React.Fragment, - { + jestExpect( + #ReactTestRenderer.create( + React.createElement( + FR, + nil, + React.createElement("div", nil), + React.createElement("div", nil) + ) + ).root:findAllByType("div") + ).toEqual(2) + jestExpect( + #ReactTestRenderer.create( + React.createElement( + React.Fragment, + nil, + React.createElement("div", nil), + React.createElement("div", nil) + ) + ).root:findAllByType("div") + ).toEqual(2) + jestExpect( + #ReactTestRenderer.create(React.createElement(React.Fragment, { key = "foo", - }, - React.createElement("div", nil), - React.createElement("div", nil) - )).root:findAllByType("div")).toEqual(2) - jestExpect(#ReactTestRenderer.create(React.createElement( - React.StrictMode, - nil, - React.createElement("div", nil), - React.createElement("div", nil) - )).root:findAllByType("div")).toEqual(2) - jestExpect(#ReactTestRenderer.create(React.createElement( - Context.Provider, - { value = Object.None }, - React.createElement("div", nil), - React.createElement("div", nil) - )).root:findAllByType("div")).toEqual(2) + }, React.createElement( + "div", + nil + ), React.createElement( + "div", + nil + ))).root:findAllByType("div") + ).toEqual(2) + jestExpect( + #ReactTestRenderer.create( + React.createElement( + React.StrictMode, + nil, + React.createElement("div", nil), + React.createElement("div", nil) + ) + ).root:findAllByType("div") + ).toEqual(2) + jestExpect( + #ReactTestRenderer.create( + React.createElement( + Context.Provider, + { value = Object.None }, + React.createElement("div", nil), + React.createElement("div", nil) + ) + ).root:findAllByType("div") + ).toEqual(2) end) end) end From 20212faa810005bf51e4e0cebfc1113f89263369 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 7 Jul 2021 12:24:29 -0700 Subject: [PATCH 005/289] Run StyLua on roblox-jest (#125) --- .../FakeTimers/__tests__/FakeTimers.spec.lua | 19 ++- modules/roblox-jest/src/FakeTimers/init.lua | 13 +- .../src/Matchers/__tests__/toWarnDev.spec.lua | 160 ++++++++++-------- .../src/Matchers/createConsoleMatcher.lua | 139 +++++++++------ .../src/Module/__tests__/Module.spec.lua | 22 ++- .../__tests__/ModuleValidation.spec.lua | 4 +- .../__tests__/TestScripts/Incrementor.lua | 2 +- .../__tests__/TestScripts/ReturnsNil.lua | 2 +- .../Module/__tests__/TestScripts/addFour.lua | 2 +- .../TestScripts/getIncrementorValue.lua | 2 +- modules/roblox-jest/src/Module/init.lua | 42 +++-- 11 files changed, 246 insertions(+), 161 deletions(-) diff --git a/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua b/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua index 2087c0e7..11c92000 100644 --- a/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua +++ b/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua @@ -1,9 +1,7 @@ - local Packages = script.Parent.Parent.Parent.Parent local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect return function() - -- ROBLOX FIXME: remove :: any once CLI understands call metamethods, JIRA ticket CLI-40294 local FakeTimers = require(script.Parent.Parent) :: any @@ -13,7 +11,7 @@ return function() end) describe("advanceTimersByTime", function() - it('one timer advances', function() + it("one timer advances", function() local log = {} -- add timer FakeTimers.delayOverride(0.1, function() @@ -28,7 +26,7 @@ return function() -- advance timers to exactly timer expiry FakeTimers.advanceTimersByTime(50) jestExpect(FakeTimers.tickOverride()).toEqual(0.1) - jestExpect(log).toEqual({"timer callback"}) + jestExpect(log).toEqual({ "timer callback" }) -- reset log log = {} @@ -37,7 +35,7 @@ return function() FakeTimers.advanceTimersByTime(10000) jestExpect(log).toEqual({}) end) - it('multiple timers advance', function() + it("multiple timers advance", function() local log = {} -- add timers @@ -59,11 +57,15 @@ return function() -- advance timers passed timer 4 expiry FakeTimers.advanceTimersByTime(70) - jestExpect(log).toEqual({"timer 4 callback"}) + jestExpect(log).toEqual({ "timer 4 callback" }) -- advance timers passed timer 1 and 2 expiry FakeTimers.advanceTimersByTime(31) - jestExpect(log).toEqual({"timer 4 callback", "timer 1 callback", "timer 2 callback"}) + jestExpect(log).toEqual({ + "timer 4 callback", + "timer 1 callback", + "timer 2 callback", + }) -- advance timers passed rest of expiries FakeTimers.advanceTimersByTime(1000) @@ -72,9 +74,8 @@ return function() "timer 1 callback", "timer 2 callback", "timer 3 callback", - "timer 5 callback" + "timer 5 callback", }) end) - end) end diff --git a/modules/roblox-jest/src/FakeTimers/init.lua b/modules/roblox-jest/src/FakeTimers/init.lua index 1ad559ca..c3662062 100644 --- a/modules/roblox-jest/src/FakeTimers/init.lua +++ b/modules/roblox-jest/src/FakeTimers/init.lua @@ -2,7 +2,7 @@ type Timer = { expiry: number, - callback: () -> () + callback: () -> (), } local realDelay = delay @@ -74,7 +74,7 @@ local function advanceTimersByTime(msToRun: number): () -- There are no timers between now and the target we're running to, so -- adjust our time cursor and quit now += secondsToRun - break; + break else secondsToRun -= nextTimerExpiry - now now = nextTimerExpiry @@ -85,9 +85,10 @@ local function advanceTimersByTime(msToRun: number): () end if i == 100000 then error( - 'Ran 100000' .. - ' timers, and there are still more! ' .. - "Assuming we've hit an infinite recursion and bailing out...") + "Ran 100000" + .. " timers, and there are still more! " + .. "Assuming we've hit an infinite recursion and bailing out..." + ) end end @@ -117,4 +118,4 @@ return { advanceTimersByTime = advanceTimersByTime, reset = reset, now = function() return now end -} \ No newline at end of file +} diff --git a/modules/roblox-jest/src/Matchers/__tests__/toWarnDev.spec.lua b/modules/roblox-jest/src/Matchers/__tests__/toWarnDev.spec.lua index e9d1504a..c8673c45 100644 --- a/modules/roblox-jest/src/Matchers/__tests__/toWarnDev.spec.lua +++ b/modules/roblox-jest/src/Matchers/__tests__/toWarnDev.spec.lua @@ -57,7 +57,7 @@ return function() if _G.__DEV__ then console.error("Hello") end - end).toErrorDev("Hello", {withoutStack = true}) + end).toErrorDev("Hello", { withoutStack = true }) jestExpect(function() if _G.__DEV__ then console.error("Hello") @@ -68,22 +68,29 @@ return function() "Hello", "Good day", "Bye", - }, {withoutStack = true}) + }, { + withoutStack = true, + }) end) - it("does not fail when expected stack-less warning number matches the actual one", function() - jestExpect(function() - if _G.__DEV__ then - console.error("Hello\n in div") - console.error("Good day") - console.error("Bye\n in div") - end - end).toErrorDev({ - "Hello", - "Good day", - "Bye", - }, {withoutStack = 1}) - end) + it( + "does not fail when expected stack-less warning number matches the actual one", + function() + jestExpect(function() + if _G.__DEV__ then + console.error("Hello\n in div") + console.error("Good day") + console.error("Bye\n in div") + end + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = 1, + }) + end + ) if _G.__DEV__ then -- // Helper methods avoids invalid toWarn().toThrow() nesting @@ -152,7 +159,7 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.error("Hello\n in div") - end).toErrorDev("Hello", {withoutStack = true}) + end).toErrorDev("Hello", { withoutStack = true }) end, "Received warning unexpectedly includes a component stack") expectToWarnAndToThrow(function() jestExpect(function() @@ -163,23 +170,30 @@ return function() "Hello", "Good day", "Bye", - }, {withoutStack = true}) + }, { + withoutStack = true, + }) end, "Received warning unexpectedly includes a component stack") end) - it("fails if expected stack-less warning number does not match the actual one", function() - expectToWarnAndToThrow(function() - jestExpect(function() - console.error("Hello\n in div") - console.error("Good day") - console.error("Bye\n in div") - end).toErrorDev({ - "Hello", - "Good day", - "Bye", - }, {withoutStack = 4}) - end, "Expected 4 warnings without a component stack but received 1") - end) + it( + "fails if expected stack-less warning number does not match the actual one", + function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello\n in div") + console.error("Good day") + console.error("Bye\n in div") + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = 4, + }) + end, "Expected 4 warnings without a component stack but received 1") + end + ) it("fails if withoutStack is invalid", function() -- deviation: null should error, but not undefined. Since they're the same @@ -192,7 +206,7 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.error("Hi") - end).toErrorDev("Hi", {withoutStack = {}}) + end).toErrorDev("Hi", { withoutStack = {} }) end, "Instead received table") expectToWarnAndToThrow(function() jestExpect(function() @@ -207,12 +221,12 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.error("Hi %s", "Sara", "extra") - end).toErrorDev("Hi", {withoutStack = true}) + end).toErrorDev("Hi", { withoutStack = true }) end, "Received 2 arguments for a message with 1 placeholders") expectToWarnAndToThrow(function() jestExpect(function() console.error("Hi %s") - end).toErrorDev("Hi", {withoutStack = true}) + end).toErrorDev("Hi", { withoutStack = true }) end, "Received 0 arguments for a message with 1 placeholders") end) @@ -292,7 +306,7 @@ return function() if _G.__DEV__ then console.warn("Hello") end - end).toWarnDev("Hello", {withoutStack = true}) + end).toWarnDev("Hello", { withoutStack = true }) jestExpect(function() if _G.__DEV__ then console.warn("Hello") @@ -303,22 +317,29 @@ return function() "Hello", "Good day", "Bye", - }, {withoutStack = true}) + }, { + withoutStack = true, + }) end) - it("does not fail when expected stack-less warning number matches the actual one", function() - jestExpect(function() - if _G.__DEV__ then - console.warn("Hello\n in div") - console.warn("Good day") - console.warn("Bye\n in div") - end - end).toWarnDev({ - "Hello", - "Good day", - "Bye", - }, {withoutStack = 1}) - end) + it( + "does not fail when expected stack-less warning number matches the actual one", + function() + jestExpect(function() + if _G.__DEV__ then + console.warn("Hello\n in div") + console.warn("Good day") + console.warn("Bye\n in div") + end + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = 1, + }) + end + ) if _G.__DEV__ then -- // Helper methods avoids invalid toWarn().toThrow() nesting @@ -387,7 +408,7 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.warn("Hello\n in div") - end).toWarnDev("Hello", {withoutStack = true}) + end).toWarnDev("Hello", { withoutStack = true }) end, "Received warning unexpectedly includes a component stack") expectToWarnAndToThrow(function() jestExpect(function() @@ -398,23 +419,30 @@ return function() "Hello", "Good day", "Bye", - }, {withoutStack = true}) + }, { + withoutStack = true, + }) end, "Received warning unexpectedly includes a component stack") end) - it("fails if expected stack-less warning number does not match the actual one", function() - expectToWarnAndToThrow(function() - jestExpect(function() - console.warn("Hello\n in div") - console.warn("Good day") - console.warn("Bye\n in div") - end).toWarnDev({ - "Hello", - "Good day", - "Bye", - }, {withoutStack = 4}) - end, "Expected 4 warnings without a component stack but received 1") - end) + it( + "fails if expected stack-less warning number does not match the actual one", + function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello\n in div") + console.warn("Good day") + console.warn("Bye\n in div") + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = 4, + }) + end, "Expected 4 warnings without a component stack but received 1") + end + ) it("fails if withoutStack is invalid", function() -- deviation: null should error, but not undefined. Since they're the same @@ -427,7 +455,7 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.warn("Hi") - end).toWarnDev("Hi", {withoutStack = {}}) + end).toWarnDev("Hi", { withoutStack = {} }) end, "Instead received table") expectToWarnAndToThrow(function() jestExpect(function() @@ -442,12 +470,12 @@ return function() expectToWarnAndToThrow(function() jestExpect(function() console.warn("Hi %s", "Sara", "extra") - end).toWarnDev("Hi", {withoutStack = true}) + end).toWarnDev("Hi", { withoutStack = true }) end, "Received 2 arguments for a message with 1 placeholders") expectToWarnAndToThrow(function() jestExpect(function() console.warn("Hi %s") - end).toWarnDev("Hi", {withoutStack = true}) + end).toWarnDev("Hi", { withoutStack = true }) end, "Received 0 arguments for a message with 1 placeholders") end) diff --git a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua b/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua index 8cd98eff..d8f934f0 100644 --- a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua +++ b/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua @@ -21,16 +21,19 @@ local function shouldIgnoreConsoleError(format, args) end end else - if format ~= nil and - typeof(format.message) == "string" and - typeof(format.stack) == "string" + if + format ~= nil + and typeof(format.message) == "string" + and typeof(format.stack) == "string" and #args == 0 then -- // In production, ReactFiberErrorLogger logs error objects directly. -- // They are noisy too so we'll try to ignore them. return true end - if format:find("act(...) is not supported in production builds of React") == 0 then + if + format:find("act(...) is not supported in production builds of React") == 0 + then -- // We don't yet support act() for prod builds, and warn for it. -- // But we'd like to use act() ourselves for prod builds. -- // Let's ignore the warning and #yolo. @@ -74,27 +77,32 @@ return function(consoleMethod, matcherName) if _G.__DEV__ then -- // Warn about incorrect usage of matcher. if typeof(expectedMessages) == "string" then - expectedMessages = {expectedMessages} + expectedMessages = { expectedMessages } elseif not Array.isArray(expectedMessages) then error( - ("%s() requires a parameter of type string or an array of strings "):format(matcherName) + ("%s() requires a parameter of type string or an array of strings "):format( + matcherName + ) .. ("but was given %s."):format(typeof(expectedMessages)) ) end -- deviation: since an empty table will return true for -- `Array.isArray(options)`, check if the table is not empty - if typeof(options) ~= "table" or - (Array.isArray(options) and next(options) ~= nil) + if + typeof(options) ~= "table" + or (Array.isArray(options) and next(options) ~= nil) then error( - ("%s() second argument, when present, should be an object. "):format(matcherName) .. - "Did you forget to wrap the messages into an array?" + ("%s() second argument, when present, should be an object. "):format( + matcherName + ) + .. "Did you forget to wrap the messages into an array?" ) end if select("#", ...) > 0 then error( - ("%s() received more than two arguments. "):format(matcherName) .. - "Did you forget to wrap the messages into an array?" + ("%s() received more than two arguments. "):format(matcherName) + .. "Did you forget to wrap the messages into an array?" ) end @@ -114,23 +122,27 @@ return function(consoleMethod, matcherName) local caughtError local function isLikelyAComponentStack(message) - return typeof(message) == "string" and - message:match('\n in ') ~= nil + return typeof(message) == "string" and message:match("\n in ") ~= nil end local function consoleSpy(format, ...) -- // Ignore uncaught errors reported by jsdom -- // and React addendums because they're too noisy. - local args = {...} - if not logAllErrors and - consoleMethod == 'error' and - shouldIgnoreConsoleError(format, args) + local args = { ... } + if + not logAllErrors + and consoleMethod == "error" + and shouldIgnoreConsoleError(format, args) then return end local message = format - local formattedOk, formattedOrError = pcall(string.format, format, unpack(args)) + local formattedOk, formattedOrError = pcall( + string.format, + format, + unpack(args) + ) if formattedOk then message = formattedOrError end @@ -155,17 +167,20 @@ return function(consoleMethod, matcherName) -- // Protect against accidentally passing a component stack -- // to warning() which already injects the component stack. - if #args >= 2 and - isLikelyAComponentStack(args[#args]) and - isLikelyAComponentStack(args[#args - 1]) + if + #args >= 2 + and isLikelyAComponentStack(args[#args]) + and isLikelyAComponentStack(args[#args - 1]) then - lastWarningWithExtraComponentStack = {format = format} + lastWarningWithExtraComponentStack = { format = format } end - for index=1, #expectedMessages do + for index = 1, #expectedMessages do local expectedMessage = expectedMessages[index] - if normalizedMessage == expectedMessage or - normalizedMessage:find(expectedMessage, 1, true) ~= nil + if + normalizedMessage == expectedMessage + or normalizedMessage:find(expectedMessage, 1, true) + ~= nil then if isLikelyAComponentStack(normalizedMessage) then table.insert(warningsWithComponentStack, normalizedMessage) @@ -179,11 +194,13 @@ return function(consoleMethod, matcherName) local errorMessage if #expectedMessages == 0 then - errorMessage = 'Unexpected warning recorded: ' .. normalizedMessage + errorMessage = "Unexpected warning recorded: " .. normalizedMessage elseif #expectedMessages == 1 then - errorMessage = 'Unexpected warning recorded: ' .. jestDiff(expectedMessages[1], normalizedMessage) + errorMessage = "Unexpected warning recorded: " + .. jestDiff(expectedMessages[1], normalizedMessage) else - errorMessage = 'Unexpected warning recorded: ' .. jestDiff(expectedMessages, {normalizedMessage}) + errorMessage = "Unexpected warning recorded: " + .. jestDiff(expectedMessages, { normalizedMessage }) end -- // Record the call stack for unexpected warnings. @@ -243,10 +260,12 @@ return function(consoleMethod, matcherName) local warnings = warningsWithoutComponentStack return { message = function() - return ("Expected %s warnings without a component stack but received %s:\n"):format( - withoutStack, - #warningsWithoutComponentStack - ) .. table.concat(warnings, "\n") + return ( + "Expected %s warnings without a component stack but received %s:\n" + ):format(withoutStack, #warningsWithoutComponentStack) .. table.concat( + warnings, + "\n" + ) end, pass = false, } @@ -255,12 +274,19 @@ return function(consoleMethod, matcherName) if #warningsWithComponentStack > 0 then return { message = function() - return "Received warning unexpectedly includes a component stack:\n" .. - (" %s\nIf this warning intentionally includes the component stack, remove ") - :format(warningsWithComponentStack[1]) .. - ("{withoutStack: true} from the %s() call. If you have a mix of "):format(matcherName) .. - ("warnings with and without stack in one %s() call, pass "):format(matcherName) .. - ("{withoutStack: N} where N is the number of warnings without stacks."):format(matcherName) + return "Received warning unexpectedly includes a component stack:\n" + .. ( + " %s\nIf this warning intentionally includes the component stack, remove " + ):format(warningsWithComponentStack[1]) + .. ( + "{withoutStack: true} from the %s() call. If you have a mix of " + ):format(matcherName) + .. ( + "warnings with and without stack in one %s() call, pass " + ):format(matcherName) + .. ( + "{withoutStack: N} where N is the number of warnings without stacks." + ):format(matcherName) end, pass = false, } @@ -271,25 +297,33 @@ return function(consoleMethod, matcherName) if #warningsWithoutComponentStack > 0 then return { message = function() - return "Received warning unexpectedly does not include a component stack:\n" .. - (" %s\nIf this warning intentionally omits the component stack, add "):format(warningsWithoutComponentStack[1]) .. - ("{withoutStack: true} to the %s call."):format(matcherName) + return "Received warning unexpectedly does not include a component stack:\n" + .. ( + " %s\nIf this warning intentionally omits the component stack, add " + ):format(warningsWithoutComponentStack[1]) + .. ("{withoutStack: true} to the %s call."):format( + matcherName + ) end, pass = false, } end else error( - ("The second argument for %s(), when specified, must be an object. It may have a "):format(matcherName) .. - 'property called "withoutStack" whose value may be undefined, boolean, or a number. ' .. - ("Instead received %s."):format(typeof(withoutStack)) + ( + "The second argument for %s(), when specified, must be an object. It may have a " + ):format(matcherName) + .. 'property called "withoutStack" whose value may be undefined, boolean, or a number. ' + .. ("Instead received %s."):format(typeof(withoutStack)) ) end if lastWarningWithMismatchingFormat ~= nil then return { message = function() - return ("Received %s arguments for a message with %s placeholders:\n %s"):format( + return ( + "Received %s arguments for a message with %s placeholders:\n %s" + ):format( #lastWarningWithMismatchingFormat.args, lastWarningWithMismatchingFormat.expectedArgCount, lastWarningWithMismatchingFormat.format @@ -301,21 +335,22 @@ return function(consoleMethod, matcherName) if lastWarningWithExtraComponentStack ~= nil then return { message = function() - return "Received more than one component stack for a warning:\n" .. - (" %s\nDid you accidentally pass a stack to warning() as the last argument? ") - :format(lastWarningWithExtraComponentStack.format) .. - "Don't forget warning() already injects the component stack automatically." + return "Received more than one component stack for a warning:\n" + .. ( + " %s\nDid you accidentally pass a stack to warning() as the last argument? " + ):format(lastWarningWithExtraComponentStack.format) + .. "Don't forget warning() already injects the component stack automatically." end, pass = false, } end - return {pass = true} + return { pass = true } else -- // Any uncaught errors or warnings should fail tests in production mode. callback() - return {pass = true} + return { pass = true } end end end diff --git a/modules/roblox-jest/src/Module/__tests__/Module.spec.lua b/modules/roblox-jest/src/Module/__tests__/Module.spec.lua index 08b03a14..1088e1ed 100644 --- a/modules/roblox-jest/src/Module/__tests__/Module.spec.lua +++ b/modules/roblox-jest/src/Module/__tests__/Module.spec.lua @@ -26,7 +26,9 @@ return function() end) it("should clear any top-level module state", function() - local Incrementor = Module.requireOverride(script.Parent.TestScripts.Incrementor) + local Incrementor = Module.requireOverride( + script.Parent.TestScripts.Incrementor + ) jestExpect(Incrementor.get()).toBe(0) Incrementor.increment() jestExpect(Incrementor.get()).toBe(1) @@ -38,8 +40,12 @@ return function() end) it("should clear transitive module requirements", function() - local Incrementor = Module.requireOverride(script.Parent.TestScripts.Incrementor) - local getIncrementorValue = Module.requireOverride(script.Parent.TestScripts.getIncrementorValue) + local Incrementor = Module.requireOverride( + script.Parent.TestScripts.Incrementor + ) + local getIncrementorValue = Module.requireOverride( + script.Parent.TestScripts.getIncrementorValue + ) jestExpect(getIncrementorValue()).toBe(0) Incrementor.increment() Incrementor.increment() @@ -48,7 +54,9 @@ return function() -- Reset will reset the Incrementor's state before the -- getIncrementorValue script requires it again Module.resetModules() - getIncrementorValue = Module.requireOverride(script.Parent.TestScripts.getIncrementorValue) + getIncrementorValue = Module.requireOverride( + script.Parent.TestScripts.getIncrementorValue + ) jestExpect(getIncrementorValue()).toBe(0) end) @@ -106,7 +114,9 @@ return function() end) -- addFour requires add, which we did not required directly -- ourselves - local addFourMocked = Module.requireOverride(script.Parent.TestScripts.addFour) + local addFourMocked = Module.requireOverride( + script.Parent.TestScripts.addFour + ) jestExpect(addFourMocked(10)).toBe(40) end) @@ -127,4 +137,4 @@ return function() jestExpect(add(10, 4)).toBe(14) end) end) -end \ No newline at end of file +end diff --git a/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua b/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua index 689a0bf6..76fca641 100644 --- a/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua +++ b/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua @@ -8,7 +8,7 @@ return function() local function overrideWarn(fn, ...) local originalWarn = getfenv(fn).warn getfenv(fn).warn = function(...) - table.insert(warnings, {...}) + table.insert(warnings, { ... }) end fn(...) getfenv(fn).warn = originalWarn @@ -59,4 +59,4 @@ return function() Module.requireOverride(script.Parent.TestScripts.ReturnsNil) end).toThrow("ReturnsNil") end) -end \ No newline at end of file +end diff --git a/modules/roblox-jest/src/Module/__tests__/TestScripts/Incrementor.lua b/modules/roblox-jest/src/Module/__tests__/TestScripts/Incrementor.lua index ddc1669e..323be5ed 100644 --- a/modules/roblox-jest/src/Module/__tests__/TestScripts/Incrementor.lua +++ b/modules/roblox-jest/src/Module/__tests__/TestScripts/Incrementor.lua @@ -7,4 +7,4 @@ return { get = function() return value end, -} \ No newline at end of file +} diff --git a/modules/roblox-jest/src/Module/__tests__/TestScripts/ReturnsNil.lua b/modules/roblox-jest/src/Module/__tests__/TestScripts/ReturnsNil.lua index bd6def05..15e53c4a 100644 --- a/modules/roblox-jest/src/Module/__tests__/TestScripts/ReturnsNil.lua +++ b/modules/roblox-jest/src/Module/__tests__/TestScripts/ReturnsNil.lua @@ -1 +1 @@ -return nil \ No newline at end of file +return nil diff --git a/modules/roblox-jest/src/Module/__tests__/TestScripts/addFour.lua b/modules/roblox-jest/src/Module/__tests__/TestScripts/addFour.lua index 0262fa65..c811a2c6 100644 --- a/modules/roblox-jest/src/Module/__tests__/TestScripts/addFour.lua +++ b/modules/roblox-jest/src/Module/__tests__/TestScripts/addFour.lua @@ -2,4 +2,4 @@ local add = require(script.Parent.add) return function(value) return add(value, 4) -end \ No newline at end of file +end diff --git a/modules/roblox-jest/src/Module/__tests__/TestScripts/getIncrementorValue.lua b/modules/roblox-jest/src/Module/__tests__/TestScripts/getIncrementorValue.lua index b17d0dfa..f58d2227 100644 --- a/modules/roblox-jest/src/Module/__tests__/TestScripts/getIncrementorValue.lua +++ b/modules/roblox-jest/src/Module/__tests__/TestScripts/getIncrementorValue.lua @@ -2,4 +2,4 @@ local Incrementor = require(script.Parent.Incrementor) return function() return Incrementor.get() -end \ No newline at end of file +end diff --git a/modules/roblox-jest/src/Module/init.lua b/modules/roblox-jest/src/Module/init.lua index 7ade2c14..eb1779d2 100644 --- a/modules/roblox-jest/src/Module/init.lua +++ b/modules/roblox-jest/src/Module/init.lua @@ -5,8 +5,10 @@ local requiredModules: { [ModuleScript]: any } = {} local mocks: { [ModuleScript]: (() -> any)? } = {} if _G.__NO_LOADMODULE__ then - warn("debug.loadmodule not enabled. Test plans relying on resetModules " .. - "will not work properly.") + warn( + "debug.loadmodule not enabled. Test plans relying on resetModules " + .. "will not work properly." + ) return { requireOverride = require, @@ -40,7 +42,11 @@ local function requireOverride(scriptInstance: ModuleScript): any -- -- TODO: This is a little janky, so we should find a way to do this that's a -- little more robust. We may want to apply it to anything in RobloxJest? - if scriptInstance == script or scriptInstance == script.Parent or scriptInstance.Name == "jest-roblox" then + if + scriptInstance == script + or scriptInstance == script.Parent + or scriptInstance.Name == "jest-roblox" + then return require(scriptInstance) end -- FIXME: an extra special hack that prevents us from frequently reloading @@ -58,16 +64,18 @@ local function requireOverride(scriptInstance: ModuleScript): any local moduleResult -- First, check the mock cache and see if this is being mocked - if typeof (mocks[scriptInstance]) == "function" then + if typeof(mocks[scriptInstance]) == "function" then -- ROBLOX FIXME: Luau flow analysis bug workaround - moduleResult = (mocks[scriptInstance]:: () -> any)() + moduleResult = (mocks[scriptInstance] :: () -> any)() if moduleResult == nil then - error(string.format( - "[Mock Error]: %s did not return a valid result\n" .. - "\tmocks must return a non-nil value", - tostring(scriptInstance) - )) + error( + string.format( + "[Mock Error]: %s did not return a valid result\n" + .. "\tmocks must return a non-nil value", + tostring(scriptInstance) + ) + ) end else -- Narrowing this type here lets us appease the type checker while still @@ -82,11 +90,13 @@ local function requireOverride(scriptInstance: ModuleScript): any moduleResult = moduleFunction() if moduleResult == nil then - error(string.format( - "[Module Error]: %s did not return a valid result\n" .. - "\tModuleScripts must return a non-nil value", - tostring(scriptInstance) - )) + error( + string.format( + "[Module Error]: %s did not return a valid result\n" + .. "\tModuleScripts must return a non-nil value", + tostring(scriptInstance) + ) + ) end end @@ -133,4 +143,4 @@ return { resetModules = resetModules, mock = mock, unmock = unmock, -} \ No newline at end of file +} From 1e363f690b63973cce8cd8df40117cea98e0207b Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 8 Jul 2021 11:40:09 -0700 Subject: [PATCH 006/289] LUAFDN-253: react-devtools-shared basics (#130) * The first lead node files. All analyze/lint/format correctly. All relevant tests pass. --- modules/react-devtools-shared/rotriever.toml | 17 + .../src/__tests__/bridge.spec.lua | 63 ++ .../src/__tests__/events.spec.lua | 126 +++ .../src/__tests__/init.spec.lua | 12 + .../src/__tests__/utils.spec.lua | 129 ++++ .../src/backend/NativeStyleEditor/types.lua | 38 + .../src/backend/types.lua | 366 +++++++++ modules/react-devtools-shared/src/bridge.lua | 394 ++++++++++ .../react-devtools-shared/src/constants.lua | 63 ++ .../src/devtools/views/Profiler/types.lua | 160 ++++ modules/react-devtools-shared/src/events.lua | 103 +++ .../react-devtools-shared/src/hydration.lua | 136 ++++ modules/react-devtools-shared/src/storage.lua | 42 + modules/react-devtools-shared/src/types.lua | 88 +++ modules/react-devtools-shared/src/utils.lua | 726 ++++++++++++++++++ 15 files changed, 2463 insertions(+) create mode 100644 modules/react-devtools-shared/rotriever.toml create mode 100644 modules/react-devtools-shared/src/__tests__/bridge.spec.lua create mode 100644 modules/react-devtools-shared/src/__tests__/events.spec.lua create mode 100644 modules/react-devtools-shared/src/__tests__/init.spec.lua create mode 100644 modules/react-devtools-shared/src/__tests__/utils.spec.lua create mode 100644 modules/react-devtools-shared/src/backend/NativeStyleEditor/types.lua create mode 100644 modules/react-devtools-shared/src/backend/types.lua create mode 100644 modules/react-devtools-shared/src/bridge.lua create mode 100644 modules/react-devtools-shared/src/constants.lua create mode 100644 modules/react-devtools-shared/src/devtools/views/Profiler/types.lua create mode 100644 modules/react-devtools-shared/src/events.lua create mode 100644 modules/react-devtools-shared/src/hydration.lua create mode 100644 modules/react-devtools-shared/src/storage.lua create mode 100644 modules/react-devtools-shared/src/types.lua create mode 100644 modules/react-devtools-shared/src/utils.lua diff --git a/modules/react-devtools-shared/rotriever.toml b/modules/react-devtools-shared/rotriever.toml new file mode 100644 index 00000000..2660440e --- /dev/null +++ b/modules/react-devtools-shared/rotriever.toml @@ -0,0 +1,17 @@ +[package] +name = "ReactDevToolsShared" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" + +[dependencies] +LuauPolyfill = { workspace = true } +Shared = { path = "../shared" } +React = { path = "../react" } +ReactIs = { path = "../react-is" } +ReactReconciler = { path = "../react-reconciler" } + +[dev_dependencies] +JestRoblox = { workspace = true } +RobloxJest = { path = "../roblox-jest" } diff --git a/modules/react-devtools-shared/src/__tests__/bridge.spec.lua b/modules/react-devtools-shared/src/__tests__/bridge.spec.lua new file mode 100644 index 00000000..aa4364b5 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/bridge.spec.lua @@ -0,0 +1,63 @@ +-- upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/bridge-test.js +--[[* + * 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. +]] +return function() + local Workspace = script.Parent.Parent + local Packages = Workspace.Parent + local jestModule = require(Packages.Dev.JestRoblox) + local RobloxJest = require(Packages.Dev.RobloxJest) + local jestExpect = jestModule.Globals.expect + local jest = jestModule.Globals.jest + + describe("bridge", function() + local Bridge + + beforeEach(function() + RobloxJest.resetModules() + RobloxJest.useFakeTimers() + Bridge = require(script.Parent.Parent.bridge) + end) + + it("should shutdown properly", function() + local wall = { + listen = jest:fn(function() + return function() end + end), + send = jest:fn(), + } + local bridge = Bridge.new(wall) + + -- Check that we're wired up correctly. + bridge:send("reloadAppForProfiling") + RobloxJest.runAllTimers() + jestExpect(wall.send).toHaveBeenCalledWith("reloadAppForProfiling") + + -- Should flush pending messages and then shut down. + wall.send.mockClear() + bridge:send("update", "1") + bridge:send("update", "2") + bridge:shutdown() + RobloxJest.runAllTimers() + jestExpect(wall.send).toHaveBeenCalledWith("update", "1") + jestExpect(wall.send).toHaveBeenCalledWith("update", "2") + jestExpect(wall.send).toHaveBeenCalledWith("shutdown") + + -- Verify that the Bridge doesn't send messages after shutdown. + + wall.send.mockClear() + -- ROBLOX deviation: instead of spying on console, use toWarnDev matcher + jestExpect(function() + bridge:send("should not send") + end).toWarnDev( + 'Cannot send message "should not send" through a Bridge that has been shutdown.', + { withoutStack = true } + ) + RobloxJest.runAllTimers() + jestExpect(wall.send).never.toHaveBeenCalled() + end) + end) +end diff --git a/modules/react-devtools-shared/src/__tests__/events.spec.lua b/modules/react-devtools-shared/src/__tests__/events.spec.lua new file mode 100644 index 00000000..3a77f5af --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/events.spec.lua @@ -0,0 +1,126 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/events-test.js +-- /** +-- * 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. +-- * @flow +-- */ + +return function() + local Packages = script.Parent.Parent.Parent + local Error = require(Packages.LuauPolyfill).Error + local JestRoblox = require(Packages.Dev.JestRoblox) + local jestExpect = JestRoblox.Globals.expect + local jest = JestRoblox.Globals.jest + local RobloxJest = require(Packages.Dev.RobloxJest) + + describe("events", function() + local dispatcher + beforeEach(function() + RobloxJest.resetModules() + local EventEmitter = require(script.Parent.Parent.events) + dispatcher = EventEmitter.new() + end) + + it("can dispatch an event with no listeners", function() + dispatcher:emit("event", 123) + end) + + it("handles a listener being attached multiple times", function() + local callback = jest:fn() + dispatcher:addListener("event", callback) + dispatcher:addListener("event", callback) + dispatcher:emit("event", 123) + jestExpect(callback).toHaveBeenCalledTimes(1) + jestExpect(callback).toHaveBeenCalledWith(123) + end) + + it("notifies all attached listeners of events", function() + local callback1 = jest:fn() + local callback2 = jest:fn() + local callback3 = jest:fn() + dispatcher:addListener("event", callback1) + dispatcher:addListener("event", callback2) + dispatcher:addListener("other-event", callback3) + dispatcher:emit("event", 123) + jestExpect(callback1).toHaveBeenCalledTimes(1) + jestExpect(callback1).toHaveBeenCalledWith(123) + jestExpect(callback2).toHaveBeenCalledTimes(1) + jestExpect(callback2).toHaveBeenCalledWith(123) + jestExpect(callback3).never.toHaveBeenCalled() + end) + + it("calls later listeners before re-throwing if an earlier one throws", function() + local callbackThatThrows = jest:fn(function() + error(Error.new("expected")) + end) + local callback = jest:fn() + dispatcher:addListener("event", callbackThatThrows) + dispatcher:addListener("event", callback) + jestExpect(function() + dispatcher:emit("event", 123) + end).toThrow("expected") + jestExpect(callbackThatThrows).toHaveBeenCalledTimes(1) + jestExpect(callbackThatThrows).toHaveBeenCalledWith(123) + jestExpect(callback).toHaveBeenCalledTimes(1) + jestExpect(callback).toHaveBeenCalledWith(123) + end) + + it("removes attached listeners", function() + local callback1 = jest:fn() + local callback2 = jest:fn() + dispatcher:addListener("event", callback1) + dispatcher:addListener("other-event", callback2) + dispatcher:removeListener("event", callback1) + dispatcher:emit("event", 123) + jestExpect(callback1).never.toHaveBeenCalled() + dispatcher:emit("other-event", 123) + jestExpect(callback2).toHaveBeenCalledTimes(1) + jestExpect(callback2).toHaveBeenCalledWith(123) + end) + + it("removes all listeners", function() + local callback1 = jest:fn() + local callback2 = jest:fn() + local callback3 = jest:fn() + dispatcher:addListener("event", callback1) + dispatcher:addListener("event", callback2) + dispatcher:addListener("other-event", callback3) + dispatcher:removeAllListeners() + dispatcher:emit("event", 123) + dispatcher:emit("other-event", 123) + jestExpect(callback1).never.toHaveBeenCalled() + jestExpect(callback2).never.toHaveBeenCalled() + jestExpect(callback3).never.toHaveBeenCalled() + end) + + it( + "should call the initial listeners even if others are added or removed during a dispatch", + function() + local callback2, callback3 + local callback1 = jest:fn(function() + dispatcher:removeListener("event", callback2) + dispatcher:addListener("event", callback3) + end) + callback2 = jest:fn() + callback3 = jest:fn() + dispatcher:addListener("event", callback1) + dispatcher:addListener("event", callback2) + dispatcher:emit("event", 123) + jestExpect(callback1).toHaveBeenCalledTimes(1) + jestExpect(callback1).toHaveBeenCalledWith(123) + jestExpect(callback2).toHaveBeenCalledTimes(1) + jestExpect(callback2).toHaveBeenCalledWith(123) + jestExpect(callback3).never.toHaveBeenCalled() + dispatcher:emit("event", 456) + jestExpect(callback1).toHaveBeenCalledTimes(2) + jestExpect(callback1).toHaveBeenCalledWith(456) + jestExpect(callback2).toHaveBeenCalledTimes(1) + jestExpect(callback3).toHaveBeenCalledTimes(1) + jestExpect(callback3).toHaveBeenCalledWith(456) + end + ) + end) +end diff --git a/modules/react-devtools-shared/src/__tests__/init.spec.lua b/modules/react-devtools-shared/src/__tests__/init.spec.lua new file mode 100644 index 00000000..d7f34e55 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/init.spec.lua @@ -0,0 +1,12 @@ +return function() + local Packages = script.Parent.Parent.Parent + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + local RobloxJest = require(Packages.Dev.RobloxJest) + + beforeAll(function() + jestExpect.extend({ + toErrorDev = RobloxJest.Matchers.toErrorDev, + toWarnDev = RobloxJest.Matchers.toWarnDev, + }) + end) +end diff --git a/modules/react-devtools-shared/src/__tests__/utils.spec.lua b/modules/react-devtools-shared/src/__tests__/utils.spec.lua new file mode 100644 index 00000000..b3db37d0 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/utils.spec.lua @@ -0,0 +1,129 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/events-test.js +-- /* +-- * 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. +-- * +-- * @flow +-- */ +return function() + local Workspace = script.Parent.Parent + local Packages = Workspace.Parent + local jest = require(Packages.Dev.JestRoblox) + local jestExpect = jest.Globals.expect + local LuauPolyfill = require(Packages.LuauPolyfill) + local Symbol = LuauPolyfill.Symbol + + local utils = require(Workspace.utils) + local getDisplayName = utils.getDisplayName + local getDisplayNameForReactElement = utils.getDisplayNameForReactElement + local ReactSymbols = require(Packages.Shared).ReactSymbols + local SuspenseList = ReactSymbols.REACT_SUSPENSE_LIST_TYPE + local StrictMode = ReactSymbols.REACT_STRICT_MODE_TYPE + local React = require(Packages.React) + local createElement = React.createElement + + describe("utils", function() + describe("getDisplayName", function() + it("should return a function name", function() + local function FauxComponent() end + + jestExpect(getDisplayName(FauxComponent)).toEqual("FauxComponent") + end) + + it("should return a displayName name if specified", function() + local FauxComponent = {} + + FauxComponent.displayName = "OverrideDisplayName" + + jestExpect(getDisplayName(FauxComponent)).toEqual("OverrideDisplayName") + end) + + it("should return the fallback for anonymous functions", function() + jestExpect(getDisplayName(function() end, "Fallback")).toEqual("Fallback") + end) + + it( + "should return Anonymous for anonymous functions without a fallback", + function() + jestExpect(getDisplayName(function() end)).toEqual("Anonymous") + end + ) + + -- Simulate a reported bug: + -- https://github.com/facebook/react/issues/16685 + it("should return a fallback when the name prop is not a string", function() + local FauxComponent = { name = {} } + jestExpect(getDisplayName(FauxComponent, "Fallback")).toEqual("Fallback") + end) + end) + describe("getDisplayNameForReactElement", function() + -- ROBLOX deviation: Lua can't put fields on functions + xit( + "should return correct display name for an element with function type", + function() + local function FauxComponent() end + + -- FauxComponent.displayName = 'OverrideDisplayName' + + local element = createElement(FauxComponent) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "OverrideDisplayName" + ) + end + ) + + it( + "should return correct display name for an element with a type of StrictMode", + function() + local element = createElement(StrictMode) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "StrictMode" + ) + end + ) + + it( + "should return correct display name for an element with a type of SuspenseList", + function() + local element = createElement(SuspenseList) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "SuspenseList" + ) + end + ) + + it( + "should return NotImplementedInDevtools for an element with invalid symbol type", + function() + local element = createElement(Symbol("foo")) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "NotImplementedInDevtools" + ) + end + ) + + it( + "should return NotImplementedInDevtools for an element with invalid type", + function() + local element = createElement(true) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "NotImplementedInDevtools" + ) + end + ) + + it("should return Element for null type", function() + local element = createElement() + + jestExpect(getDisplayNameForReactElement(element)).toEqual("Element") + end) + end) + end) +end diff --git a/modules/react-devtools-shared/src/backend/NativeStyleEditor/types.lua b/modules/react-devtools-shared/src/backend/NativeStyleEditor/types.lua new file mode 100644 index 00000000..405c3f23 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/NativeStyleEditor/types.lua @@ -0,0 +1,38 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/NativeStyleEditor/types.js +-- /** +-- * 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. +-- * +-- * @flow +-- */ +type Object = { [string]: any } + +export type BoxStyle = { + bottom: number, + left: number, + right: number, + top: number, +} + +export type Layout = { + x: number, + y: number, + width: number, + height: number, + left: number, + top: number, + margin: BoxStyle, + padding: BoxStyle, +} + +export type Style = Object + +export type StyleAndLayout = { + id: number, + style: Style | nil, + layout: Layout | nil, +} + +return {} diff --git a/modules/react-devtools-shared/src/backend/types.lua b/modules/react-devtools-shared/src/backend/types.lua new file mode 100644 index 00000000..e51c452a --- /dev/null +++ b/modules/react-devtools-shared/src/backend/types.lua @@ -0,0 +1,366 @@ +-- ROBLOX upstream: https:--github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/types.js +-- /** +-- * 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. +-- * +-- * @flow +-- */ + +local Workspace = script.Parent.Parent +local Packages = Workspace.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Object = { [string]: any } +type Array = { [number]: T } +type Function = (...any) -> any +type Map = { [K]: V } +type Set = LuauPolyfill.Set +type Symbol = Object +local exports = {} + +-- ROBLOX deviation: rotriever re-exports types to the top-level export +local ReactShared = require(Packages.Shared) +type ReactContext = ReactShared.ReactContext +type Source = ReactShared.Source +local ReactInternalTypes = require(Packages.ReactReconciler) +type Fiber = ReactInternalTypes.Fiber +local Types = require(Workspace.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType + +local DevToolsViewsProfilerTypes = require(Workspace.devtools.views.Profiler.types) +type Interaction = DevToolsViewsProfilerTypes.Interaction + +type ResolveNativeStyle = (any) -> Object? + +-- ROBLOX TODO: Luau currently can't express enumerations of literals +-- | 0 -- PROD +-- | 1; -- DEV +type BundleType = number + +export type WorkTag = number +export type WorkFlags = number +export type ExpirationTime = number + +export type WorkTagMap = { + Block: WorkTag, + ClassComponent: WorkTag, + ContextConsumer: WorkTag, + ContextProvider: WorkTag, + CoroutineComponent: WorkTag, + CoroutineHandlerPhase: WorkTag, + DehydratedSuspenseComponent: WorkTag, + ForwardRef: WorkTag, + Fragment: WorkTag, + FunctionComponent: WorkTag, + HostComponent: WorkTag, + HostPortal: WorkTag, + HostRoot: WorkTag, + HostText: WorkTag, + IncompleteClassComponent: WorkTag, + IndeterminateComponent: WorkTag, + LazyComponent: WorkTag, + MemoComponent: WorkTag, + Mode: WorkTag, + OffscreenComponent: WorkTag, + Profiler: WorkTag, + SimpleMemoComponent: WorkTag, + SuspenseComponent: WorkTag, + SuspenseListComponent: WorkTag, + YieldComponent: WorkTag, +} + +-- TODO: If it's useful for the frontend to know which types of data an Element has +-- (e.g. props, state, context, hooks) then we could add a bitmask field for this +-- to keep the number of attributes small. +export type FiberData = { + key: string | nil, + displayName: string | nil, + type: ElementType, +} + +export type NativeType = Object +export type RendererID = number + +type Dispatcher = any +export type CurrentDispatcherRef = { current: nil | Dispatcher } + +export type GetDisplayNameForFiberID = (number, boolean?) -> string | nil + +export type GetFiberIDForNative = (NativeType, boolean?) -> number | nil +export type FindNativeNodesForFiberID = (number) -> Array? + +export type ReactProviderType = { + -- ROBLOX TODO: Luau can't express field names that require quoted accessor + -- $$typeof: Symbol | number, + [string]: Symbol | number, + _context: ReactContext, + -- ... +} + +export type ReactRenderer = { + findFiberByHostInstance: (NativeType) -> Fiber?, + version: string, + rendererPackageName: string, + bundleType: BundleType, + -- 16.9+ + overrideHookState: ((Object, number, Array, any) -> ())?, + -- 17+ + overrideHookStateDeletePath: ((Object, number, Array) -> ())?, + -- 17+ + overrideHookStateRenamePath: ((Object, number, Array, Array) -> ())?, + -- 16.7+ + overrideProps: ((Object, Array, any) -> ())?, + -- 17+ + overridePropsDeletePath: ((Object, Array) -> ())?, + -- 17+ + overridePropsRenamePath: ((Object, Array, Array) -> ())?, + -- 16.9+ + scheduleUpdate: ((Object) -> ())?, + setSuspenseHandler: (((Object) -> boolean) -> ())?, + -- Only injected by React v16.8+ in order to support hooks inspection. + currentDispatcherRef: CurrentDispatcherRef?, + -- Only injected by React v16.9+ in DEV mode. + -- Enables DevTools to append owners-only component stack to error messages. + getCurrentFiber: (() -> Fiber | nil)?, + -- Uniquely identifies React DOM v15. + ComponentTree: any?, + -- Present for React DOM v12 (possibly earlier) through v15. + Mount: any?, + -- ... +} + +export type ChangeDescription = { + context: Array | boolean | nil, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | nil, + state: Array | nil, +} + +export type CommitDataBackend = { + -- Tuple of fiber ID and change description + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + -- changeDescriptions: Array<[number, ChangeDescription]> | nil, + changeDescriptions: Array | nil, + duration: number, + -- Tuple of fiber ID and actual duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + fiberActualDurations: Array, + -- Tuple of fiber ID and computed "self" duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + fiberSelfDurations: Array, + interactionIDs: Array, + priorityLevel: string | nil, + timestamp: number, +} + +export type ProfilingDataForRootBackend = { + commitData: Array, + displayName: string, + -- Tuple of Fiber ID and base duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + + initialTreeBaseDurations: Array, + -- Tuple of Interaction ID and commit indices + interactionCommits: Array, + interactions: Array, + rootID: number, +} + +-- Profiling data collected by the renderer interface. +-- This information will be passed to the frontend and combined with info it collects. +export type ProfilingDataBackend = { + dataForRoots: Array, + rendererID: number, +} + +export type PathFrame = { + key: string | nil, + index: number, + displayName: string | nil, +} + +export type PathMatch = { + id: number, + isFullMatch: boolean, +} + +export type Owner = { + displayName: string | nil, + id: number, + type: ElementType, +} + +export type OwnersList = { + id: number, + owners: Array | nil, +} + +export type InspectedElement = { + id: number, + + displayName: string | nil, + + -- Does the current renderer support editable hooks and function props? + canEditHooks: boolean, + canEditFunctionProps: boolean, + + -- Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: boolean, + canEditHooksAndRenamePaths: boolean, + canEditFunctionPropsDeletePaths: boolean, + canEditFunctionPropsRenamePaths: boolean, + + -- Is this Suspense, and can its value be overridden now? + canToggleSuspense: boolean, + + -- Can view component source location. + canViewSource: boolean, + + -- Does the component have legacy context attached to it. + hasLegacyContext: boolean, + + -- Inspectable properties. + context: Object | nil, + hooks: Object | nil, + props: Object | nil, + state: Object | nil, + key: number | string | nil, + + -- List of owners + owners: Array | nil, + + -- Location of component in source code. + source: Source | nil, + + type: ElementType, + + -- Meta information about the root this element belongs to. + rootType: string | nil, + + -- Meta information about the renderer that created this element. + rendererPackageName: string | nil, + rendererVersion: string | nil, +} + +exports.InspectElementFullDataType = "full-data" +exports.InspectElementNoChangeType = "no-change" +exports.InspectElementNotFoundType = "not-found" +exports.InspectElementHydratedPathType = "hydrated-path" + +type InspectElementFullData = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'full-data', + type: string, + value: InspectedElement, +} + +type InspectElementHydratedPath = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'hydrated-path', + type: string, + path: Array, + value: any, +} + +type InspectElementNoChange = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'no-change', + type: string, +} + +type InspectElementNotFound = { + id: number, + -- ROBLOX TODO: Luau can't express literals + -- type: 'not-found', + type: string, +} + +export type InspectedElementPayload = + InspectElementFullData + | InspectElementHydratedPath + | InspectElementNoChange + | InspectElementNotFound + +export type InstanceAndStyle = { + instance: Object | nil, + style: Object | nil, +} + +-- ROBLOX TODO: Luau can't express literals +-- type Type = 'props' | 'hooks' | 'state' | 'context'; +type Type = string + +export type RendererInterface = { + cleanup: () -> (), + copyElementPath: (number, Array) -> (), + deletePath: (Type, number, number?, Array) -> (), + findNativeNodesForFiberID: FindNativeNodesForFiberID, + flushInitialOperations: () -> (), + getBestMatchForTrackedPath: () -> PathMatch | nil, + getFiberIDForNative: GetFiberIDForNative, + getDisplayNameForFiberID: GetDisplayNameForFiberID, + getInstanceAndStyle: (number) -> InstanceAndStyle, + getProfilingData: () -> ProfilingDataBackend, + getOwnersList: (number) -> Array | nil, + getPathForElement: (number) -> Array | nil, + handleCommitFiberRoot: (Object, number?) -> (), + handleCommitFiberUnmount: (Object) -> (), + inspectElement: (number, Array?) -> InspectedElementPayload, + logElementToConsole: (number) -> (), + overrideSuspense: (number, boolean) -> (), + overrideValueAtPath: (Type, number, number?, Array, any) -> (), + prepareViewAttributeSource: (number, Array) -> (), + prepareViewElementSource: (number) -> (), + renamePath: (Type, number, number?, Array, Array) -> (), + renderer: ReactRenderer | nil, + setTraceUpdatesEnabled: (boolean) -> (), + setTrackedPath: (Array | nil) -> (), + startProfiling: (boolean) -> (), + stopProfiling: () -> (), + storeAsGlobal: (number, Array, number) -> (), + updateComponentFilters: (Array) -> (), + -- ... +} + +export type Handler = (any) -> () + +export type DevToolsHook = { + listeners: { + [string]: Array, --[[ ...]] + }, + rendererInterfaces: Map, + renderers: Map, + + emit: (string, any) -> (), + getFiberRoots: (RendererID) -> Set, + inject: (ReactRenderer) -> number | nil, + on: (string, Handler) -> (), + off: (string, Handler) -> (), + reactDevtoolsAgent: Object?, + sub: ((string, Handler) -> ()) -> (), + + -- Used by react-native-web and Flipper/Inspector + resolveRNStyle: ResolveNativeStyle?, + nativeStyleEditorValidAttributes: Array?, + + -- React uses these methods. + checkDCE: (Function) -> (), + onCommitFiberUnmount: (RendererID, Object) -> (), + onCommitFiberRoot: ( + RendererID, + Object, + -- Added in v16.9 to support Profiler priority labels +number?, + -- Added in v16.9 to support Fast Refresh +boolean? + ) -> (), + -- ... +} + +return exports diff --git a/modules/react-devtools-shared/src/bridge.lua b/modules/react-devtools-shared/src/bridge.lua new file mode 100644 index 00000000..8c9ca175 --- /dev/null +++ b/modules/react-devtools-shared/src/bridge.lua @@ -0,0 +1,394 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/bridge.js +-- /* +-- * 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. +-- */ +local Workspace = script.Parent +local Packages = Workspace.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local console = require(Packages.Shared).console +type Array = { [number]: T } +type Function = (...any) -> any + +local EventEmitter = require(script.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local Types = require(script.Parent.types) +type ComponentFilter = Types.ComponentFilter +type Wall = Types.Wall +local BackendTypes = require(script.Parent.backend.types) +type InspectedElementPayload = BackendTypes.InspectedElementPayload +type OwnersList = BackendTypes.OwnersList +type ProfilingDataBackend = BackendTypes.ProfilingDataBackend +type RendererID = BackendTypes.RendererID + +local BATCH_DURATION = 100 + +type Message = { + event: string, + payload: any, +} + +type ElementAndRendererID = { + id: number, + rendererID: RendererID, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type HighlightElementInDOM = ElementAndRendererID & { + displayName: string?, + hideAfterTimeout: boolean, + openNativeElementsPanel: boolean, + scrollIntoView: boolean, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideValue = ElementAndRendererID & { + path: Array, + wasForwarded: boolean?, + value: any, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideHookState = OverrideValue & { hookID: number } + +-- ROBLOX deviation: 'props' | 'hooks' | 'state' | 'context'; +type PathType = string + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type DeletePath = ElementAndRendererID & { + type: PathType, + hookID: number?, + path: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type RenamePath = ElementAndRendererID & { + type: PathType, + hookID: number?, + oldPath: Array, + newPath: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideValueAtPath = ElementAndRendererID & { + type: PathType, + hookID: number?, + path: Array, + value: any, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type OverrideSuspense = ElementAndRendererID & { forceFallback: boolean } + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type CopyElementPathParams = ElementAndRendererID & { + path: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type ViewAttributeSourceParams = ElementAndRendererID & { + path: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type InspectElementParams = ElementAndRendererID & { + path: Array?, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type StoreAsGlobalParams = ElementAndRendererID & { + count: number, + path: Array, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type NativeStyleEditor_RenameAttributeParams = ElementAndRendererID & { + oldName: string, + newName: string, + value: string, +} + +-- ROBLOX deviation: Luau can't use ...type, use intersection instead +type NativeStyleEditor_SetValueParams = ElementAndRendererID & { + name: string, + value: string, +} + +type UpdateConsolePatchSettingsParams = { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +} + +-- ROBLOX deviation: Luau can't define object types in a function type +type IsSupported = { + isSupported: boolean, + validAttributes: Array, +} + +type BackendEvents = { + extensionBackendInitialized: () -> (), + inspectedElement: (InspectedElementPayload) -> (), + isBackendStorageAPISupported: (boolean) -> (), + operations: (Array) -> (), + ownersList: (OwnersList) -> (), + overrideComponentFilters: (Array) -> (), + profilingData: (ProfilingDataBackend) -> (), + profilingStatus: (boolean) -> (), + reloadAppForProfiling: () -> (), + selectFiber: (number) -> (), + shutdown: () -> (), + stopInspectingNative: (boolean) -> (), + syncSelectionFromNativeElementsPanel: () -> (), + syncSelectionToNativeElementsPanel: () -> (), + unsupportedRendererVersion: (RendererID) -> (), + + -- React Native style editor plug-in. + isNativeStyleEditorSupported: (IsSupported) -> (), + -- ROBLOX deviation: StyleAndLayoutPayload type not transliterated + NativeStyleEditor_styleAndLayout: () -> (), +} + +type FrontendEvents = { + clearNativeElementHighlight: () -> (), + copyElementPath: (CopyElementPathParams) -> (), + deletePath: (DeletePath) -> (), + getOwnersList: (ElementAndRendererID) -> (), + getProfilingData: ({ rendererID: RendererID }) -> (), + getProfilingStatus: () -> (), + highlightNativeElement: (HighlightElementInDOM) -> (), + inspectElement: (InspectElementParams) -> (), + logElementToConsole: (ElementAndRendererID) -> (), + overrideSuspense: (OverrideSuspense) -> (), + overrideValueAtPath: (OverrideValueAtPath) -> (), + profilingData: (ProfilingDataBackend) -> (), + reloadAndProfile: (boolean) -> (), + renamePath: (RenamePath) -> (), + selectFiber: (number) -> (), + setTraceUpdatesEnabled: (boolean) -> (), + shutdown: () -> (), + startInspectingNative: () -> (), + startProfiling: (boolean) -> (), + stopInspectingNative: (boolean) -> (), + stopProfiling: () -> (), + storeAsGlobal: (StoreAsGlobalParams) -> (), + updateComponentFilters: (Array) -> (), + updateConsolePatchSettings: (UpdateConsolePatchSettingsParams) -> (), + viewAttributeSource: (ViewAttributeSourceParams) -> (), + viewElementSource: (ElementAndRendererID) -> (), + + -- React Native style editor plug-in. + NativeStyleEditor_measure: (ElementAndRendererID) -> (), + NativeStyleEditor_renameAttribute: (NativeStyleEditor_RenameAttributeParams) -> (), + NativeStyleEditor_setValue: (NativeStyleEditor_SetValueParams) -> (), + + -- Temporarily support newer standalone front-ends sending commands to older embedded backends. + -- We do this because React Native embeds the React DevTools backend, + -- but cannot control which version of the frontend users use. + -- + -- Note that nothing in the newer backend actually listens to these events, + -- but the new frontend still dispatches them (in case older backends are listening to them instead). + -- + -- Note that this approach does no support the combination of a newer backend with an older frontend. + -- It would be more work to suppot both approaches (and not run handlers twice) + -- so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix"). + overrideContext: (OverrideValue) -> (), + overrideHookState: (OverrideHookState) -> (), + overrideProps: (OverrideValue) -> (), + overrideState: (OverrideValue) -> (), +} + +-- ROBLOX TODO: Luau can't spread keys of a type as string +type EventName = string -- $Keys +type ElementType = any + +export type Bridge< + OutgoingEvents, IncomingEvents -- ROBLOX deviation: Luau can't express -- > extends EventEmitter<{| -- ...IncomingEvents, -- ...OutgoingEvents, -- |}> { +> = EventEmitter & { + _isShutdown: boolean, + _messageQueue: Array, + _timeoutID: TimeoutID | nil, + _wall: Wall, + _wallUnlisten: Function | nil, + send: (EventName, ...ElementType) -> (), + shutdown: () -> (), + _flush: () -> (), +} + +-- ROBLOX TODO? not sure where TimeoutID comes from in upstream +type TimeoutID = any +local Bridge = setmetatable({}, { __index = EventEmitter }) +local BridgeMetatable = { __index = Bridge } + +function Bridge.new(wall: Wall) + local self = setmetatable(EventEmitter.new(), BridgeMetatable) + + -- ROBLOX deviation: initializers from class declaration + self._isShutdown = false + self._messageQueue = {} + self._timeoutID = nil + -- _wall + self._wallUnlisten = nil + + self._wall = wall + self._wallUnlisten = wall.listen(function(message: Message) + self:emit(message.event, message.payload) + end) or nil + + -- Temporarily support older standalone front-ends sending commands to newer embedded backends. + -- We do this because React Native embeds the React DevTools backend, + -- but cannot control which version of the frontend users use. + self:addListener("overrideValueAtPath", self.overrideValueAtPath) + + -- ROBLOX deviation: just expose wall as an instance field, instead of read-only property + self.wall = wall + + return self +end + +function Bridge:send(event: EventName, ...: ElementType) + local payload = { ... } + if self._isShutdown then + console.warn( + ('Cannot send message "%s" through a Bridge that has been shutdown.'):format( + event + ) + ) + return + end + + -- When we receive a message: + -- - we add it to our queue of messages to be sent + -- - if there hasn't been a message recently, we set a timer for 0 ms in + -- the future, allowing all messages created in the same tick to be sent + -- together + -- - if there *has* been a message flushed in the last BATCH_DURATION ms + -- (or we're waiting for our setTimeout-0 to fire), then _timeoutID will + -- be set, and we'll simply add to the queue and wait for that + table.insert(self._messageQueue, event) + table.insert(self._messageQueue, payload) + + if not self._timeoutID then + self._timeoutID = LuauPolyfill.setTimeout(function() + self:_flush() + end, 0) + end +end + +function Bridge:shutdown() + if self._isShutdown then + console.warn("Bridge was already shutdown.") + return + end + + -- Queue the shutdown outgoing message for subscribers. + self:send("shutdown") + + -- Mark this bridge as destroyed, i.e. disable its public API. + self._isShutdown = true + + -- Disable the API inherited from EventEmitter that can add more listeners and send more messages. + -- $FlowFixMe This property is not writable. + self.addListener = function() end + -- $FlowFixMe This property is not writable. + self.emit = function() end + -- NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter. + + -- Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that. + self:removeAllListeners() + + -- Stop accepting and emitting incoming messages from the wall. + local wallUnlisten = self._wallUnlisten + + if wallUnlisten then + wallUnlisten() + end + + -- Synchronously flush all queued outgoing messages. + -- At this step the subscribers' code may run in this call stack. + repeat + self:_flush() + until #self._messageQueue == 0 + + -- Make sure once again that there is no dangling timer. + if self._timeoutID ~= nil then + LuauPolyfill.clearTimeout(self._timeoutID) + + self._timeoutID = nil + end +end + +function Bridge:_flush(): () + -- This method is used after the bridge is marked as destroyed in shutdown sequence, + -- so we do not bail out if the bridge marked as destroyed. + -- It is a private method that the bridge ensures is only called at the right times. + + if self._timeoutID ~= nil then + LuauPolyfill.clearTimeout(self._timeoutID) + + self._timeoutID = nil + end + if #self._messageQueue > 0 then + for i = 1, #self._messageQueue, 2 do + self._wall.send( + self._messageQueue[i], + table.unpack(self._messageQueue[i + 1]) + ) + end + table.clear(self._messageQueue) + + -- Check again for queued messages in BATCH_DURATION ms. This will keep + -- flushing in a loop as long as messages continue to be added. Once no + -- more are, the timer expires. + self._timeoutID = LuauPolyfill.setTimeout(function() + self:_flush() + end, BATCH_DURATION) + end +end + +-- Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands +-- to the older message types they may be listening to. +function Bridge:overrideValueAtPath(_ref: OverrideValueAtPath) + local id, path, rendererID, type_, value = + _ref.id, _ref.path, _ref.rendererID, _ref.type, _ref.value + if type_ == "context" then + self:send("overrideContext", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "hooks" then + self:send("overrideHookState", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "props" then + self:send("overrideProps", { + id = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + elseif type_ == "state" then + self:send("overrideState", { + id, + path, + rendererID, + wasForwarded = true, + value, + }) + end +end + +export type BackendBridge = Bridge +export type FrontendBridge = Bridge + +return Bridge diff --git a/modules/react-devtools-shared/src/constants.lua b/modules/react-devtools-shared/src/constants.lua new file mode 100644 index 00000000..5ce54ba7 --- /dev/null +++ b/modules/react-devtools-shared/src/constants.lua @@ -0,0 +1,63 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/constants.js +-- /** +-- * 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. +-- * +-- * @flow +-- */ + +local exports = {} + +-- Flip this flag to true to enable verbose console debug logging. +exports.__DEBUG__ = false + +exports.TREE_OPERATION_ADD = 1 +exports.TREE_OPERATION_REMOVE = 2 +exports.TREE_OPERATION_REORDER_CHILDREN = 3 +exports.TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4 + +exports.LOCAL_STORAGE_FILTER_PREFERENCES_KEY = "React::DevTools::componentFilters" + +exports.SESSION_STORAGE_LAST_SELECTION_KEY = "React::DevTools::lastSelection" + +exports.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + "React::DevTools::recordChangeDescriptions" + +exports.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = "React::DevTools::reloadAndProfile" + +exports.LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS = + "React::DevTools::breakOnConsoleErrors" + +exports.LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = "React::DevTools::appendComponentStack" + +exports.LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY = "React::DevTools::traceUpdatesEnabled" + +exports.PROFILER_EXPORT_VERSION = 4 + +exports.CHANGE_LOG_URL = + "https://github.com/facebook/react/blob/master/packages/react-devtools/CHANGELOG.md" + +exports.UNSUPPORTED_VERSION_URL = + "https://reactjs.org/blog/2019/08/15/new-react-devtools.html#how-do-i-get-the-old-version-back" + +-- HACK +-- +-- Extracting during build time avoids a temporarily invalid state for the inline target. +-- Sometimes the inline target is rendered before root styles are applied, +-- which would result in e.g. NaN itemSize being passed to react-window list. +-- +local COMFORTABLE_LINE_HEIGHT +local COMPACT_LINE_HEIGHT + +-- ROBLOX deviation: we won't use the CSS, and don't have a bundler, so always use the 'fallback' +-- We can't use the Webpack loader syntax in the context of Jest, +-- so tests need some reasonably meaningful fallback value. +COMFORTABLE_LINE_HEIGHT = 15 +COMPACT_LINE_HEIGHT = 10 + +exports.COMFORTABLE_LINE_HEIGHT = COMFORTABLE_LINE_HEIGHT +exports.COMPACT_LINE_HEIGHT = COMPACT_LINE_HEIGHT + +return exports diff --git a/modules/react-devtools-shared/src/devtools/views/Profiler/types.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/types.lua new file mode 100644 index 00000000..907aab8d --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/types.lua @@ -0,0 +1,160 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Profiler/types.js +-- /** +-- * 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. +-- * +-- * @flow +-- */ + +local Workspace = script.Parent.Parent.Parent.Parent +type Array = { [number]: T } +type Map = { [K]: V } +local exports = {} + +local ReactDevToolsSharedTypes = require(Workspace.types) + +type ElementType = ReactDevToolsSharedTypes.ElementType + +export type CommitTreeNode = { + id: number, + children: Array, + displayName: string | nil, + hocDisplayNames: Array | nil, + key: number | string | nil, + parentID: number, + treeBaseDuration: number, + type: ElementType, +} + +export type CommitTree = { + nodes: Map, + rootID: number, +} + +export type Interaction = { + id: number, + name: string, + timestamp: number, +} + +export type SnapshotNode = { + id: number, + children: Array, + displayName: string | nil, + hocDisplayNames: Array | nil, + key: number | string | nil, + type: ElementType, +} + +export type ChangeDescription = { + context: Array | boolean | nil, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | nil, + state: Array | nil, +} + +export type CommitDataFrontend = { + -- Map of Fiber (ID) to a description of what changed in this commit. + changeDescriptions: Map | nil, + + -- How long was this commit? + duration: number, + + -- Map of Fiber (ID) to actual duration for this commit; + -- Fibers that did not render will not have entries in this Map. + fiberActualDurations: Map, + + -- Map of Fiber (ID) to "self duration" for this commit; + -- Fibers that did not render will not have entries in this Map. + fiberSelfDurations: Map, + + -- Which interactions (IDs) were associated with this commit. + interactionIDs: Array, + + -- Priority level of the commit (if React provided this info) + priorityLevel: string | nil, + + -- When did this commit occur (relative to the start of profiling) + timestamp: number, +} + +export type ProfilingDataForRootFrontend = { + -- Timing, duration, and other metadata about each commit. + commitData: Array, + + -- Display name of the nearest descendant component (ideally a function or class component). + -- This value is used by the root selector UI. + displayName: string, + + -- Map of fiber id to (initial) tree base duration when Profiling session was started. + -- This info can be used along with commitOperations to reconstruct the tree for any commit. + initialTreeBaseDurations: Map, + + -- All interactions recorded (for this root) during the current session. + interactionCommits: Map>, + + -- All interactions recorded (for this root) during the current session. + interactions: Map, + + -- List of tree mutation that occur during profiling. + -- These mutations can be used along with initial snapshots to reconstruct the tree for any commit. + operations: Array>, + + -- Identifies the root this profiler data corresponds to. + rootID: number, + + -- Map of fiber id to node when the Profiling session was started. + -- This info can be used along with commitOperations to reconstruct the tree for any commit. + snapshots: Map, +} + +-- Combination of profiling data collected by the renderer interface (backend) and Store (frontend). +export type ProfilingDataFrontend = { + -- Profiling data per root. + dataForRoots: Map, + imported: boolean, +} + +export type CommitDataExport = { + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + -- changeDescriptions: Array<[number, ChangeDescription]> | nil, + changeDescriptions: Array | nil, + duration: number, + -- Tuple of fiber ID and actual duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + fiberActualDurations: Array, + -- Tuple of fiber ID and computed "self" duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + fiberSelfDurations: Array, + interactionIDs: Array, + priorityLevel: string | nil, + timestamp: number, +} + +export type ProfilingDataForRootExport = { + commitData: Array, + displayName: string, + -- Tuple of Fiber ID and base duration + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + initialTreeBaseDurations: Array, + -- Tuple of Interaction ID and commit indices + -- ROBLOX TODO: how to express bracket syntax embedded in Array type? + interactionCommits: Array, + interactions: Array, + operations: Array>, + rootID: number, + snapshots: Array, +} + +-- Serializable version of ProfilingDataFrontend data. +export type ProfilingDataExport = { + -- ROBLOX TODO: Luau can't express literals/enums + -- version: 4, + version: number, + dataForRoots: Array, +} + +return exports diff --git a/modules/react-devtools-shared/src/events.lua b/modules/react-devtools-shared/src/events.lua new file mode 100644 index 00000000..032632e1 --- /dev/null +++ b/modules/react-devtools-shared/src/events.lua @@ -0,0 +1,103 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/events.js +-- /* +-- * 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. +-- * +-- */ + +local Workspace = script.Parent +local Packages = Workspace.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +type Array = { [number]: T } +type Map = { [K]: V } +type Function = (any) -> any +type ElementType = any + +export type EventEmitter = { + listenersMap: Map>, + -- ROBLOX TODO: function generics >( + addListener: (EventEmitter, string, (...ElementType) -> any) -> (), + -- ROBLOX TODO: function generics >( + emit: (EventEmitter, string, ...ElementType) -> (), + removeAllListeners: (EventEmitter) -> (), + -- ROBLOX deviation: Luau doesn't support $Keys for first non-self param + removeListener: (EventEmitter, string, Function) -> (), +} +local EventEmitter = {} +local EventEmitterMetatable = { __index = EventEmitter } + +function EventEmitter.new() + local self = {} + self.listenersMap = {} + + return setmetatable(self, EventEmitterMetatable) +end + +function EventEmitter:addListener( + event: string, + listener: (...ElementType) -> any +): () + local listeners = self.listenersMap[event] + if listeners == nil then + self.listenersMap[event] = { listener } + else + local index = Array.indexOf(listeners, listener) + if index < 1 then + table.insert(listeners, listener) + end + end +end + +function EventEmitter:emit( + -- ROBLOX deviation: Luau doesn't support $Keys for first non-self param + event: string, + ...: ElementType +): () + local listeners = self.listenersMap[event] + if listeners ~= nil then + if #listeners == 1 then + -- No need to clone or try/catch + local listener = listeners[1] + listener(...) + else + local didThrow = false + local caughtError = nil + local clonedListeners = Array.from(listeners) + for i = 1, #clonedListeners do + local listener = clonedListeners[i] + local ok, error_ = pcall(function(...) + listener(...) + end, ...) + if not ok then + didThrow = true + caughtError = error_ + end + end + if didThrow then + error(caughtError) + end + end + end +end + +function EventEmitter:removeAllListeners(): () + table.clear(self.listenersMap) +end + +-- ROBLOX deviation: Luau doesn't support $Keys for first non-self param +function EventEmitter:removeListener(event: string, listener: Function): () + local listeners = self.listenersMap[event] + + if listeners ~= nil then + local index = Array.indexOf(listeners, listener) + + if index >= 1 then + Array.splice(listeners, index, 1) + end + end +end + +return EventEmitter diff --git a/modules/react-devtools-shared/src/hydration.lua b/modules/react-devtools-shared/src/hydration.lua new file mode 100644 index 00000000..6fd57f30 --- /dev/null +++ b/modules/react-devtools-shared/src/hydration.lua @@ -0,0 +1,136 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/hydration.js +-- /* +-- * 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. +-- */ + +local Workspace = script.Parent +local Packages = Workspace.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Symbol = LuauPolyfill.Symbol +type Array = { [number]: T } +type Object = { [string]: any } + +-- ROBLOX FIXME: !!! THIS FILE IS A STUB WITH BAREBONES FOR UTILS TEST +local function unimplemented(functionName) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!! " .. functionName .. " was called, but is stubbed! ") +end + +local exports = {} + +type DehydratedData = any + +exports.meta = { + inspectable = Symbol("inspectable"), + inspected = Symbol("inspected"), + name = Symbol("name"), + preview_long = Symbol("preview_long"), + preview_short = Symbol("preview_short"), + readonly = Symbol("readonly"), + size = Symbol("size"), + type = Symbol("type"), + unserializable = Symbol("unserializable"), +} + +export type Dehydrated = { + inspectable: boolean, + name: string | nil, + preview_long: string | nil, + preview_short: string | nil, + readonly: boolean?, + size: number?, + type: string, +} + +-- Typed arrays and other complex iteratable objects (e.g. Map, Set, ImmutableJS) need special handling. +-- These objects can't be serialized without losing type information, +-- so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values- +-- while preserving the original type and name. +export type Unserializable = { + name: string | nil, + preview_long: string | nil, + preview_short: string | nil, + readonly: boolean?, + size: number?, + type: string, + unserializable: boolean, + -- ... +} + +-- This threshold determines the depth at which the bridge "dehydrates" nested data. +-- Dehydration means that we don't serialize the data for e.g. postMessage or stringify, +-- unless the frontend explicitly requests it (e.g. a user clicks to expand a props object). +-- +-- Reducing this threshold will improve the speed of initial component inspection, +-- but may decrease the responsiveness of expanding objects/arrays to inspect further. +local _LEVEL_THRESHOLD = 2 + +-- /** +-- * Generate the dehydrated metadata for complex object instances +-- */ +exports.createDehydrated = function( + type: string, + inspectable: boolean, + data: Object, + cleaned: Array>, + path: Array +): Dehydrated + unimplemented("createDehydrated") + error("unimplemented createDehydrated") +end + +-- /** +-- * Strip out complex data (instances, functions, and data nested > LEVEL_THRESHOLD levels deep). +-- * The paths of the stripped out objects are appended to the `cleaned` list. +-- * On the other side of the barrier, the cleaned list is used to "re-hydrate" the cleaned representation into +-- * an object with symbols as attributes, so that a sanitized object can be distinguished from a normal object. +-- * +-- * Input: {"some": {"attr": fn()}, "other": AnInstance} +-- * Output: { +-- * "some": { +-- * "attr": {"name": the fn.name, type: "function"} +-- * }, +-- * "other": { +-- * "name": "AnInstance", +-- * "type": "object", +-- * }, +-- * } +-- * and cleaned = [["some", "attr"], ["other"]] +-- */ +exports.dehydrate = function( + data: Object, + cleaned: Array>, + unserializable: Array>, + path: Array, + isPathAllowed: (Array) -> boolean, + level: number? +): string | Dehydrated | Unserializable | Array | Array | { + [string]: string | Dehydrated | Unserializable, --[[...]] +} + if level == nil then + level = 0 + end + unimplemented("dehydrate") + return "!!! UNIMPLEMENTED !!!" +end + +exports.fillInPath = + function(object: Object, data: DehydratedData, path: Array, value: any): () + unimplemented("fillInPath") + end + +exports.hydrate = function( + object: any, + cleaned: Array>, + unserializable: Array> +): Object + unimplemented("hydrate") + return {} +end + +return exports diff --git a/modules/react-devtools-shared/src/storage.lua b/modules/react-devtools-shared/src/storage.lua new file mode 100644 index 00000000..c6a7c483 --- /dev/null +++ b/modules/react-devtools-shared/src/storage.lua @@ -0,0 +1,42 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/storage.js +-- /* +-- * 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. +-- * +-- */ + +local exports = {} +if _G.__LOCALSTORAGE__ == nil then + _G.__LOCALSTORAGE__ = {} +end + +if _G.__SESSIONSTORAGE__ == nil then + _G.__SESSIONSTORAGE__ = {} +end + +-- ROBLOX FIXME: what's a high-performance storage that for temporal (current DM lifetime) and permanent (beyond current DM lifetime) +local localStorage = _G.__LOCALSTORAGE__ +local sessionStorage = _G.__SESSIONSTORAGE__ + +exports.localStorageGetItem = function(key: string): any + return localStorage[key] +end +exports.localStorageRemoveItem = function(key: string): () + localStorage[key] = nil +end +exports.localStorageSetItem = function(key: string, value: any): () + localStorage[key] = value +end +exports.sessionStorageGetItem = function(key: string): any + return sessionStorage[key] +end +exports.sessionStorageRemoveItem = function(key: string): () + sessionStorage[key] = nil +end +exports.sessionStorageSetItem = function(key: string, value: any): () + sessionStorage[key] = value +end + +return exports diff --git a/modules/react-devtools-shared/src/types.lua b/modules/react-devtools-shared/src/types.lua new file mode 100644 index 00000000..174c3e2c --- /dev/null +++ b/modules/react-devtools-shared/src/types.lua @@ -0,0 +1,88 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/types.js +-- /* +-- * 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. +-- */ + +type Array = { [number]: T } +type Function = (...any) -> any +local exports = {} + +-- WARNING +-- The values below are referenced by ComponentFilters (which are saved via localStorage). +-- Do not change them or it will break previously saved user customizations. +-- +-- If new element types are added, use new numbers rather than re-ordering existing ones. +-- Changing these types is also a backwards breaking change for the standalone shell, +-- since the frontend and backend must share the same values- +-- and the backend is embedded in certain environments (like React Native). + +export type Wall = { + listen: (Function) -> Function, + send: (string, any, Array) -> (), +} + +exports.ElementTypeClass = 1 +exports.ElementTypeContext = 2 +exports.ElementTypeFunction = 5 +exports.ElementTypeForwardRef = 6 +exports.ElementTypeHostComponent = 7 +exports.ElementTypeMemo = 8 +exports.ElementTypeOtherOrUnknown = 9 +exports.ElementTypeProfiler = 10 +exports.ElementTypeRoot = 11 +exports.ElementTypeSuspense = 12 +exports.ElementTypeSuspenseList = 13 + +-- Different types of elements displayed in the Elements tree. +-- These types may be used to visually distinguish types, +-- or to enable/disable certain functionality. +-- ROBLOX deviation: Luau doesn't support literals as types: 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 +export type ElementType = number + +-- WARNING +-- The values below are referenced by ComponentFilters (which are saved via localStorage). +-- Do not change them or it will break previously saved user customizations. +-- If new filter types are added, use new numbers rather than re-ordering existing ones. +exports.ComponentFilterElementType = 1 +exports.ComponentFilterDisplayName = 2 +exports.ComponentFilterLocation = 3 +exports.ComponentFilterHOC = 4 + +-- ROBLOX deviation: Luau doesn't support literals as types: 1 | 2 | 3 | 4 +export type ComponentFilterType = number + +-- Hide all elements of types in this Set. +-- We hide host components only by default. +export type ElementTypeComponentFilter = { + isEnabled: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 1 + type: number, + value: ElementType, +} + +-- Hide all elements with displayNames or paths matching one or more of the RegExps in this Set. +-- Path filters are only used when elements include debug source location. +export type RegExpComponentFilter = { + isEnabled: boolean, + isValid: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 2 | 3 + type: number, + value: string, +} + +export type BooleanComponentFilter = { + isEnabled: boolean, + isValid: boolean, + -- ROBLOX deviation: Luau doesn't support literals as types: 4 + type: number, +} + +export type ComponentFilter = + BooleanComponentFilter + | ElementTypeComponentFilter + | RegExpComponentFilter + +return exports diff --git a/modules/react-devtools-shared/src/utils.lua b/modules/react-devtools-shared/src/utils.lua new file mode 100644 index 00000000..bd4f1658 --- /dev/null +++ b/modules/react-devtools-shared/src/utils.lua @@ -0,0 +1,726 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/utils.js +-- /* +-- * 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. +-- */ + +local Workspace = script.Parent +local Packages = Workspace.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Object = LuauPolyfill.Object +local Number = LuauPolyfill.Number +local String = LuauPolyfill.String +type Map = { [K]: V } +type Function = (...any) -> any +type Object = { [string]: any } +type Array = { [number]: T } +local console = require(Packages.Shared).console +local JSON = game:GetService("HttpService") + +local exports = {} + +-- ROBLOX TODO: pull in smarter cache when there's a performance reason to do so +-- local LRU = require() +-- ROBLOX deviation: pull in getComponentName for Lua-specific logic to extract component names +local Shared = require(Packages.Shared) +local getComponentName = Shared.getComponentName + +local ReactIs = require(Packages.ReactIs) +local isElement = ReactIs.isElement +local typeOf = ReactIs.typeOf +local ContextConsumer = ReactIs.ContextConsumer +local ContextProvider = ReactIs.ContextProvider +local ForwardRef = ReactIs.ForwardRef +local Fragment = ReactIs.Fragment +local Lazy = ReactIs.Lazy +local Memo = ReactIs.Memo +local Portal = ReactIs.Portal +local Profiler = ReactIs.Profiler +local StrictMode = ReactIs.StrictMode +local Suspense = ReactIs.Suspense +local ReactSymbols = require(Packages.Shared).ReactSymbols +local SuspenseList = ReactSymbols.REACT_SUSPENSE_LIST_TYPE +local constants = require(script.Parent.constants) +local TREE_OPERATION_ADD = constants.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constants.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constants.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = + constants.TREE_OPERATION_UPDATE_TREE_BASE_DURATION +local types = require(script.Parent.types) +local ElementTypeRoot = types.ElementTypeRoot +local LOCAL_STORAGE_FILTER_PREFERENCES_KEY = + constants.LOCAL_STORAGE_FILTER_PREFERENCES_KEY +local LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS = + constants.LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS +local LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = + constants.LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY +local ComponentFilterElementType = types.ComponentFilterElementType +local ElementTypeHostComponent = types.ElementTypeHostComponent +local ElementTypeClass = types.ElementTypeClass +local ElementTypeForwardRef = types.ElementTypeForwardRef +local ElementTypeFunction = types.ElementTypeFunction +local ElementTypeMemo = types.ElementTypeMemo +local storage = require(script.Parent.storage) +local localStorageGetItem = storage.localStorageGetItem +local localStorageSetItem = storage.localStorageSetItem +local hydration = require(script.Parent.hydration) +local meta = hydration.meta +type ComponentFilter = types.ComponentFilter +type ElementType = types.ElementType + +local cachedDisplayNames: Map = {} + +-- On large trees, encoding takes significant time. +-- Try to reuse the already encoded strings. +-- ROBLOX TODO: implement this when there's a performance issue in Studio tools driving it +-- local encodedStringCache = LRU({max = 1000}) + +exports.alphaSortKeys = function( + a: string | number, -- ROBLOX deviation: | Symbol, + b: string | number -- ROBLOX deviation: | Symbol, +): boolean + -- ROBLOX deviation: passed to table.sort(), which returns a bool + return tostring(a) > tostring(b) +end + +exports.getAllEnumerableKeys = function(obj: Object): Array -- | Symbol> + -- ROBLOX TODO: we probably need to enumerate inheritance chain metatables + return Object.keys(obj) +end + +exports.getDisplayName = function(type_: any, fallbackName: string): string + fallbackName = fallbackName or "Anonymous" + local nameFromCache = cachedDisplayNames[type_] + + if nameFromCache ~= nil then + return nameFromCache + end + + local displayName = fallbackName + + -- The displayName property is not guaranteed to be a string. + -- It's only safe to use for our purposes if it's a string. + -- github.com/facebook/react-devtools/issues/803 + if typeof(type_) == "table" and typeof(type_.displayName) == "string" then + displayName = type_.displayName + elseif + typeof(type_) == "table" + and typeof(type_.name) == "string" + and type_.name ~= "" + then + displayName = type_.name + -- ROBLOX deviation: use the Lua logic in getComponentName to extract names of function components + elseif typeof(type_) == "function" then + displayName = getComponentName(type_) or displayName + end + + cachedDisplayNames[type_] = displayName + + return displayName +end + +local uidCounter: number = 0 + +exports.getUID = function() + uidCounter += 1 + return uidCounter +end + +exports.utfDecodeString = function(str): string + -- ROBLOX FIXME? this appears to be done to make sure proper escaping happens, appears web-specific + return str +end +exports.utfEncodeString = function(str): string + -- ROBLOX FIXME? this appears to be done to make sure proper escaping happens, appears web-specific + return str +end + +exports.printOperationsArray = function(operations: Array) + -- The first two values are always rendererID and rootID + local rendererID = operations[1] + local rootID = operations[2] + local logs = { + ("operations for renderer:%s and root:%s"):format(rendererID, rootID), + } + + -- ROBLOX deviation: 1-indexing so start at 3 + local i = 3 + + -- ROBLOX deviation: use POSTFIX_INCREMENT instead of return i++ + local function POSTFIX_INCREMENT() + local tmp = i + i += 1 + return tmp + end + + -- Reassemble the string table. + local stringTable = { + -- ROBLOX deviation: we can't store nil, so use None placeholder + Object.None, -- ID = 0 corresponds to the null string. + } + local stringTableSize = operations[POSTFIX_INCREMENT()] + local stringTableEnd = i + stringTableSize + + -- ROBLOX deviation: adjust bounds due to 1-based indexing + while i <= stringTableEnd do + local nextLength = operations[POSTFIX_INCREMENT()] + local nextString = exports.utfDecodeString( + Array.slice(operations, i, i + nextLength) + ) + stringTable.push(nextString) + i += nextLength + end + + while i < #operations do + local operation = operations[i] + + if operation == TREE_OPERATION_ADD then + local id: number = operations[i + 1] + local type_: ElementType = operations[i + 2] + + i += 3 + + if type_ == ElementTypeRoot then + table.insert(logs, ("Add new root node %d"):format(id)) + + i += 1 -- supportsProfiling + i += 1 -- hasOwnerMetadata + else + local parentID: number = operations[i] + i += 1 + + i += 1 -- ownerID + + local displayNameStringID = operations[i] + local displayName = stringTable[displayNameStringID] + i += 1 + + i += 1 -- key + + table.insert( + logs, + ("Add node %d (%s) as child of %d"):format( + id, + displayName or "null", + parentID + ) + ) + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength: number = operations[i + 1] + i += 2 + + for removeIndex = 1, removeLength do + local id: number = operations[i] + i += 1 + + table.insert(logs, ("Remove node %d"):format(id)) + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + local id = operations[i + 1] + local numChildren = operations[i + 2] + i += 3 + local children = Array.slice(operations, i, i + numChildren) + i += numChildren + + table.insert( + logs, + ("Re-order node %s children %s"):format(id, Array.join(children, ",")) + ) + elseif operation == TREE_OPERATION_UPDATE_TREE_BASE_DURATION then + -- Base duration updates are only sent while profiling is in progress. + -- We can ignore them at this point. + -- The profiler UI uses them lazily in order to generate the tree. + i += 3 + else + error(("Unsupported Bridge operation %s"):format(operation)) + end + end + + console.log(logs.join("\n ")) +end + +exports.getDefaultComponentFilters = function(): Array + return { + { + type = ComponentFilterElementType, + value = ElementTypeHostComponent, + isEnabled = true, + }, + } +end +exports.getSavedComponentFilters = function(): Array + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if not ok then + return exports.getDefaultComponentFilters() + end + + return result +end +exports.saveComponentFilters = function(componentFilters: Array): () + localStorageSetItem( + LOCAL_STORAGE_FILTER_PREFERENCES_KEY, + JSON:JSONEncode(componentFilters) + ) +end +exports.getAppendComponentStack = function(): boolean + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if not ok then + return true + end + + return result +end +exports.setAppendComponentStack = function(value: boolean): () + localStorageSetItem(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, JSON:JSONEncode(value)) +end +exports.getBreakOnConsoleErrors = function() + local ok, result = pcall(function() + local raw = localStorageGetItem(LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS) + if raw ~= nil then + return JSON:JSONDecode(raw) + end + return nil + end) + if ok then + return result + end + return false +end + +exports.setBreakOnConsoleErrors = function(value: boolean): () + localStorageSetItem( + LOCAL_STORAGE_SHOULD_BREAK_ON_CONSOLE_ERRORS, + JSON:JSONEncode(value) + ) +end +exports.separateDisplayNameAndHOCs = function( + displayName: string | nil, + type_: ElementType +): any -- ROBLOX TODO: can Luau express this? [string | null, Array | null] + if displayName == nil then + return { nil, nil } + end + + local hocDisplayNames = nil + + if + type_ == ElementTypeClass + or type_ == ElementTypeForwardRef + or type_ == ElementTypeFunction + or type_ == ElementTypeMemo + then + if String.indexOf(displayName, "(") >= 1 then + -- ROBLOX deviation: use gmatch instead of /[^()]+/g + local matches = (displayName :: string):gmatch("[^()]+") + local nextMatch = matches() + if nextMatch then + displayName = nextMatch + -- deviation: loop through matches to populate array + hocDisplayNames = {} + while nextMatch ~= nil do + nextMatch = matches() + table.insert(hocDisplayNames, nextMatch) + end + end + end + end + + if type_ == ElementTypeMemo then + if hocDisplayNames == nil then + hocDisplayNames = { "Memo" } + else + Array.unshift(hocDisplayNames, "Memo") + end + elseif type_ == ElementTypeForwardRef then + if hocDisplayNames == nil then + hocDisplayNames = { "ForwardRef" } + else + Array.unshift(hocDisplayNames, "ForwardRef") + end + end + return { displayName, hocDisplayNames } +end + +-- Pulled from preact-compat +-- https://github.com/developit/preact-compat/blob/7c5de00e7c85e2ffd011bf3af02899b63f699d3a/src/index.js#L349 +exports.shallowDiffers = function(prev: Object, next_: Object): boolean + for key, value in pairs(prev) do + if next_[key] ~= value then + return true + end + end + return false +end + +exports.getInObject = function(object: Object, path: Array) + return Array.reduce(path, function(reduced: Object, attr) + if reduced then + if reduced[attr] ~= nil then + return reduced[attr] + end + -- ROBLOX deviation: no iterators in Symbol polyfill + -- if typeof(reduced[Symbol.iterator]) == "function" then + -- return Array.from(reduced)[attr] + -- end + end + + return nil + end, object) +end +exports.deletePathInObject = function(object: Object, path: Array) + local length = #path + local last = path[length] + + if object ~= nil then + local parent = exports.getInObject(object, Array.slice(path, 0, length)) + + if parent then + if Array.isArray(parent) then + Array.splice(parent, last, 1) + else + parent[last] = nil + end + end + end +end +exports.renamePathInObject = + function(object: Object, oldPath: Array, newPath: Array) + local length = #oldPath + + if object ~= nil then + local parent = exports.getInObject(object, Array.slice(oldPath, 1, length)) + + if parent then + local lastOld = oldPath[length] + local lastNew = newPath[length] + + parent[lastNew] = parent[lastOld] + + if Array.isArray(parent) then + Array.splice(parent, lastOld, 1) + else + parent[lastOld] = nil + end + end + end + end +exports.setInObject = function(object: Object, path: Array, value) + local length = #path + local last = path[length] + + if object ~= nil then + local parent = exports.getInObject(object, Array.slice(path, 1, length)) + + if parent then + parent[last] = value + end + end +end + +-- ROBLOX deviation: Luau can't express enumeration of literals +-- export type DataType = +-- | 'array' +-- | 'array_buffer' +-- | 'bigint' +-- | 'boolean' +-- | 'data_view' +-- | 'date' +-- | 'function' +-- | 'html_all_collection' +-- | 'html_element' +-- | 'infinity' +-- | 'iterator' +-- | 'opaque_iterator' +-- | 'nan' +-- | 'null' +-- | 'number' +-- | 'object' +-- | 'react_element' +-- | 'regexp' +-- | 'string' +-- | 'symbol' +-- | 'typed_array' +-- | 'undefined' +-- | 'unknown'; +export type DataType = string + +-- /** +-- * Get a enhanced/artificial type string based on the object instance +-- */ +exports.getDataType = function(data: Object): DataType + if data == nil then + return "null" + -- ROBLOX deviation: no undefined in Lua + -- elseif data == nil then + -- return'undefined' + end + + if isElement(data) then + return "react_element" + end + + -- ROBLOX deviation: only applies to web + -- if (typeof HTMLElement !== 'undefined' && data instanceof HTMLElement) { + -- return 'html_element'; + -- } + + local type_ = typeof(data) + if type_ == "bigint" then + return "bigint" + elseif type_ == "boolean" then + return "boolean" + elseif type_ == "function" then + return "function" + elseif type_ == "number" then + if Number.isNaN(data) then + return "nan" + elseif not Number.isFinite(data) then + return "infinity" + else + return "number" + end + elseif type_ == "object" then + if Array.isArray(data) then + return "array" + + -- ROBLOX deviation: only applies to web + -- elseif ArrayBuffer.isView(data) then + -- return Object.hasOwnProperty(data.constructor, 'BYTES_PER_ELEMENT') + -- and 'typed_array' + -- or 'data_view' + -- elseif data.constructor and data.constructor.name == 'ArrayBuffer' then + -- HACK This ArrayBuffer check is gross is there a better way? + -- We could try to create a new DataView with the value. + -- If it doesn't error, we know it's an ArrayBuffer, + -- but this seems kind of awkward and expensive. + -- return 'array_buffer' + -- elseif typeof(data[Symbol.iterator]) == 'function' then + -- return data[Symbol.iterator]() == data + -- ? 'opaque_iterator' + -- : 'iterator' + -- elseif (data.constructor and data.constructor.name == 'RegExp'then + -- return 'regexp' + -- else + -- const toStringValue = Object.prototype.toString.call(data) + -- if (toStringValue == '[object Date]'then + -- return 'date' + -- elseif (toStringValue == '[object HTMLAllCollection]'then + -- return 'html_all_collection' + -- } + -- } + else + return "object" + end + elseif type_ == "string" then + return "string" + -- ROBLOX TODO? detect our Symbol polyfill here? + -- elseif type_ == 'symbol' then + -- return 'symbol' + elseif type_ == "nil" then + -- ROBLOX deviation: skip web-specific stuff + -- if ( + -- Object.prototype.toString.call(data) == '[object HTMLAllCollection]' + -- then + -- return 'html_all_collection' + -- } + return "nil" + else + return "unknown" + end +end + +exports.getDisplayNameForReactElement = function(element): string | nil + local elementType = typeOf(element) + if elementType == ContextConsumer then + return "ContextConsumer" + elseif elementType == ContextProvider then + return "ContextProvider" + elseif elementType == ForwardRef then + return "ForwardRef" + elseif elementType == Fragment then + return "Fragment" + elseif elementType == Lazy then + return "Lazy" + elseif elementType == Memo then + return "Memo" + elseif elementType == Portal then + return "Portal" + elseif elementType == Profiler then + return "Profiler" + elseif elementType == StrictMode then + return "StrictMode" + elseif elementType == Suspense then + return "Suspense" + elseif elementType == SuspenseList then + return "SuspenseList" + else + local type_ = element and element.type or nil + if typeof(type_) == "string" then + return type_ + elseif typeof(type_) == "function" then + return exports.getDisplayName(type_, "Anonymous") + elseif type_ ~= nil then + return "NotImplementedInDevtools" + else + return "Element" + end + end +end + +local MAX_PREVIEW_STRING_LENGTH = 50 + +local function truncateForDisplay(string_: string, length) + length = length or MAX_PREVIEW_STRING_LENGTH + + if string.len(string_) > length then + return string_:sub(1, length + 1) .. "…" + else + return string_ + end +end + +-- Attempts to mimic Chrome's inline preview for values. +-- For example, the following value... +-- { +-- foo: 123, +-- bar: "abc", +-- baz: [true, false], +-- qux: { ab: 1, cd: 2 } +-- }; +-- +-- Would show a preview of... +-- {foo: 123, bar: "abc", baz: Array(2), qux: {…}} +-- +-- And the following value... +-- [ +-- 123, +-- "abc", +-- [true, false], +-- { foo: 123, bar: "abc" } +-- ]; +-- +-- Would show a preview of... +-- [123, "abc", Array(2), {…}] + +function exports.formatDataForPreview(data, showFormattedValue: boolean): string + if data ~= nil and data[meta.type] ~= nil then + return (function() + if showFormattedValue then + return data[meta.preview_long] + end + return data[meta.preview_short] + end)() + end + + local type_ = exports.getDataType(data) + + if type_ == "html_element" then + return ("<%s />"):format(truncateForDisplay(data.tagName:lower())) + elseif type_ == "function" then + return truncateForDisplay(("ƒ %s() {}"):format((function() + if typeof(data.name) == "function" then + return "" + end + return data.name + end)())) + elseif type_ == "string" then + return ('"%s"'):format(data) + -- ROBLOX TODO? should we support our RegExp and Symbol polyfills here? + -- elseif type_ == 'bigint' then + -- elseif type_ == 'regexp' then + -- elseif type_ == 'symbol' then + elseif type_ == "react_element" then + return ("<%s />"):format( + truncateForDisplay(exports.getDisplayNameForReactElement(data) or "Unknown") + ) + -- elseif type_ == 'array_buffer' then + -- elseif type_ == 'data_view' then + elseif type_ == "array" then + if showFormattedValue then + local formatted = "" + for i = 1, #data do + if i > 1 then + formatted ..= ", " + end + formatted = formatted .. exports.formatDataForPreview(data[i], false) + if #formatted > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return ("[%s]"):format(truncateForDisplay(formatted)) + else + local length = (function() + if data[meta.size] ~= nil then + return data[meta.size] + end + return #data + end)() + return ("Array(%s)"):format(length) + end + -- deviation: don't implement web-specifics + -- elseif type_ == 'typed_array' then + -- elseif type_ == 'iterator' then + -- elseif type_ == 'opaque_iterator' then + -- ROBLOX TODO? should we support Luau's datetime object? + -- elseif type_ == 'date' then + elseif type_ == "object" then + if showFormattedValue then + local keys = exports.getAllEnumerableKeys(data) + table.sort(keys, exports.alphaSortKeys) + + local formatted = "" + for i = 1, #keys do + local key = keys[i] + if i > 1 then + formatted = formatted .. ", " + end + formatted = formatted + .. ("%s: %s"):format( + tostring(key), + exports.formatDataForPreview(data[key], false) + ) + if #formatted.length > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return ("{%s}"):format(truncateForDisplay(formatted)) + else + return "{…}" + end + elseif + type_ == "boolean" + or type_ == "number" + or type_ == "infinity" + or type_ == "nan" + or type_ == "null" + or type_ == "undefined" + then + return data + else + local ok, result = pcall(function() + return truncateForDisplay("" .. data) + end) + return ok and result or "unserializable" + end +end + +return exports From cefb96894c44db7c6a3100cebd1d756b8a6dfe58 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 8 Jul 2021 14:34:18 -0700 Subject: [PATCH 007/289] LUAFDN-293: Fix failing test, enable more (already passing) tests (#132) * Fix LUAFDN-293. Two issues, both purely in test: first, we weren't re=importing LuaPolyfill after we enabled fake timers, so fake timers were never installed properly. Second, fake timers needed to be rounded before comparing to avoid pitfalls of Lua float imprecision. * Update comments and type annotations while debugging some other skipped tests. --- .../src/ReactFiberHooks.new.lua | 9 +- .../ReactHooksWithNoopRenderer.spec.lua | 29 ++++-- .../ReactIncrementalReflection.spec.lua | 3 +- .../src/__tests__/ReactLazy-internal.spec.lua | 99 ++++++++++--------- .../__tests__/ReactSuspense-internal.spec.lua | 10 +- modules/roblox-jest/src/FakeTimers/init.lua | 7 +- 6 files changed, 91 insertions(+), 66 deletions(-) diff --git a/modules/react-reconciler/src/ReactFiberHooks.new.lua b/modules/react-reconciler/src/ReactFiberHooks.new.lua index 6ad0aa3c..968413b1 100644 --- a/modules/react-reconciler/src/ReactFiberHooks.new.lua +++ b/modules/react-reconciler/src/ReactFiberHooks.new.lua @@ -158,7 +158,7 @@ export type Hook = { memoizedState: any, baseState: any, baseQueue: Update?, - queue: UpdateQueue?, + queue: UpdateQueue, next: Hook?, } @@ -415,7 +415,7 @@ exports.bailoutHooks = function( end local _isUpdatingOpaqueValueInRenderPhase = false -exports.resetHooksAfterThrow = function() +exports.resetHooksAfterThrow = function(): () -- We can assume the previous dispatcher is always this one, since we set it -- at the beginning of the render phase and there's no re-entrancy. ReactCurrentDispatcher.current = exports.ContextOnlyDispatcher @@ -505,7 +505,7 @@ local function updateWorkInProgressHook(): Hook -- FIXME (roblox): type refinement -- local nextWorkInProgressHook: Hook? - local nextWorkInProgressHook + local nextWorkInProgressHook: Hook if workInProgressHook == nil then nextWorkInProgressHook = currentlyRenderingFiber.memoizedState else @@ -595,7 +595,6 @@ function mountReducer( pending = nil, dispatch = nil, lastRenderedReducer = reducer, - -- ROBLOX TODO: recast(initialState: any) lastRenderedState = initialState } @@ -2151,7 +2150,7 @@ if _G.__DEV__ then if not ok then error(result) end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream + -- ROBLOX deviation: Lua version of useState and useReducer return two items, not list like upstream return result, setResult end, -- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { diff --git a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua index aff7c1c9..3a8421fb 100644 --- a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua @@ -13,11 +13,11 @@ local Packages = script.Parent.Parent.Parent local React -local LuauPolyfill = require(Packages.LuauPolyfill) -local clearTimeout = LuauPolyfill.clearTimeout -local setTimeout = LuauPolyfill.setTimeout -local Array = LuauPolyfill.Array -local Promise = require(Packages.Promise) +local LuauPolyfill +local clearTimeout +local setTimeout +local Array +local Promise -- local textCache @@ -48,6 +48,11 @@ return function() beforeEach(function() RobloxJest.resetModules() RobloxJest.useFakeTimers() + LuauPolyfill = require(Packages.LuauPolyfill) + clearTimeout = LuauPolyfill.clearTimeout + setTimeout = LuauPolyfill.setTimeout + Array = LuauPolyfill.Array + Promise = require(Packages.Promise) React = require(Packages.React) ReactNoop = require(Packages.Dev.ReactNoopRenderer) @@ -3557,19 +3562,22 @@ return function() describe("useRef", function() -- ROBLOX TODO: clearTimeout: attempt to index number with userdata (LUAFDN-293) - xit("creates a ref object initialized with the provided value", function() + it("creates a ref object initialized with the provided value", function() local jest = RobloxJest - jest.useFakeTimers() local function useDebouncedCallback(callback, ms, inputs) local timeoutID = useRef(-1) useEffect(function() return function() - clearTimeout(timeoutID.current) + if typeof(timeoutID.current) == "table" then + clearTimeout(timeoutID.current) + end end end, {}) local debouncedCallback = useCallback(function(...) - clearTimeout(timeoutID.current) + if typeof(timeoutID.current) == "table" then + clearTimeout(timeoutID.current) + end timeoutID.current = setTimeout(callback, ms, ...) end, { callback, @@ -3603,8 +3611,11 @@ return function() ping(4) jest.advanceTimersByTime(20) + jestExpect(Scheduler).toHaveYielded({}) ping(5) + jestExpect(Scheduler).toHaveYielded({}) ping(6) + jestExpect(Scheduler).toHaveYielded({}) jest.advanceTimersByTime(80) jestExpect(Scheduler).toHaveYielded({}) diff --git a/modules/react-reconciler/src/__tests__/ReactIncrementalReflection.spec.lua b/modules/react-reconciler/src/__tests__/ReactIncrementalReflection.spec.lua index 7ac5b19b..8237d974 100644 --- a/modules/react-reconciler/src/__tests__/ReactIncrementalReflection.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactIncrementalReflection.spec.lua @@ -158,7 +158,7 @@ return function() }) jestExpect(instances[1]:_isMounted()).toBe(false) end) - -- ROBLOX TODO: 292: gets "componentDidMount", but not the inner span + -- ROBLOX TODO: 292: gets "componentDidMount", but not the inner span. maybe ReactFeatureFlags need alignment for tests? xit('finds no node before insertion and correct node before deletion', function() local classInstance = nil local function findInstance(inst) @@ -168,6 +168,7 @@ return function() local oldConsoleError = console.error console.error = nil local ok, result = pcall(function() + -- ROBLOX FIXME: always returns nil because subtreeFlags don't match upstream while finding fiber in getNearestMountedFiber return ReactNoop.findInstance(inst) end) console.error = oldConsoleError diff --git a/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua index 6c8fd06e..4e0e55cf 100644 --- a/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactLazy-internal.spec.lua @@ -10,10 +10,17 @@ local Packages = script.Parent.Parent.Parent local LuauPolyfill = require(Packages.LuauPolyfill) local Error = LuauPolyfill.Error local setTimeout = LuauPolyfill.setTimeout + local function normalizeCodeLocInfo(str) - return str and str.replace(function(m, name) - return'\n in ' .. name .. ' (at **)' - end) + if typeof(str) ~= "string" then + return str + end + + str = str:gsub("Check your code at .*:%d+", "Check your code at **") + -- ROBLOX deviation: In roblox/luau, we're using the stack frame from luau, + -- which looks like: + -- in Component (at ModulePath.FileName.lua:123) + return (str:gsub("\n in ([%w%-%._]+)[^\n]*", "\n in %1 (at **)")) end return function() @@ -1094,7 +1101,7 @@ return function() -- end) -- end)) - -- ROBLOX TODO: wrong stacktrace + -- ROBLOX TODO: wrong component stack, not resolving past Suspense/Lazy wrappers xit('includes lazy-loaded component in warning stack', function() local LazyFoo = lazy(function() Scheduler.unstable_yieldValue('Started loading') @@ -1116,17 +1123,14 @@ return function() fallback = React.createElement(Text, { text = 'Loading...', }), - }, React.createElement(LazyFoo, nil)), {unstable_isConcurrent = true}) + }, React.createElement(LazyFoo)), {unstable_isConcurrent = true}) - -- ROBLOX FIXME: in fakeImport, we call resolve() immediately instead of acting like a Promise - -- as such, the Suspense fallback ('Loading...') never gets rendered jestExpect(Scheduler).toFlushAndYield({ 'Started loading', 'Loading...', }) jestExpect(root).never.toMatchRenderedOutput(React.createElement('div', nil, 'AB')) - -- ROBLOX FIXME: delay by one frame is current best translation of `await Promise.resolve()` Promise.delay(0):await() jestExpect(function() @@ -1351,7 +1355,7 @@ return function() function ErrorBoundary:componentDidCatch(error_, errMessage) componentStackMessage = normalizeCodeLocInfo(errMessage.componentStack) - self.setState({error = error_}) + self:setState({error = error_}) end function ErrorBoundary:render() if self.state.error then @@ -1379,47 +1383,50 @@ return function() jestExpect(Scheduler).toFlushAndYield({}) jestExpect(componentStackMessage).toContain('in ResolvedText') end) - -- xit('should error with a component stack containing Lazy if unresolved', function() - -- local componentStackMessage - -- local LazyText = lazy(function() - -- return { - -- andThen = function(resolve, reject) - -- reject(Error('oh no')) - -- end, - -- } - -- end) - -- local ErrorBoundary = React.Component:extend("ErrorBoundary") + -- ROBLOX FIXME: missing stack frame that contains Lazy + xit('should error with a component stack containing Lazy if unresolved', function() + local componentStackMessage + local LazyText = lazy(function() + return { + andThen = function(resolve, reject) + reject(Error('oh no')) + end, + } + end) + local ErrorBoundary = React.Component:extend("ErrorBoundary") - -- function ErrorBoundary:init() - -- self.state = { - -- error = nil - -- } - -- end - -- function ErrorBoundary:componentDidCatch(error, errMessage) - -- componentStackMessage = normalizeCodeLocInfo(errMessage.componentStack) + function ErrorBoundary:init() + self.state = { + error_ = nil + } + end + function ErrorBoundary:componentDidCatch(error_, errMessage) + -- ROBLOX FIXME: componentStack is missing LazyText even before normalize + componentStackMessage = normalizeCodeLocInfo(errMessage.componentStack) - -- self.setState({error = error}) - -- end - -- function ErrorBoundary:render() - -- return(function() - -- if self.state.error then - -- return nil - -- end + self:setState({error_ = error_}) + end + function ErrorBoundary:render() + return(function() + if self.state.error_ then + return nil + end - -- return self.props.children - -- end)() - -- end + return self.props.children + end)() + end - -- ReactTestRenderer.create(React.createElement(ErrorBoundary, nil, React.createElement(Suspense, { - -- fallback = React.createElement(Text, { - -- text = 'Loading...', - -- }), - -- }, React.createElement(LazyText, { - -- text = 'Hi', - -- })))) - -- jestExpect(Scheduler).toHaveYielded({}) - -- jestExpect(componentStackMessage).toContain('in Lazy') - -- end) + ReactTestRenderer.create(React.createElement(ErrorBoundary, nil, React.createElement(Suspense, { + fallback = React.createElement(Text, { + text = 'Loading...', + }), + }, React.createElement(LazyText, { + text = 'Hi', + })))) + jestExpect(Scheduler).toHaveYielded({}) + -- ROBLOX TODO: only this final assert fails + jestExpect(componentStackMessage).toContain('in Lazy') + end) -- @gate enableLazyElements -- xit('mount and reorder lazy elements', function() diff --git a/modules/react-reconciler/src/__tests__/ReactSuspense-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactSuspense-internal.spec.lua index 05307d32..60a6efbc 100644 --- a/modules/react-reconciler/src/__tests__/ReactSuspense-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactSuspense-internal.spec.lua @@ -649,9 +649,10 @@ return function() }) jestExpect(root).toMatchRenderedOutput('A2B2C2') end) - xit('mounts a lazy class component in non-concurrent mode', function() + + it('mounts a lazy class component in non-concurrent mode', function() local fakeImport = function(result) - -- ROBLOX FIXME: delay(0) because resolved promises are andThen'd on the same tick cycle + -- ROBLOX deviation: delay(0) because resolved promises are andThen'd on the same tick cycle -- remove once addressed in polyfill return Promise.delay(0):andThen(function() return {default = result} @@ -687,8 +688,8 @@ return function() }) jestExpect(root).toMatchRenderedOutput('Loading...') - -- ROBLOX FIXME: LazyClass seemingly isn't a Promise? 'attempt to call a nil value' - LazyClass:await() + -- ROBLOX deviation: used to synchronize on the above Promise.delay() + Promise.delay(0):await() jestExpect(Scheduler).toFlushExpired({ 'Hi', @@ -696,6 +697,7 @@ return function() }) jestExpect(root).toMatchRenderedOutput('Hi') end) + it('only captures if `fallback` is defined', function() local root = ReactTestRenderer.create(React.createElement(Suspense, { fallback = React.createElement(Text, { diff --git a/modules/roblox-jest/src/FakeTimers/init.lua b/modules/roblox-jest/src/FakeTimers/init.lua index c3662062..b2de938a 100644 --- a/modules/roblox-jest/src/FakeTimers/init.lua +++ b/modules/roblox-jest/src/FakeTimers/init.lua @@ -52,6 +52,11 @@ local function mockTick(_) return now end +function round(number, decimals) + local power = 10 ^ decimals + return math.floor(number * power) / power +end + local function advanceTimersByTime(msToRun: number): () -- Only run a generous number of timers and then bail. -- This is just to help avoid recursive loops @@ -70,7 +75,7 @@ local function advanceTimersByTime(msToRun: number): () local nextTimerExpiry = timers[1].expiry - if now + secondsToRun < nextTimerExpiry then + if round(now + secondsToRun, 5) < round(nextTimerExpiry, 5) then -- There are no timers between now and the target we're running to, so -- adjust our time cursor and quit now += secondsToRun From d48bfe9d684ee965b1dae5db0094224b1095ea54 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 8 Jul 2021 17:52:49 -0700 Subject: [PATCH 008/289] Set version for initial release --- rotriever.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rotriever.toml b/rotriever.toml index 00153202..04f5688d 100644 --- a/rotriever.toml +++ b/rotriever.toml @@ -1,5 +1,5 @@ [workspace] -version = "1.0.0" +version = "17.0.1-preview.0" members = ["modules/*"] authors = ["Paul Doyle ", "Matt Hargett ", "Max Mines ", "Carlo Conte "] From 322de48c7918511cac7a95663add1a2e1471a480 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 8 Jul 2021 17:54:48 -0700 Subject: [PATCH 009/289] Tidy up dependency declaration format --- rotriever.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rotriever.toml b/rotriever.toml index 04f5688d..7c679df5 100644 --- a/rotriever.toml +++ b/rotriever.toml @@ -5,6 +5,6 @@ authors = ["Paul Doyle ", "Matt Hargett " [workspace.dependencies] Promise = "github.com/evaera/roblox-lua-promise@3.1.0" -Cryo = "github.com/roblox/cryo@roblox/cryo@1.0" -LuauPolyfill = "github.com/roblox/luau-polyfill@roblox/roblox-luau-polyfill@0.2.0" +Cryo = "github.com/roblox/cryo@1.0" +LuauPolyfill = "github.com/roblox/luau-polyfill@0.2.0" JestRoblox = "github.com/roblox/jest-roblox@=1.0.0" From 90481fb020ccbb3cdef5963fdff1303c327b6b32 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 8 Jul 2021 21:49:41 -0700 Subject: [PATCH 010/289] Fix component name in traces and reenable tests (#134) --- .../src/__tests__/ReactErrorBoundaries-internal.spec.lua | 6 +++--- .../src/__tests__/ReactHooksWithNoopRenderer.spec.lua | 9 +++------ .../__tests__/ReactElementValidator-internal.spec.lua | 5 +---- modules/shared/src/ReactComponentStackFrame.lua | 6 ++++-- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua index 62ddd8f5..1cbc92ad 100644 --- a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua @@ -2239,7 +2239,7 @@ return function() return self.props.children end - local Throws = function() + local function Throws() error("expected") end @@ -2250,9 +2250,9 @@ return function() jestExpect(function() root.render(React.createElement(InvalidErrorBoundary, nil, React.createElement(Throws))) end).toErrorDev({ - "Warning: The above error occurred in one of your React components:\ + "Warning: The above error occurred in the component:\ \ - in LoadedCode.RoactAlignment._Workspace.ReactReconciler.ReactReconciler.__tests__.ReactErrorBoundaries-internal.spec (at **)\ + in Throws (at **)\ in InvalidErrorBoundary (at **)\ \ React will try to recreate this component tree from scratch using the error boundary you provided, InvalidErrorBoundary.\ diff --git a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua index 3a8421fb..b1e99fb7 100644 --- a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua @@ -396,8 +396,7 @@ return function() jestExpect(firstUpdater).toEqual(secondUpdater) end) - -- ROBLOX FIXME: error message has incorrect component name "LoadedCode.RoactAlignment.Packages.Modules.React.ReactHooks" - xit("warns on set after unmount", function() + it("warns on set after unmount", function() local updateCount local function Counter(props, ref) _, updateCount = useState(0) @@ -421,8 +420,7 @@ return function() ) end) - -- ROBLOX FIXME: error message has incorrect component name "LoadedCode.RoactAlignment.Packages.Modules.React.ReactHooks" - xit("dedupes the warning by component name", function() + it("dedupes the warning by component name", function() local _useStateCount, updateCountA local function CounterA(props, ref) _useStateCount, updateCountA = useState(0) @@ -1637,8 +1635,7 @@ return function() end) end) - -- ROBLOX FIXME: error message has incorrect component name LoadedCode.RoactAlignment.Packages.Modules.React.ReactHooks - xit("warns if there are updates after pending passive unmount effects have been flushed", function() + it("warns if there are updates after pending passive unmount effects have been flushed", function() local updaterFunction local function Component() diff --git a/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua b/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua index c81cd6c5..f01a2cd2 100644 --- a/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua +++ b/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua @@ -117,8 +117,7 @@ return function() ) end) - -- ROBLOX FIXME: instead of component name "Parent", outputs "LoadedCode.RoactAlignment._Workspace.React.React.__tests__.ReactElementValidator-internal.spec" - xit("warns for keys with component stack info", function() + it("warns for keys with component stack info", function() local function Component() return React.createElement("div", nil, { React.createElement("div"), @@ -145,8 +144,6 @@ return function() "https://reactjs.org/link/warning-keys for more information.\n" .. " in div (at **)\n" .. " in Component (at **)\n" .. - -- ROBLOX FIXME: the stack is correct, EXCEPT for Parent, which shows up as - -- in LoadedCode.RoactAlignment.Packages.Modules.React.__tests__.ReactElementValidator-internal.spec (at **) " in Parent (at **)\n" .. " in GrandParent (at **)" ) diff --git a/modules/shared/src/ReactComponentStackFrame.lua b/modules/shared/src/ReactComponentStackFrame.lua index 29c67307..de16c5ab 100644 --- a/modules/shared/src/ReactComponentStackFrame.lua +++ b/modules/shared/src/ReactComponentStackFrame.lua @@ -116,6 +116,7 @@ local function describeNativeComponentFrame( -- This will change the theorical stack trace we want, because of -- the function where we call 'debug.traceback()', but the control -- stack will have the same added frame. + local traceback local _, sample = xpcall(function() if construct then -- deviation: since we can't have a meaningful stack trace when @@ -123,8 +124,9 @@ local function describeNativeComponentFrame( -- component definition), we skip this case. else local _, x = pcall(function() + traceback = debug.traceback() error({ - stack = debug.traceback(), + stack = traceback, }) end) control = x; @@ -134,7 +136,7 @@ local function describeNativeComponentFrame( end, function(message) return { message = message, - stack = debug.traceback(), + stack = traceback, } end) From 8306ce2b42a1a6822961564e941c4c6139087f92 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Fri, 9 Jul 2021 11:38:22 -0700 Subject: [PATCH 011/289] Update DEVIATIONS.md (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update deviations inventory to include the completed compatibility changes (thanks to @maxmin-es's excellent work 🎉) --- DEVIATIONS.md | 67 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/DEVIATIONS.md b/DEVIATIONS.md index 6759603a..5df019b0 100644 --- a/DEVIATIONS.md +++ b/DEVIATIONS.md @@ -5,16 +5,16 @@ Upstream naming and logic has some deviations and incompatibilities with existin #### Table of Contents * [Naming](#naming) * [Component Lifecycle](#component-lifecycle) ✔️ - * [Reserved Prop Keys: "ref"](#reserved-prop-keys-ref) - * [Reserved Prop Keys: "key"](#reserved-prop-keys-key) + * [Reserved Prop Keys: "ref"](#reserved-prop-keys-ref) 🔨 + * [Reserved Prop Keys: "key"](#reserved-prop-keys-key) 🔨 * [Reserved Prop Keys: "children"](#reserved-prop-keys-children) ✔️ * [Behavior](#behavior) - * [Old Context](#old-context-roact-only) - * [Context.Consumer Interface](#contextconsumer-interface) + * [Old Context](#old-context-roact-only) 🔨 + * [Context.Consumer Interface](#contextconsumer-interface) ✔️ * [createFragment](#createfragment) ✔️ * [Ref Forwarding](#ref-forwarding) * [Stable Keys](#stable-keys) ✔️ - * [Use of setState](#use-of-setstate) + * [Use of setState](#use-of-setstate) ✔️ * [Functional setState](#functional-setstate) * [Roact.Portal](#roactportal) ✔️ * [State Initialization](#state-initialization) @@ -200,14 +200,17 @@ We'll likely have to modernize all existing uses of `_context` to instead use th This is likely the biggest refactor effort that the Lua Apps adoption is contingent on. It also incurs some knock-on efforts on projects that the App depends upon, like [roact-rodux](https://github.com/roblox/roact-rodux/issues/26), which has some work completed, [but with unaddressed backwards compatibility problems](https://github.com/Roblox/roact-rodux/pull/38#issuecomment-644902307). ### Context.Consumer Interface -**Status:** ❔ Alignment Strategy TBD - -Stub: context consumer api doesn't match that of Roact's createContext context consumer - -#### In Production Code +**Status:** ✔️ Resolved +
+ Details + +The context consumer api doesn't match that of Roact's createContext context consumer. +* Roact's implementation accepts a single prop, which is a render functions `render(contextObject) -> ReactElement` +* React's implementation accepts no props, and a single child, which is a `render` function with the same signature as above -#### Proposed Alignment Strategy -We'll probably want to support both interfaces +#### Implemented Alignment Strategy +We've provided support for both interfaces. Resolution and more info at https://github.com/Roblox/roact-alignment/pull/119 +
### createFragment **Status:** ✔️ Resolved @@ -386,7 +389,9 @@ An implementation of this approach was merged in [#68](https://github.com/Roblox ### Use of setState -**Status:** ❔ Alignment Strategy TBD +**Status:** ✔️ Resolved +
+ Details In React, `setState` is not allowed inside a constructor. Instead, it is recommended to assign directly to `this.state` (more info in the [React documentation](https://reactjs.org/docs/react-component.html#constructor)) @@ -416,12 +421,13 @@ end #### In Production Code It's difficult to measure this without relying heavily on formatting, but it seems that ~90 component definitions in the Lua App repo, including dependencies, invoke `setState` inside of their `init` functions (equivalent to a class component constructor). -#### Proposed Alignment Strategy -We should continue to support calling `setState` in init, and ensure that its behavior is equivalent to assigning directly to state. +#### Implemented Alignment Strategy +We continue to support calling `setState` in init, and ensure that its behavior is equivalent to assigning directly to state. -This will maximize compatibility with existing Roact code, and does not risk incurring significant tech debt, as we anticipate that class components will become less ubiquitous as hooks begin to see adoption. +This maximizes compatibility with existing Roact code, and does not risk incurring significant tech debt, as we anticipate that class components will become less ubiquitous as hooks begin to see adoption. -This deviation may be non-trivial to implement, and might have some subtle and dangerous corner cases. We should apply thorough test cases to confirm that the behavior is as expected, which may mean adapting some of current Roact's tests. +Resolution and more info at https://github.com/Roblox/roact-alignment/pull/124 +
### Functional setState **Status:** ❔ Alignment Strategy TBD @@ -585,22 +591,31 @@ Upstream React introduces a number of incoming features. Some of these are alrea _(section needs filling in...)_ ### Hooks -... +[React Hooks](https://reactjs.org/docs/hooks-intro.html) + +Most hooks are ported and exposed (a few experimental and in-development ones are not yet available): https://github.com/Roblox/roact-alignment/blob/v17.0.1-preview.0/modules/react/src/React.lua#L35-L45 ### Memo -... +[React Memo](https://reactjs.org/docs/react-api.html#reactmemo) + +Memo is ported and exposed via `React.memo`. ### Lazy -... +[React Lazy](https://reactjs.org/docs/code-splitting.html#reactlazy) + +Lazy is ported and exposed via `React.lazy`. ### Suspense -... +[React Suspense](https://reactjs.org/docs/react-api.html#reactsuspense) + +Suspense is ported and exposed via `React.Suspense`. ### Error Boundaries -... +[React Error Boundaries](https://reactjs.org/docs/error-boundaries.html) -### DEV mode -... +Error boundaries are ported and exposed via the component lifecycle methods `getDerivedStateFromError` and `componentDidCatch`. -### Better Error Messages -... +### DEV mode +DEV Mode can be enabled by setting the `__DEV__` global to `true` before the initial require of any Roact package. You can accomplish this either by: +* In Roblox Studio, executing `_G.__DEV__ = true` at the entry point of your test or application (before requiring any React packages) +* In roblox-cli, including the argument `--lua.globals=__DEV__=true` when using the `run` command From 411e646570527b5034a6410e152fd688bcec7b2d Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Fri, 9 Jul 2021 12:32:40 -0700 Subject: [PATCH 012/289] Run StyLua on jest-react (#136) --- modules/jest-react/src/JestReact.lua | 218 +++++++++--------- .../jest-react/src/getJestMatchers.roblox.lua | 23 +- modules/jest-react/src/init.lua | 4 +- 3 files changed, 126 insertions(+), 119 deletions(-) diff --git a/modules/jest-react/src/JestReact.lua b/modules/jest-react/src/JestReact.lua index 11d9e4e0..5bf22a2e 100644 --- a/modules/jest-react/src/JestReact.lua +++ b/modules/jest-react/src/JestReact.lua @@ -13,7 +13,7 @@ local function captureAssertion(fn) -- Trick to use a TestEZ expectation matcher inside another Jest -- matcher. `fn` contains an assertion; if it throws, we capture the -- error and return it, so the stack trace presented to the user points - -- to the original assertion in the test file. + -- to the original assertion in the test file. local ok, result = pcall(fn) if not ok then @@ -34,123 +34,129 @@ local function captureAssertion(fn) end local function assertYieldsWereCleared(root) - local Scheduler = root._Scheduler - local actualYields = Scheduler.unstable_clearYields() - - invariant(#actualYields == 0, 'Log of yielded values is not empty. ' .. - 'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.') + local Scheduler = root._Scheduler + local actualYields = Scheduler.unstable_clearYields() + + invariant( + #actualYields == 0, + "Log of yielded values is not empty. " + .. "Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first." + ) end local jsonChildrenToJSXChildren local function jsonChildToJSXChild(jsonChild) - if jsonChild == nil or typeof(jsonChild) == 'string' then - return jsonChild - else - local jsxChildren = jsonChildrenToJSXChildren(jsonChild.children) - - return{ - ["$$typeof"] = REACT_ELEMENT_TYPE, - type = jsonChild.type, - key = nil, - ref = nil, - props = (function() - if jsxChildren == nil then - return jsonChild.props - end - return LuauPolyfill.Object.assign({children = jsxChildren},jsonChild.props) - end)(), - _owner = nil, - _store = (function() - if _G.__DEV__ then - return{} - end - - return nil - end)(), - } - end + if jsonChild == nil or typeof(jsonChild) == "string" then + return jsonChild + else + local jsxChildren = jsonChildrenToJSXChildren(jsonChild.children) + + return { + ["$$typeof"] = REACT_ELEMENT_TYPE, + type = jsonChild.type, + key = nil, + ref = nil, + props = (function() + if jsxChildren == nil then + return jsonChild.props + end + return LuauPolyfill.Object.assign( + { children = jsxChildren }, + jsonChild.props + ) + end)(), + _owner = nil, + _store = (function() + if _G.__DEV__ then + return {} + end + + return nil + end)(), + } + end end jsonChildrenToJSXChildren = function(jsonChildren) - if jsonChildren ~= nil then - if #jsonChildren == 1 then - return jsonChildToJSXChild(jsonChildren[1]) - elseif #jsonChildren > 1 then - local jsxChildren = {} - local allJSXChildrenAreStrings = true - local jsxChildrenString = '' - - for _, jsonChild in ipairs(jsonChildren) do - local jsxChild = jsonChildToJSXChild(jsonChild) - - table.insert(jsxChildren, jsxChild) - - if allJSXChildrenAreStrings then - if typeof(jsxChild) == 'string' then - jsxChildrenString = jsxChildrenString .. jsxChild - elseif jsxChild ~= nil then - allJSXChildrenAreStrings = false - end - end - end - - if allJSXChildrenAreStrings then - return jsxChildrenString - end - - return jsxChildren - end - end - - return nil + if jsonChildren ~= nil then + if #jsonChildren == 1 then + return jsonChildToJSXChild(jsonChildren[1]) + elseif #jsonChildren > 1 then + local jsxChildren = {} + local allJSXChildrenAreStrings = true + local jsxChildrenString = "" + + for _, jsonChild in ipairs(jsonChildren) do + local jsxChild = jsonChildToJSXChild(jsonChild) + + table.insert(jsxChildren, jsxChild) + + if allJSXChildrenAreStrings then + if typeof(jsxChild) == "string" then + jsxChildrenString = jsxChildrenString .. jsxChild + elseif jsxChild ~= nil then + allJSXChildrenAreStrings = false + end + end + end + + if allJSXChildrenAreStrings then + return jsxChildrenString + end + + return jsxChildren + end + end + + return nil end local function unstable_toMatchRenderedOutput(root, expectedJSX) - assertYieldsWereCleared(root) - - local actualJSON = root.toJSON() - local actualJSX - - if actualJSON == nil or typeof(actualJSON) == 'string' then - actualJSX = actualJSON - elseif LuauPolyfill.Array.isArray(actualJSON) then - if #actualJSON == 0 then - actualJSX = nil - elseif #actualJSON == 1 then - actualJSX = jsonChildToJSXChild(actualJSON[1]) - else - local actualJSXChildren = jsonChildrenToJSXChildren(actualJSON) - - if actualJSXChildren == nil or typeof(actualJSXChildren) == 'string' then - actualJSX = actualJSXChildren - else - actualJSX = { - ["$$typeof"] = REACT_ELEMENT_TYPE, - type = REACT_FRAGMENT_TYPE, - key = nil, - ref = nil, - props = {children = actualJSXChildren}, - _owner = nil, - _store = (function() - if _G.__DEV__ then - return{} - end - - return nil - end)(), - } - end - end - else - actualJSX = jsonChildToJSXChild(actualJSON) - end - - return captureAssertion(function() - jestExpect(actualJSX).toEqual(expectedJSX) - end) + assertYieldsWereCleared(root) + + local actualJSON = root.toJSON() + local actualJSX + + if actualJSON == nil or typeof(actualJSON) == "string" then + actualJSX = actualJSON + elseif LuauPolyfill.Array.isArray(actualJSON) then + if #actualJSON == 0 then + actualJSX = nil + elseif #actualJSON == 1 then + actualJSX = jsonChildToJSXChild(actualJSON[1]) + else + local actualJSXChildren = jsonChildrenToJSXChildren(actualJSON) + + if actualJSXChildren == nil or typeof(actualJSXChildren) == "string" then + actualJSX = actualJSXChildren + else + actualJSX = { + ["$$typeof"] = REACT_ELEMENT_TYPE, + type = REACT_FRAGMENT_TYPE, + key = nil, + ref = nil, + props = { children = actualJSXChildren }, + _owner = nil, + _store = (function() + if _G.__DEV__ then + return {} + end + + return nil + end)(), + } + end + end + else + actualJSX = jsonChildToJSXChild(actualJSON) + end + + return captureAssertion(function() + jestExpect(actualJSX).toEqual(expectedJSX) + end) end return { - unstable_toMatchRenderedOutput = unstable_toMatchRenderedOutput + unstable_toMatchRenderedOutput = unstable_toMatchRenderedOutput, } diff --git a/modules/jest-react/src/getJestMatchers.roblox.lua b/modules/jest-react/src/getJestMatchers.roblox.lua index 0d6ae558..763be11c 100644 --- a/modules/jest-react/src/getJestMatchers.roblox.lua +++ b/modules/jest-react/src/getJestMatchers.roblox.lua @@ -28,25 +28,26 @@ return function(jestExpect) local function assertYieldsWereCleared(scheduler) local actualYields = scheduler.unstable_clearYields() if #actualYields ~= 0 then - error("Log of yielded values is not empty. " .. - "Call expectToHaveYielded(scheduler, ...) first.", 3) + error( + "Log of yielded values is not empty. " + .. "Call expectToHaveYielded(scheduler, ...) first.", + 3 + ) end end local function expectToMatchRenderedOutput(_matcherContext, ReactNoop, expectedJSX) - if typeof(ReactNoop.getChildrenAsJSX) == 'function' then - local Scheduler = ReactNoop._Scheduler; - assertYieldsWereCleared(Scheduler); - return captureAssertion( - function() - jestExpect(ReactNoop.getChildrenAsJSX()).toEqual(expectedJSX); - end - ) + if typeof(ReactNoop.getChildrenAsJSX) == "function" then + local Scheduler = ReactNoop._Scheduler + assertYieldsWereCleared(Scheduler) + return captureAssertion(function() + jestExpect(ReactNoop.getChildrenAsJSX()).toEqual(expectedJSX) + end) end return JestReact.unstable_toMatchRenderedOutput(ReactNoop, expectedJSX) end return { - toMatchRenderedOutput = expectToMatchRenderedOutput + toMatchRenderedOutput = expectToMatchRenderedOutput, } end diff --git a/modules/jest-react/src/init.lua b/modules/jest-react/src/init.lua index ee810831..5e300083 100644 --- a/modules/jest-react/src/init.lua +++ b/modules/jest-react/src/init.lua @@ -1,4 +1,4 @@ -- ROBLOX FIXME: Clean up this package organization return { - getJestMatchers = require(script["getJestMatchers.roblox"]) -} \ No newline at end of file + getJestMatchers = require(script["getJestMatchers.roblox"]), +} From 4ff2d31a101c32224136e6d9d2ea9dfa4ca3260e Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Fri, 9 Jul 2021 12:38:10 -0700 Subject: [PATCH 013/289] Format react-cache (#137) --- .../__tests__/ReactCacheOld-internal.spec.lua | 287 ++++++++++-------- 1 file changed, 161 insertions(+), 126 deletions(-) diff --git a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua index bfab4e16..8d4dbd92 100644 --- a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua +++ b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua @@ -57,14 +57,20 @@ return function() listeners = { { resolve = resolve, reject = reject } } LuauPolyfill.setTimeout(function() if textResourceShouldFail then - Scheduler.unstable_yieldValue(string.format("Promise rejected [%s]", text)) + Scheduler.unstable_yieldValue( + string.format("Promise rejected [%s]", text) + ) status = "rejected" - value = LuauPolyfill.Error("Failed to load: " .. text) + value = LuauPolyfill.Error( + "Failed to load: " .. text + ) for _, listener in ipairs(listeners) do listener.reject(value) end else - Scheduler.unstable_yieldValue(string.format("Promise resolved [%s]", text)) + Scheduler.unstable_yieldValue( + string.format("Promise resolved [%s]", text) + ) status = "resolved" value = text for _, listener in ipairs(listeners) do @@ -73,7 +79,10 @@ return function() end end, ms) else - table.insert(listeners, { resolve = resolve, reject = reject }) + table.insert( + listeners, + { resolve = resolve, reject = reject } + ) end elseif status == "resolved" then resolve(value) @@ -163,57 +172,64 @@ return function() jestExpect(Scheduler).toHaveYielded({ "Error! [Hi]", "Error! [Hi]" }) end) - it("warns if non-primitive key is passed to a resource without a hash function", function() - local BadTextResource = createResource(function(input) - local text = input[1] - local ms = input[2] or 0 - return Promise.new(function(resolve, _reject) - setTimeout(function() - resolve(text) - end, ms) + it( + "warns if non-primitive key is passed to a resource without a hash function", + function() + local BadTextResource = createResource(function(input) + local text = input[1] + local ms = input[2] or 0 + return Promise.new(function(resolve, _reject) + setTimeout(function() + resolve(text) + end, ms) + end) end) - end) - local function App() - Scheduler.unstable_yieldValue("App") - return BadTextResource.read({ "Hi", 100 }) - end + local function App() + Scheduler.unstable_yieldValue("App") + return BadTextResource.read({ "Hi", 100 }) + end - ReactTestRenderer.create( - React.createElement( - Suspense, - { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(App) } - ), - { unstable_isConcurrent = true } - ) + ReactTestRenderer.create( + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { React.createElement(App) } + ), + { unstable_isConcurrent = true } + ) - if _G.__DEV__ then - jestExpect(function() + if _G.__DEV__ then + jestExpect(function() + jestExpect(Scheduler).toFlushAndYield({ "App", "Loading..." }) + end).toErrorDev( + "Warning: " -- ROBLOX FIXME: remove the Warning: prefix in consoleWithStackDev + .. "Invalid key type. Expected a string, number, symbol, or " + -- ROBLOX TODO: make console polyfill format arrays the same as JS + .. 'boolean, but instead received: { "Hi", 100 }\n\n' + .. "To use non-primitive values as keys, you must pass a hash " + .. "function as the second argument to createResource()." + ) + else jestExpect(Scheduler).toFlushAndYield({ "App", "Loading..." }) - end).toErrorDev( - "Warning: " -- ROBLOX FIXME: remove the Warning: prefix in consoleWithStackDev - .. "Invalid key type. Expected a string, number, symbol, or " - -- ROBLOX TODO: make console polyfill format arrays the same as JS - .. 'boolean, but instead received: { "Hi", 100 }\n\n' - .. "To use non-primitive values as keys, you must pass a hash " - .. "function as the second argument to createResource()." - ) - else - jestExpect(Scheduler).toFlushAndYield({ "App", "Loading..." }) + end end - end) + ) it("evicts least recently used values", function() ReactCache.unstable_setGlobalCacheLimit(3) -- Render 1, 2, and 3 local root = ReactTestRenderer.create( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { - React.createElement(AsyncText, { ms = 100, text = 1 }), - React.createElement(AsyncText, { ms = 100, text = 2 }), - React.createElement(AsyncText, { ms = 100, text = 3 }), - }), + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { + React.createElement(AsyncText, { ms = 100, text = 1 }), + React.createElement(AsyncText, { ms = 100, text = 2 }), + React.createElement(AsyncText, { ms = 100, text = 3 }), + } + ), { unstable_isConcurrent = true } ) jestExpect(Scheduler).toFlushAndYield({ @@ -233,11 +249,15 @@ return function() -- Render 1, 4, 5 root.update( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { - React.createElement(AsyncText, { ms = 100, text = 1 }), - React.createElement(AsyncText, { ms = 100, text = 4 }), - React.createElement(AsyncText, { ms = 100, text = 5 }), - }) + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { + React.createElement(AsyncText, { ms = 100, text = 1 }), + React.createElement(AsyncText, { ms = 100, text = 4 }), + React.createElement(AsyncText, { ms = 100, text = 5 }), + } + ) ) jestExpect(Scheduler).toFlushAndYield({ @@ -258,11 +278,15 @@ return function() -- recently used values are 2 and 3. They should have been evicted. root.update( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { - React.createElement(AsyncText, { ms = 100, text = 1 }), - React.createElement(AsyncText, { ms = 100, text = 2 }), - React.createElement(AsyncText, { ms = 100, text = 3 }), - }) + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { + React.createElement(AsyncText, { ms = 100, text = 1 }), + React.createElement(AsyncText, { ms = 100, text = 2 }), + React.createElement(AsyncText, { ms = 100, text = 3 }), + } + ) ) jestExpect(Scheduler).toFlushAndYield({ @@ -310,98 +334,109 @@ return function() jestExpect(root).toMatchRenderedOutput("Result") end) - it("if a thenable resolves multiple times, does not update the first cached value", function() - local resolveThenable - local BadTextResource = createResource(function(props) - local _text = props.text - local _ms = props.ms or 0 - local listeners = nil - local value = nil - return { - andThen = function(self, resolve, reject) - if value ~= nil then - resolve(value) - else - if listeners == nil then - listeners = { resolve } - resolveThenable = function(v) - for _, listener in pairs(listeners) do - listener(v) + it( + "if a thenable resolves multiple times, does not update the first cached value", + function() + local resolveThenable + local BadTextResource = createResource(function(props) + local _text = props.text + local _ms = props.ms or 0 + local listeners = nil + local value = nil + return { + andThen = function(self, resolve, reject) + if value ~= nil then + resolve(value) + else + if listeners == nil then + listeners = { resolve } + resolveThenable = function(v) + for _, listener in pairs(listeners) do + listener(v) + end end + else + table.insert(listeners, resolve) end - else - table.insert(listeners, resolve) end - end - end, - } - end, function(input) - return input[1] - end) - - local function BadAsyncText(props) - local text = props.text - local ok, result = pcall(function() - local actualText = BadTextResource.read({ props.text, props.ms }) - Scheduler.unstable_yieldValue(actualText) - return actualText + end, + } + end, function(input) + return input[1] end) - if not ok then - if typeof(result.andThen) == "function" then - Scheduler.unstable_yieldValue(string.format("Suspend! [%s]", text)) - else - Scheduler.unstable_yieldValue(string.format("Error! [%s]", text)) + local function BadAsyncText(props) + local text = props.text + local ok, result = pcall(function() + local actualText = BadTextResource.read({ props.text, props.ms }) + Scheduler.unstable_yieldValue(actualText) + return actualText + end) + + if not ok then + if typeof(result.andThen) == "function" then + Scheduler.unstable_yieldValue( + string.format("Suspend! [%s]", text) + ) + else + Scheduler.unstable_yieldValue( + string.format("Error! [%s]", text) + ) + end + error(result) end - error(result) + return result end - return result - end - local root = ReactTestRenderer.create( - React.createElement( - Suspense, - { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(BadAsyncText, { text = "Hi" }) } - ), - { - unstable_isConcurrent = true, - } - ) - - jestExpect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) - - resolveThenable("Hi") - -- This thenable improperly resolves twice. We should not update the - -- cached value. - resolveThenable("Hi muahahaha I am different") + local root = ReactTestRenderer.create( + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { React.createElement(BadAsyncText, { text = "Hi" }) } + ), + { + unstable_isConcurrent = true, + } + ) - root.update( - React.createElement( - Suspense, - { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(BadAsyncText, { text = "Hi" }) } - ), - { - unstable_isConcurrent = true, - } - ) + jestExpect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) + + resolveThenable("Hi") + -- This thenable improperly resolves twice. We should not update the + -- cached value. + resolveThenable("Hi muahahaha I am different") + + root.update( + React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + { React.createElement(BadAsyncText, { text = "Hi" }) } + ), + { + unstable_isConcurrent = true, + } + ) - jestExpect(Scheduler).toHaveYielded({}) - jestExpect(Scheduler).toFlushAndYield({ "Hi" }) - jestExpect(root).toMatchRenderedOutput("Hi") - end) + jestExpect(Scheduler).toHaveYielded({}) + jestExpect(Scheduler).toFlushAndYield({ "Hi" }) + jestExpect(root).toMatchRenderedOutput("Hi") + end + ) it("throws if read is called outside render", function() jestExpect(function() TextResource.read({ "A", 1000 }) - end).toThrow("read and preload may only be called from within a component's render") + end).toThrow( + "read and preload may only be called from within a component's render" + ) end) it("throws if preload is called outside render", function() jestExpect(function() TextResource.preload({ "A", 1000 }) - end).toThrow("read and preload may only be called from within a component's render") + end).toThrow( + "read and preload may only be called from within a component's render" + ) end) end) end From 028666e007c1c6bfb274e985c776d045c884433d Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Sat, 10 Jul 2021 09:46:05 -0700 Subject: [PATCH 014/289] Run stylua on remaining small dirs (#138) --- .../react-is/src/__tests__/ReactIs.spec.lua | 97 +- modules/react-is/src/init.lua | 43 +- .../src/createReactNoop.lua | 227 +-- .../__tests__/ReactShallowRenderer.spec.lua | 743 ++++---- .../ReactShallowRendererHooks.spec.lua | 119 +- modules/react-shallow-renderer/src/init.lua | 1564 +++++++++-------- 6 files changed, 1507 insertions(+), 1286 deletions(-) diff --git a/modules/react-is/src/__tests__/ReactIs.spec.lua b/modules/react-is/src/__tests__/ReactIs.spec.lua index 542ca4c0..21fb4787 100644 --- a/modules/react-is/src/__tests__/ReactIs.spec.lua +++ b/modules/react-is/src/__tests__/ReactIs.spec.lua @@ -15,7 +15,7 @@ return function() beforeEach(function() RobloxJest.resetModules() ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false React = require(Packages.Dev.React) ReactIs = require(Packages.ReactIs) @@ -88,24 +88,38 @@ return function() jestExpect(ReactIs.isValidElementType(nil)).toEqual(false) -- ROBLOX deviation: no difference between nil and undefined in Lua -- jestExpect(ReactIs.isValidElementType(undefined)).toEqual(false) - jestExpect(ReactIs.isValidElementType({ type = "TextLabel", props = {} })).toEqual(false) + jestExpect(ReactIs.isValidElementType({ type = "TextLabel", props = {} })).toEqual( + false + ) end) it("should identify context consumers", function() local Context = React.createContext(false) jestExpect(ReactIs.isValidElementType(Context.Consumer)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(Context.Consumer))).toBe(ReactIs.ContextConsumer) - jestExpect(ReactIs.isContextConsumer(React.createElement(Context.Consumer))).toBe(true) - jestExpect(ReactIs.isContextConsumer(React.createElement(Context.Provider))).toBe(false) + jestExpect(ReactIs.typeOf(React.createElement(Context.Consumer))).toBe( + ReactIs.ContextConsumer + ) + jestExpect(ReactIs.isContextConsumer(React.createElement(Context.Consumer))).toBe( + true + ) + jestExpect(ReactIs.isContextConsumer(React.createElement(Context.Provider))).toBe( + false + ) jestExpect(ReactIs.isContextConsumer(React.createElement("div"))).toBe(false) end) it("should identify context providers", function() local Context = React.createContext(false) jestExpect(ReactIs.isValidElementType(Context.Provider)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(Context.Provider))).toBe(ReactIs.ContextProvider) - jestExpect(ReactIs.isContextProvider(React.createElement(Context.Provider))).toBe(true) - jestExpect(ReactIs.isContextProvider(React.createElement(Context.Consumer))).toBe(false) + jestExpect(ReactIs.typeOf(React.createElement(Context.Provider))).toBe( + ReactIs.ContextProvider + ) + jestExpect(ReactIs.isContextProvider(React.createElement(Context.Provider))).toBe( + true + ) + jestExpect(ReactIs.isContextProvider(React.createElement(Context.Consumer))).toBe( + false + ) jestExpect(ReactIs.isContextProvider(React.createElement("div"))).toBe(false) end) @@ -122,10 +136,16 @@ return function() -- It should also identify more specific types as elements local Context = React.createContext(false) - jestExpect(ReactIs.isElement(React.createElement(Context.Provider))).toBe(true) - jestExpect(ReactIs.isElement(React.createElement(Context.Consumer))).toBe(true) + jestExpect(ReactIs.isElement(React.createElement(Context.Provider))).toBe( + true + ) + jestExpect(ReactIs.isElement(React.createElement(Context.Consumer))).toBe( + true + ) jestExpect(ReactIs.isElement(React.createElement(React.Fragment))).toBe(true) - jestExpect(ReactIs.isElement(React.createElement(React.StrictMode))).toBe(true) + jestExpect(ReactIs.isElement(React.createElement(React.StrictMode))).toBe( + true + ) jestExpect(ReactIs.isElement(React.createElement(React.Suspense))).toBe(true) end) @@ -134,15 +154,21 @@ return function() return nil end) jestExpect(ReactIs.isValidElementType(RefForwardingComponent)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(RefForwardingComponent))).toBe(ReactIs.ForwardRef) - jestExpect(ReactIs.isForwardRef(React.createElement(RefForwardingComponent))).toBe(true) + jestExpect(ReactIs.typeOf(React.createElement(RefForwardingComponent))).toBe( + ReactIs.ForwardRef + ) + jestExpect(ReactIs.isForwardRef(React.createElement(RefForwardingComponent))).toBe( + true + ) jestExpect(ReactIs.isForwardRef({ type = ReactIs.StrictMode })).toBe(false) jestExpect(ReactIs.isForwardRef(React.createElement("div"))).toBe(false) end) it("should identify fragments", function() jestExpect(ReactIs.isValidElementType(React.Fragment)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(React.Fragment))).toBe(ReactIs.Fragment) + jestExpect(ReactIs.typeOf(React.createElement(React.Fragment))).toBe( + ReactIs.Fragment + ) jestExpect(ReactIs.isFragment(React.createElement(React.Fragment))).toBe(true) jestExpect(ReactIs.isFragment({ type = ReactIs.Fragment })).toBe(false) jestExpect(ReactIs.isFragment("React.Fragment")).toBe(false) @@ -152,7 +178,10 @@ return function() it("should identify portals", function() local ScreenGui = Instance.new("ScreenGui") - local portal = ReactRoblox.createPortal(React.createElement("Frame"), ScreenGui) + local portal = ReactRoblox.createPortal( + React.createElement("Frame"), + ScreenGui + ) jestExpect(ReactIs.isValidElementType(portal)).toBe(false) jestExpect(ReactIs.typeOf(portal)).toBe(ReactIs.Portal) jestExpect(ReactIs.isPortal(portal)).toBe(true) @@ -178,22 +207,30 @@ return function() return Component end) jestExpect(ReactIs.isValidElementType(LazyComponent)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(LazyComponent))).toBe(ReactIs.Lazy) + jestExpect(ReactIs.typeOf(React.createElement(LazyComponent))).toBe( + ReactIs.Lazy + ) jestExpect(ReactIs.isLazy(React.createElement(LazyComponent))).toBe(true) jestExpect(ReactIs.isLazy(React.createElement(Component))).toBe(false) end) it("should identify strict mode", function() jestExpect(ReactIs.isValidElementType(React.StrictMode)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(React.StrictMode))).toBe(ReactIs.StrictMode) - jestExpect(ReactIs.isStrictMode(React.createElement(React.StrictMode))).toBe(true) + jestExpect(ReactIs.typeOf(React.createElement(React.StrictMode))).toBe( + ReactIs.StrictMode + ) + jestExpect(ReactIs.isStrictMode(React.createElement(React.StrictMode))).toBe( + true + ) jestExpect(ReactIs.isStrictMode({ type = ReactIs.StrictMode })).toBe(false) jestExpect(ReactIs.isStrictMode(React.createElement("div"))).toBe(false) end) it("should identify suspense", function() jestExpect(ReactIs.isValidElementType(React.Suspense)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(React.Suspense))).toBe(ReactIs.Suspense) + jestExpect(ReactIs.typeOf(React.createElement(React.Suspense))).toBe( + ReactIs.Suspense + ) jestExpect(ReactIs.isSuspense(React.createElement(React.Suspense))).toBe(true) jestExpect(ReactIs.isSuspense({ type = ReactIs.Suspense })).toBe(false) jestExpect(ReactIs.isSuspense("React.Suspense")).toBe(false) @@ -202,8 +239,22 @@ return function() it("should identify profile root", function() jestExpect(ReactIs.isValidElementType(React.Profiler)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(React.Profiler, { id = "foo", onRender = jest:fn() }))).toBe(ReactIs.Profiler) - jestExpect(ReactIs.isProfiler(React.createElement(React.Profiler, { id = "foo", onRender = jest:fn() }))).toBe(true) + jestExpect( + ReactIs.typeOf( + React.createElement( + React.Profiler, + { id = "foo", onRender = jest:fn() } + ) + ) + ).toBe(ReactIs.Profiler) + jestExpect( + ReactIs.isProfiler( + React.createElement( + React.Profiler, + { id = "foo", onRender = jest:fn() } + ) + ) + ).toBe(true) jestExpect(ReactIs.isProfiler({ type = ReactIs.Profiler })).toBe(false) jestExpect(ReactIs.isProfiler(React.createElement("div"))).toBe(false) end) @@ -212,11 +263,11 @@ return function() it("should warn for deprecated functions", function() jestExpect(function() ReactIs.isConcurrentMode(nil) - end).toWarnDev('deprecated', {withoutStack = true}) + end).toWarnDev("deprecated", { withoutStack = true }) jestExpect(function() ReactIs.isAsyncMode(nil) - end).toWarnDev('deprecated', {withoutStack = true}) + end).toWarnDev("deprecated", { withoutStack = true }) end) end) end diff --git a/modules/react-is/src/init.lua b/modules/react-is/src/init.lua index d95b85ee..45e6874c 100644 --- a/modules/react-is/src/init.lua +++ b/modules/react-is/src/init.lua @@ -17,31 +17,33 @@ local isValidElementType = require(Packages.Shared).isValidElementType local exports = {} exports.typeOf = function(object) - if typeof(object) == 'table' and object ~= nil then - local __typeof = object['$$typeof'] + if typeof(object) == "table" and object ~= nil then + local __typeof = object["$$typeof"] if __typeof == ReactSymbols.REACT_ELEMENT_TYPE then local __type = object.type if - __type == ReactSymbols.REACT_FRAGMENT_TYPE or - __type == ReactSymbols.REACT_PROFILER_TYPE or - __type == ReactSymbols.REACT_STRICT_MODE_TYPE or - __type == ReactSymbols.REACT_SUSPENSE_TYPE or - __type == ReactSymbols.REACT_SUSPENSE_LIST_TYPE + __type == ReactSymbols.REACT_FRAGMENT_TYPE + or __type == ReactSymbols.REACT_PROFILER_TYPE + or __type == ReactSymbols.REACT_STRICT_MODE_TYPE + or __type == ReactSymbols.REACT_SUSPENSE_TYPE + or __type == ReactSymbols.REACT_SUSPENSE_LIST_TYPE then return __type else -- deviation: We need to check that __type is a table before we -- index into it, or Luau will throw errors - local __typeofType = __type and typeof(__type) == "table" and __type['$$typeof'] + local __typeofType = __type + and typeof(__type) == "table" + and __type["$$typeof"] if - __typeofType == ReactSymbols.REACT_CONTEXT_TYPE or - __typeofType == ReactSymbols.REACT_FORWARD_REF_TYPE or - __typeofType == ReactSymbols.REACT_LAZY_TYPE or - __typeofType == ReactSymbols.REACT_MEMO_TYPE or - __typeofType == ReactSymbols.REACT_PROVIDER_TYPE + __typeofType == ReactSymbols.REACT_CONTEXT_TYPE + or __typeofType == ReactSymbols.REACT_FORWARD_REF_TYPE + or __typeofType == ReactSymbols.REACT_LAZY_TYPE + or __typeofType == ReactSymbols.REACT_MEMO_TYPE + or __typeofType == ReactSymbols.REACT_PROVIDER_TYPE then return __typeofType else @@ -76,7 +78,10 @@ exports.isAsyncMode = function(object) hasWarnedAboutDeprecatedIsAsyncMode = true -- Using console['warn'] to evade Babel and ESLint - console['warn']('The ReactIs.isAsyncMode() alias has been deprecated, ' .. 'and will be removed in React 18+.') + console["warn"]( + "The ReactIs.isAsyncMode() alias has been deprecated, " + .. "and will be removed in React 18+." + ) end end @@ -89,7 +94,10 @@ exports.isConcurrentMode = function(object) hasWarnedAboutDeprecatedIsConcurrentMode = true -- Using console['warn'] to evade Babel and ESLint - console['warn']('The ReactIs.isConcurrentMode() alias has been deprecated, ' .. 'and will be removed in React 18+.') + console["warn"]( + "The ReactIs.isConcurrentMode() alias has been deprecated, " + .. "and will be removed in React 18+." + ) end end @@ -103,7 +111,10 @@ exports.isContextProvider = function(object) return exports.typeOf(object) == ReactSymbols.REACT_PROVIDER_TYPE end exports.isElement = function(object) - return ((typeof(object) == 'table' and object ~= nil) and object['$$typeof'] == ReactSymbols.REACT_ELEMENT_TYPE) + return ( + (typeof(object) == "table" and object ~= nil) + and object["$$typeof"] == ReactSymbols.REACT_ELEMENT_TYPE + ) end exports.isForwardRef = function(object) return exports.typeOf(object) == ReactSymbols.REACT_FORWARD_REF_TYPE diff --git a/modules/react-noop-renderer/src/createReactNoop.lua b/modules/react-noop-renderer/src/createReactNoop.lua index 270afd02..e6f2cd92 100644 --- a/modules/react-noop-renderer/src/createReactNoop.lua +++ b/modules/react-noop-renderer/src/createReactNoop.lua @@ -46,16 +46,16 @@ local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals local enqueueTask = require(Packages.Shared).enqueueTask local IsSomeRendererActing = ReactSharedInternals.IsSomeRendererActing -type Object = { [string]: any }; -type Array = { [number]: T }; +type Object = { [string]: any } +type Array = { [number]: T } -type HostContext = Object; +type HostContext = Object type Container = { rootID: string, children: Array, pendingChildren: Array, -- ... -}; +} type Props = { prop: any, hidden: boolean, @@ -65,7 +65,7 @@ type Props = { right: number?, top: number?, -- ... -}; +} type Instance = { type: string, id: number, @@ -74,13 +74,13 @@ type Instance = { prop: any, hidden: boolean, context: HostContext, -}; +} type TextInstance = { text: string, id: number, hidden: boolean, context: HostContext, -}; +} local NO_CONTEXT = {} local UPPERCASE_CONTEXT = {} @@ -117,9 +117,7 @@ local function createReactNoop(reconciler, useMutation: boolean) if typeof(parentInstance.rootID) ~= "string" then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. - error(Error( - "appendChildToContainer() first argument is not a container." - )) + error(Error("appendChildToContainer() first argument is not a container.")) end appendChildToContainerOrInstance(parentInstance, child) end @@ -161,9 +159,7 @@ local function createReactNoop(reconciler, useMutation: boolean) if typeof(parentInstance.rootID) ~= "string" then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. - error(Error( - "insertInContainerBefore() first argument is not a container." - )) + error(Error("insertInContainerBefore() first argument is not a container.")) end insertInContainerOrInstanceBefore(parentInstance, child, beforeChild) end @@ -201,20 +197,18 @@ local function createReactNoop(reconciler, useMutation: boolean) parentInstance: Container, child: Instance | TextInstance ) - if typeof(parentInstance) == "table" and typeof(parentInstance.rootID) ~= "string" then + if + typeof(parentInstance) == "table" + and typeof(parentInstance.rootID) ~= "string" + then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. - error(Error( - "removeChildFromContainer() first argument is not a container." - )) + error(Error("removeChildFromContainer() first argument is not a container.")) end removeChildFromContainerOrInstance(parentInstance, child) end - local function removeChild( - parentInstance: Instance, - child: Instance | TextInstance - ) + local function removeChild(parentInstance: Instance, child: Instance | TextInstance) local parentInstance: any = parentInstance if typeof(parentInstance.rootID) == "string" then -- Some calls to this aren't typesafe. @@ -255,11 +249,12 @@ local function createReactNoop(reconciler, useMutation: boolean) -- text: shouldSetTextContent(type, newProps) -- ? computeText((newProps.children: any) + '', instance.context) -- : null, - text = shouldSetTextContent(type, newProps) - and computeText(tostring(newProps.children), instance.context) - or nil, + text = shouldSetTextContent(type, newProps) and computeText( + tostring(newProps.children), + instance.context + ) or nil, context = instance.context, - } + }, }) hostCloneCounter += 1 return clone @@ -281,11 +276,7 @@ local function createReactNoop(reconciler, useMutation: boolean) return NO_CONTEXT end, - getChildHostContext = function( - parentHostContext: HostContext, - type: string, - rootcontainerInstance: Container - ) + getChildHostContext = function(parentHostContext: HostContext, type: string, rootcontainerInstance: Container) if type == "uppercase" then return UPPERCASE_CONTEXT end @@ -320,37 +311,26 @@ local function createReactNoop(reconciler, useMutation: boolean) -- text: shouldSetTextContent(type, props) -- ? computeText((props.children: any) + '', hostContext) -- : null, - text = shouldSetTextContent(type, props) - and computeText(tostring(props.children), hostContext) - or nil, + text = shouldSetTextContent(type, props) and computeText( + tostring(props.children), + hostContext + ) or nil, context = hostContext, - } + }, }) instanceCounter += 1 return inst end, - appendInitialChild = function( - parentInstance: Instance, - child: Instance | TextInstance - ) + appendInitialChild = function(parentInstance: Instance, child: Instance | TextInstance) table.insert(parentInstance.children, child) end, - finalizeInitialChildren = function( - _domElement: Instance, - _type: string, - _props: Props - ): boolean + finalizeInitialChildren = function(_domElement: Instance, _type: string, _props: Props): boolean return false end, - prepareUpdate = function( - instanceH: Instance, - type: string, - oldProps: Props, - newProps: Props - ): Object? + prepareUpdate = function(instanceH: Instance, type: string, oldProps: Props, newProps: Props): Object? if type == "errorInCompletePhase" then error(Error("Error in host config.")) end @@ -386,7 +366,7 @@ local function createReactNoop(reconciler, useMutation: boolean) __index = { id = instanceCounter, context = hostContext, - } + }, }) instanceCounter += 1 return inst @@ -533,11 +513,7 @@ local function createReactNoop(reconciler, useMutation: boolean) end end, - commitTextUpdate = function( - textInstance: TextInstance, - oldText: string, - newText: string - ) + commitTextUpdate = function(textInstance: TextInstance, oldText: string, newText: string) hostUpdateCounter += 1 textInstance.text = computeText(newText, textInstance.context) end, @@ -580,37 +556,26 @@ local function createReactNoop(reconciler, useMutation: boolean) cloneInstance = cloneInstance, clearContainer = clearContainer, - createContainerChildSet = function( - container: Container - ): Array + createContainerChildSet = function(container: Container): Array return {} end, - appendChildToContainerChildSet = function( - childSet: Array, - child: Instance | TextInstance - ) + appendChildToContainerChildSet = function(childSet: Array, child: Instance | TextInstance) table.insert(childSet, child) end, - finalizeContainerChildren = function( - container: Container, - newChildren: Array - ) + finalizeContainerChildren = function(container: Container, newChildren: Array) container.pendingChildren = newChildren if - #newChildren == 1 and - newChildren[1].text == "Error when completing root" + #newChildren == 1 + and newChildren[1].text == "Error when completing root" then -- Trigger an error for testing purposes error(Error("Error when completing root")) end end, - replaceContainerChildren = function( - container: Container, - newChildren: Array - ) + replaceContainerChildren = function(container: Container, newChildren: Array) container.children = newChildren end, @@ -634,11 +599,7 @@ local function createReactNoop(reconciler, useMutation: boolean) return clone end, - cloneHiddenTextInstance = function( - instance: TextInstance, - text: string, - internalInstanceHandle: Object - ) + cloneHiddenTextInstance = function(instance: TextInstance, text: string, internalInstanceHandle: Object) -- deviation: use metatable to define non-enumerable properties local clone = setmetatable({ text = instance.text, @@ -648,7 +609,7 @@ local function createReactNoop(reconciler, useMutation: boolean) __index = { id = instanceCounter, context = instance.context, - } + }, }) instanceCounter += 1 return clone @@ -688,9 +649,11 @@ local function createReactNoop(reconciler, useMutation: boolean) local children = Array.map(child, function(c) return childToJSX(c, nil) end) - if Array.every(children, function (c) - return typeof(c) == "string" or typeof(c) == "number" - end) then + if + Array.every(children, function(c) + return typeof(c) == "string" or typeof(c) == "number" + end) + then return Array.join(children, "") end return children @@ -702,7 +665,7 @@ local function createReactNoop(reconciler, useMutation: boolean) local children = childToJSX(instance.children, instance.text) -- ROBLOX DEVIATION: Luau flow syntax unsupported by Selene 0.11 -- local props = ({prop = instance.prop} :: any) - local props = {prop = instance.prop} + local props = { prop = instance.prop } if instance.hidden then props.hidden = true end @@ -763,9 +726,9 @@ local function createReactNoop(reconciler, useMutation: boolean) type = REACT_FRAGMENT_TYPE, key = nil, ref = nil, - props = {children}, + props = { children }, _owner = nil, - _store = store + _store = store, } end return children @@ -924,11 +887,7 @@ local function createReactNoop(reconciler, useMutation: boolean) return getPendingChildrenAsJSX(container) end, - createPortal = function( - children, - container: Container, - key: string? - ) + createPortal = function(children, container: Container, key: string?) return NoopRenderer.createPortal(children, container, nil, key) end, @@ -944,15 +903,8 @@ local function createReactNoop(reconciler, useMutation: boolean) NoopRenderer.updateContainer(element, root, nil, callback) end, - renderToRootWithID = function( - element, - rootID: string, - callback - ) - local container = ReactNoop.getOrCreateRootContainer( - rootID, - ConcurrentRoot - ) + renderToRootWithID = function(element, rootID: string, callback) + local container = ReactNoop.getOrCreateRootContainer(rootID, ConcurrentRoot) local root = roots[container.rootID] NoopRenderer.updateContainer(element, root, nil, callback) end, @@ -977,10 +929,7 @@ local function createReactNoop(reconciler, useMutation: boolean) return component end if _G.__DEV__ then - return NoopRenderer.findHostInstanceWithWarning( - component, - "findInstance" - ) + return NoopRenderer.findHostInstanceWithWarning(component, "findInstance") end return NoopRenderer.findHostInstance(component) end, @@ -990,9 +939,7 @@ local function createReactNoop(reconciler, useMutation: boolean) return Scheduler.unstable_clearYields() end, - flushWithHostCounters = function( - fn: () -> () - ) + flushWithHostCounters = function(fn: () -> ()) hostDiffCounter = 0 hostUpdateCounter = 0 hostCloneCounter = 0 @@ -1126,14 +1073,15 @@ local function createReactNoop(reconciler, useMutation: boolean) local function logFiber(fiber, depth) log( - string.rep(" ", depth) .. - "- " .. - -- need to explicitly coerce Symbol to a string - fiber.type and (fiber.type.name or tostring(fiber.type)) or "[root]", - "[" .. - fiber.childExpirationTime .. - (fiber.pendingProps and "*" or "") .. - "]" + string.rep(" ", depth) + .. "- " + -- need to explicitly coerce Symbol to a string + .. fiber.type and (fiber.type.name or tostring(fiber.type)) + or "[root]", + "[" + .. fiber.childExpirationTime + .. (fiber.pendingProps and "*" or "") + .. "]" ) if fiber.updateQueue then logUpdateQueue(fiber.updateQueue, depth) @@ -1188,15 +1136,17 @@ local function createReactNoop(reconciler, useMutation: boolean) local function noopAct(scope) if Scheduler.unstable_flushAllWithoutAsserting == nil then - error(Error( - "This version of `act` requires a special mock build of Scheduler." - )) + error( + Error("This version of `act` requires a special mock build of Scheduler.") + ) end if typeof(setTimeout) == "table" and setTimeout._isMockFunction ~= true then - error(Error( - "This version of `act` requires Jest's timer mocks " .. - "(i.e. jest.useFakeTimers)." - )) + error( + Error( + "This version of `act` requires Jest's timer mocks " + .. "(i.e. jest.useFakeTimers)." + ) + ) end local previousActingUpdatesScopeDepth = actingUpdatesScopeDepth @@ -1216,8 +1166,8 @@ local function createReactNoop(reconciler, useMutation: boolean) -- if it's _less than_ previousActingUpdatesScopeDepth, then we can -- assume the 'other' one has warned console.error( - "You seem to have overlapping act() calls, this is not supported. " .. - "Be sure to await previous act() calls before making a new one. " + "You seem to have overlapping act() calls, this is not supported. " + .. "Be sure to await previous act() calls before making a new one. " ) end end @@ -1228,30 +1178,21 @@ local function createReactNoop(reconciler, useMutation: boolean) -- our test suite, we should be able to. local ok, result = pcall(function() local thenable = batchedUpdates(scope) - if - typeof(thenable) == "table" and - typeof(thenable.andThen) == "function" - then + if typeof(thenable) == "table" and typeof(thenable.andThen) == "function" then return { andThen = function(self, resolve: () -> (), reject: (any) -> ()) - thenable:andThen( - function() - flushActWork( - function() - unwind() - resolve() - end, - function(error_) - unwind() - reject(error_) - end - ) - end, - function(err) + thenable:andThen(function() + flushActWork(function() unwind() - reject(err) - end - ) + resolve() + end, function(error_) + unwind() + reject(error_) + end) + end, function(err) + unwind() + reject(err) + end) end, } else diff --git a/modules/react-shallow-renderer/src/__tests__/ReactShallowRenderer.spec.lua b/modules/react-shallow-renderer/src/__tests__/ReactShallowRenderer.spec.lua index 49345857..5c01efdf 100644 --- a/modules/react-shallow-renderer/src/__tests__/ReactShallowRenderer.spec.lua +++ b/modules/react-shallow-renderer/src/__tests__/ReactShallowRenderer.spec.lua @@ -11,7 +11,7 @@ return function() -- local Dependencies = script.Parent.Parent.Parent.Parent.Packages local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local Error = LuauPolyfill.Error @@ -49,7 +49,9 @@ return function() local SomeComponent = React.Component:extend("SomeComponent") SomeComponent.UNSAFE_componentWillMount = logger("componentWillMount") SomeComponent.componentDidMount = logger("componentDidMount") - SomeComponent.UNSAFE_componentWillReceiveProps = logger("componentWillReceiveProps") + SomeComponent.UNSAFE_componentWillReceiveProps = logger( + "componentWillReceiveProps" + ) SomeComponent.shouldComponentUpdate = logger("shouldComponentUpdate") SomeComponent.UNSAFE_componentWillUpdate = logger("componentWillUpdate") SomeComponent.componentDidUpdate = logger("componentDidUpdate") @@ -136,80 +138,89 @@ return function() jestExpect(logs).toEqual({ "getDerivedStateFromProps", "shouldComponentUpdate" }) end) - it("should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new static gDSFP is present", function() - local Component = React.Component:extend("Component") - function Component:init() - self.state = {} - end - function Component.getDerivedStateFromProps() - return nil - end - function Component:componentWillMount() - error(Error("unexpected")) - end - function Component:componentWillReceiveProps() - error(Error("unexpected")) - end - function Component:componentWillUpdate() - error(Error("unexpected")) - end - function Component:render() - return nil + it( + "should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new static gDSFP is present", + function() + local Component = React.Component:extend("Component") + function Component:init() + self.state = {} + end + function Component.getDerivedStateFromProps() + return nil + end + function Component:componentWillMount() + error(Error("unexpected")) + end + function Component:componentWillReceiveProps() + error(Error("unexpected")) + end + function Component:componentWillUpdate() + error(Error("unexpected")) + end + function Component:render() + return nil + end + + local shallowRenderer = createRenderer() + jestExpect(function() + shallowRenderer:render(React.createElement(Component)) + end).never.toThrow() end + ) - local shallowRenderer = createRenderer() - jestExpect(function() - shallowRenderer:render(React.createElement(Component)) - end).never.toThrow() - end) + it( + "should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present", + function() + local Component = React.Component:extend("Component") + function Component:getSnapshotBeforeUpdate() + return nil + end + function Component:componentWillMount() + error(Error("unexpected")) + end + function Component:componentWillReceiveProps() + error(Error("unexpected")) + end + function Component:componentWillUpdate() + error(Error("unexpected")) + end + function Component:render() + return nil + end - it("should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present", function() - local Component = React.Component:extend("Component") - function Component:getSnapshotBeforeUpdate() - return nil - end - function Component:componentWillMount() - error(Error("unexpected")) - end - function Component:componentWillReceiveProps() - error(Error("unexpected")) - end - function Component:componentWillUpdate() - error(Error("unexpected")) - end - function Component:render() - return nil + local shallowRenderer = createRenderer() + jestExpect(function() + shallowRenderer:render(React.createElement(Component, { value = 1 })) + end).never.toThrow() + jestExpect(function() + shallowRenderer:render(React.createElement(Component, { value = 2 })) + end).never.toThrow() end + ) - local shallowRenderer = createRenderer() - jestExpect(function() - shallowRenderer:render(React.createElement(Component, { value = 1 })) - end).never.toThrow() - jestExpect(function() - shallowRenderer:render(React.createElement(Component, { value = 2 })) - end).never.toThrow() - end) + it( + "should not call getSnapshotBeforeUpdate or componentDidUpdate when updating since refs wont exist", + function() + local Component = React.Component:extend("Component") + function Component:getSnapshotBeforeUpdate() + error(Error("unexpected")) + end + function Component:componentDidUpdate() + error(Error("unexpected")) + end + function Component:render() + return nil + end - it("should not call getSnapshotBeforeUpdate or componentDidUpdate when updating since refs wont exist", function() - local Component = React.Component:extend("Component") - function Component:getSnapshotBeforeUpdate() - error(Error("unexpected")) - end - function Component:componentDidUpdate() - error(Error("unexpected")) - end - function Component:render() - return nil + local shallowRenderer = createRenderer() + jestExpect(function() + shallowRenderer:render(React.createElement(Component, { value = 1 })) + end).never.toThrow() + jestExpect(function() + shallowRenderer:render(React.createElement(Component, { value = 2 })) + end).never.toThrow() end - - local shallowRenderer = createRenderer() - jestExpect(function() - shallowRenderer:render(React.createElement(Component, { value = 1 })) - end).never.toThrow() - jestExpect(function() - shallowRenderer:render(React.createElement(Component, { value = 2 })) - end).never.toThrow() - end) + ) it("should only render 1 level deep", function() local function Child() @@ -276,8 +287,7 @@ return function() function SomeComponent:render() return React.createElement( React.Profiler, - { id = "test", onRender = function() - end }, + { id = "test", onRender = function() end }, React.createElement( "Text", nil, @@ -291,12 +301,16 @@ return function() local result = shallowRenderer:render(React.createElement(SomeComponent)) jestExpect(result.type).toEqual(React.Profiler) - jestExpect(result.props.children).toEqual(validateElement(React.createElement( - "Text", - nil, - React.createElement("Frame", { className = "child1" }), - React.createElement("Frame", { className = "child2" }) - ))) + jestExpect(result.props.children).toEqual( + validateElement( + React.createElement( + "Text", + nil, + React.createElement("Frame", { className = "child1" }), + React.createElement("Frame", { className = "child2" }) + ) + ) + ) end) it("should enable shouldComponentUpdate to prevent a re-render", function() @@ -315,14 +329,20 @@ return function() local shallowRenderer = createRenderer() shallowRenderer:render(React.createElement(SimpleComponent)) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) local instance = shallowRenderer:getMountedInstance() instance:setState({ update = false }) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) instance:setState({ update = true }) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 2 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 2 }) + ) end) it("should enable PureComponent to prevent a re-render", function() @@ -338,14 +358,20 @@ return function() local shallowRenderer = createRenderer() shallowRenderer:render(React.createElement(SimpleComponent)) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) local instance = shallowRenderer:getMountedInstance() instance:setState({ update = false }) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) instance:setState({ update = true }) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 2 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 2 }) + ) end) it("should not run shouldComponentUpdate during forced update", function() @@ -365,13 +391,17 @@ return function() local shallowRenderer = createRenderer() shallowRenderer:render(React.createElement(SimpleComponent)) jestExpect(scuCounter).toEqual(0) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) -- Force update the initial state. sCU should not fire. local instance = shallowRenderer:getMountedInstance() instance:forceUpdate() jestExpect(scuCounter).toEqual(0) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) -- Setting state updates the instance, but doesn't re-render -- because sCU returned false. @@ -380,13 +410,17 @@ return function() end) jestExpect(scuCounter).toEqual(1) jestExpect(instance.state.count).toEqual(2) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 1 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 1 }) + ) -- A force update updates the render output, but doesn't call sCU. instance:forceUpdate() jestExpect(scuCounter).toEqual(1) jestExpect(instance.state.count).toEqual(2) - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("TextLabel", { Text = 2 })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("TextLabel", { Text = 2 }) + ) end) it("should rerender when calling forceUpdate", function() @@ -421,7 +455,10 @@ return function() -- } local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { foo = "FOO" }), { bar = "BAR" }) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { foo = "FOO" }), + { bar = "BAR" } + ) jestExpect(result.type).toEqual("Frame") jestExpect(result.props.children).toEqual(validate({ @@ -432,25 +469,35 @@ return function() })) end) - it("should shallow render a component returning strings directly from render", function() - local Text = function(props) - return props.value + it( + "should shallow render a component returning strings directly from render", + function() + local Text = function(props) + return props.value + end + + local shallowRenderer = createRenderer() + local result = shallowRenderer:render( + React.createElement(Text, { value = "foo" }) + ) + jestExpect(result).toEqual("foo") end + ) - local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(Text, { value = "foo" })) - jestExpect(result).toEqual("foo") - end) + it( + "should shallow render a component returning numbers directly from render", + function() + local Text = function(props) + return props.value + end - it("should shallow render a component returning numbers directly from render", function() - local Text = function(props) - return props.value + local shallowRenderer = createRenderer() + local result = shallowRenderer:render( + React.createElement(Text, { value = 10 }) + ) + jestExpect(result).toEqual(10) end - - local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(Text, { value = 10 })) - jestExpect(result).toEqual(10) - end) + ) it("should shallow render a fragment", function() local SomeComponent = React.Component:extend("SomeComponent") @@ -469,7 +516,9 @@ return function() local shallowRenderer = createRenderer() local result = shallowRenderer:render(React.createElement(Fragment)) - jestExpect(result.ChildA).toEqual(React.createElement("TextLabel", { Text = "a" })) + jestExpect(result.ChildA).toEqual( + React.createElement("TextLabel", { Text = "a" }) + ) jestExpect(result.ChildB).toEqual(React.createElement("Frame", { Value = "b" })) jestExpect(result.ChildC).toEqual(React.createElement(SomeComponent)) jestExpect(result).toEqual({ @@ -498,8 +547,12 @@ return function() jestExpect(result.type).toEqual(React.Fragment) jestExpect(#result.props.children).toEqual(3) - jestExpect(result.props.children[1]).toEqual(validateElement(React.createElement("Text"))) - jestExpect(result.props.children[2]).toEqual(validateElement(React.createElement("Frame"))) + jestExpect(result.props.children[1]).toEqual( + validateElement(React.createElement("Text")) + ) + jestExpect(result.props.children[2]).toEqual( + validateElement(React.createElement("Frame")) + ) React.createElement(React.Fragment, nil, { React.createElement("Text"), React.createElement("Frame"), @@ -593,12 +646,12 @@ return function() end if self.props.aNew == "prop" then - return React.createElement( - "Button", - { onClick = function() + return React.createElement("Button", { + onClick = function() self:onClick() - end, className = className } - ) + end, + className = className, + }) else return React.createElement("TextLabel", nil, { React.createElement("Frame", { className = "child1" }), @@ -615,7 +668,9 @@ return function() React.createElement("Frame", { className = "child2" }), })) - local updatedResult = shallowRenderer:render(React.createElement(SomeComponent, { aNew = "prop" })) + local updatedResult = shallowRenderer:render( + React.createElement(SomeComponent, { aNew = "prop" }) + ) jestExpect(updatedResult.type).toEqual("Button") updatedResult.props:onClick() @@ -643,7 +698,7 @@ return function() it("can shallowly render components with contextTypes", function() local SimpleComponent = React.Component:extend("SimpleComponent") SimpleComponent.contextTypes = { - name = "string", -- ROBLOX TODO: missing PropTypes.string + name = "string", -- ROBLOX TODO: missing PropTypes.string } function SimpleComponent:render() @@ -700,7 +755,10 @@ return function() end local shallowRenderer = createRenderer() - shallowRenderer:render(React.createElement(SimpleComponent, initialProp), initialContext) + shallowRenderer:render( + React.createElement(SimpleComponent, initialProp), + initialContext + ) jestExpect(componentDidUpdateParams).toEqual({}) jestExpect(componentWillReceivePropsParams).toEqual({}) jestExpect(componentWillUpdateParams).toEqual({}) @@ -708,7 +766,10 @@ return function() jestExpect(shouldComponentUpdateParams).toEqual({}) -- Lifecycle hooks should be invoked with the correct prev/next params on update. - shallowRenderer:render(React.createElement(SimpleComponent, updatedProp), updatedContext) + shallowRenderer:render( + React.createElement(SimpleComponent, updatedProp), + updatedContext + ) jestExpect(componentWillReceivePropsParams).toEqual({ { updatedProp, updatedContext }, @@ -767,16 +828,24 @@ return function() -- The only lifecycle hook that should be invoked on initial render -- Is the static getDerivedStateFromProps() methods - shallowRenderer:render(React.createElement(SimpleComponent, initialProp), initialContext) - jestExpect(getDerivedStateFromPropsParams).toEqual({ { - initialProp, - initialState, - } }) + shallowRenderer:render( + React.createElement(SimpleComponent, initialProp), + initialContext + ) + jestExpect(getDerivedStateFromPropsParams).toEqual({ + { + initialProp, + initialState, + }, + }) jestExpect(componentDidUpdateParams).toEqual({}) jestExpect(shouldComponentUpdateParams).toEqual({}) -- Lifecycle hooks should be invoked with the correct prev/next params on update. - shallowRenderer:render(React.createElement(SimpleComponent, updatedProp), updatedContext) + shallowRenderer:render( + React.createElement(SimpleComponent, updatedProp), + updatedContext + ) jestExpect(getDerivedStateFromPropsParams).toEqual({ { initialProp, initialState }, @@ -811,8 +880,7 @@ return function() end return React.createElement(SimpleComponent, { - ref = function() - end, + ref = function() end, onClick = function() self:handleUserClick() end, @@ -848,12 +916,20 @@ return function() end function SimpleComponent:render() - return React.createElement("TextLabel", nil, "count:" .. self.state.count .. ", other:" .. self.state.other) + return React.createElement( + "TextLabel", + nil, + "count:" .. self.state.count .. ", other:" .. self.state.other + ) end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SimpleComponent, { incrementBy = 2 })) - jestExpect(result).toEqual(React.createElement("TextLabel", nil, "count:3, other:foobar")) + local result = shallowRenderer:render( + React.createElement(SimpleComponent, { incrementBy = 2 }) + ) + jestExpect(result).toEqual( + React.createElement("TextLabel", nil, "count:3, other:foobar") + ) end) it("can setState in componentWillMount when shallow rendering", function() @@ -887,41 +963,53 @@ return function() local doovy = self.state.doovy local separator = self.state.separator - return React.createElement("TextLabel", { Text = groovy .. separator .. doovy }) + return React.createElement( + "TextLabel", + { Text = groovy .. separator .. doovy } + ) end local shallowRenderer = createRenderer() local result = shallowRenderer:render(React.createElement(SimpleComponent)) - jestExpect(result).toEqual(React.createElement("TextLabel", { Text = "doovy-groovy" })) + jestExpect(result).toEqual( + React.createElement("TextLabel", { Text = "doovy-groovy" }) + ) end) - it("can setState in componentWillMount with an updater function repeatedly when shallow rendering", function() - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init() - self.state = { separator = "-" } - end + it( + "can setState in componentWillMount with an updater function repeatedly when shallow rendering", + function() + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init() + self.state = { separator = "-" } + end - function SimpleComponent:UNSAFE_componentWillMount() - self:setState(function(state) - return { groovy = "doovy" } - end) - self:setState(function(state) - return { doovy = state.groovy } - end) - end + function SimpleComponent:UNSAFE_componentWillMount() + self:setState(function(state) + return { groovy = "doovy" } + end) + self:setState(function(state) + return { doovy = state.groovy } + end) + end - function SimpleComponent:render() - local groovy = self.state.groovy - local doovy = self.state.doovy - local separator = self.state.separator + function SimpleComponent:render() + local groovy = self.state.groovy + local doovy = self.state.doovy + local separator = self.state.separator - return React.createElement("TextLabel", nil, { groovy .. separator .. doovy }) - end + return React.createElement( + "TextLabel", + nil, + { groovy .. separator .. doovy } + ) + end - local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SimpleComponent)) - jestExpect(result.props.children[1]).toEqual("doovy-doovy") - end) + local shallowRenderer = createRenderer() + local result = shallowRenderer:render(React.createElement(SimpleComponent)) + jestExpect(result.props.children[1]).toEqual("doovy-doovy") + end + ) it("can setState in componentWillReceiveProps when shallow rendering", function() local SimpleComponent = React.Component:extend("SimpleComponent") @@ -940,109 +1028,145 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SimpleComponent, { updateState = false })) + local result = shallowRenderer:render( + React.createElement(SimpleComponent, { updateState = false }) + ) jestExpect(result.props.children).toEqual(0) - result = shallowRenderer:render(React.createElement(SimpleComponent, { updateState = true })) + result = shallowRenderer:render( + React.createElement(SimpleComponent, { updateState = true }) + ) jestExpect(result.props.children).toEqual(1) end) - it("can update state with static getDerivedStateFromProps when shallow rendering", function() - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init() - self.state = { count = 1 } - end - - function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) - if nextProps.updateState then - return { count = nextProps.incrementBy + prevState.count } + it( + "can update state with static getDerivedStateFromProps when shallow rendering", + function() + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init() + self.state = { count = 1 } end - return nil - end + function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) + if nextProps.updateState then + return { count = nextProps.incrementBy + prevState.count } + end - function SimpleComponent:render() - return React.createElement("TextLabel", nil, self.state.count) - end + return nil + end - local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement( - SimpleComponent, - { updateState = false, incrementBy = 0 } - )) + function SimpleComponent:render() + return React.createElement("TextLabel", nil, self.state.count) + end - jestExpect(result.props.children).toEqual(1) + local shallowRenderer = createRenderer() + local result = shallowRenderer:render( + React.createElement( + SimpleComponent, + { updateState = false, incrementBy = 0 } + ) + ) - result = shallowRenderer:render(React.createElement(SimpleComponent, { updateState = true, incrementBy = 2 })) - jestExpect(result.props.children).toEqual(3) + jestExpect(result.props.children).toEqual(1) - result = shallowRenderer:render(React.createElement(SimpleComponent, { updateState = false, incrementBy = 2 })) + result = shallowRenderer:render( + React.createElement( + SimpleComponent, + { updateState = true, incrementBy = 2 } + ) + ) + jestExpect(result.props.children).toEqual(3) - jestExpect(result.props.children).toEqual(3) - end) + result = shallowRenderer:render( + React.createElement( + SimpleComponent, + { updateState = false, incrementBy = 2 } + ) + ) - it("should not override state with stale values if prevState is spread within getDerivedStateFromProps", function() - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init() - self.state = { value = 0 } + jestExpect(result.props.children).toEqual(3) end + ) - function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) - return { table.unpack(prevState) } - end + it( + "should not override state with stale values if prevState is spread within getDerivedStateFromProps", + function() + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init() + self.state = { value = 0 } + end - function SimpleComponent:updateState() - self:setState(function(state) - return { value = state.value + 1 } - end) - end + function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) + return { table.unpack(prevState) } + end - function SimpleComponent:render() - return React.createElement("TextLabel", nil, "value:" .. self.state.value) - end + function SimpleComponent:updateState() + self:setState(function(state) + return { value = state.value + 1 } + end) + end - local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SimpleComponent)) - jestExpect(result).toEqual(React.createElement("TextLabel", nil, "value:0")) + function SimpleComponent:render() + return React.createElement("TextLabel", nil, "value:" .. self.state.value) + end - local instance = shallowRenderer:getMountedInstance() - instance:updateState() - result = shallowRenderer:getRenderOutput() - jestExpect(result).toEqual(React.createElement("TextLabel", nil, "value:1")) - end) + local shallowRenderer = createRenderer() + local result = shallowRenderer:render(React.createElement(SimpleComponent)) + jestExpect(result).toEqual(React.createElement("TextLabel", nil, "value:0")) + + local instance = shallowRenderer:getMountedInstance() + instance:updateState() + result = shallowRenderer:getRenderOutput() + jestExpect(result).toEqual(React.createElement("TextLabel", nil, "value:1")) + end + ) + + it( + "should pass previous state to shouldComponentUpdate even with getDerivedStateFromProps", + function() + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init() + self.state = { + value = self.props.value, + } + end - it("should pass previous state to shouldComponentUpdate even with getDerivedStateFromProps", function() - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init() - self.state = { - value = self.props.value, - } - end + function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) + if nextProps.value == prevState.value then + return nil + end - function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) - if nextProps.value == prevState.value then - return nil + return { value = nextProps.value } end - return { value = nextProps.value } - end + function SimpleComponent:shouldComponentUpdate(nextProps, nextState) + return nextState.value ~= self.state.value + end - function SimpleComponent:shouldComponentUpdate(nextProps, nextState) - return nextState.value ~= self.state.value - end + function SimpleComponent:render() + return React.createElement( + "TextLabel", + { Text = "value:" .. self.state.value } + ) + end - function SimpleComponent:render() - return React.createElement("TextLabel", { Text = "value:" .. self.state.value }) + local shallowRenderer = createRenderer() + local initialResult = shallowRenderer:render( + React.createElement(SimpleComponent, { value = "initial" }) + ) + jestExpect(initialResult).toEqual( + React.createElement("TextLabel", { Text = "value:initial" }) + ) + local updatedResult = shallowRenderer:render( + React.createElement(SimpleComponent, { value = "updated" }) + ) + jestExpect(updatedResult).toEqual( + React.createElement("TextLabel", { Text = "value:updated" }) + ) end - - local shallowRenderer = createRenderer() - local initialResult = shallowRenderer:render(React.createElement(SimpleComponent, { value = "initial" })) - jestExpect(initialResult).toEqual(React.createElement("TextLabel", { Text = "value:initial" })) - local updatedResult = shallowRenderer:render(React.createElement(SimpleComponent, { value = "updated" })) - jestExpect(updatedResult).toEqual(React.createElement("TextLabel", { Text = "value:updated" })) - end) + ) it("can setState with an updater function", function() local instance @@ -1060,7 +1184,9 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SimpleComponent, { defaultCount = 1 })) + local result = shallowRenderer:render( + React.createElement(SimpleComponent, { defaultCount = 1 }) + ) jestExpect(result.props.children).toEqual(0) instance:setState(function(state, props) @@ -1224,7 +1350,11 @@ return function() end function SimpleComponent:render() - return React.createElement("Text", nil, self.context.foo .. ":" .. self.state.bar) + return React.createElement( + "Text", + nil, + self.context.foo .. ":" .. self.state.bar + ) end local shallowRenderer = createRenderer() @@ -1246,7 +1376,11 @@ return function() foo = "string", } function SimpleComponent:render() - return React.createElement("Text", nil, self.context.foo .. ":" .. tostring(self.context.bar)) + return React.createElement( + "Text", + nil, + self.context.foo .. ":" .. tostring(self.context.bar) + ) end local shallowRenderer = createRenderer() @@ -1310,7 +1444,11 @@ return function() end function SimpleComponent:render() - return React.createElement("Text", nil, self.props.foo .. ":" .. self.state.bar) + return React.createElement( + "Text", + nil, + self.props.foo .. ":" .. self.state.bar + ) end local shallowRenderer = createRenderer() @@ -1323,30 +1461,33 @@ return function() jestExpect(result.props.children).toEqual("baz:bar") end) - it("self.state should be updated on setState callback inside componentWillMount", function() - local stateSuccessfullyUpdated = false + it( + "self.state should be updated on setState callback inside componentWillMount", + function() + local stateSuccessfullyUpdated = false - local MyComponent = React.Component:extend("Component") - function MyComponent:init(props, context) - self.state = { - hasUpdatedState = false, - } - end + local MyComponent = React.Component:extend("Component") + function MyComponent:init(props, context) + self.state = { + hasUpdatedState = false, + } + end - function MyComponent:UNSAFE_componentWillMount() - self:setState({ hasUpdatedState = true }, function() - stateSuccessfullyUpdated = self.state.hasUpdatedState - end) - end + function MyComponent:UNSAFE_componentWillMount() + self:setState({ hasUpdatedState = true }, function() + stateSuccessfullyUpdated = self.state.hasUpdatedState + end) + end - function MyComponent:render() - return React.createElement("Text", nil, self.props.children) - end + function MyComponent:render() + return React.createElement("Text", nil, self.props.children) + end - local shallowRenderer = createRenderer() - shallowRenderer:render(React.createElement(MyComponent)) - jestExpect(stateSuccessfullyUpdated).toEqual(true) - end) + local shallowRenderer = createRenderer() + shallowRenderer:render(React.createElement(MyComponent)) + jestExpect(stateSuccessfullyUpdated).toEqual(true) + end + ) it("should handle multiple callbacks", function() local mockCalledTimes = 0 @@ -1383,40 +1524,43 @@ return function() jestExpect(mockCalledTimes).toEqual(3) end) - it("should call the setState callback even if shouldComponentUpdate = false", function() - local mockCalledTimes = 0 - local mockFn = function() - mockCalledTimes += 1 - return false - end + it( + "should call the setState callback even if shouldComponentUpdate = false", + function() + local mockCalledTimes = 0 + local mockFn = function() + mockCalledTimes += 1 + return false + end - local Component = React.Component:extend("Component") - function Component:init(props, context) - self.state = { - hasUpdatedState = false, - } - end + local Component = React.Component:extend("Component") + function Component:init(props, context) + self.state = { + hasUpdatedState = false, + } + end - function Component:shouldComponentUpdate() - return mockFn() - end + function Component:shouldComponentUpdate() + return mockFn() + end - function Component:render() - return React.createElement("Text", nil, self.state.hasUpdatedState) - end + function Component:render() + return React.createElement("Text", nil, self.state.hasUpdatedState) + end - local shallowRenderer = createRenderer() - shallowRenderer:render(React.createElement(Component)) + local shallowRenderer = createRenderer() + shallowRenderer:render(React.createElement(Component)) - local callbackWasCalled = false - local mountedInstance = shallowRenderer:getMountedInstance() - mountedInstance:setState({ hasUpdatedState = true }, function() - jestExpect(mockCalledTimes).toEqual(1) - jestExpect(mountedInstance.state.hasUpdatedState).toEqual(true) - callbackWasCalled = true - end) - jestExpect(callbackWasCalled).toEqual(true) - end) + local callbackWasCalled = false + local mountedInstance = shallowRenderer:getMountedInstance() + mountedInstance:setState({ hasUpdatedState = true }, function() + jestExpect(mockCalledTimes).toEqual(1) + jestExpect(mountedInstance.state.hasUpdatedState).toEqual(true) + callbackWasCalled = true + end) + jestExpect(callbackWasCalled).toEqual(true) + end + ) it("throws usefully when rendering badly-typed elements", function() local shallowRenderer = createRenderer() @@ -1473,7 +1617,9 @@ return function() Component.componentWillReceiveProps = logger("componentWillReceiveProps") Component.componentWillUpdate = logger("componentWillUpdate") Component.UNSAFE_componentWillMount = logger("UNSAFE_componentWillMount") - Component.UNSAFE_componentWillReceiveProps = logger("UNSAFE_componentWillReceiveProps") + Component.UNSAFE_componentWillReceiveProps = logger( + "UNSAFE_componentWillReceiveProps" + ) Component.UNSAFE_componentWillUpdate = logger("UNSAFE_componentWillUpdate") function Component:render() @@ -1579,7 +1725,11 @@ return function() end local Foo = React.memo(function(props) renderCount += 1 - return React.createElement("Text", nil, tostring(props.foo) .. tostring(props.bar)) + return React.createElement( + "Text", + nil, + tostring(props.foo) .. tostring(props.bar) + ) end, areEqual) local shallowRenderer = createRenderer() @@ -1592,20 +1742,23 @@ return function() jestExpect(renderCount).toEqual(2) end) - it("should not call the comparison function with React.memo on the initial render", function() - local comparisonWasCalled = false - local areEqual = function() - comparisonWasCalled = true - return false + it( + "should not call the comparison function with React.memo on the initial render", + function() + local comparisonWasCalled = false + local areEqual = function() + comparisonWasCalled = true + return false + end + local SomeComponent = React.memo(function(props) + return React.createElement("Text", nil, props.foo) + end, areEqual) + local shallowRenderer = createRenderer() + shallowRenderer:render(React.createElement(SomeComponent, { foo = 1 })) + jestExpect(comparisonWasCalled).toEqual(false) + jestExpect(shallowRenderer:getRenderOutput().props.children).toEqual(1) end - local SomeComponent = React.memo(function(props) - return React.createElement("Text", nil, props.foo) - end, areEqual) - local shallowRenderer = createRenderer() - shallowRenderer:render(React.createElement(SomeComponent, { foo = 1 })) - jestExpect(comparisonWasCalled).toEqual(false) - jestExpect(shallowRenderer:getRenderOutput().props.children).toEqual(1) - end) + ) it("should handle memo(forwardRef())", function() local testRef = React.createRef() @@ -1620,7 +1773,9 @@ return function() local SomeMemoComponent = React.memo(SomeComponent) local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeMemoComponent, { ref = testRef })) + local result = shallowRenderer:render( + React.createElement(SomeMemoComponent, { ref = testRef }) + ) jestExpect(result.type).toEqual("Frame") jestExpect(result.props.children).toEqual(validate({ diff --git a/modules/react-shallow-renderer/src/__tests__/ReactShallowRendererHooks.spec.lua b/modules/react-shallow-renderer/src/__tests__/ReactShallowRendererHooks.spec.lua index 775ef7e8..09aa6fc6 100644 --- a/modules/react-shallow-renderer/src/__tests__/ReactShallowRendererHooks.spec.lua +++ b/modules/react-shallow-renderer/src/__tests__/ReactShallowRendererHooks.spec.lua @@ -66,7 +66,9 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { defaultName = "Dominic" })) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { defaultName = "Dominic" }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { @@ -98,7 +100,9 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { defaultName = "Sophie" })) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { defaultName = "Sophie" }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { @@ -106,7 +110,9 @@ return function() }), })) - result = shallowRenderer:render(React.createElement(SomeComponent, { defaultName = "Dan" })) + result = shallowRenderer:render( + React.createElement(SomeComponent, { defaultName = "Dan" }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { Text = "Your name is: " .. "Sophie (S)", @@ -114,11 +120,13 @@ return function() })) _updateName("Dan") - jestExpect(shallowRenderer:getRenderOutput()).toEqual(React.createElement("Frame", nil, { - React.createElement("TextLabel", { - Text = "Your name is: " .. "Dan (D)", - }), - })) + jestExpect(shallowRenderer:getRenderOutput()).toEqual( + React.createElement("Frame", nil, { + React.createElement("TextLabel", { + Text = "Your name is: " .. "Dan (D)", + }), + }) + ) end) it("should work with useReducer", function() @@ -147,14 +155,18 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { initialCount = 0 })) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { initialCount = 0 }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { "The counter is at: 0", }), })) - result = shallowRenderer:render(React.createElement(SomeComponent, { initialCount = 10 })) + result = shallowRenderer:render( + React.createElement(SomeComponent, { initialCount = 10 }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { @@ -192,7 +204,9 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { initialCount = 0 })) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { initialCount = 0 }) + ) jestExpect(result).toEqual(React.createElement("Frame", nil, { React.createElement("TextLabel", { @@ -230,14 +244,18 @@ return function() "Frame", nil, React.createElement("TextLabel", { - Text = "The random number is: " .. tostring(randomNumberRef.current.number), + Text = "The random number is: " .. tostring( + randomNumberRef.current.number + ), }) ) end local shallowRenderer = createRenderer() local firstResult = shallowRenderer:render(React.createElement(SomeComponent)) - local secondResult = shallowRenderer:render(React.createElement(SomeComponent)) + local secondResult = shallowRenderer:render( + React.createElement(SomeComponent) + ) jestExpect(firstResult).toEqual(secondResult) end) @@ -260,7 +278,9 @@ return function() local shallowRenderer = createRenderer() local firstResult = shallowRenderer:render(React.createElement(SomeComponent)) - local secondResult = shallowRenderer:render(React.createElement(SomeComponent)) + local secondResult = shallowRenderer:render( + React.createElement(SomeComponent) + ) jestExpect(firstResult).toEqual(secondResult) end) @@ -271,17 +291,23 @@ return function() local function SomeComponent() local value = React.useContext(SomeContext) - return React.createElement("Frame", nil, React.createElement("TextLabel", { Text = tostring(value) })) + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = tostring(value) }) + ) end local shallowRenderer = createRenderer() local result = shallowRenderer:render(React.createElement(SomeComponent)) - jestExpect(result).toEqual(React.createElement( - "Frame", - nil, - React.createElement("TextLabel", { Text = "default" }) - )) + jestExpect(result).toEqual( + React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = "default" }) + ) + ) end) it("should not leak state when component type changes", function() @@ -302,12 +328,16 @@ return function() end local shallowRenderer = createRenderer() - local result = shallowRenderer:render(React.createElement(SomeComponent, { defaultName = "Dominic" })) + local result = shallowRenderer:render( + React.createElement(SomeComponent, { defaultName = "Dominic" }) + ) jestExpect(result).toEqual(React.createElement("TextLabel", { Text = "Your name is: " .. "Dominic", })) - result = shallowRenderer:render(React.createElement(SomeOtherComponent, { defaultName = "Dan" })) + result = shallowRenderer:render( + React.createElement(SomeOtherComponent, { defaultName = "Dan" }) + ) jestExpect(result).toEqual(React.createElement("TextLabel", { Text = "Your name is: " .. "Dan", @@ -322,14 +352,18 @@ return function() "Frame", { ref = ref }, React.createElement("TextLabel", { - Text = "The random number is: " .. tostring(randomNumberRef.current.number), + Text = "The random number is: " .. tostring( + randomNumberRef.current.number + ), }) ) end) local shallowRenderer = createRenderer() local firstResult = shallowRenderer:render(React.createElement(SomeComponent)) - local secondResult = shallowRenderer:render(React.createElement(SomeComponent)) + local secondResult = shallowRenderer:render( + React.createElement(SomeComponent) + ) jestExpect(firstResult).toEqual(secondResult) end) @@ -363,29 +397,40 @@ return function() end local shallowRenderer = createRenderer() - local element = React.createElement(SomeComponent, { defaultName = "Dominic" }) + local element = React.createElement( + SomeComponent, + { defaultName = "Dominic" } + ) local result = shallowRenderer:render(element) - jestExpect(result.props.children).toEqual(validateElement(React.createElement("TextLabel", { - Text = "Your name is: Dominic (0)", - }))) + jestExpect(result.props.children).toEqual( + validateElement(React.createElement("TextLabel", { + Text = "Your name is: Dominic (0)", + })) + ) result.props.onClick() local updated = shallowRenderer:render(element) - jestExpect(updated.props.children).toEqual(validateElement(React.createElement("TextLabel", { - Text = "Your name is: Dan (0)", - }))) + jestExpect(updated.props.children).toEqual( + validateElement(React.createElement("TextLabel", { + Text = "Your name is: Dan (0)", + })) + ) _dispatch("foo") updated = shallowRenderer:render(element) - jestExpect(updated.props.children).toEqual(validateElement(React.createElement("TextLabel", { - Text = "Your name is: Dan (1)", - }))) + jestExpect(updated.props.children).toEqual( + validateElement(React.createElement("TextLabel", { + Text = "Your name is: Dan (1)", + })) + ) _dispatch("inc") updated = shallowRenderer:render(element) - jestExpect(updated.props.children).toEqual(validateElement(React.createElement("TextLabel", { - Text = "Your name is: Dan (2)", - }))) + jestExpect(updated.props.children).toEqual( + validateElement(React.createElement("TextLabel", { + Text = "Your name is: Dan (2)", + })) + ) end) it("should ignore a foreign update outside the render", function() diff --git a/modules/react-shallow-renderer/src/init.lua b/modules/react-shallow-renderer/src/init.lua index e43a83c1..ce063d28 100644 --- a/modules/react-shallow-renderer/src/init.lua +++ b/modules/react-shallow-renderer/src/init.lua @@ -37,7 +37,7 @@ local RE_RENDER_LIMIT: number = 25 local emptyObject = {} if _G.__DEV__ then - Object.freeze(emptyObject) + Object.freeze(emptyObject) end -- In DEV, self is the name of the currently executing primitive hook @@ -45,135 +45,132 @@ end local currentHookNameInDev = "currentHookNameInDev" local function areHookInputsEqual(nextDeps, prevDeps) - if prevDeps == nil then - if _G.__DEV__ then - consoleWithStackDev.error( - '%s received a final argument during self render, but not during ' .. - 'the previous render. Even though the final argument is optional, ' .. - 'its type cannot change between renders.', - currentHookNameInDev - ) - end - return false - end - - if _G.__DEV__ then - -- Don't bother comparing lengths in prod because these arrays should be - -- passed inline. - if #nextDeps ~= #prevDeps then - consoleWithStackDev.error( - 'The final argument passed to %s changed size between renders. The ' .. - 'order and size of self array must remain constant.\n\n' .. - 'Previous: %s\n' .. - 'Incoming: %s', - currentHookNameInDev, - table.concat(nextDeps, ', '), - table.concat(prevDeps, ', ') - ) - end - end - local maxValue = math.min(#prevDeps, #nextDeps) - for i = 1, maxValue do - if is(nextDeps[i], prevDeps[i]) then - continue - end - return false - end - return true + if prevDeps == nil then + if _G.__DEV__ then + consoleWithStackDev.error( + "%s received a final argument during self render, but not during " + .. "the previous render. Even though the final argument is optional, " + .. "its type cannot change between renders.", + currentHookNameInDev + ) + end + return false + end + + if _G.__DEV__ then + -- Don't bother comparing lengths in prod because these arrays should be + -- passed inline. + if #nextDeps ~= #prevDeps then + consoleWithStackDev.error( + "The final argument passed to %s changed size between renders. The " + .. "order and size of self array must remain constant.\n\n" + .. "Previous: %s\n" + .. "Incoming: %s", + currentHookNameInDev, + table.concat(nextDeps, ", "), + table.concat(prevDeps, ", ") + ) + end + end + local maxValue = math.min(#prevDeps, #nextDeps) + for i = 1, maxValue do + if is(nextDeps[i], prevDeps[i]) then + continue + end + return false + end + return true end -- ROBLOX deviation: bind functions to upvalue function createUpdater(renderer) - local updater = { - _renderer = renderer, - _callbacks = {}, - } - - function updater._enqueueCallback(callback, publicInstance) - if typeof(callback) == 'function' and publicInstance then - table.insert(updater._callbacks, { - callback = callback, - publicInstance = publicInstance, - }) - end - end - - function updater._invokeCallbacks() - local callbacks = updater._callbacks - updater._callbacks = {} - - for _, value in pairs(callbacks) do - local callback = value.callback - local publicInstance = value.publicInstance - - callback(publicInstance) - end - end - - function updater.isMounted(publicInstance) - return not not updater._renderer._element - end - - function updater.enqueueForceUpdate(publicInstance, callback, _callerName) - updater._enqueueCallback(callback, publicInstance) - updater._renderer._forcedUpdate = true - updater._renderer:render(updater._renderer._element, updater._renderer._context) - end - - function updater.enqueueReplaceState(publicInstance, completeState, callback, _callerName) - updater._enqueueCallback(callback, publicInstance) - updater._renderer._newState = completeState - updater._renderer:render(updater._renderer._element, updater._renderer._context) - end - - function updater.enqueueSetState(publicInstance, partialState, callback, _callerName) - updater._enqueueCallback(callback, publicInstance) - local currentState = updater._renderer._newState or publicInstance.state - - if typeof(partialState) == 'function' then - -- ROBLOX deviation: in React, the partial state function is called on the - -- publicInstance, meaning that `this` is accessible, and scoped correctly, - -- inside of the state updater; with Lua, you would need to define your - -- functions differently, by explicitly adding the first argument for 'self' - -- for this to work the same way - partialState = partialState( - currentState, - publicInstance.props - ) - end - - -- Null and undefined are treated as no-ops. - if partialState == nil then - return - end - - updater._renderer._newState = Object.assign( - {}, - currentState, - partialState - ) - - updater._renderer:render(updater._renderer._element, updater._renderer._context) - end - - return updater + local updater = { + _renderer = renderer, + _callbacks = {}, + } + + function updater._enqueueCallback(callback, publicInstance) + if typeof(callback) == "function" and publicInstance then + table.insert(updater._callbacks, { + callback = callback, + publicInstance = publicInstance, + }) + end + end + + function updater._invokeCallbacks() + local callbacks = updater._callbacks + updater._callbacks = {} + + for _, value in pairs(callbacks) do + local callback = value.callback + local publicInstance = value.publicInstance + + callback(publicInstance) + end + end + + function updater.isMounted(publicInstance) + return not not updater._renderer._element + end + + function updater.enqueueForceUpdate(publicInstance, callback, _callerName) + updater._enqueueCallback(callback, publicInstance) + updater._renderer._forcedUpdate = true + updater._renderer:render(updater._renderer._element, updater._renderer._context) + end + + function updater.enqueueReplaceState( + publicInstance, + completeState, + callback, + _callerName + ) + updater._enqueueCallback(callback, publicInstance) + updater._renderer._newState = completeState + updater._renderer:render(updater._renderer._element, updater._renderer._context) + end + + function updater.enqueueSetState(publicInstance, partialState, callback, _callerName) + updater._enqueueCallback(callback, publicInstance) + local currentState = updater._renderer._newState or publicInstance.state + + if typeof(partialState) == "function" then + -- ROBLOX deviation: in React, the partial state function is called on the + -- publicInstance, meaning that `this` is accessible, and scoped correctly, + -- inside of the state updater; with Lua, you would need to define your + -- functions differently, by explicitly adding the first argument for 'self' + -- for this to work the same way + partialState = partialState(currentState, publicInstance.props) + end + + -- Null and undefined are treated as no-ops. + if partialState == nil then + return + end + + updater._renderer._newState = Object.assign({}, currentState, partialState) + + updater._renderer:render(updater._renderer._element, updater._renderer._context) + end + + return updater end - function createHook() - return { - memoizedState = nil, - queue = nil, - next = nil, - } + return { + memoizedState = nil, + queue = nil, + next = nil, + } end function basicStateReducer(state, action) - if typeof(action) == 'function' then - return action(state) - else - return action - end + if typeof(action) == "function" then + return action(state) + else + return action + end end -- ROBLOX deviation: hoist declaration @@ -185,714 +182,735 @@ ReactShallowRenderer.__index = ReactShallowRenderer -- ROBLOX deviation: Collapse static create function and constructor together; since -- Lua only has the former anyway function ReactShallowRenderer.createRenderer() - local self = setmetatable({}, ReactShallowRenderer) - self:_reset() + local self = setmetatable({}, ReactShallowRenderer) + self:_reset() - return self + return self end function ReactShallowRenderer:_reset() - self._context = nil - self._element = nil - self._instance = nil - self._newState = nil - self._rendered = nil - self._rendering = false - self._forcedUpdate = false - self._updater = createUpdater(self) - self._dispatcher = self:_createDispatcher() - self._workInProgressHook = nil - self._firstWorkInProgressHook = nil - self._isReRender = false - self._didScheduleRenderPhaseUpdate = false - self._renderPhaseUpdates = nil - self._numberOfReRenders = 0 + self._context = nil + self._element = nil + self._instance = nil + self._newState = nil + self._rendered = nil + self._rendering = false + self._forcedUpdate = false + self._updater = createUpdater(self) + self._dispatcher = self:_createDispatcher() + self._workInProgressHook = nil + self._firstWorkInProgressHook = nil + self._isReRender = false + self._didScheduleRenderPhaseUpdate = false + self._renderPhaseUpdates = nil + self._numberOfReRenders = 0 end function ReactShallowRenderer:_validateCurrentlyRenderingComponent() - if not (self._rendering and not self._instance) then - error(Error([[Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: + if not (self._rendering and not self._instance) then + error( + Error( + [[Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app -See https://fb.me/react-invalid-hook-call for tips about how to debug and fix self problem.]])) - end +See https://fb.me/react-invalid-hook-call for tips about how to debug and fix self problem.]] + ) + ) + end end function ReactShallowRenderer:_createDispatcher() - -- ROBLOX deviation: This function returns two values instead of an array. Lua does - -- not support destructuring, but _does_ support multiple return values - local function useReducer(reducer, initialArg, init) - self:_validateCurrentlyRenderingComponent() - self:_createWorkInProgressHook() - local workInProgressHook = self._workInProgressHook - - if self._isReRender then - -- This is a re-render. - local queue = workInProgressHook.queue - local dispatch = queue.dispatch - if self._numberOfReRenders > 0 then - -- Apply the new render phase updates to the previous current hook. - if self._renderPhaseUpdates ~= nil then - -- Render phase updates are stored in a map of queue -> linked list - local firstRenderPhaseUpdate = self._renderPhaseUpdates[queue] - if firstRenderPhaseUpdate ~= nil then - self._renderPhaseUpdates[queue] = nil - local newState = workInProgressHook.memoizedState - local update = firstRenderPhaseUpdate - repeat - local action = update.action - newState = reducer(newState, action) - update = update.next - until update == nil - workInProgressHook.memoizedState = newState - return newState, dispatch - end - end - return workInProgressHook.memoizedState, dispatch - end - -- Process updates outside of render - local newState = workInProgressHook.memoizedState - local update = queue.first - if update ~= nil then - repeat - local action = update.action - newState = reducer(newState, action) - update = update.next - until update == nil - queue.first = nil - workInProgressHook.memoizedState = newState - end - return newState, dispatch - else - local initialState - if reducer == basicStateReducer then - -- Special case for `useState`. - if typeof(initialArg) == 'function' then - initialState = initialArg() - else - initialState = initialArg - end - else - if init ~= nil then - initialState = init(initialArg) - else - initialState = initialArg - end - end - workInProgressHook.memoizedState = initialState - workInProgressHook.queue = { - first = nil, - dispatch = nil, - } - local queue = workInProgressHook.queue - queue.dispatch = function(...) - self:_dispatchAction(queue, ...) - end - local dispatch = queue.dispatch - return workInProgressHook.memoizedState, dispatch - end - end - - local function useState(initialState) - return useReducer( - basicStateReducer, - -- useReducer has a special case to support lazy useState initializers - initialState - ) - end - - local function useMemo(nextCreate, deps) - self:_validateCurrentlyRenderingComponent() - self:_createWorkInProgressHook() - - local nextDeps = deps - - if - self._workInProgressHook ~= nil and - self._workInProgressHook.memoizedState ~= nil - then - local prevState = self._workInProgressHook.memoizedState - local prevDeps = prevState[2] - if nextDeps ~= nil then - if areHookInputsEqual(nextDeps, prevDeps) then - return prevState[1] - end - end - end - - local nextValue = nextCreate() - self._workInProgressHook.memoizedState = { nextValue, nextDeps } - return nextValue - end - - local function useRef(initialValue) - self:_validateCurrentlyRenderingComponent() - self:_createWorkInProgressHook() - local previousRef = self._workInProgressHook.memoizedState - if previousRef == nil then - local ref = { current = initialValue } - if _G.__DEV__ then - Object.seal(ref) - end - self._workInProgressHook.memoizedState = ref - return ref - else - return previousRef - end - end - - local function readContext(context, _observedBits) - return context._currentValue - end - - local function noOp() - self:_validateCurrentlyRenderingComponent() - end - - local function identity(fn) - return fn - end - - local function useResponder(responder, props) - return { - props = props, - responder = responder, - } - end - - -- TODO: implement if we decide to keep the shallow renderer - local function useTransition(_config) - self:_validateCurrentlyRenderingComponent() - local startTransition = function(callback) - callback() - end - return { startTransition, false } - end - - -- TODO: implement if we decide to keep the shallow renderer - local function useDeferredValue(value, _config) - self:_validateCurrentlyRenderingComponent() - return value - end - - return { - readContext = readContext, - useCallback = identity, - useContext = function(context) - self:_validateCurrentlyRenderingComponent() - return readContext(context) - end, - useDebugValue = noOp, - useEffect = noOp, - useImperativeHandle = noOp, - useLayoutEffect = noOp, - useMemo = useMemo, - useReducer = useReducer, - useRef = useRef, - useState = useState, - useResponder = useResponder, - useTransition = useTransition, - useDeferredValue = useDeferredValue, - } + -- ROBLOX deviation: This function returns two values instead of an array. Lua does + -- not support destructuring, but _does_ support multiple return values + local function useReducer(reducer, initialArg, init) + self:_validateCurrentlyRenderingComponent() + self:_createWorkInProgressHook() + local workInProgressHook = self._workInProgressHook + + if self._isReRender then + -- This is a re-render. + local queue = workInProgressHook.queue + local dispatch = queue.dispatch + if self._numberOfReRenders > 0 then + -- Apply the new render phase updates to the previous current hook. + if self._renderPhaseUpdates ~= nil then + -- Render phase updates are stored in a map of queue -> linked list + local firstRenderPhaseUpdate = self._renderPhaseUpdates[queue] + if firstRenderPhaseUpdate ~= nil then + self._renderPhaseUpdates[queue] = nil + local newState = workInProgressHook.memoizedState + local update = firstRenderPhaseUpdate + repeat + local action = update.action + newState = reducer(newState, action) + update = update.next + until update == nil + workInProgressHook.memoizedState = newState + return newState, dispatch + end + end + return workInProgressHook.memoizedState, dispatch + end + -- Process updates outside of render + local newState = workInProgressHook.memoizedState + local update = queue.first + if update ~= nil then + repeat + local action = update.action + newState = reducer(newState, action) + update = update.next + until update == nil + queue.first = nil + workInProgressHook.memoizedState = newState + end + return newState, dispatch + else + local initialState + if reducer == basicStateReducer then + -- Special case for `useState`. + if typeof(initialArg) == "function" then + initialState = initialArg() + else + initialState = initialArg + end + else + if init ~= nil then + initialState = init(initialArg) + else + initialState = initialArg + end + end + workInProgressHook.memoizedState = initialState + workInProgressHook.queue = { + first = nil, + dispatch = nil, + } + local queue = workInProgressHook.queue + queue.dispatch = function(...) + self:_dispatchAction(queue, ...) + end + local dispatch = queue.dispatch + return workInProgressHook.memoizedState, dispatch + end + end + + local function useState(initialState) + return useReducer( + basicStateReducer, + -- useReducer has a special case to support lazy useState initializers + initialState + ) + end + + local function useMemo(nextCreate, deps) + self:_validateCurrentlyRenderingComponent() + self:_createWorkInProgressHook() + + local nextDeps = deps + + if + self._workInProgressHook ~= nil + and self._workInProgressHook.memoizedState ~= nil + then + local prevState = self._workInProgressHook.memoizedState + local prevDeps = prevState[2] + if nextDeps ~= nil then + if areHookInputsEqual(nextDeps, prevDeps) then + return prevState[1] + end + end + end + + local nextValue = nextCreate() + self._workInProgressHook.memoizedState = { nextValue, nextDeps } + return nextValue + end + + local function useRef(initialValue) + self:_validateCurrentlyRenderingComponent() + self:_createWorkInProgressHook() + local previousRef = self._workInProgressHook.memoizedState + if previousRef == nil then + local ref = { current = initialValue } + if _G.__DEV__ then + Object.seal(ref) + end + self._workInProgressHook.memoizedState = ref + return ref + else + return previousRef + end + end + + local function readContext(context, _observedBits) + return context._currentValue + end + + local function noOp() + self:_validateCurrentlyRenderingComponent() + end + + local function identity(fn) + return fn + end + + local function useResponder(responder, props) + return { + props = props, + responder = responder, + } + end + + -- TODO: implement if we decide to keep the shallow renderer + local function useTransition(_config) + self:_validateCurrentlyRenderingComponent() + local startTransition = function(callback) + callback() + end + return { startTransition, false } + end + + -- TODO: implement if we decide to keep the shallow renderer + local function useDeferredValue(value, _config) + self:_validateCurrentlyRenderingComponent() + return value + end + + return { + readContext = readContext, + useCallback = identity, + useContext = function(context) + self:_validateCurrentlyRenderingComponent() + return readContext(context) + end, + useDebugValue = noOp, + useEffect = noOp, + useImperativeHandle = noOp, + useLayoutEffect = noOp, + useMemo = useMemo, + useReducer = useReducer, + useRef = useRef, + useState = useState, + useResponder = useResponder, + useTransition = useTransition, + useDeferredValue = useDeferredValue, + } end function ReactShallowRenderer:_dispatchAction(queue, action) - local numberOfRenders: number = self._numberOfReRenders - if numberOfRenders > RE_RENDER_LIMIT then - error(Error( - "Too many re-renders. React limits the number of renders to prevent an infinite loop." - )) - end - - if self._rendering then - -- This is a render phase update. Stash it in a lazily-created map of - -- queue -> linked list of updates. After self render pass, we'll restart - -- and apply the stashed updates on top of the work-in-progress hook. - self._didScheduleRenderPhaseUpdate = true - local update = { - action = action, - next = nil, - } - local renderPhaseUpdates = self._renderPhaseUpdates - if renderPhaseUpdates == nil then - renderPhaseUpdates = {} - self._renderPhaseUpdates = renderPhaseUpdates - end - local firstRenderPhaseUpdate = renderPhaseUpdates[queue] - if firstRenderPhaseUpdate == nil then - renderPhaseUpdates[queue] = update - else - -- Append the update to the end of the list. - local lastRenderPhaseUpdate = firstRenderPhaseUpdate - while lastRenderPhaseUpdate.next ~= nil do - lastRenderPhaseUpdate = lastRenderPhaseUpdate.next - end - lastRenderPhaseUpdate.next = update - end - else - local update = { - action = action, - next = nil, - } - - -- Append the update to the end of the list. - local last = queue.first - if last == nil then - queue.first = update - else - while last.next ~= nil do - last = last.next - end - last.next = update - end - - -- Re-render now. - self:render(self._element, self._context) - end + local numberOfRenders: number = self._numberOfReRenders + if numberOfRenders > RE_RENDER_LIMIT then + error( + Error( + "Too many re-renders. React limits the number of renders to prevent an infinite loop." + ) + ) + end + + if self._rendering then + -- This is a render phase update. Stash it in a lazily-created map of + -- queue -> linked list of updates. After self render pass, we'll restart + -- and apply the stashed updates on top of the work-in-progress hook. + self._didScheduleRenderPhaseUpdate = true + local update = { + action = action, + next = nil, + } + local renderPhaseUpdates = self._renderPhaseUpdates + if renderPhaseUpdates == nil then + renderPhaseUpdates = {} + self._renderPhaseUpdates = renderPhaseUpdates + end + local firstRenderPhaseUpdate = renderPhaseUpdates[queue] + if firstRenderPhaseUpdate == nil then + renderPhaseUpdates[queue] = update + else + -- Append the update to the end of the list. + local lastRenderPhaseUpdate = firstRenderPhaseUpdate + while lastRenderPhaseUpdate.next ~= nil do + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next + end + lastRenderPhaseUpdate.next = update + end + else + local update = { + action = action, + next = nil, + } + + -- Append the update to the end of the list. + local last = queue.first + if last == nil then + queue.first = update + else + while last.next ~= nil do + last = last.next + end + last.next = update + end + + -- Re-render now. + self:render(self._element, self._context) + end end function ReactShallowRenderer:_createWorkInProgressHook() - if self._workInProgressHook == nil then - -- This is the first hook in the list - if self._firstWorkInProgressHook == nil then - self._isReRender = false - self._workInProgressHook = createHook() - self._firstWorkInProgressHook = self._workInProgressHook - else - -- There's already a work-in-progress. Reuse it. - self._isReRender = true - self._workInProgressHook = self._firstWorkInProgressHook - end - else - if self._workInProgressHook.next == nil then - self._isReRender = false - -- Append to the end of the list - self._workInProgressHook.next = createHook() - self._workInProgressHook = self._workInProgressHook.next - else - -- There's already a work-in-progress. Reuse it. - self._isReRender = true - self._workInProgressHook = self._workInProgressHook.next - end - end - return self._workInProgressHook + if self._workInProgressHook == nil then + -- This is the first hook in the list + if self._firstWorkInProgressHook == nil then + self._isReRender = false + self._workInProgressHook = createHook() + self._firstWorkInProgressHook = self._workInProgressHook + else + -- There's already a work-in-progress. Reuse it. + self._isReRender = true + self._workInProgressHook = self._firstWorkInProgressHook + end + else + if self._workInProgressHook.next == nil then + self._isReRender = false + -- Append to the end of the list + self._workInProgressHook.next = createHook() + self._workInProgressHook = self._workInProgressHook.next + else + -- There's already a work-in-progress. Reuse it. + self._isReRender = true + self._workInProgressHook = self._workInProgressHook.next + end + end + return self._workInProgressHook end function ReactShallowRenderer:_finishHooks(element, context) - if self._didScheduleRenderPhaseUpdate then - -- Updates were scheduled during the render phase. They are stored in - -- the `renderPhaseUpdates` map. Call the component again, reusing the - -- work-in-progress hooks and applying the additional updates on top. Keep - -- restarting until no more updates are scheduled. - self._didScheduleRenderPhaseUpdate = false - self._numberOfReRenders += 1 - - -- Start over from the beginning of the list - self._workInProgressHook = nil - self._rendering = false - self:render(element, context) - else - self._workInProgressHook = nil - self._renderPhaseUpdates = nil - self._numberOfReRenders = 0 - end + if self._didScheduleRenderPhaseUpdate then + -- Updates were scheduled during the render phase. They are stored in + -- the `renderPhaseUpdates` map. Call the component again, reusing the + -- work-in-progress hooks and applying the additional updates on top. Keep + -- restarting until no more updates are scheduled. + self._didScheduleRenderPhaseUpdate = false + self._numberOfReRenders += 1 + + -- Start over from the beginning of the list + self._workInProgressHook = nil + self._rendering = false + self:render(element, context) + else + self._workInProgressHook = nil + self._renderPhaseUpdates = nil + self._numberOfReRenders = 0 + end end function ReactShallowRenderer:getMountedInstance() - return self._instance + return self._instance end function ReactShallowRenderer:getRenderOutput() - return self._rendered + return self._rendered end function ReactShallowRenderer:render(element, maybeContext) - local context = maybeContext or emptyObject - if not React.isValidElement(element) then - local message = "" - if typeof(element) == 'function' or (typeof(element) == "table" and element.__componentName ~= nil) then - message = " Instead of passing a component class, make sure to instantiate " .. - "it by passing it to React.createElement." - end - error(Error(string.format( - "ReactShallowRenderer render(): Invalid component element.%s", - message - ))) - end - -- Show a special message for host elements since it's a common case. - if not (typeof(element.type) ~= 'string') then - local elementType = element.type - error(Error(string.format( - "ReactShallowRenderer render(): Shallow rendering works only with custom components, not primitives (%s). Instead of calling `.render(el)` and inspecting the rendered output, look at `el.props` directly instead.", - tostring(elementType) - ))) - end - -- ROBLOX deviation: include check for isReactComponent since our "class" components - -- aren't functions like React's are - if - not ( - isForwardRef(element) or - typeof(element.type) == 'function' or - (typeof(element.type) == 'table' and element.type.isReactComponent == true) or - isMemo(element) - ) - then - local elementType = typeof(element.type) - if Array.isArray(element.type) then - elementType = "array" - end - error(Error(string.format( - "ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `%s`.", - elementType - ))) - end - - if self._rendering then - return - end - if self._element ~= nil and self._element.type ~= element.type then - self:_reset() - end - - local elementType - if isMemo(element) then - elementType = element.type.type - else - elementType = element.type - end - - local previousElement = self._element - self._rendering = true - self._element = element - -- ROBLOX deviation: functions can't have properties in Lua, so we can't access - -- `contextTypes` if `elementType` is a function; as far as I can tell, React - -- doesn't support `contextTypes` on function components anyways, so the - -- behavior should be compatible - local contextTypes - if typeof(elementType) == "table" then - contextTypes = elementType.contextTypes - end - self._context = getMaskedContext(contextTypes, context) - - -- Inner memo component props aren't currently validated in createElement. - local prevGetStack - if _G.__DEV__ then - prevGetStack = ReactDebugCurrentFrame.getCurrentStack - ReactDebugCurrentFrame.getCurrentStack = getStackAddendum - end - local ok, result = pcall(function() - if isMemo(element) and typeof(elementType) == "table" and elementType.propTypes then - currentlyValidatingElement = element - checkPropTypes( - elementType.propTypes, - element.props, - 'prop', - getComponentName(elementType) - ) - end - - if self._instance then - self:_updateClassComponent(elementType, element, self._context) - else - if shouldConstruct(elementType) then - -- ROBLOX deviation: we don't have 'new', so we need to enumerate the element - -- types we can support - if typeof(elementType) == 'function' then - self._instance = elementType( - element.props, - self._context, - self._updater - ) - else - if elementType.isReactComponent then - self._instance = elementType.__ctor( - element.props, - self._context, - self._updater - ) - end - end - if typeof(elementType) == "table" and typeof(elementType.getDerivedStateFromProps) == 'function' then - local partialState = elementType.getDerivedStateFromProps( - element.props, - self._instance.state - ) - if partialState ~= nil then - self._instance.state = Object.assign( - {}, - self._instance.state, - partialState - ) - end - end - - if typeof(elementType) == "table" and elementType.contextTypes then - currentlyValidatingElement = element - checkPropTypes( - elementType.contextTypes, - self._context, - 'context', - getName(elementType, self._instance) - ) - - currentlyValidatingElement = nil - end - - self:_mountClassComponent(elementType, element, self._context) - else - local shouldRender = true - if isMemo(element) and previousElement ~= nil then - -- This is a Memo component that is being re-rendered. - local compare = element.type.compare or shallowEqual - if compare(previousElement.props, element.props) then - shouldRender = false - end - end - if shouldRender then - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = self._dispatcher - local ok, result = pcall(function() - -- elementType could still be a ForwardRef if it was - -- nested inside Memo. - if typeof(elementType) == "table" and elementType["$$typeof"] == ForwardRef then - if typeof(elementType.render) ~= 'function' then - error(Error(string.format( - "forwardRef requires a render function but was given %s.", - typeof(elementType.render) - ))) - end - self._rendered = elementType.render( - element.props, - element.ref - ) - else - - self._rendered = elementType(element.props, self._context) - end - end) - - -- finally - ReactCurrentDispatcher.current = prevDispatcher - - -- no catch, so we throw again - if not ok then - error(result) - end - - self:_finishHooks(element, context) - end - end - end - end) - - -- finally - if _G.__DEV__ then - ReactDebugCurrentFrame.getCurrentStack = prevGetStack - end - - -- no catch, so we throw after resolving the 'finally' - if not ok then - error(result) - end - - self._rendering = false - self._updater._invokeCallbacks() - - return self:getRenderOutput() + local context = maybeContext or emptyObject + if not React.isValidElement(element) then + local message = "" + if + typeof(element) == "function" + or (typeof(element) == "table" and element.__componentName ~= nil) + then + message = " Instead of passing a component class, make sure to instantiate " + .. "it by passing it to React.createElement." + end + error( + Error( + string.format( + "ReactShallowRenderer render(): Invalid component element.%s", + message + ) + ) + ) + end + -- Show a special message for host elements since it's a common case. + if not (typeof(element.type) ~= "string") then + local elementType = element.type + error( + Error( + string.format( + "ReactShallowRenderer render(): Shallow rendering works only with custom components, not primitives (%s). Instead of calling `.render(el)` and inspecting the rendered output, look at `el.props` directly instead.", + tostring(elementType) + ) + ) + ) + end + -- ROBLOX deviation: include check for isReactComponent since our "class" components + -- aren't functions like React's are + if + not ( + isForwardRef(element) + or typeof(element.type) == "function" + or (typeof(element.type) == "table" and element.type.isReactComponent == true) + or isMemo(element) + ) + then + local elementType = typeof(element.type) + if Array.isArray(element.type) then + elementType = "array" + end + error( + Error( + string.format( + "ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `%s`.", + elementType + ) + ) + ) + end + + if self._rendering then + return + end + if self._element ~= nil and self._element.type ~= element.type then + self:_reset() + end + + local elementType + if isMemo(element) then + elementType = element.type.type + else + elementType = element.type + end + + local previousElement = self._element + self._rendering = true + self._element = element + -- ROBLOX deviation: functions can't have properties in Lua, so we can't access + -- `contextTypes` if `elementType` is a function; as far as I can tell, React + -- doesn't support `contextTypes` on function components anyways, so the + -- behavior should be compatible + local contextTypes + if typeof(elementType) == "table" then + contextTypes = elementType.contextTypes + end + self._context = getMaskedContext(contextTypes, context) + + -- Inner memo component props aren't currently validated in createElement. + local prevGetStack + if _G.__DEV__ then + prevGetStack = ReactDebugCurrentFrame.getCurrentStack + ReactDebugCurrentFrame.getCurrentStack = getStackAddendum + end + local ok, result = pcall(function() + if + isMemo(element) + and typeof(elementType) == "table" + and elementType.propTypes + then + currentlyValidatingElement = element + checkPropTypes( + elementType.propTypes, + element.props, + "prop", + getComponentName(elementType) + ) + end + + if self._instance then + self:_updateClassComponent(elementType, element, self._context) + else + if shouldConstruct(elementType) then + -- ROBLOX deviation: we don't have 'new', so we need to enumerate the element + -- types we can support + if typeof(elementType) == "function" then + self._instance = elementType( + element.props, + self._context, + self._updater + ) + else + if elementType.isReactComponent then + self._instance = elementType.__ctor( + element.props, + self._context, + self._updater + ) + end + end + if + typeof(elementType) == "table" + and typeof(elementType.getDerivedStateFromProps) == "function" + then + local partialState = elementType.getDerivedStateFromProps( + element.props, + self._instance.state + ) + if partialState ~= nil then + self._instance.state = Object.assign( + {}, + self._instance.state, + partialState + ) + end + end + + if typeof(elementType) == "table" and elementType.contextTypes then + currentlyValidatingElement = element + checkPropTypes( + elementType.contextTypes, + self._context, + "context", + getName(elementType, self._instance) + ) + + currentlyValidatingElement = nil + end + + self:_mountClassComponent(elementType, element, self._context) + else + local shouldRender = true + if isMemo(element) and previousElement ~= nil then + -- This is a Memo component that is being re-rendered. + local compare = element.type.compare or shallowEqual + if compare(previousElement.props, element.props) then + shouldRender = false + end + end + if shouldRender then + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = self._dispatcher + local ok, result = pcall(function() + -- elementType could still be a ForwardRef if it was + -- nested inside Memo. + if + typeof(elementType) == "table" + and elementType["$$typeof"] == ForwardRef + then + if typeof(elementType.render) ~= "function" then + error( + Error( + string.format( + "forwardRef requires a render function but was given %s.", + typeof(elementType.render) + ) + ) + ) + end + self._rendered = elementType.render( + element.props, + element.ref + ) + else + self._rendered = elementType(element.props, self._context) + end + end) + + -- finally + ReactCurrentDispatcher.current = prevDispatcher + + -- no catch, so we throw again + if not ok then + error(result) + end + + self:_finishHooks(element, context) + end + end + end + end) + + -- finally + if _G.__DEV__ then + ReactDebugCurrentFrame.getCurrentStack = prevGetStack + end + + -- no catch, so we throw after resolving the 'finally' + if not ok then + error(result) + end + + self._rendering = false + self._updater._invokeCallbacks() + + return self:getRenderOutput() end function ReactShallowRenderer:unmount() - if self._instance then - if typeof(self._instance.componentWillUnmount) == 'function' then - self._instance:componentWillUnmount() - end - end - self:_reset() + if self._instance then + if typeof(self._instance.componentWillUnmount) == "function" then + self._instance:componentWillUnmount() + end + end + self:_reset() end function ReactShallowRenderer:_mountClassComponent(elementType, element, context) - self._instance.context = context - self._instance.props = element.props - self._instance.state = self._instance.state or nil - self._instance.updater = self._updater - - if - typeof(self._instance.UNSAFE_componentWillMount) == 'function' or - typeof(self._instance.componentWillMount) == 'function' - then - local beforeState = self._newState - - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - if - typeof(elementType.getDerivedStateFromProps) ~= 'function' and - typeof(self._instance.getSnapshotBeforeUpdate) ~= 'function' - then - if typeof(self._instance.componentWillMount) == 'function' then - self._instance:componentWillMount() - end - if typeof(self._instance.UNSAFE_componentWillMount) == 'function' then - self._instance:UNSAFE_componentWillMount() - end - end - - -- setState may have been called during cWM - if beforeState ~= self._newState then - self._instance.state = self._newState or emptyObject - end - end - - self._rendered = self._instance:render() - -- Intentionally do not call componentDidMount() - -- because DOM refs are not available. + self._instance.context = context + self._instance.props = element.props + self._instance.state = self._instance.state or nil + self._instance.updater = self._updater + + if + typeof(self._instance.UNSAFE_componentWillMount) == "function" + or typeof(self._instance.componentWillMount) == "function" + then + local beforeState = self._newState + + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + if + typeof(elementType.getDerivedStateFromProps) ~= "function" + and typeof(self._instance.getSnapshotBeforeUpdate) ~= "function" + then + if typeof(self._instance.componentWillMount) == "function" then + self._instance:componentWillMount() + end + if typeof(self._instance.UNSAFE_componentWillMount) == "function" then + self._instance:UNSAFE_componentWillMount() + end + end + + -- setState may have been called during cWM + if beforeState ~= self._newState then + self._instance.state = self._newState or emptyObject + end + end + + self._rendered = self._instance:render() + -- Intentionally do not call componentDidMount() + -- because DOM refs are not available. end function ReactShallowRenderer:_updateClassComponent(elementType, element, context) - local props = element.props - - local oldState = self._instance.state or emptyObject - local oldProps = self._instance.props - - if oldProps ~= props then - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - if - typeof(elementType.getDerivedStateFromProps) ~= 'function' and - typeof(self._instance.getSnapshotBeforeUpdate) ~= 'function' - then - if typeof(self._instance.componentWillReceiveProps) == 'function' then - self._instance:componentWillReceiveProps(props, context) - end - if - typeof(self._instance.UNSAFE_componentWillReceiveProps) == 'function' - then - self._instance:UNSAFE_componentWillReceiveProps(props, context) - end - end - end - - -- Read state after cWRP in case it calls setState - local state = self._newState or oldState - if typeof(elementType.getDerivedStateFromProps) == 'function' then - local partialState = elementType.getDerivedStateFromProps( - props, - state - ) - if partialState ~= nil then - state = Object.assign({}, state, partialState) - end - end - - local shouldUpdate = true - if self._forcedUpdate then - shouldUpdate = true - self._forcedUpdate = false - elseif typeof(self._instance.shouldComponentUpdate) == 'function' then - shouldUpdate = not not self._instance:shouldComponentUpdate( - props, - state, - context - ) - elseif - typeof(elementType) == "table" and - elementType.isPureReactComponent - then - shouldUpdate = - not shallowEqual(oldProps, props) or not shallowEqual(oldState, state) - end - - if shouldUpdate then - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - if - typeof(elementType.getDerivedStateFromProps) ~= 'function' and - typeof(self._instance.getSnapshotBeforeUpdate) ~= 'function' - then - if typeof(self._instance.componentWillUpdate) == 'function' then - self._instance:componentWillUpdate(props, state, context) - end - if typeof(self._instance.UNSAFE_componentWillUpdate) == 'function' then - self._instance:UNSAFE_componentWillUpdate(props, state, context) - end - end - end - - self._instance.context = context - self._instance.props = props - self._instance.state = state - self._newState = nil - - if shouldUpdate then - self._rendered = self._instance:render() - end - -- Intentionally do not call componentDidUpdate() - -- because DOM refs are not available. + local props = element.props + + local oldState = self._instance.state or emptyObject + local oldProps = self._instance.props + + if oldProps ~= props then + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + if + typeof(elementType.getDerivedStateFromProps) ~= "function" + and typeof(self._instance.getSnapshotBeforeUpdate) ~= "function" + then + if typeof(self._instance.componentWillReceiveProps) == "function" then + self._instance:componentWillReceiveProps(props, context) + end + if typeof(self._instance.UNSAFE_componentWillReceiveProps) == "function" then + self._instance:UNSAFE_componentWillReceiveProps(props, context) + end + end + end + + -- Read state after cWRP in case it calls setState + local state = self._newState or oldState + if typeof(elementType.getDerivedStateFromProps) == "function" then + local partialState = elementType.getDerivedStateFromProps(props, state) + if partialState ~= nil then + state = Object.assign({}, state, partialState) + end + end + + local shouldUpdate = true + if self._forcedUpdate then + shouldUpdate = true + self._forcedUpdate = false + elseif typeof(self._instance.shouldComponentUpdate) == "function" then + shouldUpdate = not not self._instance:shouldComponentUpdate(props, state, context) + elseif typeof(elementType) == "table" and elementType.isPureReactComponent then + shouldUpdate = not shallowEqual(oldProps, props) + or not shallowEqual(oldState, state) + end + + if shouldUpdate then + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + if + typeof(elementType.getDerivedStateFromProps) ~= "function" + and typeof(self._instance.getSnapshotBeforeUpdate) ~= "function" + then + if typeof(self._instance.componentWillUpdate) == "function" then + self._instance:componentWillUpdate(props, state, context) + end + if typeof(self._instance.UNSAFE_componentWillUpdate) == "function" then + self._instance:UNSAFE_componentWillUpdate(props, state, context) + end + end + end + + self._instance.context = context + self._instance.props = props + self._instance.state = state + self._newState = nil + + if shouldUpdate then + self._rendered = self._instance:render() + end + -- Intentionally do not call componentDidUpdate() + -- because DOM refs are not available. end function getDisplayName(element) - if element == nil then - return '#empty' - elseif typeof(element) == 'string' or typeof(element) == 'number' then - return '#text' - elseif typeof(element.type) == 'string' then - return element.type - else - local elementType - if isMemo(element) then - elementType = element.type.type - else - elementType = element.type - end - return elementType.displayName or elementType.name or 'Unknown' - end + if element == nil then + return "#empty" + elseif typeof(element) == "string" or typeof(element) == "number" then + return "#text" + elseif typeof(element.type) == "string" then + return element.type + else + local elementType + if isMemo(element) then + elementType = element.type.type + else + elementType = element.type + end + return elementType.displayName or elementType.name or "Unknown" + end end function getStackAddendum() - local stack = '' - if currentlyValidatingElement then - local name = getDisplayName(currentlyValidatingElement) - local owner = currentlyValidatingElement._owner - stack ..= describeComponentFrame( - name, - currentlyValidatingElement._source, - owner and getComponentName(owner.type) - ) - end - return stack + local stack = "" + if currentlyValidatingElement then + local name = getDisplayName(currentlyValidatingElement) + local owner = currentlyValidatingElement._owner + stack ..= describeComponentFrame( + name, + currentlyValidatingElement._source, + owner and getComponentName(owner.type) + ) + end + return stack end function getName(type, instance) - local constructor = instance and instance.constructor - return - type.displayName or - (constructor and constructor.displayName) or - type.name or - (constructor and constructor.name) or - nil + local constructor = instance and instance.constructor + return type.displayName + or (constructor and constructor.displayName) + or type.name + or (constructor and constructor.name) + or nil end function shouldConstruct(Component) - return not not (typeof(Component) == "table" and Component.isReactComponent) + return not not (typeof(Component) == "table" and Component.isReactComponent) end function getMaskedContext(contextTypes, unmaskedContext) - if not contextTypes and not unmaskedContext then - return emptyObject - end - if contextTypes and not unmaskedContext then - return emptyObject - end - -- ROBLOX deviation: we can't mask context types for function components, so be 'unsafe' to make tests pass - if not contextTypes and unmaskedContext then - contextTypes = unmaskedContext - end - - local context = {} - for key, _ in pairs(contextTypes) do - context[key] = unmaskedContext[key] - end - return context + if not contextTypes and not unmaskedContext then + return emptyObject + end + if contextTypes and not unmaskedContext then + return emptyObject + end + -- ROBLOX deviation: we can't mask context types for function components, so be 'unsafe' to make tests pass + if not contextTypes and unmaskedContext then + contextTypes = unmaskedContext + end + + local context = {} + for key, _ in pairs(contextTypes) do + context[key] = unmaskedContext[key] + end + return context end return ReactShallowRenderer From 52c07313bee4473fb3e811e827c3a94becf3b1e4 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Sun, 11 Jul 2021 17:04:44 -0700 Subject: [PATCH 015/289] #NOJIRA Luau analyze regression fixes (#139) * Fix legitimate new warnings from latest roblox-cli. * Consistently export same shape as __DEV__ and non-DEV to silence Luau warnings. * Align types from upstream to silence Luau warning. * Shore up adjacent missing type annotations. * Work around Luau false positive. --- .../__tests__/ReactCacheOld-internal.spec.lua | 2 +- .../src/ReactFiberBeginWork.new.lua | 5 +++- .../src/forks/ReactFiberHostConfig.test.lua | 26 +++++++++---------- modules/react/src/ReactContext.lua | 15 ++++++++--- modules/react/src/ReactForwardRef.lua | 9 ++++--- modules/react/src/ReactHooks.lua | 3 ++- modules/react/src/ReactLazy.lua | 12 ++++----- modules/react/src/ReactMemo.lua | 16 +++++++++--- .../__tests__/forwardRef-internal.spec.lua | 2 +- .../__tests__/ModuleValidation.spec.lua | 2 +- modules/scheduler/src/init.lua | 5 +++- .../shared/src/ReactSharedInternals/init.lua | 26 ++++++++++++++----- 12 files changed, 81 insertions(+), 42 deletions(-) diff --git a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua index 8d4dbd92..c8c69628 100644 --- a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua +++ b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua @@ -61,7 +61,7 @@ return function() string.format("Promise rejected [%s]", text) ) status = "rejected" - value = LuauPolyfill.Error( + value = LuauPolyfill.Error.new( "Failed to load: " .. text ) for _, listener in ipairs(listeners) do diff --git a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua index 3de4d84d..ed2a872a 100644 --- a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua @@ -243,7 +243,7 @@ local getExecutionContext = ReactFiberWorkLoop.getExecutionContext local RetryAfterError = ReactFiberWorkLoop.RetryAfterError local NoContext = ReactFiberWorkLoop.NoContext -local Schedule_tracing_wrap = require(Packages.Scheduler).tracing.unstable_wrap +local Schedule_tracing_wrap local setWorkInProgressVersion = require(script.Parent["ReactMutableSource.new"]).setWorkInProgressVersion local markSkippedUpdateLanes = require(script.Parent.ReactFiberWorkInProgress).markSkippedUpdateLanes local ConsolePatchingDev = require(Packages.Shared).ConsolePatchingDev @@ -2565,6 +2565,9 @@ function updateDehydratedSuspenseComponent( end if enableSchedulerTracing then + if Schedule_tracing_wrap == nil then + Schedule_tracing_wrap = require(Packages.Scheduler).tracing.unstable_wrap + end retry = Schedule_tracing_wrap(retry) end diff --git a/modules/react-reconciler/src/forks/ReactFiberHostConfig.test.lua b/modules/react-reconciler/src/forks/ReactFiberHostConfig.test.lua index eab1cd41..b7a17868 100644 --- a/modules/react-reconciler/src/forks/ReactFiberHostConfig.test.lua +++ b/modules/react-reconciler/src/forks/ReactFiberHostConfig.test.lua @@ -1,13 +1,13 @@ --- upstream: https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-reconciler/src/forks/ReactFiberHostConfig.test.js ---[[* - * 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. - * - * @flow -]] - -local Packages = script.Parent.Parent.Parent - -return require(Packages.ReactTestRenderer) +-- upstream: https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-reconciler/src/forks/ReactFiberHostConfig.test.js +--[[* + * 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. + * + * @flow +]] + +local Packages = script.Parent.Parent.Parent + +return require(Packages.Dev.ReactTestRenderer) diff --git a/modules/react/src/ReactContext.lua b/modules/react/src/ReactContext.lua index e8c1448b..ab811123 100644 --- a/modules/react/src/ReactContext.lua +++ b/modules/react/src/ReactContext.lua @@ -9,16 +9,25 @@ ]] local Packages = script.Parent.Parent -- ROBLOX: use patched console from shared -local console = require(Packages.Shared).console +local Shared = require(Packages.Shared) +local console = Shared.console local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_PROVIDER_TYPE = ReactSymbols.REACT_PROVIDER_TYPE local REACT_CONTEXT_TYPE = ReactSymbols.REACT_CONTEXT_TYPE - +type ReactContext = Shared.ReactContext local exports = {} -exports.createContext = function(defaultValue, calculateChangedBits) +-- ROBLOX TODO: function generics +-- export function createContext( +-- defaultValue: T, +-- calculateChangedBits: ?(a: T, b: T) => number, +-- ): ReactContext { +exports.createContext = function( + defaultValue, + calculateChangedBits: ((any, any) -> number)? +): ReactContext if calculateChangedBits == nil then calculateChangedBits = nil else diff --git a/modules/react/src/ReactForwardRef.lua b/modules/react/src/ReactForwardRef.lua index 96727a08..70e5d64a 100644 --- a/modules/react/src/ReactForwardRef.lua +++ b/modules/react/src/ReactForwardRef.lua @@ -14,8 +14,11 @@ local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_FORWARD_REF_TYPE = ReactSymbols.REACT_FORWARD_REF_TYPE local REACT_MEMO_TYPE = ReactSymbols.REACT_MEMO_TYPE --- ROBLOX TODO: Luau doesn't have function generics yet local exports = {} +-- ROBLOX TODO: Luau doesn't have function generics yet +-- export function forwardRef( +-- render: (props: Props, ref: React$Ref) => React$Node, +-- ) { exports.forwardRef = function(render) if _G.__DEV__ then -- ROBLOX deviation: Lua functions can't have properties @@ -65,7 +68,7 @@ exports.forwardRef = function(render) } if _G.__DEV__ then local ownName - -- deviation: use metatables to approximate Object.defineProperty logic + -- ROBLOX deviation: use metatables to approximate Object.defineProperty logic setmetatable(elementType, { __index = function(self, key) if key == "displayName" then @@ -76,7 +79,7 @@ exports.forwardRef = function(render) __newindex = function(self, key, value) if key == "displayName" then ownName = value - -- deviation: render is a function and cannot have properties + -- ROBLOX deviation: render is a function and cannot have properties -- if (render.displayName == null) { -- render.displayName = name; -- } diff --git a/modules/react/src/ReactHooks.lua b/modules/react/src/ReactHooks.lua index c7c569ca..1809a0fd 100644 --- a/modules/react/src/ReactHooks.lua +++ b/modules/react/src/ReactHooks.lua @@ -40,7 +40,8 @@ local exports = {} -- ROBLOX TODO: Luau doesn't support function generics yet exports.useContext = function( Context: ReactContext, - unstable_observedBits: number | boolean | nil,realContext, + unstable_observedBits: number | boolean | nil, + realContext, ... -- ROBLOX deviation: Lua must specify ... here to capture additional args ) local dispatcher = resolveDispatcher() diff --git a/modules/react/src/ReactLazy.lua b/modules/react/src/ReactLazy.lua index 366a0693..8156d9d9 100644 --- a/modules/react/src/ReactLazy.lua +++ b/modules/react/src/ReactLazy.lua @@ -11,6 +11,7 @@ local Packages = script.Parent.Parent -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console + local ReactTypes = require(Packages.Shared) type Wakeable = ReactTypes.Wakeable type Thenable = ReactTypes.Thenable @@ -57,13 +58,12 @@ type Payload = | ResolvedPayload | RejectedPayload -export type LazyComponent = any -- { --- ROBLOX FIXME: Luau can't express type keys with special chars --- -- $$typeof: Symbol | number, --- _payload: P, --- _init: (P) -> T, +export type LazyComponent = { + [string]: number, -- ROBLOX deviation: we don't use Symbol for $$typeof + _payload: P, + _init: (P) -> T, -- ... --- } +} -- ROBLOX TODO: function generics -- function lazyInitializer(payload: Payload): T { diff --git a/modules/react/src/ReactMemo.lua b/modules/react/src/ReactMemo.lua index f682fe87..1925a605 100644 --- a/modules/react/src/ReactMemo.lua +++ b/modules/react/src/ReactMemo.lua @@ -15,7 +15,15 @@ local isValidElementType = require(Packages.Shared).isValidElementType local exports = {} -exports.memo = function(_type, compare) +-- ROBLOX TODO: use function generics +-- export function memo( +-- type: React$ElementType, +-- compare?: (oldProps: Props, newProps: Props) => boolean, +-- ) { +exports.memo = function( + type_, + compare: ((any, any) -> boolean)? +) if _G.__DEV__ then if not isValidElementType(type) then console.error( @@ -27,7 +35,7 @@ exports.memo = function(_type, compare) local elementType = { ["$$typeof"] = REACT_MEMO_TYPE, - type = _type, + type = type_, compare = compare or nil, } @@ -41,8 +49,8 @@ exports.memo = function(_type, compare) local name = ({ ... })[1] ownName = name - if _type.displayName == nil then - _type.displayName = name + if type_.displayName == nil then + type_.displayName = name end return nil diff --git a/modules/react/src/__tests__/forwardRef-internal.spec.lua b/modules/react/src/__tests__/forwardRef-internal.spec.lua index 299c3460..a148ded7 100644 --- a/modules/react/src/__tests__/forwardRef-internal.spec.lua +++ b/modules/react/src/__tests__/forwardRef-internal.spec.lua @@ -165,7 +165,7 @@ return function() local BadRender = React.Component:extend("BadRender") function BadRender:render() Scheduler.unstable_yieldValue("BadRender throw") - error(Error("oops!")) + error(Error.new("oops!")) end local function Wrapper(props) diff --git a/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua b/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua index 76fca641..a758d4e0 100644 --- a/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua +++ b/modules/roblox-jest/src/Module/__tests__/ModuleValidation.spec.lua @@ -50,7 +50,7 @@ return function() it("should throw an error when a module returns none", function() jestExpect(function() - Module.requireOverride(script.Parent.TestScripts.NoReturn) + Module.requireOverride(script.Parent.TestScripts.NoReturn :: any) end).toThrow("NoReturn") end) diff --git a/modules/scheduler/src/init.lua b/modules/scheduler/src/init.lua index 7ee7f657..f36cb53e 100644 --- a/modules/scheduler/src/init.lua +++ b/modules/scheduler/src/init.lua @@ -38,8 +38,11 @@ local exports = { unstable_clearYields = onlyInTestError("unstable_clearYields"), unstable_advanceTime = onlyInTestError("unstable_advanceTime"), unstable_flushExpired = onlyInTestError("unstable_flushExpired"), + unstable_yieldValue = onlyInTestError("unstable_yieldValue"), - tracing = {} + tracing = { + unstable_wrap = onlyInTestError("unstable_wrap") + } } for k, v in pairs(Tracing) do diff --git a/modules/shared/src/ReactSharedInternals/init.lua b/modules/shared/src/ReactSharedInternals/init.lua index c1781bf2..6f068e89 100644 --- a/modules/shared/src/ReactSharedInternals/init.lua +++ b/modules/shared/src/ReactSharedInternals/init.lua @@ -21,6 +21,14 @@ * React depends on Shared * Shared has no intra-workspace dependencies (no cycles) ]] +local Packages = script.Parent.Parent +local console = require(Packages.LuauPolyfill).console +local function onlyInTestError(functionName: string) + return function() + console.error(functionName .. " is only available in tests, not in production") + end +end + -- import assign from 'object-assign'; local ReactCurrentDispatcher = require(script.ReactCurrentDispatcher) @@ -34,16 +42,20 @@ local ReactSharedInternals = { ReactCurrentBatchConfig = ReactCurrentBatchConfig, ReactCurrentOwner = ReactCurrentOwner, IsSomeRendererActing = IsSomeRendererActing, - -- deviation: This is understood by Luau as a sealed table, so we specify - -- this key to make it safe to assign to it below - ReactDebugCurrentFrame = {}, + -- ROBLOX deviation: Luau type checking requires us to have a consistent export shape regardless of __DEV__ + ReactDebugCurrentFrame = (function() + if _G.__DEV__ then + return ReactDebugCurrentFrame + end + return { + setExtraStackFrame = function() + onlyInTestError("setExtraStackFrame") + end + } + end)(), -- deviation: We shouldn't have to worry about duplicate bundling here -- Used by renderers to avoid bundling object-assign twice in UMD bundles: -- assign, } -if _G.__DEV__ then - ReactSharedInternals.ReactDebugCurrentFrame = ReactDebugCurrentFrame -end - return ReactSharedInternals From 89ce5bf9e13d3077102277750ea7e09d3905d08b Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Mon, 12 Jul 2021 19:34:08 +0100 Subject: [PATCH 016/289] #nojira Bugfix Correct the name mapping of the export unstable_shouldYield in Scheduler init.lua (#142) * Correct the name mapping of the export unstable_shouldYield in Scheduler init.lua --- .../react-reconciler/src/SchedulerWithReactIntegration.new.lua | 1 - modules/scheduler/src/init.lua | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua b/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua index e363f154..3333d2f4 100644 --- a/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua +++ b/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua @@ -1,5 +1,4 @@ -- upstream: https://github.com/facebook/react/blob/d17086c7c813402a550d15a2f56dc43f1dbd1735/packages/react-reconciler/src/SchedulerWithReactIntegration.new.js --- upstream https://github.com/facebook/react/blob/d17086c7c813402a550d15a2f56dc43f1dbd1735/packages/react-reconciler/src/SchedulerWithReactIntegration.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * diff --git a/modules/scheduler/src/init.lua b/modules/scheduler/src/init.lua index f36cb53e..3d9a6eae 100644 --- a/modules/scheduler/src/init.lua +++ b/modules/scheduler/src/init.lua @@ -25,7 +25,7 @@ local exports = { unstable_cancelCallback = Scheduler.unstable_cancelCallback, unstable_wrapCallback = Scheduler.unstable_wrapCallback, unstable_getCurrentPriorityLevel = Scheduler.unstable_getCurrentPriorityLevel, - unstable_shouldYield = Scheduler.unstable_shouldYieldToHost, + unstable_shouldYield = Scheduler.unstable_shouldYield, unstable_requestPaint = Scheduler.unstable_requestPaint, unstable_continueExecution = Scheduler.unstable_continueExecution, unstable_pauseExecution = Scheduler.unstable_pauseExecution, From d6b498487dcbbad0d811532f07a9c00f5d676092 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Mon, 12 Jul 2021 16:50:45 -0700 Subject: [PATCH 017/289] LUAFDN 373: Compatibility for validateProps (#131) * Add validateProps to checkPropTypes * Make validateProps second argument of checkPropTypes * Adds tests for propTypes and validateProps --- DEVIATIONS.md | 7 + docs/deviations.md | 3 + modules/react-cache/src/ReactCacheOld.lua | 1 - .../src/ReactFiberBeginWork.new.lua | 79 ++++-- .../src/ReactFiberContext.new.lua | 6 +- modules/react-shallow-renderer/src/init.lua | 48 ++-- modules/react/src/ReactElement.lua | 2 +- modules/react/src/ReactElementValidator.lua | 22 +- modules/shared/rotriever.toml | 4 + .../__tests__/checkPropTypes.roblox.spec.lua | 249 ++++++++++++++++++ modules/shared/src/__tests__/init.spec.lua | 17 ++ modules/shared/src/checkPropTypes.lua | 152 ++++++----- 12 files changed, 471 insertions(+), 119 deletions(-) create mode 100644 modules/shared/src/__tests__/checkPropTypes.roblox.spec.lua create mode 100644 modules/shared/src/__tests__/init.spec.lua diff --git a/DEVIATIONS.md b/DEVIATIONS.md index 5df019b0..302e97b0 100644 --- a/DEVIATIONS.md +++ b/DEVIATIONS.md @@ -212,6 +212,13 @@ The context consumer api doesn't match that of Roact's createContext context con We've provided support for both interfaces. Resolution and more info at https://github.com/Roblox/roact-alignment/pull/119 +### validateProps +**Status:** ✔️ Resolved +
+#### Implemented Alignment +Roact 17 supports both propTypes and validateProps. `checkPropTypes` method was expanded to include logic for validateProps. For full details, see [#131](https://github.com/Roblox/roact-alignment/pull/131). +
+ ### createFragment **Status:** ✔️ Resolved
diff --git a/docs/deviations.md b/docs/deviations.md index d14f5325..a18a9c03 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -20,5 +20,8 @@ For the time being, function components do not support the `defaultProps` featur ### propTypes For the time being, function components do not support the `propTypes` feature. While propTypes is less often used and can in many cases be superseded by static type checking, we may want to, in the future, re-implement it in terms of hooks to make sure that function components with hooks are as appealing and feature-rich as possible. +### validateProps +For the time being, we will continue to support legacy Roact's `validateProps`. Old Roact's documentation on this method can be found [here](https://roblox.github.io/roact/api-reference/#validateprops). + ### contextTypes For the time being, function components do not support the `contextTypes` feature. While contextTypes is less often used and can in many cases be superseded by static type checking, we may want to, in the future, re-implement it in terms of hooks to make sure that function components with hooks are as appealing and feature-rich as possible. diff --git a/modules/react-cache/src/ReactCacheOld.lua b/modules/react-cache/src/ReactCacheOld.lua index 5e5d8446..c2e76e97 100644 --- a/modules/react-cache/src/ReactCacheOld.lua +++ b/modules/react-cache/src/ReactCacheOld.lua @@ -75,7 +75,6 @@ local function identityHashFn(input) and typeof(input) ~= "number" and typeof(input) ~= "boolean" and input ~= nil - and input ~= nil then console.error( "Invalid key type. Expected a string, number, symbol, or boolean, " diff --git a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua index ed2a872a..69489dc6 100644 --- a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua @@ -369,10 +369,13 @@ local function updateForwardRef( if workInProgress.type ~= workInProgress.elementType then -- Lazy component props can't be validated in createElement -- because they're only guaranteed to be resolved here. + -- ROBLOX deviation: adds support for legacy Roact's validateProps() local innerPropTypes = Component.propTypes - if innerPropTypes then + local validateProps = Component.validateProps + if innerPropTypes or validateProps then checkPropTypes( innerPropTypes, + validateProps, nextProps, -- Resolved props "prop", getComponentName(Component) @@ -481,13 +484,21 @@ local function updateMemoComponent( ) end if _G.__DEV__ then + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local innerPropTypes + local validateProps -- ROBLOX deviation: avoid accessing propTypes on a function, Lua doesn't support fields on functions - local innerPropTypes = typeof(type) == "table" and type.propTypes - if innerPropTypes then + if typeof(type) == "table" then + innerPropTypes = type.propTypes + validateProps = type.validateProps + end + + if innerPropTypes or validateProps then -- Inner memo component props aren't currently validated in createElement. -- We could move it there, but we'd still need this for lazy code path. checkPropTypes( innerPropTypes, + validateProps, nextProps, -- Resolved props "prop", getComponentName(type) @@ -512,13 +523,21 @@ local function updateMemoComponent( local current = (current :: Fiber) if _G.__DEV__ then local type = Component.type + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local innerPropTypes + local validateProps -- ROBLOX deviation: only check for propTypes on class components, Lua doesn't support fields on functions - local innerPropTypes = typeof(type) == "table" and type.propTypes - if innerPropTypes then + if typeof(type) == "table" then + innerPropTypes = type.propTypes + validateProps = type.validateProps + end + + if innerPropTypes or validateProps then -- Inner memo component props aren't currently validated in createElement. -- We could move it there, but we'd still need this for lazy code path. checkPropTypes( innerPropTypes, + validateProps, nextProps, -- Resolved props 'prop', getComponentName(type) @@ -579,11 +598,19 @@ function updateSimpleMemoComponent( outerMemoType = nil end -- Inner propTypes will be validated in the function component path. + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local outerPropTypes + local validateProps -- ROBLOX deviation: avoid accessing propTypes on a function, Lua doesn't support fields on functions - local outerPropTypes = typeof(outerMemoType) == "table" and (outerMemoType :: any).propTypes - if outerPropTypes then + if typeof(type) == "table" then + outerPropTypes = (outerMemoType :: any).propTypes + validateProps = (outerMemoType :: any).validateProps + end + + if outerPropTypes or validateProps then checkPropTypes( outerPropTypes, + validateProps, nextProps, -- Resolved (SimpleMemoComponent has no defaultProps) "prop", getComponentName(outerMemoType) @@ -803,14 +830,19 @@ updateFunctionComponent = function( if typeof(Component) ~= 'function' and (workInProgress.type ~= workInProgress.elementType) then -- Lazy component props can't be validated in createElement -- because they're only guaranteed to be resolved here. + -- ROBLOX deviation: adds support for legacy Roact's validateProps() local innerPropTypes + local validateProps -- ROBLOX deviation: Roact won't support propTypes on functional components - if typeof(Component) == "table" then - innerPropTypes = Component.propTypes + if typeof(type) == "table" then + innerPropTypes = (type :: any).propTypes + validateProps = (type :: any).validateProps end - if innerPropTypes then + + if innerPropTypes or validateProps then checkPropTypes( innerPropTypes, + validateProps, nextProps, -- Resolved props 'prop', getComponentName(Component) @@ -967,10 +999,13 @@ local function updateClassComponent( if workInProgress.type ~= workInProgress.elementType then -- Lazy component props can't be validated in createElement -- because they're only guaranteed to be resolved here. + -- ROBLOX deviation: adds support for legacy Roact's validateProps() local innerPropTypes = Component.propTypes - if innerPropTypes then + local validateProps = Component.validateProps + if innerPropTypes or validateProps then checkPropTypes( innerPropTypes, + validateProps, nextProps, -- Resolved props "prop", getComponentName(Component) @@ -1372,10 +1407,13 @@ local function mountLazyComponent( elseif resolvedTag == MemoComponent then if _G.__DEV__ then if workInProgress.type ~= workInProgress.elementType then + -- ROBLOX deviation: adds support for legacy Roact's validateProps() local outerPropTypes = Component.propTypes - if outerPropTypes then + local validateProps = Component.validateProps + if outerPropTypes or validateProps then checkPropTypes( outerPropTypes, + validateProps, resolvedProps, -- Resolved for outer only 'prop', getComponentName(Component) @@ -3025,10 +3063,12 @@ local function updateContextProvider( ) end end + -- ROBLOX deviation: adds support for legacy Roact's validateProps() local providerPropTypes = workInProgress.type.propTypes + local validateProps = workInProgress.type.validateProps - if providerPropTypes then - checkPropTypes(providerPropTypes, newProps, 'prop', 'Context.Provider') + if providerPropTypes or validateProps then + checkPropTypes(providerPropTypes, validateProps, newProps, 'prop', 'Context.Provider') end end @@ -3568,11 +3608,18 @@ exports.beginWork = function( local resolvedProps = resolveDefaultProps(type, unresolvedProps) if _G.__DEV__ then if workInProgress.type ~= workInProgress.elementType then + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local outerPropTypes + local validateProps -- ROBLOX deviation: only get propTypes from class components, Lua doesn't support fields on functions - local outerPropTypes = typeof(type) == "table" and type.propTypes - if outerPropTypes then + if typeof(type) == "table" then + outerPropTypes = type.propTypes + validateProps = type.validateProps + end + if outerPropTypes or validateProps then checkPropTypes( outerPropTypes, + validateProps, resolvedProps, -- Resolved for outer only "prop", getComponentName(type) diff --git a/modules/react-reconciler/src/ReactFiberContext.new.lua b/modules/react-reconciler/src/ReactFiberContext.new.lua index e69c4fa2..b40027c5 100644 --- a/modules/react-reconciler/src/ReactFiberContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberContext.new.lua @@ -135,7 +135,8 @@ local function getMaskedContext( if _G.__DEV__ then local name = getComponentName(type) or "Unknown" - checkPropTypes(contextTypes, context, "context", name) + -- ROBLOX deviation: nil as second argument for validateProps compatibility + checkPropTypes(contextTypes, nil, context, "context", name) end -- Cache unmasked context so we can avoid recreating masked context unless necessary. @@ -249,7 +250,8 @@ local function processChildContext( end if _G.__DEV__ then local name = getComponentName(type) or "Unknown" - checkPropTypes(childContextTypes, childContext, "child context", name) + -- ROBLOX deviation: nil as second argument for validateProps compatibility + checkPropTypes(childContextTypes, nil, childContext, "child context", name) end return Cryo.Dictionary.join(parentContext, childContext) diff --git a/modules/react-shallow-renderer/src/init.lua b/modules/react-shallow-renderer/src/init.lua index ce063d28..e74fa308 100644 --- a/modules/react-shallow-renderer/src/init.lua +++ b/modules/react-shallow-renderer/src/init.lua @@ -600,19 +600,17 @@ function ReactShallowRenderer:render(element, maybeContext) ReactDebugCurrentFrame.getCurrentStack = getStackAddendum end local ok, result = pcall(function() - if - isMemo(element) - and typeof(elementType) == "table" - and elementType.propTypes - then - currentlyValidatingElement = element - checkPropTypes( - elementType.propTypes, - element.props, - "prop", - getComponentName(elementType) - ) - end + if isMemo(element) and typeof(elementType) == "table" and (elementType.propTypes or elementType.validateProps) then + currentlyValidatingElement = element + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + checkPropTypes( + elementType.propTypes, + elementType.validateProps, + element.props, + 'prop', + getComponentName(elementType) + ) + end if self._instance then self:_updateClassComponent(elementType, element, self._context) @@ -652,17 +650,19 @@ function ReactShallowRenderer:render(element, maybeContext) end end - if typeof(elementType) == "table" and elementType.contextTypes then - currentlyValidatingElement = element - checkPropTypes( - elementType.contextTypes, - self._context, - "context", - getName(elementType, self._instance) - ) - - currentlyValidatingElement = nil - end + if typeof(elementType) == "table" and (elementType.contextTypes or elementType.validateProps) then + currentlyValidatingElement = element + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + checkPropTypes( + elementType.contextTypes, + elementType.validateProps, + self._context, + 'context', + getName(elementType, self._instance) + ) + + currentlyValidatingElement = nil + end self:_mountClassComponent(elementType, element, self._context) else diff --git a/modules/react/src/ReactElement.lua b/modules/react/src/ReactElement.lua index 62cc4494..e3218b09 100644 --- a/modules/react/src/ReactElement.lua +++ b/modules/react/src/ReactElement.lua @@ -469,7 +469,7 @@ end ]] exports.cloneElement = function(element, config, ...) - invariant(not (element == nil or element == nil), "React.cloneElement(...): The argument must be a React element, but you passed " .. tostring(element)) + invariant(not (element == nil), "React.cloneElement(...): The argument must be a React element, but you passed " .. tostring(element)) -- Original props are copied local props = Object.assign({}, element.props) diff --git a/modules/react/src/ReactElementValidator.lua b/modules/react/src/ReactElementValidator.lua index 642d0ba2..7934d873 100644 --- a/modules/react/src/ReactElementValidator.lua +++ b/modules/react/src/ReactElementValidator.lua @@ -19,8 +19,8 @@ local isValidElementType = require(Packages.Shared).isValidElementType local getComponentName = require(Packages.Shared).getComponentName local ReactSymbols = require(Packages.Shared).ReactSymbols local getIteratorFn = ReactSymbols.getIteratorFn -local REACT_FORWARD_REF_TYPE = ReactSymbols.REACT_FORWARD_REF_TYPE -local REACT_MEMO_TYPE = ReactSymbols.REACT_MEMO_TYPE +local _REACT_FORWARD_REF_TYPE = ReactSymbols.REACT_FORWARD_REF_TYPE +local _REACT_MEMO_TYPE = ReactSymbols.REACT_MEMO_TYPE local REACT_FRAGMENT_TYPE = ReactSymbols.REACT_FRAGMENT_TYPE local REACT_ELEMENT_TYPE = ReactSymbols.REACT_ELEMENT_TYPE @@ -253,32 +253,28 @@ end local function validatePropTypes(element) if _G.__DEV__ then local type = element.type - if type == nil or type == nil or typeof(type) == "string" then + if type == nil or typeof(type) == "string" then return end local propTypes + local validateProps if typeof(type) == "function" then -- deviation: function components can't have propTypes in Lua -- propTypes = type.propTypes return - elseif typeof(type) == "table" and - ( - type["$$typeof"] == REACT_FORWARD_REF_TYPE or - -- Note: Memo only checks outer props here. - -- Inner props are checked in the reconciler. - type["$$typeof"] == REACT_MEMO_TYPE - ) - then + elseif typeof(type) == "table" then propTypes = type.propTypes + validateProps = type.validateProps else return end - if propTypes then + if propTypes or validateProps then -- Intentionally inside to avoid triggering lazy initializers: local name = getComponentName(type) - checkPropTypes(propTypes, element.props, "prop", name, element) + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + checkPropTypes(propTypes, validateProps, element.props, "prop", name, element) elseif type.PropTypes ~= nil and not propTypesMisspellWarningShown then propTypesMisspellWarningShown = true -- Intentionally inside to avoid triggering lazy initializers: diff --git a/modules/shared/rotriever.toml b/modules/shared/rotriever.toml index b7040172..45202397 100644 --- a/modules/shared/rotriever.toml +++ b/modules/shared/rotriever.toml @@ -11,3 +11,7 @@ LuauPolyfill = { workspace = true } [dev_dependencies] JestRoblox = { workspace = true } RobloxJest = { path = "../roblox-jest" } +React = { path = "../react" } +Scheduler = { path = "../scheduler" } +ReactNoop = { path = "../react-noop-renderer" } +JestReact = { path = "../jest-react" } diff --git a/modules/shared/src/__tests__/checkPropTypes.roblox.spec.lua b/modules/shared/src/__tests__/checkPropTypes.roblox.spec.lua new file mode 100644 index 00000000..b7cb2ebe --- /dev/null +++ b/modules/shared/src/__tests__/checkPropTypes.roblox.spec.lua @@ -0,0 +1,249 @@ +return function () + local Packages = script.Parent.Parent.Parent + local RobloxJest = require(Packages.Dev.RobloxJest) + local React = require(Packages.Dev.React) + + local ReactNoop + local Scheduler + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + local LuauPolyfill = require(Packages.LuauPolyfill) + local Error = LuauPolyfill.Error + + describe("tests propTypes and validateProps behavior", function() + beforeEach(function() + RobloxJest.resetModules() + ReactNoop = require(Packages.Dev.ReactNoop) + Scheduler = require(Packages.Dev.Scheduler) + React = require(Packages.Dev.React) + + end) + it("propTypes defined, returns error", function() + local Foo = React.Component:extend("div") + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + return Error("no no no no no") + end + } + + function Foo:render() + return React.createElement("div") + end + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toWarnDev("no no no no no") + end) + it("propTypes defined, returns nil", function() + local Foo = React.Component:extend("Foo") + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + return nil + end + } + + function Foo:render() + return React.createElement("div") + end + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + it("validateProps defined, returns false", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return false, "no no no no no" + end + + function Foo:render() + return React.createElement("div") + end + jestExpect(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end).toWarnDev("no no no no no", {withoutStack = true}) + end) + it("validateProps defined, returns true", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return true + end + + function Foo:render() + return React.createElement("div") + end + + ReactNoop.render(React.createElement(Foo, {myProp = "hello"})) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + it("warning when both methods are defined", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return true + end + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + return nil + end + } + + function Foo:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toWarnDev("You've defined both propTypes and validateProps on Foo", {withoutStack = true}) + + end) + it("validateProps fails, propTypes fails", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return false, "no no no no no" + end + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + error(Error("no no no no no")) + end + } + + function Foo:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toWarnDev( + {"You've defined both propTypes and validateProps on Foo", "no no no no no", "no no no no no"}, + {withoutStack = 2}) + end) + it("validateProps succeeds, propTypes fails", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return true + end + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + error(Error("no no no no no")) + end + } + + function Foo:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toWarnDev( + {"You've defined both propTypes and validateProps on Foo", "no no no no no"}, + {withoutStack = 1}) + end) + it("validateProps fails, propTypes succeeds", function() + local Foo = React.Component:extend("Foo") + + Foo.validateProps = function(props) + return false, "no no no no no" + end + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + return nil + end + } + + function Foo:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toWarnDev( + {"You've defined both propTypes and validateProps on Foo", "no no no no no"}, + {withoutStack = 2}) + end) + it("bad propTypes method", function() + local Foo = React.Component:extend("Foo") + + Foo.propTypes = { + myProp = function(prop, propName, componentName) + return "nil" + end + } + + function Foo:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + end) + end).toErrorDev( + {"Foo: type specification of prop" + .. " `myProp` is invalid; the type checker " + .. "function must return `null` or an `Error` but returned a string. " + .. "You may have forgotten to pass an argument to the type checker " + .. "creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and " + .. "shape all require an argument)."}) + end) + it("bad validateProps method", function() + local Foo = React.Component:extend("Foo") + + function Foo:render() + return React.createElement("div") + end + + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(function() + ReactNoop.act(function() + Foo.validateProps = "this is a string" + + ReactNoop.render( + React.createElement(Foo, {myProp = "hello"}) + ) + end) + end).toErrorDev( + {"validateProps must be a function, but it is a string.\nCheck the definition of the component \"Foo\"."}, + {withoutStack = 1}) + end) + end) +end \ No newline at end of file diff --git a/modules/shared/src/__tests__/init.spec.lua b/modules/shared/src/__tests__/init.spec.lua new file mode 100644 index 00000000..3951a9df --- /dev/null +++ b/modules/shared/src/__tests__/init.spec.lua @@ -0,0 +1,17 @@ +return function() + local Packages = script.Parent.Parent.Parent + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + local RobloxJest = require(Packages.Dev.RobloxJest) + local getTestRendererJestMatchers = require(Packages.Dev.JestReact).getJestMatchers + local getSchedulerJestMatchers = require(Packages.Dev.Scheduler).getJestMatchers + + beforeAll(function() + jestExpect.extend(getTestRendererJestMatchers(jestExpect)) + jestExpect.extend(getSchedulerJestMatchers(jestExpect)) + + jestExpect.extend({ + toErrorDev = RobloxJest.Matchers.toErrorDev, + toWarnDev = RobloxJest.Matchers.toWarnDev, + }) + end) +end diff --git a/modules/shared/src/checkPropTypes.lua b/modules/shared/src/checkPropTypes.lua index 7cce5c87..2904c348 100644 --- a/modules/shared/src/checkPropTypes.lua +++ b/modules/shared/src/checkPropTypes.lua @@ -40,80 +40,108 @@ local function setCurrentlyValidatingElement(element) end end -local function checkPropTypes(typeSpecs, values, location, componentName, element) +-- ROBLOX deviation: also checks validateProps if present +local function checkPropTypes(propTypes, validateProps, props, location, componentName, element) if _G.__DEV__ then -- deviation: hasOwnProperty shouldn't be relevant to lua objects -- $FlowFixMe This is okay but Flow doesn't know it. -- local has = Function.call.bind(Object.prototype.hasOwnProperty) - for typeSpecName, _ in pairs(typeSpecs) do - -- deviation: since our loop won't hit metatable members, we don't - -- need to worry about encountering inherited properties here - -- if has(typeSpecs, typeSpecName) then + -- ROBLOX deviation: warns if both propType and validateProps defined. + if propTypes and validateProps then + console.warn("You've defined both propTypes and validateProps on " .. componentName) + end - -- Prop type validation may throw. In case they do, we don't want to - -- fail the render phase where it didn't fail before. So we log it. - -- After these have been cleaned up, we'll local them throw. - local _, result = pcall(function() - -- This is intentionally an invariant that gets caught. It's the same - -- behavior as without this statement except with a better message. - if typeof(typeSpecs[typeSpecName]) ~= "function" then - local err = Error( - (componentName or "React class") - .. ": " - .. location - .. " type `" - .. typeSpecName - .. "` is invalid; " - .. "it must be a function, usually from the `prop-types` package, but received `" - .. typeof(typeSpecs[typeSpecName]) - .. "`." - .. "This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`." - ) - err.name = "Invariant Violation" - error(err) + -- ROBLOX deviation: also checks validateProps if present + if validateProps then + if typeof(validateProps) ~= "function" then + console.error(("validateProps must be a function, but it is a %s.\nCheck the definition of the component %q."):format( + typeof(validateProps), + componentName or "" + )) + else + local success, failureReason = validateProps(props) + + if not success then + failureReason = failureReason or "" + console.warn(("validateProps failed on a %s type in %s: %s"):format( + location, + componentName, + tostring(failureReason))) end + end + end - return typeSpecs[typeSpecName]( - values, - typeSpecName, - componentName, - location, - nil, - "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED" - ) - end) + if propTypes then + for typeSpecName, _ in pairs(propTypes) do + -- deviation: since our loop won't hit metatable members, we don't + -- need to worry about encountering inherited properties here + -- if has(propTypes, typeSpecName) then - -- deviation: FIXME: Can we expose something from JSPolyfill that - -- will let us verify that this is specifically the Error object - -- defined there? - local isErrorObject = typeof(result) == "table" - if result ~= nil and not isErrorObject then - setCurrentlyValidatingElement(element) - console.error( - string.format( - "%s: type specification of %s" - .. " `%s` is invalid; the type checker " - .. "function must return `null` or an `Error` but returned a %s. " - .. "You may have forgotten to pass an argument to the type checker " - .. "creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and " - .. "shape all require an argument).", - componentName or "React class", - location, + -- Prop type validation may throw. In case they do, we don't want to + -- fail the render phase where it didn't fail before. So we log it. + -- After these have been cleaned up, we'll local them throw. + local _, result = pcall(function() + -- This is intentionally an invariant that gets caught. It's the same + -- behavior as without this statement except with a better message. + if typeof(propTypes[typeSpecName]) ~= "function" then + local err = Error( + (componentName or "React class") + .. ": " + .. location + .. " type `" + .. typeSpecName + .. "` is invalid; " + .. "it must be a function, usually from the `prop-types` package, but received `" + .. typeof(propTypes[typeSpecName]) + .. "`." + .. "This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`." + ) + err.name = "Invariant Violation" + error(err) + end + + return propTypes[typeSpecName]( + props, typeSpecName, - typeof(result) + componentName, + location, + nil, + "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED" ) - ) - setCurrentlyValidatingElement(nil) - end + end) + + -- deviation: FIXME: Can we expose something from JSPolyfill that + -- will let us verify that this is specifically the Error object + -- defined there? + local isErrorObject = typeof(result) == "table" + if result ~= nil and not isErrorObject then + setCurrentlyValidatingElement(element) + console.error( + string.format( + "%s: type specification of %s" + .. " `%s` is invalid; the type checker " + .. "function must return `null` or an `Error` but returned a %s. " + .. "You may have forgotten to pass an argument to the type checker " + .. "creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and " + .. "shape all require an argument).", + componentName or "React class", + location, + typeSpecName, + typeof(result) + ) + ) + setCurrentlyValidatingElement(nil) + end - if isErrorObject and loggedTypeFailures[result.message] == nil then - -- Only monitor this failure once because there tends to be a lot of the - -- same error. - loggedTypeFailures[tostring(result.message)] = true - setCurrentlyValidatingElement(element) - warn(string.format('Failed %s type: %s', location, tostring(result.message))) - setCurrentlyValidatingElement(nil) + if isErrorObject and loggedTypeFailures[result.message] == nil then + -- Only monitor this failure once because there tends to be a lot of the + -- same error. + loggedTypeFailures[tostring(result.message)] = true + setCurrentlyValidatingElement(element) + console.warn(string.format('Failed %s type: %s', location, tostring(result.message))) + setCurrentlyValidatingElement(nil) + end end end end From 442bb399b714d77a9dc7388c503fbaf3957c055c Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Mon, 12 Jul 2021 16:59:13 -0700 Subject: [PATCH 018/289] Remove line saying propTypes is supported --- DEVIATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVIATIONS.md b/DEVIATIONS.md index 302e97b0..c21fd0c9 100644 --- a/DEVIATIONS.md +++ b/DEVIATIONS.md @@ -216,7 +216,7 @@ We've provided support for both interfaces. Resolution and more info at https:// **Status:** ✔️ Resolved
#### Implemented Alignment -Roact 17 supports both propTypes and validateProps. `checkPropTypes` method was expanded to include logic for validateProps. For full details, see [#131](https://github.com/Roblox/roact-alignment/pull/131). +Roact 17 supports both validateProps. `checkPropTypes` method was expanded to include logic for validateProps. For full details, see [#131](https://github.com/Roblox/roact-alignment/pull/131).
### createFragment From f7876aa7627f191bb136266f397877f34558ceb0 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 14 Jul 2021 17:48:22 +0100 Subject: [PATCH 019/289] LUAFDN-253: FakeTimers.getTimerCount (#144) * FakeTimers.getTimerCount --- .../roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua | 4 ++++ modules/roblox-jest/src/FakeTimers/init.lua | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua b/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua index 11c92000..b8afbaf6 100644 --- a/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua +++ b/modules/roblox-jest/src/FakeTimers/__tests__/FakeTimers.spec.lua @@ -55,6 +55,8 @@ return function() table.insert(log, "timer 5 callback") end) + jestExpect(FakeTimers.getTimerCount()).toEqual(5) + -- advance timers passed timer 4 expiry FakeTimers.advanceTimersByTime(70) jestExpect(log).toEqual({ "timer 4 callback" }) @@ -76,6 +78,8 @@ return function() "timer 3 callback", "timer 5 callback", }) + + jestExpect(FakeTimers.getTimerCount()).toEqual(0) end) end) end diff --git a/modules/roblox-jest/src/FakeTimers/init.lua b/modules/roblox-jest/src/FakeTimers/init.lua index b2de938a..9204282e 100644 --- a/modules/roblox-jest/src/FakeTimers/init.lua +++ b/modules/roblox-jest/src/FakeTimers/init.lua @@ -114,6 +114,10 @@ local function useRealTimers() tickOverride.__call = realTick end +local function getTimerCount(): number + return #timers +end + return { delayOverride = setmetatable({}, delayOverride), tickOverride = setmetatable({}, tickOverride), @@ -121,6 +125,7 @@ return { useFakeTimers = useFakeTimers, useRealTimers = useRealTimers, advanceTimersByTime = advanceTimersByTime, + getTimerCount = getTimerCount, reset = reset, now = function() return now end } From 65f208febcb2470886a016ca955b15a3629836e1 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Wed, 14 Jul 2021 12:29:19 -0700 Subject: [PATCH 020/289] React roblox planning (#141) A step towards closing out LUAFDN-382. I've made a few small touchup change that seemed uncontroversial and written out some documentation on the more complicated TODOs for ReactRoblox. They boil down to: Keys provided to elements in Roact are applied to the Name field of the resulting Instance; ReactRoblox does not currently do that. Refs in Roact are implemented in terms of bindings, and certain ref use cases depend on those internals being the same Text instances are unimplemented. I'm fine with keeping it that way, but it's a deviation to call out. findDOMNode is unimplemented. It seems more dangerous than useful. Legacy render and unmountComponentAtNode functions aren't implemented in roact-alignment, but are expected to be deprecated soon in upstream Some reconciler batching and scheduling logic is exposed with unstable_ prefixes Hydration is unimplemented unstable_createEventHandle appears to be an upcoming way of managing event listeners (replacing a useEvent hook); it's not clear if it applies to ReactRoblox in anyway A few other top-level apis with unstable prefixes, most of which don't seem necessary to port React has special behavior for input, select, option, and textarea tags that helps them respect the intended source of truth; it's possible that we should implement something similar for at least TextBox. Not sure if there's anything else it would apply to Roact.Change and Roact.Event have been pulled in from Roact, but we might want to think about if there's a way to make their logic more generic. For what it's worth, upstream does not seem concerned about whether they're generic or not. --- modules/react-roblox/README.md | 214 +++++++++++++++++- .../react-roblox/src/client/ReactRoblox.lua | 13 +- .../client/ReactRobloxHostTypes.roblox.lua | 6 +- .../src/client/ReactRobloxRoot.lua | 4 +- .../client/roblox/RobloxComponentProps.lua | 8 - .../src/client/roblox/createSignal.lua | 50 ++-- 6 files changed, 246 insertions(+), 49 deletions(-) diff --git a/modules/react-roblox/README.md b/modules/react-roblox/README.md index aec00fce..0e97e3df 100644 --- a/modules/react-roblox/README.md +++ b/modules/react-roblox/README.md @@ -7,4 +7,216 @@ Status: 🔨 Under Construction ### ✏️ Notes -* Mimics pieces of the interface exported from `react-dom` \ No newline at end of file +* Mimics pieces of the interface exported from `react-dom` + +# Translation Plans + +## Keys as Names +Roact will assign the keys applied to host elements to their `Name` field to make the resulting Roblox DOM more readable and easily configurable. We need to support this behavior as well. + +## Migrating Bindings +Bindings are a Roact feature that are tightly coupled with refs, and currently implemented exclusively in `ReactRoblox` despite having some generic logic. + +### Relation to Refs +Currently, bindings are exposed as part of ReactRoblox: +```lua +ReactRoblox.createBinding(nil) +ReactRoblox.joinBindings(binding1, binding2) +``` + +Bindings are described in detail [in the Roact docs](https://roblox.github.io/roact/advanced/bindings-and-refs/#bindings). Any time a host property is assigned a binding value, Roact does the following: +1. Assign the current value of the binding +2. Create an updater function that assigns new values to the host property +3. Subscribe to the binding object with the updater function + +And when either the component is unmounted, or the prop is assigned a different value: +1. Disconnect the binding subscription +2. If the component is not unmounting, assign the host prop to the new primitive value + +### Refs as Bindings +This will work just fine in many cases! However, in Roact, the binding implementation is used to power Refs as well. The Roblox API exposes certain host properties that must be assigned _Instance references_ as values. Effectively, there are native APIs that expect a `ref.current` value as a value. + +The logic of bindings is a perfect fit for this scenario. Consider the following example: +```lua +local PopupButtons = Roact.Component:extend("PopupButtons") + +function PopupButtons:init() + self.confirmRef = Roact.createRef() + self.cancelRef = Roact.createRef() +end + +function PopupButtons:render() + --[[ + "Some Description" + + [ Confirm ] [ Cancel ] + ]] + return Roact.createElement("Frame", nil { + ConfirmButton = Roact.createElement("TextButton", { + [Roact.Ref] = self.confirmRef, + Text = "Confirm", + NextSelectionRight = self.cancelRef.value, + }), + CancelButton = Roact.createElement("TextButton", { + [Roact.Ref] = self.cancelRef, + Text = "Confirm", + NextSelectionLeft = self.confirmRef.value, + }), + }) +end +``` +This example poses a problem. Since children will be rendered in an arbitrary order, one of the following will happen: +1. Confirm Button renders first and its ref is assigned +2. Confirm Button's NextSelectionRight property is set to the Cancel Button's ref, **which is currently nil** +3. Cancel Button renders and its ref is assigned +4. Cancel Button's NextSelectionLeft property is properly set to the Confirm Button's ref + +Or: +1. Cancel Button renders first and its ref is assigned +2. Cancel Button's NextSelectionLeft property is set to the Confirm Button's ref, **which is currently nil** +3. Confirm Button renders and its ref is assigned +4. Confirm Button's NextSelectionRight property is properly set to the Cancel Button's ref + +Thus, it would require much more trickery to make even a simple gamepad neighbor assignment work correctly. However *when refs are implemented as bindings under the hood*, the above scenario can be solved pretty simply: +```lua +-- ... + return Roact.createElement("Frame", nil { + ConfirmButton = Roact.createElement("TextButton", { + [Roact.Ref] = self.confirmRef, + Text = "Confirm", + -- pass the ref itself, which is a binding + NextSelectionRight = self.cancelRef, + }), + CancelButton = Roact.createElement("TextButton", { + [Roact.Ref] = self.cancelRef, + Text = "Confirm", + -- pass the ref itself, which is a binding + NextSelectionLeft = self.confirmRef, + }), + }) +-- ... +``` +With refs using binding logic, and with the above implementation, something like the following happens +1. Confirm Button renders first and its ref is assigned +2. Confirm Button's NextSelectionRight property is set to the Cancel Button's ref, **which is currently nil** +3. Cancel Button renders and its ref is assigned + * The binding value updates, and the Confirm button's NextSelectionRight property is assigned to the Cancel Button's new ref value +4. Cancel Button's NextSelectionLeft property is properly set to the Confirm Button's ref + +...or the inverse, with the Cancel Button rendering first. Either way, both refs are assigned, and both neighbor properties are assigned by the time the render is complete. + +### Relation to Reconciler Internals +Bindings operate in old Roact's model, which means that they do not interact with any work queues and always update synchronously. + +We should be able to carry over bindings as they are into the reconciler by simply replacing the implementation of `createRef` with a binding creation instead. This would restore the behavior described above. + +However, the abstraction of bindings leaks into the renderer when the renderer host config is responsible for understanding what bindings are and managing their subscriptions. Should we consider inverting the logic somehow? +* This would add more deviations to the reconciler logic +* It would be difficult (or at least delicate) to integrate this into existing reconciler logic + +### Proposed Strategy +1. Replace `ReactCreateRef`'s implementation of `createRef` with that of Roact's + * This will include changing the type definition of RefObject + * We may want to also use React's typing logic rather than the Roact logic + * We should also consider how heavily (and how) we want to obscure internals; the current bindings implementation is pretty zealous about hiding internals +2. Introduce ReactRoblox tests to ensure refs as bindings behave as before +3. Investigate using reconciler internals to manage binding updates. It may be reasonable to adjust how binding subscriptions work so that we can batch, defer, etc. the updates. There's a lot to consider here, so we'll need to proceed thoughtfully. We might consider doing this later on as a separate step. +4. Create a `useBinding` hook. This can have essentially the same signature as `useState`, but with different semantics. By virtue of being a hook, this will make bindings usable in function components. +5. Document bindings and refs. Documentation on bindings ought to make reference to `useState` as well as `useRef`. + +### Concerns +* Upstream documentation treats refs as more general objects: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + * Some of the uses outlined here would might behave differently when refs are implemented as bindings + * To conform, we'd need to make assigning to `current` equivalent to updating a binding + * There's a part of these docs I don't understand, where it says: "If we just wanted to set an interval, we wouldn’t need the ref (id could be local to the effect), but it’s useful if we want to clear the interval from an event handler". It's unclear to me why you couldn't just close over a local variable the same way we're closing over intervalRef? +* Is it reasonable to keep Roact's restrictions on refs? This would entail: + * (deviation) disallow direct assignment to ref.current + * When attempting to assign to ref.current, provide an error message that guides users to bindings documentation; suggest using either state or bindings, depending on intent + * (deviation) remove the initialValue from useRef, which makes much less sense than it does in upstream where refs are generalized + * If an initial value is provided, warn and suggest use of bindings or state + +## Text Instances +We've danced around this before; should we support text instances? +* Is it remotely useful to do so? +* Would it be valuable for sheer alignment purposes? + +## ReactDOMLegacy.findDOMNode +It's unclear whether we should port this functionality in the long run. For now, we'll hold off. Below are some tradeoffs: + +Pros +* Based on reconciler internals (`ReactFiberReconciler.findHostInstance`), which is generic reconciler logic +* Should be relatively easy to port, since the reconciler implementation already exists +* Would it be helpful for gamepad logic? Maybe a viable way of handling default selection? + +Cons +* It seems highly abusable, and rarely idiomatic +* Not clear if there are any theoretically valid use cases besides gamepad support + +## ReactDOMLegacy.render and ReactDOMLegacy.unmountComponentAtNode +Part of the legacy interface. This is the most recognizable react entry-point: +```javascript +ReactDOM.render( +

Hello, world!

, + document.getElementById('root') +); +``` + +However, it's also being phased out in favor of the "root" apis: `createLegacyRoot`, `createBlockingRoot`, and `createRoot`, the first of which should be equivalent to `render`. + +### Proposed Strategy +The `render` and `unmountComponentAtNode` APIs will be [phased out in React 18](https://github.com/reactwg/react-18/discussions/5), so we should strongly consider not porting them at all. + +## ReactDOMLegacy unstable update scheduling logic +The following scheduling-related functions are exported from ReactRoblox: +* `unstable_batchedUpdates` +* `unstable_flushControlled` +* `unstable_runWithPriority` + +These are intended to be fully released in the future. Should we exclude them until they're stabilized, or include them as is for now? + +## Hydration + +### ReactDOMLegacy.hydrate +Legacy hydration; we should probably try to avoid supporting the legacy approach; it will be phased out with the roots API. + +### `hydrate` Option for Root +Relies upon hydration support in the reconciler. Can be tackled as a single holistic feature implementation. We might want to introduce intentional, user-facing "unimplemented" errors that are thrown when the option is first encountered in `createRoot` implementations. + +### scheduleHydration +React 17 currently exports the `unstable_scheduleHydration` API. This will need investigation and implementation as part of implementing hydration + +## createEventHandle +React 17 exposes an `unstable_createEventHandle` API for managing event listeners on objects. Roact has its own approach to this sort of behavior via `Roact.Change` and `Roact.Event`, which are pulled into ReactRoblox. + +I suspect we'll simply avoid translating this logic, and call it out in documentation for `Change` and `Event`. We may also want to add a warning when accessing it that directs users to `Change` and `Event` documentation, but it may be sufficient to leave it out since it's still `unstable` anyway. + +## isNewReconciler +React 17 exposes an `unstable_isNewReconciler` API. This is probably unhelpful for us, since we haven't ported (and won't port) the _old_ reconciler anyways. We should probably just omit this. + +## renderSubtreeIntoContainer +React 17 exposes an `unstable_renderSubtreeIntoContainer` API. I'm unclear on what this does, other than it being part of the legacy rendering logic. Warnings around it say that it's deprecated, so we can likely exclude it. + +## input, select, option, and textarea tags +These tags have special handling in React to allow for them to play nicely with the DOM, regardless of which is used as a source of truth. Each of them is a DOM element that has its own externally-mutable state. + +In order to make them behave correctly, React does a few things: +* Tracks whether or not the `value` has been provided as a prop + * If so, then changes to the value will _not_ trigger prop updates to the element + * However, if values are not provided, the element will be changed when external changes to its underlying object are changed + * In this way, if a component's `value` field is not set, but something else changes it externally, React will understand when re-rendering that it should not unset the value +* Tracks whether or not the value has a default +* Uses special logic when applying prop updates to respect whether or not the component is "controlled" (`value` provided as a prop, React source of truth) or "uncontrolled" (no `value` provided via props, DOM source of truth) + +In Roact, we have similar kinds of issues with certain constructs. The only meaningful equivalent I'm aware of is `TextBox`, but it has very similar issues to those of `textarea` in ReactDOM. + +Equivalent behavior for `TextBox` would be nice to have, but will need careful implementation. + +## Roact.Change and Roact.Event +The Change and Event logic from Roact has been lifted into ReactRoblox. This logic should still behave exactly as it did in Roact. There are a couple things to address: +* This logic needs to be documented as part of Roact 17's deviation documents +* Exposing these as `ReactRoblox.Change` and `ReactRoblox.Event` means that component definitions need to depend upon the ReactRoblox library to provide them + * This leaks the renderer abstraction into component definitions, which could otherwise be renderer-agnostic + * While we don't currently have non-roblox targets, is there a possibility that this abstraction leak causes issues? + * All of the above is also true + * A less important concern is the slight ergonomics hit that comes from needing another import + * Could we possibly genericize this concept and move it into React? diff --git a/modules/react-roblox/src/client/ReactRoblox.lua b/modules/react-roblox/src/client/ReactRoblox.lua index 4b0ba340..8c7b5f5a 100644 --- a/modules/react-roblox/src/client/ReactRoblox.lua +++ b/modules/react-roblox/src/client/ReactRoblox.lua @@ -242,16 +242,17 @@ local exports = { -- This should only be used by React internals. -- unstable_runWithPriority = runWithPriority, - -- ROBLOX deviation: Provide prop markers for event/change + -- ROBLOX deviation: Export logic attached from Roact + + -- ROBLOX FIXME: Is there a better way to provide this? Exposing these here + -- means that a large number of react components that wouldn't otherwise need + -- to import `ReactRoblox` will need to do so in order to set events/change Event = Event, Change = Change, + + -- ROBLOX FIXME: Move binding implementation to React and integrate with refs createBinding = Binding.create, joinBindings = Binding.join, - - -- ROBLOX deviation: Compatibility layer for special symbol keys, aligning - -- them with simple reserved props used by upstream - Children = "children", - Ref = "ref", } -- local foundDevTools = injectIntoDevTools({ diff --git a/modules/react-roblox/src/client/ReactRobloxHostTypes.roblox.lua b/modules/react-roblox/src/client/ReactRobloxHostTypes.roblox.lua index 14c10eee..a2f1ab7e 100644 --- a/modules/react-roblox/src/client/ReactRobloxHostTypes.roblox.lua +++ b/modules/react-roblox/src/client/ReactRobloxHostTypes.roblox.lua @@ -1,9 +1,7 @@ local Packages = script.Parent.Parent.Parent --- ROBLOX FIXME: Since we've modified the reconciler to have a dynamic --- host-config entry point, types won't actually carry through --- local ReactReconciler = require(Packages.ReactReconciler) --- type FiberRoot = ReactReconciler.FiberRoot +local ReactReconciler = require(Packages.ReactReconciler) +type FiberRoot = ReactReconciler.FiberRoot local ReactTypes = require(Packages.Shared) type MutableSource = ReactTypes.MutableSource diff --git a/modules/react-roblox/src/client/ReactRobloxRoot.lua b/modules/react-roblox/src/client/ReactRobloxRoot.lua index d9bad66a..aa9d5ec8 100644 --- a/modules/react-roblox/src/client/ReactRobloxRoot.lua +++ b/modules/react-roblox/src/client/ReactRobloxRoot.lua @@ -230,7 +230,9 @@ end function warnIfReactDOMContainerInDEV(container) if _G.__DEV__ then - -- ROBLOX TODO: This behavior will deviate or be unnecessary + -- ROBLOX TODO: This behavior will deviate; should we validate that the + -- container is not a PlayerGui of any sort? + -- if -- container.nodeType == ELEMENT_NODE and -- container.tagName and diff --git a/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua b/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua index b9d5ffc2..99074c79 100644 --- a/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua +++ b/modules/react-roblox/src/client/roblox/RobloxComponentProps.lua @@ -92,18 +92,10 @@ end -- end local function applyProp(hostInstance, key, newValue, oldValue) - -- RoactAlignment TODO: Upstream react uses reserved `children` and `ref` prop - -- keys, while Roact used special symbols `Roact.Children` and `Roact.Ref` to - -- reduce likelihood of collisions; should switch back to using placeholders? if key == "ref" or key == "children" then return end - -- if key == Ref or key == Children then - -- -- Refs and children are handled in a separate pass - -- return - -- end - local internalKeyType = Type.of(key) if internalKeyType == Type.HostEvent or internalKeyType == Type.HostChangeEvent then diff --git a/modules/react-roblox/src/client/roblox/createSignal.lua b/modules/react-roblox/src/client/roblox/createSignal.lua index 90becebf..f3e0add2 100644 --- a/modules/react-roblox/src/client/roblox/createSignal.lua +++ b/modules/react-roblox/src/client/roblox/createSignal.lua @@ -12,59 +12,51 @@ disconnect() ]] -local function addToMap(map, addKey, addValue) - local new = {} - - for key, value in pairs(map) do - new[key] = value - end - - new[addKey] = addValue - - return new -end - -local function removeFromMap(map, removeKey) - local new = {} - - for key, value in pairs(map) do - if key ~= removeKey then - new[key] = value - end - end - - return new -end - local function createSignal() local connections = {} + local suspendedConnections = {} + local firing = false local function subscribe(self, callback) assert(typeof(callback) == "function", "Can only subscribe to signals with a function.") local connection = { callback = callback, - disconnected = nil, + disconnected = false, } - connections = addToMap(connections, callback, connection) + -- If the callback is already registered, don't add to the suspendedConnection. Otherwise, this will disable + -- the existing one. + if firing and not connections[callback] then + suspendedConnections[callback] = connection + end + + connections[callback] = connection local function disconnect() assert(not connection.disconnected, "Listeners can only be disconnected once.") connection.disconnected = true - connections = removeFromMap(connections, callback) + connections[callback] = nil + suspendedConnections[callback] = nil end return disconnect end local function fire(self, ...) + firing = true for callback, connection in pairs(connections) do - if not connection.disconnected then + if not connection.disconnected and not suspendedConnections[callback] then callback(...) end end + + firing = false + + for callback, _ in pairs(suspendedConnections) do + suspendedConnections[callback] = nil + end end return { @@ -73,4 +65,4 @@ local function createSignal() } end -return createSignal \ No newline at end of file +return createSignal From aa4acd42373717bb79c6317a3c82921c9a9e10d0 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 15 Jul 2021 09:48:26 -0700 Subject: [PATCH 021/289] Fixing CI for ReactDevtools --- foreman.toml | 2 +- modules/react-devtools-shared/default.project.json | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 modules/react-devtools-shared/default.project.json diff --git a/foreman.toml b/foreman.toml index 376329cc..a72204c9 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,5 +1,5 @@ [tools] -rotrieve = { source = "roblox/rotriever", version = "=0.5.0-alpha.3" } +rotrieve = { source = "roblox/rotriever", version = "=0.5.0-alpha.4" } rojo = { source = "rojo-rbx/rojo", version = "6.0.1" } selene = { source = "Kampfkarren/selene", version = "0.13" } stylua = { source = "JohnnyMorganz/StyLua", version = "0.9.3" } diff --git a/modules/react-devtools-shared/default.project.json b/modules/react-devtools-shared/default.project.json new file mode 100644 index 00000000..9638e307 --- /dev/null +++ b/modules/react-devtools-shared/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsShared", + "tree": { + "$path": "src" + } +} \ No newline at end of file From fe8a0d893565906fa470368bc6123d8f2558b658 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 15 Jul 2021 10:59:50 -0700 Subject: [PATCH 022/289] Fixing CI for ReactDevtools (#146) --- foreman.toml | 2 +- modules/react-devtools-shared/default.project.json | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 modules/react-devtools-shared/default.project.json diff --git a/foreman.toml b/foreman.toml index 376329cc..a72204c9 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,5 +1,5 @@ [tools] -rotrieve = { source = "roblox/rotriever", version = "=0.5.0-alpha.3" } +rotrieve = { source = "roblox/rotriever", version = "=0.5.0-alpha.4" } rojo = { source = "rojo-rbx/rojo", version = "6.0.1" } selene = { source = "Kampfkarren/selene", version = "0.13" } stylua = { source = "JohnnyMorganz/StyLua", version = "0.9.3" } diff --git a/modules/react-devtools-shared/default.project.json b/modules/react-devtools-shared/default.project.json new file mode 100644 index 00000000..9638e307 --- /dev/null +++ b/modules/react-devtools-shared/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsShared", + "tree": { + "$path": "src" + } +} \ No newline at end of file From 81c75b83cb76c0627ffc55e6f8309d6f6c216a80 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Thu, 15 Jul 2021 17:58:12 -0700 Subject: [PATCH 023/289] LUAFDN-281: Better debug output (#140) * While debugging test failures in social-libaries with React 17, I found opportunities for more actionable messages. * Better warning output than 'invalid argument #3' when setting non-Instance to a Parent * Default name to in Component:extend() to maintain compatibility with legacy Roact, and provide a deprecation warning. * Add test for deprecated abuse case of Component.extend() --- modules/react-reconciler/src/ReactFiber.new.lua | 9 ++++++++- modules/react/src/ReactBaseClasses.lua | 11 ++++++++++- modules/roact-compat/src/RoactTree.lua | 5 ++++- .../src/__tests__/RoactCompatibility.spec.lua | 11 ++++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/modules/react-reconciler/src/ReactFiber.new.lua b/modules/react-reconciler/src/ReactFiber.new.lua index e894aa0f..8ae19d94 100644 --- a/modules/react-reconciler/src/ReactFiber.new.lua +++ b/modules/react-reconciler/src/ReactFiber.new.lua @@ -15,6 +15,7 @@ local Object = LuauPolyfill.Object -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local inspect = require(Packages.Shared).inspect.inspect local ReactTypes = require(Packages.Shared) -- ROBLOX deviation: ReactElement is defined at the top level of Shared along @@ -579,16 +580,22 @@ local function createFiberFromTypeAndProps( local ownerName if owner then ownerName = getComponentName(owner.type) + -- ROBLOX deviation: try harder to get the name of the component + elseif type ~= nil then + ownerName = type.__componentName end if ownerName then info ..= "\n\nCheck the render method of `" .. ownerName .. "`." + else + -- ROBLOX deviation: print the raw table in readable form to give a clue, if no other info was gathered + info ..= inspect(type) end end invariant( false, "Element type is invalid: expected a string (for built-in " .. "components) or a class/function (for composite components) " .. - "but got: %s.%s", + "but got: %s. %s", typeof(type), info ) diff --git a/modules/react/src/ReactBaseClasses.lua b/modules/react/src/ReactBaseClasses.lua index 25a43cb9..850a598a 100644 --- a/modules/react/src/ReactBaseClasses.lua +++ b/modules/react/src/ReactBaseClasses.lua @@ -98,15 +98,24 @@ local Component = {} setmetatable(Component, componentClassMetatable) Component.__componentName = "Component" --- deviation: Lua doesn't expose inheritance in a class-syntax way +-- ROBLOX deviation: Lua doesn't expose inheritance in a class-syntax way --[[ A method called by consumers of Roact to create a new component class. Components can not be extended beyond this point, with the exception of PureComponent. ]] function Component:extend(name) + -- ROBLOX note: legacy Roact will accept nil here and default to empty string + -- ROBLOX TODO: if name in "" in ReactComponentStack frame, we should try and get the variable name it was assigned to + if name == nil then + console.warn("Component:extend() accepting no arguments is deprecated, and will " + .. "not be supported in a future version of Roact. Please provide an explicit name.") + name = "" + end + assert(typeof(name) == "string", "Component class name must be a string") + local class = {} for key, value in pairs(self) do diff --git a/modules/roact-compat/src/RoactTree.lua b/modules/roact-compat/src/RoactTree.lua index f22a1f62..01cd3546 100644 --- a/modules/roact-compat/src/RoactTree.lua +++ b/modules/roact-compat/src/RoactTree.lua @@ -1,5 +1,6 @@ local Packages = script.Parent.Parent local ReactRoblox = require(Packages.ReactRoblox) +local inspect = require(Packages.Shared).inspect.inspect local warnOnce = require(script.Parent.warnOnce) @@ -13,7 +14,9 @@ local function mount(element, parent, key) local root = ReactRoblox.createLegacyRoot(rootInstance) root:render(element) - if parent ~= nil then + if parent ~= nil and typeof(parent) ~= "Instance" then + warnOnce("mount invalid argument", "Cannot mount into a parent that is not a Roblox Instance\n" .. inspect(parent)) + else rootInstance.Parent = parent end if key ~= nil then diff --git a/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua b/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua index 825eaa2f..0656675e 100644 --- a/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua +++ b/modules/roact-compat/src/__tests__/RoactCompatibility.spec.lua @@ -58,7 +58,16 @@ return function() ) end) - -- FIXME: Underlying ReactChildren API not yet ported + it("warns about Component:extend() with no args", function() + jestExpect(function() + RoactCompat.Component:extend() + end).toWarnDev( + "Component:extend() accepting no arguments is deprecated", + { withoutStack = true } + ) + end) + + -- FIXME: Underlying ReactChildren API not yet ported xit("warns about oneChild", function() jestExpect(function() RoactCompat.oneChild({ RoactCompat.createElement("div") }) From 54b52fdf29ec3391f73e9c5c264261e0b8f61e82 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Fri, 16 Jul 2021 14:19:00 -0700 Subject: [PATCH 024/289] LUAFDN-389 assign name to instances using the key value (#148) Roact will assign the keys applied to host elements to their Name field to make the resulting instance hierarchy more readable and easily configurable. This is also highly needed to make existing Rhodium tests compatible too. --- .../src/client/ReactRobloxHostConfig.lua | 5 + .../__tests__/RobloxRenderer.roblox.spec.lua | 151 ++++++++++++++---- 2 files changed, 126 insertions(+), 30 deletions(-) diff --git a/modules/react-roblox/src/client/ReactRobloxHostConfig.lua b/modules/react-roblox/src/client/ReactRobloxHostConfig.lua index c94b933a..afa1568b 100644 --- a/modules/react-roblox/src/client/ReactRobloxHostConfig.lua +++ b/modules/react-roblox/src/client/ReactRobloxHostConfig.lua @@ -315,6 +315,11 @@ exports.createInstance = function( -- local hostKey = virtualNode.hostKey local domElement = Instance.new(type_) + -- ROBLOX deviation: compatibility with old Roact where instances have their name + -- set to the key value + if internalInstanceHandle.key then + domElement.Name = internalInstanceHandle.key + end precacheFiberNode(internalInstanceHandle, domElement) updateFiberProps(domElement, props) diff --git a/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua b/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua index 5b32448d..23adaa65 100644 --- a/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua +++ b/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua @@ -1,6 +1,9 @@ return function() local Packages = script.Parent.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + + local JestRoblox = require(Packages.Dev.JestRoblox) + local jestExpect = JestRoblox.Globals.expect + local jest = JestRoblox.Globals.jest local RobloxJest = require(Packages.Dev.RobloxJest) local React @@ -52,47 +55,135 @@ return function() jestExpect(rootInstance.Name).toBe(key) end) - -- it("should create children with correct names and props", function() - -- local parent = Instance.new("Folder") - -- local rootValue = "Hey there!" - -- local childValue = 173 - -- local key = "Some Key" + it("names instances with their key value using legacy key syntax", function() + local parent = Instance.new("Folder") + local key = "Some Key" - -- local element = createElement("StringValue", { - -- Value = rootValue, - -- }, { - -- ChildA = createElement("IntValue", { - -- Value = childValue, - -- }), + local element = React.createElement("Folder", {}, { + [key] = React.createElement("BoolValue"), + }) - -- ChildB = createElement("Folder"), - -- }) + local root = ReactRoblox.createRoot(parent) + root:render(element) + Scheduler.unstable_flushAllWithoutAsserting() - -- local node = reconciler.createVirtualNode(element, parent, key) + jestExpect(#parent:GetChildren()).toBe(1) - -- RobloxRenderer.mountHostNode(reconciler, node) + local rootInstance = parent:GetChildren()[1] + jestExpect(rootInstance.ClassName).toBe("Folder") - -- expect(#parent:GetChildren()).to.equal(1) + local boolValueInstance = rootInstance:FindFirstChildOfClass("BoolValue") + jestExpect(boolValueInstance).toBeDefined() + jestExpect(boolValueInstance.Name).toEqual(key) + end) - -- local root = parent:GetChildren()[1] + it("names instances with their key value (using props)", function() + local parent = Instance.new("Folder") + local key = "Some Key" - -- expect(root.ClassName).to.equal("StringValue") - -- expect(root.Value).to.equal(rootValue) - -- expect(root.Name).to.equal(key) + local element = React.createElement( + "Folder", + {}, + React.createElement("BoolValue", { + key = key, + }) + ) - -- expect(#root:GetChildren()).to.equal(2) + local root = ReactRoblox.createRoot(parent) + root:render(element) + Scheduler.unstable_flushAllWithoutAsserting() - -- local childA = root.ChildA - -- local childB = root.ChildB + jestExpect(#parent:GetChildren()).toBe(1) - -- expect(childA).to.be.ok() - -- expect(childB).to.be.ok() + local rootInstance = parent:GetChildren()[1] + jestExpect(rootInstance.ClassName).toBe("Folder") - -- expect(childA.ClassName).to.equal("IntValue") - -- expect(childA.Value).to.equal(childValue) + local boolValueInstance = rootInstance:FindFirstChildOfClass("BoolValue") + jestExpect(boolValueInstance).toBeDefined() + jestExpect(boolValueInstance.Name).toEqual(key) + end) - -- expect(childB.ClassName).to.equal("Folder") - -- end) + it("names instances with their key value using legacy key syntax and updates them", function() + local parent = Instance.new("Folder") + local key = "Some Key" + local fnMock = jest:fn() + local ref = function(...) + return fnMock(...) + end + + local element = React.createElement("Folder", {}, { + [key] = React.createElement("BoolValue", { + ref = ref, + }), + }) + + local root = ReactRoblox.createRoot(parent) + root:render(element) + Scheduler.unstable_flushAllWithoutAsserting() + + jestExpect(fnMock).toHaveBeenCalledTimes(1) + local refValue = fnMock.mock.calls[1][1] + jestExpect(refValue.Name).toEqual(key) + + local updatedKey = "Some other key" + local updatedElement = React.createElement("Folder", {}, { + [updatedKey] = React.createElement("BoolValue", { + ref = ref, + }), + }) + root:render(updatedElement) + Scheduler.unstable_flushAllWithoutAsserting() + + jestExpect(fnMock).toHaveBeenCalledTimes(3) + -- the second call should be nil + jestExpect(fnMock.mock.calls[2]).toHaveLength(0) + + local lastRefValue = fnMock.mock.calls[3][1] + jestExpect(lastRefValue.Name).toEqual(updatedKey) + end) + + it("should create children with correct names and props", function() + local parent = Instance.new("Folder") + local rootValue = "Hey there!" + local childValue = 173 + local key = "Some Key" + + local element = React.createElement("StringValue", { + key = key, + Value = rootValue, + }, { + ChildA = React.createElement("IntValue", { + Value = childValue, + }), + + ChildB = React.createElement("Folder"), + }) + + local root = ReactRoblox.createRoot(parent) + root:render(element) + Scheduler.unstable_flushAllWithoutAsserting() + + expect(#parent:GetChildren()).to.equal(1) + + local rootInstance = parent:GetChildren()[1] + + expect(rootInstance.ClassName).to.equal("StringValue") + expect(rootInstance.Value).to.equal(rootValue) + expect(rootInstance.Name).to.equal(key) + + expect(#rootInstance:GetChildren()).to.equal(2) + + local childA = rootInstance.ChildA + local childB = rootInstance.ChildB + + expect(childA).to.be.ok() + expect(childB).to.be.ok() + + expect(childA.ClassName).to.equal("IntValue") + expect(childA.Value).to.equal(childValue) + + expect(childB.ClassName).to.equal("Folder") + end) -- it("should attach Bindings to Roblox properties", function() -- local parent = Instance.new("Folder") From ba14f9be051e3f0be82790366ef7764a6ae32013 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Mon, 19 Jul 2021 13:03:14 -0700 Subject: [PATCH 025/289] LUAFDN-389 assign name to instance from their keys (#150) The previous PR did not account for keys that comes from outside of a component. With these changes, name should have the first key coming from their parent in the component hierarchy, just like legacy Roact does it. This also change a bit how `RoactCompat.mount` works so that it is more similar to legacy Roact. --- .../src/client/ReactRobloxHostConfig.lua | 117 ++++++++++-------- .../__tests__/RobloxRenderer.roblox.spec.lua | 26 ++++ modules/roact-compat/src/RoactTree.lua | 20 ++- 3 files changed, 98 insertions(+), 65 deletions(-) diff --git a/modules/react-roblox/src/client/ReactRobloxHostConfig.lua b/modules/react-roblox/src/client/ReactRobloxHostConfig.lua index afa1568b..51e3ede9 100644 --- a/modules/react-roblox/src/client/ReactRobloxHostConfig.lua +++ b/modules/react-roblox/src/client/ReactRobloxHostConfig.lua @@ -306,61 +306,70 @@ exports.resetAfterCommit = function(containerInfo: Container) end exports.createInstance = function( - type_: string, - props: Props, - rootContainerInstance: Container, - hostContext: HostContext, - internalInstanceHandle: Object + type_: string, + props: Props, + rootContainerInstance: Container, + hostContext: HostContext, + internalInstanceHandle: Object ): HostInstance - -- local hostKey = virtualNode.hostKey - - local domElement = Instance.new(type_) - -- ROBLOX deviation: compatibility with old Roact where instances have their name - -- set to the key value - if internalInstanceHandle.key then - domElement.Name = internalInstanceHandle.key - end - - precacheFiberNode(internalInstanceHandle, domElement) - updateFiberProps(domElement, props) - - -- TODO: Support refs (does that actually happen here, or later?) - -- applyRef(element.props[Ref], instance) - - -- Will have to be managed outside of createInstance - -- if virtualNode.eventManager ~= nil then - -- virtualNode.eventManager:resume() - -- end - - return domElement - - -- return Instance.new("Frame") - -- local parentNamespace: string - -- if __DEV__) - -- -- TODO: take namespace into account when validating. - -- local hostContextDev = ((hostContext: any): HostContextDev) - -- validateDOMNesting(type, nil, hostContextDev.ancestorInfo) - -- if - -- typeof props.children == 'string' or - -- typeof props.children == 'number' - -- ) - -- local string = '' + props.children - -- local ownAncestorInfo = updatedAncestorInfo( - -- hostContextDev.ancestorInfo, - -- type, - -- ) - -- validateDOMNesting(null, string, ownAncestorInfo) - -- end - -- parentNamespace = hostContextDev.namespace - -- } else { - -- parentNamespace = ((hostContext: any): HostContextProd) - -- end - -- local domElement: Instance = createElement( - -- type, - -- props, - -- rootContainerInstance, - -- parentNamespace, - -- ) + -- local hostKey = virtualNode.hostKey + + local domElement = Instance.new(type_) + -- ROBLOX deviation: compatibility with old Roact where instances have their name + -- set to the key value + if internalInstanceHandle.key then + domElement.Name = internalInstanceHandle.key + else + local currentHandle = internalInstanceHandle.return_ + while currentHandle do + if currentHandle.key then + domElement.Name = currentHandle.key + break + end + currentHandle = currentHandle.return_ + end + end + + precacheFiberNode(internalInstanceHandle, domElement) + updateFiberProps(domElement, props) + + -- TODO: Support refs (does that actually happen here, or later?) + -- applyRef(element.props[Ref], instance) + + -- Will have to be managed outside of createInstance + -- if virtualNode.eventManager ~= nil then + -- virtualNode.eventManager:resume() + -- end + + return domElement + + -- return Instance.new("Frame") + -- local parentNamespace: string + -- if __DEV__) + -- -- TODO: take namespace into account when validating. + -- local hostContextDev = ((hostContext: any): HostContextDev) + -- validateDOMNesting(type, nil, hostContextDev.ancestorInfo) + -- if + -- typeof props.children == 'string' or + -- typeof props.children == 'number' + -- ) + -- local string = '' + props.children + -- local ownAncestorInfo = updatedAncestorInfo( + -- hostContextDev.ancestorInfo, + -- type, + -- ) + -- validateDOMNesting(null, string, ownAncestorInfo) + -- end + -- parentNamespace = hostContextDev.namespace + -- } else { + -- parentNamespace = ((hostContext: any): HostContextProd) + -- end + -- local domElement: Instance = createElement( + -- type, + -- props, + -- rootContainerInstance, + -- parentNamespace, + -- ) end exports.appendInitialChild = function( diff --git a/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua b/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua index 23adaa65..99229f81 100644 --- a/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua +++ b/modules/react-roblox/src/client/__tests__/RobloxRenderer.roblox.spec.lua @@ -185,6 +185,32 @@ return function() expect(childB.ClassName).to.equal("Folder") end) + it("names instances with their key value using legacy key syntax through function component", function() + local parent = Instance.new("Folder") + local key = "Some Key" + + local function Foo() + return React.createElement("BoolValue") + end + + local element = React.createElement("Folder", {}, { + [key] = React.createElement(Foo), + }) + + local root = ReactRoblox.createRoot(parent) + root:render(element) + Scheduler.unstable_flushAllWithoutAsserting() + + jestExpect(#parent:GetChildren()).toBe(1) + + local rootInstance = parent:GetChildren()[1] + jestExpect(rootInstance.ClassName).toBe("Folder") + + local boolValueInstance = rootInstance:FindFirstChildOfClass("BoolValue") + jestExpect(boolValueInstance).toBeDefined() + jestExpect(boolValueInstance.Name).toEqual(key) + end) + -- it("should attach Bindings to Roblox properties", function() -- local parent = Instance.new("Folder") -- local key = "Some Key" diff --git a/modules/roact-compat/src/RoactTree.lua b/modules/roact-compat/src/RoactTree.lua index 01cd3546..6b93c273 100644 --- a/modules/roact-compat/src/RoactTree.lua +++ b/modules/roact-compat/src/RoactTree.lua @@ -9,22 +9,20 @@ local function mount(element, parent, key) warnOnce("mount", "Please use the createRoot API in ReactRoblox") end - local rootInstance = Instance.new("Folder") - - local root = ReactRoblox.createLegacyRoot(rootInstance) - root:render(element) - if parent ~= nil and typeof(parent) ~= "Instance" then warnOnce("mount invalid argument", "Cannot mount into a parent that is not a Roblox Instance\n" .. inspect(parent)) - else - rootInstance.Parent = parent end - if key ~= nil then - rootInstance.Name = key - else - rootInstance.Name = "ReactLegacyRoot" + + local rootInstance = parent + + if rootInstance == nil then + rootInstance = Instance.new("Folder") + rootInstance.Name = key or "ReactLegacyRoot" end + local root = ReactRoblox.createLegacyRoot(rootInstance) + root:render(element) + return root end From b5825cf033bb4b780ea90a4b8136b2fe74247b97 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Mon, 19 Jul 2021 20:48:59 -0700 Subject: [PATCH 026/289] Luafdn 281 part 2 (#149) * Add supposedly-matching test, but identical strings aren't comparing the same in jest-roblox * Add new test that demonstrates incorrect behavior * Add tostring metamethod for pure component * Fix string matching issue, temporarily disable issue with dev matchers * Reintroduce additional error logging * Switch over to react roblox renderer to properly align tests, including warnings --- .../react-reconciler/src/ReactFiber.new.lua | 12 +- .../ReactErrorBoundaries-internal.spec.lua | 14 +- modules/react/rotriever.toml | 1 + modules/react/src/ReactBaseClasses.lua | 5 +- .../ReactElementValidator-internal.spec.lua | 159 ++++++++++-------- .../src/Matchers/createConsoleMatcher.lua | 6 +- 6 files changed, 104 insertions(+), 93 deletions(-) diff --git a/modules/react-reconciler/src/ReactFiber.new.lua b/modules/react-reconciler/src/ReactFiber.new.lua index 8ae19d94..2e2988d8 100644 --- a/modules/react-reconciler/src/ReactFiber.new.lua +++ b/modules/react-reconciler/src/ReactFiber.new.lua @@ -580,22 +580,20 @@ local function createFiberFromTypeAndProps( local ownerName if owner then ownerName = getComponentName(owner.type) - -- ROBLOX deviation: try harder to get the name of the component - elseif type ~= nil then - ownerName = type.__componentName end if ownerName then info ..= "\n\nCheck the render method of `" .. ownerName .. "`." - else - -- ROBLOX deviation: print the raw table in readable form to give a clue, if no other info was gathered - info ..= inspect(type) + elseif owner then + -- ROBLOX deviation: print the raw table in readable + -- form to give a clue, if no other info was gathered + info ..= "\n" .. inspect(owner) end end invariant( false, "Element type is invalid: expected a string (for built-in " .. "components) or a class/function (for composite components) " .. - "but got: %s. %s", + "but got: %s.%s", typeof(type), info ) diff --git a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua index 1cbc92ad..8ebd8749 100644 --- a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua @@ -2249,16 +2249,10 @@ return function() -- deviation: ReactNoop runs with a StrictMode root and logs more warnings jestExpect(function() root.render(React.createElement(InvalidErrorBoundary, nil, React.createElement(Throws))) - end).toErrorDev({ - "Warning: The above error occurred in the component:\ -\ - in Throws (at **)\ - in InvalidErrorBoundary (at **)\ -\ -React will try to recreate this component tree from scratch using the error boundary you provided, InvalidErrorBoundary.\ - in InvalidErrorBoundary (at **)", - "Warning: InvalidErrorBoundary: Error boundaries should implement getDerivedStateFromError(). In that method, return a state update to display an error message or fallback UI.", - }) + end).toErrorDev( + "InvalidErrorBoundary: Error boundaries should implement getDerivedStateFromError(). " .. + "In that method, return a state update to display an error message or fallback UI." + ) jestExpect(textContent(root)).toEqual("") end) it("should call both componentDidCatch and getDerivedStateFromError if both exist on a component", function() diff --git a/modules/react/rotriever.toml b/modules/react/rotriever.toml index fdb7d943..e6a777a3 100644 --- a/modules/react/rotriever.toml +++ b/modules/react/rotriever.toml @@ -17,5 +17,6 @@ Promise = { workspace = true} RobloxJest = { path = "../roblox-jest" } ReactCache = { path = "../react-cache" } ReactNoopRenderer = { path = "../react-noop-renderer" } +ReactRoblox = { path = "../react-roblox" } ReactTestRenderer = { path = "../react-test-renderer" } Scheduler = { path = "../scheduler" } diff --git a/modules/react/src/ReactBaseClasses.lua b/modules/react/src/ReactBaseClasses.lua index 850a598a..14ab6b0c 100644 --- a/modules/react/src/ReactBaseClasses.lua +++ b/modules/react/src/ReactBaseClasses.lua @@ -272,7 +272,10 @@ pureComponentClassPrototype.isPureReactComponent = true -- ROBLOX: FIXME: we should clean this up and align the implementations of -- Component and PureComponent more clearly and explicitly setmetatable(PureComponent, { - __index = pureComponentClassPrototype + __index = pureComponentClassPrototype, + __tostring = function(self) + return self.__componentName + end, }) return { diff --git a/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua b/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua index f01a2cd2..4fd631b0 100644 --- a/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua +++ b/modules/react/src/__tests__/ReactElementValidator-internal.spec.lua @@ -12,11 +12,20 @@ return function() local RobloxJest = require(Packages.Dev.RobloxJest) local React - local ReactNoop + local ReactRoblox local ReactFeatureFlags -- ROBLOX deviation: the tests using these are currently SKIPped local PropTypes = nil - local ReactTestUtils = nil + -- ROBLOX deviation: This function is a misnomer even in upstream; here, we + -- just render it into an orphaned root + local ReactTestUtils = { + renderIntoDocument = function(element) + local instance = Instance.new("Folder") + local root = ReactRoblox.createLegacyRoot(instance) + root:render(element) + return root + end + } describe("ReactElementValidator", function() local ComponentClass @@ -28,9 +37,7 @@ return function() ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false React = require(script.Parent.Parent) - -- deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop = require(Packages.Dev.ReactNoopRenderer) - -- ReactDOM = require("react-dom") + ReactRoblox = require(Packages.Dev.ReactRoblox) -- ReactTestUtils = require("react-dom/test-utils") ComponentClass = React.Component:extend("ComponentClass") function ComponentClass:render() @@ -64,10 +71,7 @@ return function() end jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement(ComponentWrapper)) - end) + ReactTestUtils.renderIntoDocument(React.createElement(ComponentWrapper)) end).toErrorDev( 'Each child in a list should have a unique "key" prop.' .. "\n\nCheck the render method of `InnerClass`. " .. @@ -77,51 +81,45 @@ return function() it("warns for keys for arrays with no owner or parent info", function() local function Anonymous() - return React.createElement("div") + return React.createElement("Frame") end -- Object.defineProperty(Anonymous, "name", {value = nil}) local divs = { - React.createElement("div"), - React.createElement("div"), + React.createElement("Frame"), + React.createElement("Frame"), } jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement(Anonymous, nil, divs)) - end) + ReactTestUtils.renderIntoDocument(React.createElement(Anonymous, nil, divs)) end).toErrorDev( "Warning: Each child in a list should have a unique " .. '"key" prop. See https://reactjs.org/link/warning-keys for more information.\n' .. - " in div (at **)" + " in Frame (at **)" ) end) it("warns for keys for arrays of elements with no owner info", function() local divs = { - React.createElement("div"), - React.createElement("div"), + React.createElement("Frame"), + React.createElement("Frame"), } jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement("div", nil, divs)) - end) + ReactTestUtils.renderIntoDocument(React.createElement("Frame", nil, divs)) end).toErrorDev( "Warning: Each child in a list should have a unique " .. - '"key" prop.\n\nCheck the top-level render call using
. See ' .. + '"key" prop.\n\nCheck the top-level render call using . See ' .. "https://reactjs.org/link/warning-keys for more information.\n" .. - " in div (at **)" + " in Frame (at **)" ) end) it("warns for keys with component stack info", function() local function Component() - return React.createElement("div", nil, { - React.createElement("div"), - React.createElement("div"), + return React.createElement("Frame", nil, { + React.createElement("Frame"), + React.createElement("Frame"), }) end local function Parent(props) @@ -134,15 +132,12 @@ return function() end jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement(GrandParent, nil)) - end) + ReactTestUtils.renderIntoDocument(React.createElement(GrandParent, nil)) end).toErrorDev( "Warning: Each child in a list should have a unique " .. '"key" prop.\n\nCheck the render method of `Component`. See ' .. "https://reactjs.org/link/warning-keys for more information.\n" .. - " in div (at **)\n" .. + " in Frame (at **)\n" .. " in Component (at **)\n" .. " in Parent (at **)\n" .. " in GrandParent (at **)" @@ -155,24 +150,20 @@ return function() "Frame", nil, props.children, - React.createElement('footer') + React.createElement("Frame") ) end - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM - -- renderer; additionally, add an expectation to make sure we get - -- _no_ errors + -- ROBLOX deviation: Add expectation to make sure we get _no_ errors jestExpect(function() - ReactNoop.act(function() - ReactNoop.render( - React.createElement( - Wrapper, - nil, - React.createElement('span'), - React.createElement('span', nil) - ) + ReactTestUtils.renderIntoDocument( + React.createElement( + Wrapper, + nil, + React.createElement("Frame"), + React.createElement("Frame", nil) ) - end) + ) end).toErrorDev({}) end) @@ -182,14 +173,12 @@ return function() it("does not warn for keys when providing keys via children tables", function() -- ROBLOX FIXME: Expect coercion jestExpect(function() - ReactNoop.act(function() - ReactNoop.render( - React.createElement("Frame", nil, { - ChildA = React.createElement('span'), - ChildB = React.createElement('span'), - }) - ) - end) + ReactTestUtils.renderIntoDocument( + React.createElement("Frame", nil, { + ChildA = React.createElement("Frame"), + ChildB = React.createElement("Frame"), + }) + ) end).toErrorDev({}) end) @@ -341,10 +330,13 @@ return function() React.createElement("Frame") end) - -- ROBLOX TODO: ReactTestUtils should be backed by react-test-renderer - itSKIP("includes the owner name when passing null, undefined, boolean, or number", function() + it("includes the owner name when passing null, undefined, boolean, or number", function() local function ParentComp() - return React.createElement(nil) + -- ROBLOX DEVIATION: The test says "null, undefined, boolean, or + -- number", but uses `null`, which it treats differently from + -- `undefined`. Here, we're passing a number, which should have + -- behavior identical to upstream + return React.createElement(1) end jestExpect(function() @@ -352,14 +344,39 @@ return function() ReactTestUtils.renderIntoDocument(React.createElement(ParentComp)) end).toThrowError( "Element type is invalid: expected a string (for built-in components) " .. - "or a class/function (for composite components) but got: null." .. + "or a class/function (for composite components) but got: number." .. (_G.__DEV__ and "\n\nCheck the render method of `ParentComp`." or "") ) end).toErrorDev( "Warning: React.createElement: type is invalid -- expected a string " .. "(for built-in components) or a class/function (for composite " .. - "components) but got: null." .. - "\n\nCheck the render method of `ParentComp`.\n in ParentComp" + "components) but got: number." + -- ROBLOX FIXME: Error output differs + -- "\n\nCheck the render method of `ParentComp`.\n in ParentComp", + ) + end) + + -- ROBLOX deviation: Regression test for error output issue + it("includes the owner name of a PureComponent", function() + local ParentPureComp = React.PureComponent:extend("ParentPureComp") + function ParentPureComp:render() + return React.createElement(1) + end + + jestExpect(function() + jestExpect(function() + ReactTestUtils.renderIntoDocument(React.createElement(ParentPureComp)) + end).toThrowError( + "Element type is invalid: expected a string (for built-in components) " .. + "or a class/function (for composite components) but got: number." .. + (_G.__DEV__ and "\n\nCheck the render method of `ParentPureComp`." or "") + ) + end).toErrorDev( + "Warning: React.createElement: type is invalid -- expected a string " .. + "(for built-in components) or a class/function (for composite " .. + "components) but got: number." + -- ROBLOX FIXME: Error output differs + -- "\n\nCheck the render method of `ParentPureComp`.\n in ParentPureComp" ) end) @@ -484,13 +501,12 @@ return function() it("warns for fragments with illegal attributes", function() local Foo = React.Component:extend("Foo") function Foo:render() - return React.createElement(React.Fragment, {a = 1}, "123") + -- ROBLOX deviation: Use an actual child element instead of a + -- text instance, which is unsupported in ReactRoblox + return React.createElement(React.Fragment, {a = 1}, React.createElement("Frame")) end jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement(Foo)) - end) + ReactTestUtils.renderIntoDocument(React.createElement(Foo)) end).toErrorDev( "Invalid prop `a` supplied to `React.Fragment`. React.Fragment " .. "can only have `key` and `children` props." @@ -603,23 +619,20 @@ return function() it("warns when keys are provided via both the 'key' prop AND table keys", function() local Component = React.Component:extend("Component") function Component:render() - return React.createElement("div", nil, { - a = React.createElement("div", {key="a"}), - b = React.createElement("div", {key="b"}), + return React.createElement("Frame", nil, { + a = React.createElement("Frame", {key="a"}), + b = React.createElement("Frame", {key="b"}), }) end jestExpect(function() - -- ROBLOX deviation: Use Noop to drive these tests instead of DOM renderer - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component)) - end) + ReactTestUtils.renderIntoDocument(React.createElement(Component)) end).toErrorDev('Child element received a "key" prop in addition to a key in ' .. 'the "children" table of its parent. Please provide only ' .. 'one key definition. When both are present, the "key" prop ' .. 'will take precedence.\n\nCheck the render method of `Component`. ' .. 'See https://reactjs.org/link/warning-keys for more information.\n' .. - ' in div (at **)\n' .. + ' in Frame (at **)\n' .. ' in Component (at **)' ) end) diff --git a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua b/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua index d8f934f0..ad4c1f5e 100644 --- a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua +++ b/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua @@ -9,12 +9,14 @@ local function shouldIgnoreConsoleError(format, args) -- we use the __DEV__ global if _G.__DEV__ then if typeof(format) == "string" then - if format:find("Error: Uncaught [") == 0 then + if format:find("Error: Uncaught [") == 1 then -- // This looks like an uncaught error from invokeGuardedCallback() wrapper -- // in development that is reported by jsdom. Ignore because it's noisy. return true end - if format:find("The above error occurred") == 0 then + -- ROBLOX FIXME: The "Warning: " prefix is applied before the string + -- reaches this function, which appears to not be the case upstream + if format:find("Warning: The above error occurred") == 1 then -- // This looks like an error addendum from ReactFiberErrorLogger. -- // Ignore it too. return true From ec94c53e5b17b6ffd9c1e035837748dd665bc9c5 Mon Sep 17 00:00:00 2001 From: Anonymous Author Date: Tue, 20 Jul 2021 10:44:08 -0700 Subject: [PATCH 027/289] Luafdn 381 portal implementation (#147) * Added test to check if portal logic renders one portal * fixing style check * Remove unused container * React portal test - should render many portals * Removed unnecessary passthrough frame in multiple portal render test * Add test - should render nested portals * Add test - should reconcile portal children * Add test - should unmount empty portal component wherever it appears * Added test - should create and destroy instances as children of target * Add test - should pass prop updates through to children * Added test - should throw if target is nil * Added test - should recreate instances if target changes in an update * add test - Should pass context values through portal nodes * remove focus from previous unit test * Remove changes for legacy context API * Add deviation information * Add ReactTestRenderer test suite and portal test * Added ReactIncrementalSideEffects test suite and implemented test `can delete a child when it unmounts inside a portal` * fix linting tests * More lint fixing * add ReactIncrementalSideEffect test - can delete a child when it unmounts with a portal * Add test to ReactRobloxFiber - should pass portal context when rendering subtree elsewhere * Added additional check to context test for correct number of children * Remove commented out code in portal Context test * Added test - should update portal context if it changes due to setState * Added test - should update portal context if it changes due to re-render * Add test - should throw on bad createPortal argument * Added some comments about duplicated tests and deviations * refactor parent and root initialization * removed unnecessary comments --- .../ReactIncrementalSideEffects.spec.lua | 1327 ++++++++++++++ .../__tests__/ReactRobloxFiber.spec.lua | 1545 +++++++++++++++++ .../__tests__/RobloxRenderer.roblox.spec.lua | 359 ++-- modules/react-test-renderer/rotriever.toml | 1 + .../src/__tests__/ReactTestRenderer.spec.lua | 126 ++ 5 files changed, 3155 insertions(+), 203 deletions(-) create mode 100644 modules/react-reconciler/src/__tests__/ReactIncrementalSideEffects.spec.lua create mode 100644 modules/react-roblox/src/client/__tests__/ReactRobloxFiber.spec.lua create mode 100644 modules/react-test-renderer/src/__tests__/ReactTestRenderer.spec.lua diff --git a/modules/react-reconciler/src/__tests__/ReactIncrementalSideEffects.spec.lua b/modules/react-reconciler/src/__tests__/ReactIncrementalSideEffects.spec.lua new file mode 100644 index 00000000..ee9e4ce4 --- /dev/null +++ b/modules/react-reconciler/src/__tests__/ReactIncrementalSideEffects.spec.lua @@ -0,0 +1,1327 @@ +--[[* + * 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 + ]] + +-- 'use strict' + +local Packages = script.Parent.Parent.Parent +local React +local ReactNoop +local Scheduler + +return function() + local RobloxJest = require(Packages.Dev.RobloxJest) + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + + describe('ReactIncrementalSideEffects', function() + beforeEach(function() + RobloxJest.resetModules() + + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + end) + + local function div(...) + local _, _, children = ... + -- ROBLOX DEVIATION: commented out unused children processing logic + -- children = children.map(function(c) + -- return(function() + -- if typeof(c) == 'string' then + -- return{ + -- text = c, + -- hidden = false, + -- } + -- end + + -- return c + -- end)() + -- end) + + return { + type = 'div', + children = children or {}, + prop = nil, + hidden = false, + } + end + + local function span(prop) + return{ + type = "span", + children = {}, + prop = prop, + hidden = false, + } + end + + local function text(t) + return { + text = t, + hidden = false + } + end + + -- -- Note: This is based on a similar component we use in www. We can delete + -- -- once the extra div wrapper is no longer necessary. + -- function LegacyHiddenDiv({children, mode}) + -- return ( + -- + -- ) + -- } + + -- it('can update child nodes of a host instance', () => { + -- function Bar(props) + -- return {props.text} + -- } + + -- function Foo(props) + -- return ( + --
+ -- + -- {props.text == 'World' ? : nil} + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span())]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span(), span())]) + -- }) + + -- it('can update child nodes of a fragment', function() + -- function Bar(props) + -- return {props.text} + -- } + + -- function Foo(props) + -- return ( + --
+ -- + -- {props.text == 'World' + -- ? [,
] + -- : props.text == 'Hi' + -- ? [
, ] + -- : nil} + -- + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span(), span('test'))]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(span(), span(), div(), span('test')), + -- ]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(span(), div(), span(), span('test')), + -- ]) + -- }) + + -- it('can update child nodes rendering into text nodes', function() + -- function Bar(props) + -- return props.text + -- } + + -- function Foo(props) + -- return ( + --
+ -- + -- {props.text == 'World' + -- ? [, '!'] + -- : nil} + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div('Hello')]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div('World', 'World', '!')]) + -- }) + + -- it('can deletes children either components, host or text', function() + -- function Bar(props) + -- return + -- } + + -- function Foo(props) + -- return ( + --
+ -- {props.show + -- ? [
, Hello, 'World'] + -- : []} + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(div(), span('Hello'), 'World'), + -- ]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div()]) + -- }) + + -- it('can delete a child that changes type - implicit keys', function() + -- local unmounted = false + + -- class ClassComponent extends React.Component { + -- componentWillUnmount() + -- unmounted = true + -- } + -- render() + -- return + -- } + -- } + + -- function FunctionComponent(props) + -- return + -- } + + -- function Foo(props) + -- return ( + --
+ -- {props.useClass ? ( + -- + -- ) : props.useFunction ? ( + -- + -- ) : props.useText ? ( + -- 'Text' + -- ) : nil} + -- Trail + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span('Class'), 'Trail')]) + + -- expect(unmounted).toBe(false) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span('Function'), 'Trail')]) + + -- expect(unmounted).toBe(true) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div('Text', 'Trail')]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div('Trail')]) + -- }) + + -- it('can delete a child that changes type - explicit keys', function() + -- local unmounted = false + + -- class ClassComponent extends React.Component { + -- componentWillUnmount() + -- unmounted = true + -- } + -- render() + -- return + -- } + -- } + + -- function FunctionComponent(props) + -- return + -- } + + -- function Foo(props) + -- return ( + --
+ -- {props.useClass ? ( + -- + -- ) : props.useFunction ? ( + -- + -- ) : nil} + -- Trail + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span('Class'), 'Trail')]) + + -- expect(unmounted).toBe(false) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div(span('Function'), 'Trail')]) + + -- expect(unmounted).toBe(true) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([div('Trail')]) + -- }) + + it('can delete a child when it unmounts inside a portal', function() + local function Bar(props) + return React.createElement("span", { + prop=props.children, + }) + end + + local portalContainer = ReactNoop.getOrCreateRootContainer('portalContainer') + + local function Foo(props) + return ReactNoop.createPortal( + props.show and { + React.createElement("div", { key = "a" }), + React.createElement(Bar, { key = "b" }, "Hello"), + "World", + }, + portalContainer + ) + end + + ReactNoop.render( + React.createElement("div", {}, { + React.createElement(Foo, { show = true }) + }) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({div()}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({ + div(), + span("Hello"), + text('World') + }) + + ReactNoop.render( + React.createElement("div", {}, { + React.createElement(Foo, { show = false }) + }) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({div()}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + + ReactNoop.render( + React.createElement("div", {}, { + React.createElement(Foo, { show = true }) + }) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({div()}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({ + div(), + span("Hello"), + text('World') + }) + + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + + ReactNoop.render(React.createElement(Foo, { show = false })) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + + ReactNoop.render(React.createElement(Foo, { show = true })) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({ + div(), + span("Hello"), + text('World') + }) + + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + end) + + it('can delete a child when it unmounts with a portal', function() + local function Bar(props) + return React.createElement("span", { prop = props.children }) + end + + local portalContainer = ReactNoop.getOrCreateRootContainer('portalContainer') + + local function Foo(props) + return ReactNoop.createPortal( + { + React.createElement("div", { key = "a" }), + React.createElement(Bar, { key = "b" }, "Hello"), + "World" + }, + portalContainer + ) + end + + ReactNoop.render( + React.createElement("div", {}, { + React.createElement(Foo) + }) + ) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({div()}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({ + div(), + span("Hello"), + text('World') + }) + + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + + ReactNoop.render(React.createElement(Foo)) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({ + div(), + span("Hello"), + text('World') + }) + + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushWithoutYielding() + jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren('portalContainer')).toEqual({}) + end) + + -- it('does not update child nodes if a flush is aborted', () => { + -- function Bar(props) + -- Scheduler.unstable_yieldValue('Bar') + -- return + -- } + + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return ( + --
+ --
+ -- + -- {props.text == 'Hello' ? : nil} + --
+ -- + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'Bar', 'Bar']) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(div(span('Hello'), span('Hello')), span('Yo')), + -- ]) + + -- ReactNoop.render() + + -- -- Flush some of the work without committing + -- expect(Scheduler).toFlushAndYieldThrough(['Foo', 'Bar']) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(div(span('Hello'), span('Hello')), span('Yo')), + -- ]) + -- }) + + -- -- @gate experimental + -- it('preserves a previously rendered node when deprioritized', () => { + -- function Middle(props) + -- Scheduler.unstable_yieldValue('Middle') + -- return + -- } + + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return ( + --
+ -- + -- {props.text} + -- + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Middle']) + + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- + --
, + -- ) + + -- ReactNoop.render(, () => + -- Scheduler.unstable_yieldValue('commit'), + -- ) + -- expect(Scheduler).toFlushAndYieldThrough(['Foo', 'commit']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- + --
, + -- ) + + -- expect(Scheduler).toFlushAndYield(['Middle']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- + --
, + -- ) + -- }) + + -- -- @gate experimental + -- it('can reuse side-effects after being preempted', () => { + -- function Bar(props) + -- Scheduler.unstable_yieldValue('Bar') + -- return + -- } + + -- local middleContent = ( + --
+ -- Hello + -- World + --
+ -- ) + + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return ( + -- + -- {props.step == 0 ? ( + --
+ -- Hi + -- {props.text} + --
+ -- ) : ( + -- middleContent + -- )} + --
+ -- ) + -- } + + -- -- Init + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'Bar']) + + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + + -- -- Make a quick update which will schedule low priority work to + -- -- update the middle content. + -- ReactNoop.render(, () => + -- Scheduler.unstable_yieldValue('commit'), + -- ) + -- expect(Scheduler).toFlushAndYieldThrough(['Foo', 'commit', 'Bar']) + + -- -- The tree remains unchanged. + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + + -- -- The first Bar has already completed its update but we'll interrupt it to + -- -- render some higher priority work. The middle content will bailout so + -- -- it remains untouched which means that it should reuse it next time. + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'Bar']) + + -- -- Since we did nothing to the middle subtree during the interruption, + -- -- we should be able to reuse the reconciliation work that we already did + -- -- without restarting. The side-effects should still be replayed. + + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + -- }) + + -- -- @gate experimental + -- it('can reuse side-effects after being preempted, if shouldComponentUpdate is false', () => { + -- class Bar extends React.Component { + -- shouldComponentUpdate(nextProps) + -- return this.props.children ~= nextProps.children + -- } + -- render() + -- Scheduler.unstable_yieldValue('Bar') + -- return + -- } + -- } + + -- class Content extends React.Component { + -- shouldComponentUpdate(nextProps) + -- return this.props.step ~= nextProps.step + -- } + -- render() + -- Scheduler.unstable_yieldValue('Content') + -- return ( + --
+ -- {this.props.step == 0 ? 'Hi' : 'Hello'} + -- {this.props.step == 0 ? this.props.text : 'World'} + --
+ -- ) + -- } + -- } + + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return ( + -- + -- + -- + -- ) + -- } + + -- -- Init + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Content', 'Bar', 'Bar']) + + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + + -- -- Make a quick update which will schedule low priority work to + -- -- update the middle content. + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYieldThrough(['Foo', 'Content', 'Bar']) + + -- -- The tree remains unchanged. + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + + -- -- The first Bar has already completed its update but we'll interrupt it to + -- -- render some higher priority work. The middle content will bailout so + -- -- it remains untouched which means that it should reuse it next time. + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Content', 'Bar', 'Bar']) + + -- -- Since we did nothing to the middle subtree during the interruption, + -- -- we should be able to reuse the reconciliation work that we already did + -- -- without restarting. The side-effects should still be replayed. + + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + -- }) + + -- it('can update a completed tree before it has a chance to commit', () => { + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return + -- } + -- ReactNoop.render() + -- -- This should be just enough to complete the tree without committing it + -- expect(Scheduler).toFlushAndYieldThrough(['Foo']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual(null) + -- -- To confirm, perform one more unit of work. The tree should now + -- -- be flushed. + -- ReactNoop.flushNextYield() + -- expect(ReactNoop.getChildrenAsJSX()).toEqual() + + -- ReactNoop.render() + -- -- This should be just enough to complete the tree without committing it + -- expect(Scheduler).toFlushAndYieldThrough(['Foo']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual() + -- -- This time, before we commit the tree, we update the root component with + -- -- new props + -- ReactNoop.render() + -- expect(ReactNoop.getChildrenAsJSX()).toEqual() + -- -- Now let's commit. We already had a commit that was pending, which will + -- -- render 2. + -- ReactNoop.flushNextYield() + -- expect(ReactNoop.getChildrenAsJSX()).toEqual() + -- -- If we flush the rest of the work, we should get another commit that + -- -- renders 3. If it renders 2 again, that means an update was dropped. + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildrenAsJSX()).toEqual() + -- }) + + -- -- @gate experimental + -- it('updates a child even though the old props is empty', () => { + -- function Foo(props) + -- return ( + -- + -- + -- + -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + -- , + -- ) + -- }) + + -- xit('can defer side-effects and resume them later on', () => { + -- class Bar extends React.Component { + -- shouldComponentUpdate(nextProps) + -- return this.props.idx ~= nextProps.idx + -- } + -- render() + -- return + -- } + -- } + -- function Foo(props) + -- return ( + --
+ -- + -- + --
+ -- ) + -- } + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(40 + 25) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(0), + -- div(--[[the spans are down-prioritized and not rendered yet]]), + -- ), + -- ]) + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(35 + 25) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(span(1), div(--[[still not rendered yet]])), + -- ]) + -- ReactNoop.flushDeferredPri(30 + 25) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(1), + -- div( + -- -- Now we had enough time to finish the spans. + -- span(0), + -- span(1), + -- ), + -- ), + -- ]) + -- local innerSpanA = ReactNoop.getChildren()[0].children[1].children[1] + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(30 + 25) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(2), + -- div( + -- -- Still same old numbers. + -- span(0), + -- span(1), + -- ), + -- ), + -- ]) + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(3), + -- div( + -- -- New numbers. + -- span(1), + -- span(2), + -- ), + -- ), + -- ]) + + -- local innerSpanB = ReactNoop.getChildren()[0].children[1].children[1] + -- -- This should have been an update to an existing instance, not recreation. + -- -- We verify that by ensuring that the child instance was the same as + -- -- before. + -- expect(innerSpanA).toBe(innerSpanB) + -- }) + + -- xit('can defer side-effects and reuse them later - complex', function() + -- local ops = [] + + -- class Bar extends React.Component { + -- shouldComponentUpdate(nextProps) + -- return this.props.idx ~= nextProps.idx + -- } + -- render() + -- ops.push('Bar') + -- return + -- } + -- } + -- class Baz extends React.Component { + -- shouldComponentUpdate(nextProps) + -- return this.props.idx ~= nextProps.idx + -- } + -- render() + -- ops.push('Baz') + -- return [ + -- , + -- , + -- ] + -- } + -- } + -- function Foo(props) + -- ops.push('Foo') + -- return ( + --
+ -- + -- + --
+ -- ) + -- } + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(65 + 5) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(0), + -- div(--[[the spans are down-prioritized and not rendered yet]]), + -- ), + -- ]) + + -- expect(ops).toEqual(['Foo', 'Baz', 'Bar']) + -- ops = [] + + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(70) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div(span(1), div(--[[still not rendered yet]])), + -- ]) + + -- expect(ops).toEqual(['Foo']) + -- ops = [] + + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(1), + -- div( + -- -- Now we had enough time to finish the spans. + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- ), + -- ), + -- ]) + + -- expect(ops).toEqual(['Bar', 'Baz', 'Bar', 'Bar', 'Baz', 'Bar', 'Bar']) + -- ops = [] + + -- -- Now we're going to update the index but we'll only local it finish half + -- -- way through. + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(95) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(2), + -- div( + -- -- Still same old numbers. + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- ), + -- ), + -- ]) + + -- -- We local it finish half way through. That means we'll have one fully + -- -- completed Baz, one half-way completed Baz and one fully incomplete Baz. + -- expect(ops).toEqual(['Foo', 'Baz', 'Bar', 'Bar', 'Baz', 'Bar']) + -- ops = [] + + -- -- We'll update again, without letting the new index update yet. Only half + -- -- way through. + -- ReactNoop.render() + -- ReactNoop.flushDeferredPri(50) + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(3), + -- div( + -- -- Old numbers. + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- span(0), + -- ), + -- ), + -- ]) + + -- expect(ops).toEqual(['Foo']) + -- ops = [] + + -- -- We should now be able to reuse some of the work we've already done + -- -- and replay those side-effects. + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([ + -- div( + -- span(3), + -- div( + -- -- New numbers. + -- span(1), + -- span(1), + -- span(1), + -- span(1), + -- span(1), + -- span(1), + -- ), + -- ), + -- ]) + + -- expect(ops).toEqual(['Bar', 'Baz', 'Bar', 'Bar']) + -- }) + + -- -- @gate experimental + -- it('deprioritizes setStates that happens within a deprioritized tree', () => { + -- local barInstances = [] + + -- class Bar extends React.Component { + -- constructor() + -- super() + -- this.state = {active: false} + -- } + -- activate() + -- this.setState({active: true}) + -- } + -- render() + -- barInstances.push(this) + -- Scheduler.unstable_yieldValue('Bar') + -- return + -- } + -- } + -- function Foo(props) + -- Scheduler.unstable_yieldValue('Foo') + -- return ( + --
+ -- + -- + -- + -- + -- + -- + --
+ -- ) + -- } + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'Bar', 'Bar']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- + -- + --
, + -- ) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushAndYieldThrough(['Foo', 'Bar', 'Bar']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- {--[[ Updated ]]} + -- + -- + --
, + -- ) + + -- barInstances[0].activate() + + -- -- This should not be enough time to render the content of all the hidden + -- -- items. Including the set state since that is deprioritized. + -- -- ReactNoop.flushDeferredPri(35) + -- expect(Scheduler).toFlushAndYieldThrough(['Bar']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- {--[[ Updated ]]} + -- + -- + --
, + -- ) + + -- -- However, once we render fully, we will have enough time to finish it all + -- -- at once. + -- expect(Scheduler).toFlushAndYield(['Bar', 'Bar']) + -- expect(ReactNoop.getChildrenAsJSX()).toEqual( + --
+ -- + -- + --
, + -- ) + -- }) + -- -- TODO: Test that side-effects are not cut off when a work in progress node + -- -- moves to "current" without flushing due to having lower priority. Does this + -- -- even happen? Maybe a child doesn't get processed because it is lower prio? + + -- it('calls callback after update is flushed', () => { + -- local instance + -- class Foo extends React.Component { + -- constructor() + -- super() + -- instance = this + -- this.state = {text: 'foo'} + -- } + -- render() + -- return + -- } + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([span('foo')]) + -- local called = false + -- instance.setState({text: 'bar'}, () => { + -- expect(ReactNoop.getChildren()).toEqual([span('bar')]) + -- called = true + -- }) + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(called).toBe(true) + -- }) + + -- it('calls setState callback even if component bails out', () => { + -- local instance + -- class Foo extends React.Component { + -- constructor() + -- super() + -- instance = this + -- this.state = {text: 'foo'} + -- } + -- shouldComponentUpdate(nextProps, nextState) + -- return this.state.text ~= nextState.text + -- } + -- render() + -- return + -- } + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ReactNoop.getChildren()).toEqual([span('foo')]) + -- local called = false + -- instance.setState({}, () => { + -- called = true + -- }) + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(called).toBe(true) + -- }) + + -- -- TODO: Test that callbacks are not lost if an update is preempted. + + -- it('calls componentWillUnmount after a deletion, even if nested', () => { + -- local ops = [] + + -- class Bar extends React.Component { + -- componentWillUnmount() + -- ops.push(this.props.name) + -- } + -- render() + -- return + -- } + -- } + + -- class Wrapper extends React.Component { + -- componentWillUnmount() + -- ops.push('Wrapper') + -- } + -- render() + -- return + -- } + -- } + + -- function Foo(props) + -- return ( + --
+ -- {props.show + -- ? [ + -- , + -- , + --
+ -- + -- , + --
, + -- [, ], + -- ] + -- : []} + --
{props.show ? : nil}
+ -- + --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([]) + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([ + -- 'A', + -- 'Wrapper', + -- 'B', + -- 'C', + -- 'Wrapper', + -- 'D', + -- 'E', + -- 'F', + -- 'G', + -- ]) + -- }) + + -- it('calls componentDidMount/Update after insertion/update', () => { + -- local ops = [] + + -- class Bar extends React.Component { + -- componentDidMount() + -- ops.push('mount:' + this.props.name) + -- } + -- componentDidUpdate() + -- ops.push('update:' + this.props.name) + -- } + -- render() + -- return + -- } + -- } + + -- class Wrapper extends React.Component { + -- componentDidMount() + -- ops.push('mount:wrapper-' + this.props.name) + -- } + -- componentDidUpdate() + -- ops.push('update:wrapper-' + this.props.name) + -- } + -- render() + -- return + -- } + -- } + + -- function Foo(props) + -- return ( + --
+ -- + -- + --
+ -- + -- + --
+ -- {[, ]} + --
+ -- + --
+ --
+ -- ) + -- } + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([ + -- 'mount:A', + -- 'mount:B', + -- 'mount:wrapper-B', + -- 'mount:C', + -- 'mount:D', + -- 'mount:wrapper-D', + -- 'mount:E', + -- 'mount:F', + -- 'mount:G', + -- ]) + + -- ops = [] + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([ + -- 'update:A', + -- 'update:B', + -- 'update:wrapper-B', + -- 'update:C', + -- 'update:D', + -- 'update:wrapper-D', + -- 'update:E', + -- 'update:F', + -- 'update:G', + -- ]) + -- }) + + -- it('invokes ref callbacks after insertion/update/unmount', () => { + -- local classInstance = nil + + -- local ops = [] + + -- class ClassComponent extends React.Component { + -- render() + -- classInstance = this + -- return + -- } + -- } + + -- function FunctionComponent(props) + -- return + -- } + + -- function Foo(props) + -- return props.show ? ( + --
+ -- ops.push(n)} /> + -- ops.push(n)} /> + --
ops.push(n)} /> + --
+ -- ) : nil + -- } + + -- ReactNoop.render() + -- expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( + -- 'Warning: Function components cannot be given refs. ' + + -- 'Attempts to access this ref will fail. ' + + -- 'Did you mean to use React.forwardRef()?\n\n' + + -- 'Check the render method ' + + -- 'of `Foo`.\n' + + -- ' in FunctionComponent (at **)\n' + + -- ' in div (at **)\n' + + -- ' in Foo (at **)', + -- ) + -- expect(ops).toEqual([ + -- classInstance, + -- -- no call for function components + -- div(), + -- ]) + + -- ops = [] + + -- -- Refs that switch function instances get reinvoked + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([ + -- -- detach all refs that switched handlers first. + -- nil, + -- nil, + -- -- reattach as a separate phase + -- classInstance, + -- div(), + -- ]) + + -- ops = [] + + -- ReactNoop.render() + -- expect(Scheduler).toFlushWithoutYielding() + -- expect(ops).toEqual([ + -- -- unmount + -- nil, + -- nil, + -- ]) + -- }) + + -- -- TODO: Test that mounts, updates, refs, unmounts and deletions happen in the + -- -- expected way for aborted and resumed render life-cycles. + + -- it('supports string refs', () => { + -- local fooInstance = nil + + -- class Bar extends React.Component { + -- componentDidMount() + -- this.test = 'test' + -- } + -- render() + -- return
+ -- } + -- } + + -- class Foo extends React.Component { + -- render() + -- fooInstance = this + -- return + -- } + -- } + + -- ReactNoop.render() + -- expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( + -- 'Warning: A string ref, "bar", has been found within a strict mode tree.', + -- ) + + -- expect(fooInstance.refs.bar.test).toEqual('test') + -- }) + end) +end \ No newline at end of file diff --git a/modules/react-roblox/src/client/__tests__/ReactRobloxFiber.spec.lua b/modules/react-roblox/src/client/__tests__/ReactRobloxFiber.spec.lua new file mode 100644 index 00000000..23ae1b4d --- /dev/null +++ b/modules/react-roblox/src/client/__tests__/ReactRobloxFiber.spec.lua @@ -0,0 +1,1545 @@ +--[[* + * 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 + ]] + +-- 'use strict' +local Packages = script.Parent.Parent.Parent.Parent + +local React +local ReactRoblox +local reactRobloxRoot +local Scheduler +local parent +-- local PropTypes = require('prop-types') + +return function() + -- local container + local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect + local RobloxJest = require(Packages.Dev.RobloxJest) + + beforeEach(function() + -- ROBLOX DEVIATION: Document logic does not apply to Roblox + -- container = document.createElement('div') + -- document.body.appendChild(container) + RobloxJest.resetModules() + RobloxJest.useFakeTimers() + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + parent = Instance.new("Folder") + reactRobloxRoot = ReactRoblox.createRoot(parent) + Scheduler = require(Packages.Scheduler) + end) + + -- ROBLOX DEVIATION: Document logic does not apply to Roblox + -- afterEach(function() + -- document.body.removeChild(container) + -- container = nil + -- end) + + -- it('should render strings as children', () => { + -- local Box = ({value}) =>
{value}
+ + -- ReactDOM.render(, container) + -- expect(container.textContent).toEqual('foo') + -- }) + + -- it('should render numbers as children', () => { + -- local Box = ({value}) =>
{value}
+ + -- ReactDOM.render(, container) + + -- expect(container.textContent).toEqual('10') + -- }) + + -- it('should be called a callback argument', () => { + -- -- mounting phase + -- local called = false + -- ReactDOM.render(
Foo
, container, () => (called = true)) + -- expect(called).toEqual(true) + + -- -- updating phase + -- called = false + -- ReactDOM.render(
Foo
, container, () => (called = true)) + -- expect(called).toEqual(true) + -- }) + + -- it('should call a callback argument when the same element is re-rendered', () => { + -- class Foo extends React.Component { + -- render() + -- return
Foo
+ -- } + -- } + -- local element = + + -- -- mounting phase + -- local called = false + -- ReactDOM.render(element, container, () => (called = true)) + -- expect(called).toEqual(true) + + -- -- updating phase + -- called = false + -- ReactDOM.unstable_batchedUpdates(() => { + -- ReactDOM.render(element, container, () => (called = true)) + -- }) + -- expect(called).toEqual(true) + -- }) + + -- it('should render a component returning strings directly from render', () => { + -- local Text = ({value}) => value + + -- ReactDOM.render(, container) + -- expect(container.textContent).toEqual('foo') + -- }) + + -- it('should render a component returning numbers directly from render', () => { + -- local Text = ({value}) => value + + -- ReactDOM.render(, container) + + -- expect(container.textContent).toEqual('10') + -- }) + + -- it('finds the DOM Text node of a string child', () => { + -- class Text extends React.Component { + -- render() + -- return this.props.value + -- } + -- } + + -- local instance = nil + -- ReactDOM.render( + -- (instance = ref)} />, + -- container, + -- ) + + -- local textNode = ReactDOM.findDOMNode(instance) + -- expect(textNode).toBe(container.firstChild) + -- expect(textNode.nodeType).toBe(3) + -- expect(textNode.nodeValue).toBe('foo') + -- }) + + -- it('finds the first child when a component returns a fragment', () => { + -- class Fragment extends React.Component { + -- render() + -- return [
, ] + -- } + -- } + + -- local instance = nil + -- ReactDOM.render( (instance = ref)} />, container) + + -- expect(container.childNodes.length).toBe(2) + + -- local firstNode = ReactDOM.findDOMNode(instance) + -- expect(firstNode).toBe(container.firstChild) + -- expect(firstNode.tagName).toBe('DIV') + -- }) + + -- it('finds the first child even when fragment is nested', () => { + -- class Wrapper extends React.Component { + -- render() + -- return this.props.children + -- } + -- } + + -- class Fragment extends React.Component { + -- render() + -- return [ + -- + --
+ -- , + -- , + -- ] + -- } + -- } + + -- local instance = nil + -- ReactDOM.render( (instance = ref)} />, container) + + -- expect(container.childNodes.length).toBe(2) + + -- local firstNode = ReactDOM.findDOMNode(instance) + -- expect(firstNode).toBe(container.firstChild) + -- expect(firstNode.tagName).toBe('DIV') + -- }) + + -- it('finds the first child even when first child renders nil', () => { + -- class NullComponent extends React.Component { + -- render() + -- return nil + -- } + -- } + + -- class Fragment extends React.Component { + -- render() + -- return [,
, ] + -- } + -- } + + -- local instance = nil + -- ReactDOM.render( (instance = ref)} />, container) + + -- expect(container.childNodes.length).toBe(2) + + -- local firstNode = ReactDOM.findDOMNode(instance) + -- expect(firstNode).toBe(container.firstChild) + -- expect(firstNode.tagName).toBe('DIV') + -- }) + + -- it('renders an empty fragment', () => { + -- local Div = () =>
+ -- local EmptyFragment = () => <> + -- local NonEmptyFragment = () => ( + -- <> + --
+ -- + -- ) + + -- ReactDOM.render(, container) + -- expect(container.firstChild).toBe(null) + + -- ReactDOM.render(, container) + -- expect(container.firstChild.tagName).toBe('DIV') + + -- ReactDOM.render(, container) + -- expect(container.firstChild).toBe(null) + + -- ReactDOM.render(
, container) + -- expect(container.firstChild.tagName).toBe('DIV') + + -- ReactDOM.render(, container) + -- expect(container.firstChild).toBe(null) + -- }) + + -- local svgEls, htmlEls, mathEls + -- local expectSVG = {ref: el => svgEls.push(el)} + -- local expectHTML = {ref: el => htmlEls.push(el)} + -- local expectMath = {ref: el => mathEls.push(el)} + + -- local usePortal = function(tree) + -- return ReactDOM.createPortal(tree, document.createElement('div')) + -- } + + -- local assertNamespacesMatch = function(tree) + -- local testContainer = document.createElement('div') + -- svgEls = [] + -- htmlEls = [] + -- mathEls = [] + + -- ReactDOM.render(tree, testContainer) + -- svgEls.forEach(el => { + -- expect(el.namespaceURI).toBe('http:--www.w3.org/2000/svg') + -- }) + -- htmlEls.forEach(el => { + -- expect(el.namespaceURI).toBe('http:--www.w3.org/1999/xhtml') + -- }) + -- mathEls.forEach(el => { + -- expect(el.namespaceURI).toBe('http:--www.w3.org/1998/Math/MathML') + -- }) + + -- ReactDOM.unmountComponentAtNode(testContainer) + -- expect(testContainer.innerHTML).toBe('') + -- } + + it('should render one portal', function() + local portalContainer = Instance.new("Frame") + + reactRobloxRoot:render( + React.createElement("Frame", {}, + ReactRoblox.createPortal(React.createElement("TextLabel", {Text = "portal"}), portalContainer) + ) + ) + Scheduler.unstable_flushAllWithoutAsserting() + + local children = portalContainer:GetChildren() + + jestExpect(#children).toBe(1) + jestExpect(children[1].ClassName).toBe("TextLabel") + jestExpect(children[1].Text).toBe('portal') + + reactRobloxRoot:unmount() + Scheduler.unstable_flushAllWithoutAsserting() + + children = portalContainer:GetChildren() + + jestExpect(#children).toBe(0) + end) + + -- ROBLOX DEVIATION: unstable_createPortal is not implemented in Roblox + -- -- TODO: remove in React 18 + -- if !__EXPERIMENTAL__) + -- it('should support unstable_createPortal alias', () => { + -- local portalContainer = document.createElement('div') + + -- expect(() => + -- ReactDOM.render( + --
+ -- {ReactDOM.unstable_createPortal(
portal
, portalContainer)} + --
, + -- container, + -- ), + -- ).toWarnDev( + -- 'The ReactDOM.unstable_createPortal() alias has been deprecated, ' + + -- 'and will be removed in React 18+. Update your code to use ' + + -- 'ReactDOM.createPortal() instead. It has the exact same API, ' + + -- 'but without the "unstable_" prefix.', + -- {withoutStack: true}, + -- ) + -- expect(portalContainer.innerHTML).toBe('
portal
') + -- expect(container.innerHTML).toBe('
') + + -- ReactDOM.unmountComponentAtNode(container) + -- expect(portalContainer.innerHTML).toBe('') + -- expect(container.innerHTML).toBe('') + -- }) + -- } + + it('should render many portals', function() + local portalContainer1 = Instance.new("Frame") + local portalContainer2 = Instance.new("Frame") + + local ops = {} + + local Child = React.Component:extend("Child") + + function Child:componentDidMount() + ops[#ops+1] = self.props.name .. " componentDidMount" + end + + function Child:componentDidUpdate() + ops[#ops+1] = self.props.name .. " componentDidUpdate" + end + + function Child:componentWillUnmount() + ops[#ops+1] = self.props.name .. " componentWillUnmount" + end + + function Child:render() + return React.createElement("TextLabel", {Text = self.props.name}) + end + + local Parent = React.Component:extend("Parent") + + function Parent:componentDidMount() + ops[#ops+1] = "Parent:" .. self.props.step .. " componentDidMount" + end + + function Parent:componentDidUpdate() + ops[#ops+1] = "Parent:" .. self.props.step .. " componentDidUpdate" + end + + function Parent:componentWillUnmount() + ops[#ops+1] = "Parent:" .. self.props.step .. " componentWillUnmount" + end + + function Parent:render() + local step = self.props.step + return { + React.createElement(Child, { + key = "a", + name = "normal[0]:" .. step, + }), + ReactRoblox.createPortal(React.createElement(Child, { + key = "b", + name = "portal1[0]:" .. step, + }), portalContainer1), + React.createElement(Child, { + key = "c", + name = "normal[1]:" .. step, + }), + ReactRoblox.createPortal( + { + React.createElement(Child, { + key = "d", + name = "portal2[0]:" .. step, + }), + React.createElement(Child, { + key = "e", + name = "portal2[1]:" .. step, + }) + }, portalContainer2), + } + end + + reactRobloxRoot:render(React.createElement(Parent, {step = "a"})) + + Scheduler.unstable_flushAllWithoutAsserting() + + local children1 = portalContainer1:GetChildren() + jestExpect(#children1).toBe(1) + jestExpect(children1[1].ClassName).toBe("TextLabel") + jestExpect(children1[1].Text).toBe("portal1[0]:a") + + local children2 = portalContainer2:GetChildren() + jestExpect(#children2).toBe(2) + jestExpect(children2[1].ClassName).toBe("TextLabel") + jestExpect(children2[1].Text).toBe("portal2[0]:a") + jestExpect(children2[2].ClassName).toBe("TextLabel") + jestExpect(children2[2].Text).toBe("portal2[1]:a") + + local childrenParent = parent:GetChildren() + jestExpect(#childrenParent).toBe(2) + jestExpect(childrenParent[1].ClassName).toBe("TextLabel") + jestExpect(childrenParent[1].Text).toBe("normal[0]:a") + jestExpect(childrenParent[2].ClassName).toBe("TextLabel") + jestExpect(childrenParent[2].Text).toBe("normal[1]:a") + + jestExpect(ops).toEqual({ + 'normal[0]:a componentDidMount', + 'portal1[0]:a componentDidMount', + 'normal[1]:a componentDidMount', + 'portal2[0]:a componentDidMount', + 'portal2[1]:a componentDidMount', + 'Parent:a componentDidMount', + }) + + ops = {} + + reactRobloxRoot:render(React.createElement(Parent, {step = "b"})) + + Scheduler.unstable_flushAllWithoutAsserting() + + children1 = portalContainer1:GetChildren() + jestExpect(#children1).toBe(1) + jestExpect(children1[1].ClassName).toBe("TextLabel") + jestExpect(children1[1].Text).toBe("portal1[0]:b") + + children2 = portalContainer2:GetChildren() + jestExpect(#children2).toBe(2) + jestExpect(children2[1].ClassName).toBe("TextLabel") + jestExpect(children2[1].Text).toBe("portal2[0]:b") + jestExpect(children2[2].ClassName).toBe("TextLabel") + jestExpect(children2[2].Text).toBe("portal2[1]:b") + + childrenParent = parent:GetChildren() + jestExpect(#childrenParent).toBe(2) + jestExpect(childrenParent[1].ClassName).toBe("TextLabel") + jestExpect(childrenParent[1].Text).toBe("normal[0]:b") + jestExpect(childrenParent[2].ClassName).toBe("TextLabel") + jestExpect(childrenParent[2].Text).toBe("normal[1]:b") + + jestExpect(ops).toEqual({ + 'normal[0]:b componentDidUpdate', + 'portal1[0]:b componentDidUpdate', + 'normal[1]:b componentDidUpdate', + 'portal2[0]:b componentDidUpdate', + 'portal2[1]:b componentDidUpdate', + 'Parent:b componentDidUpdate', + }) + + ops = {} + + reactRobloxRoot:unmount() + Scheduler.unstable_flushAllWithoutAsserting() + + children1 = portalContainer1:GetChildren() + jestExpect(#children1).toBe(0) + + + children2 = portalContainer2:GetChildren() + jestExpect(#children2).toBe(0) + + childrenParent = parent:GetChildren() + jestExpect(#childrenParent).toBe(0) + + jestExpect(ops).toEqual({ + 'Parent:b componentWillUnmount', + 'normal[0]:b componentWillUnmount', + 'portal1[0]:b componentWillUnmount', + 'normal[1]:b componentWillUnmount', + 'portal2[0]:b componentWillUnmount', + 'portal2[1]:b componentWillUnmount', + }) + + end) + + it('should render nested portals', function() + local portalContainer1 = Instance.new("Frame") + local portalContainer2 = Instance.new("Frame") + local portalContainer3 = Instance.new("Frame") + + reactRobloxRoot:render({ + React.createElement("TextLabel", {key = "a", Text = "normal[0]"}), + ReactRoblox.createPortal({ + React.createElement("TextLabel", {key = "b", Text = "portal1[0]"}), + ReactRoblox.createPortal(React.createElement("TextLabel", {key = "c", Text = "portal2[0]"}), portalContainer2), + ReactRoblox.createPortal(React.createElement("TextLabel", {key = "d", Text = "portal3[0]"}), portalContainer3), + React.createElement("TextLabel", {key = "e", Text = "portal1[1]"}), + }, portalContainer1), + React.createElement("TextLabel", {key = "f", Text = "normal[1]"}), + }) + + Scheduler.unstable_flushAllWithoutAsserting() + + local children1 = portalContainer1:GetChildren() + jestExpect(#children1).toBe(2) + jestExpect(children1[1].Text).toBe("portal1[0]") + jestExpect(children1[2].Text).toBe("portal1[1]") + + local children2 = portalContainer2:GetChildren() + jestExpect(#children2).toBe(1) + jestExpect(children2[1].Text).toBe("portal2[0]") + + local children3 = portalContainer3:GetChildren() + jestExpect(#children3).toBe(1) + jestExpect(children3[1].Text).toBe("portal3[0]") + + local childrenParent = parent:GetChildren() + jestExpect(#childrenParent).toBe(2) + jestExpect(childrenParent[1].Text).toBe("normal[0]") + jestExpect(childrenParent[2].Text).toBe("normal[1]") + + reactRobloxRoot:unmount() + + Scheduler.unstable_flushAllWithoutAsserting() + + jestExpect(#portalContainer1:GetChildren()).toBe(0) + jestExpect(#portalContainer2:GetChildren()).toBe(0) + jestExpect(#portalContainer3:GetChildren()).toBe(0) + jestExpect(#parent:GetChildren()).toBe(0) + end) + + it('should reconcile portal children', function() + local portalContainer = Instance.new("Frame") + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal(React.createElement("TextLabel", {Text = "portal:1"}), portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("portal:1") + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal(React.createElement("TextLabel", {Text = "portal:2"}), portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("portal:2") + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal(React.createElement("TextLabel", {Text = "portal:3"}), portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("portal:3") + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal({ + React.createElement("TextLabel", {Text = "Hi"}), + React.createElement("TextLabel", {Text = "Bye"}), + }, portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(2) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("Hi") + jestExpect(portalContainer:GetChildren()[2].Text).toBe("Bye") + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal({ + React.createElement("TextLabel", {Text = "Bye"}), + React.createElement("TextLabel", {Text = "Hi"}), + }, portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(2) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("Bye") + jestExpect(portalContainer:GetChildren()[2].Text).toBe("Hi") + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + + reactRobloxRoot:render( + React.createElement("Frame", {}, { + ReactRoblox.createPortal(nil, portalContainer) + }) + ) + + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#portalContainer:GetChildren()).toBe(0) + jestExpect(#parent:GetChildren()).toBe(1) + jestExpect(#parent:GetChildren()[1]:GetChildren()).toBe(0) + end) + + it('should unmount empty portal component wherever it appears', function() + local portalContainer = Instance.new("Frame") + + local capturedState + local capturedSetState + + local Wrapper = React.Component:extend("Wrapper") + function Wrapper:init() + self:setState({ + show = true, + }) + end + + function Wrapper:render() + capturedState = self.state + capturedSetState = function(...) + self:setState(...) + end + + return React.createElement("Frame", {}, { + self.state.show and React.createElement(React.Fragment, nil, { + ReactRoblox.createPortal(nil, portalContainer), + React.createElement("TextLabel", {Text = "child"}), + }), + React.createElement("TextLabel", {Text = "parent"}), + }) + end + + reactRobloxRoot:render(React.createElement(Wrapper)) + Scheduler.unstable_flushAllWithoutAsserting() + + jestExpect(#parent:GetChildren()).toBe(1) + + local children = parent:GetChildren()[1]:GetChildren() + jestExpect(#children).toBe(2) + jestExpect(children[1].Text).toBe("child") + jestExpect(children[2].Text).toBe("parent") + + capturedSetState({show = false}) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(capturedState.show).toBe(false) + jestExpect(#parent:GetChildren()).toBe(1) + + children = parent:GetChildren()[1]:GetChildren() + jestExpect(#children).toBe(1) + jestExpect(children[1].Text).toBe("parent") + + end) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should keep track of namespace across portals (simple)', () => { + -- assertNamespacesMatch( + -- + -- + -- {usePortal(
)} + -- + -- , + -- ) + -- assertNamespacesMatch( + -- + -- + -- {usePortal(
)} + -- + -- , + -- ) + -- assertNamespacesMatch( + --
+ --

+ -- {usePortal( + -- + -- + -- , + -- )} + --

+ --

, + -- ) + -- }) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should keep track of namespace across portals (medium)', () => { + -- assertNamespacesMatch( + -- + -- + -- {usePortal(
)} + -- + -- {usePortal(
)} + -- + -- , + -- ) + -- assertNamespacesMatch( + --
+ -- + -- + -- {usePortal( + -- + -- + -- , + -- )} + -- + --

+ --

, + -- ) + -- assertNamespacesMatch( + -- + -- + -- {usePortal( + -- + -- + -- + --

+ -- + -- + -- + --

+ -- + -- + -- , + -- )} + -- + -- , + -- ) + -- assertNamespacesMatch( + --

+ -- {usePortal( + -- + -- {usePortal(
)} + -- + -- , + -- )} + --

+ --

, + -- ) + -- assertNamespacesMatch( + -- + -- + -- {usePortal(
)} + -- + -- + -- + -- , + -- ) + -- }) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should keep track of namespace across portals (complex)', () => { + -- assertNamespacesMatch( + --
+ -- {usePortal( + -- + -- + -- , + -- )} + --

+ -- + -- + -- + -- + -- + -- + -- + -- + -- + --

+ --

, + -- ) + -- assertNamespacesMatch( + --
+ -- + -- + -- + -- {usePortal( + -- + -- + -- + -- + -- + -- + -- , + -- )} + -- + -- + --

+ -- {usePortal(

)} + --

+ -- + -- + -- + -- + --

+ -- , + -- ) + -- assertNamespacesMatch( + --

+ -- + -- + --

+ -- {usePortal( + -- + -- + -- + -- + -- + --

+ -- + -- {usePortal(

)} + -- + -- + -- , + -- )} + --

+ -- + -- + -- + --

+ -- , + -- ) + -- }) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should unwind namespaces on uncaught errors', () => { + -- function BrokenRender() + -- throw new Error('Hello') + -- } + + -- expect(() => { + -- assertNamespacesMatch( + -- + -- + -- , + -- ) + -- }).toThrow('Hello') + -- assertNamespacesMatch(

) + -- }) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should unwind namespaces on caught errors', () => { + -- function BrokenRender() + -- throw new Error('Hello') + -- } + + -- class ErrorBoundary extends React.Component { + -- state = {error: nil} + -- componentDidCatch(error) + -- this.setState({error}) + -- } + -- render() + -- if this.state.error) + -- return

+ -- } + -- return this.props.children + -- } + -- } + + -- assertNamespacesMatch( + -- + -- + -- + -- + -- + -- + -- + -- + -- + -- , + -- ) + -- assertNamespacesMatch(

) + -- }) + + -- ROBLOX DEVIATION: Roblox does not have the same concept of namespaces as upstream React + -- it('should unwind namespaces on caught errors in a portal', () => { + -- function BrokenRender() + -- throw new Error('Hello') + -- } + + -- class ErrorBoundary extends React.Component { + -- state = {error: nil} + -- componentDidCatch(error) + -- this.setState({error}) + -- } + -- render() + -- if this.state.error) + -- return + -- } + -- return this.props.children + -- } + -- } + + -- assertNamespacesMatch( + -- + -- + -- {usePortal( + --
+ -- + -- ) + -- + --
, + -- )} + -- + -- {usePortal(
)} + -- , + -- ) + -- }) + + it('should pass portal context when rendering subtree elsewhere', function() + local portalContainer = Instance.new("Folder") + + local Context = React.createContext(1) + + local function Consumer() + return React.createElement(Context.Consumer, nil, function(value) + return React.createElement("TextLabel", {Text = tostring(value)}) + end) + end + + local function Parent(props) + return React.createElement(Context.Provider, { + value = props.value + }, { + Portal = ReactRoblox.createPortal({ + Consumer = React.createElement(Consumer) + }, portalContainer) + }) + end + + reactRobloxRoot:render(React.createElement(Parent, { value = "bar" })) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#parent:GetChildren()).toBe(0) + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("bar") + + end) + + it('should update portal context if it changes due to setState', function() + local portalContainer = Instance.new("Folder") + + local Context = React.createContext(1) + + local function Consumer() + return React.createElement(Context.Consumer, nil, function(value) + return React.createElement("TextLabel", {Text = tostring(value)}) + end) + end + + local capturedSetState + + local Parent = React.Component:extend("Parent") + + function Parent:init() + self:setState({ + value = "initial" + }) + end + + function Parent:render() + capturedSetState = function(...) + self:setState(...) + end + + return React.createElement(Context.Provider, { + value = self.state.value, + }, { + Portal = ReactRoblox.createPortal({ + Consumer = React.createElement(Consumer) + }, portalContainer) + }) + end + + reactRobloxRoot:render(React.createElement(Parent)) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#parent:GetChildren()).toBe(0) + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("initial") + + capturedSetState({ value = "changed" }) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#parent:GetChildren()).toBe(0) + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("changed") + end) + + it('should update portal context if it changes due to re-render', function() + -- ROBLOX TODO: This test is essentially duplicated in RobloxRenderer.roblox.spec.lua. Where do we want it? + local portalContainer = Instance.new("Folder") + + local Context = React.createContext(1) + + local function Consumer() + return React.createElement(Context.Consumer, nil, function(value) + return React.createElement("TextLabel", {Text = tostring(value)}) + end) + end + + local function Parent(props) + return React.createElement(Context.Provider, { + value = props.value + }, { + Portal = ReactRoblox.createPortal({ + Consumer = React.createElement(Consumer) + }, portalContainer) + }) + end + + reactRobloxRoot:render(React.createElement(Parent, { value = "initial" })) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#parent:GetChildren()).toBe(0) + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("initial") + + reactRobloxRoot:render(React.createElement(Parent, { value = "changed" })) + Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(#parent:GetChildren()).toBe(0) + jestExpect(#portalContainer:GetChildren()).toBe(1) + jestExpect(portalContainer:GetChildren()[1].Text).toBe("changed") + end) + + -- it('findDOMNode should find dom element after expanding a fragment', () => { + -- class MyNode extends React.Component { + -- render() + -- return !this.props.flag + -- ? [
] + -- : [,
] + -- } + -- } + + -- local myNodeA = ReactDOM.render(, container) + -- local a = ReactDOM.findDOMNode(myNodeA) + -- expect(a.tagName).toBe('DIV') + + -- local myNodeB = ReactDOM.render(, container) + -- expect(myNodeA == myNodeB).toBe(true) + + -- local b = ReactDOM.findDOMNode(myNodeB) + -- expect(b.tagName).toBe('SPAN') + -- }) + + -- ROBLOX DEVIATION: We do not have event bubbling like this in Roact + xit('should bubble events from the portal to the parent', function() + -- local portalContainer = document.createElement('div') + -- document.body.appendChild(portalContainer) + -- try { + -- local ops = [] + -- local portal = nil + + -- ReactDOM.render( + --
ops.push('parent clicked')}> + -- {ReactDOM.createPortal( + --
ops.push('portal clicked')} + -- ref={n => (portal = n)}> + -- portal + --
, + -- portalContainer, + -- )} + --
, + -- container, + -- ) + + -- expect(portal.tagName).toBe('DIV') + + -- portal.click() + + -- expect(ops).toEqual(['portal clicked', 'parent clicked']) + -- } finally { + -- document.body.removeChild(portalContainer) + -- } + end) + + -- ROBLOX DEVIATION: We do not have event bubbling in Roblox like this + xit('should not onMouseLeave when staying in the portal', function() + -- local portalContainer = document.createElement('div') + -- document.body.appendChild(portalContainer) + + -- local ops = [] + -- local firstTarget = nil + -- local secondTarget = nil + -- local thirdTarget = nil + + -- function simulateMouseMove(from, to) + -- if from) + -- from.dispatchEvent( + -- new MouseEvent('mouseout', { + -- bubbles: true, + -- cancelable: true, + -- relatedTarget: to, + -- }), + -- ) + -- } + -- if to) + -- to.dispatchEvent( + -- new MouseEvent('mouseover', { + -- bubbles: true, + -- cancelable: true, + -- relatedTarget: from, + -- }), + -- ) + -- } + -- } + + -- try { + -- ReactDOM.render( + --
+ --
ops.push('enter parent')} + -- onMouseLeave={() => ops.push('leave parent')}> + --
(firstTarget = n)} /> + -- {ReactDOM.createPortal( + --
ops.push('enter portal')} + -- onMouseLeave={() => ops.push('leave portal')} + -- ref={n => (secondTarget = n)}> + -- portal + --
, + -- portalContainer, + -- )} + --
+ --
(thirdTarget = n)} /> + --
, + -- container, + -- ) + + -- simulateMouseMove(null, firstTarget) + -- expect(ops).toEqual(['enter parent']) + + -- ops = [] + + -- simulateMouseMove(firstTarget, secondTarget) + -- expect(ops).toEqual([ + -- -- Parent did not invoke leave because we're still inside the portal. + -- 'enter portal', + -- ]) + + -- ops = [] + + -- simulateMouseMove(secondTarget, thirdTarget) + -- expect(ops).toEqual([ + -- 'leave portal', + -- 'leave parent', -- Only when we leave the portal does onMouseLeave fire. + -- ]) + -- } finally { + -- document.body.removeChild(portalContainer) + -- } + end) + + -- -- Regression test for https:--github.com/facebook/react/issues/19562 + -- it('does not fire mouseEnter twice when relatedTarget is the root node', () => { + -- local ops = [] + -- local target = nil + + -- function simulateMouseMove(from, to) + -- if from) + -- from.dispatchEvent( + -- new MouseEvent('mouseout', { + -- bubbles: true, + -- cancelable: true, + -- relatedTarget: to, + -- }), + -- ) + -- } + -- if to) + -- to.dispatchEvent( + -- new MouseEvent('mouseover', { + -- bubbles: true, + -- cancelable: true, + -- relatedTarget: from, + -- }), + -- ) + -- } + -- } + + -- ReactDOM.render( + --
(target = n)} + -- onMouseEnter={() => ops.push('enter')} + -- onMouseLeave={() => ops.push('leave')} + -- />, + -- container, + -- ) + + -- simulateMouseMove(null, container) + -- expect(ops).toEqual([]) + + -- ops = [] + -- simulateMouseMove(container, target) + -- expect(ops).toEqual(['enter']) + + -- ops = [] + -- simulateMouseMove(target, container) + -- expect(ops).toEqual(['leave']) + + -- ops = [] + -- simulateMouseMove(container, nil) + -- expect(ops).toEqual([]) + -- }) + + -- -- @gate enableEagerRootListeners + -- it('listens to events that do not exist in the Portal subtree', () => { + -- local onClick = jest.fn() + + -- local ref = React.createRef() + -- ReactDOM.render( + --
+ -- {ReactDOM.createPortal(, document.body)} + --
, + -- container, + -- ) + -- local event = new MouseEvent('click', { + -- bubbles: true, + -- }) + -- ref.current.dispatchEvent(event) + + -- expect(onClick).toHaveBeenCalledTimes(1) + -- }) + + it('should throw on bad createPortal argument', function() + jestExpect(function() + ReactRoblox.createPortal(React.createElement("Frame"), nil) + end).toThrow("Target container is not a Roblox Instance.") + jestExpect(function() + ReactRoblox.createPortal(React.createElement("Frame"), "hi") + end).toThrow("Target container is not a Roblox Instance.") + end) + + -- it('should warn for non-functional event listeners', () => { + -- class Example extends React.Component { + -- render() + -- return
+ -- } + -- } + -- expect(() => ReactDOM.render(, container)).toErrorDev( + -- 'Expected `onClick` listener to be a function, instead got a value of `string` type.\n' + + -- ' in div (at **)\n' + + -- ' in Example (at **)', + -- ) + -- }) + + -- it('should warn with a special message for `false` event listeners', () => { + -- class Example extends React.Component { + -- render() + -- return
+ -- } + -- } + -- expect(() => ReactDOM.render(, container)).toErrorDev( + -- 'Expected `onClick` listener to be a function, instead got `false`.\n\n' + + -- 'If you used to conditionally omit it with onClick={condition and value}, ' + + -- 'pass onClick={condition ? value : undefined} instead.\n' + + -- ' in div (at **)\n' + + -- ' in Example (at **)', + -- ) + -- }) + + -- it('should not update event handlers until commit', () => { + -- spyOnDev(console, 'error') + + -- local ops = [] + -- local handlerA = () => ops.push('A') + -- local handlerB = () => ops.push('B') + + -- function click() + -- local event = new MouseEvent('click', { + -- bubbles: true, + -- cancelable: true, + -- }) + -- Object.defineProperty(event, 'timeStamp', { + -- value: 0, + -- }) + -- node.dispatchEvent(event) + -- } + + -- class Example extends React.Component { + -- state = {flip: false, count: 0} + -- flip() + -- this.setState({flip: true, count: this.state.count + 1}) + -- } + -- tick() + -- this.setState({count: this.state.count + 1}) + -- } + -- render() + -- local useB = !this.props.forceA and this.state.flip + -- return
+ -- } + -- } + + -- class Click extends React.Component { + -- constructor() + -- super() + -- node.click() + -- } + -- render() + -- return nil + -- } + -- } + + -- local inst + -- ReactDOM.render([ (inst = n)} />], container) + -- local node = container.firstChild + -- expect(node.tagName).toEqual('DIV') + + -- click() + + -- expect(ops).toEqual(['A']) + -- ops = [] + + -- -- Render with the other event handler. + -- inst.flip() + + -- click() + + -- expect(ops).toEqual(['B']) + -- ops = [] + + -- -- Rerender without changing any props. + -- inst.tick() + + -- click() + + -- expect(ops).toEqual(['B']) + -- ops = [] + + -- -- Render a flip back to the A handler. The second component invokes the + -- -- click handler during render to simulate a click during an aborted + -- -- render. I use this hack because at current time we don't have a way to + -- -- test aborted ReactDOM renders. + -- ReactDOM.render( + -- [, ], + -- container, + -- ) + + -- -- Because the new click handler has not yet committed, we should still + -- -- invoke B. + -- expect(ops).toEqual(['B']) + -- ops = [] + + -- -- Any click that happens after commit, should invoke A. + -- click() + -- expect(ops).toEqual(['A']) + + -- if __DEV__) + -- -- TODO: this warning shouldn't be firing in the first place if user didn't call it. + -- local errorCalls = console.error.calls.count() + -- for (local i = 0; i < errorCalls; i++) + -- expect(console.error.calls.argsFor(i)[0]).toMatch( + -- 'unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering.', + -- ) + -- } + -- } + -- }) + + -- it('should not crash encountering low-priority tree', () => { + -- ReactDOM.render( + --