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/.gitignore b/.gitignore index 9c4b7caa..1e923871 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ +sourcemap.json rotriever.lock Packages/* !Packages/.robloxrc !Packages/_Workspace/ Packages/_Workspace/* !Packages/_Workspace/.robloxrc +standalone/Packages **/*.rbx[lm]* .DS_Store .vscode +roblox.yml +roblox.toml **/luacov.* .idea/ +site/ + +default_modules +modules_* diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 00000000..2c8a1314 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +*.snap.lua \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..329d988c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +lua-foundation@roblox.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/DEVIATIONS.md b/DEVIATIONS.md index 6759603a..25a835bd 100644 --- a/DEVIATIONS.md +++ b/DEVIATIONS.md @@ -4,25 +4,26 @@ 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) + * [Component Lifecycle](#component-lifecycle) c + * [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) - * [Functional setState](#functional-setstate) + * [Use of setState](#use-of-setstate) ✔️ + * [Functional setState](#functional-setstate) ✔️ * [Roact.Portal](#roactportal) ✔️ - * [State Initialization](#state-initialization) + * [State Initialization](#state-initialization) ✔️ + * [Functional setState Signature](#functional-setstate-signature) ✔️ ## Naming ### Component Lifecycle -**Status:** ✔️ Resolved +**Status:** ✔️ Resolved (backwards compatible with deprecation warnings)
Details @@ -56,7 +57,7 @@ An implementation of tactic #2 above was merged in [#88](https://github.com/Robl
### Reserved Prop Keys: "ref" -**Status:** 🔨 In Progress (updating consumers to comply) +**Status:** ✔️ Resolved (consumers updated to comply) Upstream React reserves the prop key "ref". In Roact the "ref" key is replaced by a Symbol exported as part of the API and applied as a prop with the key `[Roact.Ref]`. This means that there's no need to reserve a key, because the key is already unique and has a special meaning. @@ -102,7 +103,7 @@ This would be blocked by refactors to remove any existing uses of the `ref` key, This alignment effort should be considered in tandem with that of [ref forwarding logic](#ref-forwarding). ### Reserved Prop Keys: "key" -**Status:** 🔨 In Progress (updating consumers to comply) +**Status:** ✔️ Resolved (consumers updated to comply) Upstream React reserves the prop key "key". In Roact, "key" has no special meaning. @@ -123,7 +124,7 @@ However, we may instead consider providing a `Roact.Key` symbol key that can be For any approach to stable key assignment, we should additionally support Roact's approach. This alignment effort should be considered in tandem with that of [stable keys](#stable-keys) ### Reserved Prop Keys: "children" -**Status:** ✔️ Resolved +**Status:** ✔️ Resolved (consumers updated to comply)
Upstream React [reserves the prop key "children"](https://reactjs.org/docs/glossary.html#propschildren). In Roact the "children" key is replaced by a Symbol exported as part of the API and applied as a prop with the key `[Roact.Children]`. This means that there's no need to reserve a key, because the key is already unique and has a special meaning. @@ -160,7 +161,7 @@ The most straightforward approach would be to export `Roact.Children` with a val
### Old Context (Roact only) -**Status:** 🔨 In Progress (updating consumers to comply) +**Status:** ✔️ Resolved (consumers updated to comply) In Roact, the "old" context behavior was a `_context` field defined on every class component instance. To provide context, a component would mutate its `_context` field in `init`: ```lua @@ -200,17 +201,27 @@ 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 +**Status:** ✔️ Resolved (backwards compatible with deprecation warnings) +
+ Details -Stub: context consumer api doesn't match that of Roact's createContext context consumer +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 -#### In Production Code +#### Implemented Alignment Strategy +We've provided support for both interfaces. Resolution and more info at https://github.com/Roblox/roact-alignment/pull/119 +
-#### Proposed Alignment Strategy -We'll probably want to support both interfaces +### validateProps +**Status:** ✔️ Resolved (backwards compatible) +
+#### Implemented Alignment +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 -**Status:** ✔️ Resolved +**Status:** ✔️ Resolved (backwards compatible with deprecation warnings)
Details @@ -262,9 +273,9 @@ This would be a simple compatibility layer that should require very little maint #### Implemented Alignment The `createFragment` function described above was added to React.lua in [#92](https://github.com/Roblox/roact-alignment/pull/92/files).
- + ### Ref Forwarding -**Status:** 🔨 In Progress (updating consumers to comply) +**Status:** ✔️ Resolved (consumers updated to comply) Ref forwarding is possible in React via the [`forwardRef` API](https://reactjs.org/docs/forwarding-refs.html). @@ -361,7 +372,7 @@ function NumberList(props) -- its sibling elements ListLayout = Roact.createElement("UIListLayout", {SortOrder = Enum.SortOrder.LayoutOrder}) } - for i, number in ipairs(numbers) do + for i, number in numbers do -- Here, the key in the list (`i`) is the key associated with the -- equivalently-numbered element. It can be any kind of value. listItems[i] = Roact.createElement("TextLabel", { @@ -385,8 +396,111 @@ In the event that both a table key and the `key` prop are provided to the same e An implementation of this approach was merged in [#68](https://github.com/Roblox/roact-alignment/pull/68). +### Child Array Default Keys +**Status:** ✔️ Resolved (backwards compatible) +
+Details +In React 17, keys are not applied to children in an array. However, Legacy Roact automatically set a child's key in an array of children to that child's index in the array. Some downstream behavior relies on this legacy roact behavior, so Roact 17 automatically applies a child's index as its stable key if it is in an array and not passed a key prop. + + +#### Example +React: +```jsx +function ChildrenArrayComponent(props) { + // The children of div are not given keys. Order is preservered inherently in the DOM. + return ( +
+ + + +
+ ) +} +``` + +Roact 17: +```lua +function ChildrenArrayComponent(props) + return Roact.createElement("Frame", nil, { + -- These children receive keys 1, 2, and 3, respectively + Roact.createElement(Foo), + Roact.createElement(Foo), + Roact.createElement(Bar), + }) +end +``` + +Equivalent Roact 17 with Lua Table Keys: +```lua +function ChildrenArrayComponent(props) + return Roact.createElement("Frame", nil, { + -- These children receive keys 1, 2, and 3, respectively + [1] = Roact.createElement(Foo), + [2] = Roact.createElement(Foo), + [3] = Roact.createElement(Bar), + }) +end +``` + +Equivalent Roact 17 with prop keys: +```lua +function ChildrenArrayComponent(props) + return Roact.createElement("Frame", nil, { + -- These children receive keys 1, 2, and 3, respectively + Roact.createElement(Foo, { key = 1 }), + Roact.createElement(Foo, { key = 2 }), + Roact.createElement(Bar, { key = 3 }), + }) +end +``` + +#### Proposed Alignment Strategy +This change is fully backwards compatible with Legacy Roact. However, users should be wary of elements potentially re-mounting on render when children are not given keys. This case can occur when a user switches from rendering multiple child elements in an array to rendering a single child element. Re-mounting is inefficient in comparison to updating an element on render. To avoid this case, a single child element should be given a key. The particular case is shown below: + +Re-mounting Case (inefficient): +```lua +local root = Roact.createLegacyRoot(container) + +-- Initial Render +root.render(Roact.createElement(Foo, nil, { + Roact.createElement(Bar), + Roact.createElement(Bar), + Roact.createElement(FooBar), +})) + +-- Re-Render +-- Bar will be remounted here. Roact 17 cannot discern if this is +-- the same Bar as above, so it remounts it. +root.render(Roact.createElement(Foo, nil, Roact.createElement(Bar))) +``` + +Update Case (efficient): +```lua +local root = Roact.createLegacyRoot(container) + +-- Initial Render +-- Keys are not necessary here, they are applied by default +-- as { 1 = Bar, 2 = Bar, 3 = FooBar } +root.render(Roact.createElement(Foo, nil, { + Roact.createElement(Bar), + Roact.createElement(Bar), + Roact.createElement(FooBar), +})) + +-- Re-Render +-- Bar will be updated here, as the key allows Roact 17 to match +-- this Bar to the first Bar above. If we instead set key to 2, it +-- would match the second Bar above +root.render(Roact.createElement(Foo, nil, + Roact.createElement(Bar, { key = 1 }) +)) +``` +
+ ### Use of setState -**Status:** ❔ Alignment Strategy TBD +**Status:** ✔️ Resolved (aligned to legacy Roact) +
+ 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,15 +530,16 @@ 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 +**Status:** ✔️ Resolved (aligned to legacy Roact) In both React and Roact, `setState` can accept a function as its argument in place of a table (with async rendering, this is encouraged as the default choice). In React, however, the argument passed to `setState` is invoked via `payload.call(instance, prevState, nextProps)`. In other words, React calls the function in such a way that the `instance` is in scope as `this` in the body of the updater function. @@ -473,8 +588,13 @@ Alternatively, we might consider: * Align all existing usages, modifying them to accept `self` as their first argument. While this seems reasonable on the surface, there are serious caveats. Since lua and js have different mechanisms of defining and calling methods with `self`, the exact _behavior_ will be more similar to upstream, but the _API_ will deviate and need to be called out in documentation. We'd likely also want to add additional warnings to detect expected misuses. * Perform some trickery with `setfenv` to allow the arguments to be in the same place, but `self` to be accessible. This is ugly, because it won't be understood by linting and I don't actually know how it will interact with shadowing/closures. As far as I'm concerned, this is a non-option, but it's worth calling out for thoroughness. +#### Implemented Alignment Strategy +We opted to align to legacy roact in our code to reduce impact on adoption. There remains no known use case for explicit injection of `self`, especially when closing over `self` is trivial. + +This strategy was implemented in https://github.com/Roblox/roact-alignment/pull/160 + ### Roact.Portal -**Status:** ✔️ Resolved (backwards compatible) +**Status:** ✔️ Resolved (backwards compatible with deprecation warnings)
Details @@ -521,7 +641,7 @@ We should create a special component called `Portal`, expose it via our compatib
### State Initialization -**Status:** ❔ Alignment Strategy TBD +**Status:** Alignment Strategy TBD In Roact, ["stateful" components](https://roblox.github.io/roact/guide/state-and-lifecycle/) (equivalent of React's "class" components) will automatically initialize their state value to an empty table if it is not assigned via `init` or `getDerivedStateFromProps`. @@ -556,7 +676,7 @@ end function ShowCount:render() return Roact.createElement("TextLabel", { -- `self.state` is an empty table, and `self.state.count` is nil. - -- Text will be left as its default value for a TextLabel + -- Text will be left as its default value for a TextLabel Text = self.state.count, }) end @@ -565,8 +685,42 @@ end #### In Production Code It's difficult to find where this is relied upon in production! +#### Alignment Strategy +Accessing uninitialized state may not be strictly wrong, but it is still a code smell. To encourage proper state initialization, we introduced a compoatibility layer: a singleton `UninitializedState` sentinel object that provides a useful error when accessed in dev mode, and should work just like an empty table in non-dev. + +The solution was implemented here: https://github.com/Roblox/roact-alignment/pull/155 + +### Functional setState Signature +**Status:** ✔️ Resolved (minor deviation from upstream) +
+ +In both [React](https://reactjs.org/docs/react-component.html#setstate) and [Roact](https://roblox.github.io/roact/api-reference/#setstate), class components can call `setState` with a function instead of a partial state table. When state updates rely on previous state, this can make them more resilient to multiple queued updates. This approach is favored by upstream documentation for this reason, and encouraged in Roact's documentation as well (in anticipation of async rendering). + +In React, the updater function is called with `this` in scope, using JavaScript's `bind` function. This poses a problem for the translation: if we want to align this behavior, we need to change the signature of the state updater from `(state, props) -> partialState` to `(self, state, props) -> partialState`. This would be a backwards-incompatible change + +#### Example +React (adapted from the React documentation): +```js +this.setState((state, props) => { + // `this` is in scope here and can be read from + return {counter: state.counter + props.step}; +}); +``` + +Roact: +```lua +self:setState(function(state, props) + -- `self` is NOT in scope here + return { counter = state.counter + props.step } +end); +``` + +#### In Production Code +This functionality is used relatively sparingly in the lua-apps code base (including dependencies), only ~15 confirmed usages and ~45 more possible ones (more investigation needed to confirm). + #### Proposed Alignment Strategy -We should adopt Roact's behavior to provide less likelihood of runtime surprises. +While it's possible to adapt to the upstream behavior, I was not able to find _any_ idiomatic usages of `this` in the body of a `setState` updater function. We should keep Roact's current behavior, which encourages function purity and serves all known use cases without issue. +
# Unique Features Roact has a couple of unique features that are not present in upstream, while a number of new features from upstream will be introduced by the alignment effort. @@ -585,22 +739,32 @@ 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 +* If `__COMPAT_WARNINGS__` is set, it will output Legacy Roact compatibility layer warnings, which will help teams write code that will be forward compatible at the next major version update to Roact. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..eab237bd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2023 Roblox Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 68cd9583..d1ae0a5a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ +# Roact Alignment [![Tests](https://github.com/Roblox/roact-alignment/workflows/ci/badge.svg?branch=master)](https://github.com/Roblox/roact-alignment/actions?query=workflow%3ACI+branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/Roblox/roact-alignment/badge.svg?branch=master&t=TvTSze)](https://coveralls.io/github/Roblox/roact-alignment?branch=master) +[![Docs](https://img.shields.io/badge/docs-website-green.svg)](https://roblox.github.io/roact-alignment/) -# Roact Alignment -A temporary ground-up Roact repository that will track our preliminary alignment with React, starting with leaf nodes like the scheduler. +A comprehensive, but not exhaustive, translation of upstream ReactJS 17.x into Roblox Lua. + +## TL;DR + +* In a new project, you can consume this library by adding this line to your rotriever.toml + * `React = "github.com/roblox/roact-alignment@17.0.1"` +* If you have legacy Roact code, use the roact-compat library instead + * `RoactCompat = "github.com/roblox/roact-alignment@17.0.1"` +* Make sure you are using the latest [rotriever](https://github.com/Roblox/rotriever/releases) 0.5 (or later) release + * you can download the release binary, or add it to your `foreman.toml`: ```rotrieve = { source = "roblox/rotriever", version = "=0.5.12" }``` +* For unit testing components and trees of components, you'll want to use the [`act()`](https://github.com/threepointone/react-act-examples/blob/master/sync.md) API exported from the react-roblox package. ## Status The [react repo](https://github.com/facebook/react) is a monorepo with a number of member projects in its `packages` folder, managed by a yarn workspace. Below is a description of each of those package, its status in our alignment repo, and how it likely fits into our future plans. @@ -11,54 +22,54 @@ The [react repo](https://github.com/facebook/react) is a monorepo with a number | Project | Description | Status | Plan | Notes | | - | - | - | - | - | -| `create-subscription` | Used for subscribing to external data | ❌ Not ported | ❔ Not yet assessed | | -| `dom-event-testing-library` | Dom event simulation for tests | ❌ Not ported | ❔ Not yet assessed | May inspire Rhodium improvements | -| `eslint-plugin-react-hooks` | Linting plugin for hooks rules | ❌ Not ported | ❔ Not yet assessed | Depends on future linting tools | -| `jest-mock-scheduler` | Reexports scheduler testing utilities | ❌ Not ported | ❔ Not yet assessed | | -| 📌`jest-react` | Jest matchers and utilities | ✔️ Ported | | Haven't yet run into any uses of this in tests we've ported so far | -| 📌`react` | Base react interface | 🔨 Port in progress | | Defines basic shape of internals like Components and Elements. We may add things like Bindings here. | -| `react-art` | For drawing vector graphics | ❌ Not ported | ➖ Unlikely to be ported | | -| `react-cache` | Basic cache for use with experimental React features | ❌ Not ported | ❔ Not yet assessed | API is flagged as unstable | -| `react-client` | Experimental package for consuming React streaming models | ❌ Not ported | ❔ Not yet assessed | API considered unstable. Might be worth investigating if it stabilizes | -| `react-debug-tools` | Experimental debugger package | ❌ Not ported | ❔ Not yet assessed | API considered unstable | +| `create-subscription` | Used for subscribing to external data | ❌ Not ported | ➖ Unlikely to be ported | Intended to help transition from pre-React 17 | +| `dom-event-testing-library` | Dom event simulation for tests | ❌ Not ported | ➖ Unlikely to be ported | Obviated by [ReactTestingLibrary](https://github.com/roblox/dom-testing-library-lua) | +| `eslint-plugin-react-hooks` | Linting plugin for hooks rules | ❌ Not ported | 🔁 Revisit if needed | Should be a reference for future linting tools | +| `jest-mock-scheduler` | Reexports scheduler testing utilities | ❌ Not ported | ➖ Unlikely to be ported | Exports internal utilities for mocking scheduler, not as useful as `act()` | +| 📌`jest-react` | Jest matchers and utilities | ✔️ Ported | | Used for internal framework tests | +| 📌`react` | Base react interface | ✔️ Ported | | Defines basic shape of internals like Components and Elements. We added added Roblox-specifics like Bindings, but otherwise comply with upstream ReactJS. | +| `react-art` | For drawing vector graphics | ❌ Not ported | ➖ Unlikely to be ported | No vector graphics on Roblox | +| `react-cache` | Basic cache for use with experimental React features | ✔️ Ported | | API is flagged as unstable, is stable in React 18, used in advanced Suspense cases | +| `react-client` | Experimental package for consuming React streaming models | ❌ Not ported | 🔁 Revisit if needed | API considered unstable. Might be worth investigating if it stabilizes | +| `react-debug-tools` | Experimental debugger package | ✔️ Ported | | Used by DevTools and Roblox Studio Inspector | | `react-devtools` | Top-level app for react devtools | ❌ Not ported | ➕ Likely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | | `react-devtools-core` | Standalone devtools impl | ❌ Not ported | ➕ Likely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | -| `react-devtools-extensions` | Devtools browser extension | ❌ Not ported | ➖ Unlikely to be ported | | +| `react-devtools-extensions` | Devtools browser extension | ❌ Not ported | ➖ Unlikely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | | `react-devtools-inline` | Impl for embedding in browser-based IDEs | ❌ Not ported | ➕ Likely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | -| `react-devtools-scheduling-profiler` | Experimental concurrent mode profiler | ❌ Not ported | ❔ Not yet assessed | | -| `react-devtools-shared` | Private shared utilities for devtools | ❌ Not ported | ➕ Likely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | -| `react-devtools-shell` | Harness for testing other devtools packages | ❌ Not ported | ❔ Not yet assessed | Devtools needs to be addressed as a whole to see where/how it translates | -| `react-dom` | Entrypoint for DOM and server renderers | ❌ Not ported | ➖ Unlikely to be ported | Will inform top-level interface, but will be mostly replaced with Roblox-specific logic | -| `react-fetch` | For use with experimental React features | ❌ Not ported | ❔ Not yet assessed | API considered unstable | -| `react-interactions` | For use with experimental React features | ❌ Not ported | ❔ Not yet assessed | | +| `react-devtools-scheduling-profiler` | Experimental concurrent mode profiler | ❌ Not ported | 🔁 Revisit if needed | Supplanted in React 18 by `react-devtools-timeline` package | +| `react-devtools-shared` | Private shared utilities for devtools | ✔️ Ported | | Used by Roblox Studio Inspector | +| `react-devtools-shell` | Harness for testing other devtools packages | ❌ Not ported | ➖ Unlikely to be ported | Devtools needs to be addressed as a whole to see where/how it translates | +| `react-dom` | Entrypoint for DOM and server renderers | ❌ Not ported | ➖ Unlikely to be ported | Not ported directly, but it heavily inspired the React-Roblox renderer interface and implementation | +| `react-fetch` | For use with experimental React features | ❌ Not ported | ➖ Unlikely to be ported | API considered unstable, [removed in React 18](https://github.com/facebook/react/pull/25577) | +| `react-interactions` | For use with experimental React features | ❌ Not ported | ➖ Unlikely to be ported | API unstable, used only with internal features | | 📌`react-is` | Runtime type checks for React elements | ✔️ Ported | | | -| `react-native-renderer` | Renderer interface for react-native | ❌ Not ported | ❔ Not yet assessed | This package has no readme, so it's hard to understand its scope | -| 📌`react-noop-renderer` | Renderer used for debugging Fiber | ✔️ Ported | | Will be needed to verify our Fiber/Reconciler work | +| `react-native-renderer` | Renderer interface for react-native | ❌ Not ported | ➖ Unlikely to be ported | Not well documented, likely does not apply to Roblox | +| 📌`react-noop-renderer` | Renderer used for debugging Fiber | ✔️ Ported | | Used heavily for internal framework testing | | 📌`react-reconciler` | Reconciler implementation used with various renderers | ✔️ Ported | | Bulk of React's complicated logic lives here | -| `react-refresh` | Wiring for Fast Refresh | ❌ Not ported | ❔ Not yet assessed, depend on applicability | Officially supported successor to "hot reloading" | -| `react-server` | Experimental package for creating React streaming server renderers | ❌ Not ported | ❔ Not yet assessed | | -| `react-test-renderer` | Test renderer with dom snapshotting | ✔️ Ported | | Used for testing much of React's internals | -| `react-transport-dom-delay` | Internal package, likely for testing | ❌ Not ported | ➖ Unlikely to be ported | No readme in package | -| `react-transport-dom-webpack` | Related to above | ❌ Not ported | ➖ Unlikely to be ported | Appears to be webpack-specific | -| 📌`scheduler` | Cooperative scheduling implementation | ✔️ Ported | | Tracing feature is excluded, will be needed at some point for devtools | -| 📌`shared` | Loose collection of shared utilities and definitions | ✔️ Ported | | Working with upstream to see if this can be cleaned up | -| `use-subscription` | Hook for managing subscriptions in concurrent mode | ❌ Not ported | ❔ Not yet assessed | Not sure if/how this will apply to Roblox | - -Projects not in the react repo: +| `react-refresh` | Wiring for Fast Refresh | ✔️ Ported | | Successor to "hot reloading", but relies on bundler step | +| `react-server` | Experimental package for creating React streaming server renderers | ❌ Not ported | 🔁 Revisit if needed | API considered unstable. Might be worth investigating if it stabilizes | +| `react-test-renderer` | Test renderer helpful utilities and snapshot support | ✔️ Ported | | Used for testing much of React's internals, can be used by client developers | +| `react-transport-dom-delay` | Internal package, likely for testing | ❌ Not ported | ➖ Unlikely to be ported | Internal library for experimental React Flight feature | +| `react-transport-dom-webpack` | Related to above | ❌ Not ported | ➖ Unlikely to be ported | Webpack-specific bindings for experimental React Flight feature | +| 📌`scheduler` | Cooperative scheduling implementation | ✔️ Ported | | Includes Tracing and Profiling features, which are enabled through ReactFeatureFlags | +| 📌`shared` | Loose collection of shared utilities and definitions | ✔️ Ported | | We pushed many things into this leaf node module to fix circular dependencies. Working with upstream to clean this up. | +| `use-subscription` | Hook for managing subscriptions in concurrent mode | ❌ Not ported | 🔁 Revisit if needed | Supplanted by [use-sync-external-store](https://github.com/facebook/react/tree/main/packages/use-sync-external-store) in upstream | + +Projects not in the upstream React repo: | Project | Description | Notes | | - | - | - | | 📌`react-shallow-renderer` | Shallow renderer used in tests for some older React features. Re-exported alongside `react-test-renderer`, source of truth [here](https://github.com/NMinhNguyen/react-shallow-renderer). | ✔️ Ported - with tests that are helping us exercise functionality in the `react` package | -| `roblox-jest` | Custom matchers and timer logic for TestEZ | A rough approximation of what we'll eventually have with the [`jest` alignment effort](https://github.com/Roblox/lest-alignment) | -| `roblox-js-polyfill` | Implementations of JS specific interfaces or functionality | Most implementations are incomplete or slightly adjusted for Lua | +| `react-roblox` | Based on react-dom renderer, shares much of its code and public interface.| Also exports [`act()`](https://github.com/threepointone/react-act-examples/blob/master/sync.md) functionality, which is *required* for testing components that are asynchronously rendered (the default). | +| `roact-compat` | A comaptibility layer that emulates some deprecated behaviors of legacy Roact | Meant to ease initial adoption of Roact 17, using React APIs directly is encouraged and necessary for newer functonality (eg Hooks) | ## Deviations from [Roact](https://github.com/roblox/roact) This repo is meant to supplant the `Roact` project, which is an open-source project that currently powers the majority of the Lua App and is used by the community as well. Our goal is to be as compatible as possible with Roact by the time we're ready to start adopting this alignment effort for use. With that in mind, however, there will still be a small number of behavioral deviations that make the transition from existing Roact smoother, or account for nuances of the Roblox ecosystem: * Stable Keys: Aligned Roact will allow table keys to be used as stable keys for child elements, equivalent to the behavior relied upon in Roact today -* Context: Current Roact's deprecated `_context` feature will not be present in aligned Roact; users will have to switch to the `createContext` feature, which is present in both current and aligned Roact and is semantically equivalent +* Context: Legacy Roact's deprecated `_context` feature will not be present in aligned Roact; users will have to switch to the `createContext` feature, which is present in both current and aligned Roact and is semantically equivalent * Class Component Refs: Aligned Roact will allow refs provided to class components (referred to in Roact documentation as "stateful components") to point to the actual component instance. This is not supported in current Roact, and there may be changes around the `Roact.Ref` prop key to support this with minimal disruption -* Bindings: We intend to keep `createBindings` and `joinBindings`, a feature unique to Roact and [documented here](https://roblox.github.io/roact/api-reference#roactcreatebinding) +* Bindings: We intend to keep `createBinding` and `joinBindings`, a feature unique to Roact and [documented here](https://roblox.github.io/roact/api-reference#roactcreatebinding) See [this document](DEVIATIONS.md) for details about any deviations and the design and refactoring efforts being proposed to address them. @@ -68,7 +79,7 @@ You need to create a GitHub Access Token: * GitHub.com -> Settings -> Developer Settings -> Personal Access Tokens * Your token must have the `repo` and read:packages` scopes * On that same page, you then need to click Enable SSO -* BE SURE TO COPY THE ACCESS TOKEN SOMEWHERE +* BE SURE TO COPY THE ACCESS TOKEN SOMEWHERE ``` npm login --registry=https://npm.pkg.github.com/ --scope=@roblox @@ -98,33 +109,35 @@ cd roact-alignment roblox-cli analyze default.project.json ``` -Foreman is an un-package manager that retrieves code directly from GitHub repositories. We'll use this to get a Lua package manager and other utilities. The Foreman packages are listed in `foreman.toml`. Foreman uses Rust, so you'll have to install Rust first. +Foreman is an un-package manager that retrieves code directly from GitHub repositories. We'll use this to get a Lua package manager and other utilities. The Foreman packages are listed in `foreman.toml`. + +You can install Foreman from a binary by downloading the appropriate version for your platform from [the GitHub releases page](https://github.com/roblox/foreman/releases/latest). + +Alternatively, since Foreman uses Rust, you can install Rust and use cargo to install it: ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh export PATH=$PATH:$HOME/.cargo/bin cargo install foreman -foreman github-auth -foreman install -export PATH=$PATH:~/.foreman/bin/ # you might want to add this to your bash profile file too ``` -Now you can run the tests, edit code, and contribute! Next we need to install our Lua package dependencies. We do this with a tool called Rotriever, which Foreman just installed for us. The package dependencies are listed in `rotriever.toml`. - +Once Foreman is installed, provide an API token so that it can access private tools like [rotriever](https://github.com/roblox/rotriever) and make sure that it's added to your path. ``` -rotrieve install +foreman github-auth +foreman install +export PATH=~/.foreman/bin/:$PATH # you might want to add this to your bash profile file too ``` -Next we're going to use Rojo (installed by Foreman above) to compile and package our Lua code into a format that Roblox understands. +Now you can run the tests, edit code, and contribute! Next we need to install our Lua package dependencies. We do this with a tool called Rotriever, which Foreman just installed for us. The package dependencies are listed in `rotriever.toml`. ``` -rojo build tests.project.json --output model.rbxmx +rotrieve install ``` -Now we can use `roblox-cli` to run our tests. We specify the Rojo build output file and our test runner file. +Now we can use `roblox-cli` to run our tests. We need to specify some additional arguments to make sure that the latest luau language features are enabled as well as `debug.loadmodule`, which allows us to reset module state between tests. ``` -roblox-cli run --load.model model.rbxmx --run bin/spec.lua +roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true --fastFlags.allOnLuau ``` ### Common Issues @@ -138,7 +151,7 @@ To avoid this in the future, be sure that your foreman binary path is *before* t ## Contribution Guidelines -* Try to keep the directory structure, file name/location, and code symbol names aligned with React upstream. At the top of the mirrored files, put a comment in this format that includes the specific hash of the version of the file you're mirroring: +* Try to keep the directory structure, file name/location, and code symbol names aligned with React upstream. At the top of the mirrored files, put a comment in this format that includes the specific hash of the version of the file you're mirroring: ``` -- ROBLOX upstream https://github.com/facebook/react/blob/9abc2785cb070148d64fae81e523246b90b92016/packages/scheduler/src/Scheduler.js ``` @@ -147,9 +160,9 @@ To avoid this in the future, be sure that your foreman binary path is *before* t * If you have a deviation from upstream code logic for Lua-specific reasons (1-based array indices, etc) put a comment above the deviated line: ``` -- ROBLOX deviation: use explicit nil check instead of falsey -``` +``` -* For deviations due to Lua language differences (no spread operator) that don't involve changing the logic, don't put a deviation comment. Just use the appropriate equivalent from the Cryo and other utility libraries. +* For deviations due to Lua language differences (no spread operator) that don't involve changing the logic, don't put a deviation comment. Just use the appropriate equivalent from the es7-luau (fka LuauPolyfill) and other utility libraries. * For files that are new and Roblox-specific, use the file name: ```Timeout.roblox.lua``` @@ -159,6 +172,8 @@ To avoid this in the future, be sure that your foreman binary path is *before* t First, install the roblox-lrdb debugger extension for VSCode (following [the installation instructions here](https://github.com/Roblox/vscode-rbx-lrdb)). +Note: As specified in the lrdb installation instructions, you _must_ be connected to the VPN in order for the Internal Tool Provider to be able to download what it needs. + In order for breakpoints to work correctly, you'll need to disable the module reloading behavior that relies on `debug.loadmodule` by providing the `__NO_LOADMODULE__` global. To do so, create a `launch.json` file with the following contents: ``` { @@ -226,4 +241,3 @@ Shortly after you see the status bar change colors to indicate an active debugge ![image](https://user-images.githubusercontent.com/1550766/104785584-b4095a00-573f-11eb-8363-3b73a612e2a2.png) Note that VS Code even tells you the value of the variables on the line. Pretty cool! - diff --git a/WorkspaceStatic/jest.config.lua b/WorkspaceStatic/jest.config.lua new file mode 100644 index 00000000..2ca8c9d4 --- /dev/null +++ b/WorkspaceStatic/jest.config.lua @@ -0,0 +1,24 @@ +local Workspace = script.Parent + +-- In case we need to specify a custom testSetupFile for a project, we need to do that in in a separate jest.config.lua file that's in the project's root folder. +-- Therefore we specify the project here and provide it to the "projects" field in this config file. +-- We also need to add the project to the "testPathIgnorePatterns" field so that Jest doesn't try to run the project's tests again. +local projectsWithCustomJestConfig = { + Workspace.ReactDevtoolsShared.ReactDevtoolsShared, +} +local testPathIgnorePatterns = {} +local allProjects = { Workspace } + +for _, project in projectsWithCustomJestConfig do + table.insert(testPathIgnorePatterns, tostring(project)) + table.insert(allProjects, project) +end + +return { + setupFilesAfterEnv = { Workspace.jest.testSetupFile }, + projects = allProjects, + testMatch = { + "**/__tests__/*.(spec|test)", + }, + testPathIgnorePatterns = testPathIgnorePatterns, +} diff --git a/WorkspaceStatic/jest/.robloxrc b/WorkspaceStatic/jest/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/WorkspaceStatic/jest/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/WorkspaceStatic/jest/matchers/__tests__/toWarnDev.spec.lua b/WorkspaceStatic/jest/matchers/__tests__/toWarnDev.spec.lua new file mode 100644 index 00000000..a53edd79 --- /dev/null +++ b/WorkspaceStatic/jest/matchers/__tests__/toWarnDev.spec.lua @@ -0,0 +1,551 @@ +-- ROBLOX upstream https://github.com/facebook/react/blob/0b5a26a4895261894f04e50d5a700e83b9c0dcf6/scripts/jest/matchers/__tests__/toWarnDev-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. + * + * @emails react-core + ]] +local Packages = script.Parent.Parent.Parent.Parent.TestRunner +local ReactGlobals = require(Packages.Dev.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local beforeAll = JestGlobals.beforeAll +local describe = JestGlobals.describe +local it = JestGlobals.it +local toErrorDev = require(script.Parent.Parent.Parent.matchers.toErrorDev) +local toWarnDev = require(script.Parent.Parent.Parent.matchers.toWarnDev) + +local console + +beforeAll(function() + jestExpect.extend({ + toErrorDev = toErrorDev, + toWarnDev = toWarnDev, + }) +end) + +beforeEach(function() + local LuauPolyfill = require(Packages.Dev.LuauPolyfill) + console = LuauPolyfill.console +end) + +describe("toErrorDev", function() + it("does not fail if a warning contains a stack", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.error("Hello\n in div") + end + end).toErrorDev("Hello") + end) + + it("does not fail if all warnings contain a stack", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.error("Hello\n in div") + console.error("Good day\n in div") + console.error("Bye\n in div") + end + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }) + end) + + it("does not fail if warnings without stack explicitly opt out", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.error("Hello") + end + end).toErrorDev("Hello", { withoutStack = true }) + jestExpect(function() + if ReactGlobals.__DEV__ then + console.error("Hello") + console.error("Good day") + console.error("Bye") + end + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = true, + }) + end) + + it( + "does not fail when expected stack-less warning number matches the actual one", + function() + jestExpect(function() + if ReactGlobals.__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 ReactGlobals.__DEV__ then + -- // Helper methods avoids invalid toWarn().toThrow() nesting + -- // See no-to-warn-dev-within-to-throw + local function expectToWarnAndToThrow(expectBlock, expectedErrorMessage) + -- deviation: use TestEZ to thow to get a better error message + jestExpect(expectBlock).toThrow(expectedErrorMessage) + end + + it("fails if a warning does not contain a stack", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello") + end).toErrorDev("Hello") + end, "Received warning unexpectedly does not include a component stack") + end) + + it("fails if some warnings do not contain a stack", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello\n in div") + console.error("Good day\n in div") + console.error("Bye") + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello") + console.error("Good day\n in div") + console.error("Bye\n in div") + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + 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", + }) + end, "Received warning unexpectedly does not include a component stack") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello") + console.error("Good day") + console.error("Bye") + end).toErrorDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + end) + + it("fails if warning is expected to not have a stack, but does", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hello\n in div") + end).toErrorDev("Hello", { withoutStack = true }) + end, "Received warning unexpectedly includes a component stack") + 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 = 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 withoutStack is invalid", function() + -- deviation: null should error, but not undefined. Since they're the same + -- in Lua, we can comment the expectation + -- expectToWarnAndToThrow(function() + -- jestExpect(function() + -- console.error("Hi") + -- end).toErrorDev("Hi", {withoutStack = null}) + -- end, "Instead received object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi") + end).toErrorDev("Hi", { withoutStack = {} }) + end, "Instead received table") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi") + end).toErrorDev("Hi", { + withoutStack = "haha", + }) + end, "Instead received string") + end) + + it("fails if the argument number does not match", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi %s", "Sara", "extra") + 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, "Received 0 arguments for a message with 1 placeholders") + end) + + it("fails if stack is passed twice", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi %s%s", "\n in div", "\n in div") + end).toErrorDev("Hi") + end, "Received more than one component stack for a warning") + end) + + it("fails if multiple strings are passed without an array wrapper", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi \n in div") + end).toErrorDev("Hi", "Bye") + end, "toErrorDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi \n in div") + console.error("Bye \n in div") + end).toErrorDev("Hi", "Bye") + end, "toErrorDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi \n in div") + console.error("Wow \n in div") + console.error("Bye \n in div") + end).toErrorDev("Hi", "Bye") + end, "toErrorDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi \n in div") + console.error("Wow \n in div") + console.error("Bye \n in div") + end).toErrorDev("Hi", "Wow", "Bye") + end, "toErrorDev() second argument, when present, should be an object") + end) + + it("fails on more than two arguments", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.error("Hi \n in div") + console.error("Wow \n in div") + console.error("Bye \n in div") + end).toErrorDev("Hi", nil, "Bye") + end, "toErrorDev() received more than two arguments.") + end) + end +end) + +describe("toWarnDev", function() + it("does not fail if a warning contains a stack", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.warn("Hello\n in div") + end + end).toWarnDev("Hello") + end) + + it("does not fail if all warnings contain a stack", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.warn("Hello\n in div") + console.warn("Good day\n in div") + console.warn("Bye\n in div") + end + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }) + end) + + it("does not fail if warnings without stack explicitly opt out", function() + jestExpect(function() + if ReactGlobals.__DEV__ then + console.warn("Hello") + end + end).toWarnDev("Hello", { withoutStack = true }) + jestExpect(function() + if ReactGlobals.__DEV__ then + console.warn("Hello") + console.warn("Good day") + console.warn("Bye") + end + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }, { + withoutStack = true, + }) + end) + + it( + "does not fail when expected stack-less warning number matches the actual one", + function() + jestExpect(function() + if ReactGlobals.__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 ReactGlobals.__DEV__ then + -- // Helper methods avoids invalid toWarn().toThrow() nesting + -- // See no-to-warn-dev-within-to-throw + local function expectToWarnAndToThrow(expectBlock, expectedErrorMessage) + -- deviation: use TestEZ to thow to get a better error message + jestExpect(expectBlock).toThrow(expectedErrorMessage) + end + + it("fails if a warning does not contain a stack", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello") + end).toWarnDev("Hello") + end, "Received warning unexpectedly does not include a component stack") + end) + + it("fails if some warnings do not contain a stack", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello\n in div") + console.warn("Good day\n in div") + console.warn("Bye") + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello") + console.warn("Good day\n in div") + console.warn("Bye\n in div") + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + 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", + }) + end, "Received warning unexpectedly does not include a component stack") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello") + console.warn("Good day") + console.warn("Bye") + end).toWarnDev({ + "Hello", + "Good day", + "Bye", + }) + end, "Received warning unexpectedly does not include a component stack") + end) + + it("fails if warning is expected to not have a stack, but does", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hello\n in div") + end).toWarnDev("Hello", { withoutStack = true }) + end, "Received warning unexpectedly includes a component stack") + 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 = 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 withoutStack is invalid", function() + -- deviation: null should error, but not undefined. Since they're the same + -- in Lua, we can comment the expectation + -- expectToWarnAndToThrow(function() + -- jestExpect(function() + -- console.warn("Hi") + -- end).toWarnDev("Hi", {withoutStack = nil}) + -- end, "Instead received object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi") + end).toWarnDev("Hi", { withoutStack = {} }) + end, "Instead received table") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi") + end).toWarnDev("Hi", { + withoutStack = "haha", + }) + end, "Instead received string") + end) + + it("fails if the argument number does not match", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi %s", "Sara", "extra") + 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, "Received 0 arguments for a message with 1 placeholders") + end) + + it("fails if stack is passed twice", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi %s%s", "\n in div", "\n in div") + end).toWarnDev("Hi") + end, "Received more than one component stack for a warning") + end) + + it("fails if multiple strings are passed without an array wrapper", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi \n in div") + end).toWarnDev("Hi", "Bye") + end, "toWarnDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi \n in div") + console.warn("Bye \n in div") + end).toWarnDev("Hi", "Bye") + end, "toWarnDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi \n in div") + console.warn("Wow \n in div") + console.warn("Bye \n in div") + end).toWarnDev("Hi", "Bye") + end, "toWarnDev() second argument, when present, should be an object") + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi \n in div") + console.warn("Wow \n in div") + console.warn("Bye \n in div") + end).toWarnDev("Hi", "Wow", "Bye") + end, "toWarnDev() second argument, when present, should be an object") + end) + + it("fails on more than two arguments", function() + expectToWarnAndToThrow(function() + jestExpect(function() + console.warn("Hi \n in div") + console.warn("Wow \n in div") + console.warn("Bye \n in div") + end).toWarnDev("Hi", nil, "Bye") + end, "toWarnDev() received more than two arguments.") + end) + end +end) + +describe("toLogDev", function() + it("does not fail if a logs contains a stack", function() + jestExpect(function() + console.log("Hello\n in div") + end).toLogDev("Hello") + end) + + it("does not fail if all logs contain a stack", function() + jestExpect(function() + console.log("Hello\n in div") + console.log("Good day\n in div") + console.log("Bye\n in div") + end).toLogDev({ + "Hello", + "Good day", + "Bye", + }) + end) +end) diff --git a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua b/WorkspaceStatic/jest/matchers/createConsoleMatcher.lua similarity index 61% rename from modules/roblox-jest/src/Matchers/createConsoleMatcher.lua rename to WorkspaceStatic/jest/matchers/createConsoleMatcher.lua index 8cd98eff..86ee5204 100644 --- a/modules/roblox-jest/src/Matchers/createConsoleMatcher.lua +++ b/WorkspaceStatic/jest/matchers/createConsoleMatcher.lua @@ -1,36 +1,42 @@ --- upstream https://github.com/facebook/react/blob/6d50a9d090a2a672fc3dea5ce77a3a05332a6caa/fixtures/legacy-jsx-runtimes/setupTests.js ---!nocheck -local Packages = script.Parent.Parent.Parent -local jestDiff = require(script.Parent.jestDiff) --- deviation: until we can require jest, use a simple implementation +--!nonstrict +-- ROBLOX upstream: https://github.com/facebook/react/blob/6d50a9d090a2a672fc3dea5ce77a3a05332a6caa/fixtures/legacy-jsx-runtimes/setupTests.js +local Packages = script.Parent.Parent.Parent.TestRunner +local ReactGlobals = require(Packages.Dev.ReactGlobals) +local JestDiff = require(Packages.Dev.JestDiff) local function shouldIgnoreConsoleError(format, args) -- deviation: instead of checking if `process.env.NODE_ENV ~= "production"` -- we use the __DEV__ global - if _G.__DEV__ then + if ReactGlobals.__DEV__ then if typeof(format) == "string" then - if format:find("Error: Uncaught [") == 0 then + if string.find(format, "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 string.find(format, "Warning: The above error occurred") == 1 then -- // This looks like an error addendum from ReactFiberErrorLogger. -- // Ignore it too. return true 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 + string.find(format, "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. @@ -48,7 +54,7 @@ local function normalizeCodeLocInfo(str) -- // This special case exists only for the special source location in -- // ReactElementValidator. That will go away if we remove source locations. - str = str:gsub("Check your code at .*:%d+", "Check your code at **") + str = string.gsub(str, "Check your code at .*:%d+", "Check your code at **") -- // V8 format: -- // at Component (/path/filename.js:123:45) -- // React format: @@ -57,12 +63,12 @@ local function normalizeCodeLocInfo(str) -- 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 **)")) + return (string.gsub(str, "\n in ([%w%-%._]+)[^\n]*", "\n in %1 (at **)")) end return function(consoleMethod, matcherName) return function(_matcherContext, callback, expectedMessages, options, ...) - local LuauPolyfill = require(Packages.LuauPolyfill) + local LuauPolyfill = require(Packages.Dev.LuauPolyfill) local Array = LuauPolyfill.Array local console = LuauPolyfill.console @@ -71,30 +77,37 @@ return function(consoleMethod, matcherName) end -- deviation: instead of checking if `process.env.NODE_ENV ~= "production"` -- we use the __DEV__ global - if _G.__DEV__ then + if ReactGlobals.__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) - .. ("but was given %s."):format(typeof(expectedMessages)) + string.format( + "%s() requires a parameter of type string or an array of strings ", + matcherName + ) + .. string.format("but was given %s.", 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?" + string.format( + "%s() second argument, when present, should be an object. ", + 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?" + string.format("%s() received more than two arguments. ", matcherName) + .. "Did you forget to wrap the messages into an array?" ) end @@ -114,33 +127,36 @@ 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 string.match(message, "\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 local normalizedMessage = normalizeCodeLocInfo(message) - -- // Remember if the number of %s interpolations - -- // doesn't match the number of arguments. - -- // We'll fail the test if it happens. + -- Remember if the number of %s interpolations + -- doesn't match the number of arguments. + -- We'll fail the test if it happens. local argIndex = 0 - format:gsub("%%s", function() + -- ROBLOX FIXME selene: remove _ assignment when bug in selene is fixed https://github.com/Kampfkarren/selene/issues/406 + local _ = string.gsub(format, "%%s", function() argIndex = argIndex + 1 return argIndex - 1 end) @@ -155,17 +171,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 string.find(normalizedMessage, expectedMessage, 1, true) + ~= nil then if isLikelyAComponentStack(normalizedMessage) then table.insert(warningsWithComponentStack, normalizedMessage) @@ -179,11 +198,15 @@ 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: " + .. tostring(JestDiff.diff(expectedMessages[1], normalizedMessage)) else - errorMessage = 'Unexpected warning recorded: ' .. jestDiff(expectedMessages, {normalizedMessage}) + errorMessage = "Unexpected warning recorded: " + .. tostring( + JestDiff.diff(expectedMessages, { normalizedMessage }) + ) end -- // Record the call stack for unexpected warnings. @@ -229,7 +252,8 @@ return function(consoleMethod, matcherName) if #expectedMessages > 0 then return { message = function() - return ("Expected warning was not recorded: %s\n "):format( + return string.format( + "Expected warning was not recorded: %s\n ", expectedMessages[1] ) end, @@ -243,10 +267,10 @@ return function(consoleMethod, matcherName) local warnings = warningsWithoutComponentStack return { message = function() - return ("Expected %s warnings without a component stack but received %s:\n"):format( + return ("Expected %d warnings without a component stack but received %d:\n"):format( withoutStack, #warningsWithoutComponentStack - ) .. table.concat(warnings, "\n") + ) .. table.concat(warnings, "\n") end, pass = false, } @@ -255,12 +279,17 @@ 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." end, pass = false, } @@ -271,25 +300,32 @@ 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] + ) + .. string.format( + "{withoutStack: true} to the %s call.", + 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. ' + .. string.format("Instead received %s.", 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 %d arguments for a message with %s placeholders:\n %s"):format( #lastWarningWithMismatchingFormat.args, lastWarningWithMismatchingFormat.expectedArgCount, lastWarningWithMismatchingFormat.format @@ -301,21 +337,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/WorkspaceStatic/jest/matchers/interactionTracingMatchers.lua b/WorkspaceStatic/jest/matchers/interactionTracingMatchers.lua new file mode 100644 index 00000000..0589b605 --- /dev/null +++ b/WorkspaceStatic/jest/matchers/interactionTracingMatchers.lua @@ -0,0 +1,136 @@ +--!nonstrict +-- ROBLOX upstream https://github.com/facebook/react/blob/v17.0.1/scripts/jest/matchers/interactionTracingMatchers.js +local Packages = script.Parent.Parent.Parent.TestRunner +local JestDiff = require(Packages.Dev.JestDiff) +local LuauPolyfill = require(Packages.Dev.LuauPolyfill) +local Object = LuauPolyfill.Object +-- ROBLOX deviation: hoist and pre-declare functions +local toContainNoInteractions, toHaveBeenLastNotifiedOfWork, toHaveBeenLastNotifiedOfInteraction, toMatchInteraction, toMatchInteractions + +function toContainNoInteractions(self, actualSet) + return { + message = function() + if self.isNot then + return "Expected interactions but there were none." + end + return "Expected no interactions but there were " + .. tostring(#Object.keys(actualSet)) + .. "." + end, + pass = #actualSet == 0, + } +end + +function toHaveBeenLastNotifiedOfInteraction(self, mockFunction, expectedInteraction) + local calls = mockFunction.mock.calls + if #calls == 0 then + return { + message = function() + return "Mock function was not called" + end, + pass = false, + } + end + + local actualInteraction = calls[#calls][1] + + return toMatchInteraction(self, actualInteraction, expectedInteraction) +end + +function toHaveBeenLastNotifiedOfWork( + self, + mockFunction, + expectedInteractions, + expectedThreadID +) + local calls = mockFunction.mock.calls + if #calls == 0 then + return { + message = function() + return "Mock function was not called" + end, + pass = false, + } + end + + local actualInteractions, actualThreadID = calls[#calls][1], calls[#calls][2] + + if expectedThreadID ~= nil then + if expectedThreadID ~= actualThreadID then + return { + message = function() + return JestDiff.diff( + tostring(expectedThreadID), + tostring(actualThreadID) + ) + end, + pass = false, + } + end + end + + return toMatchInteractions(self, actualInteractions, expectedInteractions) +end + +function toMatchInteraction(self, actual, expected) + for _, attribute in expected do + if actual[attribute] ~= expected[attribute] then + return { + message = function() + return JestDiff.diff(expected, actual) + end, + + pass = false, + } + end + end + + return { pass = true } +end + +function toMatchInteractions(self, actualSetOrArray, expectedSetOrArray) + local actualArrayLength = 0 + for _, __ in actualSetOrArray do + actualArrayLength += 1 + end + + local expectedArrayLength = 0 + for _, __ in expectedSetOrArray do + expectedArrayLength += 1 + end + + if actualArrayLength ~= expectedArrayLength then + return { + message = function() + return string.format( + "Expected %d interactions but there were %d", + expectedArrayLength, + actualArrayLength + ) + end, + pass = false, + } + end + + for i, actualInteraction in actualSetOrArray do + local expectedInteraction = if expectedSetOrArray._array + then expectedSetOrArray._array[i] + else expectedSetOrArray[i] + local result = toMatchInteraction(self, actualInteraction, expectedInteraction) + if result.pass == false then + return result + end + end + + return { pass = true } +end + +local exports = { + toContainNoInteractions = toContainNoInteractions, + toHaveBeenLastNotifiedOfInteraction = toHaveBeenLastNotifiedOfInteraction, + toHaveBeenLastNotifiedOfWork = toHaveBeenLastNotifiedOfWork, + toMatchInteraction = toMatchInteraction, + toMatchInteractions = toMatchInteractions, +} + +return exports diff --git a/WorkspaceStatic/jest/matchers/reactTestMatchers.lua b/WorkspaceStatic/jest/matchers/reactTestMatchers.lua new file mode 100644 index 00000000..308d6b19 --- /dev/null +++ b/WorkspaceStatic/jest/matchers/reactTestMatchers.lua @@ -0,0 +1,108 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/scripts/jest/matchers/reactTestMatchers.js +local Packages = script.Parent.Parent.Parent.TestRunner +-- ROBLOX deviation START: fix import +-- local LuauPolyfill = require(Packages.LuauPolyfill) +local LuauPolyfill = require(Packages.Dev.LuauPolyfill) +-- ROBLOX deviation END +-- ROBLOX deviation START: not used +-- local Error = LuauPolyfill.Error +-- ROBLOX deviation END +local Object = LuauPolyfill.Object +local JestGlobals = require(Packages.Dev.JestGlobals) +local expect = JestGlobals.expect +-- ROBLOX deviation START: fix import +-- local JestReact = require_("jest-react") +-- local SchedulerMatchers = require_("./schedulerTestMatchers") +local JestReact = require(Packages.Dev.JestReact) +local SchedulerMatchers = require(script.Parent.schedulerTestMatchers) +-- ROBLOX deviation END +-- ROBLOX deviation START: add return type +-- local function captureAssertion(fn) +local function captureAssertion( + fn +): { pass: false, message: () -> string } | { pass: true } + -- ROBLOX deviation END + -- Trick to use a Jest 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. + do --[[ ROBLOX COMMENT: try-catch block conversion ]] + -- ROBLOX deviation START: use pcall, simplify + -- local ok, result, hasReturned = xpcall(function() + -- fn() + -- end, function(error_) + -- return { + -- pass = false, + -- message = function() + -- return error_.message + -- end, + -- }, + -- true + -- end) + -- if hasReturned then + -- return result + -- end + local ok, result = pcall(fn) + + if not ok then + return { + pass = false, + message = function() + return tostring(result) + end, + } + end + -- ROBLOX deviation END + end + return { pass = true } +end + +local function assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- local actualYields = Scheduler:unstable_clearYields() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + -- ROBLOX deviation START: fix .length + -- if actualYields.length ~= 0 then + if #actualYields ~= 0 then + -- ROBLOX deviation END + error( + -- ROBLOX deviation START: adapt error message, error with string + -- Error.new( + -- "Log of yielded values is not empty. " + -- .. "Call expect(Scheduler).toHaveYielded(...) first." + -- ) + "Log of yielded values is not empty. " + .. "Call expectToHaveYielded(Scheduler, ...) first.", + 3 + -- ROBLOX deviation END + ) + end +end +-- ROBLOX deviation START: add context argument +-- local function toMatchRenderedOutput(ReactNoop, expectedJSX) +local function toMatchRenderedOutput(_matcherContext, ReactNoop, expectedJSX) + -- ROBLOX deviation END + if typeof(ReactNoop.getChildrenAsJSX) == "function" then + local Scheduler = ReactNoop._Scheduler + assertYieldsWereCleared(Scheduler) + return captureAssertion(function() + -- ROBLOX deviation START: use dot notation + -- expect(ReactNoop:getChildrenAsJSX()).toEqual(expectedJSX) + expect(ReactNoop.getChildrenAsJSX()).toEqual(expectedJSX) + -- ROBLOX deviation END + end) + end + -- ROBLOX deviation START: use dot notation + -- return JestReact:unstable_toMatchRenderedOutput(ReactNoop, expectedJSX) + return JestReact.unstable_toMatchRenderedOutput(ReactNoop, expectedJSX) + -- ROBLOX deviation END +end +-- ROBLOX deviation START: replace module.exports +-- module.exports = Object.assign( +return Object.assign( + -- ROBLOX deviation END + {}, + SchedulerMatchers, + { toMatchRenderedOutput = toMatchRenderedOutput } +) diff --git a/WorkspaceStatic/jest/matchers/schedulerTestMatchers.lua b/WorkspaceStatic/jest/matchers/schedulerTestMatchers.lua new file mode 100644 index 00000000..65eae617 --- /dev/null +++ b/WorkspaceStatic/jest/matchers/schedulerTestMatchers.lua @@ -0,0 +1,212 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/scripts/jest/matchers/schedulerTestMatchers.js +local Packages = script.Parent.Parent.Parent.TestRunner +-- ROBLOX deviation START: fix import +-- local LuauPolyfill = require(Packages.LuauPolyfill) +local LuauPolyfill = require(Packages.Dev.LuauPolyfill) +-- ROBLOX deviation END +-- ROBLOX deviation START: not used +-- local Array = LuauPolyfill.Array +-- local Error = LuauPolyfill.Error +-- ROBLOX deviation END +local JestGlobals = require(Packages.Dev.JestGlobals) +local expect = JestGlobals.expect +-- ROBLOX deviation START: add import +type Array = LuauPolyfill.Array +-- ROBLOX deviation END +-- ROBLOX FIXME Luau: have to have explicit annotation as workaround for CLI-50002 +-- ROBLOX deviation START: add return type +-- local function captureAssertion(fn) +local function captureAssertion(fn): { pass: boolean, message: (() -> string)? } + -- ROBLOX deviation END + -- Trick to use a Jest 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. + do --[[ ROBLOX COMMENT: try-catch block conversion ]] + -- ROBLOX deviation START: replace xpcall, addapt message to something useful + -- local ok, result, hasReturned = xpcall(function() + -- fn() + -- end, function(error_) + -- return { + -- pass = false, + -- message = function() + -- return error_.message + -- end, + -- }, + -- true + -- end) + -- if hasReturned then + -- return result + -- end + local ok, result = pcall(fn) + if not ok then + local stringResult = tostring(result) + local subMessageIndex = string.find(stringResult, " ") + assert( + subMessageIndex ~= nil, + "assertion failure text wasn't in expected format" + ) + local message = string.sub(stringResult, subMessageIndex + 1) + + return { + pass = false, + message = function() + return message + end, + } + end + -- ROBLOX deviation END + end + return { pass = true } +end +local function assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- local actualYields = Scheduler:unstable_clearYields() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + -- ROBLOX deviation START: fix .length + -- if actualYields.length ~= 0 then + if #actualYields ~= 0 then + -- ROBLOX deviation END + error( + -- ROBLOX deviation START: adapt error message, error with string + -- Error.new( + -- "Log of yielded values is not empty. " + -- .. "Call expect(Scheduler).toHaveYielded(...) first." + -- ) + "Log of yielded values is not empty. " + .. "Call expectToHaveYielded(Scheduler, ...) first.", + 3 + -- ROBLOX deviation END + ) + end +end + +-- ROBLOX FIXME Luau: Array annotation here is so we don't have to put the annotation in many places due to mixed arrays +-- ROBLOX deviation START: add context argument +-- local function toFlushAndYield(Scheduler, expectedYields) +local function toFlushAndYield(_matcherContext, Scheduler, expectedYields: Array) + -- ROBLOX deviation END + assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushAllWithoutAsserting() + -- local actualYields = Scheduler:unstable_clearYields() + Scheduler.unstable_flushAllWithoutAsserting() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + return captureAssertion(function() + expect(actualYields).toEqual(expectedYields) + end) +end + +-- ROBLOX FIXME Luau: Array annotation here is so we don't have to put the annotation in many places due to mixed arrays +-- ROBLOX deviation START: add context argument +-- local function toFlushAndYieldThrough(Scheduler, expectedYields) +local function toFlushAndYieldThrough( + _matcherContext, + Scheduler, + expectedYields: Array +) + -- ROBLOX deviation END + assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushNumberOfYields(expectedYields.length) + -- local actualYields = Scheduler:unstable_clearYields() + Scheduler.unstable_flushNumberOfYields(#expectedYields) + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + return captureAssertion(function() + expect(actualYields).toEqual(expectedYields) + end) +end +-- ROBLOX FIXME Luau: Array annotation here is so we don't have to put the annotation in many places due to mixed arrays +-- ROBLOX deviation START: add context argument +-- local function toFlushUntilNextPaint(Scheduler, expectedYields) +local function toFlushUntilNextPaint( + _matcherContext, + Scheduler, + expectedYields: Array +) + -- ROBLOX deviation END + assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushUntilNextPaint() + -- local actualYields = Scheduler:unstable_clearYields() + Scheduler.unstable_flushUntilNextPaint() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + return captureAssertion(function() + expect(actualYields).toEqual(expectedYields) + end) +end +-- ROBLOX deviation START: add context argument +-- local function toFlushWithoutYielding(Scheduler) +local function toFlushWithoutYielding(_matcherContext, Scheduler) + -- ROBLOX deviation END + -- ROBLOX deviation START: add context argument + -- return toFlushAndYield(Scheduler, {}) + return toFlushAndYield(_matcherContext, Scheduler, {}) + -- ROBLOX deviation END +end + +-- ROBLOX FIXME Luau: Array annotation here is so we don't have to put the annotation in many places due to mixed arrays +-- ROBLOX deviation START: add context argument +-- local function toFlushExpired(Scheduler, expectedYields) +local function toFlushExpired(_matcherContext, Scheduler, expectedYields: Array) + -- ROBLOX deviation END + assertYieldsWereCleared(Scheduler) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushExpired() + -- local actualYields = Scheduler:unstable_clearYields() + Scheduler.unstable_flushExpired() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + return captureAssertion(function() + expect(actualYields).toEqual(expectedYields) + end) +end + +-- ROBLOX FIXME Luau: Array annotation here is so we don't have to put the annotation in many places due to mixed arrays +-- ROBLOX deviation START: add context argument +-- local function toHaveYielded(Scheduler, expectedYields) +local function toHaveYielded(_matcherContext, Scheduler, expectedYields: Array) + -- ROBLOX deviation END + return captureAssertion(function() + -- ROBLOX deviation START: use dot notation + -- local actualYields = Scheduler:unstable_clearYields() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + expect(actualYields).toEqual(expectedYields) + end) +end +-- ROBLOX deviation START: add context argument +-- local function toFlushAndThrow( +-- Scheduler, +-- ...: any --[[ ROBLOX CHECK: check correct type of elements. ]] +-- ) +local function toFlushAndThrow(_matcherContext, Scheduler, ...) + -- ROBLOX deviation END + local rest = { ... } + assertYieldsWereCleared(Scheduler) + return captureAssertion(function() + -- ROBLOX TODO Luau: if we wrap this function, we get an odd analyze error: Type '() -> ()' could not be converted into '{| |}' + -- ROBLOX deviation START: use dot notation, fix spreading + -- expect(function() + -- Scheduler:unstable_flushAllWithoutAsserting() + -- end).toThrow(table.unpack(Array.spread(rest))) + expect(Scheduler.unstable_flushAllWithoutAsserting).toThrow(table.unpack(rest)) + -- ROBLOX deviation END + end) +end +-- ROBLOX deviation START: replace module.exports +-- module.exports = { +return { + -- ROBLOX deviation END + toFlushAndYield = toFlushAndYield, + toFlushAndYieldThrough = toFlushAndYieldThrough, + toFlushUntilNextPaint = toFlushUntilNextPaint, + toFlushWithoutYielding = toFlushWithoutYielding, + toFlushExpired = toFlushExpired, + toHaveYielded = toHaveYielded, + toFlushAndThrow = toFlushAndThrow, +} diff --git a/modules/roblox-jest/src/Matchers/toErrorDev.lua b/WorkspaceStatic/jest/matchers/toErrorDev.lua similarity index 100% rename from modules/roblox-jest/src/Matchers/toErrorDev.lua rename to WorkspaceStatic/jest/matchers/toErrorDev.lua diff --git a/WorkspaceStatic/jest/matchers/toLogDev.lua b/WorkspaceStatic/jest/matchers/toLogDev.lua new file mode 100644 index 00000000..082beca3 --- /dev/null +++ b/WorkspaceStatic/jest/matchers/toLogDev.lua @@ -0,0 +1,3 @@ +local createConsoleMatcher = require(script.Parent.createConsoleMatcher) + +return createConsoleMatcher("log", "toLogDev") diff --git a/modules/roblox-jest/src/Matchers/toWarnDev.lua b/WorkspaceStatic/jest/matchers/toWarnDev.lua similarity index 100% rename from modules/roblox-jest/src/Matchers/toWarnDev.lua rename to WorkspaceStatic/jest/matchers/toWarnDev.lua diff --git a/WorkspaceStatic/jest/testSetupFile.lua b/WorkspaceStatic/jest/testSetupFile.lua new file mode 100644 index 00000000..aee2b968 --- /dev/null +++ b/WorkspaceStatic/jest/testSetupFile.lua @@ -0,0 +1,24 @@ +-- ROBLOX note: no upstream +-- loosely based on https://github.com/facebook/react/blob/9abc2785cb070148d64fae81e523246b90b92016/scripts/jest/setupTests.js +-- in a way that we use this file to extend jestExpect with custom matchers + +local Packages = script.Parent.Parent.TestRunner +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect + +local InteractionTracingMatchers = + require(script.Parent.matchers.interactionTracingMatchers) + +jestExpect.extend(require(script.Parent.matchers.reactTestMatchers)) +jestExpect.extend({ + toErrorDev = require(script.Parent.matchers.toErrorDev), + toWarnDev = require(script.Parent.matchers.toWarnDev), + toLogDev = require(script.Parent.matchers.toLogDev), + toContainNoInteractions = InteractionTracingMatchers.toContainNoInteractions, + toHaveBeenLastNotifiedOfInteraction = InteractionTracingMatchers.toHaveBeenLastNotifiedOfInteraction, + toHaveBeenLastNotifiedOfWork = InteractionTracingMatchers.toHaveBeenLastNotifiedOfWork, + toMatchInteraction = InteractionTracingMatchers.toMatchInteraction, + toMatchInteractions = InteractionTracingMatchers.toMatchInteractions, +}) + +_G.jest = true diff --git a/bin/ci-benchmarks.sh b/bin/ci-benchmarks.sh new file mode 100755 index 00000000..9f74c3d6 --- /dev/null +++ b/bin/ci-benchmarks.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -x + +rotrieve install +rojo build tests.project.json --output model.rbxm + +echo "Remove .robloxrc from dependencies" +find Packages/_Index -name "*.robloxrc" | xargs rm -f + +echo "Run static analysis" +roblox-cli analyze tests.project.json +selene --version +selene --config selene.toml modules/ +stylua --version +stylua -c modules -g "*[a-bdh-km-oquvyz].lua" + +echo "Run benchmarks" +robloxdev-cli run --load.model model.rbxm --run bin/run-first-render-benchmark.lua --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true EnableDelayedTaskMethods=true --headlessRenderer 1 +robloxdev-cli run --load.model model.rbxm --run bin/run-frame-rate-benchmark.lua --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true EnableDelayedTaskMethods=true --headlessRenderer 1 +robloxdev-cli run --load.model model.rbxm --run bin/run-deep-tree-benchmark.lua --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true EnableDelayedTaskMethods=true --headlessRenderer 1 +robloxdev-cli run --load.model model.rbxm --run bin/run-wide-tree-benchmark.lua --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true EnableDelayedTaskMethods=true --headlessRenderer 1 +robloxdev-cli run --load.model model.rbxm --run bin/run-sierpinski-triangle-benchmark.lua --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true EnableDelayedTaskMethods=true --headlessRenderer 1 diff --git a/bin/ci.sh b/bin/ci.sh index 0f751f60..ebb557ba 100755 --- a/bin/ci.sh +++ b/bin/ci.sh @@ -3,17 +3,28 @@ set -x rotrieve install -rojo build tests.project.json --output model.rbxmx echo "Remove .robloxrc from dependencies" find Packages/_Index -name "*.robloxrc" | xargs rm -f -roblox-cli analyze tests.project.json +echo "Run static analysis" +roblox-cli analyze --project tests.project.json selene --version -selene --config selene.toml modules/ +selene --config selene.toml modules/ WorkspaceStatic/ stylua --version -stylua -c modules -g "*[a-bdh-km-oquvyz].lua" +stylua -c modules bin WorkspaceStatic + echo "Run tests in DEV" -roblox-cli run --load.model model.rbxmx --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true --fastFlags.allOnLuau --lua.globals=__DEV__=true +robloxdev-cli run --load.model tests.project.json \ + --run bin/spec.lua \ + --fastFlags.allOnLuau --fastFlags.overrides UseDateTimeType3=true EnableLoadModule=true DebugDisableOptimizedBytecode=true EnableDelayedTaskMethods=true MaxDeferReentrancyDepth=65 \ + --load.asRobloxScript --headlessRenderer 1 --virtualInput 1 --fs.readwrite=$PWD --lua.globals=__COMPAT_WARNINGS__=true \ + --lua.globals=UPDATESNAPSHOT=false --lua.globals=CI=true --lua.globals=__ROACT_17_MOCK_SCHEDULER__=true \ + --lua.globals=__DEV__=true + echo "Run tests in release" -roblox-cli run --load.model model.rbxmx --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true --fastFlags.allOnLuau +robloxdev-cli run --load.model tests.project.json \ + --run bin/spec.lua \ + --fastFlags.allOnLuau --fastFlags.overrides UseDateTimeType3=true EnableLoadModule=true DebugDisableOptimizedBytecode=true EnableDelayedTaskMethods=true MaxDeferReentrancyDepth=65 \ + --load.asRobloxScript --headlessRenderer 1 --virtualInput 1 --fs.readwrite=$PWD \ + --lua.globals=UPDATESNAPSHOT=false --lua.globals=CI=true --lua.globals=__ROACT_17_MOCK_SCHEDULER__=true diff --git a/bin/compare-benchmarks.py b/bin/compare-benchmarks.py new file mode 100644 index 00000000..fb48f386 --- /dev/null +++ b/bin/compare-benchmarks.py @@ -0,0 +1,81 @@ +#!/usr/bin/python + +# This script creates a CSV comparing the difference between two benchmark CSVs +# Pass --output or -o to set an output dir, helpful for comparisons and organization (ex: -o bin/featureName-benchmarks) +# Pass the two CSVs as direct args, no --flag-prefix needed + +import os +import sys +import re + +def percentChange(a, b): + a = a if type(a) == float else float(a) + b = b if type(b) == float else float(b) + return (b-a)/abs(b) * 100 + +CSVs = [] + +# Default parameters +parameters = { + "directory": "bin/benchmarks", +} + +# Parse command line arguments +argNum = 1 +while argNum < len(sys.argv): + arg = sys.argv[argNum] + if arg == "-o" or arg == "--output": + value = sys.argv[argNum+1] + if value[0:1] != "-": + parameters['directory'] = value + else: + print(f"Error: Argument for {arg} is missing, please specify an output directory") + exit(1) + + argNum += 2 + elif arg[0:1] == "-": + print(f"Error: Unsupported flag {arg}") + exit(1) + else: + CSVs.append(arg) + argNum += 1 + +csvA = open(CSVs[0], mode="r", encoding="utf-8") +csvB = open(CSVs[1], mode="r", encoding="utf-8") + +aName = re.search(r"([\w\-]+)\.csv$", CSVs[0]).group(1).strip().replace("-benchmark", "") +bName = re.search(r"([\w\-]+)\.csv$", CSVs[1]).group(1).strip().replace("-benchmark", "") + +csvALines = csvA.readlines() +csvBLines = csvB.readlines() + +# Create the results directory +if not os.path.exists(parameters['directory']): + os.makedirs(parameters['directory']) + +outputFile = open(f"{parameters['directory']}/compare-{aName}-to-{bName}-benchmark.csv", mode="w", encoding="utf-8") +outputFile.write(f"Test,Metric,{aName},{bName},Unit,Change") +outputFile.write('\n') + +headers = {} +for i, line in enumerate(csvALines): + if i == 0: + rawHeaders = line.split(",") + for idx, header in enumerate(rawHeaders): + headers[header] = idx + continue + + aValues = line.split(",") + bValues = csvBLines[i].split(",") + + outputFile.write("{test},{metric},{a},{b},{unit},{change:.3f}%".format( + test=aValues[headers['Test']], + metric=aValues[headers['Metric']], + a = aValues[headers['Value']], + b = bValues[headers['Value']], + unit = aValues[headers['Unit']], + change = percentChange(aValues[headers['Value']], bValues[headers['Value']]), + )) + outputFile.write('\n') + +outputFile.close() diff --git a/bin/run-benchmarks.py b/bin/run-benchmarks.py new file mode 100644 index 00000000..4c3e9f0e --- /dev/null +++ b/bin/run-benchmarks.py @@ -0,0 +1,204 @@ +#!/usr/bin/python + +# This script runs all the benchmarks, and outputs: +# - Flamegraphs of each benchmark +# - A detailed csv of all the processed results +# File names will include your git branch as a prefix, making it easier to compare changes. +# +# Pass --output or -o to set an output dir, helpful for comparisons and organization (ex: -o bin/featureName-benchmarks) +# Pass --runs or -r to set how many time each benchmark is run (ex: -r 5) +# Pass --dev or -d to run tests in DEV and COMPAT_WARNINGS mode (ex: --dev) + +BENCHMARK_FILES = "bin/run-*-benchmark.lua" +PROJECT_JSON = "tests.project.json" +OUTPUT_PATTERN = r"(.+) x ([\d\.]+) ([/\w]+) ±([\d\.]+)\% \((\d+) runs sampled\)" + +import os +import sys +import re +import subprocess +import glob +import svg +os.system('color') # Colored output support + +# Profiler output node +class Node(svg.Node): + def __init__(self): + svg.Node.__init__(self) + self.function = "" + self.source = "" + self.line = 0 + self.ticks = 0 + + def text(self): + return self.function + + def title(self): + if self.line > 0: + return "{}\n{}:{}".format(self.function, self.source, self.line) + else: + return self.function + + def details(self, root): + return "Function: {} [{}:{}] ({:,} usec, {:.1%}); self: {:,} usec".format(self.function, self.source, self.line, self.width, self.width / root.width, self.ticks) + + +# Default parameters +parameters = { + "directory": "bin/benchmarks", + "runs": 3, + "dev": "", +} + +# Parse command line arguments +argNum = 1 +while argNum < len(sys.argv): + arg = sys.argv[argNum] + if arg == "-o" or arg == "--output": + value = sys.argv[argNum+1] + if value[0:1] != "-": + parameters['directory'] = value + else: + print(f"Error: Argument for {arg} is missing, please specify an output directory") + exit(1) + + argNum += 2 + elif arg == "-r" or arg == "--runs": + value = sys.argv[argNum+1] + if value[0:1] != "-": + parameters['runs'] = int(value) + else: + print(f"Error: Argument for {arg} is missing, please specify a number of runs") + exit(1) + + argNum += 2 + elif arg == "-d" or arg == "--dev": + parameters['dev'] = " --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true" + + argNum += 1 + elif arg[0:1] == "-": + print(f"Error: Unsupported flag {arg}") + exit(1) + else: + argNum += 1 + +# Gather the path information +branch = subprocess.getoutput("git symbolic-ref --short HEAD").strip().replace("/", "-") +prefix=f"{parameters['directory']}/{branch}" +logPath = f"{prefix}-benchmark.log" + +# Create the results directory +if not os.path.exists(parameters['directory']): + os.makedirs(parameters['directory']) + +logFile = open(logPath, mode="w", encoding="utf-8") +logFile.write("") +logFile.flush() + +# Run each benchmark file +for test in glob.iglob(BENCHMARK_FILES): + testName = test[8:-14] + print(f"\033[94mRunning {testName}...\033[0m", flush=True) # Colored output since benchmarks can be noisy and this helps readability + + logFile.write(f"TEST: {testName.replace('-', ' ')}\n") + logFile.flush() + for i in range(1, parameters['runs']+1): + print(f" Run {i}", flush=True) + runResults = subprocess.Popen( + f"robloxdev-cli run --load.model {PROJECT_JSON} --run {test} --headlessRenderer 1 --fastFlags.overrides \"EnableDelayedTaskMethods=true\" \"FIntScriptProfilerFrequency=1000000\" \"DebugScriptProfilerEnabled=true\" \"EnableLoadModule=true\" --fastFlags.allOnLuau" + parameters['dev'], + encoding="utf-8", stdout=logFile, + ) + runResults.wait() + logFile.flush() + + # Generate flamegraph from last run data + flameFile=f"{prefix}-{testName.replace('/', '-')}-profile.svg" + + dump = open("profile.out").readlines() + root = Node() + + for l in dump: + ticks, stack = l.strip().split(" ", 1) + node = root + + for f in reversed(stack.split(";")): + source, function, line = f.split(",") + + child = node.child(f) + child.function = function + child.source = source + child.line = int(line) if len(line) > 0 else 0 + + node = child + + node.ticks += int(ticks) + + svg.layout(root, lambda n: n.ticks) + svg.display(open(flameFile, mode="w"), root, "Flame Graph", "hot", flip = True) + + print(f"Flamegraph results written to {flameFile}") + if os.path.exists("profile.out"): + os.remove("profile.out") + +logFile.flush() +logFile.close() + +# Process the benchmark data into a csv +results = {} + +testName = "" +for line in open(logPath, mode="r", encoding="utf-8").readlines(): + newTestMatch = re.match(r"TEST: (.+)", line) + if newTestMatch: + testName = newTestMatch.group(1) + results[testName] = {} + else: + metricMatch = re.match(OUTPUT_PATTERN, line) + if metricMatch: + metric = metricMatch.group(1) + value = metricMatch.group(2) + unit = metricMatch.group(3) + deviation = metricMatch.group(4) + samples = metricMatch.group(5) + + testResult = results.get(testName) + if not testResult: + testResult = {} + results[testName] = testResult + + metricResult = testResult.get(metric) + if not metricResult: + metricResult = { + "count": 0, + "valueSum": 0, + "unit": unit, + "deviationSum": 0, + "samples": 0, + } + testResult[metric] = metricResult + + metricResult['count'] += 1 + metricResult['valueSum'] += float(value) + metricResult['unit'] = unit + metricResult['deviationSum'] += float(deviation) + metricResult['samples'] += int(samples) + +# Build the csv file from the result data +outputFile = open(f"{prefix}-benchmark.csv", mode="w", encoding="utf-8") +outputFile.write("Test,Metric,Value,Unit,Deviation,Samples") +outputFile.write('\n') +for testName, testResults in results.items(): + for metric, metricResult in testResults.items(): + outputFile.write("{test},\"{metric}\",{value:.5f},{unit},\"±{deviation:.2f}%\",{samples}".format( + test=testName, + metric=metric, + value=metricResult['valueSum']/metricResult['count'], + unit=metricResult['unit'], + deviation=metricResult['deviationSum']/metricResult['count'], + samples=metricResult['samples'], + )) + outputFile.write('\n') + +os.remove(logPath) +outputFile.close() +print(f"Benchmark results written to {prefix}-benchmark.csv") diff --git a/bin/run-deep-tree-benchmark.lua b/bin/run-deep-tree-benchmark.lua new file mode 100644 index 00000000..6e66de44 --- /dev/null +++ b/bin/run-deep-tree-benchmark.lua @@ -0,0 +1,15 @@ +local Packages = script.Parent.RoactAlignment +local RotrieverWorkspace = Packages._Workspace + +local Roact = require(RotrieverWorkspace.React.React) +local ReactRoblox = require(RotrieverWorkspace.ReactRoblox.ReactRoblox) + +local config = {} +if _G.minSamples ~= nil then + config.sampleCount = tonumber(_G.minSamples) +end + +require(RotrieverWorkspace.React.Dev.PerformanceBenchmarks).deepTreeBenchmark( + Roact, + ReactRoblox +)(config) diff --git a/bin/run-first-render-benchmark.lua b/bin/run-first-render-benchmark.lua new file mode 100644 index 00000000..8e776e6b --- /dev/null +++ b/bin/run-first-render-benchmark.lua @@ -0,0 +1,17 @@ +local Packages = script.Parent.RoactAlignment +local RotrieverWorkspace = Packages._Workspace + +local Roact = require(RotrieverWorkspace.React.React) +local ReactRoblox = require(RotrieverWorkspace.ReactRoblox.ReactRoblox) +local Scheduler = require(RotrieverWorkspace.Scheduler.Scheduler) +local firstRenderBenchmark = + require(RotrieverWorkspace.React.Dev.PerformanceBenchmarks).firstRenderBenchmark + +local config = { + minSamples = 200, +} +if _G.minSamples ~= nil then + config.minSamples = tonumber(_G.minSamples) +end + +firstRenderBenchmark(Roact, ReactRoblox, Scheduler)(config) diff --git a/bin/run-frame-rate-benchmark.lua b/bin/run-frame-rate-benchmark.lua new file mode 100644 index 00000000..91ef8159 --- /dev/null +++ b/bin/run-frame-rate-benchmark.lua @@ -0,0 +1,17 @@ +local Packages = script.Parent.RoactAlignment +local RotrieverWorkspace = Packages._Workspace + +local Roact = require(RotrieverWorkspace.React.React) +local ReactRoblox = require(RotrieverWorkspace.ReactRoblox.ReactRoblox) +local Scheduler = require(RotrieverWorkspace.Scheduler.Scheduler) +local frameRateBenchmark = + require(RotrieverWorkspace.React.Dev.PerformanceBenchmarks).frameRateBenchmark + +local config = { + minSamples = 600, +} +if _G.minSamples ~= nil then + config.minSamples = tonumber(_G.minSamples) +end + +frameRateBenchmark(Roact, ReactRoblox, Scheduler)(config) diff --git a/bin/run-sierpinski-triangle-benchmark.lua b/bin/run-sierpinski-triangle-benchmark.lua new file mode 100644 index 00000000..958e8540 --- /dev/null +++ b/bin/run-sierpinski-triangle-benchmark.lua @@ -0,0 +1,16 @@ +local Packages = script.Parent.RoactAlignment +local RotrieverWorkspace = Packages._Workspace + +local Roact = require(RotrieverWorkspace.React.React) +local ReactRoblox = require(RotrieverWorkspace.ReactRoblox.ReactRoblox) +local sierpinskiTriangleBenchmark = require( + RotrieverWorkspace.React.Dev.PerformanceBenchmarks +).sierpinskiTriangleBenchmark + +local config = {} +if _G.minSamples ~= nil then + config.sampleCount = tonumber(_G.minSamples) +end + +sierpinskiTriangleBenchmark(Roact, ReactRoblox)(config) +wait(1) diff --git a/bin/run-wide-tree-benchmark.lua b/bin/run-wide-tree-benchmark.lua new file mode 100644 index 00000000..ce337952 --- /dev/null +++ b/bin/run-wide-tree-benchmark.lua @@ -0,0 +1,14 @@ +local Packages = script.Parent.RoactAlignment +local RotrieverWorkspace = Packages._Workspace + +local Roact = require(RotrieverWorkspace.React.React) +local ReactRoblox = require(RotrieverWorkspace.ReactRoblox.ReactRoblox) +local wideTreeBenchmark = + require(RotrieverWorkspace.React.Dev.PerformanceBenchmarks).wideTreeBenchmark + +local config = {} +if _G.minSamples ~= nil then + config.sampleCount = tonumber(_G.minSamples) +end + +wideTreeBenchmark(Roact, ReactRoblox)(config) diff --git a/bin/run-with-cachegrind.sh b/bin/run-with-cachegrind.sh new file mode 100755 index 00000000..2b687628 --- /dev/null +++ b/bin/run-with-cachegrind.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +# Tips for getting consistent perf benchmark results on inconsistent hardware +# found here https://pythonspeed.com/articles/consistent-benchmarking-in-ci/ + +set -euo pipefail +IFS=$'\n\t' + +declare -A event_map +event_map[Ir]="TotalInstructionsExecuted,executions\n" +event_map[I1mr]="L1_InstrReadCacheMisses,misses/op\n" +event_map[ILmr]="LL_InstrReadCacheMisses,misses/op\n" +event_map[Dr]="TotalMemoryReads,reads\n" +event_map[D1mr]="L1_DataReadCacheMisses,misses/op\n" +event_map[DLmr]="LL_DataReadCacheMisses,misses/op\n" +event_map[Dw]="TotalMemoryWrites,writes\n" +event_map[D1mw]="L1_DataWriteCacheMisses,misses/op\n" +event_map[DLmw]="LL_DataWriteCacheMisses,misses/op\n" +event_map[Bc]="ConditionalBranchesExecuted,executions\n" +event_map[Bcm]="ConditionalBranchMispredictions,mispredictions/op\n" +event_map[Bi]="IndirectBranchesExecuted,executions\n" +event_map[Bim]="IndirectBranchMispredictions,mispredictions/op\n" + +now_ms() { + echo -n $(date +%s%N | cut -b1-13) +} + +# Run cachegrind on a given benchmark and echo the results. +CLI_VERSION=$($1 version | tr -d '\n') +ITERATION_COUNT=$4 +START_TIME=$(now_ms) + +# Get arch for disabling ALSR +ARCH=$(uname -m | sed 's/ *$//g') + +# Run valgrind with virtual address randomization disabled, cachegrind enabled, +# and cache sizes specified +setarch \ +$ARCH \ +-R \ +valgrind \ + --quiet \ + --tool=cachegrind \ + --I1=32768,8,64 \ + --D1=32768,8,64 \ + --LL=52428800,25,64 \ + "$1" run \ + --load.model model.rbxm \ + --run "$2" \ + --headlessRenderer 1 \ + --lua.globals minSamples=$ITERATION_COUNT \ + --lua.globals cachegrind=true \ + --fastFlags.allOnLuau \ +>/dev/null + +TIME_ELAPSED=$(($(now_ms) - START_TIME)) + +# Generate report using cg_annotate and extract the header and totals of the +# recorded events valgrind was configured to record. +CG_RESULTS=$(cg_annotate $(ls -t cachegrind.out.* | head -1)) +CG_HEADERS=$(grep -B2 'PROGRAM TOTALS$' <<< "$CG_RESULTS" | head -1 | sed -E 's/\s+/\n/g' | sed '/^$/d') +CG_TOTALS=$(grep 'PROGRAM TOTALS$' <<< "$CG_RESULTS" | head -1 | grep -Po '[0-9,]+\s' | tr -d ', ') + +TOTALS_ARRAY=($CG_TOTALS) +HEADERS_ARRAY=($CG_HEADERS) + +declare -A header_map +for i in "${!TOTALS_ARRAY[@]}"; do + header_map[${HEADERS_ARRAY[$i]}]=$i +done + +# Map the results to the format that the benchmark script expects. +for i in "${!TOTALS_ARRAY[@]}"; do + TOTAL=${TOTALS_ARRAY[$i]} + + # Labels and unit descriptions are packed together in the map. + EVENT_TUPLE=${event_map[${HEADERS_ARRAY[$i]}]} + IFS=$',' read -d '\n' -ra EVENT_VALUES < <(printf "%s" "$EVENT_TUPLE") + EVENT_NAME="${EVENT_VALUES[0]}" + UNIT="${EVENT_VALUES[1]}" + + case ${HEADERS_ARRAY[$i]} in + I1mr | ILmr) + REF=${TOTALS_ARRAY[header_map["Ir"]]} + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL / $REF}") + ;; + + D1mr | DLmr) + REF=${TOTALS_ARRAY[header_map["Dr"]]} + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL / $REF}") + ;; + + D1mw | DLmw) + REF=${TOTALS_ARRAY[header_map["Dw"]]} + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL / $REF}") + ;; + + Bcm) + REF=${TOTALS_ARRAY[header_map["Bc"]]} + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL / $REF}") + ;; + + Bim) + REF=${TOTALS_ARRAY[header_map["Bi"]]} + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL / $REF}") + ;; + + *) + OPS_PER_SEC=$(awk "BEGIN {printf \"%.6f\", $TOTAL}") + ;; + esac + + STD_DEV="0%" + RUNS="1" + + if [[ $OPS_PER_SEC =~ ^[+-]?[0-9]*$ ]] + then # $OPS_PER_SEC is integer + printf "%s#%s x %.0f %s ±%s (%d runs sampled)(roblox-cli version %s)\n" \ + "$3" "$EVENT_NAME" "$OPS_PER_SEC" "$UNIT" "$STD_DEV" "$RUNS" "$CLI_VERSION" + else # $OPS_PER_SEC is float + printf "%s#%s x %.10f %s ±%s (%d runs sampled)(roblox-cli version %s)\n" \ + "$3" "$EVENT_NAME" "$OPS_PER_SEC" "$UNIT" "$STD_DEV" "$RUNS" "$CLI_VERSION" + fi + +done + diff --git a/bin/spec.lua b/bin/spec.lua index be57a167..4de000a3 100644 --- a/bin/spec.lua +++ b/bin/spec.lua @@ -1,32 +1,46 @@ -local Packages = script.Parent.RoactAlignment -local ProcessService = game:GetService("ProcessService") +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +local Workspace = script.Parent.RoactAlignment._Workspace +local runCLI = require(Workspace.TestRunner.Dev.Jest).runCLI -local RotrieverWorkspace = Packages._Workspace - --- ROBLOX FIXME: What's the more reasonable way of accessing this? Are all dev --- dependencies hoisted to the top level in addition to existing as their --- relevant interdependency links? -local JestRoblox = require(RotrieverWorkspace.React.Dev.JestRoblox) -local RobloxJest = require(RotrieverWorkspace.React.Dev.RobloxJest) - --- ROBLOX deviation: upstream mocks both of these via --- scripts/setupHostConfigs.js, but this testing entry-point is the closest --- equivalent we have -RobloxJest.mock(RotrieverWorkspace.Scheduler.Scheduler, function() - return require(RotrieverWorkspace.Scheduler.Scheduler.unstable_mock) +local processServiceExists, ProcessService = pcall(function() + return game:GetService("ProcessService") end) --- Run all tests, collect results, and report to stdout. -local result = JestRoblox.TestBootstrap:run( - { RotrieverWorkspace }, - JestRoblox.Reporters.TextReporterQuiet, - { extraEnvironment = RobloxJest.testEnv } -) +local status, result = runCLI(Workspace, { + verbose = if _G.verbose == "true" then true else nil, + ci = _G.CI == "true", + updateSnapshot = _G.UPDATESNAPSHOT == "true", +}, { Workspace }):awaitStatus() -if result.failureCount == 0 and #result.errors == 0 then - ProcessService:ExitAsync(0) +if status == "Rejected" then + print(result) end -ProcessService:ExitAsync(1) +if + status == "Resolved" + and result.results.numFailedTestSuites == 0 + and result.results.numFailedTests == 0 +then + if processServiceExists then + ProcessService:ExitAsync(0) + end +end + +if processServiceExists then + ProcessService:ExitAsync(1) +end return nil diff --git a/bin/svg.py b/bin/svg.py new file mode 100644 index 00000000..e204352d --- /dev/null +++ b/bin/svg.py @@ -0,0 +1,480 @@ +class Node: + def __init__(self): + self.name = "" + self.children = {} + # computed + self.depth = 0 + self.width = 0 + self.offset = 0 + + def child(self, name): + node = self.children.get(name) + if not node: + node = self.__class__() + node.name = name + self.children[name] = node + return node + + def subtree(self): + result = [self] + offset = 0 + + while offset < len(result): + p = result[offset] + offset += 1 + for c in p.children.values(): + result.append(c) + + return result + +def escape(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + +def layout(root, widthcb): + for n in reversed(root.subtree()): + # propagate width to the parent + n.width = widthcb(n) + for c in n.children.values(): + n.width += c.width + + # compute offset from parent for every child in width order (layout order) + offset = 0 + for c in sorted(n.children.values(), key = lambda x: x.width, reverse = True): + c.offset = offset + offset += c.width + + for n in root.subtree(): + for c in n.children.values(): + c.depth = n.depth + 1 + c.offset += n.offset + +# svg template (stolen from framegraph.pl) +template = r""" + + + + + + + + + + + + +$title +Reset Zoom +Search +ic + + + +""" + +def namehash(s): + # FNV-1a + hval = 0x811c9dc5 + for ch in s: + hval = hval ^ ord(ch) + hval = hval * 0x01000193 + hval = hval % (2 ** 32) + return (hval % 31337) / 31337.0 + +def display(file, root, title, colors, flip = False): + if colors == "cold": + gradient_start = "#eef2ee" + gradient_end = "#e0ffe0" + else: + gradient_start = "#eeeeee" + gradient_end = "#eeeeb0" + + maxdepth = 0 + for n in root.subtree(): + maxdepth = max(maxdepth, n.depth) + + svgheight = maxdepth * 16 + 3 * 16 + 2 * 16 + + file.write(template + .replace("$title", title) + .replace("$gradient-start", gradient_start) + .replace("$gradient-end", gradient_end) + .replace("$height", str(svgheight)) + .replace("$status", str((svgheight - 16 + 3 if flip else 3 * 16 - 3))) + .replace("$flip", str(int(flip))) + ) + + framewidth = 1200 - 20 + + def pixels(x): + return float(x) / root.width * framewidth if root.width > 0 else 0 + + for n in root.subtree(): + if pixels(n.width) < 0.1: + continue + + x = 10 + pixels(n.offset) + y = (maxdepth - 1 - n.depth if flip else n.depth) * 16 + 3 * 16 + width = pixels(n.width) + height = 15 + + if colors == "cold": + fillr = 0 + fillg = int(190 + 50 * namehash(n.name)) + fillb = int(210 * namehash(n.name[::-1])) + else: + fillr = int(205 + 50 * namehash(n.name)) + fillg = int(230 * namehash(n.name[::-1])) + fillb = int(55 * namehash(n.name[::-2])) + + fill = "rgb({},{},{})".format(fillr, fillg, fillb) + chars = width / (12 * 0.59) + + text = n.text() + + if chars >= 3: + if chars < len(text): + text = text[:int(chars-2)] + ".." + else: + text = "" + + file.writelines([ + "", + "{}".format(escape(n.title())), + "
{}
".format(escape(n.details(root))), + "".format(x, y, width, height, fill), + "{}".format(x + 3, y + 10.5, escape(text)), + "{}".format(escape(n.text())), + "
", + ]) + + file.write("
\n
\n") diff --git a/bin/testing.sh b/bin/testing.sh new file mode 100755 index 00000000..3fbc1e19 --- /dev/null +++ b/bin/testing.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -e + +<&2 + exit 1 + fi + ;; + -d|--deferred) + DEFERRED=0 + shift + ;; + -*|--*=) + echo "Error: Unsupported flag $1" >&2 + exit 1 + ;; + *) # Preserve positional arguments + PARAMS="$PARAMS $1" + shift + ;; + esac +done +# Set positional arguments in their proper place +eval set -- "$PARAMS" + +# Perform requested action + +if [[ $SNAPSHOT ]]; then + echo "Generating snapshots..." + # Figure out how to enable PROFILING global in jestSetup once we upgrade to jest 28 + # roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true --fastFlags.allOnLuau --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true --lua.globals=__PROFILE__=true --lua.globals=UPDATESNAPSHOT="all" --load.asRobloxScript --fs.readwrite="$(pwd)" + roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true DebugDisableOptimizedBytecode=true --fastFlags.allOnLuau --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true --lua.globals=UPDATESNAPSHOT="all" --load.asRobloxScript --fs.readwrite="$(pwd)" + exit 0 +fi + +if [[ $ANALYZE ]]; then + echo "Analyzing..." + robloxdev-cli analyze tests.project.json + exit 0 +fi + +if [[ $PROFILE ]]; then + echo "Generating profiled benchmark '$PROFILE'..." + robloxdev-cli run --load.model tests.project.json --run $PROFILE --headlessRenderer 1 --fastFlags.overrides "EnableDelayedTaskMethods=true" "FIntScriptProfilerFrequency=1000000" "DebugScriptProfilerEnabled=true" "EnableLoadModule=true" "DebugDisableOptimizedBytecode=true" --fastFlags.allOnLuau + python ../game-engine/Client/Luau/tools/perfgraph.py profile.out > $PROFILE-profile.svg + rm profile.out + start $PROFILE-profile.svg + echo "Flamegraph opened successfully in default svg viewer application" + exit 0 +fi + +if [[ $DEFERRED ]]; then + echo "Running tests with deferred lua enabled..." + roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.allOn \ + --fastFlags.overrides EnableLoadModule=true DebugDisableOptimizedBytecode=true EnableSignalBehavior=true DebugForceDeferredSignalBehavior=true \ + --fastFlags.overrides MaxDeferReentrancyDepth=40 \ + --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true +else + echo "Running tests..." + # Figure out how to enable PROFILING global in jestSetup once we upgrade to jest 28 + #roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.overrides EnableLoadModule=true --fastFlags.allOnLuau --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true --lua.globals=__PROFILE__=true + roblox-cli run --load.model tests.project.json --run bin/spec.lua --fastFlags.allOn \ + --fastFlags.overrides MaxDeferReentrancyDepth=40 \ + --lua.globals=__DEV__=true --lua.globals=__COMPAT_WARNINGS__=true +fi + diff --git a/bin/upstream-tag.sh b/bin/upstream-tag.sh index 8d904063..c8b2ac19 100644 --- a/bin/upstream-tag.sh +++ b/bin/upstream-tag.sh @@ -33,7 +33,7 @@ do continue fi - if [[ `head -n 1 $file` == "-- upstream:"* ]]; then + if [[ `head -n 1 $file` == "-- ROBLOX upstream:"* ]]; then echo "SKIP: $file already has 'upstream' comment" continue fi @@ -51,7 +51,7 @@ do pushd $REACT_PATH > /dev/null COMMIT=`git log -- $targetFile | head -n 1 | sed "s/commit //g"` REPO_PATH=`realpath --relative-to=$REACT_PATH $targetFile` - PREFIX="-- upstream: https://github.com/facebook/react/blob/$COMMIT/$REPO_PATH" + PREFIX="-- ROBLOX upstream: https://github.com/facebook/react/blob/$COMMIT/$REPO_PATH" if [[ "$COMMIT" == "" ]]; then echo "SKIP: Could not find commit for $targetFile -> $file" continue diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..6ed82589 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,7 @@ +ignore: + - '*spec.lua' + - '*__test*' + - '*jest*' + - '*mock*' + - '*Dev*' + - '*/_Index/*' diff --git a/docs/align-files-guide.md b/docs/align-files-guide.md new file mode 100644 index 00000000..6f077922 --- /dev/null +++ b/docs/align-files-guide.md @@ -0,0 +1,163 @@ +# Aligning files guide + +We are aligning files by going through them module by module. We keep track of them in a [Adapt roact-alignment implementation](https://github.com/Roblox/roact-alignment/milestone/1) milestone. + +## Prerequisites + +In this guide we assume that you have the following project cloned inside of the common directory: +* js-to-lua [repo link](https://github.com/roblox/js-to-lua) + * checkout `main` branch +* roact-alignment [repo link](https://github.com/roblox/roact-alignment) + * your working branch on top of `master` branch +* react [repo link](https://github.com/facebook/react) + * checkout `v17.0.2` tag + +## Tools installation + +First follow the [README](https://github.com/Roblox/js-to-lua/blob/main/README.md) instructions to set up js-to-lua. The easiest way is to build the tool by running `npx nx build convert-js-to-lua` (from within `js-to-lua` directory) + +## Aligning process + +I will show you the process based on the `react-devtools-shared` module. All the commands shown below are to be executed from `js-to-lua` directory + +Depending on the size of the module and/or your preference you can either align all the files at once or do it file by file. In order to convert the whole module at once you can run the following command + +```shell +dist/apps/convert-js-to-lua/index.js \ + --input ../react/packages/react-devtools-shared/src/**/*.js \ + --output ../roact-alignment/modules/ \ + --rootDir ../react/packages \ + --plugin=knownImports --plugin=jestGlobals \ + --babelTransformConfig babel-flow-transform-react.config.json \ + --babelConfig babel-flow.config.json +``` + +This will run the conversion on all the files in the `react-devtools-shared` which in our case is a bit too much as this module contains a lot of files that haven't been converted yet. That's why for this particular module I chose to go file by file: + +```shell +dist/apps/convert-js-to-lua/index.js \ + --input ../react/packages/react-devtools-shared/src/ \ + --output ../roact-alignment/modules/ \ + --rootDir ../react/packages \ + --plugin=knownImports --plugin=jestGlobals \ + --babelTransformConfig babel-flow-transform-react.config.json \ + --babelConfig babel-flow.config.json +``` + +eg. + +``` +dist/apps/convert-js-to-lua/index.js \ + --input ../react/packages/react-devtools-shared/src/utils.js \ + --output ../roact-alignment/modules/ \ + --rootDir ../react/packages \ + --plugin=knownImports --plugin=jestGlobals \ + --babelTransformConfig babel-flow-transform-react.config.json \ + --babelConfig babel-flow.config.json +``` + +After we run the automatic conversion the file will contain a lot of changes. Please additionally run `stylua module` in the roact-alignment directory to make sure all the files are converted according to the StyLua configuration. + +Now our job is to capture the deviations that the original code has made and mark them with appropriate `ROBLOX deviation START` / `ROBLOX deviation END` comments. + +There are some special cases which fast-follow treats separately and you don't need to wrap them in a deviation comment: + +* upstream comment - with the command shown above there is a small issue with part of the upstream comment not being included. In this case we need to add the missing `packages/` part of the path. + ![Upstream comment diff](diff-upstream-comment.png) +* Packages - js-to-lua can't automatically assign a proper path to `Packages` variable right now. However, fast-follow will treat conflicts resulting from this line separately so there is no need to wrap it with deviation comment. + ![Packages local diff](diff-packages.png) + +We've added a bunch of improvements in js-to-lua to the output code so be mindful that new types might be better left as is straight from the conversion. Also you might encounter a lot of diff when it comes to class conversion. I suggest leaving the class conversion structure as is and only mark deviation inside of the class's methods body. + +For example the following diff should not be a deviation anymore: +![Spread type diff](diff-spread-flow-type-no-deviation-anymore.png) + +And with class conversion the following example shows how js-to-lua handles class "methods" which are defined as arrow functions. The body of the function might still need to be marked with deviation blocks but the structure should most probably stay the same + +![Class arrow function new conversion](diff-class-arrow-function-method-new.png) ![Class arrow function old conversion](diff-class-arrow-function-method-old.png) + +As for the actual deviations that need to be marked let's look at the following example. We can see that the call to `Boolean.toJSBoolean` is unnecessary in this case so we can comment-out that line, wrap it with `-- ROBLOX deviation START:` and `-- ROBLOX deviation END` comments and follow the commented out line with the proper implementation. + +![Bridge shutdown method's diff before marking a deviation](diff-bridge-shutdown-method-before.png) +![Bridge shutdown method's diff after marking a deviation](diff-bridge-shutdown-method-after.png) + +In order to make that process a little bit easier we've created a set of VSCode snippets that you can use - [link](#snippets) to snippets section. + +## Snippets + +You can configure your snippets by invoking `Snippets: Configure User Snippets` command from VSCode's command pallette and choosing `.lua` extension. Below you can find all of the snippets we've used. + +In order to use the snippets you should configure a hot key (keyboard shortcut) for `Snippets: Insert Snippet` action. Then you can just select a piece of code that you need to mark with deviation, press the hot key and choose the appropriate snippet: + +* `deviation-block` - will comment out the currently selected code, wrap it with deviation START/END comments and insert a duplicated version of the same code for you to tweak +* `deviation-block-skipped` - will do the same as `deviation-block` but won't insert a duplicated code as it's meant for blocks of code that should be skipped +* `deviation-block-add` - will wrap the currently selected block of code with deviation START/END comments and allow you to put an additional code inside. It's intended for some additional blocks of code that were not included in the original conversion. We use a convention of `-- ROBLOX deviation START: add ` to easily distinguish added and changes sections +* `deviation-start` and `deviation-end` - will only insert a proper deviation start/end comment. We use those to avoid typos. + +There are couple more utility snippets for special cases that were occurring more often: +* `deviation-block-type` - it will wrap a block with deviation comments, comment out the block and insert a duplicate but change `local` for `type`. It's useful when js-to-lua imported locals from a module when it should actually import types +* `deviation-line` and `deviation-line-skipped` are similar to their block equivalents but will use the current line instead of selected block of code + +```json +{ + "Deviation block - start": { + "prefix": "deviation-start", + "body": "-- ROBLOX deviation START: ${0:description}", + "description": "Roblox deviation start marker" + }, + "Deviation block - end": { + "prefix": "deviation-end", + "body": "-- ROBLOX deviation END", + "description": "Roblox deviation end marker" + }, + "Deviation block": { + "prefix": "deviation-block", + "body": [ + "-- ROBLOX deviation START: ${0:changed because}", + "-- ${TM_SELECTED_TEXT/\n/\n-- /g}", + "$TM_SELECTED_TEXT", + "-- ROBLOX deviation END" + ], + "description": "Roblox deviation end marker", + }, + "Deviation block - type": { + "prefix": "deviation-block-type", + "body": [ + "-- ROBLOX deviation START: ${0:import as type instead of local}", + "-- ${TM_SELECTED_TEXT/\n/\n-- /g}", + "${TM_SELECTED_TEXT/^local /type /gm}", + "-- ROBLOX deviation END" + ], + "description": "Roblox deviation end marker", + }, + "Deviation block - add": { + "prefix": "deviation-block-add", + "body": [ + "-- ROBLOX deviation START: ${0:add some code}", + "${TM_SELECTED_TEXT}", + "${1}", + "-- ROBLOX deviation END" + ], + "description": "Roblox deviation end marker", + }, + "Deviation block - skip": { + "prefix": "deviation-block-skipped", + "body": [ + "-- ROBLOX deviation START: ${0:changed because}", + "-- ${TM_SELECTED_TEXT/\n/\n-- /g}", + "-- ROBLOX deviation END" + ], + "description": "Roblox deviation end marker", + }, + "Deviation line": { + "prefix": "deviation-line", + "body": ["-- ROBLOX deviation START: ${0:changed because}", "-- $TM_CURRENT_LINE", "$TM_CURRENT_LINE", "-- ROBLOX deviation END"], + "description": "Roblox deviation end marker" + }, + "Deviation line - skipped": { + "prefix": "deviation-line-skipped", + "body": ["-- ROBLOX deviation START: ${0:changed because}", "-- $TM_CURRENT_LINE", "-- ROBLOX deviation END"], + "description": "Roblox deviation end marker" + } +} +``` diff --git a/docs/api-reference/additional-libraries.md b/docs/api-reference/additional-libraries.md new file mode 100644 index 00000000..a983ad6a --- /dev/null +++ b/docs/api-reference/additional-libraries.md @@ -0,0 +1,10 @@ +# Additional Libraries + +## ReactIs +*Under construction 🔨* + +## ReactTestRender +*Under construction 🔨* + +## ReactDevToolsExtensions +*Under construction 🔨* diff --git a/docs/api-reference/react-roblox.md b/docs/api-reference/react-roblox.md new file mode 100644 index 00000000..afeef06e --- /dev/null +++ b/docs/api-reference/react-roblox.md @@ -0,0 +1,59 @@ +# ReactRoblox + +The ReactRoblox package is the entry point for any Roblox-opinionated logic. It can be thought of as the equivalent of the ReactDOM package in React JS. + +However, rather than fully implementing all of the features of ReactDOM, it leaves most deprecated APIs out entirely, implementing only the modern and forward-looking portions of the API. + +Notable absences are: + +* `render` - Use `createRoot` (or, depending on the circumstances, `createBlockingRoot` or `createLegacyRoot`) instead +* `unmountComponentAtNode` - Use the `unmount` method on the root object returned from `createRoot` instead +* `findDOMNode` - Use [refs](https://reactjs.org/docs/refs-and-the-dom.html) instead + +## ReactRoblox.createRoot +Adopted as part of the Concurrent Mode API. Refer to [`ReactDOM.createRoot` documentation](https://reactjs.org/docs/concurrent-mode-reference.html#createroot). + +## ReactRoblox.createBlockingRoot +Adopted as part of the Concurrent Mode API. "Blocking" roots represent a transitional step between synchronous rendering and full concurrent rendering. Refer to [`ReactDOM.createBlockingRoot` documentation](https://reactjs.org/docs/concurrent-mode-adoption.html#migration-step-blocking-mode). + +## ReactRoblox.createLegacyRoot +Adopted as part of the Concurrent Mode API. "Legacy" roots are essentially equivalent to pre-concurrent React behavior. Refer to [`ReactDOM.createLegacyRoot` documentation](https://reactjs.org/docs/concurrent-mode-adoption.html#migration-step-blocking-mode). + +### Deviations +React documentation provides [a handy chart](https://reactjs.org/docs/concurrent-mode-adoption.html#feature-comparison) that explains the feature differences between the roots. This table lists three deprecated features under legacy roots only: + +* [String Refs](https://reactjs.org/docs/refs-and-the-dom.html#legacy-api-string-refs) +* [Legacy Context](https://reactjs.org/docs/legacy-context.html) +* [findDOMNode](https://reactjs.org/docs/strict-mode.html#warning-about-deprecated-finddomnode-usage) + +**These features were never implemented in legacy Roact, and have not been ported.** Instead, creating a Legacy Mode root simply tells Roact to render synchronously. This behavior is most similar to legacy Roact or pre-Concurrent-Mode React JS. + +Generally speaking, `ReactRoblox.createRoot` should be used for all new work, while the Blocking and Legacy versions can be used for upgrading existing Roact logic in the event that it does not yet meet the requirements for Concurrent Mode. + +## ReactRoblox.createPortal +Refer to [`ReactDOM.createPortal` documentation](https://reactjs.org/docs/portals.html). + + +## ReactRoblox.act +Used for testing code with scheduling logic mocked. This ensures that tests behave consistently and don't batch work unpredictably across frames. `act` executes a function and then plays forward the mock scheduler after it's done, and should be used to wrap any interactions that trigger updates to a roact UI. + +More info on using `act` can be found [in the migration guide](../migrating-from-1x/adopt-new-features.md#reactrobloxact). + +Refer to [ReactDOM's `act` testing documentation](https://reactjs.org/docs/test-utils.html#act) for more high-level guidance. + +### Deviations +In production, `ReactRoblox.act` will not be available. Set the global value `_G.__ROACT_17_INLINE_ACT__` to `true` in order to enable this behavior in tests. + +!!! info + This behavior is still a work in progress, and there may be changes to how Roact detects whether or not it should provide the `act` function. For now, use the global `__ROACT_17_INLINE_ACT__` to enable it for tests. + +## ReactRoblox.Event +*Roact-only* + +Re-exports [`React.Event`](react.md#reactevent). This is only exposed for backwards compatibility; please prefer to use `React.Event`. + +## ReactRoblox.Change +*Roact-only* + +Re-exports [`React.Change`](react.md#reactchange). This is only exposed for backwards compatibility; please prefer to use `React.Change`. + diff --git a/docs/api-reference/react.md b/docs/api-reference/react.md new file mode 100644 index 00000000..f3253baf --- /dev/null +++ b/docs/api-reference/react.md @@ -0,0 +1,868 @@ +# React + +!!! info + This page is intended to provide *brief* descriptions, examples, and notes about deviations in behavior or API. For complete feature documentation of upstream-aligned features, follow the `View React Docs` link and consult the React JS docs. + +The `React` package is the entry point for most React logic and behavior. Most of its API members align directly to their equivalents in [React JS](https://reactjs.org/docs/react-api.html). Some API members have slightly different behavior, different guidance around their usage, or slightly different function signatures to better accommodate Luau functionality and idioms. + +### API Deviations + +#### Not Supported +The following API members are notable absences relative to React JS 17.0.1: + +* `React.createFactory` - Considered legacy and will likely not be included +* `React.useDebugValue` - Not yet implemented + +Additionally, the following APIs are implemented, but have blocking issues that prevent them from being fully supported or usable: + +* [`React.lazy`](#reactlazy) +* [`React.Suspense`](#reactsuspense) + +#### Undocumented +The `React` packages includes some features that are undocumented in React JS 17.0.1, but are present in its implementation. The following API members may be less stable than the features that are fully documented upstream: + +* [`React.createMutableSource`](#reactcreatemutablesource) +* [`React.useMutableSource`](#reactusemutablesource) + +#### Unique to Roblox +`React` also exports a small collection of [Roact-exclusive APIs](#roblox-and-luau) which have no upstream equivalent. These are designed to facilitate better integration with the Luau language and the Roblox engine functionality. + +* [`React.None`](#reactnone) +* [`React.Event`](#reactevent) +* [`React.Change`](#reactchange) +* [`React.Tag`](#reacttag) +* [`React.useBinding`](#reactusebinding) +* [`React.createBinding`](#reactcreatebinding) +* [`React.joinBindings`](#reactjoinbindings) + +## React.Component +React API Change + +Luau does not have ES6's class semantics, so class components work slightly differently from React JS in the following ways: + +* Use `Component:extend` in place of ES6 class semantics +* Implement an `init` method on components instead of a constructor + +Roact 17 also includes the following deviations: + +* Instead of initializing component state by assigning a value to `self.state` in the `init` method, use `setState` as you would elsewhere (this is inherited from Legacy Roact for compatibility and consistency) +* Error boundaries are not fully supported in Roact 17 at this time due to a limitation in recursive `pcall` depth + +```lua +local MyComponent = React.Component:extend("MyComponent") + +function MyComponent:init() + self:setState({ expanded = true }) +end + +function MyComponent:render() + return React.createElement("TextButton", { + Text = if expanded + then self.props.text + else "Click to Expand", + Size = if expanded + then UDim2.new(1, 0, 0, 200) + else UDim2.new(1, 0, 0, 30), + [React.Event.Activated] = function() + self:setState(function(expanded) + return { expanded = not expanded } + end) + end + }) +end +``` + +Check the [deviations guide](../deviations.md#class-components) for more detailed information. + +## React.PureComponent +React API Change + +The same deviations to `React.Component` apply equivalently to `React.PureComponent`. Check the [deviations guide](../deviations.md#class-components) for more detailed information. + +## React.memo +React Aligned + +```lua +local MyComponent = React.memo(function MyComponent(props) + --[[ render using props ]] +end) +``` + +Guidance specified in the [React documentation](https://beta.reactjs.org/reference/react/memo#usage) applies for Roact as well. Use this only as a performance optimization, and only when relevant to the use case. + +## React.createElement +React Aligned + +While the `createElement` API is aligned to React JS, current tooling in Luau does not support an equivalent for JSX. When writing Roact code, you'll use `createElement` directly much more often than in React JS. + +```lua +--[[ +React.createElement( + type, + [props], + [...children] +) +]] +local element = React.createElement( + "Frame", + { Size = UDim2.fromScale(1, 1) }, + React.createElement("TextLabel", { Text = "Child1" }) + React.createElement("TextLabel", { Text = "Child2" }) +) +``` + +## React.cloneElement +React Aligned + +Clone an existing element, using its provided config values (props, `ref`, and `key`) as a starting point. + +```lua +local onlyChild = React.Children.only(props.children) +local cloned = React.cloneElement(onlyChild, { Text = "Cloned" }) +``` + +## React.isValidElement +React Aligned + +Returns `true` if the provided value is a React element; otherwise, returns `false`. + +```lua +if React.isValidElement(props.frameContents) then + return React.createElement("Frame", { Size = UDim2.fromScale(1, 1) }, props.frameContents) +end +``` + +## React.Children +React Deviation + +!!! warning + This field can often be confused with a Legacy Roact API ca lled `Roact.Children`. In the legacy API, `Roact.Children` was a special opaque prop key that could be used to pass children through props tables. In Roact 17, the `children` string key is reserved and used instead. Learn more about how to migrate away from `Roact.Children` in the [migration guide](../migrating-from-1x/convert-legacy-conventions.md#reserved-keys). + +A collection of utilities for manipulating the children provided to a React component via `props.children` or `self.props.children`. The `children` property should always be used as an opaque data structure; manipulate it via `React.Children` utilities rather than accessing it directly. + +There are a few notable deviations: + +* React Children values with type "userdata" will be treated as nil in callbacks. This means that a `React.None` child passed to `React.Children.forEach` or `React.Children.map` will be treated the same as a nil value or boolean in the callbacks. `React.Children.count` will not include userdata children in the count. +* The `context` argument for mapChildren is not passed to the callback. This is typically used to pass `this` in javascript, but does not have an equivalent in lua. +* React Children utilities work with tables with string keys as well as arrays + + +### React.Children.map +React Aligned + +Use `React.Children.map` to transform each member child of the `children` property. + +```lua +local ListItem = require(script.Parent.ListItem) + +local function OrderedList(props) + return React.createElement( + "Frame", + { Size = UDim2.fromScale(1, 1) }, + React.Children.map(props.children, function(child, i) + return React.createElement(ListItem, { layoutOrder = i }, child) + end) + ) +end +``` + +### React.Children.forEach +React Aligned + +Accepts a function that will be called with each member child of the `children` property. Similar to `React.Children.map`, but the provided callback does not return a new child. + +```lua +local ListItem = require(script.Parent.ListItem) +local Separator = require(script.Parent.Separator) + +local function SeparatorList(props) + local listItems = {} + + React.Children.forEach(props.children, function(child, i) + table.insert(listItems, React.createElement(ListItem, { layoutOrder = i*2 }, child)) + table.insert(listItems, React.createElement(Separator, { layoutOrder = i*2 + 1 })) + end) + + return React.createElement( + "Frame", + { Size = UDim2.fromScale(1, 1) }, + listItems + ) +end +``` + +### React.Children.count +React Aligned + +Returns the number of child members in the provided `children` object. + +```lua +local function List(props) + print(string.format("Render list with %d children", React.Children.count(props.children))) + + return React.createElement( + "Frame", + { Size = UDim2.fromScale(1, 1) }, + props.children, + ) +end +``` + +### React.Children.only +React Aligned + +Asserts that the provided object is a single React element. If the provided object is an array or table of elements, this function will throw; otherwise, it returns the element that was passed into it. + +```lua +local function Box(props) + local element = React.Children.only(props.children) + + return React.createElement( + "Frame", + { Size = UDim2.fromOffset(100, 100) }, + element, + ) +end +``` + +### React.Children.toArray +React Aligned + +Converts the opaque `children` prop into an array. The resulting value can then be manipulated with typical Lua array semantics or using utility methods that operate on array-like Lua tables. + +```lua +local Array = require(Packages.Collections).Array + +local function FilteredList(props) + local children = React.Children.toArray(props.children) + + return React.createElement( + "Frame", + { Size = UDim2.fromScale(1, 1) }, + Array.filter(children, props.filterFn), + ) +end +``` + +## React.Fragment +React Aligned + +Renders multiple components using a special component called a `Fragment`. Fragments are useful for returning multiple elements from a single component without creating additional Instances in the DataModel. + +```lua +function MyComponent:render() + return React.createElement( + React.Fragment, + nil, + React.createElement("TextLabel", { Text = "Some text." }), + React.createElement("TextButton", { Text = "Button" }), + ) +end +``` + +## React.createRef +React Aligned + +Creates a Ref object which can be used to access underlying Instances in the DataModel. Use this as an escape hatch when you need to interact directly with Roblox Instances. + +```lua +local MyComponent = React.Component:extend("MyComponent") + +function MyComponent:init() + self.inputRef = React.createRef() +end + +function MyComponent:render() + return React.createElement("TextBox", { ref = self.inputRef }) +end + +function MyComponent:componentDidMount() + self.inputRef.current:CaptureFocus() +end +``` + +## React.forwardRef +React Aligned + +In some cases, you may want your component to accept a ref and forward it to a child, typically a Roblox host component. + +`React.forwardRef` accepts a render function that receives `props` (just like a function component) as well as a `ref` object, and can then assign its ref within the render callback. + +```lua +local FancyButton = React.forwardRef(function(props, ref) + return React.createElement("TextButton", { + ref = ref, + Text = props.text, + [React.Event.Activated] = props.onActivated, + }, props.children) +end) + +-- You can now get a ref directly to the TextButton Instance: +local ref = React.createRef() +React.createElement(FancyButton, { ref = ref, text = "Click me!" }) +``` + +## React.createContext +React Aligned + +Creates a Context object. Context is a mechanism for passing information through many levels of the React component tree without needing to explicitly pass it down at each level. + +```lua +local MyContext = React.createContext(defaultValue) +``` + +### Context.Provider +React Aligned + +The Context object returned from `createContext` includes a `Provider` component, which can be used to pass a context value into the component tree. + +```lua +React.createElement(MyContext.Provider, { value = someValue }) +``` + +### Context.Consumer +React Aligned + +The Context object returned from `createContext` includes a `Consumer` component, which can be used to retrieve a context value that was provided higher up in the tree. + +```lua +React.createElement(MyContext.Consumer, nil, function(value) + -- render something based on the context value +end) +``` + +## React.lazy +React Aligned + +!!! warning + Though `React.lazy`'s functionality is implemented, it may not be especially useful without complete support for [`React.Suspense`](#reactsuspense). + +!!! warning + The `lazy` utility is designed an ecosystem where loading modules can be expensive. **This is rarely a concern in the context of Luau projects.** Since `lazy` relies upon a dynamic call to `require`, tooling like type checking may not work correctly. Use `React.lazy` with caution and intent, and only if it has a measurable performance impact. + +Wraps a component in a lazy-loading container that waits until the component is used in a render before calling the function to load it. + +```lua +local Component = React.lazy(function() + return require(script.Parent.Component) +end) +``` + + +## React.Suspense +React Deviation + +!!! warning + **While `React.Suspense` is technically implemented, it should be considered unusable as of version `17.0.1`.** This is due to a limitation in Luau around recursive `pcall` depth. Future updates to React will unravel the recursive traversal and enable these features. + Future version of React eliminate this recursive structure and should enable full use of `Suspense` and error boundaries. + +Renders a subtree of children that can yield. If the children suspend while rendering, the `fallback` field provided will be used until the children have resolved their render. + +```lua +React.createElement( + React.Suspense, + { + fallback = React.createElement("TextLabel", { + Text = "Loading...", + }) + }, + React.createElement(SomeContents) +) +``` + +## Hooks + +Hooks allow simple function components to introduce stateful behaviors without incurring the overhead or complexity of the full [Component](#reactcomponent) lifecycle. + +### React.useState +React API Change + +`useState` is a React Hook that creates a state variable that can be used and updated by your component. Updating the state will cause the component to re-render. + +```lua +local function ClickCounter(props) + local count, setCount = React.useState(0) + + return React.createElement("TextButton", { + Text = string.format("Clicked %d times", count) + -- Clicking the button updates the count, which re-renders the component + [React.Event.Activated] = function() + setCount(count + 1) + end + }) +end +``` + +Note that Luau does not have syntactic sugar for destructuring like javascript: +```js +const [value, setValue] = useState(0); +``` +However, it _does_ support multiple return values, so Roact 17 supports a very similar usage: +```lua +local value, setValue = useState(0) +``` + +### React.useEffect +React Aligned + +`useEffect` is a React Hook that causes a side effect to run after a component's render has completed, allowing synchronization with external systems. + +```lua +local UserInputService = game:GetService("UserInputService") +local LastInputTypeChanged = UserInputService.LastInputTypeChanged + +local function LastInputLabel(props) + local lastInput, setLastInput = React.useState(Enum.UserInputType.None) + + React.useEffect(function() + -- Update `lastInput` state when the engine signal fires + local connection = LastInputTypeChanged:Connect(setLastInput) + + return function() + connection:Disconnect() + end + end) + + return React.createElement("TextLabel", { + Text = string.format("Last input: %s", lastInput) + }) +end +``` + +### React.useContext +React Aligned + +`useContext` is a React Hook that allows a component to read from and subscribe to context values provided from [`createContext`](#reactcreatecontext). + +```lua +local ThemeContext = require(...) + +local function ThemedLabel(props) + local theme = React.useContext(ThemeContext) + + return React.createElement("TextLabel", { + TextSize = theme.TextSize, + TextColor3 = theme.TextColor3, + -- ... + }) +end +``` + +### React.useReducer +React Aligned + +`useReducer` is a React Hook that introduces a reducer function to your component for managing complex state. See the [`Rodux` documentation](https://roblox.github.io/rodux/introduction/reducers/) for detailed examples of the reducer pattern in Lua. + +```lua +local function reducer(state, action) + if action.type == "add" then + return { + value = state.value + action.value, + ops = state.ops + 1, + } + elseif action.type == "multiply" then + return { + value = state.value * action.value, + ops = state.ops + 1, + } + end +end + +local function Calculator(props) + local state, dispatch = React.useReducer(reducer, { value = 1, ops = 0 }) + + return React.createElement(React.Fragment, nil, + React.createElement("TextLabel", { + Text = string.format("value: %d - ops: %d", state.value, state.ops) + }) + React.createElement("TextButton", { + Text = "Add 4", + [React.Event.Activated] = function() + dispatch({ type = "add", value = 4 }) + end + }) + React.createElement("TextButton", { + Text = "Multiply by 3", + [React.Event.Activated] = function() + dispatch({ type = "multiply", value = 3 }) + end + }) + ) +end +``` + +Just like the `useState` hook, we use Luau's multiple returns to approximate JavaScript's destructuring. The following JS: +```js +const [state, dispatch] = useReducer(reducer, initialState); +``` +However, it _does_ support multiple return values, so Roact 17 supports a very similar usage: +```lua +local state, dispatch = useReducer(reducer, initialState) +``` + +### React.useCallback +React Aligned + +`useCallback` is a React Hook that allows a component to cache a function definition between re-renders. + +```lua +local ItemTile = require(...) +local equipItem = require(...) + +local function EquippableItemTile(props) + local onClick = React.useCallback(function() + equipItem(props.itemId) + end, { props.itemId }) + + return React.createElement(ItemTile, { + text = props.itemName, + onTileClicked = onClick, + }) +end +``` + +### React.useMemo +React Aligned + +`useMemo` is a React Hook that caches a computed value between re-renders. + +```lua +local function filterByCategory(todos, category) + -- ... + return filteredTodos +end + +local function FilteredTodoList(props) + local filteredTodos = React.useMemo(function() + return filterByCategory(props.todos, props.selectedCategory) + end, { props.todos, props.selectedCategory }) + + return React.createElement(TodoList, { + items = filteredTodos, + }) +end +``` + +### React.useRef +React Aligned + +`useRef` is a React Hook that stores a value that's not needed for rendering. In other words, refs are arbitrary value containers that do no invoke re-renders when they change. + +Refs can be used for a variety of purposes, including assigning them to the `ref` field of an element to get a reference to the underlying Roblox `Instance`. + +```lua +local function TextBoxWithButton(props) + local textBoxRef = React.useRef(nil) + + return React.createElement(React.Fragment, nil, + React.createElement("TextBox", { ref = textBoxRef }), + React.createElement("TextButton", { + Text = "->", + [React.Event.Activated] = function() + textBoxRef.current:CaptureFocus() + end + }), + ) +end +``` + +### React.useImperativeHandle +React Aligned + +`useImperativeHandle` is a React Hook that provides a way to customize a handle that's exposed as a ref. + +This allows a ref to be defined around a narrower or more convenient interface rather than a direct reference to the underlying `Instance`, and can be used to expose a customized ref interface from a function or class component. + +```lua +local ControllableScrollingFrame = React.forwardRef(function(props, ref) + local innerRef = React.useRef(nil) + + React.useImperativeHandle(ref, function() + return { + scrollToTop = function() + innerRef.current.CanvasPosition = Vector2.new(0, 0) + end + } + end, {}) + + return React.createElement("ScrollingFrame", { + ref = innerRef + -- ... + }) +end) + +-- Another component will be able to access this interface via a ref: +local function TodoListApp(props) + local scrollingFrameRef = React.useRef(nil) + + return React.createElement(ControllableScrollingFrame, + { + ref = scrollingFrameRef + }, + React.createElement(TodoList, { + items = props.todoItems, + }), + React.createElement("TextButton", { + Text = "Back to top", + [React.Event.Activated] = function() + scrollingFrameRef.current.scrollToTop() + end + }) + ) +end +``` + +### React.useLayoutEffect +React Aligned + +`useLayoutEffect` is an alternative version of `useEffect` that fires after the component runs its render function, but before the Roblox `Instance` hierarchy is reconciled with the new properties. + +!!! caution + Use this hook sparingly. While `useEffect` runs asynchronously after a render operation is completed, `useLayoutEffect` runs _during_ an update in between rendering and reconciliation: this can introduce performance issues if used carelessly. + + In React JS, `useLayoutEffect` is most often used to measure and position elements before the browser repaints them. It's not yet clear how this use case translates to roblox usage, and this hook should be used with caution until performance is investigated in detail and best practices emerge. + +### React.useBinding +Roblox + +A hook introduced in Roact that creates a `Binding` object. Bindings are isolated containers that automatically update. Creates and returns a binding and its associated updater function as multiple return values, [similar to `useState`](#reactusestate). + +```lua +local function DisplaysSize(props) + local absSize, setAbsSize = React.useBinding(Vector2.new(0, 0)) + return React.createElement(React.Fragment, nil, + React.createElement("ImageLabel", { + Image = props.image, + [React.Change.AbsoluteSize] = function(rbx) + setAbsSize(rbx.AbsoluteSize) + end, + }), + React.createElement("TextLabel", { + Text = absSize:map(function(value) + return "X = " .. tostring(value.X) .. "; Y = " .. tostring(value.Y) + end) + } + ) +end +``` + +A `Binding` has the following API: + +#### getValue + +!!! warning + Using `getValue` inside a component's `render` method is likely to result in using stale values! Using the unwrapped value directly won't allow Roact to subscribe to a binding's updates. To guarantee that a bound value will update, use the binding itself for your prop value or use the `map` method to map the value to a new one. + +Returns the internal value of the binding. This is helpful when updating a binding relative to its current value. + +```lua +local function UpdateOnClick() + local count, setCount = React.useBinding(0) + + return React.createElement("TextButton", { + Text = count, + [React.Event.Activated] = function() + setCount(count:getValue() + 1) + end, + }) +end +``` + +#### map + +Returns a new binding that maps the existing binding's value to something else. For example, `map` can be used to transform an animation progress value like `0.4` into a property that can be consumed by a Roblox Instance like `UDim2.new(0.4, 0, 1, 0)`. + +In function components, the `useBinding` hook provides equivalent functionality, and guarantees that it will return the same binding and updater objects on subsequent calls (just like `useRef` does). + +```lua +local function UpdateOnClick() + local count, setCount = React.useBinding(0) + + return React.createElement("TextButton", { + Text = count:map(function(value) + return string.format("Clicked %d times", value) + end), + [React.Event.Activated] = function() + setCount(count:getValue() + 1) + end, + }) +end +``` + +## React.Profiler +Refer to [React Profiler API documentation](https://beta.reactjs.org/reference/react/Profiler). + +## React.StrictMode +Refer to [React StrictMode API documentation](https://beta.reactjs.org/reference/react/StrictMode). + +## React.createMutableSource +Refer to [relevant React RFC](https://github.com/reactjs/rfcs/pull/147). + +## React.useMutableSource +Refer to [relevant React RFC](https://github.com/reactjs/rfcs/pull/147). + +## Roblox and Luau + +The React package shipped from Roact includes certain APIs designed specifically to account for nuances of the Luau language and to more easily leverage Roblox engine features. + +These APIs are unique to Roact and do not have equivalents in React JS. + +### React.None +Roblox + +A placeholder value that can be used to remove fields from a table (by changing the value to nil) when merging tables. This allows state fields to be nil-able despite lua treating table fields with `nil` values as semantically equivalent to absent fields. + +`React.None` can be used to remove values from React class component state via these uses: + +* When returning a table from the updater function passed to a class component's [`setState`]() method + ```lua + self:setState(function(_prevState) + return { myStateValue = React.None } + end) + ``` +* When passing a table directly to a class component's `setState` method + ```lua + self:setState({ myStateValue = React.None }) + ``` +* When returning a table from a component's `getDerivedStateFromProps` implementation + ```lua + function MyComponent.getDerivedStateFromProps(props, state) + return { + value = if props.someCondition + then state.value + else React.None + } + end + ``` + +!!! caution + `React.None` should be used sparingly; component state fields can generally be expressed more clearly with enumerated values or reasonable defaults than with nil-able values. + + Additionally, `React.None` is not intended to be used as a prop value, and may be reverted to nil by internal React logic in some cases if it's provided as one. + +### React.Event +Roblox + +A special key that can be used to interact with [events on Roblox Instance objects](https://developer.roblox.com/en-us/api-reference/function/Instance/GetPropertyChangedSignal). Index into `Roact.Event` with the name of an Event that's defined for the given host Instance class in order to get a special prop key that connects to that event: + +```lua +Roact.createElement("ImageButton", { + [React.Event.MouseButton1Click] = function(rbx, x, y) + print(rbx, "clicked at position", x, y) + end, +}) +``` + +The event connection will be automatically created when the host element is mounted and automatically disconnected when the element is unmounted. + +!!! info + Event callbacks receive the Roblox Instance as the first parameter, followed by any parameters yielded by the event. + +### React.Change +Roblox + +A special key that can be used to interact with the [`GetPropertyChangedSignal` method on Roblox Instance objects](https://developer.roblox.com/en-us/api-reference/function/Instance/GetPropertyChangedSignal). Index into `Roact.Change` with the name of the property to get a special prop key that connects to the change signal of an Instance with that property: + +```lua +Roact.createElement("ScrollingFrame", { + [React.Change.CanvasPosition] = function(rbx) + print("ScrollingFrame scrolled to", rbx.CanvasPosition) + end, +}) +``` + +The change signal connection will be automatically created when the host element is mounted and automatically disconnected when the element is unmounted. + +### React.Tag +Roblox + +```lua +local button = Roact.createElement("TextButton", { + [React.Tag] = "confirm-button" + Text = "Confirm", + -- ... +}) +``` + +A special key that can be used to apply [`CollectionService`](https://developer.roblox.com/en-us/api-reference/class/CollectionService) tags to a host component. + +Multiple tags can be provided as a single space-delimited string. For example: +```lua +[React.Tag] = "some-tag some-other-tag" +``` +will apply "some-tag" and "some-other-tag" as `CollectionService` tags to the underlying Roblox Instance when the component is mounted. + +### React.createBinding +Roblox + +Creates a binding object. This function can be used outside of React function components and works similarly to [`useBinding`](#reactusebinding). + +Use `createBinding` when you'd like to use a binding within a class component: + +```lua +local DisplaysSize = React.Component:extend("DisplaysSize") + +function DisplaysSize:init() + self.absSize, self.setAbsSize = React.createBinding(Vector2.new(0, 0)) +end + +function DisplaysSize:render() + return React.createElement(React.Fragment, nil, + React.createElement("ImageLabel", { + Image = self.props.image, + [React.Change.AbsoluteSize] = function(rbx) + self.setAbsSize(rbx.AbsoluteSize) + end, + }), + React.createElement("TextLabel", { + Text = self.absSize:map(function(value) + return "X = " .. tostring(value.X) .. "; Y = " .. tostring(value.Y) + end) + } + ) +end +``` + +### React.joinBindings +Roblox + +Combines a map or array of bindings into a single binding. The new binding's value will have the same keys as the input table of bindings. Each time any of the input bindings update, the resulting binding will update as well with a new value. + +This capability can be used to create a single binding value that responds to changes from several input bindings: +```lua +local function Flex() + local aSize, setASize = React.useBinding(Vector2.new()) + local bSize, setBSize = React.useBinding(Vector2.new()) + + return React.createElement( + "Frame", + { + Size = React.joinBindings({aSize, bSize}):map(function(sizes) + local sum = Vector2.new() + + for _, size in sizes do + sum += size + end + + return UDim2.new(0, sum.X, 0, sum.Y) + end), + }, + React.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 30), + [React.Change.AbsoluteSize] = function(instance) + setASize(instance.Size) + end, + }), + React.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 30), + Position = aSize:map(function(size) + return UDim2.new(0, 0, 0, size.Y) + end), + [React.Change.AbsoluteSize] = function(instance) + setBSize(instance.Size) + end, + }) + ) +end +``` + diff --git a/docs/api-reference/roact-compat.md b/docs/api-reference/roact-compat.md new file mode 100644 index 00000000..b23b3b1b --- /dev/null +++ b/docs/api-reference/roact-compat.md @@ -0,0 +1,151 @@ +# RoactCompat + +The `RoactCompat` package is designed to have the same interface as [legacy Roact](https://roblox.github.io/roact/api-reference/). This should allow easier adoption of Roact 17 in existing Roact code. + +`RoactCompat` is fully compatible with all Roact 17 logic. In particular, you may wish to use `RoactCompat` in combination with the [`React`](react.md#react) and [`ReactRoblox`](react-roblox.md#reactroblox) packages that provide the new interface. + +!!! caution + `RoactCompat` is **not** compatible with legacy Roact in any way. It should be used only as a drop-in replacement for legacy Roact, for the purposes of upgrading existing projects written for legacy Roact. + +## RoactCompat.Component +Re-exports [React.Component](react.md#ReactComponent). + +## RoactCompat.PureComponent +Re-exports [React.PureComponent](react.md#ReactPureComponent). + +## RoactCompat.createElement +Re-exports [React.createElement](react.md#ReactcreateElement). + +## RoactCompat.createContext +Re-exports [React.createContext](react.md#ReactcreateContext). + +## RoactCompat.createRef +Re-exports [React.createRef](react.md#ReactcreateRef). + +## RoactCompat.forwardRef +Re-exports [React.forwardRef](react.md#ReactforwardRef). + +## RoactCompat.mount + +``` +RoactCompat.mount( + element: ReactElement, + container: Instance?, + name: string? +): RoactTree +``` +Compatibility method mimicking [legacy `Roact.mount`](https://roblox.github.io/roact/api-reference/#roactmount). + +For all intents and purposes, this should function equivalently to legacy Roact's `mount` function. Under the hood, RoactCompat takes the following steps: + +1. Creates a root using [`React.createRoot`](react.md#reactcreateroot) + * When `_G.__ROACT_17_COMPAT_LEGACY_ROOT__` is enabled, this will use [`React.createLegacyRoot`](react.md#reactcreatelegacyroot) instead +2. Calls `root:render` with the provided element + * React's roots take complete control of the provided container, deleting all existing children. Legacy Roact does not tamper with existing children of the provided container. To mimic the legacy behavior, we use a [`Portal`](react.md#reactcreateportal) to mount into the container instead of providing it directly to the root. + * When `_G.__ROACT_17_INLINE_ACT__` is enabled, the `render` call is automatically wrapped in [`ReactRoblox.act`](react-roblox.md#reactrobloxact) to ensure that mounting behavior resolves synchronously in tests. +3. Returns an opaque handle to the root that can be used with [`RoactCompat.update`](#roactcompatupdate) and [`RoactCompat.unmount`](#roactcompatunmount) + +## RoactCompat.update + +``` +RoactCompat.update(tree: RoactTree, element: ReactElement): RoactTree +``` +Compatibility method mimicking [legacy `Roact.update`](https://roblox.github.io/roact/api-reference/#roactupdate). + +The first argument should be the value returned from a prior call to [`RoactCompat.mount`](#roactcompatmount) or `RoactCompat.update`. This function will not work if the argument passed in was created with legacy Roact. + +## RoactCompat.unmount + +``` +RoactCompat.unmount(tree: RoactTreeHandle) +``` +Compatibility method mimicking [legacy `Roact.unmount`](https://roblox.github.io/roact/api-reference/#roactunmount). + +-- API compatibility layers to accommodate old interfaces +## RoactCompat.createFragment + +``` +RoactCompat.createFragment(elements: { [string | number]: ReactElement }): ReactElement +``` +Compatibility method mimicking [`Roact.createFragment`](https://roblox.github.io/roact/api-reference/#roactcreatefragment). Uses the special component [`React.Fragment`](react.md#reactfragment) under the hood. + +## RoactCompat.oneChild + +``` +RoactCompat.oneChild( + children: { [string | number]: ReactElement } | ReactElement | nil +): ReactElement +``` +Compatibility method mimicking [`Roact.oneChild`](https://roblox.github.io/roact/api-reference/#roactonechild). This function is similar to [`React.Children.only`](react.md#reactchildrenonly), but provides additional functionality to unwrap a table that may contain a single element. + +## RoactCompat.setGlobalConfig + +``` +RoactCompat.setGlobalConfig(configValues: { [string]: boolean }) +``` +Compatibility method mimicking [`Roact.setGlobalConfig`](https://roblox.github.io/roact/api-reference/#roactsetglobalconfig). **This does not apply to Roact 17, so calling this function is a no-op.** + +!!! info + If you need to apply global configuration to Roact 17, you can do so by setting global values [FIXME LINK TO CONFIGURATION DOC](../configuration.md) + +## RoactCompat.Portal + +Compatibility component mimicking [`Roact.Portal`](https://roblox.github.io/roact/api-reference/#roactportal). Uses the [React.createPortal](react.md#reactcreateportal) function under the hood. + +## RoactCompat.Ref + +Compatibility field that mimics the special symbol key [`Roact.Ref`](https://roblox.github.io/roact/api-reference/#roactref). In RoactCompat, the `Ref` field is simply equal to the string "ref", which is a reserved prop key in Roact 17. + +This allows prop tables that are written for legacy Roact: +```lua +Roact.createElement("TextLabel", { + Text = "Hello", + [Roact.Ref] = textLabelRef, +}) +``` +...to be equivalent to prop tables written for Roact 17, which uses "ref" as a reserved prop name: +```lua +Roact.createElement("TextLabel", { + Text = "Hello", + ref = textLabelRef, +}) +``` + +## RoactCompat.Children + +Compatibility field that mimics the special symbol key [`Roact.Children`](https://roblox.github.io/roact/api-reference/#roactchildren). In RoactCompat, the `Children` field is simply equal to the string "children", which is a reserved prop key in Roact 17. + +This allows prop tables that are written for legacy Roact: +```lua +-- forwards the children provided to this component +Roact.createElement("Frame", nil, self.props[Roact.Children]) +``` +...to be equivalent to prop tables written for Roact 17, which uses "children" as a reserved prop name: +```lua +-- forwards the children provided to this component +Roact.createElement("Frame", nil, self.props.children) +``` + +!!! caution + This is not to be confused with [`React.Children`](react.md#reactchildren), which is a set of utilities for transforming or interacting with sets of children passed to `createElement`. + +## RoactCompat.None + +Re-exports [React.None](react.md#reactnone). + +## RoactCompat.Event + +Re-exports [React.Event](react.md#reactevent). + +## RoactCompat.Change + +Re-exports [React.Change](react.md#reactchange). + +## RoactCompat.createBinding +Re-exports [ReactRoblox.createBinding](react.md#reactcreatebinding). + +## RoactCompat.joinBindings +Re-exports [ReactRoblox.joinBindings](react.md#reactjoinbindings). + +## RoactCompat.act +Re-exports [ReactRoblox.act](react-roblox.md#reactrobloxact). diff --git a/docs/bench.md b/docs/bench.md new file mode 100644 index 00000000..77425da0 --- /dev/null +++ b/docs/bench.md @@ -0,0 +1,21 @@ +# Benchmarks + + +
+ + + + diff --git a/docs/bench/data.json b/docs/bench/data.json new file mode 100644 index 00000000..331dd92d --- /dev/null +++ b/docs/bench/data.json @@ -0,0 +1,19857 @@ +{ + "lastUpdate": 1645033057748, + "repoUrl": "https://github.com/Roblox/roact-alignment", + "entries": { + "Concurrent Benchmark": [ + { + "commit": { + "author": { + "email": "37384169+ZoteTheMighty@users.noreply.github.com", + "name": "Paul Doyle", + "username": "ZoteTheMighty" + }, + "committer": { + "email": "noreply@github.com", + "name": "GitHub", + "username": "web-flow" + }, + "distinct": true, + "id": "d46e91e5737cb82041f6086f13eb27fa2766a2a1", + "message": "LUAFDN-461 - Fix forward ref in dev (#177)\n\nLUAFDN-461\r\n\r\nFixes an issue where forwardRef objects did not allow fields to be assigned to them, which will break certain use cases in the wild.", + "timestamp": "2021-08-16T13:26:12-07:00", + "tree_id": "993e932ceffdeee799c21dc5224dab05cd1a6087", + "url": "https://github.com/Roblox/roact-alignment/commit/d46e91e5737cb82041f6086f13eb27fa2766a2a1" + }, + "date": 1629145682083, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 61.5777, + "range": "±7.80%", + "unit": "ops/sec", + "extra": "617 samples\nroblox-cli version: ?" + }, + { + "name": "FrameRate#Δt", + "value": 22.7619, + "range": "±31.19%", + "unit": "ms/op", + "extra": "617 samples\nroblox-cli version: ?" + }, + { + "name": "FirstRender#Δt", + "value": 115.5126, + "range": "±4.92%", + "unit": "sec/op", + "extra": "200 samples\nroblox-cli version: ?" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f854288e5e7aa7f6f5bbfbbb962e8d28d672363f", + "message": "Calc bench on pr", + "timestamp": "2021-08-19T15:13:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/183/commits/f854288e5e7aa7f6f5bbfbbb962e8d28d672363f" + }, + "date": 1629723305117, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 61.9574, + "range": "±7.81%", + "unit": "ops/sec", + "extra": "613 samples\nroblox-cli version: 0.492.0.428906" + }, + { + "name": "FrameRate#Δt", + "value": 22.869, + "range": "±31.12%", + "unit": "ms/op", + "extra": "613 samples\nroblox-cli version: 0.492.0.428906" + }, + { + "name": "FirstRender#Δt", + "value": 114.1414, + "range": "±4.97%", + "unit": "sec/op", + "extra": "200 samples\nroblox-cli version: 0.492.0.428906" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b05ed983ec8a2bfb8183ac394532cdf26fd396c4", + "message": "LUAFDN-496 Wrong Instance Names in Array of Children", + "timestamp": "2021-09-01T22:45:36Z", + "url": "https://github.com/Roblox/roact-alignment/pull/187/commits/b05ed983ec8a2bfb8183ac394532cdf26fd396c4" + }, + "date": 1630616941031, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 61.7646, + "range": "±34.74%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.493.1.4930375" + }, + { + "name": "FrameRate#Δt", + "value": 22.6675, + "range": "±226.93%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.493.1.4930375" + }, + { + "name": "FirstRender#Δt", + "value": 120.3817, + "range": "±24.80%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.493.1.4930375" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0960cb8107c7599e71e7411d967065bc9fd8d9ad", + "message": "LUAFDN-540 Limit pcalls in recursivelyCommitLayoutEffects", + "timestamp": "2021-09-03T16:54:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/190/commits/0960cb8107c7599e71e7411d967065bc9fd8d9ad" + }, + "date": 1630696317433, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 62.784, + "range": "±40.74%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.494.0.4940337" + }, + { + "name": "FrameRate#Δt", + "value": 22.776, + "range": "±224.20%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.494.0.4940337" + }, + { + "name": "FirstRender#Δt", + "value": 106.46, + "range": "±21.91%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.494.0.4940337" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "89a9413bf48d6f22096a6be5458202c7d6eafb24", + "message": "LUAFDN-317 React devtools", + "timestamp": "2021-09-03T19:53:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/115/commits/89a9413bf48d6f22096a6be5458202c7d6eafb24" + }, + "date": 1630723698313, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 63.1118, + "range": "±37.70%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.494.0.4940339" + }, + { + "name": "FrameRate#Δt", + "value": 23.1141, + "range": "±228.12%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.494.0.4940339" + }, + { + "name": "FirstRender#Δt", + "value": 129.7819, + "range": "±27.30%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.494.0.4940339" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d316007283b3ec76aabda8cc9fc6e6f847afc937", + "message": "LUAFDN-537 and LUAFDN-499: fix string formatting in several error messages", + "timestamp": "2021-09-04T02:58:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/192/commits/d316007283b3ec76aabda8cc9fc6e6f847afc937" + }, + "date": 1631297857604, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 64.2736, + "range": "±43.25%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 22.5439, + "range": "±227.05%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FirstRender#Δt", + "value": 108.4224, + "range": "±20.38%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cdad9e14ed240e308df8ed82ea6913545d5c14da", + "message": "LUAFDN-56: Debug tracing API and tests", + "timestamp": "2021-09-10T18:47:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/143/commits/cdad9e14ed240e308df8ed82ea6913545d5c14da" + }, + "date": 1631306410178, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 63.1338, + "range": "±39.58%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 23.0241, + "range": "±228.75%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FirstRender#Δt", + "value": 126.4891, + "range": "±25.69%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81", + "message": "Switch back to setTimeout", + "timestamp": "2021-09-10T23:31:56Z", + "url": "https://github.com/Roblox/roact-alignment/pull/195/commits/f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81" + }, + "date": 1631648269921, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 63.3279, + "range": "±39.66%", + "unit": "ops/sec", + "extra": "603 samples\nroblox-cli version: 0.495.0.4950267" + }, + { + "name": "FrameRate#Δt", + "value": 22.7398, + "range": "±225.77%", + "unit": "ms/op", + "extra": "603 samples\nroblox-cli version: 0.495.0.4950267" + }, + { + "name": "FirstRender#Δt", + "value": 113.3065, + "range": "±30.29%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.495.0.4950267" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "47dcf2451a484650001d667ff4ee068f2254ccb9", + "message": "LUAFDN-58 Fix additional bugs/quality gaps in DevTools", + "timestamp": "2021-09-14T21:12:42Z", + "url": "https://github.com/Roblox/roact-alignment/pull/197/commits/47dcf2451a484650001d667ff4ee068f2254ccb9" + }, + "date": 1631844120515, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 62.555, + "range": "±37.55%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 23.2108, + "range": "±226.64%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FirstRender#Δt", + "value": 131.4866, + "range": "±24.38%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "59d9aee9aa9fbaa9573632fc4bf68072d81aae7e", + "message": "LUAFDN-538: use task library", + "timestamp": "2021-09-17T02:14:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/198/commits/59d9aee9aa9fbaa9573632fc4bf68072d81aae7e" + }, + "date": 1632256586311, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.3076, + "range": "±27.46%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 22.8944, + "range": "±222.92%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FirstRender#Δt", + "value": 83.2509, + "range": "±20.82%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c74e1b9e25531ce64bcfa805cbe5873f629f925a", + "message": "LUAFDN-607: Update MAX_PCALL_DEPTH usage", + "timestamp": "2021-09-21T22:08:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/201/commits/c74e1b9e25531ce64bcfa805cbe5873f629f925a" + }, + "date": 1632346013343, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.4422, + "range": "±30.02%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 23.177, + "range": "±219.83%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FirstRender#Δt", + "value": 92.2023, + "range": "±24.31%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "5fe5615d45e448a94e57006eb8b8ce6822d85c19", + "message": "#nojira - Upgrade polyfill to play nice with in lua-apps", + "timestamp": "2021-09-23T16:01:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/202/commits/5fe5615d45e448a94e57006eb8b8ce6822d85c19" + }, + "date": 1632425161298, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.8238, + "range": "±31.48%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 23.3239, + "range": "±221.02%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FirstRender#Δt", + "value": 95.9572, + "range": "±24.69%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "11f0938c4b71d4b3d1f11bf27333550cf4d105dd", + "message": "#nojira - Put warning behind dev", + "timestamp": "2021-09-23T19:52:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/203/commits/11f0938c4b71d4b3d1f11bf27333550cf4d105dd" + }, + "date": 1632456047799, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.0111, + "range": "±33.35%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 23.0055, + "range": "±223.38%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FirstRender#Δt", + "value": 84.7814, + "range": "±20.51%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "180e78d05c536fec1e9dee503db30a054db12af1", + "message": "#nojira - Update to rc.3", + "timestamp": "2021-09-24T04:08:39Z", + "url": "https://github.com/Roblox/roact-alignment/pull/204/commits/180e78d05c536fec1e9dee503db30a054db12af1" + }, + "date": 1632508694854, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.5149, + "range": "±26.66%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 22.6034, + "range": "±224.16%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FirstRender#Δt", + "value": 79.5571, + "range": "±19.77%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6b2b75ac88f3405cbe2892693c8284665c074881", + "message": "Sometimes instance names are incorrect", + "timestamp": "2021-09-24T18:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/209/commits/6b2b75ac88f3405cbe2892693c8284665c074881" + }, + "date": 1632769985376, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.3411, + "range": "±23.43%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970358" + }, + { + "name": "FrameRate#Δt", + "value": 21.6079, + "range": "±231.93%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970358" + }, + { + "name": "FirstRender#Δt", + "value": 72.1709, + "range": "±20.29%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970358" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "41a491696aa3933a35daf6c2b05903e3633c7d4c", + "message": "Made types compatible with Luau RFC recursive-type-restriction", + "timestamp": "2021-09-27T19:51:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/210/commits/41a491696aa3933a35daf6c2b05903e3633c7d4c" + }, + "date": 1632775888609, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.4029, + "range": "±26.20%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.214, + "range": "±226.69%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 74.1752, + "range": "±18.10%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8956b5705618418701bfd118411c4ac606e861b5", + "message": "LUAFDN-611: Fix hard runtime errors caused by multiple mistranslation bugs", + "timestamp": "2021-09-27T21:17:24Z", + "url": "https://github.com/Roblox/roact-alignment/pull/206/commits/8956b5705618418701bfd118411c4ac606e861b5" + }, + "date": 1632777759503, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.9119, + "range": "±32.99%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 23.6102, + "range": "±218.48%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 89.5044, + "range": "±24.70%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8d1d0941fffd01703c6ae30208eb3979fad7c870", + "message": "LUAFDN-610: Fix a mistranslation bug that results in a hard runtime error", + "timestamp": "2021-09-27T21:35:46Z", + "url": "https://github.com/Roblox/roact-alignment/pull/205/commits/8d1d0941fffd01703c6ae30208eb3979fad7c870" + }, + "date": 1632779124827, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.2772, + "range": "±23.24%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 21.9207, + "range": "±230.29%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 76.0529, + "range": "±23.19%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a45b813eba458f0757f081ca36c805c950628cd8", + "message": "LUAFDN-247: Type public API more thoroughly", + "timestamp": "2021-09-27T21:51:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/211/commits/a45b813eba458f0757f081ca36c805c950628cd8" + }, + "date": 1632785097165, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.2933, + "range": "±29.34%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.2876, + "range": "±226.82%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 82.2506, + "range": "±16.67%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6a24d389ce9e63933d437e3ee75ce6431db8959b", + "message": "Switch promise dependency to internal one", + "timestamp": "2021-09-27T23:52:53Z", + "url": "https://github.com/Roblox/roact-alignment/pull/212/commits/6a24d389ce9e63933d437e3ee75ce6431db8959b" + }, + "date": 1632798146238, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.1224, + "range": "±22.29%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.0294, + "range": "±228.31%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 77.4657, + "range": "±18.58%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "e7a58f4cfcc4a682fe9479c590b1455ca37c509d", + "message": "LUAFDN-498 - Better error output for bad prop assignments", + "timestamp": "2021-09-28T06:02:25Z", + "url": "https://github.com/Roblox/roact-alignment/pull/200/commits/e7a58f4cfcc4a682fe9479c590b1455ca37c509d" + }, + "date": 1632872858159, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.1674, + "range": "±23.13%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.4917, + "range": "±225.61%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 82.029, + "range": "±21.99%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c645f97ebb36682bb7e8d33d1b22a08b536ca18a", + "message": "#nojira Cleanup pragmas, add strict mode where reasonable, fix a bug", + "timestamp": "2021-09-29T00:15:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/213/commits/c645f97ebb36682bb7e8d33d1b22a08b536ca18a" + }, + "date": 1632878703882, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.8072, + "range": "±28.84%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.7196, + "range": "±222.41%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 78.1392, + "range": "±19.59%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "737e2a29452e082b7398f2f4f561675309147df2", + "message": "LUAFDN-622 - Export `act` from the top level; enable it behind tests", + "timestamp": "2021-09-29T17:33:55Z", + "url": "https://github.com/Roblox/roact-alignment/pull/214/commits/737e2a29452e082b7398f2f4f561675309147df2" + }, + "date": 1632961626411, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.386, + "range": "±26.79%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.7327, + "range": "±223.32%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 87.9777, + "range": "±20.54%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1fbcbe16f83b5ee7a654c2ea835ceb7674722e32", + "message": "LUAFDN-58: Devtools quality improvements", + "timestamp": "2021-09-30T00:34:28Z", + "url": "https://github.com/Roblox/roact-alignment/pull/215/commits/1fbcbe16f83b5ee7a654c2ea835ceb7674722e32" + }, + "date": 1632964058263, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.6201, + "range": "±32.38%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 23.5182, + "range": "±220.83%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 100.5898, + "range": "±25.34%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1a62c42d4dbb37c387e9647475a8f6bec33b2432", + "message": "LUAFDN-58: inject internals into dev tools if the global is present", + "timestamp": "2021-09-30T02:42:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/216/commits/1a62c42d4dbb37c387e9647475a8f6bec33b2432" + }, + "date": 1633034431611, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.0225, + "range": "±26.16%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 22.3322, + "range": "±224.11%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FirstRender#Δt", + "value": 65.8723, + "range": "±16.96%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "7edeedbcccb24cef1e506538d8df35ca2aedea45", + "message": "#nojira - Bump version", + "timestamp": "2021-09-30T20:42:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/217/commits/7edeedbcccb24cef1e506538d8df35ca2aedea45" + }, + "date": 1633037735861, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.6921, + "range": "±30.02%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 22.8155, + "range": "±222.39%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FirstRender#Δt", + "value": 76.6676, + "range": "±18.56%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b1224be94e4b5d679e8c950e597953f813bc3adb", + "message": "RIDE-5275 React devtools inspector integration", + "timestamp": "2021-09-30T21:49:35Z", + "url": "https://github.com/Roblox/roact-alignment/pull/185/commits/b1224be94e4b5d679e8c950e597953f813bc3adb" + }, + "date": 1633039266712, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.1644, + "range": "±29.61%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 23.466, + "range": "±220.69%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FirstRender#Δt", + "value": 95.8256, + "range": "±27.13%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b28927f22559f567348efe7f0c63157baedf6a16", + "message": "#nojira - Switch promise to proper semver dep", + "timestamp": "2021-09-30T22:07:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/218/commits/b28927f22559f567348efe7f0c63157baedf6a16" + }, + "date": 1633042757013, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.6507, + "range": "±27.84%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 22.7051, + "range": "±222.00%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FirstRender#Δt", + "value": 78.7269, + "range": "±20.88%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "3705df6efca1811e9cd025eaecabd2b49d034605", + "message": "#nojira - Strict mode seems to stumble sometimes when using the 'convert' command", + "timestamp": "2021-09-30T23:05:29Z", + "url": "https://github.com/Roblox/roact-alignment/pull/219/commits/3705df6efca1811e9cd025eaecabd2b49d034605" + }, + "date": 1633109547593, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.2945, + "range": "±24.61%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 22.64, + "range": "±224.22%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FirstRender#Δt", + "value": 80.3916, + "range": "±20.17%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b7a75292e97de978e822d69371f6f1a0435f6cf1", + "message": "Remove troublesome optimization", + "timestamp": "2021-10-01T17:39:47Z", + "url": "https://github.com/Roblox/roact-alignment/pull/221/commits/b7a75292e97de978e822d69371f6f1a0435f6cf1" + }, + "date": 1633134826563, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.4136, + "range": "±22.65%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 21.7897, + "range": "±228.82%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 67.0458, + "range": "±17.93%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b76de6b4be3953f6b4da77b36e6d0d4a030609bc", + "message": "Adopt jest roblox with rotriever 0.5", + "timestamp": "2021-10-02T00:38:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/181/commits/b76de6b4be3953f6b4da77b36e6d0d4a030609bc" + }, + "date": 1633358799030, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.1711, + "range": "±27.20%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.9264, + "range": "±222.88%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 88.7817, + "range": "±23.13%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "aeb440bbb8474f606c94dc50ff4342ee76da2e5f", + "message": "LUAFDN-642", + "timestamp": "2021-10-04T14:53:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/223/commits/aeb440bbb8474f606c94dc50ff4342ee76da2e5f" + }, + "date": 1633636747270, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.7906, + "range": "±22.30%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 21.7317, + "range": "±231.81%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 77.7831, + "range": "±22.76%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "63272527248f7791e9aa6c28f975a563c2743af9", + "message": "LUAFDN-637 Split compatibility layer warnings", + "timestamp": "2021-10-07T20:50:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/224/commits/63272527248f7791e9aa6c28f975a563c2743af9" + }, + "date": 1633715655019, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.9943, + "range": "±25.20%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.275, + "range": "±228.20%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 78.5566, + "range": "±24.52%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d77d7c6b84a4092ce2124bc2aa2ec77fe3693778", + "message": "#nojira Fix use*Effect create function type", + "timestamp": "2021-10-08T18:05:52Z", + "url": "https://github.com/Roblox/roact-alignment/pull/225/commits/d77d7c6b84a4092ce2124bc2aa2ec77fe3693778" + }, + "date": 1633719576259, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.3081, + "range": "±24.56%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.4806, + "range": "±224.80%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 84.0414, + "range": "±21.44%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "26008c76a91bb7eaa458b7ff756b20fd2f2a4522", + "message": "LUAFDN-580: Simple optimizations", + "timestamp": "2021-10-08T19:11:22Z", + "url": "https://github.com/Roblox/roact-alignment/pull/227/commits/26008c76a91bb7eaa458b7ff756b20fd2f2a4522" + }, + "date": 1633733655668, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.0893, + "range": "±22.02%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 21.5445, + "range": "±231.36%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 69.3066, + "range": "±19.82%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a7f90fc02f826dd5240e57c4b351486b64404eab", + "message": "#nojira another fix that Alan's in-development analyze feature turned up", + "timestamp": "2021-10-08T23:23:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/228/commits/a7f90fc02f826dd5240e57c4b351486b64404eab" + }, + "date": 1633738139886, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 56.9271, + "range": "±25.10%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.7, + "range": "±223.11%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 85.4012, + "range": "±20.57%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "fa677ba23ad2df4754d2721ae1fa2d2217973a8f", + "message": "#nojira - Bump version for rc.5", + "timestamp": "2021-10-09T00:15:48Z", + "url": "https://github.com/Roblox/roact-alignment/pull/226/commits/fa677ba23ad2df4754d2721ae1fa2d2217973a8f" + }, + "date": 1633738824210, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.0882, + "range": "±28.15%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.459, + "range": "±225.15%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 79.9764, + "range": "±17.91%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "bbbf7f1d1dcdaa33aebf99092913f23ee5829655", + "message": "robloxdev-cli convert can fail in strict mode", + "timestamp": "2021-10-09T01:49:01Z", + "url": "https://github.com/Roblox/roact-alignment/pull/229/commits/bbbf7f1d1dcdaa33aebf99092913f23ee5829655" + }, + "date": 1633747700921, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.4832, + "range": "±31.49%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 23.0788, + "range": "±220.69%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 87.3145, + "range": "±21.40%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "837d50d8d7f22fdcb28ec02b6e05919d2c50f057", + "message": "LUAFDN-623 - Use `act` in compat layer when the `__ROACT_17_INLINE_ACT__` global is enabled", + "timestamp": "2021-10-09T02:54:03Z", + "url": "https://github.com/Roblox/roact-alignment/pull/232/commits/837d50d8d7f22fdcb28ec02b6e05919d2c50f057" + }, + "date": 1634056127998, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.5657, + "range": "±28.59%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.8167, + "range": "±222.80%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 78.7666, + "range": "±16.77%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "733f87608ee9d26fe5d0e678a537b7840128a7a5", + "message": "LUAFDN-311: Use recycle pool for queue update objects", + "timestamp": "2021-10-12T16:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/231/commits/733f87608ee9d26fe5d0e678a537b7840128a7a5" + }, + "date": 1634078780090, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.7028, + "range": "±21.75%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 21.0232, + "range": "±237.38%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 63.6544, + "range": "±16.25%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "30a019de74755f03651f9d84ac961df1f7ce1d71", + "message": "#nojira - Add global for legacy root", + "timestamp": "2021-10-12T22:53:00Z", + "url": "https://github.com/Roblox/roact-alignment/pull/233/commits/30a019de74755f03651f9d84ac961df1f7ce1d71" + }, + "date": 1634083020761, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.6708, + "range": "±28.32%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.0282, + "range": "±227.77%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 74.7729, + "range": "±15.66%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "149598c92cfe72abc16c1b101903b1f65f95882b", + "message": "#nojira Work around new Luau recursive type error", + "timestamp": "2021-10-13T00:04:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/234/commits/149598c92cfe72abc16c1b101903b1f65f95882b" + }, + "date": 1634092703381, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.6676, + "range": "±23.49%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 22.3496, + "range": "±226.78%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 84.2389, + "range": "±21.01%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a3fe1e8dd69f257f26fc29f589ae094fdc660a5d", + "message": "#nojira - Comment out object pool warning for now", + "timestamp": "2021-10-13T15:42:15Z", + "url": "https://github.com/Roblox/roact-alignment/pull/237/commits/a3fe1e8dd69f257f26fc29f589ae094fdc660a5d" + }, + "date": 1634152384622, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 57.7006, + "range": "±31.22%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 23.277, + "range": "±221.41%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 96.6531, + "range": "±27.17%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9c07840d01844b4ac2e044c0e5905a407bb08286", + "message": "LUAFDN-311: Moar simple optimizations", + "timestamp": "2021-10-13T19:45:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/230/commits/9c07840d01844b4ac2e044c0e5905a407bb08286" + }, + "date": 1634154456645, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.2114, + "range": "±25.82%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 21.7995, + "range": "±230.40%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FirstRender#Δt", + "value": 72.3507, + "range": "±14.96%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "4742d67758a17082104fc2bebc5e864d7f7e5055", + "message": "LUAFDN-311: Optimization pass", + "timestamp": "2021-10-13T19:56:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/220/commits/4742d67758a17082104fc2bebc5e864d7f7e5055" + }, + "date": 1634161545349, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.0054, + "range": "±26.86%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 22.4348, + "range": "±225.70%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FirstRender#Δt", + "value": 75.0041, + "range": "±19.17%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cc4a52d1082b6b9dba96db3ca6cd8a9d98640536", + "message": "#nojira Update rotriever.toml for rc.6", + "timestamp": "2021-10-13T21:46:59Z", + "url": "https://github.com/Roblox/roact-alignment/pull/235/commits/cc4a52d1082b6b9dba96db3ca6cd8a9d98640536" + }, + "date": 1634161824007, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.8127, + "range": "±22.95%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 21.5951, + "range": "±230.03%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FirstRender#Δt", + "value": 63.2549, + "range": "±15.35%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + } + ], + "Mount Deep Tree": [ + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f854288e5e7aa7f6f5bbfbbb962e8d28d672363f", + "message": "Calc bench on pr", + "timestamp": "2021-08-19T15:13:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/183/commits/f854288e5e7aa7f6f5bbfbbb962e8d28d672363f" + }, + "date": 1629723305910, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#Δt", + "value": 49.4806, + "range": "±13.85%", + "unit": "ms/op", + "extra": "49 samples\nroblox-cli version: 0.492.0.428906" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b05ed983ec8a2bfb8183ac394532cdf26fd396c4", + "message": "LUAFDN-496 Wrong Instance Names in Array of Children", + "timestamp": "2021-09-01T22:45:36Z", + "url": "https://github.com/Roblox/roact-alignment/pull/187/commits/b05ed983ec8a2bfb8183ac394532cdf26fd396c4" + }, + "date": 1630616941723, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.1426, + "range": "±13.01%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.493.1.4930375" + }, + { + "name": "FrameRate#Δt", + "value": 53.3119, + "range": "±16.90%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.493.1.4930375" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0960cb8107c7599e71e7411d967065bc9fd8d9ad", + "message": "LUAFDN-540 Limit pcalls in recursivelyCommitLayoutEffects", + "timestamp": "2021-09-03T16:54:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/190/commits/0960cb8107c7599e71e7411d967065bc9fd8d9ad" + }, + "date": 1630696318070, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 25.1691, + "range": "±10.22%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.494.0.4940337" + }, + { + "name": "FrameRate#Δt", + "value": 40.1717, + "range": "±10.99%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.494.0.4940337" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "89a9413bf48d6f22096a6be5458202c7d6eafb24", + "message": "LUAFDN-317 React devtools", + "timestamp": "2021-09-03T19:53:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/115/commits/89a9413bf48d6f22096a6be5458202c7d6eafb24" + }, + "date": 1630723698936, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.3849, + "range": "±9.78%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.494.0.4940339" + }, + { + "name": "FrameRate#Δt", + "value": 52.1111, + "range": "±10.81%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.494.0.4940339" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d316007283b3ec76aabda8cc9fc6e6f847afc937", + "message": "LUAFDN-537 and LUAFDN-499: fix string formatting in several error messages", + "timestamp": "2021-09-04T02:58:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/192/commits/d316007283b3ec76aabda8cc9fc6e6f847afc937" + }, + "date": 1631297858182, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 23.7843, + "range": "±8.75%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 42.4006, + "range": "±9.79%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cdad9e14ed240e308df8ed82ea6913545d5c14da", + "message": "LUAFDN-56: Debug tracing API and tests", + "timestamp": "2021-09-10T18:47:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/143/commits/cdad9e14ed240e308df8ed82ea6913545d5c14da" + }, + "date": 1631306410805, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.7784, + "range": "±8.18%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 53.6171, + "range": "±8.50%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81", + "message": "Switch back to setTimeout", + "timestamp": "2021-09-10T23:31:56Z", + "url": "https://github.com/Roblox/roact-alignment/pull/195/commits/f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81" + }, + "date": 1631648270586, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 23.0356, + "range": "±10.60%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950267" + }, + { + "name": "FrameRate#Δt", + "value": 44.0827, + "range": "±14.91%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.495.0.4950267" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "47dcf2451a484650001d667ff4ee068f2254ccb9", + "message": "LUAFDN-58 Fix additional bugs/quality gaps in DevTools", + "timestamp": "2021-09-14T21:12:42Z", + "url": "https://github.com/Roblox/roact-alignment/pull/197/commits/47dcf2451a484650001d667ff4ee068f2254ccb9" + }, + "date": 1631844121275, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.468, + "range": "±9.75%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 54.7131, + "range": "±10.79%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "59d9aee9aa9fbaa9573632fc4bf68072d81aae7e", + "message": "LUAFDN-538: use task library", + "timestamp": "2021-09-17T02:14:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/198/commits/59d9aee9aa9fbaa9573632fc4bf68072d81aae7e" + }, + "date": 1632256586980, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.3896, + "range": "±12.74%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 52.6942, + "range": "±17.94%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c74e1b9e25531ce64bcfa805cbe5873f629f925a", + "message": "LUAFDN-607: Update MAX_PCALL_DEPTH usage", + "timestamp": "2021-09-21T22:08:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/201/commits/c74e1b9e25531ce64bcfa805cbe5873f629f925a" + }, + "date": 1632346014121, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.1521, + "range": "±12.38%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 59.6752, + "range": "±19.26%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "5fe5615d45e448a94e57006eb8b8ce6822d85c19", + "message": "#nojira - Upgrade polyfill to play nice with in lua-apps", + "timestamp": "2021-09-23T16:01:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/202/commits/5fe5615d45e448a94e57006eb8b8ce6822d85c19" + }, + "date": 1632425162036, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.1504, + "range": "±12.25%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 59.4611, + "range": "±16.71%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "11f0938c4b71d4b3d1f11bf27333550cf4d105dd", + "message": "#nojira - Put warning behind dev", + "timestamp": "2021-09-23T19:52:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/203/commits/11f0938c4b71d4b3d1f11bf27333550cf4d105dd" + }, + "date": 1632456048508, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.217, + "range": "±13.76%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 56.4091, + "range": "±20.40%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "180e78d05c536fec1e9dee503db30a054db12af1", + "message": "#nojira - Update to rc.3", + "timestamp": "2021-09-24T04:08:39Z", + "url": "https://github.com/Roblox/roact-alignment/pull/204/commits/180e78d05c536fec1e9dee503db30a054db12af1" + }, + "date": 1632508695528, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.4541, + "range": "±14.33%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 52.7708, + "range": "±19.12%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6b2b75ac88f3405cbe2892693c8284665c074881", + "message": "Sometimes instance names are incorrect", + "timestamp": "2021-09-24T18:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/209/commits/6b2b75ac88f3405cbe2892693c8284665c074881" + }, + "date": 1632769985971, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 21.3806, + "range": "±13.61%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970358" + }, + { + "name": "FrameRate#Δt", + "value": 47.9499, + "range": "±18.91%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970358" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "41a491696aa3933a35daf6c2b05903e3633c7d4c", + "message": "Made types compatible with Luau RFC recursive-type-restriction", + "timestamp": "2021-09-27T19:51:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/210/commits/41a491696aa3933a35daf6c2b05903e3633c7d4c" + }, + "date": 1632775889165, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.195, + "range": "±11.81%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 50.4792, + "range": "±16.90%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8956b5705618418701bfd118411c4ac606e861b5", + "message": "LUAFDN-611: Fix hard runtime errors caused by multiple mistranslation bugs", + "timestamp": "2021-09-27T21:17:24Z", + "url": "https://github.com/Roblox/roact-alignment/pull/206/commits/8956b5705618418701bfd118411c4ac606e861b5" + }, + "date": 1632777760247, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.9105, + "range": "±14.42%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 57.5168, + "range": "±21.67%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8d1d0941fffd01703c6ae30208eb3979fad7c870", + "message": "LUAFDN-610: Fix a mistranslation bug that results in a hard runtime error", + "timestamp": "2021-09-27T21:35:46Z", + "url": "https://github.com/Roblox/roact-alignment/pull/205/commits/8d1d0941fffd01703c6ae30208eb3979fad7c870" + }, + "date": 1632779125481, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.2977, + "range": "±14.71%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 50.6438, + "range": "±19.90%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a45b813eba458f0757f081ca36c805c950628cd8", + "message": "LUAFDN-247: Type public API more thoroughly", + "timestamp": "2021-09-27T21:51:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/211/commits/a45b813eba458f0757f081ca36c805c950628cd8" + }, + "date": 1632785097712, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.1083, + "range": "±11.70%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 50.5223, + "range": "±13.91%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6a24d389ce9e63933d437e3ee75ce6431db8959b", + "message": "Switch promise dependency to internal one", + "timestamp": "2021-09-27T23:52:53Z", + "url": "https://github.com/Roblox/roact-alignment/pull/212/commits/6a24d389ce9e63933d437e3ee75ce6431db8959b" + }, + "date": 1632798146948, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.6813, + "range": "±12.21%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 51.789, + "range": "±16.08%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "e7a58f4cfcc4a682fe9479c590b1455ca37c509d", + "message": "LUAFDN-498 - Better error output for bad prop assignments", + "timestamp": "2021-09-28T06:02:25Z", + "url": "https://github.com/Roblox/roact-alignment/pull/200/commits/e7a58f4cfcc4a682fe9479c590b1455ca37c509d" + }, + "date": 1632872858812, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.8622, + "range": "±13.74%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 51.7234, + "range": "±20.49%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c645f97ebb36682bb7e8d33d1b22a08b536ca18a", + "message": "#nojira Cleanup pragmas, add strict mode where reasonable, fix a bug", + "timestamp": "2021-09-29T00:15:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/213/commits/c645f97ebb36682bb7e8d33d1b22a08b536ca18a" + }, + "date": 1632878704555, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.9107, + "range": "±12.77%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 51.3202, + "range": "±17.47%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "737e2a29452e082b7398f2f4f561675309147df2", + "message": "LUAFDN-622 - Export `act` from the top level; enable it behind tests", + "timestamp": "2021-09-29T17:33:55Z", + "url": "https://github.com/Roblox/roact-alignment/pull/214/commits/737e2a29452e082b7398f2f4f561675309147df2" + }, + "date": 1632961627152, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.2068, + "range": "±14.33%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 59.7636, + "range": "±20.60%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1fbcbe16f83b5ee7a654c2ea835ceb7674722e32", + "message": "LUAFDN-58: Devtools quality improvements", + "timestamp": "2021-09-30T00:34:28Z", + "url": "https://github.com/Roblox/roact-alignment/pull/215/commits/1fbcbe16f83b5ee7a654c2ea835ceb7674722e32" + }, + "date": 1632964058847, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.5272, + "range": "±15.53%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 66.6555, + "range": "±22.94%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1a62c42d4dbb37c387e9647475a8f6bec33b2432", + "message": "LUAFDN-58: inject internals into dev tools if the global is present", + "timestamp": "2021-09-30T02:42:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/216/commits/1a62c42d4dbb37c387e9647475a8f6bec33b2432" + }, + "date": 1633034432263, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 23.8607, + "range": "±14.01%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 43.0321, + "range": "±19.89%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "7edeedbcccb24cef1e506538d8df35ca2aedea45", + "message": "#nojira - Bump version", + "timestamp": "2021-09-30T20:42:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/217/commits/7edeedbcccb24cef1e506538d8df35ca2aedea45" + }, + "date": 1633037736411, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.793, + "range": "±12.31%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 51.6659, + "range": "±19.25%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b1224be94e4b5d679e8c950e597953f813bc3adb", + "message": "RIDE-5275 React devtools inspector integration", + "timestamp": "2021-09-30T21:49:35Z", + "url": "https://github.com/Roblox/roact-alignment/pull/185/commits/b1224be94e4b5d679e8c950e597953f813bc3adb" + }, + "date": 1633039267496, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.5457, + "range": "±13.06%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 62.0302, + "range": "±20.47%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b28927f22559f567348efe7f0c63157baedf6a16", + "message": "#nojira - Switch promise to proper semver dep", + "timestamp": "2021-09-30T22:07:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/218/commits/b28927f22559f567348efe7f0c63157baedf6a16" + }, + "date": 1633042757632, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.3586, + "range": "±12.60%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 52.9222, + "range": "±19.54%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "3705df6efca1811e9cd025eaecabd2b49d034605", + "message": "#nojira - Strict mode seems to stumble sometimes when using the 'convert' command", + "timestamp": "2021-09-30T23:05:29Z", + "url": "https://github.com/Roblox/roact-alignment/pull/219/commits/3705df6efca1811e9cd025eaecabd2b49d034605" + }, + "date": 1633109548286, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.918, + "range": "±15.36%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 54.5209, + "range": "±21.01%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b7a75292e97de978e822d69371f6f1a0435f6cf1", + "message": "Remove troublesome optimization", + "timestamp": "2021-10-01T17:39:47Z", + "url": "https://github.com/Roblox/roact-alignment/pull/221/commits/b7a75292e97de978e822d69371f6f1a0435f6cf1" + }, + "date": 1633134827136, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 22.9928, + "range": "±12.81%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 44.5838, + "range": "±19.99%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b76de6b4be3953f6b4da77b36e6d0d4a030609bc", + "message": "Adopt jest roblox with rotriever 0.5", + "timestamp": "2021-10-02T00:38:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/181/commits/b76de6b4be3953f6b4da77b36e6d0d4a030609bc" + }, + "date": 1633358799929, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.0961, + "range": "±11.85%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 56.2743, + "range": "±16.00%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "aeb440bbb8474f606c94dc50ff4342ee76da2e5f", + "message": "LUAFDN-642", + "timestamp": "2021-10-04T14:53:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/223/commits/aeb440bbb8474f606c94dc50ff4342ee76da2e5f" + }, + "date": 1633636748015, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.3527, + "range": "±13.56%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 50.2368, + "range": "±17.16%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "63272527248f7791e9aa6c28f975a563c2743af9", + "message": "LUAFDN-637 Split compatibility layer warnings", + "timestamp": "2021-10-07T20:50:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/224/commits/63272527248f7791e9aa6c28f975a563c2743af9" + }, + "date": 1633715655737, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 21.6928, + "range": "±12.42%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 46.984, + "range": "±15.84%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d77d7c6b84a4092ce2124bc2aa2ec77fe3693778", + "message": "#nojira Fix use*Effect create function type", + "timestamp": "2021-10-08T18:05:52Z", + "url": "https://github.com/Roblox/roact-alignment/pull/225/commits/d77d7c6b84a4092ce2124bc2aa2ec77fe3693778" + }, + "date": 1633719576923, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.7853, + "range": "±12.71%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 51.6291, + "range": "±17.59%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "26008c76a91bb7eaa458b7ff756b20fd2f2a4522", + "message": "LUAFDN-580: Simple optimizations", + "timestamp": "2021-10-08T19:11:22Z", + "url": "https://github.com/Roblox/roact-alignment/pull/227/commits/26008c76a91bb7eaa458b7ff756b20fd2f2a4522" + }, + "date": 1633733656383, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 22.9915, + "range": "±12.44%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 44.4146, + "range": "±17.56%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a7f90fc02f826dd5240e57c4b351486b64404eab", + "message": "#nojira another fix that Alan's in-development analyze feature turned up", + "timestamp": "2021-10-08T23:23:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/228/commits/a7f90fc02f826dd5240e57c4b351486b64404eab" + }, + "date": 1633738140518, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.3246, + "range": "±12.59%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 55.5537, + "range": "±14.60%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "fa677ba23ad2df4754d2721ae1fa2d2217973a8f", + "message": "#nojira - Bump version for rc.5", + "timestamp": "2021-10-09T00:15:48Z", + "url": "https://github.com/Roblox/roact-alignment/pull/226/commits/fa677ba23ad2df4754d2721ae1fa2d2217973a8f" + }, + "date": 1633738824824, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.4752, + "range": "±12.99%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 55.477, + "range": "±20.00%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "bbbf7f1d1dcdaa33aebf99092913f23ee5829655", + "message": "robloxdev-cli convert can fail in strict mode", + "timestamp": "2021-10-09T01:49:01Z", + "url": "https://github.com/Roblox/roact-alignment/pull/229/commits/bbbf7f1d1dcdaa33aebf99092913f23ee5829655" + }, + "date": 1633747701500, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.5626, + "range": "±11.59%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 57.8745, + "range": "±14.70%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "837d50d8d7f22fdcb28ec02b6e05919d2c50f057", + "message": "LUAFDN-623 - Use `act` in compat layer when the `__ROACT_17_INLINE_ACT__` global is enabled", + "timestamp": "2021-10-09T02:54:03Z", + "url": "https://github.com/Roblox/roact-alignment/pull/232/commits/837d50d8d7f22fdcb28ec02b6e05919d2c50f057" + }, + "date": 1634056128797, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.8421, + "range": "±10.95%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 51.1523, + "range": "±13.91%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "733f87608ee9d26fe5d0e678a537b7840128a7a5", + "message": "LUAFDN-311: Use recycle pool for queue update objects", + "timestamp": "2021-10-12T16:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/231/commits/733f87608ee9d26fe5d0e678a537b7840128a7a5" + }, + "date": 1634078780701, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 24.3762, + "range": "±12.65%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 41.848, + "range": "±16.50%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "30a019de74755f03651f9d84ac961df1f7ce1d71", + "message": "#nojira - Add global for legacy root", + "timestamp": "2021-10-12T22:53:00Z", + "url": "https://github.com/Roblox/roact-alignment/pull/233/commits/30a019de74755f03651f9d84ac961df1f7ce1d71" + }, + "date": 1634083021404, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.3447, + "range": "±13.20%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 53.025, + "range": "±19.84%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "149598c92cfe72abc16c1b101903b1f65f95882b", + "message": "#nojira Work around new Luau recursive type error", + "timestamp": "2021-10-13T00:04:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/234/commits/149598c92cfe72abc16c1b101903b1f65f95882b" + }, + "date": 1634092704038, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.953, + "range": "±14.16%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 54.2651, + "range": "±21.25%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a3fe1e8dd69f257f26fc29f589ae094fdc660a5d", + "message": "#nojira - Comment out object pool warning for now", + "timestamp": "2021-10-13T15:42:15Z", + "url": "https://github.com/Roblox/roact-alignment/pull/237/commits/a3fe1e8dd69f257f26fc29f589ae094fdc660a5d" + }, + "date": 1634152385433, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.6561, + "range": "±12.30%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 61.331, + "range": "±17.86%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9c07840d01844b4ac2e044c0e5905a407bb08286", + "message": "LUAFDN-311: Moar simple optimizations", + "timestamp": "2021-10-13T19:45:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/230/commits/9c07840d01844b4ac2e044c0e5905a407bb08286" + }, + "date": 1634154457252, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.2453, + "range": "±10.59%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 50.1186, + "range": "±14.46%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "4742d67758a17082104fc2bebc5e864d7f7e5055", + "message": "LUAFDN-311: Optimization pass", + "timestamp": "2021-10-13T19:56:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/220/commits/4742d67758a17082104fc2bebc5e864d7f7e5055" + }, + "date": 1634161545989, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.0616, + "range": "±11.71%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 50.8742, + "range": "±18.22%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cc4a52d1082b6b9dba96db3ca6cd8a9d98640536", + "message": "#nojira Update rotriever.toml for rc.6", + "timestamp": "2021-10-13T21:46:59Z", + "url": "https://github.com/Roblox/roact-alignment/pull/235/commits/cc4a52d1082b6b9dba96db3ca6cd8a9d98640536" + }, + "date": 1634161824605, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 23.6701, + "range": "±11.97%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 43.1308, + "range": "±18.23%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "83a6aba3009e4c710e244395cb5cc65ae19351fa", + "message": "ci: update benchmarks workflow", + "timestamp": "2021-10-13T21:55:02Z", + "url": "https://github.com/Roblox/roact-alignment/pull/238/commits/83a6aba3009e4c710e244395cb5cc65ae19351fa" + }, + "date": 1634750347826, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 19.6275, + "range": "±12.76%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 52.0989, + "range": "±18.34%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1030689534, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029015915, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000781467, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 259669968, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0293031037, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005572651, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 134462954, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0131739855, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074690015, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1202013220, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032883349, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000657314, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 312215952, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.032018806, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004595633, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 155762063, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0129533531, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066312874, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "2b8ebac9d357b22453c27aebc5c446932d49a6c7", + "message": "Change thresholds to udpate FPS branch", + "timestamp": "2021-10-28T18:43:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/242/commits/2b8ebac9d357b22453c27aebc5c446932d49a6c7" + }, + "date": 1635446935540, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.9257, + "range": "±11.94%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 53.7447, + "range": "±14.78%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1021921513, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0028436646, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000736377, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 256451163, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0296323944, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005348621, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 131897871, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133997007, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076664846, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1195187138, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0031777383, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.000063001, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 309437019, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0325511667, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004390877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 153368355, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0129412877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.006719913, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f5858b79139430febb1f073f2182654db08bd637", + "message": "LUAFDN-694: eliminate unnecessary allocations", + "timestamp": "2021-10-28T19:24:12Z", + "url": "https://github.com/Roblox/roact-alignment/pull/241/commits/f5858b79139430febb1f073f2182654db08bd637" + }, + "date": 1635543018749, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 23.9269, + "range": "±12.30%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 42.6981, + "range": "±18.04%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1031013230, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030065356, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000731746, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 258748594, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0291399767, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005268473, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132113750, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133910664, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0071667786, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1204371419, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033737632, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000630287, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 312038573, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0320213713, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004415223, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 152757551, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0130743586, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0072015294, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "15ddcfb84d3d69fe5f4937931a783ff1ec3df930", + "message": "Fix Mysterious top bar disappearing bug", + "timestamp": "2021-10-30T01:46:20Z", + "url": "https://github.com/Roblox/roact-alignment/pull/249/commits/15ddcfb84d3d69fe5f4937931a783ff1ec3df930" + }, + "date": 1635830683784, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 21.4616, + "range": "±12.36%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 47.5038, + "range": "±16.57%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1022537276, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030209764, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000712375, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 256490456, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0293581216, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004763608, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 130598950, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0135188606, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0077143576, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1195948459, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033777551, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000630612, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 309617950, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0318998882, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004075765, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 151387804, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0131723359, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0067800706, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9d261c78bbe3ce612749e4e8823c918d4acf8c31", + "message": "LUAFDN-692: Add assertions around the instance map to detect bad objects being inserted", + "timestamp": "2021-11-02T16:35:50Z", + "url": "https://github.com/Roblox/roact-alignment/pull/248/commits/9d261c78bbe3ce612749e4e8823c918d4acf8c31" + }, + "date": 1635971043478, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.2311, + "range": "±10.54%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 55.5256, + "range": "±12.14%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1049578164, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029319408, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000770967, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 264889581, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0300123431, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005511957, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132452437, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133476895, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0075636207, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1244524163, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032330638, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000647203, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 324943625, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0328806328, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004439139, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 154235062, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0129125698, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066830718, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f683410321e0b3eb3d4d893d1e3dd3692ef35e85", + "message": "LUAFDN-698: Fix possible memory leak in RobloxComponentProps.lua", + "timestamp": "2021-11-03T20:31:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/244/commits/f683410321e0b3eb3d4d893d1e3dd3692ef35e85" + }, + "date": 1635977835488, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 21.3895, + "range": "±11.31%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 47.4743, + "range": "±14.51%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1048066632, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029425333, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000719878, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 264442653, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0299227372, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005150153, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132210659, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.013343909, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076363586, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1248025208, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032368425, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000638152, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 325906641, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0335232015, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004459713, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 154852277, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0130218169, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.007089137, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "eb240e688e5745b3493f682736278caf005dd4d0", + "message": "#nojira - Cut version rc.7", + "timestamp": "2021-11-04T15:57:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/251/commits/eb240e688e5745b3493f682736278caf005dd4d0" + }, + "date": 1636041793048, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 21.1493, + "range": "±13.81%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 48.557, + "range": "±20.62%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1048484967, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029442005, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000721574, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 264545873, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0300131577, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005199363, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132256168, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0135014119, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076326194, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1247849753, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032281322, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000649525, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 325856837, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0330950153, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004473314, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 154680146, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0130421586, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.007087503, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "353d64f2776e697b033af9735db7c32fc196255c", + "message": "#nojira Emergency fix to try and unblock the reverse merge", + "timestamp": "2021-11-04T16:32:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/253/commits/353d64f2776e697b033af9735db7c32fc196255c" + }, + "date": 1636057640092, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.3907, + "range": "±10.86%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 55.1392, + "range": "±13.43%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1052700910, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029247006, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000766761, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 265711984, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0296561332, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005501634, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132795775, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133199644, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0075462717, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1250695870, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032288505, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000601865, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 326671736, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0324903652, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004205445, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 154966744, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.012809729, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066273574, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af", + "message": "Fix __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + "timestamp": "2021-11-04T20:38:54Z", + "url": "https://github.com/Roblox/roact-alignment/pull/255/commits/d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af" + }, + "date": 1636580041539, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.0205, + "range": "±11.77%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 63.3069, + "range": "±12.44%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1043216649, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0028875584, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000743824, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 263166071, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0296488144, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.000534024, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 132048896, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133663064, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076274398, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1238063569, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0031937698, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000629572, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 323142231, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0326075176, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004419447, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 153853735, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0129322892, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0071504536, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c0df797acc7f79c1e0928d1ac7ab884b156b34e5", + "message": "LUAFDN-723: Manually massage non-string errors into strings before re-throwing", + "timestamp": "2021-11-17T19:13:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/254/commits/c0df797acc7f79c1e0928d1ac7ab884b156b34e5" + }, + "date": 1637179366836, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.1194, + "range": "±10.03%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 62.666, + "range": "±10.48%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1040646121, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029302958, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000780246, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 262120281, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0294453827, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005713064, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 131418268, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133451158, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076431155, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1234301450, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032582413, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000662812, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 321790681, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0325311316, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004658525, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 153311458, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.012982787, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0071883668, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0408a7e730e0d979915f8e2cc362cdd6a3e2f65d", + "message": "Further optimizations", + "timestamp": "2021-11-17T20:11:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/240/commits/0408a7e730e0d979915f8e2cc362cdd6a3e2f65d" + }, + "date": 1637191572613, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.5233, + "range": "±13.95%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 54.9535, + "range": "±14.02%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1025180433, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029329393, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000706715, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 258134901, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0293735832, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004648074, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 129934563, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0134082954, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0072892153, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1216564054, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032216972, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000630431, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 317378571, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0322329134, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0004148925, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 151780624, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0129877777, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0072466628, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "25e7dcd87c9987a2053ec0a52efc99460a024688", + "message": "Separate scheduler mocking from act inlining", + "timestamp": "2021-11-23T18:15:18Z", + "url": "https://github.com/Roblox/roact-alignment/pull/259/commits/25e7dcd87c9987a2053ec0a52efc99460a024688" + }, + "date": 1638223875987, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.3643, + "range": "±10.55%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 61.7729, + "range": "±10.82%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1028934007, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0028816902, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000708199, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 259360110, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0295690266, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001618213, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 130607301, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.01326213, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0075014413, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1218197861, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0031640213, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000625194, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 317998590, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0323812819, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.000156573, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 152035621, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127775122, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0070743948, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a8f8c4933fcae79ebfbd498723b680fb195be5c2", + "message": "LUAFDN-257: Backfilling tests", + "timestamp": "2021-11-29T23:50:37Z", + "url": "https://github.com/Roblox/roact-alignment/pull/258/commits/a8f8c4933fcae79ebfbd498723b680fb195be5c2" + }, + "date": 1638303803608, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.7532, + "range": "±12.05%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 60.4718, + "range": "±11.78%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 1030176193, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0028746879, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000705336, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 259725749, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0296853009, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001488532, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 130731318, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132517137, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0069951716, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1221084557, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0031679788, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000625182, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 318751424, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.032328643, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0001567334, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 152462482, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127472475, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0070655087, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ee62df85c6072dd919a664ffcde25cfac4367380", + "message": "#nojira - Provide properly-formatted args to cachegrind", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/266/commits/ee62df85c6072dd919a664ffcde25cfac4367380" + }, + "date": 1642635785363, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 18.5201, + "range": "±10.18%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 54.5494, + "range": "±10.57%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 988167725, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029272672, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000733023, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 249761921, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.029796836, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001588152, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 126068573, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132041552, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0073139878, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1178113825, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032149364, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000619524, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 307901443, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.032896192, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0001539649, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 147985249, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0128441991, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.0073420629, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ae436d269232d09c76caa2e70a48247d7f788d00", + "message": "LUAFDN-707 Fix useCallback hook in DEV mode", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/269/commits/ae436d269232d09c76caa2e70a48247d7f788d00" + }, + "date": 1645033057362, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 20.052, + "range": "±10.93%", + "unit": "ops/sec", + "extra": "100 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 50.5125, + "range": "±12.37%", + "unit": "ms/op", + "extra": "100 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalInstructionsExecuted", + "value": 990936414, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0028366563, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000738514, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryReads", + "value": 250831131, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0302572969, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001846103, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#TotalMemoryWrites", + "value": 126689758, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132939476, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0077697678, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalInstructionsExecuted", + "value": 1177679974, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_InstrReadCacheMisses", + "value": 0.0031189832, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000624041, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryReads", + "value": 308161517, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataReadCacheMisses", + "value": 0.0328328829, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataReadCacheMisses", + "value": 0.0001598123, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#TotalMemoryWrites", + "value": 148530404, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127797269, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountDeepTreeCG#LL_DataWriteCacheMisses", + "value": 0.007296082, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + } + ] + } + ], + "Mount Wide Tree": [ + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f854288e5e7aa7f6f5bbfbbb962e8d28d672363f", + "message": "Calc bench on pr", + "timestamp": "2021-08-19T15:13:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/183/commits/f854288e5e7aa7f6f5bbfbbb962e8d28d672363f" + }, + "date": 1629723306055, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#Δt", + "value": 65.079, + "range": "±13.50%", + "unit": "ms/op", + "extra": "49 samples\nroblox-cli version: 0.492.0.428906" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b05ed983ec8a2bfb8183ac394532cdf26fd396c4", + "message": "LUAFDN-496 Wrong Instance Names in Array of Children", + "timestamp": "2021-09-01T22:45:36Z", + "url": "https://github.com/Roblox/roact-alignment/pull/187/commits/b05ed983ec8a2bfb8183ac394532cdf26fd396c4" + }, + "date": 1630616942363, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.8802, + "range": "±16.05%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.493.1.4930375" + }, + { + "name": "FrameRate#Δt", + "value": 68.8792, + "range": "±16.53%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.493.1.4930375" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0960cb8107c7599e71e7411d967065bc9fd8d9ad", + "message": "LUAFDN-540 Limit pcalls in recursivelyCommitLayoutEffects", + "timestamp": "2021-09-03T16:54:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/190/commits/0960cb8107c7599e71e7411d967065bc9fd8d9ad" + }, + "date": 1630696318737, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.8774, + "range": "±12.05%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940337" + }, + { + "name": "FrameRate#Δt", + "value": 57.0225, + "range": "±16.26%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940337" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "89a9413bf48d6f22096a6be5458202c7d6eafb24", + "message": "LUAFDN-317 React devtools", + "timestamp": "2021-09-03T19:53:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/115/commits/89a9413bf48d6f22096a6be5458202c7d6eafb24" + }, + "date": 1630723699580, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.371, + "range": "±11.40%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940339" + }, + { + "name": "FrameRate#Δt", + "value": 70.5449, + "range": "±12.43%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940339" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d316007283b3ec76aabda8cc9fc6e6f847afc937", + "message": "LUAFDN-537 and LUAFDN-499: fix string formatting in several error messages", + "timestamp": "2021-09-04T02:58:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/192/commits/d316007283b3ec76aabda8cc9fc6e6f847afc937" + }, + "date": 1631297858733, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.3949, + "range": "±10.87%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 58.3581, + "range": "±14.00%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cdad9e14ed240e308df8ed82ea6913545d5c14da", + "message": "LUAFDN-56: Debug tracing API and tests", + "timestamp": "2021-09-10T18:47:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/143/commits/cdad9e14ed240e308df8ed82ea6913545d5c14da" + }, + "date": 1631306411439, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.9546, + "range": "±10.85%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 72.6083, + "range": "±12.35%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81", + "message": "Switch back to setTimeout", + "timestamp": "2021-09-10T23:31:56Z", + "url": "https://github.com/Roblox/roact-alignment/pull/195/commits/f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81" + }, + "date": 1631648271245, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.9239, + "range": "±12.47%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950267" + }, + { + "name": "FrameRate#Δt", + "value": 60.3474, + "range": "±17.04%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950267" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "47dcf2451a484650001d667ff4ee068f2254ccb9", + "message": "LUAFDN-58 Fix additional bugs/quality gaps in DevTools", + "timestamp": "2021-09-14T21:12:42Z", + "url": "https://github.com/Roblox/roact-alignment/pull/197/commits/47dcf2451a484650001d667ff4ee068f2254ccb9" + }, + "date": 1631844122035, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.5503, + "range": "±11.15%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 74.718, + "range": "±11.48%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "59d9aee9aa9fbaa9573632fc4bf68072d81aae7e", + "message": "LUAFDN-538: use task library", + "timestamp": "2021-09-17T02:14:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/198/commits/59d9aee9aa9fbaa9573632fc4bf68072d81aae7e" + }, + "date": 1632256587677, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.8233, + "range": "±13.39%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 73.9023, + "range": "±16.34%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c74e1b9e25531ce64bcfa805cbe5873f629f925a", + "message": "LUAFDN-607: Update MAX_PCALL_DEPTH usage", + "timestamp": "2021-09-21T22:08:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/201/commits/c74e1b9e25531ce64bcfa805cbe5873f629f925a" + }, + "date": 1632346014827, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.1705, + "range": "±13.11%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 77.9703, + "range": "±21.09%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "5fe5615d45e448a94e57006eb8b8ce6822d85c19", + "message": "#nojira - Upgrade polyfill to play nice with in lua-apps", + "timestamp": "2021-09-23T16:01:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/202/commits/5fe5615d45e448a94e57006eb8b8ce6822d85c19" + }, + "date": 1632425162831, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.5056, + "range": "±13.02%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 81.959, + "range": "±19.85%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "11f0938c4b71d4b3d1f11bf27333550cf4d105dd", + "message": "#nojira - Put warning behind dev", + "timestamp": "2021-09-23T19:52:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/203/commits/11f0938c4b71d4b3d1f11bf27333550cf4d105dd" + }, + "date": 1632456049194, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.7101, + "range": "±12.71%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 74.4684, + "range": "±16.54%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "180e78d05c536fec1e9dee503db30a054db12af1", + "message": "#nojira - Update to rc.3", + "timestamp": "2021-09-24T04:08:39Z", + "url": "https://github.com/Roblox/roact-alignment/pull/204/commits/180e78d05c536fec1e9dee503db30a054db12af1" + }, + "date": 1632508696225, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.3176, + "range": "±14.26%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 71.4084, + "range": "±16.05%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6b2b75ac88f3405cbe2892693c8284665c074881", + "message": "Sometimes instance names are incorrect", + "timestamp": "2021-09-24T18:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/209/commits/6b2b75ac88f3405cbe2892693c8284665c074881" + }, + "date": 1632769986549, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.0597, + "range": "±12.66%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970358" + }, + { + "name": "FrameRate#Δt", + "value": 67.765, + "range": "±16.17%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970358" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "41a491696aa3933a35daf6c2b05903e3633c7d4c", + "message": "Made types compatible with Luau RFC recursive-type-restriction", + "timestamp": "2021-09-27T19:51:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/210/commits/41a491696aa3933a35daf6c2b05903e3633c7d4c" + }, + "date": 1632775890915, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.2597, + "range": "±12.82%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 66.9929, + "range": "±18.05%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8956b5705618418701bfd118411c4ac606e861b5", + "message": "LUAFDN-611: Fix hard runtime errors caused by multiple mistranslation bugs", + "timestamp": "2021-09-27T21:17:24Z", + "url": "https://github.com/Roblox/roact-alignment/pull/206/commits/8956b5705618418701bfd118411c4ac606e861b5" + }, + "date": 1632777760952, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.1949, + "range": "±14.19%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 78.0315, + "range": "±21.54%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8d1d0941fffd01703c6ae30208eb3979fad7c870", + "message": "LUAFDN-610: Fix a mistranslation bug that results in a hard runtime error", + "timestamp": "2021-09-27T21:35:46Z", + "url": "https://github.com/Roblox/roact-alignment/pull/205/commits/8d1d0941fffd01703c6ae30208eb3979fad7c870" + }, + "date": 1632779126129, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.1513, + "range": "±14.99%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 67.8878, + "range": "±19.77%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a45b813eba458f0757f081ca36c805c950628cd8", + "message": "LUAFDN-247: Type public API more thoroughly", + "timestamp": "2021-09-27T21:51:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/211/commits/a45b813eba458f0757f081ca36c805c950628cd8" + }, + "date": 1632785098283, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.124, + "range": "±13.55%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 67.4266, + "range": "±14.78%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6a24d389ce9e63933d437e3ee75ce6431db8959b", + "message": "Switch promise dependency to internal one", + "timestamp": "2021-09-27T23:52:53Z", + "url": "https://github.com/Roblox/roact-alignment/pull/212/commits/6a24d389ce9e63933d437e3ee75ce6431db8959b" + }, + "date": 1632798147617, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.1983, + "range": "±12.22%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 71.5944, + "range": "±13.85%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "e7a58f4cfcc4a682fe9479c590b1455ca37c509d", + "message": "LUAFDN-498 - Better error output for bad prop assignments", + "timestamp": "2021-09-28T06:02:25Z", + "url": "https://github.com/Roblox/roact-alignment/pull/200/commits/e7a58f4cfcc4a682fe9479c590b1455ca37c509d" + }, + "date": 1632872859491, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.9059, + "range": "±14.49%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 68.8521, + "range": "±19.61%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c645f97ebb36682bb7e8d33d1b22a08b536ca18a", + "message": "#nojira Cleanup pragmas, add strict mode where reasonable, fix a bug", + "timestamp": "2021-09-29T00:15:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/213/commits/c645f97ebb36682bb7e8d33d1b22a08b536ca18a" + }, + "date": 1632878705234, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.707, + "range": "±14.20%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 69.7532, + "range": "±18.48%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "737e2a29452e082b7398f2f4f561675309147df2", + "message": "LUAFDN-622 - Export `act` from the top level; enable it behind tests", + "timestamp": "2021-09-29T17:33:55Z", + "url": "https://github.com/Roblox/roact-alignment/pull/214/commits/737e2a29452e082b7398f2f4f561675309147df2" + }, + "date": 1632961628087, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.1177, + "range": "±13.54%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 77.9108, + "range": "±16.83%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1fbcbe16f83b5ee7a654c2ea835ceb7674722e32", + "message": "LUAFDN-58: Devtools quality improvements", + "timestamp": "2021-09-30T00:34:28Z", + "url": "https://github.com/Roblox/roact-alignment/pull/215/commits/1fbcbe16f83b5ee7a654c2ea835ceb7674722e32" + }, + "date": 1632964059521, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 11.8442, + "range": "±15.44%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 86.7556, + "range": "±18.42%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1a62c42d4dbb37c387e9647475a8f6bec33b2432", + "message": "LUAFDN-58: inject internals into dev tools if the global is present", + "timestamp": "2021-09-30T02:42:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/216/commits/1a62c42d4dbb37c387e9647475a8f6bec33b2432" + }, + "date": 1633034432940, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.1488, + "range": "±13.80%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 59.6958, + "range": "±17.32%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "7edeedbcccb24cef1e506538d8df35ca2aedea45", + "message": "#nojira - Bump version", + "timestamp": "2021-09-30T20:42:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/217/commits/7edeedbcccb24cef1e506538d8df35ca2aedea45" + }, + "date": 1633037737005, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.6637, + "range": "±12.81%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 69.6116, + "range": "±16.39%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b1224be94e4b5d679e8c950e597953f813bc3adb", + "message": "RIDE-5275 React devtools inspector integration", + "timestamp": "2021-09-30T21:49:35Z", + "url": "https://github.com/Roblox/roact-alignment/pull/185/commits/b1224be94e4b5d679e8c950e597953f813bc3adb" + }, + "date": 1633039268299, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.6705, + "range": "±13.69%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 80.7179, + "range": "±16.68%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b28927f22559f567348efe7f0c63157baedf6a16", + "message": "#nojira - Switch promise to proper semver dep", + "timestamp": "2021-09-30T22:07:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/218/commits/b28927f22559f567348efe7f0c63157baedf6a16" + }, + "date": 1633042758188, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.4817, + "range": "±12.55%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 70.486, + "range": "±16.51%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "3705df6efca1811e9cd025eaecabd2b49d034605", + "message": "#nojira - Strict mode seems to stumble sometimes when using the 'convert' command", + "timestamp": "2021-09-30T23:05:29Z", + "url": "https://github.com/Roblox/roact-alignment/pull/219/commits/3705df6efca1811e9cd025eaecabd2b49d034605" + }, + "date": 1633109549553, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.9893, + "range": "±14.18%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 68.2713, + "range": "±16.73%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b7a75292e97de978e822d69371f6f1a0435f6cf1", + "message": "Remove troublesome optimization", + "timestamp": "2021-10-01T17:39:47Z", + "url": "https://github.com/Roblox/roact-alignment/pull/221/commits/b7a75292e97de978e822d69371f6f1a0435f6cf1" + }, + "date": 1633134827768, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.1401, + "range": "±12.53%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 59.5101, + "range": "±16.14%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b76de6b4be3953f6b4da77b36e6d0d4a030609bc", + "message": "Adopt jest roblox with rotriever 0.5", + "timestamp": "2021-10-02T00:38:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/181/commits/b76de6b4be3953f6b4da77b36e6d0d4a030609bc" + }, + "date": 1633358801110, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.8861, + "range": "±11.92%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 79.0098, + "range": "±15.24%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "aeb440bbb8474f606c94dc50ff4342ee76da2e5f", + "message": "LUAFDN-642", + "timestamp": "2021-10-04T14:53:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/223/commits/aeb440bbb8474f606c94dc50ff4342ee76da2e5f" + }, + "date": 1633636748694, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.6377, + "range": "±15.04%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 70.0278, + "range": "±17.23%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "63272527248f7791e9aa6c28f975a563c2743af9", + "message": "LUAFDN-637 Split compatibility layer warnings", + "timestamp": "2021-10-07T20:50:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/224/commits/63272527248f7791e9aa6c28f975a563c2743af9" + }, + "date": 1633715656402, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.5993, + "range": "±13.95%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 61.628, + "range": "±16.99%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d77d7c6b84a4092ce2124bc2aa2ec77fe3693778", + "message": "#nojira Fix use*Effect create function type", + "timestamp": "2021-10-08T18:05:52Z", + "url": "https://github.com/Roblox/roact-alignment/pull/225/commits/d77d7c6b84a4092ce2124bc2aa2ec77fe3693778" + }, + "date": 1633719577572, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.5196, + "range": "±14.65%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 70.5467, + "range": "±17.26%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "26008c76a91bb7eaa458b7ff756b20fd2f2a4522", + "message": "LUAFDN-580: Simple optimizations", + "timestamp": "2021-10-08T19:11:22Z", + "url": "https://github.com/Roblox/roact-alignment/pull/227/commits/26008c76a91bb7eaa458b7ff756b20fd2f2a4522" + }, + "date": 1633733657044, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 16.8531, + "range": "±13.36%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 60.6722, + "range": "±17.15%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a7f90fc02f826dd5240e57c4b351486b64404eab", + "message": "#nojira another fix that Alan's in-development analyze feature turned up", + "timestamp": "2021-10-08T23:23:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/228/commits/a7f90fc02f826dd5240e57c4b351486b64404eab" + }, + "date": 1633738141136, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.1604, + "range": "±12.80%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 77.6299, + "range": "±17.09%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "fa677ba23ad2df4754d2721ae1fa2d2217973a8f", + "message": "#nojira - Bump version for rc.5", + "timestamp": "2021-10-09T00:15:48Z", + "url": "https://github.com/Roblox/roact-alignment/pull/226/commits/fa677ba23ad2df4754d2721ae1fa2d2217973a8f" + }, + "date": 1633738825439, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.635, + "range": "±12.78%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 74.7593, + "range": "±15.32%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "bbbf7f1d1dcdaa33aebf99092913f23ee5829655", + "message": "robloxdev-cli convert can fail in strict mode", + "timestamp": "2021-10-09T01:49:01Z", + "url": "https://github.com/Roblox/roact-alignment/pull/229/commits/bbbf7f1d1dcdaa33aebf99092913f23ee5829655" + }, + "date": 1633747702072, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.9613, + "range": "±12.44%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 78.5425, + "range": "±14.81%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "837d50d8d7f22fdcb28ec02b6e05919d2c50f057", + "message": "LUAFDN-623 - Use `act` in compat layer when the `__ROACT_17_INLINE_ACT__` global is enabled", + "timestamp": "2021-10-09T02:54:03Z", + "url": "https://github.com/Roblox/roact-alignment/pull/232/commits/837d50d8d7f22fdcb28ec02b6e05919d2c50f057" + }, + "date": 1634056129479, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.4607, + "range": "±12.62%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 70.5386, + "range": "±15.96%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "733f87608ee9d26fe5d0e678a537b7840128a7a5", + "message": "LUAFDN-311: Use recycle pool for queue update objects", + "timestamp": "2021-10-12T16:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/231/commits/733f87608ee9d26fe5d0e678a537b7840128a7a5" + }, + "date": 1634078781354, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.8031, + "range": "±14.49%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 57.6019, + "range": "±17.84%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "30a019de74755f03651f9d84ac961df1f7ce1d71", + "message": "#nojira - Add global for legacy root", + "timestamp": "2021-10-12T22:53:00Z", + "url": "https://github.com/Roblox/roact-alignment/pull/233/commits/30a019de74755f03651f9d84ac961df1f7ce1d71" + }, + "date": 1634083022029, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.8933, + "range": "±11.99%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 68.2192, + "range": "±13.50%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "149598c92cfe72abc16c1b101903b1f65f95882b", + "message": "#nojira Work around new Luau recursive type error", + "timestamp": "2021-10-13T00:04:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/234/commits/149598c92cfe72abc16c1b101903b1f65f95882b" + }, + "date": 1634092706115, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.6805, + "range": "±14.69%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 75.0156, + "range": "±18.32%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a3fe1e8dd69f257f26fc29f589ae094fdc660a5d", + "message": "#nojira - Comment out object pool warning for now", + "timestamp": "2021-10-13T15:42:15Z", + "url": "https://github.com/Roblox/roact-alignment/pull/237/commits/a3fe1e8dd69f257f26fc29f589ae094fdc660a5d" + }, + "date": 1634152386254, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.3315, + "range": "±14.03%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 82.8969, + "range": "±16.34%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9c07840d01844b4ac2e044c0e5905a407bb08286", + "message": "LUAFDN-311: Moar simple optimizations", + "timestamp": "2021-10-13T19:45:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/230/commits/9c07840d01844b4ac2e044c0e5905a407bb08286" + }, + "date": 1634154457866, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.3516, + "range": "±9.26%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 65.7393, + "range": "±10.11%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "4742d67758a17082104fc2bebc5e864d7f7e5055", + "message": "LUAFDN-311: Optimization pass", + "timestamp": "2021-10-13T19:56:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/220/commits/4742d67758a17082104fc2bebc5e864d7f7e5055" + }, + "date": 1634161546613, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.7069, + "range": "±13.12%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 69.4098, + "range": "±16.04%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cc4a52d1082b6b9dba96db3ca6cd8a9d98640536", + "message": "#nojira Update rotriever.toml for rc.6", + "timestamp": "2021-10-13T21:46:59Z", + "url": "https://github.com/Roblox/roact-alignment/pull/235/commits/cc4a52d1082b6b9dba96db3ca6cd8a9d98640536" + }, + "date": 1634161825179, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.6136, + "range": "±12.64%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 57.9539, + "range": "±16.65%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "83a6aba3009e4c710e244395cb5cc65ae19351fa", + "message": "ci: update benchmarks workflow", + "timestamp": "2021-10-13T21:55:02Z", + "url": "https://github.com/Roblox/roact-alignment/pull/238/commits/83a6aba3009e4c710e244395cb5cc65ae19351fa" + }, + "date": 1634750347984, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.1739, + "range": "±13.62%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 71.8198, + "range": "±13.56%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1128099654, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030019972, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000672299, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 289350329, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0306789698, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004797057, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 145433583, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.012967947, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0068649206, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1356121126, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034695256, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000600699, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 359122496, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0333761408, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0004223963, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 173858268, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0126861956, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0068409804, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "2b8ebac9d357b22453c27aebc5c446932d49a6c7", + "message": "Change thresholds to udpate FPS branch", + "timestamp": "2021-10-28T18:43:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/242/commits/2b8ebac9d357b22453c27aebc5c446932d49a6c7" + }, + "date": 1635446935715, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.3857, + "range": "±17.58%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 82.9941, + "range": "±16.90%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1124319424, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029301966, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000658843, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 287464006, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0311022243, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004394115, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 143496630, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132610362, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0073357123, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1348009180, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033731321, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000591057, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 355949425, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0338158068, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003927103, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171073085, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0128351283, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0069486208, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f5858b79139430febb1f073f2182654db08bd637", + "message": "LUAFDN-694: eliminate unnecessary allocations", + "timestamp": "2021-10-28T19:24:12Z", + "url": "https://github.com/Roblox/roact-alignment/pull/241/commits/f5858b79139430febb1f073f2182654db08bd637" + }, + "date": 1635543018931, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.0947, + "range": "±11.94%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 77.4486, + "range": "±12.17%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1130104092, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0031431459, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000646082, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 288891481, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0299911751, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004207324, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 142870629, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132117218, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0070006411, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1390240142, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0035726468, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000538324, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 364703733, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0324562431, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.000366821, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 173773021, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127369657, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0068844289, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "15ddcfb84d3d69fe5f4937931a783ff1ec3df930", + "message": "Fix Mysterious top bar disappearing bug", + "timestamp": "2021-10-30T01:46:20Z", + "url": "https://github.com/Roblox/roact-alignment/pull/249/commits/15ddcfb84d3d69fe5f4937931a783ff1ec3df930" + }, + "date": 1635830683977, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 15.4047, + "range": "±11.62%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 65.9128, + "range": "±13.46%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1125097636, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0031430526, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.000065669, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 287505572, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0304538828, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004373654, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 141884651, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0133041945, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074204221, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1347154618, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0036069015, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000592605, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 355600557, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0332796189, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003943526, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 168605248, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0130121098, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0070414178, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9d261c78bbe3ce612749e4e8823c918d4acf8c31", + "message": "LUAFDN-692: Add assertions around the instance map to detect bad objects being inserted", + "timestamp": "2021-11-02T16:35:50Z", + "url": "https://github.com/Roblox/roact-alignment/pull/248/commits/9d261c78bbe3ce612749e4e8823c918d4acf8c31" + }, + "date": 1635971043655, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.2093, + "range": "±12.51%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 71.4694, + "range": "±12.94%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1150278142, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030734306, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000707003, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 295424184, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0312771178, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005019765, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 143461876, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132448359, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0073755135, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1395567461, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034806508, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000560689, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 371091361, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0340695616, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003968052, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171137669, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0128526292, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0069779319, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f683410321e0b3eb3d4d893d1e3dd3692ef35e85", + "message": "LUAFDN-698: Fix possible memory leak in RobloxComponentProps.lua", + "timestamp": "2021-11-03T20:31:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/244/commits/f683410321e0b3eb3d4d893d1e3dd3692ef35e85" + }, + "date": 1635977835677, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.3742, + "range": "±11.05%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 81.7781, + "range": "±11.15%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1152447215, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030654263, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000670174, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 296137648, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0308926071, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004735636, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 143699390, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0131739112, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074434902, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1402983025, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.003469233, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000565445, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 373063396, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0338572375, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0004024463, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 172227811, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0128038264, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.006881496, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "eb240e688e5745b3493f682736278caf005dd4d0", + "message": "#nojira - Cut version rc.7", + "timestamp": "2021-11-04T15:57:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/251/commits/eb240e688e5745b3493f682736278caf005dd4d0" + }, + "date": 1636041793270, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.9071, + "range": "±13.02%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 56.8063, + "range": "±13.81%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1154414010, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030616789, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000651984, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 296773399, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0309590382, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004644587, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 143870332, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.01334118, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074295373, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1400208066, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034785066, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000540105, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 372335245, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0338501664, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003730643, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171766050, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.012926431, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0069529223, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "353d64f2776e697b033af9735db7c32fc196255c", + "message": "#nojira Emergency fix to try and unblock the reverse merge", + "timestamp": "2021-11-04T16:32:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/253/commits/353d64f2776e697b033af9735db7c32fc196255c" + }, + "date": 1636057640304, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.9541, + "range": "±12.05%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 72.6742, + "range": "±12.13%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1150895974, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030721048, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000658713, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 295521189, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0304536471, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004662373, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 143464021, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132269261, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0075124968, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1402186672, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034715877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000543651, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 372887766, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0333155392, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003726483, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171891369, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0128247161, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0069434376, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af", + "message": "Fix __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + "timestamp": "2021-11-04T20:38:54Z", + "url": "https://github.com/Roblox/roact-alignment/pull/255/commits/d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af" + }, + "date": 1636580041729, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.0434, + "range": "±10.20%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 77.3861, + "range": "±9.50%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1142193498, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030007341, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000711447, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 293109727, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0305561371, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0005115763, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 142848546, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0131880446, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074749798, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1391964109, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033939604, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000590683, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 370093719, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0333673023, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0004461465, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171156086, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0126943193, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066576482, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c0df797acc7f79c1e0928d1ac7ab884b156b34e5", + "message": "LUAFDN-723: Manually massage non-string errors into strings before re-throwing", + "timestamp": "2021-11-17T19:13:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/254/commits/c0df797acc7f79c1e0928d1ac7ab884b156b34e5" + }, + "date": 1637179367035, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.1868, + "range": "±11.36%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 71.4005, + "range": "±11.56%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1139770750, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0030827401, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000664634, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 292204912, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0309972134, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004763438, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 142277253, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132173201, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074963353, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1393665520, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034971404, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.000055006, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 370051025, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0338391739, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003862197, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 171173144, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0126859269, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0065535047, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0408a7e730e0d979915f8e2cc362cdd6a3e2f65d", + "message": "Further optimizations", + "timestamp": "2021-11-17T20:11:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/240/commits/0408a7e730e0d979915f8e2cc362cdd6a3e2f65d" + }, + "date": 1637191572804, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.5791, + "range": "±13.93%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 74.9015, + "range": "±13.02%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1127078657, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.003058641, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000656077, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 289108594, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0309931396, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0004394577, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 141351901, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.013364815, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0074593691, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1370215803, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0034547208, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.000057969, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 364249296, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0338726392, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0003798388, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 169086992, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127355805, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066514578, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "25e7dcd87c9987a2053ec0a52efc99460a024688", + "message": "Separate scheduler mocking from act inlining", + "timestamp": "2021-11-23T18:15:18Z", + "url": "https://github.com/Roblox/roact-alignment/pull/259/commits/25e7dcd87c9987a2053ec0a52efc99460a024688" + }, + "date": 1638223876170, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 12.1633, + "range": "±13.84%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 83.9629, + "range": "±15.91%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1127693933, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029830301, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000726518, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 289440585, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.030709864, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0002327248, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 141492035, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0131291277, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0073423497, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1368476007, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033661175, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000569882, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 364016342, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0337032479, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0001880108, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 169192396, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0125774151, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0068694636, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a8f8c4933fcae79ebfbd498723b680fb195be5c2", + "message": "LUAFDN-257: Backfilling tests", + "timestamp": "2021-11-29T23:50:37Z", + "url": "https://github.com/Roblox/roact-alignment/pull/258/commits/a8f8c4933fcae79ebfbd498723b680fb195be5c2" + }, + "date": 1638303803799, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 13.3132, + "range": "±13.74%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 76.6184, + "range": "±15.33%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1128477590, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029738721, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000724153, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 289689944, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0308350986, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0002320067, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 141475872, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0130694158, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0073515716, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1370796218, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033522116, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000595749, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 364724428, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0332761808, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0001960494, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 169301367, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0125876597, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0069063412, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ee62df85c6072dd919a664ffcde25cfac4367380", + "message": "#nojira - Provide properly-formatted args to cachegrind", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/266/commits/ee62df85c6072dd919a664ffcde25cfac4367380" + }, + "date": 1642635785511, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 17.0409, + "range": "±9.89%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 59.147, + "range": "±8.26%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1086859866, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029935745, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000668835, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 279470528, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0312136777, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001482231, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 137062181, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0131309818, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0071235989, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1331575671, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0033909571, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000552271, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 354119537, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0342143732, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0001467329, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 165533975, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.0127593927, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0071213115, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ae436d269232d09c76caa2e70a48247d7f788d00", + "message": "LUAFDN-707 Fix useCallback hook in DEV mode", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/269/commits/ae436d269232d09c76caa2e70a48247d7f788d00" + }, + "date": 1645033057560, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 14.5337, + "range": "±13.35%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 69.7613, + "range": "±11.15%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalInstructionsExecuted", + "value": 1086486024, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_InstrReadCacheMisses", + "value": 0.0029265227, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_InstrReadCacheMisses", + "value": 0.0000674744, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryReads", + "value": 279583012, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataReadCacheMisses", + "value": 0.0315628154, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataReadCacheMisses", + "value": 0.0001646595, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#TotalMemoryWrites", + "value": 137650832, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#L1_DataWriteCacheMisses", + "value": 0.0132479621, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCGCold#LL_DataWriteCacheMisses", + "value": 0.0076744324, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalInstructionsExecuted", + "value": 1332965079, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_InstrReadCacheMisses", + "value": 0.0032966325, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_InstrReadCacheMisses", + "value": 0.0000554043, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryReads", + "value": 354897136, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataReadCacheMisses", + "value": 0.0349867039, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataReadCacheMisses", + "value": 0.0001374849, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#TotalMemoryWrites", + "value": 166303316, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#L1_DataWriteCacheMisses", + "value": 0.012584241, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "MountWideTreeCG#LL_DataWriteCacheMisses", + "value": 0.0066895419, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + } + ] + } + ], + "Sierpinski Triangle Benchmark": [ + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f854288e5e7aa7f6f5bbfbbb962e8d28d672363f", + "message": "Calc bench on pr", + "timestamp": "2021-08-19T15:13:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/183/commits/f854288e5e7aa7f6f5bbfbbb962e8d28d672363f" + }, + "date": 1629723306185, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#Δt", + "value": 11.3002, + "range": "±5.51%", + "unit": "ms/op", + "extra": "49 samples\nroblox-cli version: 0.492.0.428906" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b05ed983ec8a2bfb8183ac394532cdf26fd396c4", + "message": "LUAFDN-496 Wrong Instance Names in Array of Children", + "timestamp": "2021-09-01T22:45:36Z", + "url": "https://github.com/Roblox/roact-alignment/pull/187/commits/b05ed983ec8a2bfb8183ac394532cdf26fd396c4" + }, + "date": 1630616943115, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 102.9753, + "range": "±15.52%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.493.1.4930375" + }, + { + "name": "FrameRate#Δt", + "value": 9.9321, + "range": "±14.53%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.493.1.4930375" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0960cb8107c7599e71e7411d967065bc9fd8d9ad", + "message": "LUAFDN-540 Limit pcalls in recursivelyCommitLayoutEffects", + "timestamp": "2021-09-03T16:54:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/190/commits/0960cb8107c7599e71e7411d967065bc9fd8d9ad" + }, + "date": 1630696319324, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 100.4906, + "range": "±11.55%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940337" + }, + { + "name": "FrameRate#Δt", + "value": 10.0745, + "range": "±10.68%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940337" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "89a9413bf48d6f22096a6be5458202c7d6eafb24", + "message": "LUAFDN-317 React devtools", + "timestamp": "2021-09-03T19:53:44Z", + "url": "https://github.com/Roblox/roact-alignment/pull/115/commits/89a9413bf48d6f22096a6be5458202c7d6eafb24" + }, + "date": 1630723700223, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 102.3565, + "range": "±13.63%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940339" + }, + { + "name": "FrameRate#Δt", + "value": 9.9553, + "range": "±13.91%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.494.0.4940339" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d316007283b3ec76aabda8cc9fc6e6f847afc937", + "message": "LUAFDN-537 and LUAFDN-499: fix string formatting in several error messages", + "timestamp": "2021-09-04T02:58:11Z", + "url": "https://github.com/Roblox/roact-alignment/pull/192/commits/d316007283b3ec76aabda8cc9fc6e6f847afc937" + }, + "date": 1631297859445, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 100.5119, + "range": "±9.30%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 10.0323, + "range": "±8.96%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cdad9e14ed240e308df8ed82ea6913545d5c14da", + "message": "LUAFDN-56: Debug tracing API and tests", + "timestamp": "2021-09-10T18:47:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/143/commits/cdad9e14ed240e308df8ed82ea6913545d5c14da" + }, + "date": 1631306412071, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 88.1114, + "range": "±12.30%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + }, + { + "name": "FrameRate#Δt", + "value": 11.5328, + "range": "±13.20%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950265" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81", + "message": "Switch back to setTimeout", + "timestamp": "2021-09-10T23:31:56Z", + "url": "https://github.com/Roblox/roact-alignment/pull/195/commits/f6a2e9ae2764afcfd9958c8aa4cc9bea68f68c81" + }, + "date": 1631648271877, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 95.2944, + "range": "±10.99%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950267" + }, + { + "name": "FrameRate#Δt", + "value": 10.6152, + "range": "±10.44%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.495.0.4950267" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "47dcf2451a484650001d667ff4ee068f2254ccb9", + "message": "LUAFDN-58 Fix additional bugs/quality gaps in DevTools", + "timestamp": "2021-09-14T21:12:42Z", + "url": "https://github.com/Roblox/roact-alignment/pull/197/commits/47dcf2451a484650001d667ff4ee068f2254ccb9" + }, + "date": 1631844122877, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 87.0444, + "range": "±17.30%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 11.8321, + "range": "±17.24%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "59d9aee9aa9fbaa9573632fc4bf68072d81aae7e", + "message": "LUAFDN-538: use task library", + "timestamp": "2021-09-17T02:14:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/198/commits/59d9aee9aa9fbaa9573632fc4bf68072d81aae7e" + }, + "date": 1632256588398, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 100.9674, + "range": "±13.87%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 10.132, + "range": "±16.69%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c74e1b9e25531ce64bcfa805cbe5873f629f925a", + "message": "LUAFDN-607: Update MAX_PCALL_DEPTH usage", + "timestamp": "2021-09-21T22:08:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/201/commits/c74e1b9e25531ce64bcfa805cbe5873f629f925a" + }, + "date": 1632346015560, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 85.6057, + "range": "±9.81%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 11.7991, + "range": "±10.23%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "5fe5615d45e448a94e57006eb8b8ce6822d85c19", + "message": "#nojira - Upgrade polyfill to play nice with in lua-apps", + "timestamp": "2021-09-23T16:01:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/202/commits/5fe5615d45e448a94e57006eb8b8ce6822d85c19" + }, + "date": 1632425163701, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 87.6227, + "range": "±10.96%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + }, + { + "name": "FrameRate#Δt", + "value": 11.5552, + "range": "±11.37%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.496.0.4960343" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "11f0938c4b71d4b3d1f11bf27333550cf4d105dd", + "message": "#nojira - Put warning behind dev", + "timestamp": "2021-09-23T19:52:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/203/commits/11f0938c4b71d4b3d1f11bf27333550cf4d105dd" + }, + "date": 1632456049866, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 86.2582, + "range": "±11.38%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 11.747, + "range": "±11.65%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "180e78d05c536fec1e9dee503db30a054db12af1", + "message": "#nojira - Update to rc.3", + "timestamp": "2021-09-24T04:08:39Z", + "url": "https://github.com/Roblox/roact-alignment/pull/204/commits/180e78d05c536fec1e9dee503db30a054db12af1" + }, + "date": 1632508696893, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 97.4152, + "range": "±13.13%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + }, + { + "name": "FrameRate#Δt", + "value": 10.4496, + "range": "±13.55%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970357" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6b2b75ac88f3405cbe2892693c8284665c074881", + "message": "Sometimes instance names are incorrect", + "timestamp": "2021-09-24T18:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/209/commits/6b2b75ac88f3405cbe2892693c8284665c074881" + }, + "date": 1632769987145, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 94.811, + "range": "±12.60%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970358" + }, + { + "name": "FrameRate#Δt", + "value": 10.7441, + "range": "±14.72%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970358" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "41a491696aa3933a35daf6c2b05903e3633c7d4c", + "message": "Made types compatible with Luau RFC recursive-type-restriction", + "timestamp": "2021-09-27T19:51:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/210/commits/41a491696aa3933a35daf6c2b05903e3633c7d4c" + }, + "date": 1632775891435, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 94.965, + "range": "±12.08%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 10.6988, + "range": "±13.22%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8956b5705618418701bfd118411c4ac606e861b5", + "message": "LUAFDN-611: Fix hard runtime errors caused by multiple mistranslation bugs", + "timestamp": "2021-09-27T21:17:24Z", + "url": "https://github.com/Roblox/roact-alignment/pull/206/commits/8956b5705618418701bfd118411c4ac606e861b5" + }, + "date": 1632777761673, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 77.3473, + "range": "±12.53%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 13.1313, + "range": "±12.49%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "8d1d0941fffd01703c6ae30208eb3979fad7c870", + "message": "LUAFDN-610: Fix a mistranslation bug that results in a hard runtime error", + "timestamp": "2021-09-27T21:35:46Z", + "url": "https://github.com/Roblox/roact-alignment/pull/205/commits/8d1d0941fffd01703c6ae30208eb3979fad7c870" + }, + "date": 1632779126758, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 98.6196, + "range": "±13.54%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 10.3607, + "range": "±16.41%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a45b813eba458f0757f081ca36c805c950628cd8", + "message": "LUAFDN-247: Type public API more thoroughly", + "timestamp": "2021-09-27T21:51:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/211/commits/a45b813eba458f0757f081ca36c805c950628cd8" + }, + "date": 1632785098815, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 109.4538, + "range": "±10.80%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 9.251, + "range": "±11.68%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "6a24d389ce9e63933d437e3ee75ce6431db8959b", + "message": "Switch promise dependency to internal one", + "timestamp": "2021-09-27T23:52:53Z", + "url": "https://github.com/Roblox/roact-alignment/pull/212/commits/6a24d389ce9e63933d437e3ee75ce6431db8959b" + }, + "date": 1632798148275, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 93.5237, + "range": "±8.98%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 10.7819, + "range": "±9.28%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "e7a58f4cfcc4a682fe9479c590b1455ca37c509d", + "message": "LUAFDN-498 - Better error output for bad prop assignments", + "timestamp": "2021-09-28T06:02:25Z", + "url": "https://github.com/Roblox/roact-alignment/pull/200/commits/e7a58f4cfcc4a682fe9479c590b1455ca37c509d" + }, + "date": 1632872860112, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 105.6557, + "range": "±9.57%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 9.5539, + "range": "±9.82%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c645f97ebb36682bb7e8d33d1b22a08b536ca18a", + "message": "#nojira Cleanup pragmas, add strict mode where reasonable, fix a bug", + "timestamp": "2021-09-29T00:15:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/213/commits/c645f97ebb36682bb7e8d33d1b22a08b536ca18a" + }, + "date": 1632878708039, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 96.7296, + "range": "±15.78%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 10.6381, + "range": "±18.89%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "737e2a29452e082b7398f2f4f561675309147df2", + "message": "LUAFDN-622 - Export `act` from the top level; enable it behind tests", + "timestamp": "2021-09-29T17:33:55Z", + "url": "https://github.com/Roblox/roact-alignment/pull/214/commits/737e2a29452e082b7398f2f4f561675309147df2" + }, + "date": 1632961628954, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 94.3201, + "range": "±11.51%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 10.7507, + "range": "±12.14%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1fbcbe16f83b5ee7a654c2ea835ceb7674722e32", + "message": "LUAFDN-58: Devtools quality improvements", + "timestamp": "2021-09-30T00:34:28Z", + "url": "https://github.com/Roblox/roact-alignment/pull/215/commits/1fbcbe16f83b5ee7a654c2ea835ceb7674722e32" + }, + "date": 1632964060115, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 88.6791, + "range": "±17.11%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 11.6428, + "range": "±18.83%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "1a62c42d4dbb37c387e9647475a8f6bec33b2432", + "message": "LUAFDN-58: inject internals into dev tools if the global is present", + "timestamp": "2021-09-30T02:42:41Z", + "url": "https://github.com/Roblox/roact-alignment/pull/216/commits/1a62c42d4dbb37c387e9647475a8f6bec33b2432" + }, + "date": 1633034433612, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 103.7986, + "range": "±11.44%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + }, + { + "name": "FrameRate#Δt", + "value": 9.7624, + "range": "±11.63%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.497.0.4970359" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "7edeedbcccb24cef1e506538d8df35ca2aedea45", + "message": "#nojira - Bump version", + "timestamp": "2021-09-30T20:42:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/217/commits/7edeedbcccb24cef1e506538d8df35ca2aedea45" + }, + "date": 1633037737569, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 92.4191, + "range": "±9.85%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 10.9262, + "range": "±9.92%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b1224be94e4b5d679e8c950e597953f813bc3adb", + "message": "RIDE-5275 React devtools inspector integration", + "timestamp": "2021-09-30T21:49:35Z", + "url": "https://github.com/Roblox/roact-alignment/pull/185/commits/b1224be94e4b5d679e8c950e597953f813bc3adb" + }, + "date": 1633039269128, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 82.7136, + "range": "±12.09%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 12.2622, + "range": "±11.75%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b28927f22559f567348efe7f0c63157baedf6a16", + "message": "#nojira - Switch promise to proper semver dep", + "timestamp": "2021-09-30T22:07:19Z", + "url": "https://github.com/Roblox/roact-alignment/pull/218/commits/b28927f22559f567348efe7f0c63157baedf6a16" + }, + "date": 1633042758749, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 88.6473, + "range": "±11.48%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 11.4661, + "range": "±14.63%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "3705df6efca1811e9cd025eaecabd2b49d034605", + "message": "#nojira - Strict mode seems to stumble sometimes when using the 'convert' command", + "timestamp": "2021-09-30T23:05:29Z", + "url": "https://github.com/Roblox/roact-alignment/pull/219/commits/3705df6efca1811e9cd025eaecabd2b49d034605" + }, + "date": 1633109550227, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 114.9687, + "range": "±12.70%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + }, + { + "name": "FrameRate#Δt", + "value": 8.8524, + "range": "±13.99%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980396" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b7a75292e97de978e822d69371f6f1a0435f6cf1", + "message": "Remove troublesome optimization", + "timestamp": "2021-10-01T17:39:47Z", + "url": "https://github.com/Roblox/roact-alignment/pull/221/commits/b7a75292e97de978e822d69371f6f1a0435f6cf1" + }, + "date": 1633134828373, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 108.7327, + "range": "±9.51%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 9.2835, + "range": "±9.88%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "b76de6b4be3953f6b4da77b36e6d0d4a030609bc", + "message": "Adopt jest roblox with rotriever 0.5", + "timestamp": "2021-10-02T00:38:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/181/commits/b76de6b4be3953f6b4da77b36e6d0d4a030609bc" + }, + "date": 1633358802098, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 93.4441, + "range": "±9.58%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 10.8052, + "range": "±10.11%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "aeb440bbb8474f606c94dc50ff4342ee76da2e5f", + "message": "LUAFDN-642", + "timestamp": "2021-10-04T14:53:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/223/commits/aeb440bbb8474f606c94dc50ff4342ee76da2e5f" + }, + "date": 1633636749393, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 110.9846, + "range": "±9.33%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 9.0889, + "range": "±9.40%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "63272527248f7791e9aa6c28f975a563c2743af9", + "message": "LUAFDN-637 Split compatibility layer warnings", + "timestamp": "2021-10-07T20:50:49Z", + "url": "https://github.com/Roblox/roact-alignment/pull/224/commits/63272527248f7791e9aa6c28f975a563c2743af9" + }, + "date": 1633715657098, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 114.8182, + "range": "±11.63%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 8.8265, + "range": "±11.51%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d77d7c6b84a4092ce2124bc2aa2ec77fe3693778", + "message": "#nojira Fix use*Effect create function type", + "timestamp": "2021-10-08T18:05:52Z", + "url": "https://github.com/Roblox/roact-alignment/pull/225/commits/d77d7c6b84a4092ce2124bc2aa2ec77fe3693778" + }, + "date": 1633719578290, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 108.0228, + "range": "±10.24%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 9.3552, + "range": "±10.31%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "26008c76a91bb7eaa458b7ff756b20fd2f2a4522", + "message": "LUAFDN-580: Simple optimizations", + "timestamp": "2021-10-08T19:11:22Z", + "url": "https://github.com/Roblox/roact-alignment/pull/227/commits/26008c76a91bb7eaa458b7ff756b20fd2f2a4522" + }, + "date": 1633733657701, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 110.8305, + "range": "±9.33%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 9.1028, + "range": "±9.49%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a7f90fc02f826dd5240e57c4b351486b64404eab", + "message": "#nojira another fix that Alan's in-development analyze feature turned up", + "timestamp": "2021-10-08T23:23:58Z", + "url": "https://github.com/Roblox/roact-alignment/pull/228/commits/a7f90fc02f826dd5240e57c4b351486b64404eab" + }, + "date": 1633738141909, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 97.9342, + "range": "±11.55%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 10.3541, + "range": "±12.11%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "fa677ba23ad2df4754d2721ae1fa2d2217973a8f", + "message": "#nojira - Bump version for rc.5", + "timestamp": "2021-10-09T00:15:48Z", + "url": "https://github.com/Roblox/roact-alignment/pull/226/commits/fa677ba23ad2df4754d2721ae1fa2d2217973a8f" + }, + "date": 1633738826068, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 84.7773, + "range": "±12.48%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 11.9904, + "range": "±13.20%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "bbbf7f1d1dcdaa33aebf99092913f23ee5829655", + "message": "robloxdev-cli convert can fail in strict mode", + "timestamp": "2021-10-09T01:49:01Z", + "url": "https://github.com/Roblox/roact-alignment/pull/229/commits/bbbf7f1d1dcdaa33aebf99092913f23ee5829655" + }, + "date": 1633747702664, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 92.4659, + "range": "±13.35%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 11.0154, + "range": "±13.83%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "837d50d8d7f22fdcb28ec02b6e05919d2c50f057", + "message": "LUAFDN-623 - Use `act` in compat layer when the `__ROACT_17_INLINE_ACT__` global is enabled", + "timestamp": "2021-10-09T02:54:03Z", + "url": "https://github.com/Roblox/roact-alignment/pull/232/commits/837d50d8d7f22fdcb28ec02b6e05919d2c50f057" + }, + "date": 1634056130205, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 91.1503, + "range": "±11.16%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 11.1085, + "range": "±11.19%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "733f87608ee9d26fe5d0e678a537b7840128a7a5", + "message": "LUAFDN-311: Use recycle pool for queue update objects", + "timestamp": "2021-10-12T16:50:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/231/commits/733f87608ee9d26fe5d0e678a537b7840128a7a5" + }, + "date": 1634078782006, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 107.9921, + "range": "±16.20%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 9.535, + "range": "±18.35%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "30a019de74755f03651f9d84ac961df1f7ce1d71", + "message": "#nojira - Add global for legacy root", + "timestamp": "2021-10-12T22:53:00Z", + "url": "https://github.com/Roblox/roact-alignment/pull/233/commits/30a019de74755f03651f9d84ac961df1f7ce1d71" + }, + "date": 1634083022735, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 91.8054, + "range": "±11.83%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 11.0592, + "range": "±12.94%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "149598c92cfe72abc16c1b101903b1f65f95882b", + "message": "#nojira Work around new Luau recursive type error", + "timestamp": "2021-10-13T00:04:10Z", + "url": "https://github.com/Roblox/roact-alignment/pull/234/commits/149598c92cfe72abc16c1b101903b1f65f95882b" + }, + "date": 1634092706783, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 98.7981, + "range": "±10.20%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 10.2301, + "range": "±10.51%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a3fe1e8dd69f257f26fc29f589ae094fdc660a5d", + "message": "#nojira - Comment out object pool warning for now", + "timestamp": "2021-10-13T15:42:15Z", + "url": "https://github.com/Roblox/roact-alignment/pull/237/commits/a3fe1e8dd69f257f26fc29f589ae094fdc660a5d" + }, + "date": 1634152387014, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 83.0522, + "range": "±10.38%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 12.1658, + "range": "±9.98%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9c07840d01844b4ac2e044c0e5905a407bb08286", + "message": "LUAFDN-311: Moar simple optimizations", + "timestamp": "2021-10-13T19:45:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/230/commits/9c07840d01844b4ac2e044c0e5905a407bb08286" + }, + "date": 1634154458477, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 98.4747, + "range": "±12.80%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + }, + { + "name": "FrameRate#Δt", + "value": 10.3292, + "range": "±13.44%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.498.0.4980398" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "4742d67758a17082104fc2bebc5e864d7f7e5055", + "message": "LUAFDN-311: Optimization pass", + "timestamp": "2021-10-13T19:56:31Z", + "url": "https://github.com/Roblox/roact-alignment/pull/220/commits/4742d67758a17082104fc2bebc5e864d7f7e5055" + }, + "date": 1634161547232, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 90.9743, + "range": "±17.18%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 11.3545, + "range": "±19.46%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "cc4a52d1082b6b9dba96db3ca6cd8a9d98640536", + "message": "#nojira Update rotriever.toml for rc.6", + "timestamp": "2021-10-13T21:46:59Z", + "url": "https://github.com/Roblox/roact-alignment/pull/235/commits/cc4a52d1082b6b9dba96db3ca6cd8a9d98640536" + }, + "date": 1634161825808, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 111.2714, + "range": "±12.91%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + }, + { + "name": "FrameRate#Δt", + "value": 9.1306, + "range": "±12.29%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.499.0.4990382" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "83a6aba3009e4c710e244395cb5cc65ae19351fa", + "message": "ci: update benchmarks workflow", + "timestamp": "2021-10-13T21:55:02Z", + "url": "https://github.com/Roblox/roact-alignment/pull/238/commits/83a6aba3009e4c710e244395cb5cc65ae19351fa" + }, + "date": 1634750348128, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 110.4384, + "range": "±14.04%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 9.2286, + "range": "±13.56%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 813209878, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0025572919, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000955067, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 193876972, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0248137412, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.000692532, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 107852636, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0139138185, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0084221956, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 863680117, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025077016, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000907385, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 209197643, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0256858439, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006438409, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 113831166, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.013516966, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0080468121, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "2b8ebac9d357b22453c27aebc5c446932d49a6c7", + "message": "Change thresholds to udpate FPS branch", + "timestamp": "2021-10-28T18:43:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/242/commits/2b8ebac9d357b22453c27aebc5c446932d49a6c7" + }, + "date": 1635446935875, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 101.4379, + "range": "±12.63%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.012, + "range": "±12.30%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 818307642, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.002604182, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000945891, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194653300, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0256019343, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0007155954, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 107708227, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0140865841, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0084392718, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 847830875, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0024872425, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000913, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 203875429, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.026124899, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006827355, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 110243562, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0139525699, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0082982896, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f5858b79139430febb1f073f2182654db08bd637", + "message": "LUAFDN-694: eliminate unnecessary allocations", + "timestamp": "2021-10-28T19:24:12Z", + "url": "https://github.com/Roblox/roact-alignment/pull/241/commits/f5858b79139430febb1f073f2182654db08bd637" + }, + "date": 1635543019143, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 113.0314, + "range": "±11.13%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 8.9522, + "range": "±10.64%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 814685092, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026486443, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000914893, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 193277425, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0248283885, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006990987, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106626905, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0140953918, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0079710745, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 854149410, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025617321, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000879354, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 205478044, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0262604018, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006617836, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 110335950, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0139449472, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0082669882, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "15ddcfb84d3d69fe5f4937931a783ff1ec3df930", + "message": "Fix Mysterious top bar disappearing bug", + "timestamp": "2021-10-30T01:46:20Z", + "url": "https://github.com/Roblox/roact-alignment/pull/249/commits/15ddcfb84d3d69fe5f4937931a783ff1ec3df930" + }, + "date": 1635830684161, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 100.126, + "range": "±15.36%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.2427, + "range": "±16.83%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 817080364, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026764528, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000882741, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194273271, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0256439395, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006236988, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106760150, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0142370257, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0084952297, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 846419261, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025650822, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000852603, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 203417614, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0259395777, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0005810805, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 109169689, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0141217129, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0077661025, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9d261c78bbe3ce612749e4e8823c918d4acf8c31", + "message": "LUAFDN-692: Add assertions around the instance map to detect bad objects being inserted", + "timestamp": "2021-11-02T16:35:50Z", + "url": "https://github.com/Roblox/roact-alignment/pull/248/commits/9d261c78bbe3ce612749e4e8823c918d4acf8c31" + }, + "date": 1635971043828, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 94.9366, + "range": "±13.25%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.7199, + "range": "±13.34%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 816698414, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026819717, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000883398, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194101849, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.025406945, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006238838, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106740241, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0142652198, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0085030631, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 846064412, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025645943, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000852831, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 203319224, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.026172867, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0005813813, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 109012014, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0141007302, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0077773997, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f683410321e0b3eb3d4d893d1e3dd3692ef35e85", + "message": "LUAFDN-698: Fix possible memory leak in RobloxComponentProps.lua", + "timestamp": "2021-11-03T20:31:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/244/commits/f683410321e0b3eb3d4d893d1e3dd3692ef35e85" + }, + "date": 1635977835869, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 108.5237, + "range": "±12.56%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 9.3568, + "range": "±12.21%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 809726308, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026435513, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.000092183, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 192142111, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0251211459, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0007070184, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 105596879, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.014209795, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0086016273, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 864735057, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025886461, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000901247, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 208831749, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0266201142, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006508062, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 111803334, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0140213439, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0081922065, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "eb240e688e5745b3493f682736278caf005dd4d0", + "message": "#nojira - Cut version rc.7", + "timestamp": "2021-11-04T15:57:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/251/commits/eb240e688e5745b3493f682736278caf005dd4d0" + }, + "date": 1636041793467, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 96.3736, + "range": "±13.84%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.5865, + "range": "±14.57%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 819347735, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026770856, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000897592, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194791676, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0254215586, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006632984, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 107020571, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0141896272, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0078704215, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 850676744, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025674359, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000898555, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 204508979, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0261506758, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006270776, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 109667014, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0141057638, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0077442612, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "353d64f2776e697b033af9735db7c32fc196255c", + "message": "#nojira Emergency fix to try and unblock the reverse merge", + "timestamp": "2021-11-04T16:32:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/253/commits/353d64f2776e697b033af9735db7c32fc196255c" + }, + "date": 1636057640503, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 97.8945, + "range": "±16.83%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.598, + "range": "±22.91%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 819082133, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.002679376, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000900032, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194786816, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0256234539, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006616156, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 107021341, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0142268167, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0078712899, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 850192886, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025556248, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000871332, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 204500703, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0262417435, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006330394, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 109598551, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0140689269, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0077450933, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af", + "message": "Fix __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + "timestamp": "2021-11-04T20:38:54Z", + "url": "https://github.com/Roblox/roact-alignment/pull/255/commits/d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af" + }, + "date": 1636580041941, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 96.9159, + "range": "±16.23%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.6262, + "range": "±18.47%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 809214486, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026377877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000903098, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 191997048, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0250412861, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006743906, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106030523, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0141936865, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0079529929, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 843042916, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025224339, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000891295, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 202529108, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0258065226, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006776557, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 108747571, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0139196672, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0083976956, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c0df797acc7f79c1e0928d1ac7ab884b156b34e5", + "message": "LUAFDN-723: Manually massage non-string errors into strings before re-throwing", + "timestamp": "2021-11-17T19:13:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/254/commits/c0df797acc7f79c1e0928d1ac7ab884b156b34e5" + }, + "date": 1637179367237, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 92.556, + "range": "±12.82%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 10.9828, + "range": "±12.88%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 812200315, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0026660332, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000908704, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 192762398, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0253656732, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006948191, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106215350, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0141445657, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0079289858, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 842813834, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0025463441, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000926848, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 202306672, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.026039769, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0006751779, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 108766444, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0140804548, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0083995207, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0408a7e730e0d979915f8e2cc362cdd6a3e2f65d", + "message": "Further optimizations", + "timestamp": "2021-11-17T20:11:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/240/commits/0408a7e730e0d979915f8e2cc362cdd6a3e2f65d" + }, + "date": 1637191573002, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 121.5116, + "range": "±16.02%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 8.4388, + "range": "±15.66%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 813119913, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0027386465, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000883191, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 193103044, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0253617804, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0006182968, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 106914860, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0141743159, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0078820662, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 859145686, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.002710091, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000838368, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 207050096, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0264632671, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.000591311, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 112192744, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0138498885, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0081461596, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "25e7dcd87c9987a2053ec0a52efc99460a024688", + "message": "Separate scheduler mocking from act inlining", + "timestamp": "2021-11-23T18:15:18Z", + "url": "https://github.com/Roblox/roact-alignment/pull/259/commits/25e7dcd87c9987a2053ec0a52efc99460a024688" + }, + "date": 1638223876372, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 124.5411, + "range": "±12.93%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 8.1603, + "range": "±12.62%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 818490377, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0028084753, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000878679, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 194742578, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0252045241, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0001958123, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 108072216, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0138078782, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0075769983, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 850721900, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0026967485, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.000084788, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 204845810, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.026058019, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0001996282, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 110859975, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0135986771, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0080759896, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a8f8c4933fcae79ebfbd498723b680fb195be5c2", + "message": "LUAFDN-257: Backfilling tests", + "timestamp": "2021-11-29T23:50:37Z", + "url": "https://github.com/Roblox/roact-alignment/pull/258/commits/a8f8c4933fcae79ebfbd498723b680fb195be5c2" + }, + "date": 1638303804007, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 104.461, + "range": "±12.19%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 9.7279, + "range": "±13.32%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 820175672, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0028043663, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000876861, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 195208885, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0252036735, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0001953087, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 108261639, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0138233544, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0075763309, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 851903057, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0026929919, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000848465, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 205205422, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.026036276, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0001995902, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 110981515, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0135920203, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0080710738, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ee62df85c6072dd919a664ffcde25cfac4367380", + "message": "#nojira - Provide properly-formatted args to cachegrind", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/266/commits/ee62df85c6072dd919a664ffcde25cfac4367380" + }, + "date": 1642635785657, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 115.5649, + "range": "±12.01%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 8.7723, + "range": "±11.41%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 776684157, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.0027921234, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000928035, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 185646649, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0258862793, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0002222771, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 102779867, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0140026937, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0086008576, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 819067407, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0027901794, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000881295, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 198374064, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0264724727, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0002096897, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 107884776, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.013726376, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.0082497275, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ae436d269232d09c76caa2e70a48247d7f788d00", + "message": "LUAFDN-707 Fix useCallback hook in DEV mode", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/269/commits/ae436d269232d09c76caa2e70a48247d7f788d00" + }, + "date": 1645033057736, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 116.6819, + "range": "±15.82%", + "unit": "ops/sec", + "extra": "50 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 8.8618, + "range": "±22.98%", + "unit": "ms/op", + "extra": "50 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalInstructionsExecuted", + "value": 781987668, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_InstrReadCacheMisses", + "value": 0.002748982, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_InstrReadCacheMisses", + "value": 0.0000925731, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryReads", + "value": 187148933, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataReadCacheMisses", + "value": 0.0258644114, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataReadCacheMisses", + "value": 0.0002037735, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#TotalMemoryWrites", + "value": 104134469, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#L1_DataWriteCacheMisses", + "value": 0.0139809711, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCGCold#LL_DataWriteCacheMisses", + "value": 0.0078426577, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalInstructionsExecuted", + "value": 819934978, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_InstrReadCacheMisses", + "value": 0.0026727083, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_InstrReadCacheMisses", + "value": 0.0000884351, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryReads", + "value": 198666581, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataReadCacheMisses", + "value": 0.0264090617, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataReadCacheMisses", + "value": 0.0001970034, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#TotalMemoryWrites", + "value": 108222279, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#L1_DataWriteCacheMisses", + "value": 0.0137140246, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "SierpinskiTriangleCG#LL_DataWriteCacheMisses", + "value": 0.007614994, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + } + ] + } + ], + "Concurrent FPS Benchmark": [ + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "83a6aba3009e4c710e244395cb5cc65ae19351fa", + "message": "ci: update benchmarks workflow", + "timestamp": "2021-10-13T21:55:02Z", + "url": "https://github.com/Roblox/roact-alignment/pull/238/commits/83a6aba3009e4c710e244395cb5cc65ae19351fa" + }, + "date": 1634750347531, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.0114, + "range": "±12.58%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.7765, + "range": "±216.30%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1013039154, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0040849349, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000796751, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 253665721, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0324116596, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.000542734, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 131835282, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0140431679, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0081291289, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 1218786656, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0045957264, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000616802, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 320031956, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.030312048, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0004266105, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 161551471, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0131213847, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0067829961, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "2b8ebac9d357b22453c27aebc5c446932d49a6c7", + "message": "Change thresholds to udpate FPS branch", + "timestamp": "2021-10-28T18:43:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/242/commits/2b8ebac9d357b22453c27aebc5c446932d49a6c7" + }, + "date": 1635446935172, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 58.5673, + "range": "±15.90%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 21.4896, + "range": "±235.59%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1008879470, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0040086434, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000733884, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 251598404, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0327976882, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0004869347, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 129712543, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0141858602, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0076575401, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 4129467322, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0046357609, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000194609, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1256536082, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0232074259, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001121146, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 463163595, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0074668412, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0028789201, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f5858b79139430febb1f073f2182654db08bd637", + "message": "LUAFDN-694: eliminate unnecessary allocations", + "timestamp": "2021-10-28T19:24:12Z", + "url": "https://github.com/Roblox/roact-alignment/pull/241/commits/f5858b79139430febb1f073f2182654db08bd637" + }, + "date": 1635543018363, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.7192, + "range": "±17.64%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 21.0245, + "range": "±241.04%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1014207613, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041423885, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000748851, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 253011030, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0320656416, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005455138, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 129376670, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142360752, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0082675725, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 4208281411, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035674021, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000185235, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1275457294, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0222272123, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.000114112, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 448996381, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0079286563, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.002790733, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "15ddcfb84d3d69fe5f4937931a783ff1ec3df930", + "message": "Fix Mysterious top bar disappearing bug", + "timestamp": "2021-10-30T01:46:20Z", + "url": "https://github.com/Roblox/roact-alignment/pull/249/commits/15ddcfb84d3d69fe5f4937931a783ff1ec3df930" + }, + "date": 1635830683347, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.517, + "range": "±20.48%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.7953, + "range": "±218.99%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 991756426, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041977636, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000818447, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 246783384, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0300607799, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005671411, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 126611394, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142507474, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0080308412, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3965771063, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035890831, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000206535, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1198389549, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0227780875, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001329735, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 425304371, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0079870752, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0027689064, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9d261c78bbe3ce612749e4e8823c918d4acf8c31", + "message": "LUAFDN-692: Add assertions around the instance map to detect bad objects being inserted", + "timestamp": "2021-11-02T16:35:50Z", + "url": "https://github.com/Roblox/roact-alignment/pull/248/commits/9d261c78bbe3ce612749e4e8823c918d4acf8c31" + }, + "date": 1635971043128, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.0177, + "range": "±15.02%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.861, + "range": "±218.74%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1003295067, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041586978, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000805855, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 250003120, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0322797811, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005553611, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 127665902, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0143987155, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.008427262, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3966298257, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035920866, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.000020449, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1198368217, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0231661393, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001230006, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 425555803, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0080193878, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0029266409, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f683410321e0b3eb3d4d893d1e3dd3692ef35e85", + "message": "LUAFDN-698: Fix possible memory leak in RobloxComponentProps.lua", + "timestamp": "2021-11-03T20:31:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/244/commits/f683410321e0b3eb3d4d893d1e3dd3692ef35e85" + }, + "date": 1635977835107, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.1095, + "range": "±13.14%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.751, + "range": "±217.15%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1011194546, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041505386, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000750251, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 252214842, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0324414374, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.000548013, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 128512953, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.014297267, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0083381634, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3881436891, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035166435, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000197192, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1170916864, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0227448701, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001219096, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 413885656, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0080433737, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0030083816, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "eb240e688e5745b3493f682736278caf005dd4d0", + "message": "#nojira - Cut version rc.7", + "timestamp": "2021-11-04T15:57:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/251/commits/eb240e688e5745b3493f682736278caf005dd4d0" + }, + "date": 1636041792685, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.3484, + "range": "±16.40%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.8204, + "range": "±220.06%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1010476299, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041487702, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000755782, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 252027421, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0325154936, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005568005, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 128568726, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142415971, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0078197633, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3976818770, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035953522, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000207098, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1201783734, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0230033343, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001384658, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 427004977, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0079157321, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0027848364, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "353d64f2776e697b033af9735db7c32fc196255c", + "message": "#nojira Emergency fix to try and unblock the reverse merge", + "timestamp": "2021-11-04T16:32:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/253/commits/353d64f2776e697b033af9735db7c32fc196255c" + }, + "date": 1636057639779, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.6545, + "range": "±19.20%", + "unit": "ops/sec", + "extra": "602 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.75, + "range": "±219.33%", + "unit": "ms/op", + "extra": "602 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 1009611398, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0041411309, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000730449, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 251785667, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.032161513, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0004819655, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 128386051, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0143111809, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0078203979, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 4094817295, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0035834124, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000196729, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1239499637, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0232027668, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001137943, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 437795385, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0079156636, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.002844829, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af", + "message": "Fix __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + "timestamp": "2021-11-04T20:38:54Z", + "url": "https://github.com/Roblox/roact-alignment/pull/255/commits/d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af" + }, + "date": 1636580041162, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.8658, + "range": "±14.11%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.8721, + "range": "±217.88%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 997480049, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0040783432, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000755845, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 248527952, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0321779339, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005451821, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 127164294, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142132901, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0079358912, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 4046726154, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0048965784, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000200263, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1225407749, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0229194152, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001199364, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 433403228, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0078624541, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.002774384, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c0df797acc7f79c1e0928d1ac7ab884b156b34e5", + "message": "LUAFDN-723: Manually massage non-string errors into strings before re-throwing", + "timestamp": "2021-11-17T19:13:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/254/commits/c0df797acc7f79c1e0928d1ac7ab884b156b34e5" + }, + "date": 1637179366448, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.7993, + "range": "±13.87%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.8784, + "range": "±216.05%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 998974881, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0042061058, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000746425, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 248544760, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0321669304, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005525564, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 127457892, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142206651, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0078887387, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3945569644, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0050094161, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000205659, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1193244715, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0237030507, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001265298, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 424233663, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0079745345, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0029911016, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0408a7e730e0d979915f8e2cc362cdd6a3e2f65d", + "message": "Further optimizations", + "timestamp": "2021-11-17T20:11:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/240/commits/0408a7e730e0d979915f8e2cc362cdd6a3e2f65d" + }, + "date": 1637191572239, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.9932, + "range": "±15.52%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.8788, + "range": "±216.46%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 980006820, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0042501255, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000770719, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 244452045, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0323344483, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0005700014, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 126082626, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142460151, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0084789874, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3776357658, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.005257448, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000213687, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1144176848, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0254427723, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0001306555, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 405040311, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0080091781, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0029164184, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "25e7dcd87c9987a2053ec0a52efc99460a024688", + "message": "Separate scheduler mocking from act inlining", + "timestamp": "2021-11-23T18:15:18Z", + "url": "https://github.com/Roblox/roact-alignment/pull/259/commits/25e7dcd87c9987a2053ec0a52efc99460a024688" + }, + "date": 1638223875584, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.0812, + "range": "±23.96%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 21.1278, + "range": "±241.65%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 968442913, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0042938845, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.000076624, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 241320214, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0294751811, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0001892962, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 125196009, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0142336726, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0083117346, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3997050560, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0057786169, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000201173, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1215125694, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.02295465, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0000501685, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 424866495, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0077220069, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0029660658, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a8f8c4933fcae79ebfbd498723b680fb195be5c2", + "message": "LUAFDN-257: Backfilling tests", + "timestamp": "2021-11-29T23:50:37Z", + "url": "https://github.com/Roblox/roact-alignment/pull/258/commits/a8f8c4933fcae79ebfbd498723b680fb195be5c2" + }, + "date": 1638303802595, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.2405, + "range": "±16.86%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.8072, + "range": "±218.69%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 978441008, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0042597233, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000756591, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 244096114, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0321219821, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0001852139, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 125834086, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.014123987, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0083668427, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3585245170, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0057797425, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000223109, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1082597144, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0239946042, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0000530945, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 387524782, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0083672946, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0029913648, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ee62df85c6072dd919a664ffcde25cfac4367380", + "message": "#nojira - Provide properly-formatted args to cachegrind", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/266/commits/ee62df85c6072dd919a664ffcde25cfac4367380" + }, + "date": 1642635785020, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 59.8566, + "range": "±16.19%", + "unit": "ops/sec", + "extra": "600 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.9111, + "range": "±217.36%", + "unit": "ms/op", + "extra": "600 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 941628238, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.004300778, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000775221, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 235512639, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0333123905, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0001961126, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 121781075, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0143914315, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.008622366, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3946457362, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0066009752, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.0000186699, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1190859350, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.023141785, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0000427171, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 431363069, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0074884297, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0028635622, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ae436d269232d09c76caa2e70a48247d7f788d00", + "message": "LUAFDN-707 Fix useCallback hook in DEV mode", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/269/commits/ae436d269232d09c76caa2e70a48247d7f788d00" + }, + "date": 1645033057003, + "tool": "roblox", + "benches": [ + { + "name": "FrameRate#FPS", + "value": 60.043, + "range": "±12.18%", + "unit": "ops/sec", + "extra": "601 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#Δt", + "value": 19.7027, + "range": "±215.42%", + "unit": "ms/op", + "extra": "601 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalInstructionsExecuted", + "value": 944067549, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_InstrReadCacheMisses", + "value": 0.0042270206, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_InstrReadCacheMisses", + "value": 0.0000782327, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryReads", + "value": 236434226, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataReadCacheMisses", + "value": 0.0285875489, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataReadCacheMisses", + "value": 0.0002143514, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#TotalMemoryWrites", + "value": 122583944, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#L1_DataWriteCacheMisses", + "value": 0.0135607237, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRateCold#LL_DataWriteCacheMisses", + "value": 0.0085658771, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#TotalInstructionsExecuted", + "value": 3772251072, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#L1_InstrReadCacheMisses", + "value": 0.0038987092, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#LL_InstrReadCacheMisses", + "value": 0.000019478, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryReads", + "value": 1136534431, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataReadCacheMisses", + "value": 0.0211125826, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataReadCacheMisses", + "value": 0.0000422539, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#TotalMemoryWrites", + "value": 418628478, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#L1_DataWriteCacheMisses", + "value": 0.0071796907, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FrameRate#LL_DataWriteCacheMisses", + "value": 0.0027797798, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + } + ] + } + ], + "Concurrent First Render Benchmark": [ + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "83a6aba3009e4c710e244395cb5cc65ae19351fa", + "message": "ci: update benchmarks workflow", + "timestamp": "2021-10-13T21:55:02Z", + "url": "https://github.com/Roblox/roact-alignment/pull/238/commits/83a6aba3009e4c710e244395cb5cc65ae19351fa" + }, + "date": 1634750347681, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 75.2551, + "range": "±20.16%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1008291492, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039079711, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000784208, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 251994778, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0323985285, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005412811, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 131144479, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0139656889, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0076666819, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1301683270, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0047150679, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000598971, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 339968321, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0374082619, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004283487, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 165581185, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0140382435, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0077772, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.500.0.5000375", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "2b8ebac9d357b22453c27aebc5c446932d49a6c7", + "message": "Change thresholds to udpate FPS branch", + "timestamp": "2021-10-28T18:43:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/242/commits/2b8ebac9d357b22453c27aebc5c446932d49a6c7" + }, + "date": 1635446935336, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 83.4865, + "range": "±17.71%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1001097616, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0038882432, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000737101, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 249069032, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0327613671, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0004886718, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 128552459, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143523509, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0078245022, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1294594763, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0046751325, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.000062021, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 336858761, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0380595118, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004172402, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 162661188, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.014083046, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0078572216, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.501.0.5010364", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f5858b79139430febb1f073f2182654db08bd637", + "message": "LUAFDN-694: eliminate unnecessary allocations", + "timestamp": "2021-10-28T19:24:12Z", + "url": "https://github.com/Roblox/roact-alignment/pull/241/commits/f5858b79139430febb1f073f2182654db08bd637" + }, + "date": 1635543018561, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 84.3454, + "range": "±16.45%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1005909468, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039982604, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000727451, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 250433445, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0321654642, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005015185, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 128043272, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0142848037, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0083716855, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1294460612, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0047886185, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000577368, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 336880536, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0379179491, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0003835098, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 160170219, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0141834544, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.007577064, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "15ddcfb84d3d69fe5f4937931a783ff1ec3df930", + "message": "Fix Mysterious top bar disappearing bug", + "timestamp": "2021-10-30T01:46:20Z", + "url": "https://github.com/Roblox/roact-alignment/pull/249/commits/15ddcfb84d3d69fe5f4937931a783ff1ec3df930" + }, + "date": 1635830683532, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 74.5102, + "range": "±17.46%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 997661143, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040077806, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.000080808, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 248135958, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0317169066, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005528985, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 126755678, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143334013, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0079465237, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1291094522, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0048074606, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.000059606, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 336116352, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0373720229, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004293245, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 159466654, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0142697607, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0080173815, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "9d261c78bbe3ce612749e4e8823c918d4acf8c31", + "message": "LUAFDN-692: Add assertions around the instance map to detect bad objects being inserted", + "timestamp": "2021-11-02T16:35:50Z", + "url": "https://github.com/Roblox/roact-alignment/pull/248/commits/9d261c78bbe3ce612749e4e8823c918d4acf8c31" + }, + "date": 1635971043312, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 77.2848, + "range": "±19.78%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1001633810, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040008933, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000796998, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 249342119, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0323959908, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005556662, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 127221524, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0142597018, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0084335022, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1294684784, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0048094108, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000621371, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 337094356, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0371542975, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004435257, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 160039345, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0142141734, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0079454337, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "f683410321e0b3eb3d4d893d1e3dd3692ef35e85", + "message": "LUAFDN-698: Fix possible memory leak in RobloxComponentProps.lua", + "timestamp": "2021-11-03T20:31:27Z", + "url": "https://github.com/Roblox/roact-alignment/pull/244/commits/f683410321e0b3eb3d4d893d1e3dd3692ef35e85" + }, + "date": 1635977835301, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 86.1342, + "range": "±19.40%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1003988674, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039910968, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.000073466, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 249985534, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0323328149, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0004852081, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 127426435, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143337683, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0079004957, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1298860753, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.004793055, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000617764, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 338239211, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.037879189, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.000408152, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 160614831, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0141966466, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0075123635, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "eb240e688e5745b3493f682736278caf005dd4d0", + "message": "#nojira - Cut version rc.7", + "timestamp": "2021-11-04T15:57:57Z", + "url": "https://github.com/Roblox/roact-alignment/pull/251/commits/eb240e688e5745b3493f682736278caf005dd4d0" + }, + "date": 1636041792868, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 68.8148, + "range": "±21.07%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1003311971, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039861101, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000790781, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 249757542, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0320029935, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005453649, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 127480552, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0142819432, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0078976909, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1297066144, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0047821224, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000623931, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 337662886, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0370992594, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004426131, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 160257203, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.014175706, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0079384201, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "353d64f2776e697b033af9735db7c32fc196255c", + "message": "#nojira Emergency fix to try and unblock the reverse merge", + "timestamp": "2021-11-04T16:32:04Z", + "url": "https://github.com/Roblox/roact-alignment/pull/253/commits/353d64f2776e697b033af9735db7c32fc196255c" + }, + "date": 1636057639936, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 93.9404, + "range": "±21.53%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 1006208057, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039963375, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000765349, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 250535713, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0321945798, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005606506, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 127805931, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143071608, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0084016367, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1299834542, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0047749877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.000064579, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 338244658, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0375075576, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0005048919, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 160503436, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0142595701, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0079954986, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.502.0.5020361", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af", + "message": "Fix __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + "timestamp": "2021-11-04T20:38:54Z", + "url": "https://github.com/Roblox/roact-alignment/pull/255/commits/d4ea3c7eb4e9d35d3428b6c0e88ef2ac2acdf2af" + }, + "date": 1636580041353, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 66.3608, + "range": "±11.73%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 991765365, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0038989908, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000800522, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 246567715, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0316423097, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005537667, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 126335185, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143483306, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.007993933, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1288064329, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0046744203, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.000063142, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 335241794, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0371333027, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004493503, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 159498305, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0142605215, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0080478222, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.503.0.5030338", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "c0df797acc7f79c1e0928d1ac7ab884b156b34e5", + "message": "LUAFDN-723: Manually massage non-string errors into strings before re-throwing", + "timestamp": "2021-11-17T19:13:06Z", + "url": "https://github.com/Roblox/roact-alignment/pull/254/commits/c0df797acc7f79c1e0928d1ac7ab884b156b34e5" + }, + "date": 1637179366643, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 77.752, + "range": "±14.81%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 991403516, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0039822403, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000804577, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 246149354, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0325112533, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.000569967, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 126147629, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143439319, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0080020212, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1281322520, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.004774186, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000638645, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 332850101, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.037553334, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0006000509, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 158585723, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0141942475, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.00772183, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "0408a7e730e0d979915f8e2cc362cdd6a3e2f65d", + "message": "Further optimizations", + "timestamp": "2021-11-17T20:11:34Z", + "url": "https://github.com/Roblox/roact-alignment/pull/240/commits/0408a7e730e0d979915f8e2cc362cdd6a3e2f65d" + }, + "date": 1637191572426, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 81.263, + "range": "±17.51%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 971447434, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040348637, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000762687, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 241600795, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0319790711, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0005194478, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 124747799, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143508103, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0085932177, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1242963947, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.004930744, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000645522, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 324137736, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.037276897, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0004386345, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 155983260, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0144418061, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0082045214, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.504.0.5040413", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "25e7dcd87c9987a2053ec0a52efc99460a024688", + "message": "Separate scheduler mocking from act inlining", + "timestamp": "2021-11-23T18:15:18Z", + "url": "https://github.com/Roblox/roact-alignment/pull/259/commits/25e7dcd87c9987a2053ec0a52efc99460a024688" + }, + "date": 1638223875792, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 75.1508, + "range": "±14.50%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 971253787, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0041026774, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000786036, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 241760783, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0321111716, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0002487542, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 124737982, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0142520985, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0084874309, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1241667324, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0049998634, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000674488, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 324129878, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0376957751, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0002529542, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 155906000, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0141732005, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0081301682, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "a8f8c4933fcae79ebfbd498723b680fb195be5c2", + "message": "LUAFDN-257: Backfilling tests", + "timestamp": "2021-11-29T23:50:37Z", + "url": "https://github.com/Roblox/roact-alignment/pull/258/commits/a8f8c4933fcae79ebfbd498723b680fb195be5c2" + }, + "date": 1638303802819, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 73.9718, + "range": "±11.39%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 972856014, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040965877, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000829773, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 242194304, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0322182845, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0002372888, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 124922867, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0142667155, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.008443258, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1246658098, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0049789104, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000647587, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 325516975, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0376029115, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0002063917, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 156528984, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0142833355, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0076302035, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.505.0.5050420", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ee62df85c6072dd919a664ffcde25cfac4367380", + "message": "#nojira - Provide properly-formatted args to cachegrind", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/266/commits/ee62df85c6072dd919a664ffcde25cfac4367380" + }, + "date": 1642635785163, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 68.6806, + "range": "±14.94%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 933921654, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040463362, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000779894, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 232889043, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0330843173, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0001820953, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 120502797, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0143927697, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0081765156, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1205049163, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0048708278, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000614606, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 314110851, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.03850193, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0001673931, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 152003053, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0145684837, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0082834718, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.510.0.5100270", + "type": "roblox" + } + ] + }, + { + "commit": { + "author": { + "name": "Roblox", + "username": "Roblox" + }, + "committer": { + "name": "Roblox", + "username": "Roblox" + }, + "id": "ae436d269232d09c76caa2e70a48247d7f788d00", + "message": "LUAFDN-707 Fix useCallback hook in DEV mode", + "timestamp": "2021-12-13T22:01:30Z", + "url": "https://github.com/Roblox/roact-alignment/pull/269/commits/ae436d269232d09c76caa2e70a48247d7f788d00" + }, + "date": 1645033057185, + "tool": "roblox", + "benches": [ + { + "name": "FirstRender#Δt", + "value": 64.0077, + "range": "±14.29%", + "unit": "ms/op", + "extra": "200 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalInstructionsExecuted", + "value": 936249185, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_InstrReadCacheMisses", + "value": 0.0040213856, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_InstrReadCacheMisses", + "value": 0.0000785144, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryReads", + "value": 233801688, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataReadCacheMisses", + "value": 0.0328478638, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataReadCacheMisses", + "value": 0.0002094253, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#TotalMemoryWrites", + "value": 121256190, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#L1_DataWriteCacheMisses", + "value": 0.0144906417, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRenderCold#LL_DataWriteCacheMisses", + "value": 0.0086891234, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#TotalInstructionsExecuted", + "value": 1206104495, + "range": "±0%", + "unit": "executions", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#L1_InstrReadCacheMisses", + "value": 0.0048878576, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#LL_InstrReadCacheMisses", + "value": 0.0000610436, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryReads", + "value": 314956439, + "range": "±0%", + "unit": "reads", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataReadCacheMisses", + "value": 0.0380605014, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataReadCacheMisses", + "value": 0.0001507542, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#TotalMemoryWrites", + "value": 152961814, + "range": "±0%", + "unit": "writes", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#L1_DataWriteCacheMisses", + "value": 0.0144012479, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + }, + { + "name": "FirstRender#LL_DataWriteCacheMisses", + "value": 0.0077704426, + "range": "±0%", + "unit": "misses/op", + "extra": "1 samples\nroblox-cli version: 0.514.0.5140397", + "type": "roblox" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docs/bench/index.js b/docs/bench/index.js new file mode 100644 index 00000000..618f0ace --- /dev/null +++ b/docs/bench/index.js @@ -0,0 +1,175 @@ +"use strict"; +(function () { + // Colors from https://github.com/github/linguist/blob/master/lib/linguist/languages.yml + const toolColors = { + cargo: "#dea584", + go: "#00add8", + benchmarkjs: "#f1e05a", + pytest: "#3572a5", + googlecpp: "#f34b7d", + catch2: "#f34b7d", + roblox: "#000080", + _: "#333333", + }; + + function init(data) { + function collectBenchesPerTestCase(entries) { + const map = new Map(); + for (const entry of entries) { + const { commit, date, tool, benches } = entry; + for (const bench of benches) { + const result = { commit, date, tool, bench }; + const arr = map.get(bench.name); + if (arr === undefined) { + map.set(bench.name, [result]); + } else { + arr.push(result); + } + } + } + return map; + } + + // Render header + document.getElementById("last-update").textContent = new Date( + data.lastUpdate + ).toString(); + const repoLink = document.getElementById("repository-link"); + repoLink.href = data.repoUrl; + repoLink.textContent = data.repoUrl; + + // Render footer + document.getElementById("dl-button").onclick = () => { + const dataUrl = "data:," + JSON.stringify(data, null, 2); + const a = document.createElement("a"); + a.href = dataUrl; + a.download = "benchmark_data.json"; + a.click(); + }; + + // Prepare data points for charts + return Object.keys(data.entries).map((name) => ({ + name, + dataSet: collectBenchesPerTestCase(data.entries[name]), + })); + } + + function renderAllChars(dataSets) { + function renderGraph(parent, name, dataset) { + const canvas = document.createElement("canvas"); + canvas.className = "benchmark-chart"; + parent.appendChild(canvas); + + const color = toolColors[dataset.length > 0 ? dataset[0].tool : "_"]; + const data = { + labels: dataset.map((d) => d.commit.id.slice(0, 7)), + datasets: [ + { + label: name, + data: dataset.map((d) => d.bench.value), + borderColor: color, + backgroundColor: color + "60", // Add alpha for #rrggbbaa + }, + ], + }; + const options = { + scales: { + xAxes: [ + { + scaleLabel: { + display: true, + labelString: "commit", + }, + }, + ], + yAxes: [ + { + scaleLabel: { + display: true, + labelString: dataset.length > 0 ? dataset[0].bench.unit : "", + }, + ticks: { + beginAtZero: true, + }, + }, + ], + }, + tooltips: { + callbacks: { + afterTitle: (items) => { + const { index } = items[0]; + const data = dataset[index]; + return ( + "\n" + + data.commit.message + + "\n\n" + + data.commit.timestamp + + " committed by @" + + data.commit.committer.username + + "\n" + ); + }, + label: (item) => { + let label = item.value; + const { range, unit } = dataset[item.index].bench; + label += " " + unit; + if (range) { + label += " (" + range + ")"; + } + return label; + }, + afterLabel: (item) => { + const { extra } = dataset[item.index].bench; + return extra ? "\n" + extra : ""; + }, + }, + }, + onClick: (_mouseEvent, activeElems) => { + if (activeElems.length === 0) { + return; + } + // XXX: Undocumented. How can we know the index? + const index = activeElems[0]._index; + const url = dataset[index].commit.url; + window.open(url, "_blank"); + }, + }; + + new Chart(canvas, { + type: "line", + data, + options, + }); + } + + function renderBenchSet(name, benchSet, main) { + const setElem = document.createElement("div"); + setElem.className = "benchmark-set"; + main.appendChild(setElem); + + const nameElem = document.createElement("h1"); + nameElem.className = "benchmark-title"; + nameElem.textContent = name; + setElem.appendChild(nameElem); + + const graphsElem = document.createElement("div"); + graphsElem.className = "benchmark-graphs"; + setElem.appendChild(graphsElem); + + for (const [benchName, benches] of benchSet.entries()) { + renderGraph(graphsElem, benchName, benches); + } + } + + const main = document.getElementById("main"); + for (const { name, dataSet } of dataSets) { + renderBenchSet(name, dataSet, main); + } + } + + fetch("./data.json") + .then((response) => response.json()) + .then((data) => { + renderAllChars(init(data)); + }); +})(); diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..1275c566 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,70 @@ + +Roact can currently be configured by assigning values to a small number of special global variables. Typically, they need to be set up before `React` or `ReactRoblox` is initialized via `require`. There are a couple of ways to do this: + +* Set globals using the `lua.globals` argument to tooling like `robloxdev-cli` + * If you're using `lest`, you can do this [in a configuration file](https://github.com/Roblox/lest/#configuration) +* Assign values immediately at the beginning of the entry point script in your project + * For lua-apps projects, you can do this conditionally by checking if you're in studio in your project's `StarterScript`, and then assigning globals accordingly + +## Globals + +### \_\_DEV\_\_ +Enabling `_G.__DEV__` enables "Dev Mode", a general-purpose option that sacrifices performance to enable a number of features that improve the development experience: + +* Component `render` methods are run twice to ensure that no side-effects are being counted upon +* Warnings for behavior that violates Roact rules and best practices, like: + * Reading state when it hasn't been initialized + * Calling `setState` before a component has mounted + * Assigning [multiple keys](deviations.md#stable-keys) to a component + * Failing to assign keys to elements in a list (a potential de-optimization) +* Warnings for the use of deprecated components or features +* Validation of properties passed into components via [`validateProps` or `propTypes`](deviations.md#property-validation) + +You should enable Dev Mode in any or all of the following situations: + +* Running unit tests and rhodium tests +* Running storybooks +* Developing and testing locally as you work + +**Dev Mode is _not_ meant to be enabled on production.** While it exposes a great deal of useful information and introduces extra assurances, it pays a hefty performance cost to do so. + +!!! info + In the future, projects will use tools like [darklua](https://gitlab.com/seaofvoices/darklua) to automatically remove all code branches that check for Dev Mode when creating bundles for production. This reduces the overhead of branching on Dev Mode logic and saves a little bit of extra performance in places where it matters. + +### \_\_DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION\_\_ +Occasionally, some older projects will issue more warnings in Dev Mode than can easily be resolved. In order to introduce prop validation but silence all other Dev Mode warnings, set the `__DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__` global to `true`. + +!!! info + Typically, this is only necessary in tests that are strict about reducing warning output. In general, prefer the full-featured Dev Mode. + +### \_\_COMPAT_WARNINGS\_\_ +Enables compatibility warnings for any uses of outdated APIs in your code. These compatibility mismatches should have no effect on behavior, but can be modernized to better align to standards and anticipate future releases. Compat warnings will help you surface uses of outdated APIs when you [migrate from Roact 1.x](migrating-from-roact-1x.md/#updating-conventions-and-apis). + + +### \_\_ROACT_17_MOCK_SCHEDULER\_\_ +Ensure that Roact's internal scheduler is mocked instead of using real async logic like `task.delay`. This is useful in conjunction with [the `act` function](api-reference/react-roblox.md#reactrobloxact) to test concurrent behavior via the "arrange-act-assert" pattern. + +Use this global in test configuration to make sure that you're not inadvertently relying on asynchronous logic in tests. Since Roact 17 uses concurrent rendering by default, you will always need this global to be set to `true` (except when using the [`__ROACT_17_COMPAT_LEGACY_ROOT__`](#ROACT17COMPATLEGACYROOT) global described below). + +!!! caution + In future updates, Roact should _always_ mock the scheduler when in a testing environment and avoid extra configuration. For now, Roact favors explicitness while we shore up the testing experience. + +### \_\_ROACT_17_INLINE_ACT\_\_ +This global will automatically wrap the behavior of `RoactCompat.mount`, `RoactCompat.update`, and `RoactCompat.unmount` in `ReactRoblox.act`, which ensures that queued actions will be played forward by the mocked scheduler. + +Use this global to shore up existing tests that may not be + +**This is intended for tests only, and will not work correctly unless `__ROACT_17_MOCK_SCHEDULER__` is also enabled.** + +### \_\_ROACT_17_COMPAT_LEGACY_ROOT\_\_ +Ensures that the `RoactCompat.mount` compatibility function creates a Legacy Root instead of a Concurrent Root, which is the default behavior. + +Use this global to preserve old behavior in certain testing scenarios. If you need to explicitly rely on a legacy root in production, consider opting for the [`createLegacyRoot`](api-reference/react-roblox.md#reactrobloxcreatelegacyroot) API instead. + + \ No newline at end of file diff --git a/docs/deviations.md b/docs/deviations.md index be3766f2..ae0bf03a 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -1,16 +1,203 @@ -# Deviations -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. +While Roact has been architected to align with React JS's APIs and idioms, a small number of deviations have been introduced for one or several of the following reasons: + +* Differences between JavaScript and Luau +* Differences between Roblox and the HTML DOM +* Supporting features from legacy Roact that are not in React JS +* Easier adoption of Roact 17+ by users of legacy Roact + +The following list attempts to comprehensively describe all of the differences between Roact 17+ and its equivalent upstream version in React JS. It is intended to be a companion to the [Roact 17 adoption guide](migrating-from-1x/adopt-new-features.md), which focuses more on the differences between legacy Roact and Roact 17+. + +## JSX + +The Luau ecosystem does not yet have the tooling to support JSX. Instead, use `React.createElement` as your primary tool for building UIs with Roact 17. Element construction in Roact is exactly like [using React without JSX](https://react.dev/reference/react/createElement#creating-an-element-without-jsx). + +!!! info + Future support for a JSX-equivalent feature for Luau has been proposed, and will be considered as Roact 17+ is adopted. + +## React.useState + +`React.useState` returns two values rather than an array containing two values. + +Luau does not have syntactic sugar for destructuring like javascript: +```js +const [value, setValue] = React.useState(0); +``` + +However, it _does_ support multiple return values, so we can support a very similar usage: +```lua +local value, setValue = React.useState(0) +``` + +## Hook Dependency Arrays + +### Dependency Arrays + +In React JS, some hooks [accept an array of dependency values](https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects) that determine when they need to be re-invoked: +```js +useEffect(() => { + document.title = `You clicked ${count} times`; +}, [count]); // Only re-run the effect if count changes +``` + +The dependency array should always be specified inline rather than composed dynamically to ensure that it accurately reflects all values relevant to the hook, whatever their state may be during a given render. + +React JS provides warnings in DEV mode when a dependency array changes length between renders, an indication that the feature isn't being used correctly. It additionally provides a linter plugin that can help enforce these rules and prevent mistakes during development: https://www.npmjs.com/package/eslint-plugin-react-hooks + +### Deviations for Luau + +Hooks in Roact 17+ aim to treat dependency arrays exactly like React JS. However, arrays in luau that contain nil-able values might be indistinguishable from similar arrays that simply have different lengths. + +For example: +```lua +print(#{"A", "B", nil} == #{ "A", "B" }) -- prints: true +``` + +This means that, if we align behavior directly with React JS, we introduce a possible scenario in which a correctly-specified dependency array triggers warnings about differences in length: +```lua +local root = ReactRoblox.createRoot(someContainer) +local function Component(props: { A: number, B: number? }) + React.useEffect(function() + -- Trigger some effect + end, { props.A, props.B }) + + return nil +end + +-- does not warn: +root:render(React.createElement(Component, { A = 1, B = 2 })) +-- subsequent render warns about different length arrays: +root:render(React.createElement(Component, { A = 1 })) +``` + +To address this and support the same API as React JS, Roact 17 introduces two minor deviations: + +* If a dependency array changes in length, **a re-render will always be triggered** (in React JS, the comparison is short-circuited in production with the assumption that warnings had been ignored or addressed) +* If a dependency array changes in length, we assume the developer provided an array ending with one or more nil-able values, and we **suppress the warning** + +Hooks affected: + +* `useEffect` +* `useLayoutEffect` +* `useMemo` +* `useCallback` + +## Stable Keys + +In React JS, the reserved "key" prop is used to provide stable identities to DOM elements. This improves performance when list-like data is reordered by helping React understand which elements are which, instead of simply modifying the element at each position to line up with the new ordering (more info in the [React documentation](https://reactjs.org/docs/lists-and-keys.html)). + +Since order has no inherent meaning in Roblox's DOM, legacy Roact generally expected children to be provided as a _map_ instead of an array, where the keys to the map are the stable keys associated with the elements. This behavior was used instead of a reserved "key" prop (more info in the [Roact documentation](https://roblox.github.io/roact/performance/reduce-reconciliation/#stable-keys)). + +Roact 17+ supports _both_ methods for providing keys. Both of the following examples are valid and equivalent. + +With table keys: +```lua +-- Returns a fragment of items in an ordered list +function NumberList(props) + local numbers = props.numbers + for i, number in numbers do + local key = tostring(i) + listItems[key] = React.createElement("TextLabel", { + Text = key, + LayoutOrder = i, + }) + end); + return listItems +end +``` + +Using the special "key" prop: +```lua +-- Returns a fragment of items in an ordered list +function NumberList(props) + local numbers = props.numbers + for i, number in numbers do + local key = tostring(i) + local element = React.createElement("TextLabel", { + key = key, + Text = key, + LayoutOrder = i, + }) + table.insert(listItems, element) + end); + return listItems +end +``` + +If your component provides keys using both methods at the same time, Roact will consider this a mistake and print a warning. The following code would result in a warning: +```lua +return React.createElement("Frame", nil, { + Label = React.createElement("TextLabel", { + key = "label1", + Text = "Hello", + }) +}) +``` + +In the above example, Roact doesn't know whether you wanted to use "label1" or "Label" as the key, so it falls back to the explicitly provided key ("label1"). In [Dev Mode](configuration.md#dev), it will output an appropriate warning as well. ## Class Components -Lua does not have ES6's `class` semantics. For class components, Roact will expose `Component:extend(name: string) -> Component` to bridge this gap. Equivalently, `PureComponent:extend` is used to define PureComponents. +Luau does not currently have ES6's `class` semantics. For class components, Roact exposes an `extend` method to provide equivalent behavior. + +### React.Component:extend +``` +React.Component:extend(name: string): ReactComponent +``` +The `extend` method on components replaces the `extend` behavior used in ES6's class components. It returns a React component definition, which can then be used to define lifecycle methods. + +For example, a class component in Roact can be created like this: +```lua +local MyComponent = React.Component:extend("MyComponent") + +function MyComponent:render() + return React.createElement("TextLabel", {Text = self.props.text}) +end + +function MyComponent:componentDidMount() + print("rendered with text " .. self.props.text) +end +``` + +Equivalently, `React.PureComponent:extend` is used to define PureComponents. ### Constructors -Because of the lack of class semantics, there are also no inheritable constructors; instead, Roact provides a lifecycle method called `init` that takes the place of the constructor, running immediately after an instance is created for that class. +Since Luau currently lacks a `class` feature, there are also no inheritable constructors; instead, Roact provides a lifecycle method called `init` that takes the place of the constructor, running immediately after an instance of that class is created. + +For all intents and purposes, this should behave exactly like a constructor for a class component in React JS, except that there is no `super` logic needed. + +### Calling `setState` in Constructors +In React JS, `setState` is not allowed inside component constructors. Instead, React documentation suggests that `this.state` should be assigned to directly, but _never anywhere else_. + +Legacy Roact opts to allow `setState` inside of the `init` method (equivalent to a constructor), because it allows documentation to consistently warn against assigning directly to `self.state`. However, for backwards compatibility, it still supports direct assignments to `self.state` in `init`. + +#### Recommended Use + +As with legacy Roact, Roact 17 allows both direct assignment and use of `setState`. This allows guidance from legacy Roact documentation and common practice to remain accurate. + +In Roact 17+, it is still recommended to use `setState` inside of component `init` methods. This means that you will _always_ avoid assigning directly to `self.state`. + +#### Behavior + +When used in a constructor, `setState` will treat the `updater` argument exactly as it does elsewhere. + +* If `setState` is called multiple times in a constructor, each subsequent update will be merged into previous state +* The `updater` argument can be a function that accepts previous state and returns a new table that will be merged into any previous state +* The `updater` argument can be a table that will be merged into any previous state -For all intents and purposes, this should function exactly like a constructor for a class component in React, except that there is no need to call `super`. +!!! caution + When using `setState` in a constructor, the optional `callback` argument will not be used. Instead, consider putting the desired behavior in a `componentDidMount` implementation. + +### Error Boundaries + +Error boundaries are not yet fully supported due to a limitation in Luau around recursive `pcall` depth. Future updates to React will unravel the recursive traversal and enable these features. + +### Property Validation +The legacy api `validateProps` is still present and has a backwards-compatible API. ## Function Components -JavaScript Functions are also objects, which means that they can have member fields defined on them. Lua/Luau does not allow this. +In JavaScript, functions are also objects, which means that they can have member fields defined on them. Luau does not allow this, so some features are not available on function components. + +!!! info + With the introduction of Hooks, function components are the preferred style of component definition. Giving up features like `defaultProps` and prop validation is not ideal, so future API additions may provide a way to create smarter function components. ### defaultProps For the time being, function components do not support the `defaultProps` feature. In the future, we may want to re-implement it in terms of hooks to make sure that function components with hooks are as appealing and feature-rich as possible. @@ -18,5 +205,85 @@ 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. -### 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. +### validateProps +In Roact 17, we continue to support legacy Roact's `validateProps`. Prior Roact documentation on this method can be found [here](https://roblox.github.io/roact/api-reference/#validateprops). + +## Bindings and Refs +Roact supports callback refs, refs created using `React.createRef`, and refs using the `React.useRef` hook. However, under the hood, Refs are built on top of a concept called Bindings. + +### Bindings +Roact introduces a bindings feature that provides a unidirectional data binding that can be updated outside of the render cycle (much like refs could). + +For now, bindings are documented in more detail [here](https://roblox.github.io/roact/advanced/bindings-and-refs/#bindings). + +### Host Properties with Instance Values +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 *with refs implemented as bindings*, 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 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. diff --git a/docs/diff-bridge-shutdown-method-after.png b/docs/diff-bridge-shutdown-method-after.png new file mode 100644 index 00000000..16683ab0 Binary files /dev/null and b/docs/diff-bridge-shutdown-method-after.png differ diff --git a/docs/diff-bridge-shutdown-method-before.png b/docs/diff-bridge-shutdown-method-before.png new file mode 100644 index 00000000..fb24d1b1 Binary files /dev/null and b/docs/diff-bridge-shutdown-method-before.png differ diff --git a/docs/diff-class-arrow-function-method-new.png b/docs/diff-class-arrow-function-method-new.png new file mode 100644 index 00000000..cad47af1 Binary files /dev/null and b/docs/diff-class-arrow-function-method-new.png differ diff --git a/docs/diff-class-arrow-function-method-old.png b/docs/diff-class-arrow-function-method-old.png new file mode 100644 index 00000000..2a5b9f4c Binary files /dev/null and b/docs/diff-class-arrow-function-method-old.png differ diff --git a/docs/diff-packages.png b/docs/diff-packages.png new file mode 100644 index 00000000..4a71848b Binary files /dev/null and b/docs/diff-packages.png differ diff --git a/docs/diff-spread-flow-type-no-deviation-anymore.png b/docs/diff-spread-flow-type-no-deviation-anymore.png new file mode 100644 index 00000000..9bb0caa7 Binary files /dev/null and b/docs/diff-spread-flow-type-no-deviation-anymore.png differ diff --git a/docs/diff-upstream-comment.png b/docs/diff-upstream-comment.png new file mode 100644 index 00000000..4ae6f6a4 Binary files /dev/null and b/docs/diff-upstream-comment.png differ 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/images/aligned.svg b/docs/images/aligned.svg new file mode 100644 index 00000000..3111f889 --- /dev/null +++ b/docs/images/aligned.svg @@ -0,0 +1 @@ +AlignedAligned \ No newline at end of file diff --git a/docs/images/apichange.svg b/docs/images/apichange.svg new file mode 100644 index 00000000..4c223362 --- /dev/null +++ b/docs/images/apichange.svg @@ -0,0 +1 @@ +API ChangeAPI Change \ No newline at end of file diff --git a/docs/images/deviation.svg b/docs/images/deviation.svg new file mode 100644 index 00000000..4a93fd91 --- /dev/null +++ b/docs/images/deviation.svg @@ -0,0 +1,9 @@ + + Deviation + + + + + Deviation + + \ No newline at end of file diff --git a/docs/images/reactjs.svg b/docs/images/reactjs.svg new file mode 100644 index 00000000..b17e85ec --- /dev/null +++ b/docs/images/reactjs.svg @@ -0,0 +1,12 @@ + + View React Docs + + + + + + + + View React Docs + + diff --git a/docs/images/roblox.svg b/docs/images/roblox.svg new file mode 100644 index 00000000..95871d85 --- /dev/null +++ b/docs/images/roblox.svg @@ -0,0 +1 @@ +RobloxRoblox \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..497580b6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +Roact is a lua port of Facebook's [React](https://reactjs.org) UI library. + +By and large, [React's documentation](https://reactjs.org/docs/getting-started.html) should be able to serve most Roact users' needs. This documentation site serves as a comprehensive guide to the _differences_ between Roact and React. + +If you're new to the React library and want **to start learning the concepts of React**, begin with the [React JS documentation](https://reactjs.org/docs/getting-started.html). + +If you want **to find out if a React feature is present in Roact (and if there are any differences to be aware of)**, check out the [API Reference](api-reference/react.md). + +If you're familiar with React and want **to learn where Roact differs**, start with the [Deviations page](deviations.md). + +And if you want **to migrate an existing project from Roact 1.x to Roact 17+**, check out the guide on [Migrating From Roact 1.x](migrating-from-1x/minimum-requirements.md). + +### Which Part is "Roact"? + +Previously, **Roact** was the name that referred to a single package. It implemented a similar API to that of React 15. This documentation refers to those older versions of Roact as **legacy Roact** or **Roact 1.x**. + +Today, **Roact** is used as an umbrella term to describe the collection of packages that compose the Luau port of the React library. This collection of packages includes a few top-level ones, namely `React`, `ReactRoblox`, and `RoactCompat`. This documentation refers to the initial release of this version of Roact as **Roact 17** (because it aligns its implementation to React JS version 17.0.1) or **Roact 17+** (to include future releases). + +The originating React UI library, written in JavaScript, is referred to in this documentation as **React JS** in order to clearly disambiguate it from Roact and from the top-level package called `React`. In can be considered an umbrella term for the collection of packages, similar to "Roact". + +This documentation endeavors to use these terms consistently throughout to avoid confusion. diff --git a/docs/migrating-from-1x/adopt-new-features.md b/docs/migrating-from-1x/adopt-new-features.md new file mode 100644 index 00000000..90c2faf1 --- /dev/null +++ b/docs/migrating-from-1x/adopt-new-features.md @@ -0,0 +1,241 @@ +# Adopt New Features + +Roact 17 ships with a number of features new to Roact that have been ported from React JS, in addition to a couple of new capabilities unique to the Roact ecosystem. + +## Asynchronous Rendering + +Roact 17 introduces a paradigm shift to the underlying rendering behavior that allows it to divide work across multiple frames and preserve high framerate and interactivity. + +This behavior is called [Concurrent Mode](https://17.reactjs.org/docs/concurrent-mode-intro.html#what-is-concurrent-mode) rendering. + +**Roact 17 will use Concurrent Mode by default in its `mount` compatibility layer.** + +!!! info + Roact 17 is aligned to React JS 17.0.1, which means that it has not inherited any [changes to Concurrent Mode described in the React 18 documentation](https://reactjs.org/blog/2022/03/29/react-v18.html#gradually-adopting-concurrent-features). As we begin to build _Roact_ 18, we may shift our distinctions similarly. For now, **opting into Concurrent Mode is the best way to get the latest optimizations and ensure that your components are robust.** + +### ReactRoblox.createRoot + +In legacy Roact, the [`Roact.mount`](https://roblox.github.io/roact/api-reference/#roactmount) function is used to render a component tree. In React JS 17 and older, the primary entry-point for rendering a component tree is [`ReactDOM.render`](https://reactjs.org/docs/react-dom.html#render), while the experimental `ReactDOM.createRoot` API is used to adopt Concurrent Mode. + +Roact 17 skips introducing the top-level `render` function from earlier versions of React JS, and instead provides the following for mounting Roact UI elements: + +* The `createRoot`, `createBlockingRoot`, and `createLegacyRoot` APIs from React JS 17 +* A [compatibility layer](../api-reference/roact-compat.md#roactcompatmount) that exports a `mount` function aligned with legacy Roact's API + +In new code, you should always use [`ReactRoblox.createRoot`](https://reactjs.org/docs/concurrent-mode-reference.html#createroot). The vast majority of existing Roact code should be compatible with `createRoot`, which enables Concurrent Mode and can greatly improve responsiveness for complex applications. + +If you run into problems with Concurrent Mode that are difficult to address, you may be interested in considering [`ReactRoblox.createBlockingRoot` or `ReactRoblox.createLegacyRoot`](https://reactjs.org/docs/concurrent-mode-reference.html#createblockingroot) at a cost to overall app responsiveness. Reach out to #lua-foundation before proceeding with any new code using these APIs. + +### ReactRoblox.act + +When Concurrent Mode is enabled, Roact will attempt to schedule work evenly across rendering frames to keep the application running smoothly, even when UI rendering work has been queued up. + +However, this means that tests relying on synchronous rendering behavior will no longer function correctly. To fix this, use the [`ReactRoblox.act`](../api-reference/react-roblox.md#reactrobloxact) function to play scheduler logic forward (also re-exported as [`RoactCompat.act`](../api-reference/roact-compat.md#roactcompatact)). + +#### How to Use `act` + +The `ReactRoblox.act` utility works by: + +1. Running the provided function +2. Performing queued work by playing forward Roact's internal scheduler +3. Repeating step 2 until the queue is empty + +When running tests using the mock scheduler, the following scenarios will need to be wrapped in an `act` call: + +* Rendering an initial tree with the `render` method of a React root or the `RoactCompat.mount` compatibility function (if the [`__ROACT_17_INLINE_ACT__`](../configuration.md#__roact_17_inline_act__) global is set to true, this will happen **automatically** for `mount`, `update`, and `unmount`) +* Rendering updates with the `render` method of a React root +* Unmounting a tree by passing `nil` to the `render` method of a React root +* Calling `task.wait` or other yielding functions to allow engine callbacks to fire +* Triggering behavior that causes a component to update its state, including firing signals that your component has subscribed to +* Processing user inputs generated by Rhodium + * Keep in mind that most input events only fire if the target elements meet certain requirements (on screen, visible, enabled). + * Virtual input events may need to be explicitly flushed before events fire and relevant Roact work is queued (see example below) + +!!! info + In order to enable the `act` function, you'll need Roact to use the mocked version of its internal scheduler. To do this, set the [`__ROACT_17_MOCK_SCHEDULER__`](../configuration.md#__roact_17_mock_scheduler__) global to true in your testing configuration. + +#### Example + +Many integration tests involve validating various states of a component and interacting with the component via virtual input events or mocked signals. +The following is a comprehensive example of several of the above scenarios in practice. + +Suppose we have a component called `TooltipButton`: +```lua +local function TooltipButton(props) + local showTooltip, setShowTooltip = React.useState(false) + + return React.createElement("Frame", { + key = "TooltipButton", + Size = UDim2.fromScale(1, 1), + }, { + Button = React.createElement("TextButton", { + Text = "Show tooltip", + Active = props.enabled, + [React.Event.Activated] = function() + setShowTooltip(true) + task.delay(props.tooltipFadeDelay, function() + setShowTooltip(false) + end) + end, + }) + Tooltip = if showTooltip + then React.createElement("TextLabel", { + Text = "Tooltip text!", + }) + else nil + }) +end +``` + +We'd like to write a test to validate the tooltip behavior. Here's how we might use `act` to guarantee that all scheduled work is flushed and our test works as expected: +```lua +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +local container, root +beforeEach(function() + container = Instance.new("ScreenGui") + container.Parent = Players.LocalPlayer.PlayerGui + + root = ReactRoblox.createRoot(container) +end) + +afterEach(function() + container:Destroy() +end) + +it("shows a tooltip on click and hides it after a delay", function() + -- Use `act` for the initial render + ReactRoblox.act(function() + -- Render the button in a disabled state + root:render(React.createElement(TooltipButton, { + enabled = false, + tooltipFadeDelay = 1, + })) + end) + + expect(container.TooltipButton.Tooltip).toBeNil() + expect(container.TooltipButton.Button.Active).toBe(false) + + -- Use `act` to re-render the tree + ReactRoblox.act(function() + -- Rerender in the enabled state + root:render(React.createElement(TooltipButton, { + enabled = true, + tooltipFadeDelay = 1, + })) + end) + + expect(container.TooltipButton.Tooltip).toBeNil() + expect(container.TooltipButton.Button.Active).toBe(true) + + -- Use `act` to trigger virtual input + local element = Rhodium.Element.new(container.TooltipButton.Button) + ReactRoblox.act(function() + -- Click the button to trigger the tooltip + element:click() + Rhodium.VirtualInput.waitForInputEventsProcessed() + end) + + expect(container.TooltipButton.Tooltip).never.toBeNil() + expect(container.TooltipButton.Tooltip.Text).toEqual("Tooltip text!") + + -- Use `act` to resume queued renders after a delayed callback fires + ReactRoblox.act(function() + task.wait(1) + end) + + expect(container.TooltipButton.Tooltip).toBeNil() +end) +``` + +For more details and examples, refer to documentation on [the `act` function in React JS](https://reactjs.org/docs/test-utils.html#act). + +!!! info + In new test code, consider adopting libraries like [`dom-testing-library-lua`](https://github.com/Roblox/dom-testing-library-lua) and [`react-testing-library-lua`](https://github.com/Roblox/react-testing-library-lua), which are ports of JS testing libraries that handle much of the `act` logic for you. Reach out to #lua-foundation on slack for the status of these libraries. + +## Hooks + +While async rendering is a paradigm shift for under-the-hood rendering behavior, hooks are a paradigm shift for component development. They're designed to be a more performant, composable, testable, and ergonomic approach to defining stateful behavior and side effects (relative to to class component lifecycle methods). + +You can access hooks via the `React` Package. In order to encourage migration to the new package conventions, `RoactCompat` **does not** export the hooks API. Follow the [previous section](upgrading-to-roact-17.md#accessing-new-features) to set up your dependencies appropriately. + +An excellent and comprehensive guide for hooks can be found in the [React JS documentation](https://reactjs.org/docs/hooks-intro.html); the example below will help illustrate what they look like when used in Luau. + +### Hooks Example + +The following is a simple example that uses two of the most common hooks: `useState` and `useEffect`. + +```lua +local React = require(Packages.React) + +function ClickerComponent(props) + local count, setCount = React.useState(0) + local function onClick() + setCount(function(oldCount) + return oldCount + 1 + end) + end + + React.useEffect(function() + print(string.format("You've clicked %d times!", count)) + end) + + return React.createElement("TextButton", { + Text = tostring(count), + [React.Event.Activated] = onClick, + }) +end +``` + +Check the [API Reference](../api-reference/react.md#reactusestate) to see the complete list of hooks supported by Roact 17. + +## Utilities + +### React.memo + +The `memo` function can be used to memoize a function component, returning the same element if the same props are provided. This may be a helpful optimization in scenarios where a function component frequently re-renders without changes due to its parent re-rendering. + +Refer to the [React JS documentation](https://reactjs.org/docs/react-api.html#reactmemo) for more details and examples. + +### React.Children + +*Under construction 🔨* + +## Roact-Only Features + +In addition to the new features provided by aligning with React JS 17, a few new features have been added to extend and improve support for legacy Roact features. + +### useBinding Hook + +Legacy Roact introduces a concept called "bindings", a shorthand for the concept of "unidirectional data bindings." You can learn more about bindings in the [legacy Roact documentation](https://roblox.github.io/roact/advanced/bindings-and-refs/#bindings). + +The legacy version of bindings were created similarly to refs, using a `createBinding` method that would be called in the `init` method of a class component. So how do we use bindings with function components? + +Roact 17 introduces, alongside the core hooks from React JS 17, an additional `useBinding` hook to address this case. You can use it much like a `useState` hook, except it will follow binding semantics: instead of triggering component re-renders, binding updates will directly update subscribed values. + +```lua +local React = require(Packages.React) + +function ClickerComponent(props) + local count, setCount = React.useBinding(0) + local function onClick() + -- This will only update subscribed host properties, specifically the + -- Text field of the button that's rendered below + setCount(count:getValue() + 1) + end + + React.useEffect(function() + print(string.format("You've clicked %d times!", count)) + end) + + return React.createElement("TextButton", { + Text = count:map(tostring), + [React.Event.Activated] = onClick, + }) +end +``` + +### React.Tag + +*Under Construction 🔨* diff --git a/docs/migrating-from-1x/convert-legacy-conventions.md b/docs/migrating-from-1x/convert-legacy-conventions.md new file mode 100644 index 00000000..755bcc32 --- /dev/null +++ b/docs/migrating-from-1x/convert-legacy-conventions.md @@ -0,0 +1,288 @@ +# Updating Conventions and APIs + +Almost all legacy Roact code can be patched to Roact 17 using the RoactCompat library. However, while RoactCompat is backwards compatible with with Legacy Roact, it does not export new Roact 17 features like hooks. + +When writing code exclusively for Roact 17, you should access the primary APIs of Roact 17 through the `React` and `ReactRoblox` APIs, instead of through `RoactCompat`. + +* `React` exposes: + * some core legacy Roact APIs unchanged (`createElement`, `createContext`, `Component`, `Change`/`Event`, etc.) + * some React-JS-aligned updates to other legacy Roact APIs (`createFragment` -> `Fragment`, `oneChild` -> `Children.only`, etc.) + * and several brand new APIs (`useState`, `useRef`, `memo`, `cloneElement`, etc.) +* `ReactRoblox` exposes: + * the `createRoot` API, which replaces the `mount`/`update`/`unmount` APIs + * the `createPortal` API, which replaces the `Roact.Portal` component + +To reduce confusion, try to avoid using `RoactCompat` and `React`/`ReactRoblox` in the same file. If you are adding new Roact features to code that uses `RoactCompat` syntax, take the following steps to align conventions: + +1. Replace the `Roact` import with the `React` import +2. Swap all uses of `Roact` with `React` +3. If the module uses any `Roact.Portal` instances, import the `ReactRoblox` package and replace them with `ReactRoblox.createPortal` +4. If the module uses `mount`/`update`/`unmount`, import the `ReactRoblox` package to replace them + * Replace `Roact.mount` with `ReactRoblox.createRoot` followed by `root:render` + * Replace `Roact.update` with subsequent calls to `root:render` + * Replace `Roact.unmount` by calling `root:unmount` +5. Begin adopting new features exported from the `React` package as needed + +## API Conversions + +Roact 17 adopts the naming conventions and API shape of React JS wherever possible, with small deviations made to accommodate the distinct features of the Luau language. Using new naming conventions and APIs helps to align with the React JS ecosystem and reduce friction and mental overhead when troubleshooting or browsing documentation. + +**Even though Roact 17 is designed to be largely compatible with legacy Roact, updating these conventions when adopting new features is highly encouraged.** + +### Mounting, Updating, and Unmounting + +Legacy Roact uses three functions to manage the lifecycle of an entire Roact tree composed of numerous components. This is typically the entry point into Roact-managed UI. + +In Roact 17, the equivalent mechanisms use a new concept called a "root" to more clearly encapsulate this behavior. + +* `ReactRoblox.createRoot` +* `ReactRoblox.createBlockingRoot` +* `ReactRoblox.createLegacyRoot` + +Since the root needs to access a Roblox `Instance` in order to attach to it, these functions are exported through the `ReactRoblox` package, also known as a "renderer". A renderer takes the abstract descriptions of UI generated by React components and turns them into a concrete UI element tree. You can think of the `ReactRoblox` package as the semantic equivalent of the `react-dom` package in the React JS ecosystem. + +#### Legacy Roact +```lua +local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui +local Roact = require(Packages.Roact) + +local roactTree = Roact.mount(Roact.createElement("TextLabel", { + Text = "Hello world!", +}, PlayerGui) + +task.wait(3) + +roactTree = Roact.update(roactTree, Roact.createElement("TextLabel", { + Text = "Hello Roblox!", +}) + +task.wait(3) + +Roact.unmount(roactTree) +``` + +#### Roact 17 +```lua hl_lines="2-3 5-8 10-11 13 17 23" +local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui +local React = require(Packages.React) +local ReactRoblox = require(Packages.ReactRoblox) + +-- Roact 17 roots will take full ownership of the instance provided to them, +-- so we should not create a root using PlayerGui directly +local container = Instance.new("Folder") +container.Parent = PlayerGui + +local root = ReactRoblox.createRoot(container) +root:render(Roact.createElement("TextLabel", { + Text = "Hello world!", +}) + +task.wait(3) + +root:render(Roact.createElement("TextLabel", { + Text = "Hello Roblox!", +}) + +task.wait(3) + +root:render(nil) +``` + +!!! warning + The `createBlockingRoot` and `createLegacyRoot` functions will opt out of concurrent rendering, a feature of Roact 17 that allows smooth, scalable UI by dividing units of work across multiple frames when large amounts of UI changes are needed. We always recommend using `createRoot` unless you know what you're doing. + +### Component Lifecycle Names + +Legacy Roact used simplified names for component lifecycle functions to alleviate redundancy when defining lifecycle methods with syntax like `function MyComponent:didMount() ... end`. + +However, Roact 17 prefers to align as close as possible to React JS to reduce friction when referring to resources from the React JS ecosystem (questions, tutorials, examples, documentation, etc.). It's recommended to update class component lifecycle names to match React JS. + +* `didMount` ➡ `componentDidMount` +* `shouldUpdate` ➡ `shouldComponentUpdate` +* `didUpdate` ➡ `componentDidUpdate` +* `didMount` ➡ `componentDidMount` +* `willUpdate` ➡ `UNSTABLE_componentWillUpdate` + * It's recommended to use the `UNSTABLE_` prefix here, since the `componentWillUpdate` method is deprecated +* `willUnmount` ➡ `componentWillUnmount` + +### Reserved Keys + +Legacy Roact went out of its way to let all prop keys be valid for component developers to use. In Roact 17, a small subset of prop keys are reserved for internal use to make them simple to provide. + +* `[Roact.Ref]` becomes `ref` +* `[Roact.Children]` becomes `children` + +You can index `props.children` to refer to the children provided to a component, and use `ref` as a key to an element to provide a ref to it. + +#### Legacy Roact +```lua +local FocusButton = Roact.Component:extend("FocusButton") + +function FocusButton:init() + self.ref = Roact.createRef() +end + +function FocusButton:render() + return Roact.createElement("Button", { + Size = self.props.Size, + [Roact.Ref] = self.ref + }, self.props[Roact.Children]) +end + +function FocusButton:didMount() + GuiService.SelectedObject = self.ref.current +end +``` + +#### Roact 17 +```lua hl_lines="10 11" +local FocusButton = Roact.Component:extend("FocusButton") + +function FocusButton:init() + self.ref = Roact.createRef() +end + +function FocusButton:render() + return Roact.createElement("Button", { + Size = self.props.Size, + ref = self.ref + }, self.props.children) +end + +function FocusButton:didMount() + GuiService.SelectedObject = self.ref.current +end +``` + +### Context.Consumer + +Legacy Roact treats the special `Context.Consumer` component generated by `createContext` more like a typical component with a single prop (`render`) as its prop interface. React JS, on the other hand, uses a slightly abbreviated structure. + +The `Context.Consumer` component expects _no_ props and a _single_ child, where the child is the same mapping function that would be provided as the `render` prop in legacy Roact. + +#### Legacy Roact +```lua +local ThemeContext = Roact.createContext(nil) + +-- ... + +local function Button(props) + return Roact.createElement(ThemeContext.Consumer, { + render = function(theme) + return Roact.createElement("TextButton", { + BackgroundColor3 = theme.ButtonColor, + Text = props.text, + [Roact.Event.Activated] = props.onActivated, + }) + end + }) +end +``` + +#### Roact 17 +```lua hl_lines="6-7 14" +local ThemeContext = Roact.createContext(nil) + +-- ... + +local function Button(props) + return Roact.createElement(ThemeContext.Consumer, nil, + function(theme) + return Roact.createElement("TextButton", { + BackgroundColor3 = theme.ButtonColor, + Text = props.text, + [Roact.Event.Activated] = props.onActivated, + }) + end + ) +end +``` + +!!! info + Roact 17 has a compatibility layer that will allow either structure to work as expected. If you'd like to see warnings that will help you migrate, enable the [`__COMPAT_WARNINGS__` global](../configuration.md#compatwarnings). + +### React.Fragment + +Legacy Roact treats fragments as a special kind of rendered object, distinct from a Roact element, using the `Roact.createFragment` function. + +In Roact 17, however, fragments are nothing more than a special component type. Your component should return a single element with the `React.Fragment` component type, with the children provided as the element's children. + +#### Legacy Roact +```lua +local function LabeledButton(props) + return Roact.createFragment({ + Button = Roact.createElement("ImageButton", { + Image = props.buttonIcon, + }), + Label = Roact.createElement("TextLabel", { + Text = props.labelText, + }), + }) +end +``` + +#### Roact 17 +```lua hl_lines="2" +local function LabeledButton(props) + return React.createElement(React.Fragment, nil, { + Button = React.createElement("ImageButton", { + Image = props.buttonIcon, + }), + Label = React.createElement("TextLabel", { + Text = props.labelText, + }), + }) +end +``` + +### ReactRoblox.createPortal + +Legacy Roact uses a special `Roact.Portal` component to represent a portal. In Roact 17, we align with upstream and export the `ReactRoblox.createPortal` function. + +Portals are considered to be a feature with renderer-specific functionality since they attach directly to a host instance. Any APIs that interact directly with Roblox Instances will be exported via `ReactRoblox` in order to maintain the renderer abstraction. + +#### Legacy Roact +```lua +local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui + +local function Modal(props) + return Roact.createElement(Roact.Portal, { + target = PlayerGui, + }, { + Modal = Roact.createElement("ScreenGui", {}, { + Label = Roact.createElement("TextButton", { + Text = "Click me to close!", + [Roact.Event.Activated] = props.onClose, + }) + }) + }) +end +``` + +#### Roact 17 +```lua hl_lines="4 11" +local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui + +local function Modal(props) + return ReactRoblox.createPortal({ + Modal = Roact.createElement("ScreenGui", {}, { + Label = Roact.createElement("TextButton", { + Text = "Click me to close!", + [Roact.Event.Activated] = props.onClose, + }) + }) + }, PlayerGui) +end +``` + +### Roact.oneChild + +Legacy Roact provides an infrequently-used utility function called `oneChild` that guarantees that the argument provided to it is a table with only one child Roact element. If the provided table contains zero elements or two or more elements, `oneChild` will throw an error. + +Roact 17 inherits a somewhat similar function from React JS: `React.Children.only`. This function will throw an error if the provided argument is not _itself_ a single child element. In other words, it expects a single child instead of a table containing only one child. This can be converted by unwrapping the provided children manually before passing them along to `React.Children.only`. + +Limiting a component to a single child isn't always necessary. Consider whether your component is capable of supporting an arbitrary number of children after all. In that case, you can simply remove the call to `Roact.oneChild`. + +## Eliminating RoactCompat + +Once you've completed all of the above conversions, you should no longer need to rely on the `RoactCompat` package in your module. Congratulations, your migration is complete! \ No newline at end of file diff --git a/docs/migrating-from-1x/minimum-requirements.md b/docs/migrating-from-1x/minimum-requirements.md new file mode 100644 index 00000000..26f2c1f5 --- /dev/null +++ b/docs/migrating-from-1x/minimum-requirements.md @@ -0,0 +1,250 @@ +# Minimum Requirements + +When upgrading to Roact 17+, a small set of legacy patterns and features need to be fixed in order to maintain intended behavior. Once these conditions are met, your legacy Roact code should work as expected in Roact 17. + +**All of these requirements can be met using APIs available in legacy Roact**, though most will require a minimum version. This means that a Roact codebase can safely be made _compatible_ with Roact 17 as a preliminary step before adopting it. + +## No Reserved Props + +In Roact 17, components cannot rely on any reserved prop keywords: + +* "ref" - reserved by Roact to assign refs, equivalent to legacy Roact's `Roact.Ref` +* "key" - reserved by Roact to assign stable keys to children +* "children" - reserved by Roact as a special prop representing the children passed down to the component + +If your component is using "ref" or "key" as the name of one of its props, **those props will no longer be populated with a value in Roact 17+.** + +Additionally, if it's using "children" as the name of one of its props, **the value of the "children" prop will become the table of child elements instead of the value provided by the parent component in Roact 17+.** + +!!! info + This restriction does not involve legacy APIs, so this migration can be completed in codebases depending upon **any version** of legacy Roact. + +### Example + +Suppose we have a component `OptionButton` and a separate component `ButtonGroup` that uses it. + +The `OptionButton` component is using a prop called `key` to pass through to its LayoutOrder. In Roact 17, this prop will be nil because it will be consumed by Roact and used as a [stable key](https://reactjs.org/docs/lists-and-keys.html#keys). + +To fix this, we replace the use of the `key` prop with a prop with a different name. + +#### Legacy +```lua +local function OptionButton(props) + return Roact.createElement("TextButton", { + LayoutOrder = props.key, + Text = props.text, + [Roact.Event.Activated] = props.onClick, + }) +end + +local function ButtonGroup(props) + return Roact.createFragment({ + CancelButton = Roact.createElement(OptionButton, { + key = 1, + text = "Cancel", + onClick = props.cancelCallback, + }) + ConfirmButton = Roact.createElement(OptionButton, { + key = 2, + text = "Confirm", + onClick = props.confirmCallback, + }) + }) +end +``` + +#### Roact 17 Compatible +```lua hl_lines="3 12 17" +local function OptionButton(props) + return Roact.createElement("TextButton", { + LayoutOrder = props.order, + Text = props.text, + [Roact.Event.Activated] = props.onClick, + }) +end + +local function ButtonGroup(props) + return Roact.createFragment({ + CancelButton = Roact.createElement(OptionButton, { + order = 1, + text = "Cancel", + onClick = props.cancelCallback, + }) + ConfirmButton = Roact.createElement(OptionButton, { + order = 2, + text = "Confirm", + onClick = props.confirmCallback, + }) + }) +end +``` + +You can see a full example of a migration away from reserved keys in [this UIBlox PR](https://github.com/Roblox/uiblox/pull/368). + +## No Legacy Context + +Legacy Roact implemented a `_context` field on all component instances as an alternative implementation for the Context feature. This is deprecated in legacy Roact and is not supported in Roact 17+. **Attempting to access fields on `self._context` in Roact 17+ will throw an error.** + +### How To Convert + +Replace any uses of `_context` with the [Provider and Consumer pattern via `createContext`](https://roblox.github.io/roact/advanced/context/). This is the preferred pattern in legacy Roact as well because it allows Roact to trigger updates on context consumers when context providers pass in a new value. + +Generally, you'll take the following steps: + +1. Create a context object that you will use in place of `_context` by calling `createContext` and saving the result, usually as the return value of a separate `ModuleScript`. +2. Wherever you have a component that _writes_ to `self._context`, instead wrap the component's children in a `Context.Provider` component and provide the value that was previously being written to `self._context`. +3. Wherever you have a component that _reads_ from `self._context`, instead wrap that component's children in a `Context.Consumer` component. The Consumer accepts the `render` prop, which is a function that accepts a context value and returns a Roact element. + +!!! info + The `createContext` API is available in legacy **Roact 1.3.0** (or newer) and is fully supported in Roact 17. + +### Example + +Suppose we have a `style` object that must be provided to all children of our app. We define it in our top-level `App` component and read from it in our `Label` component. + +#### Legacy +```lua +local AppStyle = require(script.Parent.AppStyle) + +local Label = Roact.Component:extend("Label") + +function Label:init() + -- reading style from context + self.style = self._context.style +end + +function Label:render() + return Roact.createElement("TextLabel", { + BackgroundColor3 = self.style.LabelColor, + Text = props.text, + }) +end + +local App = Roact.Component:extend("App") + +function App:init() + -- defining style in context + self._context.style = AppStyle +end + +function App:render() + return Roact.createElement("Frame", { + Size = UDim2.fromScale(1, 1) + }, { + Start = Roact.createElement(Button, { + text = "Hello World", + }) + }) +end +``` + +#### Roact 17 Compatible +```lua hl_lines="3 8-9 11 14-15 21-24 31" +local AppStyle = require(script.Parent.AppStyle) + +local StyleContext = Roact.createContext(nil) + +local Label = Roact.Component:extend("Label") + +function Label:render() + return Roact.createElement(StyleContext.Consumer, { + render = function(style) + return Roact.createElement("TextLabel", { + BackgroundColor3 = style.LabelColor, + Text = props.text, + }) + end + }) +end + +local App = Roact.Component:extend("App") + +function App:render() + return Roact.createElement(StyleContext.Provider, { + value = AppStyle, + }, { + App = Roact.createElement("Frame", { + Size = UDim2.fromScale(1, 1) + }, { + Start = Roact.createElement(Button, { + text = "Hello World", + }) + }) + }) +end +``` + +You can see a full example of a migration to the `createContext` API in [this Lua Apps PR](https://github.com/Roblox/lua-apps/pull/3612). + +## Explicit Ref Forwarding + +Legacy Roact uses `Roact.Ref` as a special prop key to support the refs feature. Assigning the `[Roact.Ref]` property to a callback ref or ref object allows Roact to assign its value. However, Roact only interacts with the `Roact.Ref` property if the component receiving the props is a host component. + +Some class component definitions rely on this behavior by accepting and reassigning the `[Roact.Ref]` prop themselves, knowing that Roact won't capture it. The pattern of passing a provided ref onto a child is called "ref forwarding". We refer to using `[Roact.Ref]` as mechanism of ref forwarding as "implicit ref forwarding". + +### How To Convert + +In Roact 17+, `Roact.Ref` is aliased to the string "ref", and refs that point to class components are now supported. **Components that were using implicit ref forwarding will fail to forward their provided refs when upgrading to Roact 17+.** + +Fortunately, this can be easily fixed with the [`forwardRef` function](https://roblox.github.io/roact/advanced/bindings-and-refs/#ref-forwarding). We refer to this as "explicit ref forwarding". + +!!! info + The `forwardRef` API is available in legacy **Roact 1.4.0** (or newer) and is fully supported in Roact 17. + +### Example + +Suppose we have a `FancyTextBox` component that accepts a ref, and passes it on to an underlying `TextBox`. Rather than accepting the `[Roact.Ref]` prop, we should use the `Roact.forwardRef` wrapper to explicitly accept a ref and assign it to the `TextBox`. + +#### Legacy +```lua +local function FancyButton(props) + return Roact.createElement("TextBox", { + PlaceholderText = "Enter your text here", + PlaceholderColor3 = Color3.new(0.4, 0.4, 0.4), + [Roact.Change.Text] = props.onTextChange, + -- Implicitly forwarding a ref via the `Roact.Ref` prop + [Roact.Ref] = props[Roact.Ref], + }) +end +``` + +#### Roact 17 Compatible +```lua hl_lines="1 6-7" +local FancyButton = Roact.forwardRef(function(props, ref) + return Roact.createElement("TextBox", { + PlaceholderText = "Enter your text here", + PlaceholderColor3 = Color3.new(0.4, 0.4, 0.4), + [Roact.Change.Text] = props.onTextChange, + -- Explicitly forwarding a ref passed in via `forwardRef` + [Roact.Ref] = ref, + }) +end) +``` + +You can see a full example of `forwardRef` migration in [this UIBlox PR](https://github.com/Roblox/uiblox/pull/275). + +## Prefer getDerivedStateFromProps + +Legacy Roact allows class components to implement both `willUpdate` and `getDerivedStateFromProps` lifecycle methods. + +React JS, however, does not support both methods when implemented on the same component. When `getDerivedStateFromProps` is defined, it _replaces_ `componentWillUpdate` entirely. **Roact 17 inherits this restriction: `getDerivedStateFromProps` will replace `willUpdate` if both are defined.** + +### How To Convert + +In order to make existing components Roact 17 compatible, make sure to use _either_ `willUpdate` or `getDerivedStateFromProps`, but not both. + +Whenever possible, use `getDerivedStateFromProps` to resolve interactions between state and props. Just like in React JS 16.3.0 and onward, `willUpdate` is a deprecated legacy lifecycle method and should be avoided as it can exacerbate problems with asynchronous rendering, a flagship feature of Roact 17+. + +!!! info + The `getDerivedStateFromProps` static lifecycle method is supported in legacy Roact as far back as **Roact 0.2.0** and is fully supported in Roact 17. + +### Example + +Typically, usage of both `willUpdate` and `getDerivedStateFromProps` is a sign of an overly complicated component, which makes it difficult to provide a simplified example that's meaningful. + +Consider looking at other migrations of this kind for more complex examples: + +* [This PR to the legacy Infinite Scroller component](https://github.com/Roblox/infinite-scroller/pull/140). +* [This PR to the Roact Gamepad library](https://github.com/Roblox/roact-gamepad/pull/53). + +Additionally, refer to the React JS guidance on [migrating away from legacy lifecycle methods](https://reactjs.org/blog/2018/03/27/update-on-async-rendering.html). diff --git a/docs/migrating-from-1x/upgrading-to-roact-17.md b/docs/migrating-from-1x/upgrading-to-roact-17.md new file mode 100644 index 00000000..6a8be62b --- /dev/null +++ b/docs/migrating-from-1x/upgrading-to-roact-17.md @@ -0,0 +1,109 @@ +# Adding a Roact 17 Dependency + +Roact 17 is available as a collection of packages hosted in the https://github.com/roblox/roact-alignment repository. Internal Roblox projects use a package manager called [Rotriever](https://github.com/roblox/rotriever) to resolve and manage dependencies. + +!!! info + Roact 17 is a "package workspace", meaning that it consists of multiple packages living in the same project. We recommend using Rotriever version [0.5.4](https://github.com/Roblox/rotriever/releases/tag/v0.5.4) or newer to make sure it has the latest workspace feature support. + + Refer to [Rotriever's documentation](https://roblox.github.io/rotriever/installation/) for more information on how to install or upgrade your version. + +## New Projects + +1. Make sure you're using rotriever version `0.5.4` or newer. You can check this by running `rotrieve -V`. +2. Add the following to your `rotriever.toml` manifest file: + ```toml + [dependencies] + React = "github.com/roblox/roact-alignment@17.0.1" + ReactRoblox = "github.com/roblox/roact-alignment@17.0.1" + ``` +3. Run `rotrieve install` to install all dependencies +4. `React.lua` and `ReactRoblox.lua` will be added to the `Packages` folder generated by rotriever in your project. Make sure this folder is included in your project when testing with `roblox-cli` or Roblox Studio. + +## Replacing Legacy Roact + +The easiest way to adopt Roact 17 in an existing Roact project is to change your existing dependency on legacy Roact. + +Make the following changes to your `rotriever.toml` manifest file: +```diff +[dependencies] +- Roact = "github.com/roblox/roact@1.4" ++ Roact = { target = "github.com/roblox/roact-alignment", version = "17.0.1", package = "RoactCompat" } +``` + +This creates a dependency on the `RoactCompat` package, which provides [a compatibility layer](../api-reference/roact-compat.md) for migrating from legacy APIs. + +Since it's still named `Roact` in the above snippet, it will be aliased to `Roact` in your code. If your code meets the [minimum requirements](minimum-requirements.md), this dependency change will upgrade your project to Roact 17 without requiring any further changes to your source code. + +### Accessing New Features + +However, the `RoactCompat` API only covers enough to provide backwards compatibility. To access new features like hooks or suspense, you will need to add dependencies on `React` and `ReactRoblox` as well: +```toml +[dependencies] +Roact = { target = "github.com/roblox/roact-alignment", version = "17.0.1", package = "RoactCompat" } +React = "github.com/roblox/roact-alignment@17.0.1" +ReactRoblox = "github.com/roblox/roact-alignment@17.0.1" +``` + +The `RoactCompat` package **can be safely mixed and matched** with the new `React` and `ReactRoblox` packages; it's nothing more than a thin wrapper around them that aligns with the legacy Roact API and semantics. + +!!! info + Since we're not using an alias for the `React` and `ReactRoblox` packages, we can define our dependencies using the shorthand format. Read up on [specifying Rotriever dependencies](https://roblox.github.io/rotriever/guide/specifying-dependencies/) for more information. + +### Patching Other Dependencies + +You may need to additionally account for other dependencies in your project that use Roact. If you depend on a package that has its _own_ dependency on legacy Roact, you may need to use rotriever's `patch` feature to replace the legacy version throughout the tree. + +You might encounter this if you have existing dependencies on any of the following: + +* [InfiniteScroller](https://github.com/Roblox/infinite-scroller) (compatible with Roact 17 from version 0.8.0 onwards) +* [RoactFitComponents](https://github.com/roblox/roact-fit-components) (compatible with Roact 17 from version 2.0.0 onwards) +* [RoactRodux](https://github.com/Roblox/roact-rodux) (compatible with Roact 17 from version 0.4.1 onwards) +* [UIBlox](https://github.com/Roblox/uiblox) (compatible with Roact 17 on the latest master) +* Numerous other projects that depend on legacy Roact, especially if they're older than the latest version + +To resolve this, you can patch over any dependencies on legacy Roact and align them with your newly-added version. Add this additional section to your `rotriever.toml` manifest file: +```toml +[config.patch."github.com/roblox/roact"] +Roact = { target = "github.com/roblox/roact-alignment", version = "17.0.1", package = "RoactCompat" } +``` + +To learn more about patching dependencies, check out [the Rotriever documentation](https://roblox.github.io/rotriever/guide/specifying-dependencies/#patching-dependencies). + +!!! caution + When you're patching over Roact, make sure your other dependencies are compatible with Roact 17. You may need to upgrade them to more recent versions so that they comply with the [Roact 17 requirements](minimum-requirements.md). + +## Testing with Both + +In some cases, you may not yet be equipped to safely adopt Roact 17, but you want to begin adopting it in tests or storybooks so your project can enforce compatibility. + +One reasonable way to accomplish this is to depend on _both_ projects, but to conditionally swap in Roact 17 at runtime. To do this, make sure your `rotriever.toml` manifest file contains the following: +```toml +[dependencies] +Roact = "github.com/roblox/roact@1.4" + +[dev_dependencies] +RoactCompat = "github.com/roblox/roact-alignment@17.0.1" +``` + +This declares a dependency on legacy Roact as well as a [dev dependency](https://roblox.github.io/rotriever/guide/specifying-dependencies/#development-dependencies) on Roact 17. + +In your test runner script or other equivalent entry point, check the value of a global (or flag, or any other preferred configuration method), and overwrite the `Roact` API with that of `RoactCompat` when enabled. It may look something like this: + +```lua +if _G.__NEW_ROACT__ then + local Roact = require(Packages.Roact) + local RoactCompat = require(Packages.Dev.RoactCompat) + + -- Overwrite the contents of the `Roact` package with that of the + -- RoactCompat package + for api, _ in Roact do + Roact[api] = RoactCompat[api] + end +end + +-- Proceed with test setup... +``` + +In the above example, an additional test suite can be created for use with `lest` or via a `robloxdev-cli` command line invocation that sets the `__NEW_ROACT__` global to `true`. + +To see this technique in action, you can find a comprehensive example [in this PR to the UIBlox library](https://github.com/Roblox/uiblox/pull/309). 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/examples.project.json b/examples.project.json index 652b34fc..efb044b3 100644 --- a/examples.project.json +++ b/examples.project.json @@ -7,6 +7,9 @@ "$className": "ReplicatedStorage", "Packages": { "$path": "Packages" + }, + "DeveloperTools": { + "$className": "BindableEvent" } }, @@ -29,4 +32,4 @@ } } } -} \ No newline at end of file +} diff --git a/examples/ProjectWorkspace/DeveloperTools.lua b/examples/ProjectWorkspace/DeveloperTools.lua new file mode 100644 index 00000000..f60973b9 --- /dev/null +++ b/examples/ProjectWorkspace/DeveloperTools.lua @@ -0,0 +1,6 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +-- FIXME: This is a bit hacky; should rotriever provide a smoother way to access +-- dev dependencies? +-- return require(ReplicatedStorage.Packages._Index["DeveloperTools"]["developer-tools"]) +return require(ReplicatedStorage.Packages._Index["DeveloperTools"]["DeveloperTools"]) diff --git a/examples/ProjectWorkspace/React.lua b/examples/ProjectWorkspace/React.lua index fe34f482..ecd94ff6 100644 --- a/examples/ProjectWorkspace/React.lua +++ b/examples/ProjectWorkspace/React.lua @@ -3,4 +3,4 @@ local Workspace = ReplicatedStorage.Packages._Workspace -- FIXME: This is a bit hacky; should rotriever provide a smoother way to access -- local workspace members, even if they don't see use? -return require(Workspace.React.React) \ No newline at end of file +return require(Workspace.React.React) diff --git a/examples/ProjectWorkspace/ReactDevtoolsExtensions.lua b/examples/ProjectWorkspace/ReactDevtoolsExtensions.lua new file mode 100644 index 00000000..fb674e36 --- /dev/null +++ b/examples/ProjectWorkspace/ReactDevtoolsExtensions.lua @@ -0,0 +1,5 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +return require( + ReplicatedStorage.Packages._Workspace["ReactDevtoolsExtensions"]["ReactDevtoolsExtensions"] +) diff --git a/examples/ProjectWorkspace/ReactRoblox.lua b/examples/ProjectWorkspace/ReactRoblox.lua index 4ce27307..44e783b3 100644 --- a/examples/ProjectWorkspace/ReactRoblox.lua +++ b/examples/ProjectWorkspace/ReactRoblox.lua @@ -3,4 +3,4 @@ local Workspace = ReplicatedStorage.Packages._Workspace -- FIXME: This is a bit hacky; should rotriever provide a smoother way to access -- local workspace members, even if they don't see use? -return require(Workspace.ReactRoblox.ReactRoblox) \ No newline at end of file +return require(Workspace.ReactRoblox.ReactRoblox) diff --git a/examples/binding/init.lua b/examples/binding/init.lua index 8a737cea..8f3234f3 100644 --- a/examples/binding/init.lua +++ b/examples/binding/init.lua @@ -19,7 +19,7 @@ return function() end function BindingExample:init() - self.binding, self.updateBinding = ReactRoblox.createBinding({}) + self.binding, self.updateBinding = React.createBinding({}) end function BindingExample:render() @@ -67,4 +67,4 @@ return function() end return stop -end \ No newline at end of file +end diff --git a/examples/changed-signal/init.lua b/examples/changed-signal/init.lua index 6a6d1b15..3e400f12 100644 --- a/examples/changed-signal/init.lua +++ b/examples/changed-signal/init.lua @@ -30,7 +30,7 @@ return function() return React.createElement("TextLabel", { LayoutOrder = layoutOrder, Size = UDim2.new(1, 0, 0.5, 0), - Text = "Reversed: " .. inputText:reverse(), + Text = "Reversed: " .. string.reverse(inputText), }) end diff --git a/examples/clock/init.lua b/examples/clock/init.lua index c36d0a78..1747393b 100644 --- a/examples/clock/init.lua +++ b/examples/clock/init.lua @@ -46,4 +46,4 @@ return function() end return stop -end \ No newline at end of file +end diff --git a/examples/hello-roact/init.lua b/examples/hello-roact/init.lua index d2935a25..56c5f844 100644 --- a/examples/hello-roact/init.lua +++ b/examples/hello-roact/init.lua @@ -25,4 +25,4 @@ return function() end return stop -end \ No newline at end of file +end diff --git a/examples/init.client.lua b/examples/init.client.lua index a320546c..bc675f7c 100644 --- a/examples/init.client.lua +++ b/examples/init.client.lua @@ -1,5 +1,11 @@ local PlayerGui = game:GetService("Players").LocalPlayer:WaitForChild("PlayerGui") +local DeveloperTools = require(script.ProjectWorkspace.DeveloperTools) +local ReactDevtoolsExtensions = require(script.ProjectWorkspace.ReactDevtoolsExtensions) + +local inspector = DeveloperTools.forLibrary("ReactExamples") +inspector:initRoact(ReactDevtoolsExtensions) + local exampleData = { { name = "hello-roact", @@ -31,7 +37,7 @@ local exampleData = { }, } -for _, example in ipairs(exampleData) do +for _, example in exampleData do example.source = script:WaitForChild(example.name) example.start = require(example.source) end @@ -98,8 +104,8 @@ function Examples.makeExampleList() listLayout.SortOrder = Enum.SortOrder.LayoutOrder listLayout.Parent = exampleList - for index, example in ipairs(exampleData) do - local label = ("%s\nexamples/%s"):format(example.label, example.name) + for index, example in exampleData do + local label = string.format("%s\nexamples/%s", example.label, example.name) local exampleCard = Instance.new("TextButton") exampleCard.Name = "Example: " .. example.name @@ -131,4 +137,4 @@ function Examples.makeExampleList() end Examples.exampleList = Examples.makeExampleList() -Examples.exampleList.Parent = PlayerGui \ No newline at end of file +Examples.exampleList.Parent = PlayerGui diff --git a/examples/stress-test/init.lua b/examples/stress-test/init.lua index 5662d0fa..d22a2753 100644 --- a/examples/stress-test/init.lua +++ b/examples/stress-test/init.lua @@ -6,7 +6,7 @@ return function() local ReactRoblox = require(script.Parent.ProjectWorkspace.ReactRoblox) local NODE_SIZE = 10 - local GRID_SIZE = 50 + local GRID_SIZE = 70 --[[ A frame that changes its background color according to time and position props @@ -78,7 +78,7 @@ return function() local rootInstance = Instance.new("Folder") rootInstance.Parent = PlayerGui - local root = ReactRoblox.createBlockingRoot(rootInstance) + local root = ReactRoblox.createRoot(rootInstance) root:render(app) local function stop() @@ -87,4 +87,4 @@ return function() end return stop -end \ No newline at end of file +end diff --git a/foreman.toml b/foreman.toml index 376329cc..468f3234 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,6 +1,7 @@ [tools] -rotrieve = { source = "roblox/rotriever", version = "=0.5.0-alpha.3" } -rojo = { source = "rojo-rbx/rojo", version = "6.0.1" } -selene = { source = "Kampfkarren/selene", version = "0.13" } -stylua = { source = "JohnnyMorganz/StyLua", version = "0.9.3" } +rotrieve = { source = "Roblox/rotriever", version = "=0.5.13-alpha.4" } +rojo = { source = "Roblox/rojo-rbx-rojo", version = "7.2.1" } +selene = { source = "Roblox/Kampfkarren-selene", version = "=0.28.0" } +stylua = { source = "Roblox/JohnnyMorganz-StyLua", version = "=0.18.1" } testez = { source = "roblox/testez", version = "0.3.2" } +rbx-aged-cli = { source = "Roblox/rbx-aged-tool", version = "=5.7.9" } diff --git a/js-to-lua.config.js b/js-to-lua.config.js new file mode 100644 index 00000000..fc08841e --- /dev/null +++ b/js-to-lua.config.js @@ -0,0 +1,76 @@ +module.exports = { + lastSync: { + ref: "12adaffef7105e2714f82651ea51936c563fe15c", + conversionToolVersion: "ffc56d3952bd9ce9ccecc900f6632b0a1c08f222" + }, + upstream: { + owner: "facebook", + repo: "react", + primaryBranch: "main" + }, + downstream: { + owner: "roblox", + repo: "roact-alignment", + primaryBranch: "master", + patterns: [ + "**/*.lua" + ], + ignorePatterns: [ + "Packages/**/*" + ] + }, + renameFiles: [ + [ + (filename) => filename.includes("packages/"), + (filename) => filename.replace("packages/", "modules/") + + ], + [ + (filename) => filename.includes("modules/shared") && !filename.includes("modules/shared/src"), + (filename) => filename.replace("modules/shared", "modules/shared/src") + + ], + [ + (filename) => filename.includes("modules/react/src/ReactSharedInternals.lua"), + (filename) => filename.replace("modules/react/src/ReactSharedInternals.lua", "modules/shared/src/ReactSharedInternals/init.lua") + ], + [ + (filename) => filename.includes("modules/react/src/ReactCurrentDispatcher.lua") || filename.includes("modules/react/src/ReactCurrentBatchConfig.lua") || filename.includes("modules/react/src/ReactCurrentActQueue.lua") || filename.includes("modules/react/src/ReactCurrentOwner.lua") || filename.includes("modules/react/src/ReactDebugCurrentFrame.lua"), + (filename) => filename.replace("modules/react/src/", "modules/shared/src/ReactSharedInternals/") + ], + [ + (filename) => filename.includes("modules/react-reconciler/src/ReactFiberHostConfigWithNoHydration.lua"), + (filename) => filename.replace("modules/react-reconciler/src/ReactFiberHostConfigWithNoHydration.lua", "modules/shared/src/ReactFiberHostConfig/WithNoHydration.lua") + ], + [ + (filename) => filename.includes("modules/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.lua"), + (filename) => filename.replace("modules/react-reconciler/src/ReactFiberHostConfigWithNoPersistence.lua", "modules/shared/src/ReactFiberHostConfig/WithNoPersistence.lua") + ], + [ + (filename) => filename.includes("modules/react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors.lua"), + (filename) => filename.replace("modules/react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors.lua", "modules/shared/src/ReactFiberHostConfig/WithNoTestSelectors.lua") + ], + [ + (filename) => filename.includes("__tests__/") && !filename.includes("src/__tests__/") && !filename.includes("PropMarkers/__tests__/") && !filename.includes("client/__tests__/"), + (filename) => filename.replace("__tests__/", "src/__tests__/") + ], + [ + (filename) => filename.endsWith("-test.lua"), + (filename) => filename.replace("-test.lua", ".spec.lua") + + ], + [ + (filename) => filename.endsWith("-test.internal.lua"), + (filename) => filename.replace("-test.internal.lua", "-internal.spec.lua") + + ], + [ + (filename) => filename.includes("scripts/jest/matchers"), + (filename) => filename.replace("scripts/jest/matchers", "WorkspaceStatic/jest/matchers") + ], + [ + (filename) => filename.endsWith("fixtures/legacy-jsx-runtimes/setupTests.lua"), + (filename) => filename.replace("fixtures/legacy-jsx-runtimes/setupTests.lua", "WorkspaceStatic/jest/matchers/createConsoleMatcher.lua") + ], + ], +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..576f0636 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,54 @@ +site_name: Roact Documentation +site_url: https://roblox.github.io/roact-alignment/ +repo_name: Roblox/roact-alignment +repo_url: https://github.com/Roblox/roact-alignment + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + primary: indigo + scheme: default + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + primary: indigo + scheme: slate + toggle: + icon: material/toggle-switch + name: Switch to light mode + +plugins: + - search: + separator: '[\s\-\.]' + +nav: + - Home: index.md + - Deviations: deviations.md + - Configuration: configuration.md + - Migrating From 1.x: + - Minimum Requirements: migrating-from-1x/minimum-requirements.md + - Add Roact 17 Dependency: migrating-from-1x/upgrading-to-roact-17.md + - Adopt New Features: migrating-from-1x/adopt-new-features.md + - Convert Legacy Conventions: migrating-from-1x/convert-legacy-conventions.md + - API Reference: + - React: api-reference/react.md + - ReactRoblox: api-reference/react-roblox.md + - RoactCompat: api-reference/roact-compat.md + - Additional Libraries: api-reference/additional-libraries.md + - Benchmarks: bench.md + +extra_css: + - extra.css + +markdown_extensions: + - admonition + - codehilite: + guess_lang: false + - toc: + permalink: true + - pymdownx.superfences + # FIXME: Add this back when the tabbed extension is supported by docs-deploy + # - pymdownx.tabbed: + # alternate_style: false diff --git a/modules/TestRunner/default.project.json b/modules/TestRunner/default.project.json new file mode 100644 index 00000000..5d3bb4db --- /dev/null +++ b/modules/TestRunner/default.project.json @@ -0,0 +1,8 @@ +{ + "name": "TestRunner", + "tree": { + "Src": { + "$path": "src" + } + } +} diff --git a/modules/TestRunner/rotriever.toml b/modules/TestRunner/rotriever.toml new file mode 100644 index 00000000..9595c610 --- /dev/null +++ b/modules/TestRunner/rotriever.toml @@ -0,0 +1,15 @@ +[package] +name = "TestRunner" +version = { workspace = true } +author = "Roblox" +content_root = "src" +files = ["src/**"] + +[dev_dependencies] +LuauPolyfill = { workspace = true } +JestDiff = "github.com/roblox/jest-roblox@3" +Jest = { workspace = true } +JestGlobals = { workspace = true } +JestReact = { path = "../jest-react" } +ReactGlobals = { path = "../react-globals" } +Scheduler = { path = "../scheduler" } diff --git a/modules/TestRunner/src/init.lua b/modules/TestRunner/src/init.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/modules/TestRunner/src/init.lua @@ -0,0 +1 @@ +return {} diff --git a/modules/example-app/README.md b/modules/example-app/README.md new file mode 100644 index 00000000..286ea48f --- /dev/null +++ b/modules/example-app/README.md @@ -0,0 +1,3 @@ +# Example App + +A small example app using Foundation. Primarily intended to make testing React features and tooling easier. diff --git a/modules/example-app/StarterScript.lua b/modules/example-app/StarterScript.lua new file mode 100644 index 00000000..a8f70e98 --- /dev/null +++ b/modules/example-app/StarterScript.lua @@ -0,0 +1,16 @@ +local CorePackages = game:GetService("CorePackages") +local RunService = game:GetService("RunService") + +if RunService:IsServer() then + return +end + +-- Set global flags and initialize devtools before React is ever used +local ReactGlobals = require(CorePackages.ReactGlobals) +ReactGlobals.__DEV__ = true +ReactGlobals.__PROFILE__ = true + +local ReactDevtoolsCore = require(CorePackages.ReactDevtoolsCore) +ReactDevtoolsCore.backend.connectToDevtools() + +require(CorePackages.ExampleApp) diff --git a/modules/roblox-jest/default.project.json b/modules/example-app/default.project.json similarity index 61% rename from modules/roblox-jest/default.project.json rename to modules/example-app/default.project.json index 2deebd58..233b46b1 100644 --- a/modules/roblox-jest/default.project.json +++ b/modules/example-app/default.project.json @@ -1,5 +1,5 @@ { - "name": "RobloxJest", + "name": "ExampleApp", "tree": { "$path": "src" } diff --git a/modules/example-app/rotriever.toml b/modules/example-app/rotriever.toml new file mode 100644 index 00000000..c26a8ee4 --- /dev/null +++ b/modules/example-app/rotriever.toml @@ -0,0 +1,19 @@ +[package] +name = "ExampleApp" +version = { workspace = true } +authors = { workspace = true } +publish = false +content_root = "src" +files = ["*", "!**/__tests__/**"] + +[config] +registry_index = true + +[dependencies] +React = { path = "../react" } +ReactRoblox = { path = "../react-roblox" } +ReactDevtoolsCore = { path = "../react-devtools-core" } +ReactGlobals = { path = "../react-globals" } + +Foundation = "1.42.0" +ReactUtils = "1.6.0" diff --git a/Packages/_Workspace/.robloxrc b/modules/jest-react/.robloxrc similarity index 100% rename from Packages/_Workspace/.robloxrc rename to modules/jest-react/.robloxrc diff --git a/modules/jest-react/rotriever.toml b/modules/jest-react/rotriever.toml index a1dd31dc..ac258ff5 100644 --- a/modules/jest-react/rotriever.toml +++ b/modules/jest-react/rotriever.toml @@ -4,8 +4,10 @@ version = { workspace = true } authors = { workspace = true } publish = true content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] LuauPolyfill = { workspace = true } -JestRoblox = { workspace = true } +JestGlobals = { workspace = true } +ReactGlobals = { path = "../react-globals" } Shared = { path = "../shared" } diff --git a/modules/jest-react/src/JestReact.lua b/modules/jest-react/src/JestReact.lua index 11d9e4e0..a55e0243 100644 --- a/modules/jest-react/src/JestReact.lua +++ b/modules/jest-react/src/JestReact.lua @@ -1,156 +1,230 @@ ---[[ - Roblox upstream: https://github.com/facebook/react/blob/e0f89aa056de95afc4c23909fce3d91fefb7dec7/packages/jest-react/src/JestReact.js -]] +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/jest-react/src/JestReact.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 Packages = script.Parent.Parent -local ReactSymbols = require(Packages.Shared).ReactSymbols -local REACT_ELEMENT_TYPE = ReactSymbols.REACT_ELEMENT_TYPE -local REACT_FRAGMENT_TYPE = ReactSymbols.REACT_FRAGMENT_TYPE -local invariant = require(Packages.Shared).invariant +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) -local jestExpect = require(Packages.JestRoblox).Globals.expect - +local Array = LuauPolyfill.Array +-- ROBLOX deviation START: not used +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Object = LuauPolyfill.Object +local exports = {} +-- ROBLOX deviation START: fix import +-- local JestGlobals = require(Packages.Dev.JestGlobals) +local JestGlobals = require(Packages.JestGlobals) +local expect = JestGlobals.expect +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import +-- local sharedReactSymbolsModule = require(Packages.shared.ReactSymbols) +local sharedReactSymbolsModule = require(Packages.Shared).ReactSymbols +-- ROBLOX deviation END +local REACT_ELEMENT_TYPE = sharedReactSymbolsModule.REACT_ELEMENT_TYPE +local REACT_FRAGMENT_TYPE = sharedReactSymbolsModule.REACT_FRAGMENT_TYPE +-- ROBLOX deviation START: fix import +-- local invariant = require(Packages.shared.invariant).default +local invariant = require(Packages.Shared).invariant +-- ROBLOX deviation END +-- ROBLOX deviation START: predeclare variables +local jsonChildToJSXChild, jsonChildrenToJSXChildren +-- ROBLOX deviation END 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. - local ok, result = pcall(fn) - - if not ok then - -- deviation: The message here will be a string with some extra info - -- that's not helpful, so we trim it down a bit - local subMessageIndex = string.find(result, " ") - local message = string.sub(result, subMessageIndex + 1) - - return { - pass = false, - message = function() - return message - end, - } + -- Trick to use a Jest 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. + do --[[ ROBLOX COMMENT: try-catch block conversion ]] + -- ROBLOX deviation START: use pcall, format message + -- local ok, result, hasReturned = xpcall(function() + -- fn() + -- end, function(error_) + -- return { + -- pass = false, + -- message = function() + -- return error_.message + -- end, + -- }, + -- true + -- end) + -- if hasReturned then + -- return result + -- end + local ok, result = pcall(fn) + + if not ok then + local stringResult = tostring(result) + local subMessageIndex = string.find(stringResult, " ") + local message = string.sub(stringResult, subMessageIndex + 1) + + return { + pass = false, + message = function() + return message + end, + } + end + -- ROBLOX deviation END end - return { pass = true } 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 + -- ROBLOX deviation START: use dot notation + -- local actualYields = Scheduler:unstable_clearYields() + local actualYields = Scheduler.unstable_clearYields() + -- ROBLOX deviation END + invariant( + -- ROBLOX deviation START: fix .length + -- actualYields.length == 0, + #actualYields == 0, + -- ROBLOX deviation END + "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 +local function unstable_toMatchRenderedOutput(root, expectedJSX) + assertYieldsWereCleared(root) + -- ROBLOX deviation START: use dot notation + -- local actualJSON = root:toJSON() + local actualJSON = root.toJSON() + -- ROBLOX deviation END + local actualJSX + if actualJSON == nil or typeof(actualJSON) == "string" then + actualJSX = actualJSON + -- ROBLOX deviation START: remove toJSBoolean + -- elseif Boolean.toJSBoolean(Array.isArray(actualJSON)) then + elseif Array.isArray(actualJSON) then + -- ROBLOX deviation END + -- ROBLOX deviation START: fix .length + -- if actualJSON.length == 0 then + if #actualJSON == 0 then + -- ROBLOX deviation END + actualJSX = nil + -- ROBLOX deviation START: fix .length + -- elseif actualJSON.length == 1 then + elseif #actualJSON == 1 then + -- ROBLOX deviation END + actualJSX = jsonChildToJSXChild(actualJSON[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]) + 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, + -- ROBLOX deviation START: remove toJSBoolean, use ReactGlobals.__DEV__ + -- _store = if Boolean.toJSBoolean(__DEV__) then {} else nil, + _store = if ReactGlobals.__DEV__ then {} else nil, + -- ROBLOX deviation END + } + end + end + else + actualJSX = jsonChildToJSXChild(actualJSON) + end + return captureAssertion(function() + expect(actualJSX).toEqual(expectedJSX) + 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 +exports.unstable_toMatchRenderedOutput = unstable_toMatchRenderedOutput +-- ROBLOX deviation START: predeclared function +-- local function jsonChildToJSXChild(jsonChild) +function jsonChildToJSXChild(jsonChild) + -- ROBLOX deviation 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 = if jsxChildren == nil + then jsonChild.props + else Object.assign({}, jsonChild.props, { children = jsxChildren }), + _owner = nil, + -- ROBLOX deviation START: remove toJSBoolean, use ReactGlobals.__DEV__ + -- _store = if Boolean.toJSBoolean(__DEV__) then {} else nil, + _store = if ReactGlobals.__DEV__ then {} else nil, + -- ROBLOX deviation END + } + end 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) +-- ROBLOX deviation START: predeclared function +-- local function jsonChildrenToJSXChildren(jsonChildren) +function jsonChildrenToJSXChildren(jsonChildren) + -- ROBLOX deviation END + if jsonChildren ~= nil then + -- ROBLOX deviation START: fix .length + -- if jsonChildren.length == 1 then + if #jsonChildren == 1 then + -- ROBLOX deviation END + return jsonChildToJSXChild(jsonChildren[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]) + elseif + -- ROBLOX deviation START: fix .length + -- jsonChildren.length + #jsonChildren + -- ROBLOX deviation END + > 1 --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + then + local jsxChildren = {} + local allJSXChildrenAreStrings = true + local jsxChildrenString = "" + -- ROBLOX deviation START: use in loop instead of while loop + -- do + -- local i = 0 + -- while + -- i + -- < jsonChildren.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local jsxChild = jsonChildToJSXChild(jsonChildren[tostring(i)]) + -- table.insert(jsxChildren, jsxChild) --[[ ROBLOX CHECK: check if 'jsxChildren' is an Array ]] + -- if Boolean.toJSBoolean(allJSXChildrenAreStrings) then + -- if typeof(jsxChild) == "string" then + -- jsxChildrenString += jsxChild + -- elseif jsxChild ~= nil then + -- allJSXChildrenAreStrings = false + -- end + -- end + -- i += 1 + -- end + -- end + for _, jsonChild in 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 + -- ROBLOX deviation END + -- ROBLOX deviation START: remove toJSBoolean + -- return if Boolean.toJSBoolean(allJSXChildrenAreStrings) + return if allJSXChildrenAreStrings + -- ROBLOX deviation END + then jsxChildrenString + else jsxChildren + end + end + return nil end - -return { - unstable_toMatchRenderedOutput = unstable_toMatchRenderedOutput -} +return exports diff --git a/modules/jest-react/src/getJestMatchers.roblox.lua b/modules/jest-react/src/getJestMatchers.roblox.lua deleted file mode 100644 index 0d6ae558..00000000 --- a/modules/jest-react/src/getJestMatchers.roblox.lua +++ /dev/null @@ -1,52 +0,0 @@ ---[[ - Roblox upstream: https://github.com/facebook/react/blob/69060e1da6061af845162dcf6854a5d9af28350a/scripts/jest/matchers/reactTestMatchers.js - - Note: this file is partially redundant with modules/scheduler/src/getJestMatchers.roblox.lua - That is also happening upstream: https://github.com/facebook/react/blob/47ff31a77add22bef54aaed9d4fb62d5aa693afd/scripts/jest/matchers/schedulerTestMatchers.js -]] -local JestReact = require(script.Parent.JestReact) - -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. - local ok, result = pcall(fn) - - if not ok then - return { - pass = false, - message = function() - return tostring(result) - end, - } - end - return { pass = true } -end - -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) - 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 - ) - end - return JestReact.unstable_toMatchRenderedOutput(ReactNoop, expectedJSX) - end - - return { - toMatchRenderedOutput = expectToMatchRenderedOutput - } -end diff --git a/modules/jest-react/src/init.lua b/modules/jest-react/src/init.lua index ee810831..2ba651cb 100644 --- a/modules/jest-react/src/init.lua +++ b/modules/jest-react/src/init.lua @@ -1,4 +1,16 @@ --- ROBLOX FIXME: Clean up this package organization -return { - getJestMatchers = require(script["getJestMatchers.roblox"]) -} \ No newline at end of file +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/jest-react/index.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 Packages = script.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object +local exports = {} +-- ROBLOX deviation START: extract to variable, fix import and export type +-- Object.assign(exports, require(script.src.JestReact)) +local jestReactModule = Object.assign(exports, require(script.JestReact)) +return exports :: typeof(exports) & typeof(jestReactModule) +-- ROBLOX deviation END diff --git a/modules/react-cache/.robloxrc b/modules/react-cache/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-cache/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-cache/rotriever.toml b/modules/react-cache/rotriever.toml index 0e230258..91554a93 100644 --- a/modules/react-cache/rotriever.toml +++ b/modules/react-cache/rotriever.toml @@ -3,16 +3,17 @@ name = "ReactCache" version = { workspace = true } authors = { workspace = true } content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] +ReactGlobals = { path = "../react-globals" } Scheduler = { path = "../scheduler" } Shared = { path = "../shared" } React = { path = "../react" } +LuauPolyfill = { workspace = true } [dev_dependencies] -JestRoblox = { workspace = true } -LuauPolyfill = { workspace = true } +JestGlobals = { workspace = true } Promise = { workspace = true } JestReact = { path = "../jest-react" } ReactTestRenderer = { path = "../react-test-renderer" } -RobloxJest = { path = "../roblox-jest" } diff --git a/modules/react-cache/src/LRU.lua b/modules/react-cache/src/LRU.lua index ea694ad6..5907c8cd 100644 --- a/modules/react-cache/src/LRU.lua +++ b/modules/react-cache/src/LRU.lua @@ -1,151 +1,172 @@ --- Upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-cache/src/LRU.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? } - +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-cache/src/LRU.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 -local Scheduler = require(Packages.Scheduler) +local LuauPolyfill = require(Packages.LuauPolyfill) +type Object = LuauPolyfill.Object local exports = {} - --- ROBLOX deviation: predeclare methods to fix declaration ordering problems -local deleteLeastRecentlyUsedEntries -local cleanUp - --- Intentionally not named imports because Rollup would +-- ROBLOX deviation START: fix import +-- local Scheduler = require(Packages.scheduler) -- Intentionally not named imports because Rollup would +local Scheduler = require(Packages.Scheduler) +-- ROBLOX deviation END -- use dynamic dispatch for CommonJS interop named imports. -local scheduleCallback = Scheduler.unstable_scheduleCallback -local IdlePriority = Scheduler.unstable_IdlePriority - -type Entry = { +local scheduleCallback, IdlePriority = + Scheduler.unstable_scheduleCallback, Scheduler.unstable_IdlePriority +-- ROBLOX deviation START: use next_ instead +-- type Entry = { value: T, onDelete: () -> unknown, previous: Entry, next: Entry } +export type Entry = { value: T, - onDelete: () -> any, + onDelete: () -> ...unknown, previous: Entry, next_: Entry, } - --- ROBLOX TODO: function generics --- exports.createLRU = function(limit: number) -exports.createLRU = function(limit: number) - local LIMIT = limit - - -- Circular, doubly-linked list - -- ROBLOX TODO: function generics - local first: Entry | nil = nil +-- ROBLOX deviation END +local function createLRU(limit: number) + -- ROBLOX deviation START: add predeclared methods to fix declaration ordering problems + local deleteLeastRecentlyUsedEntries + local cleanUp + -- ROBLOX deviation END + local LIMIT = limit -- Circular, doubly-linked list + local first: Entry | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] = + nil local size: number = 0 - local cleanUpIsScheduled: boolean = false - local function scheduleCleanUp() - if cleanUpIsScheduled == false and size > LIMIT then + if + cleanUpIsScheduled == false + and size > LIMIT --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + then -- The cache size exceeds the limit. Schedule a callback to delete the -- least recently used entries. cleanUpIsScheduled = true scheduleCallback(IdlePriority, cleanUp) end end - - cleanUp = function() + -- ROBLOX deviation START: predeclared function + -- local function cleanUp() + function cleanUp() + -- ROBLOX deviation END cleanUpIsScheduled = false deleteLeastRecentlyUsedEntries(LIMIT) end - - deleteLeastRecentlyUsedEntries = function(targetSize: number) + -- ROBLOX deviation START: predeclared function + -- local function deleteLeastRecentlyUsedEntries(targetSize: number) + function deleteLeastRecentlyUsedEntries(targetSize: number) + -- ROBLOX deviation END -- Delete entries from the cache, starting from the end of the list. if first ~= nil then - local resolvedFirst: Entry = first :: any - local last = resolvedFirst.previous - while size > targetSize and last ~= nil do + local resolvedFirst: Entry = first :: any + -- ROBLOX deviation START: explicitly type last + -- local last = resolvedFirst.previous + local last: Entry? = resolvedFirst.previous + -- ROBLOX deviation END + while + size > targetSize --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + and last ~= nil + do local onDelete = last.onDelete local previous = last.previous - last.onDelete = nil :: any - - -- Remove from the list + last.onDelete = nil :: any -- Remove from the list + -- ROBLOX deviation START: use next_ instead + -- last.next = nil :: any + -- last.previous = last.next last.next_ = nil :: any last.previous = last.next_ + -- ROBLOX deviation END if last == first then -- Reached the head of the list. last = nil first = last else (first :: any).previous = previous + -- ROBLOX deviation START: use next_ instead + -- previous.next = first :: any previous.next_ = first :: any + -- ROBLOX deviation END last = previous end - - size -= 1 - - -- Call the destroy method after removing the entry from the list. If it + size -= 1 -- Call the destroy method after removing the entry from the list. If it -- throws, the rest of cache will not be deleted, but it will be in a -- valid state. onDelete() end end end - - -- ROBLOX TODO: function generics - -- local function add(value: T, onDelete: () -> any): Entry - local function add(value: any, onDelete: () -> any): Entry - -- ROBLOX TODO: function generics - local entry: Entry = { + local function add(value: Object, onDelete: () -> unknown): Entry + local entry = { value = value, onDelete = onDelete, - next_ = nil, - previous = nil, - } + -- ROBLOX deviation START: use next_ instead + -- next = nil :: any, + next_ = nil :: any, + -- ROBLOX deviation END + previous = nil :: any, + -- ROBLOX deviation START: need to cast to Entry + -- } + } :: Entry + -- ROBLOX deviation END if first == nil then + -- ROBLOX deviation START: use next_ instead + -- entry.next = entry + -- entry.previous = entry.next entry.next_ = entry entry.previous = entry.next_ + -- ROBLOX deviation END first = entry else -- Append to head - -- ROBLOX FIXME: function generics, remove recast once Luau understands nil check - local last = (first :: Entry).previous + local last = first.previous + -- ROBLOX deviation START: use next_ instead + -- last.next = entry last.next_ = entry - entry.previous = last; - (first :: Entry).previous = entry - entry.next_ = first :: Entry - + -- ROBLOX deviation END + entry.previous = last + first.previous = entry + -- ROBLOX deviation START: use next_ instead + -- entry.next = first + entry.next_ = first + -- ROBLOX deviation END first = entry end size += 1 return entry end - - -- ROBLOX TODO: function generics - -- local function update(entry: Entry, newValue: T): () - local function update(entry: Entry, newValue: any): () + local function update(entry: Entry, newValue: T): () entry.value = newValue end - - -- ROBLOX TODO: function generics - -- local function access(entry: Entry): T - local function access(entry: Entry): any + local function access(entry: Entry): T + -- ROBLOX deviation START: use next_ instead + -- local next_ = entry.next local next_ = entry.next_ + -- ROBLOX deviation END if next_ ~= nil then -- Entry already cached - -- ROBLOX TODO: function generics - local resolvedFirst: Entry = first :: Entry + local resolvedFirst: Entry = first :: any if first ~= entry then -- Remove from current position local previous = entry.previous + -- ROBLOX deviation START: use next_ instead + -- previous.next = next_ previous.next_ = next_ - next_.previous = previous - - -- Append to head + -- ROBLOX deviation END + next_.previous = previous -- Append to head local last = resolvedFirst.previous + -- ROBLOX deviation START: use next_ instead + -- last.next = entry last.next_ = entry + -- ROBLOX deviation END entry.previous = last - resolvedFirst.previous = entry + -- ROBLOX deviation START: use next_ instead + -- entry.next = resolvedFirst entry.next_ = resolvedFirst - + -- ROBLOX deviation END first = entry end else @@ -155,18 +176,11 @@ exports.createLRU = function(limit: number) scheduleCleanUp() return entry.value end - local function setLimit(newLimit: number) LIMIT = newLimit scheduleCleanUp() end - - return { - add = add, - update = update, - access = access, - setLimit = setLimit, - } + return { add = add, update = update, access = access, setLimit = setLimit } end - +exports.createLRU = createLRU return exports diff --git a/modules/react-cache/src/ReactCacheOld.lua b/modules/react-cache/src/ReactCacheOld.lua index 5e5d8446..d925f991 100644 --- a/modules/react-cache/src/ReactCacheOld.lua +++ b/modules/react-cache/src/ReactCacheOld.lua @@ -1,61 +1,79 @@ --- Upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-cache/src/ReactCacheOld.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 --- */ - +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-cache/src/ReactCacheOld.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 +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: unnecessary import +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +-- ROBLOX deviation START: add inspect to print objects +local inspect = LuauPolyfill.util.inspect +-- ROBLOX deviation END +-- ROBLOX deviation START: not needed +-- local Error = LuauPolyfill.Error +-- local Map = LuauPolyfill.Map +-- ROBLOX deviation END +-- ROBLOX deviation START: use console from React Shared +-- local console = LuauPolyfill.console local console = require(Packages.Shared).console -local inspect = require(Packages.Shared).inspect.inspect -type Map = { [K]: V } -type Object = { [string]: any } +-- ROBLOX deviation END +type Map = LuauPolyfill.Map +local exports = {} +-- ROBLOX deviation START: add predeclared methods to fix declaration ordering +local deleteEntry +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import +-- local sharedReactTypesModule = require(Packages.shared.ReactTypes) +-- type Thenable = sharedReactTypesModule.Thenable local ReactTypes = require(Packages.Shared) +type Thenable = ReactTypes.Thenable +-- ROBLOX deviation END local React = require(Packages.React) -type Thenable = ReactTypes.Thenable - --- ROBLOX deviation: predeclare methods to fix declaration ordering -local deleteEntry - local createLRU = require(script.Parent.LRU).createLRU - --- ROBLOX deviation: use andThen convention, extra parameter for self +-- ROBLOX deviation START: add this type in an attempt to tighten up the types to detect bugs found manually +local LRU = require(script.Parent.LRU) +type Entry = LRU.Entry +type Record = { [K]: V } +-- ROBLOX deviation END +-- ROBLOX deviation START: change then to andThen +-- type Suspender = { ["then"]: (resolve: () -> unknown, reject: () -> unknown) -> unknown } --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] type Suspender = { - andThen: (Object, () -> any, () -> any) -> any, + andThen: ( + self: Suspender, + resolve: (...any) -> () | Suspender, + reject: (...any) -> () | Suspender + ) -> () | Suspender, } - +-- ROBLOX deviation END type PendingResult = { - status: number, + status: number,--[[ ROBLOX NOTE: changed '0' to 'number' as Luau doesn't support numeric singleton types ]] value: Suspender, } - -type ResolvedResult = { status: number, value: V } - +type ResolvedResult = { + status: number,--[[ ROBLOX NOTE: changed '1' to 'number' as Luau doesn't support numeric singleton types ]] + value: V, +} type RejectedResult = { - status: number, - value: any, + status: number,--[[ ROBLOX NOTE: changed '2' to 'number' as Luau doesn't support numeric singleton types ]] + value: unknown, } - type Result = PendingResult | ResolvedResult | RejectedResult - -type Resource = { - read: (I) -> V, - preload: (I) -> (), -} - +type Resource = { read: (I) -> V, preload: (I) -> () } --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] local Pending = 0 local Resolved = 1 local Rejected = 2 - local ReactCurrentDispatcher = + -- ROBLOX deviation START: import from Shared package + -- React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher require(Packages.Shared).ReactSharedInternals.ReactCurrentDispatcher - -local exports = {} - +-- ROBLOX deviation END local function readContext(Context, observedBits) local dispatcher = ReactCurrentDispatcher.current if dispatcher == nil then @@ -65,158 +83,251 @@ local function readContext(Context, observedBits) .. "lifecycle methods." ) end - return dispatcher.readContext(Context, observedBits) + -- ROBLOX deviation START: use dot notation and additional cast as Luau doesn't narrow type to non-nil + -- return dispatcher:readContext(Context, observedBits) + return (dispatcher :: any).readContext(Context, observedBits) + -- ROBLOX deviation END end - local function identityHashFn(input) - if _G.__DEV__ then + -- ROBLOX deviation START: remove unnecessary toJSBoolean and use _G + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END if typeof(input) ~= "string" and typeof(input) ~= "number" and typeof(input) ~= "boolean" and input ~= nil - and input ~= nil + -- ROBLOX deviation START: unnecessary duplicated condition - no difference between null and undefined + -- and input ~= nil + -- ROBLOX deviation END then console.error( "Invalid key type. Expected a string, number, symbol, or boolean, " .. "but instead received: %s" .. "\n\nTo use non-primitive values as keys, you must pass a hash " .. "function as the second argument to createResource().", + -- ROBLOX deviation START: use inspect to print object + -- input inspect(input) + -- ROBLOX deviation END ) end end return input end - local CACHE_LIMIT = 500 local lru = createLRU(CACHE_LIMIT) - -local entries: Map, Map> = {} - +-- ROBLOX deviation START: tightened this up versus upstream to try and detect more bugs +-- local entries: Map, Map> = Map.new() +local entries: Record, Record>> = {} +-- ROBLOX deviation END local CacheContext = React.createContext(nil) - --- ROBLOX TODO: use function generics when they are unflagged --- local function accessResult( --- resource: any, --- fetch: (I) -> Thenable, --- input: I, --- key: K --- ): Result -local function accessResult( +local function accessResult( resource: any, - fetch: (any) -> Thenable, - input: any, - key: any -): Result + fetch: (I) -> Thenable, + input: I, + key: K +): Result + -- ROBLOX deviation START: use regular indexing instead + -- local entriesForResource = entries:get(resource) local entriesForResource = entries[resource] + -- ROBLOX deviation END if entriesForResource == nil then + -- ROBLOX deviation START: use table instead + -- entriesForResource = Map.new() entriesForResource = {} - entries[resource] = entriesForResource + -- ROBLOX deviation END + -- ROBLOX deviation START: cast as Luau doesn't narrow type on itself and use regular index setting + -- entries:set(resource, entriesForResource) + entries[resource] = entriesForResource :: Record> + -- ROBLOX deviation END end - local entry = entriesForResource[key] + -- ROBLOX deviation START: cast as Luau doesn't narrow type on itself + -- local entry = entriesForResource:get(key) + local entriesForResource_ = ( + entriesForResource :: Record> + ) :: Record> + local entry = entriesForResource_[key] + -- ROBLOX deviation END if entry == nil then local thenable = fetch(input) - - -- ROBLOX deviation: reorder so newResult can be referenced in andThen() + -- ROBLOX deviation START: add reordered declaration so newResults so it can be referenced in andThen() local newResult: PendingResult = { status = Pending, - value = thenable, + value = thenable :: any, } - + -- ROBLOX deviation END + -- ROBLOX deviation START: use andThen + -- thenable:then_(function(value) thenable:andThen(function(value) + -- ROBLOX deviation END if newResult.status == Pending then - -- ROBLOX TODO: use function generics - local resolvedResult: ResolvedResult = newResult :: any + local resolvedResult: ResolvedResult = newResult :: any resolvedResult.status = Resolved resolvedResult.value = value end - end, function(error_) + -- ROBLOX deviation START: explicit return type + -- end, function(error_) + end, function(error_): () + -- ROBLOX deviation END if newResult.status == Pending then local rejectedResult: RejectedResult = newResult :: any rejectedResult.status = Rejected rejectedResult.value = error_ end end) - - local newEntry = lru.add(newResult, function() - return deleteEntry(resource, key) + -- ROBLOX deviation START: skip reordered code + -- local newResult: PendingResult = { status = Pending, value = thenable } + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- local newEntry = lru:add(newResult, function(...) + local newEntry = lru.add(newResult, function(...) + -- ROBLOX deviation END + -- ROBLOX deviation START: deleteEntry doesn't use 'self' + -- return deleteEntry(nil, resource, key, ...) + return deleteEntry(resource, key :: any) + -- ROBLOX deviation END end) - entriesForResource[key] = newEntry + -- ROBLOX deviation START: use casted variable as Luau doesn't narrow type on itself + -- entriesForResource:set(key, newEntry) + entriesForResource_[key] = newEntry + -- ROBLOX deviation END return newResult else - return (lru.access(entry) :: any) + -- ROBLOX deviation START: use dot notation + -- return lru:access(entry) :: any + return lru.access(entry) :: any + -- ROBLOX deviation END end end - -deleteEntry = function(resource, key) +-- ROBLOX deviation START: predeclared function +-- local function deleteEntry(resource, key) +function deleteEntry(resource, key): () + -- ROBLOX deviation END + -- ROBLOX deviation START: use regular indexer + -- local entriesForResource = entries:get(resource) local entriesForResource = entries[resource] + -- ROBLOX deviation END if entriesForResource ~= nil then + -- ROBLOX deviation START: set property to nil instead + -- entriesForResource:delete(key) entriesForResource[key] = nil - if entriesForResource.size == 0 then + -- ROBLOX deviation END + -- ROBLOX deviation START: use # operator instead + -- if entriesForResource.size == 0 then + if #entriesForResource == 0 then + -- ROBLOX deviation END + -- ROBLOX deviation START: set property to nil instead + -- entries:delete(resource) entries[resource] = nil + -- ROBLOX deviation END end end end - --- ROBLOX TODO: function generics and constraints --- export function unstable_createResource( --- exports.unstable_createResource = function( --- fetch: (I) -> Thenable, --- maybeHashInput: ((I) -> K)? --- ): Resource -exports.unstable_createResource = - function( - fetch: (any) -> Thenable, - maybeHashInput: ((any) -> any)? - ): Resource - local hashInput: (any) -> any - if maybeHashInput ~= nil then - -- ROBLOX TODO: remove recast once Luau understands nil check - hashInput = maybeHashInput :: (any) -> any - else - hashInput = identityHashFn :: any - end - - local resource - resource = { - -- ROBLOX TODO: function generics and constraints - read = function(input: any): any - -- react-cache currently doesn't rely on context, but it may in the - -- future, so we read anyway to prevent access outside of render. - readContext(CacheContext) - local key = hashInput(input) - -- ROBLOX TODO: function generics and constraints - local result: Result = accessResult(resource, fetch, input, key) - if result.status == Pending then - local suspender = result.value - error(suspender) - elseif result.status == Resolved then - local value = result.value - return value - elseif result.status == Rejected then - local error_ = result.value - error(error_) - else - -- Should be unreachable - return nil :: any - end - end, - - -- ROBLOX TODO: function generics and constraints - preload = function(input: any): () - -- react-cache currently doesn't rely on context, but it may in the - -- future, so we read anyway to prevent access outside of render. - readContext(CacheContext) - local key = hashInput(input) - accessResult(resource, fetch, input, key) - end, - } - return resource - end - -exports.unstable_setGlobalCacheLimit = function(limit: number) +local function unstable_createResource< + I, + K, --[[ ROBLOX CHECK: upstream type uses type constraint which is not supported by Luau ]] --[[ K: string | number ]] + V +>( + fetch: ( + I + ) -> Thenable, + maybeHashInput: ( + (I) -> K + )? +): Resource< + I, + V +> + local hashInput: (I) -> K = if maybeHashInput ~= nil + then maybeHashInput + else identityHashFn :: any + -- ROBLOX deviation START: split declaration and assignment + -- local resource = { + local resource + resource = { + -- ROBLOX deviation END + -- ROBLOX deviation START: no self param + -- read = function(self, input: I): V + read = function(input: I): V + -- ROBLOX deviation END + -- react-cache currently doesn't rely on context, but it may in the + -- future, so we read anyway to prevent access outside of render. + readContext(CacheContext) + local key = hashInput(input) + local result: Result = accessResult(resource, fetch, input, key) + -- ROBLOX deviation START: simplify switch statement conversion + -- repeat --[[ ROBLOX comment: switch statement conversion ]] + -- local entered_, break_ = false, false + -- local condition_ = result.status + -- for _, v in ipairs({ Pending, Resolved, Rejected }) do + -- if condition_ == v then + -- if v == Pending then + -- entered_ = true + -- do + -- local suspender = result.value + -- error(suspender) + -- end + -- end + -- if v == Resolved or entered_ then + -- entered_ = true + -- do + -- local value = result.value + -- return value + -- end + -- end + -- if v == Rejected or entered_ then + -- entered_ = true + -- do + -- local error_ = result.value + -- error(error_) + -- end + -- end + -- end + -- end + -- if not break_ then + -- -- Should be unreachable + -- return nil :: any + -- end + -- until true + if result.status == Pending then + local suspender = result.value + error(suspender) + elseif result.status == Resolved then + local value = result.value + -- ROBLOX deviation START: needs cast to narrow type + -- return value + return value :: V + -- ROBLOX deviation END + elseif result.status == Rejected then + local error_ = result.value + error(error_) + else + -- Should be unreachable + return nil :: any + end + -- ROBLOX deviation END + end, + -- ROBLOX deviation START: no self param + -- preload = function(self, input: I): () + preload = function(input: I): () + -- ROBLOX deviation END + -- react-cache currently doesn't rely on context, but it may in the + -- future, so we read anyway to prevent access outside of render. + readContext(CacheContext) + local key = hashInput(input) + accessResult(resource, fetch, input, key) + end, + } + return resource +end +exports.unstable_createResource = unstable_createResource +local function unstable_setGlobalCacheLimit(limit: number) + -- ROBLOX deviation START: use dot notation + -- lru:setLimit(limit) lru.setLimit(limit) + -- ROBLOX deviation END end - +exports.unstable_setGlobalCacheLimit = unstable_setGlobalCacheLimit return exports diff --git a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua index bfab4e16..d3698dba 100644 --- a/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua +++ b/modules/react-cache/src/__tests__/ReactCacheOld-internal.spec.lua @@ -1,15 +1,34 @@ --- Upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.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. --- * --- * @emails react-core --- */ - --- !strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.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. + * + * @emails react-core + ]] +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: unused imports +-- local Array = LuauPolyfill.Array +-- local Boolean = LuauPolyfill.Boolean +-- local Error = LuauPolyfill.Error +-- ROBLOX deviation END +-- ROBLOX deviation START: add additional types +type Error = LuauPolyfill.Error +-- ROBLOX deviation END +local setTimeout = LuauPolyfill.setTimeout +-- ROBLOX deviation START: import promise from dev dependencies +-- local Promise = require(Packages.Promise) +local Promise = require(Packages.Dev.Promise) +-- ROBLOX deviation END +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest local ReactCache local createResource @@ -20,388 +39,601 @@ local Scheduler local Suspense local TextResource local textResourceShouldFail - -return function() - local Packages = script.Parent.Parent.Parent - local LuauPolyfill = require(Packages.Dev.LuauPolyfill) - local Promise = require(Packages.Dev.Promise) - local setTimeout = LuauPolyfill.setTimeout - local jest = require(Packages.Dev.JestRoblox) - local jestExpect = jest.Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) - describe("ReactCache", function() - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() - - ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false - React = require(Packages.React) - Suspense = React.Suspense - ReactCache = require(script.Parent.Parent) - createResource = ReactCache.unstable_createResource - ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) - Scheduler = require(Packages.Scheduler) - - TextResource = createResource(function(input) - local text = input[1] - local ms = input[2] or 0 - local listeners = nil - local status = "pending" - local value = nil - return { - andThen = function(self, resolve, reject) - if status == "pending" then - if listeners == nil then - listeners = { { resolve = resolve, reject = reject } } - LuauPolyfill.setTimeout(function() - if textResourceShouldFail then - Scheduler.unstable_yieldValue(string.format("Promise rejected [%s]", text)) - status = "rejected" - 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)) - status = "resolved" - value = text - for _, listener in ipairs(listeners) do - listener.resolve(value) - end +describe("ReactCache", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: add useFakeTimers call + jest.useFakeTimers() + -- ROBLOX deviation END + -- ROBLOX deviation START: fix require + -- ReactFeatureFlags = require_("shared/ReactFeatureFlags") + ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + -- ROBLOX deviation END + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false + -- ROBLOX deviation START: fix require + -- React = require_("react") + React = require(Packages.React) + -- ROBLOX deviation END + Suspense = React.Suspense + -- ROBLOX deviation START: fix require + -- ReactCache = require_("react-cache") + ReactCache = require(script.Parent.Parent) + -- ROBLOX deviation END + createResource = ReactCache.unstable_createResource + -- ROBLOX deviation START: fix requires + -- ReactTestRenderer = require_("react-test-renderer") + -- Scheduler = require_("scheduler") + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + Scheduler = require(Packages.Scheduler) + -- ROBLOX deviation END + -- ROBLOX deviation START: explicit type + -- TextResource = createResource(function(ref0) + -- local text = ref0[1] + TextResource = createResource(function(ref0: { string | number }) + local text = ref0[1] :: string + -- ROBLOX deviation END + -- ROBLOX deviation START: simplify + -- local ms = (function() + -- local element = table.unpack(ref0, 2, 2) + -- if element == nil then + -- return 0 + -- else + -- return element + -- end + -- end)() + local ms = ref0[2] or 0 + -- ROBLOX deviation END + local listeners = nil + local status = "pending" + -- ROBLOX deviation START: explicit type + -- local value = nil + local value = nil :: string | Error | nil + -- ROBLOX deviation END + return { + -- ROBLOX deviation START: use andThen + -- ["then"] = function(self, resolve, reject) + andThen = function(self, resolve, reject) + -- ROBLOX deviation END + -- ROBLOX deviation START: simplify switch statement conversion + -- repeat --[[ ROBLOX comment: switch statement conversion ]] + -- local condition_ = status + -- if condition_ == "pending" then + -- do + -- if listeners == nil then + -- listeners = { { resolve = resolve, reject = reject } } + -- setTimeout(function() + -- if + -- Boolean.toJSBoolean(textResourceShouldFail) + -- then + -- Scheduler:unstable_yieldValue( + -- ("Promise rejected [%s]"):format( + -- tostring(text) + -- ) + -- ) + -- status = "rejected" + -- value = Error.new( + -- "Failed to load: " .. tostring(text) + -- ) + -- Array.forEach(listeners, function(listener) + -- return listener:reject(value) + -- end) --[[ ROBLOX CHECK: check if 'listeners' is an Array ]] + -- else + -- Scheduler:unstable_yieldValue( + -- ("Promise resolved [%s]"):format( + -- tostring(text) + -- ) + -- ) + -- status = "resolved" + -- value = text + -- Array.forEach(listeners, function(listener) + -- return listener:resolve(value) + -- end) --[[ ROBLOX CHECK: check if 'listeners' is an Array ]] + -- end + -- end, ms) + -- else + -- table.insert( + -- listeners, + -- { resolve = resolve, reject = reject } + -- ) --[[ ROBLOX CHECK: check if 'listeners' is an Array ]] + -- end + -- break + -- end + -- elseif condition_ == "resolved" then + -- do + -- resolve(value) + -- break + -- end + -- elseif condition_ == "rejected" then + -- do + -- reject(value) + -- break + -- end + -- end + -- until true + if status == "pending" then + if listeners == nil then + listeners = { { resolve = resolve, reject = reject } } + LuauPolyfill.setTimeout(function() + if textResourceShouldFail then + Scheduler.unstable_yieldValue( + string.format("Promise rejected [%s]", text) + ) + status = "rejected" + value = + LuauPolyfill.Error.new("Failed to load: " .. text) + for _, listener in listeners do + listener.reject(value) end - end, ms) - else - table.insert(listeners, { resolve = resolve, reject = reject }) - end - elseif status == "resolved" then - resolve(value) - elseif status == "rejected" then - reject(value) + else + Scheduler.unstable_yieldValue( + string.format("Promise resolved [%s]", text) + ) + status = "resolved" + value = text + for _, listener in listeners do + listener.resolve(value) + end + end + end, ms) + else + table.insert( + listeners, + { resolve = resolve, reject = reject } + ) end - end, - } - end, function(input) - return input[1] - end) - - textResourceShouldFail = false + elseif status == "resolved" then + resolve(value) + elseif status == "rejected" then + reject(value) + end + -- ROBLOX deviation END + end, + } + end, function(ref0) + -- ROBLOX deviation START: ms not used + -- local text, ms = table.unpack(ref0, 1, 2) + -- return text + return ref0[1] + -- ROBLOX deviation END end) - - local function Text(props) - Scheduler.unstable_yieldValue(props.text) - return props.text - end - - local function AsyncText(props) - local text = props.text + textResourceShouldFail = false + end) + local function Text(props) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_yieldValue(props.text) + Scheduler.unstable_yieldValue(props.text) + -- ROBLOX deviation END + return props.text + end + -- ROBLOX deviation START: explicit type + -- local function AsyncText(props) + local function AsyncText(props: { ms: number, text: string | number }) + -- ROBLOX deviation END + local text = props.text + do --[[ ROBLOX COMMENT: try-catch block conversion ]] + -- ROBLOX deviation START: use pcall + -- local ok, result, hasReturned = xpcall(function() local ok, result = pcall(function() - TextResource.read({ props.text, props.ms }) + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- TextResource:read({ props.text, props.ms }) + -- Scheduler:unstable_yieldValue(text) + TextResource.read({ props.text :: string | number, props.ms }) Scheduler.unstable_yieldValue(text) + -- ROBLOX deviation END + -- ROBLOX deviation START: using pcall + -- return text, true + -- end, function(promise) return text end) if not ok then - if typeof(result.andThen) == "function" then - Scheduler.unstable_yieldValue(string.format("Suspend! [%s]", text)) + local promise = result + -- ROBLOX deviation END + -- ROBLOX deviation START: use andThen + -- if typeof(promise["then"]) == "function" then + if typeof(promise.andThen) == "function" then + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_yieldValue( + Scheduler.unstable_yieldValue( + -- ROBLOX deviation END + ("Suspend! [%s]"):format(tostring(text)) + ) else - Scheduler.unstable_yieldValue(string.format("Error! [%s]", text)) + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_yieldValue(("Error! [%s]"):format(tostring(text))) + Scheduler.unstable_yieldValue(("Error! [%s]"):format(tostring(text))) + -- ROBLOX deviation END end - error(result) + error(promise) + -- ROBLOX deviation START: using pcall + -- end) + -- if hasReturned then + -- return result + -- end end return result + -- ROBLOX deviation END end - - it("throws a promise if the requested value is not in the cache", function() - local function App() - return (React.createElement( - Suspense, - { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(AsyncText, { ms = 100, text = "Hi" }) } - )) - end - - ReactTestRenderer.create(React.createElement(App), { - unstable_isConcurrent = true, - }) - -- Promise.delay(0):await() - - -- ROBLOX TODO: currently fails here with ReactCacheOld:178: attempt to index nil with 'status' - -- return value from accessResult is nil - jestExpect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) - - RobloxJest.advanceTimersByTime(100) - jestExpect(Scheduler).toHaveYielded({ "Promise resolved [Hi]" }) - jestExpect(Scheduler).toFlushAndYield({ "Hi" }) - end) - - it("throws an error on the subsequent read if the promise is rejected", function() + end + it("throws a promise if the requested value is not in the cache", function() + local function App() + return React.createElement( + Suspense, + { fallback = React.createElement(Text, { text = "Loading..." }) }, + React.createElement(AsyncText, { ms = 100, text = "Hi" }) + ) + end + ReactTestRenderer.create( + React.createElement(App, nil), + { unstable_isConcurrent = true } + ) + expect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) + jest.advanceTimersByTime(100) + expect(Scheduler).toHaveYielded({ "Promise resolved [Hi]" }) + expect(Scheduler).toFlushAndYield({ "Hi" }) + end) + it("throws an error on the subsequent read if the promise is rejected", function() + return Promise.resolve():andThen(function() local function App() - return (React.createElement( + return React.createElement( Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(AsyncText, { ms = 100, text = "Hi" }) } - )) + React.createElement(AsyncText, { ms = 100, text = "Hi" }) + ) end - - local root = ReactTestRenderer.create(React.createElement(App), { - unstable_isConcurrent = true, - }) - - jestExpect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) - + local root = ReactTestRenderer.create( + React.createElement(App, nil), + { unstable_isConcurrent = true } + ) + expect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) textResourceShouldFail = true - RobloxJest.advanceTimersByTime(100) - jestExpect(Scheduler).toHaveYielded({ "Promise rejected [Hi]" }) - - jestExpect(Scheduler).toFlushAndThrow("Failed to load: Hi") - jestExpect(Scheduler).toHaveYielded({ "Error! [Hi]", "Error! [Hi]" }) - - -- Should throw again on a subsequent read - root.update(React.createElement(App)) - jestExpect(Scheduler).toFlushAndThrow("Failed to load: Hi") - jestExpect(Scheduler).toHaveYielded({ "Error! [Hi]", "Error! [Hi]" }) + jest.advanceTimersByTime(100) + expect(Scheduler).toHaveYielded({ "Promise rejected [Hi]" }) + expect(Scheduler).toFlushAndThrow("Failed to load: Hi") + expect(Scheduler).toHaveYielded({ "Error! [Hi]", "Error! [Hi]" }) -- Should throw again on a subsequent read + -- ROBLOX deviation START: use dot notation + -- root:update(React.createElement(App, nil)) + root.update(React.createElement(App, nil)) + -- ROBLOX deviation END + expect(Scheduler).toFlushAndThrow("Failed to load: Hi") + expect(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() + end) + it( + "warns if non-primitive key is passed to a resource without a hash function", + function() + -- ROBLOX deviation START: explicit type + -- local BadTextResource = createResource(function(ref0) + -- local text = ref0[1] + local BadTextResource = createResource(function(ref0: { string | number }) + local text = ref0[1] :: string + -- ROBLOX deviation END + -- ROBLOX deviation START: simplify + -- local ms = (function() + -- local element = table.unpack(ref0, 2, 2) + -- if element == nil then + -- return 0 + -- else + -- return element + -- end + -- end)() + local ms = ref0[2] or 0 + -- ROBLOX deviation END + return Promise.new(function(resolve, reject) + return setTimeout(function() resolve(text) end, ms) end) end) - local function App() + -- ROBLOX deviation START: use dot notation and cast type because luau doesn't support mixed arrays + -- Scheduler:unstable_yieldValue("App") + -- return BadTextResource:read({ "Hi", 100 }) Scheduler.unstable_yieldValue("App") - return BadTextResource.read({ "Hi", 100 }) + return BadTextResource.read({ "Hi" :: string | number, 100 }) + -- ROBLOX deviation END end - ReactTestRenderer.create( React.createElement( Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(App) } + React.createElement(App, nil) ), { unstable_isConcurrent = true } ) - - 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' + -- ROBLOX deviation START: remove toJSBoolean and use _G + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + expect(function() + expect(Scheduler).toFlushAndYield({ "App", "Loading..." }) + end).toErrorDev({ + "Invalid key type. Expected a string, number, symbol, or " + -- ROBLOX deviation START: FIXME - make console polyfill format arrays the same as JS + -- .. "boolean, but instead received: Hi,100\n\n" + .. 'boolean, but instead received: ["Hi", 100]\n\n' + -- ROBLOX deviation END .. "To use non-primitive values as keys, you must pass a hash " - .. "function as the second argument to createResource()." - ) + .. "function as the second argument to createResource().", + }) else - jestExpect(Scheduler).toFlushAndYield({ "App", "Loading..." }) + expect(Scheduler).toFlushAndYield({ "App", "Loading..." }) end - end) - - it("evicts least recently used values", function() + end + ) + it("evicts least recently used values", function() + return Promise.resolve():andThen(function() + -- ROBLOX deviation START: use dot notation + -- ReactCache:unstable_setGlobalCacheLimit(3) -- Render 1, 2, and 3 ReactCache.unstable_setGlobalCacheLimit(3) - - -- Render 1, 2, and 3 + -- ROBLOX deviation END local root = ReactTestRenderer.create( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { + 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(AsyncText, { ms = 100, text = 3 }) + ), { unstable_isConcurrent = true } ) - jestExpect(Scheduler).toFlushAndYield({ + expect(Scheduler).toFlushAndYield({ "Suspend! [1]", "Suspend! [2]", "Suspend! [3]", "Loading...", }) - RobloxJest.advanceTimersByTime(100) - jestExpect(Scheduler).toHaveYielded({ + jest.advanceTimersByTime(100) + expect(Scheduler).toHaveYielded({ "Promise resolved [1]", "Promise resolved [2]", "Promise resolved [3]", }) - jestExpect(Scheduler).toFlushAndYield({ 1, 2, 3 }) - jestExpect(root).toMatchRenderedOutput("123") - - -- Render 1, 4, 5 + expect(Scheduler).toFlushAndYield({ 1, 2, 3 }) + expect(root).toMatchRenderedOutput("123") -- Render 1, 4, 5 + -- ROBLOX deviation START: use dot notation + -- root:update( root.update( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { + -- ROBLOX deviation END + 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(AsyncText, { ms = 100, text = 5 }) + ) ) - - jestExpect(Scheduler).toFlushAndYield({ - 1, + expect(Scheduler).toFlushAndYield({ + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- 1, + 1 :: number | string, + -- ROBLOX deviation END "Suspend! [4]", "Suspend! [5]", "Loading...", }) - RobloxJest.advanceTimersByTime(100) - jestExpect(Scheduler).toHaveYielded({ + jest.advanceTimersByTime(100) + expect(Scheduler).toHaveYielded({ "Promise resolved [4]", "Promise resolved [5]", }) - jestExpect(Scheduler).toFlushAndYield({ 1, 4, 5 }) - jestExpect(root).toMatchRenderedOutput("145") - - -- We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least + expect(Scheduler).toFlushAndYield({ 1, 4, 5 }) + expect(root).toMatchRenderedOutput("145") -- We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least -- recently used values are 2 and 3. They should have been evicted. - + -- ROBLOX deviation START: use dot notation + -- root:update( root.update( - React.createElement(Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, { + -- ROBLOX deviation END + 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(AsyncText, { ms = 100, text = 3 }) + ) ) - - jestExpect(Scheduler).toFlushAndYield({ + expect(Scheduler).toFlushAndYield({ -- 1 is still cached - 1, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- 1, + 1 :: number | string, + -- ROBLOX deviation END -- 2 and 3 suspend because they were evicted from the cache "Suspend! [2]", "Suspend! [3]", "Loading...", }) - RobloxJest.advanceTimersByTime(100) - jestExpect(Scheduler).toHaveYielded({ + jest.advanceTimersByTime(100) + expect(Scheduler).toHaveYielded({ "Promise resolved [2]", "Promise resolved [3]", }) - jestExpect(Scheduler).toFlushAndYield({ 1, 2, 3 }) - jestExpect(root).toMatchRenderedOutput("123") + expect(Scheduler).toFlushAndYield({ 1, 2, 3 }) + expect(root).toMatchRenderedOutput("123") end) - - it("preloads during the render phase", function() + end, 9999999999) + it("preloads during the render phase", function() + return Promise.resolve():andThen(function() local function App() + -- ROBLOX deviation START: use dot notation + -- TextResource:preload({ "B", 1000 }) + -- TextResource:read({ "A", 1000 }) + -- TextResource:read({ "B", 1000 }) TextResource.preload({ "B", 1000 }) TextResource.read({ "A", 1000 }) TextResource.read({ "B", 1000 }) + -- ROBLOX deviation END return React.createElement(Text, { text = "Result" }) end - local root = ReactTestRenderer.create( React.createElement( Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(App) } + React.createElement(App, nil) ), { unstable_isConcurrent = true } ) - - jestExpect(Scheduler).toFlushAndYield({ "Loading..." }) - - RobloxJest.advanceTimersByTime(1000) - jestExpect(Scheduler).toHaveYielded({ + expect(Scheduler).toFlushAndYield({ "Loading..." }) + jest.advanceTimersByTime(1000) + expect(Scheduler).toHaveYielded({ "Promise resolved [B]", "Promise resolved [A]", }) - jestExpect(Scheduler).toFlushAndYield({ "Result" }) - jestExpect(root).toMatchRenderedOutput("Result") + expect(Scheduler).toFlushAndYield({ "Result" }) + expect(root).toMatchRenderedOutput("Result") end) - - it("if a thenable resolves multiple times, does not update the first cached value", function() + 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 BadTextResource = createResource(function(ref0) + -- ROBLOX deviation START: unused + -- local text = ref0[1] + -- local ms = (function() + -- local element = table.unpack(ref0, 2, 2) + -- if element == nil then + -- return 0 + -- else + -- return element + -- end + -- end)() + -- ROBLOX deviation END local listeners = nil local value = nil return { + -- ROBLOX deviation START: use andThen + -- ["then"] = function(self, resolve, reject) andThen = function(self, resolve, reject) + -- ROBLOX deviation END if value ~= nil then resolve(value) else if listeners == nil then listeners = { resolve } resolveThenable = function(v) - for _, listener in pairs(listeners) do + -- ROBLOX deviation START: use for..in loop instead + -- Array.forEach(listeners, function(listener) + -- return listener(v) + -- end) --[[ ROBLOX CHECK: check if 'listeners' is an Array ]] + for _, listener in listeners do listener(v) end + -- ROBLOX deviation END end else - table.insert(listeners, resolve) + table.insert(listeners, resolve) --[[ ROBLOX CHECK: check if 'listeners' is an Array ]] end end end, } - end, function(input) - return input[1] + -- ROBLOX deviation START: explicit type + -- end, function(ref0) + end, function(ref0: { any }) + -- ROBLOX deviation END + -- ROBLOX deviation START: ms not used + -- local text, ms = table.unpack(ref0, 1, 2) + -- return text + return ref0[1] + -- ROBLOX deviation END 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) - - 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)) + do --[[ ROBLOX COMMENT: try-catch block conversion ]] + -- ROBLOX deviation START: use pcall + -- local ok, result, hasReturned = xpcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- local actualText = BadTextResource:read({ props.text, props.ms }) + -- Scheduler:unstable_yieldValue(actualText) + local actualText = BadTextResource.read({ props.text, props.ms }) + Scheduler.unstable_yieldValue(actualText) + -- ROBLOX deviation END + -- ROBLOX deviation START: using pcall + -- return actualText, true + -- end, function(promise) + return actualText + end) + if not ok then + local promise = result + -- ROBLOX deviation END + -- ROBLOX deviation START: use andThen + -- if typeof(promise["then"]) == "function" then + if typeof(promise.andThen) == "function" then + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_yieldValue( + Scheduler.unstable_yieldValue( + -- ROBLOX deviation END + ("Suspend! [%s]"):format(tostring(text)) + ) + else + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_yieldValue( + Scheduler.unstable_yieldValue( + -- ROBLOX deviation END + ("Error! [%s]"):format(tostring(text)) + ) + end + error(promise) + -- ROBLOX deviation START: using pcall + -- end) + -- if hasReturned then + -- return result + -- end end - error(result) + return result + -- ROBLOX deviation END end - return result end - local root = ReactTestRenderer.create( React.createElement( Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(BadAsyncText, { text = "Hi" }) } + React.createElement(BadAsyncText, { text = "Hi" }) ), - { - unstable_isConcurrent = true, - } + { unstable_isConcurrent = true } ) - - jestExpect(Scheduler).toFlushAndYield({ "Suspend! [Hi]", "Loading..." }) - - resolveThenable("Hi") - -- This thenable improperly resolves twice. We should not update the + expect(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") - + -- ROBLOX deviation START: use dot notation + -- root:update( root.update( + -- ROBLOX deviation END React.createElement( Suspense, { fallback = React.createElement(Text, { text = "Loading..." }) }, - { React.createElement(BadAsyncText, { text = "Hi" }) } + React.createElement(BadAsyncText, { text = "Hi" }) ), - { - unstable_isConcurrent = true, - } + { unstable_isConcurrent = true } ) - - 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) - - 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) + expect(Scheduler).toHaveYielded({}) + expect(Scheduler).toFlushAndYield({ "Hi" }) + expect(root).toMatchRenderedOutput("Hi") + end + ) + it("throws if read is called outside render", function() + expect(function() + -- ROBLOX deviation START: use dot notation + -- return TextResource:read({ "A", 1000 }) + TextResource.read({ "A", 1000 }) + -- ROBLOX deviation END + 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() + expect(function() + -- ROBLOX deviation START: use dot notation + -- return TextResource:preload({ "A", 1000 }) + TextResource.preload({ "A", 1000 }) + -- ROBLOX deviation END + end).toThrow( + "read and preload may only be called from within a component's render" + ) end) -end +end) diff --git a/modules/react-cache/src/__tests__/init.spec.lua b/modules/react-cache/src/__tests__/init.spec.lua deleted file mode 100644 index 22f66832..00000000 --- a/modules/react-cache/src/__tests__/init.spec.lua +++ /dev/null @@ -1,17 +0,0 @@ -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.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/react-cache/src/init.lua b/modules/react-cache/src/init.lua index 93ae8313..60d74650 100644 --- a/modules/react-cache/src/init.lua +++ b/modules/react-cache/src/init.lua @@ -1 +1,19 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-cache/index.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 + ]] +-- ROBLOX deviation START: simplify +-- local Packages --[[ ROBLOX comment: must define Packages module ]] +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Object = LuauPolyfill.Object +-- local exports = {} +-- +-- Object.assign(exports, require(script.src.ReactCacheOld)) +-- return exports return require(script.ReactCacheOld) +-- ROBLOX deviation END diff --git a/modules/react-debug-tools/.robloxrc b/modules/react-debug-tools/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-debug-tools/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-debug-tools/default.project.json b/modules/react-debug-tools/default.project.json new file mode 100644 index 00000000..78363235 --- /dev/null +++ b/modules/react-debug-tools/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDebugTools", + "tree": { + "$path": "src" + } +} diff --git a/modules/react-debug-tools/rotriever.toml b/modules/react-debug-tools/rotriever.toml new file mode 100644 index 00000000..2541efaf --- /dev/null +++ b/modules/react-debug-tools/rotriever.toml @@ -0,0 +1,19 @@ +[package] +name = "ReactDebugTools" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" + +[dependencies] +LuauPolyfill = { workspace = true } +Shared = { path = "../shared" } +ReactGlobals = { path = "../react-globals" } +ReactReconciler = { path = "../react-reconciler" } + +[dev_dependencies] +JestGlobals = { workspace = true } +Promise = { workspace = true } +React = { path = "../react" } +ReactTestRenderer = { path = "../react-test-renderer" } +Scheduler = { path = "../scheduler" } diff --git a/modules/react-debug-tools/src/ReactDebugHooks.lua b/modules/react-debug-tools/src/ReactDebugHooks.lua new file mode 100644 index 00000000..9d0642a0 --- /dev/null +++ b/modules/react-debug-tools/src/ReactDebugHooks.lua @@ -0,0 +1,1248 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/ReactDebugHooks.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 void = nil --[[ ROBLOX FIXME: adding `void` type alias to make it easier to use Luau `void` equivalent when supported ]] +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +type Error = LuauPolyfill.Error +type Map = LuauPolyfill.Map +-- ROBLOX deviation START: add additional imports +type Object = LuauPolyfill.Object +local String = LuauPolyfill.String +-- ROBLOX deviation END +local exports = {} +-- local sharedReactTypesModule = require(Packages.shared.ReactTypes) +-- type MutableSource = sharedReactTypesModule.MutableSource +-- type MutableSourceGetSnapshotFn = sharedReactTypesModule.MutableSourceGetSnapshotFn +-- type MutableSourceSubscribeFn = sharedReactTypesModule.MutableSourceSubscribeFn +-- type ReactContext = sharedReactTypesModule.ReactContext +-- type ReactProviderType = sharedReactTypesModule.ReactProviderType +local ReactTypes = require(Packages.Shared) +type MutableSource = ReactTypes.MutableSource +type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn< + Source, + Snapshot +> +type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn< + Source, + Snapshot +> +type ReactContext = ReactTypes.ReactContext +type ReactProviderType = ReactTypes.ReactProviderType + +-- ROBLOX deviation END +-- ROBLOX deviation START: add import type that is a built-in in flow +type React_Node = ReactTypes.React_Node +-- ROBLOX deviation END + +-- ROBLOX deviation START: add binding support +type ReactBinding = ReactTypes.ReactBinding +type ReactBindingUpdater = ReactTypes.ReactBindingUpdater +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import +-- local reactReconcilerSrcReactInternalTypesModule = +-- require(Packages["react-reconciler"].src.ReactInternalTypes) +local reactReconcilerSrcReactInternalTypesModule = require(Packages.ReactReconciler) +-- ROBLOX deviation END +type Fiber = reactReconcilerSrcReactInternalTypesModule.Fiber +type DispatcherType = reactReconcilerSrcReactInternalTypesModule.Dispatcher +-- ROBLOX deviation START: fix import - import from Shared +-- local reactReconcilerSrcReactFiberHostConfigModule = +-- require(Packages["react-reconciler"].src.ReactFiberHostConfig) +local reactReconcilerSrcReactFiberHostConfigModule = require(Packages.Shared) +-- ROBLOX deviation END +type OpaqueIDType = reactReconcilerSrcReactFiberHostConfigModule.OpaqueIDType +-- ROBLOX deviation START: fix import +-- local NoMode = require(Packages["react-reconciler"].src.ReactTypeOfMode).NoMode +local ReconcilerModule = require(Packages.ReactReconciler)({}) +local NoMode = ReconcilerModule.ReactTypeOfMode.NoMode +-- ROBLOX deviation END +-- ROBLOX deviation START: add inline ErrorStackParser implementation +-- local ErrorStackParser = require(Packages["error-stack-parser"]).default +type StackFrame = { + source: string?, + functionName: string?, +} +local ErrorStackParser = { + parse = function(error_: Error): Array + if error_.stack == nil then + return {} + end + local filtered = Array.filter( + string.split(error_.stack :: string, "\n"), + function(line) + return string.find(line, "^LoadedCode") ~= nil + end + ) + return Array.map(filtered, function(stackTraceLine) + -- ROBLOX FIXME Luau: shouldn't need to explicitly provide nilable field + local functionName = string.match(stackTraceLine, "function (%w+)$") + return { source = stackTraceLine, functionName = functionName } + end) + end, +} +-- ROBLOX deviation END +-- ROBLOX deviation START: import from Shared +-- local ReactSharedInternals = require(Packages.shared.ReactSharedInternals).default +-- local REACT_OPAQUE_ID_TYPE = require(Packages.shared.ReactSymbols).REACT_OPAQUE_ID_TYPE +local SharedModule = require(Packages.Shared) +local ReactSharedInternals = SharedModule.ReactSharedInternals +local ReactSymbols = SharedModule.ReactSymbols +local REACT_OPAQUE_ID_TYPE = ReactSymbols.REACT_OPAQUE_ID_TYPE +-- ROBLOX deviation END +-- ROBLOX deviation START: fix import - get from ReconcilerModule +-- local reactReconcilerSrcReactWorkTagsModule = +-- require(Packages["react-reconciler"].src.ReactWorkTags) +local reactReconcilerSrcReactWorkTagsModule = ReconcilerModule.ReactWorkTags +-- ROBLOX deviation END +local FunctionComponent = reactReconcilerSrcReactWorkTagsModule.FunctionComponent +local SimpleMemoComponent = reactReconcilerSrcReactWorkTagsModule.SimpleMemoComponent +local ContextProvider = reactReconcilerSrcReactWorkTagsModule.ContextProvider +local ForwardRef = reactReconcilerSrcReactWorkTagsModule.ForwardRef +local Block = reactReconcilerSrcReactWorkTagsModule.Block +-- ROBLOX deviation START: fix import +-- type CurrentDispatcherRef = typeof(ReactSharedInternals_ReactCurrentDispatcher) -- Used to track hooks called during a render +type CurrentDispatcherRef = typeof(ReactSharedInternals.ReactCurrentDispatcher) +-- ROBLOX deviation END +type HookLogEntry = { primitive: string, stackError: Error, value: unknown } --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] +local hookLog: Array = {} -- Primitives +type BasicStateAction = (S) -> S | S +type Dispatch = (A) -> () +local primitiveStackCache: nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Map> = + nil +local currentFiber: Fiber | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] = + nil +type Hook = { + memoizedState: any, + next: Hook | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +} +-- ROBLOX deviation START: add predefined variable +local Dispatcher: DispatcherType +-- ROBLOX deviation END +local function getPrimitiveStackCache(): Map> + -- This initializes a cache of all primitive hooks so that the top + -- most stack frames added by calling the primitive hook can be removed. + if primitiveStackCache == nil then + local cache = Map.new() + local readHookLog + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + -- Use all hooks here to add them to the hook log. + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useContext({ _currentValue = nil } :: any) + -- Dispatcher:useState(nil) + -- Dispatcher:useReducer(function(s, a) + Dispatcher.useContext({ _currentValue = nil } :: any) + Dispatcher.useState(nil) + Dispatcher.useReducer(function(s, a) + -- ROBLOX deviation END + return s + end, nil) + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useRef(nil) + -- Dispatcher:useLayoutEffect(function() end) + -- Dispatcher:useEffect(function() end) + -- Dispatcher:useImperativeHandle(nil, function() + Dispatcher.useRef(nil) + Dispatcher.useLayoutEffect(function() end) + Dispatcher.useEffect(function() end) + Dispatcher.useImperativeHandle(nil, function() + -- ROBLOX deviation END + return nil + end) + -- ROBLOX deviation START: use dot notation + -- Dispatcher:useDebugValue(nil) + -- Dispatcher:useCallback(function() end) + -- Dispatcher:useMemo(function() + Dispatcher.useDebugValue(nil) + Dispatcher.useCallback(function() end) + Dispatcher.useMemo(function() + -- ROBLOX deviation END + return nil + end) + end) + do + readHookLog = hookLog + hookLog = {} + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use for in loop instead of while + -- do + -- local i = 0 + -- while + -- i + -- < readHookLog.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local hook = readHookLog[tostring(i)] + -- cache:set(hook.primitive, ErrorStackParser:parse(hook.stackError)) + -- i += 1 + -- end + -- end + for i = 1, #readHookLog do + local hook = readHookLog[i] + cache:set(hook.primitive, ErrorStackParser.parse(hook.stackError)) + end + -- ROBLOX deviation END + primitiveStackCache = cache + end + -- ROBLOX deviation START: needs cast + -- return primitiveStackCache + return primitiveStackCache :: Map> + -- ROBLOX deviation END +end +local currentHook: nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Hook = + nil +local function nextHook( +): nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | Hook + local hook = currentHook + if hook ~= nil then + currentHook = hook.next + end + return hook +end +local function readContext( + context: ReactContext, + observedBits: void | number | boolean +): T + -- For now we don't expose readContext usage in the hooks debugging info. + return context._currentValue +end +local function useContext( + context: ReactContext, + observedBits: void | number | boolean +): T + table.insert( + hookLog, + { primitive = "Context", stackError = Error.new(), value = context._currentValue } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return context._currentValue +end +-- ROBLOX deviation START: return 2 values instead of a tuple +-- local function useState( +-- initialState: () -> S | S +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [S, Dispatch>] ]] +local function useState(initialState: (() -> S) | S): (S, Dispatch>) + -- ROBLOX deviation END + local hook = nextHook() + local state: S = if hook ~= nil + then hook.memoizedState + else if typeof(initialState) == "function" + then -- $FlowFixMe: Flow doesn't like mixed types + initialState() + else initialState + table.insert( + hookLog, + { primitive = "State", stackError = Error.new(), value = state } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: return 2 values instead of a tuple + -- return { state, function(action: BasicStateAction) end } + return state, function(action: BasicStateAction) end + -- ROBLOX deviation END +end +-- ROBLOX deviation START: return 2 values instead of a tuple +-- local function useReducer( +-- reducer: (S, A) -> S, +-- initialArg: I, +-- init: ((I) -> S)? +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [S, Dispatch] ]] +local function useReducer( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? +): (S, Dispatch) + -- ROBLOX deviation END + local hook = nextHook() + local state + if hook ~= nil then + state = hook.memoizedState + else + state = if init ~= nil then init(initialArg) else (initialArg :: any) :: S + end + table.insert( + hookLog, + { primitive = "Reducer", stackError = Error.new(), value = state } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: return 2 values instead of a tuple + -- return { state, function(action: A) end } + return state, function(action: A) end + -- ROBLOX deviation END +end +local function useRef(initialValue: T): { current: T } + local hook = nextHook() + local ref = if hook ~= nil then hook.memoizedState else { current = initialValue } + table.insert( + hookLog, + { primitive = "Ref", stackError = Error.new(), value = ref.current } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return ref +end +-- ROBLOX deviation START: add binding support; these aren't fully working hooks, so this +-- is just an approximation modeled off of the `ref` hook above +local function useBinding(initialValue: T): (ReactBinding, ReactBindingUpdater) + local hook = nextHook() + local binding = if hook ~= nil + then hook.memoizedState + else ({ + getValue = function(_self) + return initialValue + end, + } :: any) :: ReactBinding + + table.insert(hookLog, { + primitive = "Binding", + stackError = Error.new(), + value = binding:getValue(), + }) + + return binding, function(_value) end +end +-- ROBLOX deviation END +local function useLayoutEffect( + -- ROBLOX deviation START: Luau needs union type packs for this type to translate idiomatically + -- create: () -> () -> () | void, + create: (() -> ()) | (() -> () -> ()), + -- ROBLOX deviation END + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() + table.insert( + hookLog, + { primitive = "LayoutEffect", stackError = Error.new(), value = create } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useEffect( + -- ROBLOX deviation START: Luau needs union type packs for this type to translate idiomatically + -- create: () -> () -> () | void, + create: (() -> ()) | (() -> () -> ()), + -- ROBLOX deviation END + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() + table.insert( + hookLog, + { primitive = "Effect", stackError = Error.new(), value = create } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useImperativeHandle( + ref: { + current: T | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + } | ( + inst: T | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + ) -> unknown | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] | void, + create: () -> T, + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + nextHook() -- We don't actually store the instance anywhere if there is no ref callback + -- and if there is a ref callback it might not store it but if it does we + -- have no way of knowing where. So let's only enable introspection of the + -- ref itself if it is using the object form. + local instance = nil + if ref ~= nil and typeof(ref) == "table" then + instance = ref.current + end + table.insert( + hookLog, + { primitive = "ImperativeHandle", stackError = Error.new(), value = instance } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +-- ROBLOX deviation START: add generic params +-- local function useDebugValue(value: any, formatterFn: ((value: any) -> any)?) +local function useDebugValue(value: T, formatterFn: ((value: T) -> any)?): () + -- ROBLOX deviation END + table.insert(hookLog, { + primitive = "DebugValue", + stackError = Error.new(), + value = if typeof(formatterFn) == "function" then formatterFn(value) else value, + }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +end +local function useCallback( + callback: T, + inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): T + local hook = nextHook() + table.insert(hookLog, { + primitive = "Callback", + stackError = Error.new(), + value = if hook ~= nil + then hook.memoizedState[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + else callback, + }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return callback +end +-- ROBLOX deviation START: FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 +-- local function useMemo( +-- nextCreate: () -> T, +-- inputs: Array | void | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +-- ): T +local function useMemo(nextCreate: () -> T..., inputs: Array | nil): ...any + -- ROBLOX deviation END + local hook = nextHook() + -- ROBLOX deviation START: Wrap memoized values in a table and unpack to allow for multiple return values + -- local value = if hook ~= nil + -- then hook.memoizedState[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ] + -- else nextCreate() + local value = if hook ~= nil then hook.memoizedState[1] else { nextCreate() } + -- ROBLOX deviation END + + table.insert(hookLog, { primitive = "Memo", stackError = Error.new(), value = value }) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + -- ROBLOX deviation START: unwrap memoized values in a table + -- return value + return table.unpack(value) + -- ROBLOX deviation END +end +local function useMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > +): Snapshot + -- useMutableSource() composes multiple hooks internally. + -- Advance the current hook index the same number of times + -- so that subsequent hooks have the right memoized state. + nextHook() -- MutableSource + nextHook() -- State + nextHook() -- Effect + nextHook() -- Effect + local value = getSnapshot(source._source) + table.insert( + hookLog, + { primitive = "MutableSource", stackError = Error.new(), value = value } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return value +end +-- ROBLOX deviation START: enable these once they are fully enabled in the Dispatcher type and in ReactFiberHooks' myriad dispatchers +-- local function useTransition( +-- ): any --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [(() => void) => void, boolean] ]] +-- -- useTransition() composes multiple hooks internally. +-- -- Advance the current hook index the same number of times +-- -- so that subsequent hooks have the right memoized state. +-- nextHook() -- State +-- nextHook() -- Callback +-- table.insert( +-- hookLog, +-- { primitive = "Transition", stackError = Error.new(), value = nil } +-- ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +-- return { function(callback) end, false } +-- end +-- local function useDeferredValue(value: T): T +-- -- useDeferredValue() composes multiple hooks internally. +-- -- Advance the current hook index the same number of times +-- -- so that subsequent hooks have the right memoized state. +-- nextHook() -- State +-- nextHook() -- Effect +-- table.insert( +-- hookLog, +-- { primitive = "DeferredValue", stackError = Error.new(), value = value } +-- ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] +-- return value +-- end +-- ROBLOX deviation END +local function useOpaqueIdentifier(): OpaqueIDType | void + local hook = nextHook() -- State + -- ROBLOX deviation START: simplify + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(currentFiber) + -- then currentFiber.mode == NoMode + -- else currentFiber + -- ) + -- then + if currentFiber and currentFiber.mode == NoMode then + -- ROBLOX deviation END + nextHook() -- Effect + end + local value = if hook == nil then nil else hook.memoizedState + -- ROBLOX deviation START: simplify + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(value) + -- then value["$$typeof"] == REACT_OPAQUE_ID_TYPE + -- else value + -- ) + -- then + if value and (value :: any)["$$typeof"] == REACT_OPAQUE_ID_TYPE then + -- ROBLOX deviation END + value = nil + end + table.insert( + hookLog, + { primitive = "OpaqueIdentifier", stackError = Error.new(), value = value } + ) --[[ ROBLOX CHECK: check if 'hookLog' is an Array ]] + return value +end +-- ROBLOX deviation START: predefined variable +-- local Dispatcher: DispatcherType = { +Dispatcher = { + -- ROBLOX deviation END + readContext = readContext, + useCallback = useCallback, + useContext = useContext, + useEffect = useEffect, + -- ROBLOX deviation START: needs cast + -- useImperativeHandle = useImperativeHandle, + useImperativeHandle = useImperativeHandle :: any, + -- ROBLOX deviation END + useDebugValue = useDebugValue, + useLayoutEffect = useLayoutEffect, + -- ROBLOX deviation START: needs cast + -- useMemo = useMemo, + useMemo = useMemo :: any, + -- ROBLOX deviation END + useReducer = useReducer, + useRef = useRef, + -- ROBLOX deviation START: add useBinding + useBinding = useBinding, + -- ROBLOX deviation END + -- ROBLOX deviation START: needs cast + -- useState = useState, + useState = useState :: any, + -- ROBLOX deviation END + -- ROBLOX deviation START: not implemented + -- useTransition = useTransition, + -- ROBLOX deviation END + useMutableSource = useMutableSource, + -- ROBLOX deviation START: not implemented + -- useDeferredValue = useDeferredValue, + -- ROBLOX deviation END + useOpaqueIdentifier = useOpaqueIdentifier, +} -- Inspect +export type HooksNode = { + id: number | nil,--[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] + isStateEditable: boolean, + name: string, + value: unknown, + subHooks: Array, +} --[[ ROBLOX CHECK: inexact type upstream which is not supported by Luau. Verify if it doesn't break the analyze ]] +export type HooksTree = Array -- Don't assume +-- +-- We can't assume that stack frames are nth steps away from anything. +-- E.g. we can't assume that the root call shares all frames with the stack +-- of a hook call. A simple way to demonstrate this is wrapping `new Error()` +-- in a wrapper constructor like a polyfill. That'll add an extra frame. +-- Similar things can happen with the call to the dispatcher. The top frame +-- may not be the primitive. Likewise the primitive can have fewer stack frames +-- such as when a call to useState got inlined to use dispatcher.useState. +-- +-- We also can't assume that the last frame of the root call is the same +-- frame as the last frame of the hook call because long stack traces can be +-- truncated to a stack trace limit. +-- ROBLOX deviation START: adapt to 1-based indexing +-- local mostLikelyAncestorIndex = 0 +local mostLikelyAncestorIndex = 1 +-- ROBLOX deviation END +-- ROBLOX deviation START: explicit type +-- local function findSharedIndex(hookStack, rootStack, rootIndex) +local function findSharedIndex(hookStack, rootStack, rootIndex: number) + -- ROBLOX deviation END + -- ROBLOX deviation START: don't use tostring + -- local source = rootStack[tostring(rootIndex)].source + local source = rootStack[rootIndex].source + -- ROBLOX deviation END + -- ROBLOX deviation START: implement LabeledStatement + -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: LabeledStatement ]] --[[ hookSearch: for (let i = 0; i < hookStack.length; i++) { + -- if (hookStack[i].source === source) { + -- // This looks like a match. Validate that the rest of both stack match up. + -- for (let a = rootIndex + 1, b = i + 1; a < rootStack.length && b < hookStack.length; a++, b++) { + -- if (hookStack[b].source !== rootStack[a].source) { + -- // If not, give up and try a different match. + -- continue hookSearch; + -- } + -- } + + -- return i; + -- } + -- } ]] + for i = 1, #hookStack do + if hookStack[i].source == source then + -- This looks like a match. Validate that the rest of both stack match up. + -- ROBLOX deviation: rewrite complex loop + local a = rootIndex + 1 + local b = i + 1 + local skipReturn = false + while a <= #rootStack and b <= #hookStack do + if hookStack[b].source ~= rootStack[a].source then + -- If not, give up and try a different match. + skipReturn = true + break + end + a += 1 + b += 1 + end + if not skipReturn then + return i + end + end + end + -- ROBLOX deviation END + return -1 +end +local function findCommonAncestorIndex(rootStack, hookStack) + local rootIndex = findSharedIndex(hookStack, rootStack, mostLikelyAncestorIndex) + if rootIndex ~= -1 then + return rootIndex + end -- If the most likely one wasn't a hit, try any other frame to see if it is shared. + -- If that takes more than 5 frames, something probably went wrong. + -- ROBLOX deviation START: use numeric for loop + -- do + -- local i = 0 + -- while + -- i < rootStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and i < 5 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- rootIndex = findSharedIndex(hookStack, rootStack, i) + -- if rootIndex ~= -1 then + -- mostLikelyAncestorIndex = i + -- return rootIndex + -- end + -- i += 1 + -- end + -- end + for i = 1, math.min(#rootStack, 5) do + rootIndex = findSharedIndex(hookStack, rootStack, i) + if rootIndex ~= -1 then + mostLikelyAncestorIndex = i + return rootIndex + end + end + -- ROBLOX deviation END + return -1 +end +local function isReactWrapper(functionName, primitiveName) + -- ROBLOX deviation START: simplify + -- if not Boolean.toJSBoolean(functionName) then + if not functionName or functionName == "" then + -- ROBLOX deviation END + return false + end + local expectedPrimitiveName = "use" .. tostring(primitiveName) + -- ROBLOX deviation START: fix length implementation + Luau doesn't understand the guard above + -- if + -- functionName.length + -- < expectedPrimitiveName.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- then + if string.len(functionName :: string) < string.len(expectedPrimitiveName) then + -- ROBLOX deviation END + return false + end + -- ROBLOX deviation START: fix length implementation + Luau doesn't understand the guard above + -- return functionName:lastIndexOf(expectedPrimitiveName) + -- == functionName.length - expectedPrimitiveName.length + return String.lastIndexOf(functionName :: string, expectedPrimitiveName) + == (string.len(functionName :: string) - string.len(expectedPrimitiveName) + 1) + -- ROBLOX deviation END +end +local function findPrimitiveIndex(hookStack, hook) + local stackCache = getPrimitiveStackCache() + local primitiveStack = stackCache:get(hook.primitive) + if primitiveStack == nil then + return -1 + end + -- ROBLOX deviation START: use numeric for loop and precompute iteration count + -- do + -- local i = 0 + -- while + -- i < primitiveStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and i < hookStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- if primitiveStack[tostring(i)].source ~= hookStack[tostring(i)].source then + -- -- If the next two frames are functions called `useX` then we assume that they're part of the + -- -- wrappers that the React packager or other packages adds around the dispatcher. + -- if + -- Boolean.toJSBoolean( + -- i < hookStack.length - 1 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and isReactWrapper( + -- hookStack[tostring(i)].functionName, + -- hook.primitive + -- ) + -- ) + -- then + -- i += 1 + -- end + -- if + -- Boolean.toJSBoolean( + -- i < hookStack.length - 1 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and isReactWrapper( + -- hookStack[tostring(i)].functionName, + -- hook.primitive + -- ) + -- ) + -- then + -- i += 1 + -- end + -- return i + -- end + -- i += 1 + -- end + -- end + for i = 1, math.min(#primitiveStack :: Array, #hookStack) do + if (primitiveStack :: Array)[i].source ~= hookStack[i].source then + -- If the next two frames are functions called `useX` then we assume that they're part of the + -- wrappers that the React packager or other packages adds around the dispatcher. + -- ROBLOX NOTE: 1-indexed so drop -1 + if + i < #hookStack + and isReactWrapper(hookStack[i].functionName, hook.primitive) + then + i += 1 + end + -- ROBLOX NOTE: 1-indexed so drop -1 + if + i < #hookStack + and isReactWrapper(hookStack[i].functionName, hook.primitive) + then + i += 1 + end + return i + end + end + -- ROBLOX deviation END + return -1 +end +-- ROBLOX deviation START: Luau doesn't infer Array | nil like it should +-- local function parseTrimmedStack(rootStack, hook) +local function parseTrimmedStack(rootStack, hook): Array? + -- ROBLOX deviation END + -- Get the stack trace between the primitive hook function and + -- the root function call. I.e. the stack frames of custom hooks. + -- ROBLOX deviation START: use dot notation + -- local hookStack = ErrorStackParser:parse(hook.stackError) + local hookStack = ErrorStackParser.parse(hook.stackError) + -- ROBLOX deviation END + local rootIndex = findCommonAncestorIndex(rootStack, hookStack) + local primitiveIndex = findPrimitiveIndex(hookStack, hook) + if + rootIndex == -1 + or primitiveIndex == -1 + or rootIndex - primitiveIndex < 2 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + then + -- Something went wrong. Give up. + return nil + end + return Array.slice(hookStack, primitiveIndex, rootIndex - 1) --[[ ROBLOX CHECK: check if 'hookStack' is an Array ]] +end +local function parseCustomHookName(functionName: void | string): string + -- ROBLOX deviation START: simplify + -- if not Boolean.toJSBoolean(functionName) then + if not functionName then + -- ROBLOX deviation END + return "" + end + -- ROBLOX deviation START: fix implementation + -- local startIndex = functionName:lastIndexOf(".") + local startIndex = String.lastIndexOf(functionName :: string, ".") + -- ROBLOX deviation END + if startIndex == -1 then + -- ROBLOX deviation START: adapt for 1-based indexing + -- startIndex = 0 + startIndex = 1 + -- ROBLOX deviation END + end + -- ROBLOX deviation START: fix implementation + -- if functionName:substr(startIndex, 3) == "use" then + if String.substr(functionName :: string, startIndex, 3) == "use" then + -- ROBLOX deviation END + startIndex += 3 + end + -- ROBLOX deviation START: fix implementation + -- return functionName:substr(startIndex) + return String.substr(functionName :: string, startIndex) + -- ROBLOX deviation END +end +-- ROBLOX deviation START: add predefined function +local processDebugValues +-- ROBLOX deviation END +-- ROBLOX deviation START: explicit type +-- local function buildTree(rootStack, readHookLog): HooksTree +local function buildTree(rootStack, readHookLog: Array): HooksTree + -- ROBLOX deviation END + local rootChildren = {} + local prevStack = nil + local levelChildren = rootChildren + -- ROBLOX deviation START: adjust for 1-based indexing + -- local nativeHookID = 0 + local nativeHookID = 1 + -- ROBLOX deviation END + local stackOfChildren = {} + -- ROBLOX deviation START: use numeric for loop + -- do + -- local i = 0 + -- while + -- i + -- < readHookLog.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local hook = readHookLog[tostring(i)] + -- local stack = parseTrimmedStack(rootStack, hook) + -- if stack ~= nil then + -- -- Note: The indices 0 <= n < length-1 will contain the names. + -- -- The indices 1 <= n < length will contain the source locations. + -- -- That's why we get the name from n - 1 and don't check the source + -- -- of index 0. + -- local commonSteps = 0 + -- if prevStack ~= nil then + -- -- Compare the current level's stack to the new stack. + -- while + -- commonSteps < stack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- and commonSteps < prevStack.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + -- local stackSource = + -- stack[tostring(stack.length - commonSteps - 1)].source + -- local prevSource = + -- prevStack[tostring(prevStack.length - commonSteps - 1)].source + -- if stackSource ~= prevSource then + -- break + -- end + -- commonSteps += 1 + -- end -- Pop back the stack as many steps as were not common. + -- do + -- local j = prevStack.length - 1 + -- while + -- j + -- > commonSteps --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + -- do + -- levelChildren = table.remove(stackOfChildren) --[[ ROBLOX CHECK: check if 'stackOfChildren' is an Array ]] + -- j -= 1 + -- end + -- end + -- end -- The remaining part of the new stack are custom hooks. Push them + -- -- to the tree. + -- do + -- local j = stack.length - commonSteps - 1 + -- while + -- j + -- >= 1 --[[ ROBLOX CHECK: operator '>=' works only if either both arguments are strings or both are a number ]] + -- do + -- local children = {} + -- table.insert(levelChildren, { + -- id = nil, + -- isStateEditable = false, + -- name = parseCustomHookName( + -- stack[tostring(j - 1)].functionName + -- ), + -- value = nil, + -- subHooks = children, + -- }) --[[ ROBLOX CHECK: check if 'levelChildren' is an Array ]] + -- table.insert(stackOfChildren, levelChildren) --[[ ROBLOX CHECK: check if 'stackOfChildren' is an Array ]] + -- levelChildren = children + -- j -= 1 + -- end + -- end + -- prevStack = stack + -- end + -- local primitive = hook.primitive -- For now, the "id" of stateful hooks is just the stateful hook index. + -- -- Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + -- local id = if primitive == "Context" or primitive == "DebugValue" + -- then nil + -- else (function() + -- local ref = nativeHookID + -- nativeHookID += 1 + -- return ref + -- end)() -- For the time being, only State and Reducer hooks support runtime overrides. + -- local isStateEditable = primitive == "Reducer" or primitive == "State" + -- table.insert(levelChildren, { + -- id = id, + -- isStateEditable = isStateEditable, + -- name = primitive, + -- value = hook.value, + -- subHooks = {}, + -- }) --[[ ROBLOX CHECK: check if 'levelChildren' is an Array ]] + -- i += 1 + -- end + -- end -- Associate custom hook values (useDebugValue() hook entries) with the correct hooks. + for i = 1, #readHookLog do + local hook = readHookLog[i] + local stack = parseTrimmedStack(rootStack, hook) + + if stack ~= nil then + -- Note: The indices 0 <= n < length-1 will contain the names. + -- The indices 1 <= n < length will contain the source locations. + -- That's why we get the name from n - 1 and don't check the source + -- of index 0. + local commonSteps = 0 + if prevStack ~= nil then + -- Compare the current level's stack to the new stack. + while commonSteps < #stack and commonSteps < #prevStack do + local stackSource = stack[#stack - commonSteps].source + local prevSource = prevStack[#prevStack - commonSteps].source + + if stackSource ~= prevSource then + break + end + + commonSteps += 1 + end + -- Pop back the stack as many steps as were not common. + for j = #prevStack - 1, commonSteps + 1, -1 do + levelChildren = + table.remove(stackOfChildren :: Array) :: Array + end + end + + -- The remaining part of the new stack are custom hooks. Push them + -- to the tree. + for j = #stack - commonSteps, 2, -1 do + local children = {} + table.insert(levelChildren, { + -- ROBLOX FIXME Luau: Luau should infer number | nil here by (at least) looking at the function-level usage + id = nil :: number | nil, + isStateEditable = false, + name = parseCustomHookName(stack[j - 1].functionName), + value = nil, + subHooks = children, + }) + table.insert(stackOfChildren, levelChildren) + levelChildren = children + end + + prevStack = stack + end + + local function POSTFIX_INCREMENT() + local prev = nativeHookID + nativeHookID += 1 + return prev + end + + local primitive = hook.primitive + + -- For now, the "id" of stateful hooks is just the stateful hook index. + -- Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue). + -- ROBLOX FIXME Luau: Luau doesn't infer number | nil like it should + local id = if primitive == "Context" or primitive == "DebugValue" + then nil + else POSTFIX_INCREMENT() + -- For the time being, only State and Reducer hooks support runtime overrides. + local isStateEditable = primitive == "Reducer" or primitive == "State" + + table.insert(levelChildren, { + id = id, + isStateEditable = isStateEditable, + name = primitive, + value = hook.value, + subHooks = {}, + }) + end + -- ROBLOX deviation END + processDebugValues(rootChildren, nil) + return rootChildren +end -- Custom hooks support user-configurable labels (via the special useDebugValue() hook). +-- That hook adds user-provided values to the hooks tree, +-- but these values aren't intended to appear alongside of the other hooks. +-- Instead they should be attributed to their parent custom hook. +-- This method walks the tree and assigns debug values to their custom hook owners. +-- ROBLOX deviation START: predefined function +-- local function processDebugValues( +function processDebugValues( + -- ROBLOX deviation END + hooksTree: HooksTree, + parentHooksNode: HooksNode | nil --[[ ROBLOX CHECK: verify if `null` wasn't used differently than `undefined` ]] +): () + local debugValueHooksNodes: Array = {} + do + -- ROBLOX deviation START: adapt for 1-based indexing + -- local i = 0 + local i = 1 + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- while + -- i + -- < hooksTree.length --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + -- do + while i <= #hooksTree do + -- ROBLOX deviation END + -- ROBLOX deviation START: don't use tostring for iterating an array + -- local hooksNode = hooksTree[tostring(i)] + local hooksNode = hooksTree[i] + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- if hooksNode.name == "DebugValue" and hooksNode.subHooks.length == 0 then + if hooksNode.name == "DebugValue" and #hooksNode.subHooks == 0 then + -- ROBLOX deviation END + Array.splice(hooksTree, i, 1) --[[ ROBLOX CHECK: check if 'hooksTree' is an Array ]] + i -= 1 + table.insert(debugValueHooksNodes, hooksNode) --[[ ROBLOX CHECK: check if 'debugValueHooksNodes' is an Array ]] + else + processDebugValues(hooksNode.subHooks, hooksNode) + end + i += 1 + end + end -- Bubble debug value labels to their custom hook owner. + -- If there is no parent hook, just ignore them for now. + -- (We may warn about this in the future.) + if parentHooksNode ~= nil then + -- ROBLOX deviation START: fix length implementation + -- if debugValueHooksNodes.length == 1 then + if #debugValueHooksNodes == 1 then + -- ROBLOX deviation END + parentHooksNode.value = debugValueHooksNodes[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].value + -- ROBLOX deviation START: fix length implementation + -- elseif + -- debugValueHooksNodes.length + -- > 1 --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + -- then + elseif #debugValueHooksNodes > 1 then + -- ROBLOX deviation END + parentHooksNode.value = Array.map(debugValueHooksNodes, function(ref0) + local value = ref0.value + return value + end) --[[ ROBLOX CHECK: check if 'debugValueHooksNodes' is an Array ]] + end + end +end +local function inspectHooks( + renderFunction: (Props) -> React_Node --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: React$Node ]], + props: Props, + currentDispatcher: CurrentDispatcherRef? +): HooksTree + -- DevTools will pass the current renderer's injected dispatcher. + -- Other apps might compile debug hooks as part of their app though. + if + currentDispatcher == nil --[[ ROBLOX CHECK: loose equality used upstream ]] + then + currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher + end + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- local previousDispatcher = currentDispatcher.current + local previousDispatcher = (currentDispatcher :: CurrentDispatcherRef).current + -- ROBLOX deviation END + local readHookLog; + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- currentDispatcher.current = Dispatcher + (currentDispatcher :: CurrentDispatcherRef).current = Dispatcher + -- ROBLOX deviation END + local ancestorStackError + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + ancestorStackError = Error.new() + renderFunction(props) + end) + do + readHookLog = hookLog + hookLog = {}; + -- ROBLOX deviation START: Luau doesn't understand that currentDispatcher is not nil + -- currentDispatcher.current = previousDispatcher + (currentDispatcher :: CurrentDispatcherRef).current = previousDispatcher + -- ROBLOX deviation END + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use dot notation + -- local rootStack = ErrorStackParser:parse(ancestorStackError) + local rootStack = ErrorStackParser.parse(ancestorStackError) + -- ROBLOX deviation END + return buildTree(rootStack, readHookLog) +end +exports.inspectHooks = inspectHooks +local function setupContexts(contextMap: Map, any>, fiber: Fiber) + local current = fiber + -- ROBLOX deviation START: toJSBoolean not needed + -- while Boolean.toJSBoolean(current) do + while current do + -- ROBLOX deviation END + if current.tag == ContextProvider then + local providerType: ReactProviderType = current.type + local context: ReactContext = providerType._context + -- ROBLOX deviation START: toJSBoolean not needed + -- if not Boolean.toJSBoolean(contextMap:has(context)) then + if not contextMap:has(context) then + -- ROBLOX deviation END + -- Store the current value that we're going to restore later. + contextMap:set(context, context._currentValue) -- Set the inner most provider value on the context. + context._currentValue = current.memoizedProps.value + end + end + -- ROBLOX deviation START: use return_ + -- current = current["return"] + current = current.return_ :: Fiber + -- ROBLOX deviation END + end +end +local function restoreContexts(contextMap: Map, any>) + -- ROBLOX deviation START: use for..in loop + -- Array.forEach(contextMap, function(value, context) + -- context._currentValue = value + -- return context._currentValue + -- end) --[[ ROBLOX CHECK: check if 'contextMap' is an Array ]] + for _, ref in contextMap do + local context, value = ref[1], ref[2] + context._currentValue = value + end + -- ROBLOX deviation END +end +local function inspectHooksOfForwardRef( + renderFunction: (Props, Ref) -> React_Node --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: React$Node ]], + props: Props, + ref: Ref, + currentDispatcher: CurrentDispatcherRef +): HooksTree + local previousDispatcher = currentDispatcher.current + local readHookLog + currentDispatcher.current = Dispatcher + local ancestorStackError + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + ancestorStackError = Error.new() + renderFunction(props, ref) + end) + do + readHookLog = hookLog + hookLog = {} + currentDispatcher.current = previousDispatcher + end + -- ROBLOX deviation START: doesn't return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + end + -- ROBLOX deviation START: use dot notation + -- local rootStack = ErrorStackParser:parse(ancestorStackError) + local rootStack = ErrorStackParser.parse(ancestorStackError) + -- ROBLOX deviation END + return buildTree(rootStack, readHookLog) +end +-- ROBLOX deviation START: explicit type +-- local function resolveDefaultProps(Component, baseProps) +local function resolveDefaultProps(Component, baseProps: Object) + -- ROBLOX deviation END + -- ROBLOX deviation START: toJSBoolean not needed + -- if + -- Boolean.toJSBoolean( + -- if Boolean.toJSBoolean(Component) then Component.defaultProps else Component + -- ) + -- then + if typeof(Component) == "table" and Component.defaultProps then + -- ROBLOX deviation END + -- Resolve default props. Taken from ReactElement + local props = Object.assign({}, baseProps) + local defaultProps = Component.defaultProps + for propName in defaultProps do + -- ROBLOX deviation START: needs cast + -- if props[tostring(propName)] == nil then + -- props[tostring(propName)] = defaultProps[tostring(propName)] + if (props :: Object)[propName] == nil then + (props :: Object)[propName] = defaultProps[propName] + end + -- ROBLOX deviation END + end + return props + end + return baseProps +end +local function inspectHooksOfFiber(fiber: Fiber, currentDispatcher: CurrentDispatcherRef?) + -- DevTools will pass the current renderer's injected dispatcher. + -- Other apps might compile debug hooks as part of their app though. + if + currentDispatcher == nil --[[ ROBLOX CHECK: loose equality used upstream ]] + then + currentDispatcher = ReactSharedInternals.ReactCurrentDispatcher + end + currentFiber = fiber + if + fiber.tag ~= FunctionComponent + and fiber.tag ~= SimpleMemoComponent + and fiber.tag ~= ForwardRef + and fiber.tag ~= Block + then + error( + Error.new("Unknown Fiber. Needs to be a function component to inspect hooks.") + ) + end -- Warm up the cache so that it doesn't consume the currentHook. + getPrimitiveStackCache() + local type_ = fiber.type + local props = fiber.memoizedProps + if type_ ~= fiber.elementType then + props = resolveDefaultProps(type_, props) + end -- Set up the current hook so that we can step through and read the + -- current state from them. + currentHook = fiber.memoizedState :: Hook + local contextMap = Map.new() + do --[[ ROBLOX COMMENT: try-finally block conversion ]] + -- ROBLOX deviation START: doesn't need conditional return + -- local ok, result, hasReturned = pcall(function() + local ok, result = pcall(function() + -- ROBLOX deviation END + setupContexts(contextMap, fiber) + if fiber.tag == ForwardRef then + return inspectHooksOfForwardRef( + type_.render, + props, + fiber.ref, + -- ROBLOX deviation START: needs cast + -- currentDispatcher + currentDispatcher :: CurrentDispatcherRef + -- ROBLOX deviation END + ) + end + return inspectHooks(type_, props, currentDispatcher) + end) + do + currentHook = nil + restoreContexts(contextMap) + end + -- ROBLOX deviation START: doesn't need conditional return + -- if hasReturned then + -- return result + -- end + -- ROBLOX deviation END + if not ok then + error(result) + end + -- ROBLOX deviation START: add return + return result + -- ROBLOX deviation END + end +end +exports.inspectHooksOfFiber = inspectHooksOfFiber +return exports diff --git a/modules/react-debug-tools/src/ReactDebugTools.lua b/modules/react-debug-tools/src/ReactDebugTools.lua new file mode 100644 index 00000000..a218566e --- /dev/null +++ b/modules/react-debug-tools/src/ReactDebugTools.lua @@ -0,0 +1,20 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/ReactDebugTools.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 = {} +local reactDebugHooksModule = require(script.Parent.ReactDebugHooks) +-- ROBLOX deviation START: add re-exporting of types +export type HooksNode = reactDebugHooksModule.HooksNode +export type HooksTree = reactDebugHooksModule.HooksTree +-- ROBLOX deviation END +local inspectHooks = reactDebugHooksModule.inspectHooks +local inspectHooksOfFiber = reactDebugHooksModule.inspectHooksOfFiber +exports.inspectHooks = inspectHooks +exports.inspectHooksOfFiber = inspectHooksOfFiber +return exports diff --git a/modules/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua b/modules/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua new file mode 100644 index 00000000..05e887f9 --- /dev/null +++ b/modules/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration.spec.lua @@ -0,0 +1,643 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-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. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Error = LuauPolyfill.Error +local JestGlobals = require(Packages.Dev.JestGlobals) +-- ROBLOX deviation START: add additional import +local afterEach = JestGlobals.afterEach +-- ROBLOX deviation END +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest + +describe("React hooks DevTools integration", function() + local React + local ReactDebugTools + local ReactTestRenderer + local Scheduler + local act + local overrideHookState + local scheduleUpdate + local setSuspenseHandler + beforeEach(function() + -- ROBLOX deviation START: use _G instead of global + -- global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + -- ROBLOX deviation END + inject = function(injected) + overrideHookState = injected.overrideHookState + scheduleUpdate = injected.scheduleUpdate + setSuspenseHandler = injected.setSuspenseHandler + end, + supportsFiber = true, + onCommitFiberRoot = function() end, + onCommitFiberUnmount = function() end, + } + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactDebugTools = require_("react-debug-tools") + -- ReactTestRenderer = require_("react-test-renderer") + -- Scheduler = require_("scheduler") + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + React = require(Packages.Dev.React) + ReactDebugTools = require(Packages.ReactDebugTools) + Scheduler = require(Packages.Dev.Scheduler) + -- ROBLOX deviation END + act = ReactTestRenderer.act + end) + -- ROBLOX deviation START: add afterEach to revert global flag + afterEach(function() + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = nil + end) + -- ROBLOX deviation END + it("should support editing useState hooks", function() + local setCountFn + local function MyComponent() + -- ROBLOX deviation START: useState returns 2 values + -- local count, setCount = table.unpack(React.useState(0), 1, 2) + local count, setCount = React.useState(0) + -- ROBLOX deviation END + setCountFn = setCount + -- ROBLOX deviation START: use TextLabel instead + -- return React.createElement("div", nil, "count:", count) + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = "count:" }), + React.createElement("TextLabel", { Text = tostring(count) }) + ) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "0" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "0" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local stateHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(stateHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, stateHook.id, {}, 10) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "10" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "10" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return setCountFn(function(count) + return count + 1 + end) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "11" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "11" } }, + }, + -- ROBLOX deviation END + }) + end + end) + it("should support editable useReducer hooks", function() + local initialData = { foo = "abc", bar = 123 } + local function reducer(state, action) + local condition_ = action.type + if condition_ == "swap" then + return { foo = state.bar, bar = state.foo } + else + error(Error.new()) + end + end + local dispatchFn + local function MyComponent() + -- ROBLOX deviation START: returns 2 values + -- local state, dispatch = + -- table.unpack(React.useReducer(reducer, initialData), 1, 2) + -- ROBLOX deviation END + local state, dispatch = React.useReducer(reducer, initialData) + dispatchFn = dispatch + -- ROBLOX deviation START: use Frame and TextLabels instead + -- return React.createElement("div", nil, "foo:", state.foo, ", bar:", state.bar) + return React.createElement( + "Frame", + {}, + React.createElement("TextLabel", { Text = "foo:" }), + React.createElement("TextLabel", { Text = tostring(state.foo) }), + React.createElement("TextLabel", { Text = ", bar:" }), + React.createElement("TextLabel", { Text = tostring(state.bar) }) + ) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "abc", ", bar:", "123" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "abc" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "123" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local reducerHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(reducerHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, reducerHook.id, { "foo" }, "def") + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "def", ", bar:", "123" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "def" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "123" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return dispatchFn({ type = "swap" }) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "foo:", "123", ", bar:", "def" }, + children = { + { type = "TextLabel", props = { Text = "foo:" } }, + { type = "TextLabel", props = { Text = "123" } }, + { type = "TextLabel", props = { Text = ", bar:" } }, + { type = "TextLabel", props = { Text = "def" } }, + }, + -- ROBLOX deviation END + }) + end + end) -- This test case is based on an open source bug report: + -- facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 + it( + "should handle interleaved stateful hooks (e.g. useState) and non-stateful hooks (e.g. useContext)", + function() + local MyContext = React.createContext(1) + local setStateFn + local function useCustomHook() + local context = React.useContext(MyContext) + -- ROBLOX deviation START: returns 2 values + -- local state, setState = + -- table.unpack(React.useState({ count = context }), 1, 2) + local state, setState = React.useState({ count = context }) + -- ROBLOX deviation END + React.useDebugValue(state.count) + setStateFn = setState + return state.count + end + local function MyComponent() + local count = useCustomHook() + -- ROBLOX deviation START: use Frame and TextLabels instead + -- return React.createElement("div", nil, "count:", count) + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Text = "count:" }), + React.createElement("TextLabel", { Text = tostring(count) }) + ) + -- ROBLOX deviation END + end + local renderer = + ReactTestRenderer.create(React.createElement(MyComponent, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "1" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "1" } }, + }, + -- ROBLOX deviation END + }) + local fiber = renderer.root:findByType(MyComponent):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(fiber) + local tree = ReactDebugTools.inspectHooksOfFiber(fiber) + -- ROBLOX deviation END + local stateHook = tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].subHooks[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + expect(stateHook.isStateEditable).toBe(true) + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + overrideHookState(fiber, stateHook.id, { "count" }, 10) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "10" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "10" } }, + }, + -- ROBLOX deviation END + }) + act(function() + return setStateFn(function(state) + return { count = state.count + 1 } + end) + end) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + -- ROBLOX deviation START: use TextLabels instead + -- children = { "count:", "11" }, + children = { + { type = "TextLabel", props = { Text = "count:" } }, + { type = "TextLabel", props = { Text = "11" } }, + }, + -- ROBLOX deviation END + }) + end + end + ) + it("should support overriding suspense in legacy mode", function() + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + -- Lock the first render + setSuspenseHandler(function() + return true + end) + end + local function MyComponent() + -- ROBLOX deviation START: use TextLabel instead + -- return "Done" + return React.createElement("TextLabel", { Text = "Done" }) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + nil, + React.createElement( + React.Suspense, + -- ROBLOX deviation START: use TextLabel instead + -- { fallback = "Loading" }, + { fallback = React.createElement("TextLabel", { Text = "Loading" }) }, + -- ROBLOX deviation END + React.createElement(MyComponent, nil) + ) + )) + local fiber = renderer.root:_currentFiber().child + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + -- First render was locked + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return true + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Ensure it checks specific fibers. + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f == fiber or f == fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f ~= fiber and f ~= fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + else + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + end + end) + it("should support overriding suspense in concurrent mode", function() + -- ROBLOX deviation START: add useFakeTimers + jest.useFakeTimers() + -- ROBLOX deviation END + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + -- Lock the first render + setSuspenseHandler(function() + return true + end) + end + local function MyComponent() + -- ROBLOX deviation START: use TextLabel instead + -- return "Done" + return React.createElement("TextLabel", { Text = "Done" }) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create( + React.createElement( + "div", + nil, + React.createElement( + React.Suspense, + -- ROBLOX deviation START: use TextLabel instead + -- { fallback = "Loading" }, + { fallback = React.createElement("TextLabel", { Text = "Loading" }) }, + -- ROBLOX deviation END + React.createElement(MyComponent, nil) + ) + ), + { unstable_isConcurrent = true } + ) + expect(Scheduler).toFlushAndYield({}) -- Ensure we timeout any suspense time. + jest.advanceTimersByTime(1000) + local fiber = renderer.root:_currentFiber().child + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + if ReactGlobals.__DEV__ then + -- ROBLOX deviation END + -- First render was locked + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use dot notation + -- Scheduler:unstable_flushAll() + Scheduler.unstable_flushAll() + -- ROBLOX deviation END + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return true + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) -- Release the lock again + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function() + return false + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) -- Ensure it checks specific fibers. + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f == fiber or f == fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Loading" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Loading" }, + }, + }) + -- ROBLOX deviation END + setSuspenseHandler(function(f) + return f ~= fiber and f ~= fiber.alternate + end) + scheduleUpdate(fiber) -- Re-render + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + -- ROBLOX deviation END + else + -- ROBLOX deviation START: use TextLabel instead + -- expect(renderer:toJSON().children).toEqual({ "Done" }) + expect(renderer:toJSON().children).toEqual({ + { + type = "TextLabel", + props = { Text = "Done" }, + }, + }) + end + -- ROBLOX deviation START: add useRealTimers + jest.useRealTimers() + -- ROBLOX deviation END + end) +end) diff --git a/modules/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua b/modules/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua new file mode 100644 index 00000000..f1313643 --- /dev/null +++ b/modules/react-debug-tools/src/__tests__/ReactHooksInspection.spec.lua @@ -0,0 +1,497 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactHooksInspection-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. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +-- ROBLOX deviation START: not needed +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest + +local React +local ReactDebugTools +describe("ReactHooksInspection", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactDebugTools = require_("react-debug-tools") + React = require(Packages.Dev.React) + ReactDebugTools = require(Packages.ReactDebugTools) + -- ROBLOX deviation END + end) + it("should inspect a simple useState hook", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + return React.createElement("div", nil, state) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello world", + subHooks = {}, + }, + }) + end) + it("should inspect a simple custom hook", function() + local function useCustom(value) + local state = React.useState(value)[1] + React.useDebugValue("custom hook label") + return state + end + local function Foo(props) + local value = useCustom("hello world") + return React.createElement("div", nil, value) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "custom hook label" else nil, + value = if ReactGlobals.__DEV__ then "custom hook label" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello world", + subHooks = {}, + }, + }, + }, + }) + end) + it("should inspect a tree of multiple hooks", function() + local function effect() end + local function useCustom(value) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState(value)[1] + local state = React.useState(value) + -- ROBLOX deviation END + React.useEffect(effect) + return state + end + local function Foo(props) + local value1 = useCustom("hello") + local value2 = useCustom("world") + return React.createElement("div", nil, value1, " ", value2) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "hello" :: any, + -- ROBLOX deviation END + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Effect", + subHooks = {}, + value = effect, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "world", + value = "world" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + }, + }, + }) + end) + it("should inspect a tree of multiple levels of hooks", function() + local function effect() end + local function useCustom(value) + local state = React.useReducer(function(s, a) + return s + -- ROBLOX deviation START: useReducer returns 2 values + -- end, value)[1] + end, value) + -- ROBLOX deviation END + React.useEffect(effect) + return state + end + local function useBar(value) + local result = useCustom(value) + React.useLayoutEffect(effect) + return result + end + local function useBaz(value) + React.useLayoutEffect(effect) + local result = useCustom(value) + return result + end + local function Foo(props) + local value1 = useBar("hello") + local value2 = useBaz("world") + return React.createElement("div", nil, value1, " ", value2) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: tell Luau to type this field loosely + id = nil :: number?, + -- ROBLOX deviation END + name = "Bar", + value = nil, + subHooks = { + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "Custom", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = nil, + value = nil :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "Reducer", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "hello", + value = "hello" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "Baz", + value = nil, + subHooks = { + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4 :: number?, + -- ROBLOX deviation END + name = "LayoutEffect", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = effect, + value = effect :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + id = nil, + name = "Custom", + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Reducer", + subHooks = {}, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = "world", + value = "world" :: any, + -- ROBLOX deviation END + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "Effect", + subHooks = {}, + value = effect, + }, + }, + value = nil, + }, + }, + }, + }) + end) + it("should inspect the default value using the useContext hook", function() + local MyContext = React.createContext("default") + local function Foo(props) + local value = React.useContext(MyContext) + return React.createElement("div", nil, value) + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Context", + value = "default", + subHooks = {}, + }, + }) + end) + it("should support an injected dispatcher", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + return React.createElement("div", nil, state) + end + local initial = {} + local current = initial + local getterCalls = 0 + local setterCalls = {} + -- ROBLOX deviation START: implement getter and setter + -- local FakeDispatcherRef = { + -- current = function(self) + -- getterCalls += 1 + -- return current + -- end, + -- current = function(self, value) + -- table.insert(setterCalls, value) --[[ ROBLOX CHECK: check if 'setterCalls' is an Array ]] + -- current = value + -- end, + -- } + local FakeDispatcherRef = setmetatable({ + __getters = { + current = function(self) + print("getting current") + getterCalls += 1 + return current + end, + }, + __setters = { + current = function(self, value) + print("setting current", value) + table.insert(setterCalls, value) + current = value + end, + }, + }, { + __index = function(self, key) + if typeof(self.__getters[key]) == "function" then + return self.__getters[key](self) + else + return nil + end + end, + __newindex = function(self, key, value) + if typeof(self.__setters[key]) == "function" then + return self.__setters[key](self, value) + else + return nil + end + end, + }) :: any + -- ROBLOX deviation END + -- ROBLOX deviation START: aligned to React 18 so we get a hot path optimization in upstream + -- expect(function() + -- ReactDebugTools:inspectHooks(Foo, {}, FakeDispatcherRef) + -- end).toThrow( + -- "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:\n" + -- .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + -- .. "2. You might be breaking the Rules of Hooks\n" + -- .. "3. You might have more than one copy of React in the same app\n" + -- .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + -- ) + local didCatch = false + expect(function() + -- mock the Error constructor to check the internal of the error instance + expect(function() + ReactDebugTools.inspectHooks(Foo, {}, FakeDispatcherRef) + end).toThrow( + -- ROBLOX NOTE: Lua-specific error on nil deref + "attempt to index nil with 'useState'" + ) + didCatch = true + end).toErrorDev( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.", + { withoutStack = true } + ) + -- avoid false positive if no error was thrown at all + expect(didCatch).toBe(true) + -- ROBLOX deviation END + expect(getterCalls).toBe(1) + expect(setterCalls).toHaveLength(2) + expect(setterCalls[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ROBLOX deviation START: use never instead of ["not"] + -- ])["not"].toBe(initial) + ]).never.toBe(initial) + -- ROBLOX deviation END + expect(setterCalls[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(initial) + end) + describe("useDebugValue", function() + it("should be ignored when called outside of a custom hook", function() + local function Foo(props) + React.useDebugValue("this is invalid") + return nil + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toHaveLength(0) + end) + it("should support an optional formatter function param", function() + local function useCustom() + React.useDebugValue({ bar = 123 }, function(object) + return ("bar:%s"):format(tostring(object.bar)) + end) + React.useState(0) + end + local function Foo(props) + useCustom() + return nil + end + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooks(Foo, {}) + local tree = ReactDebugTools.inspectHooks(Foo, {}) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "bar:123" else nil, + value = if ReactGlobals.__DEV__ then "bar:123" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + value = 0, + }, + }, + }, + }) + end) + end) +end) diff --git a/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua new file mode 100644 index 00000000..360a95b3 --- /dev/null +++ b/modules/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration.spec.lua @@ -0,0 +1,1304 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +--[[* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + ]] +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +-- ROBLOX deviation START: import from dev dependencies +-- local Promise = require(Packages.Promise) +local Promise = require(Packages.Dev.Promise) +-- ROBLOX deviation END +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest +-- ROBLOX deviation START: add additional imports +local String = LuauPolyfill.String +-- ROBLOX deviation END + +local React +local ReactTestRenderer +local Scheduler +local ReactDebugTools +local act +describe("ReactHooksInspectionIntegration", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: fix requires + -- React = require_("react") + -- ReactTestRenderer = require_("react-test-renderer") + -- Scheduler = require_("scheduler") + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + Scheduler = require(Packages.Dev.Scheduler) + React = require(Packages.Dev.React) + -- ROBLOX deviation END + act = ReactTestRenderer.unstable_concurrentAct + -- ROBLOX deviation START: fix requires + -- ReactDebugTools = require_("react-debug-tools") + ReactDebugTools = require(Packages.ReactDebugTools) + -- ROBLOX deviation END + end) + it("should inspect the current state of useState hooks", function() + local useState = React.useState + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state1, setState1 = table.unpack(useState("hello"), 1, 2) + -- local state2, setState2 = table.unpack(useState("world"), 1, 2) + local state1, setState1 = useState("hello") + local state2, setState2 = useState("world") + -- ROBLOX deviation END + return React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + { onMouseDown = setState1, onMouseUp = setState2 }, + state1, + " ", + state2 + ) + end + local renderer = + ReactTestRenderer.create(React.createElement(Foo, { prop = "prop" })) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world", + subHooks = {}, + }, + }) + local setStateA, setStateB + do + -- ROBLOX deviation START: use Frame instead + -- local ref = renderer.root:findByType("div").props + local ref = renderer.root:findByType("Frame").props + -- ROBLOX deviation END + setStateA, setStateB = ref.onMouseDown, ref.onMouseUp + end + act(function() + return setStateA("Hi") + end) + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "Hi", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world", + subHooks = {}, + }, + }) + act(function() + return setStateB("world!") + end) + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "Hi", + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "world!", + subHooks = {}, + }, + }) + end) + it("should inspect the current state of all stateful hooks", function() + local outsideRef = React.createRef() + local function effect() end + local function Foo(props) + -- ROBLOX deviation START: useState and useReducer return 2 values + -- local state1, setState = table.unpack(React.useState("a"), 1, 2) + -- local state2, dispatch = table.unpack( + -- React.useReducer(function(s, a) + -- return a.value + -- end, "b"), + -- 1, + -- 2 + -- ) + local state1, setState = React.useState("a") + local state2, dispatch = React.useReducer(function(s, a) + return a.value + end, "b") + -- ROBLOX deviation END + local ref = React.useRef("c") + React.useLayoutEffect(effect) + React.useEffect(effect) + React.useImperativeHandle(outsideRef, function() + -- Return a function so that jest treats them as non-equal. + return function() end + end, {}) + React.useMemo(function() + -- ROBLOX deviation START: use string concatenation + -- return state1 + state2 + return state1 .. state2 + -- ROBLOX deviation END + end, { state1 }) + local function update() + act(function() + setState("A") + end) + act(function() + dispatch({ value = "B" }) + end) + ref.current = "C" + end + local memoizedUpdate = React.useCallback(update, {}) + return React.createElement( + -- ROBLOX deviation START: use Frame instead + -- "div", + "Frame", + -- ROBLOX deviation END + { onClick = memoizedUpdate }, + state1, + " ", + state2 + ) + end + local renderer + act(function() + renderer = + ReactTestRenderer.create(React.createElement(Foo, { prop = "prop" })) + end) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use Frame instead + -- local updateStates = renderer.root:findByType("div").props.onClick + local updateStates = renderer.root:findByType("Frame").props.onClick + -- ROBLOX deviation END + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "a" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Reducer", + value = "b", + subHooks = {}, + }, + -- ROBLOX deviation START: adjust for 1-based indexing + -- { isStateEditable = false, id = 2, name = "Ref", value = "c", subHooks = {} }, + { isStateEditable = false, id = 3, name = "Ref", value = "c", subHooks = {} }, + -- ROBLOX deviation END + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 6, + id = 7, + -- ROBLOX deviation END + name = "Memo", + -- ROBLOX deviation START: useMemo wraps a value + -- value = "ab", + value = { "ab" }, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 7, + id = 8, + -- ROBLOX deviation END + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + updateStates() + childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = "A" :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "Reducer", + value = "B", + subHooks = {}, + }, + -- ROBLOX deviation START: adjust for 1-based indexing + -- { isStateEditable = false, id = 2, name = "Ref", value = "C", subHooks = {} }, + { isStateEditable = false, id = 3, name = "Ref", value = "C", subHooks = {} }, + -- ROBLOX deviation END + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "LayoutEffect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 4, + id = 5, + -- ROBLOX deviation END + name = "Effect", + value = effect, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 5, + id = 6, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = outsideRef.current, + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 6, + id = 7, + -- ROBLOX deviation END + name = "Memo", + -- ROBLOX deviation START: useMemo wraps a value + -- value = "Ab", + value = { "Ab" }, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 7, + id = 8, + -- ROBLOX deviation END + name = "Callback", + value = updateStates, + subHooks = {}, + }, + }) + end) + it("should inspect the value of the current provider in useContext", function() + local MyContext = React.createContext("default") + local function Foo(props) + local value = React.useContext(MyContext) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create( + React.createElement( + MyContext.Provider, + { value = "contextual" }, + React.createElement(Foo, { prop = "prop" }) + ) + ) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: adjust for 1-based indexing + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Context", + value = "contextual", + subHooks = {}, + }, + }) + end) + it("should inspect forwardRef", function() + local function obj() end + local Foo = React.forwardRef(function(props, ref) + React.useImperativeHandle(ref, function() + return obj + end) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil) + return React.createElement("Frame", nil) + -- ROBLOX deviation END + end) + local ref = React.createRef() + local renderer = ReactTestRenderer.create(React.createElement(Foo, { ref = ref })) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "ImperativeHandle", + value = obj, + subHooks = {}, + }, + }) + end) + it("should inspect memo", function() + local function InnerFoo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState("hello")[1] + local value = React.useState("hello") + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local Foo = React.memo(InnerFoo) + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) -- TODO: Test renderer findByType is broken for memo. Have to search for the inner. + local childFiber = renderer.root:findByType(InnerFoo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + }) + end) + it("should inspect custom hooks", function() + local function useCustom() + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState("hello")[1] + local value = React.useState("hello") + -- ROBLOX deviation END + return value + end + local function Foo(props) + local value = useCustom() + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, value) + return React.createElement("Frame", nil, value) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "hello", + subHooks = {}, + }, + }, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useTransition is not implemented + -- it("should support composite useTransition hook", function() + it.skip("should support composite useTransition hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not supported + -- React.unstable_useTransition() + -- ROBLOX deviation END + local memoizedValue = React.useMemo(function() + return "hello" + end, {}) + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, memoizedValue) + return React.createElement("Frame", nil, memoizedValue) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + isStateEditable = false, + name = "Transition", + -- ROBLOX deviation START: tell Luau to type this field loosely + value = nil :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = false, + name = "Memo", + value = "hello", + subHooks = {}, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useDeferredValue not implemented + -- it("should support composite useDeferredValue hook", function() + it.skip("should support composite useDeferredValue hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not implemented + -- React.unstable_useDeferredValue("abc", { timeoutMs = 500 }) + -- ROBLOX deviation END + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, state) + return React.createElement("Frame", nil, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + isStateEditable = false, + name = "DeferredValue", + value = "abc", + subHooks = {}, + }, + { + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useOpaqueIdentifier not implemented + -- it("should support composite useOpaqueIdentifier hook", function() + it.skip("should support composite useOpaqueIdentifier hook", function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX deviation START: not implemented + -- local id = React.unstable_useOpaqueIdentifier() + local id = nil + -- ROBLOX deviation END + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", { id = id }, state) + return React.createElement("Frame", { id = id }, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length implementation + -- expect(tree.length).toEqual(2) + expect(#tree).toEqual(2) + -- ROBLOX deviation END + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].id).toEqual(0) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].isStateEditable).toEqual(false) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].name).toEqual("OpaqueIdentifier") + -- ROBLOX deviation START: use String.startsWith + -- expect((tostring(tree[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ].value) .. ""):startsWith("c_")).toBe(true) + expect(String.startsWith(tree[1].value :: string .. "", "c_")).toBe(true) + -- ROBLOX deviation END + expect(tree[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toEqual({ + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }) + end) -- @gate experimental + -- ROBLOX deviation START: unstable_useOpaqueIdentifier not implemented + -- it("should support composite useOpaqueIdentifier hook in concurrent mode", function() + it.skip( + "should support composite useOpaqueIdentifier hook in concurrent mode", + function() + -- ROBLOX deviation END + local function Foo(props) + -- ROBLOX FIXME: type this correctly when this is supported + local id = (React :: any).unstable_useOpaqueIdentifier() + local state = React.useState(function() + return "hello" + -- ROBLOX deviation START: useState returns 2 values + -- end, {})[1] + end, {}) + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", { id = id }, state) + return React.createElement("Frame", { id = id }, state) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create( + React.createElement(Foo, nil), + { unstable_isConcurrent = true } + ) + expect(Scheduler).toFlushWithoutYielding() + local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + -- ROBLOX deviation START: fix length conversion + -- expect(tree.length).toEqual(2) + expect(#tree).toEqual(2) + -- ROBLOX deviation END + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].id).toEqual(0) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].isStateEditable).toEqual(false) + expect(tree[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ].name).toEqual("OpaqueIdentifier") + -- ROBLOX deviation START: use String.startsWith + -- expect((tostring(tree[ + -- 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ].value) .. ""):startsWith("c_")).toBe(true) + expect(String.startsWith(tree[1].value :: string .. "", "c_")).toBe(true) + -- ROBLOX deviation END + expect(tree[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toEqual({ + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + isStateEditable = true, + name = "State", + value = "hello", + subHooks = {}, + }) + end + ) + describe("useDebugValue", function() + it("should support inspectable values for multiple custom hooks", function() + local function useLabeledValue(label) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState(label)[1] + local value = React.useState(label) + -- ROBLOX deviation END + React.useDebugValue(("custom label %s"):format(tostring(label))) + return value + end + local function useAnonymous(label) + -- ROBLOX deviation START: useState returns 2 values + -- local value = React.useState(label)[1] + local value = React.useState(label) + -- ROBLOX deviation END + return value + end + local function Example() + useLabeledValue("a") + React.useState("b") + useAnonymous("c") + useLabeledValue("d") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: tell Luau to type this field loosely + id = nil :: number?, + -- ROBLOX deviation END + name = "LabeledValue", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ and cast + -- value = if Boolean.toJSBoolean(__DEV__) + -- then "custom label a" + -- else nil, + value = ( + if ReactGlobals.__DEV__ then "custom label a" else nil + ) :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = "a", + subHooks = {}, + }, + }, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = "b", + subHooks = {}, + }, + { + isStateEditable = false, + id = nil, + name = "Anonymous", + value = nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + value = "c", + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "LabeledValue", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) + value = if ReactGlobals.__DEV__ + -- ROBLOX deviation END + then "custom label d" + else nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 3, + id = 4, + -- ROBLOX deviation END + name = "State", + value = "d", + subHooks = {}, + }, + }, + }, + }) + end) + it("should support inspectable values for nested custom hooks", function() + local function useInner() + React.useDebugValue("inner") + React.useState(0) + end + local function useOuter() + React.useDebugValue("outer") + useInner() + end + local function Example() + useOuter() + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Outer", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "outer" else nil, + value = if ReactGlobals.__DEV__ then "outer" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = false, + id = nil, + name = "Inner", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "inner" else nil, + value = if ReactGlobals.__DEV__ then "inner" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + }, + }, + }) + end) + it("should support multiple inspectable values per custom hooks", function() + local function useMultiLabelCustom() + React.useDebugValue("one") + React.useDebugValue("two") + React.useDebugValue("three") + React.useState(0) + end + local function useSingleLabelCustom(value) + React.useDebugValue(("single %s"):format(tostring(value))) + React.useState(0) + end + local function Example() + useSingleLabelCustom("one") + useMultiLabelCustom() + useSingleLabelCustom("two") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: adjust for 1-based indexing + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "SingleLabelCustom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "single one" else nil, + value = (if ReactGlobals.__DEV__ then "single one" else nil) :: any, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "MultiLabelCustom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) + value = if ReactGlobals.__DEV__ + -- ROBLOX deviation END + then { "one", "two", "three" } + else nil, + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 1, + id = 2, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + { + isStateEditable = false, + id = nil, + name = "SingleLabelCustom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "single two" else nil, + value = if ReactGlobals.__DEV__ then "single two" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 2, + id = 3, + -- ROBLOX deviation END + name = "State", + value = 0, + subHooks = {}, + }, + }, + }, + }) + end) + it("should ignore useDebugValue() made outside of a custom hook", function() + local function Example() + React.useDebugValue("this is invalid") + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toHaveLength(0) + end) + it("should support an optional formatter function param", function() + local function useCustom() + React.useDebugValue({ bar = 123 }, function(object) + return ("bar:%s"):format(tostring(object.bar)) + end) + React.useState(0) + end + local function Example() + useCustom() + return nil + end + local renderer = ReactTestRenderer.create(React.createElement(Example, nil)) + local childFiber = renderer.root:findByType(Example):_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + id = nil, + name = "Custom", + -- ROBLOX deviation START: use ReactGlobals.__DEV__ + -- value = if Boolean.toJSBoolean(__DEV__) then "bar:123" else nil, + value = if ReactGlobals.__DEV__ then "bar:123" else nil, + -- ROBLOX deviation END + subHooks = { + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + subHooks = {}, + value = 0, + }, + }, + }, + }) + end) + end) + -- ROBLOX deviation START: defaultProps not supported for function components yet + -- it("should support defaultProps and lazy", function() + it.skip("should support defaultProps and lazy", function() + -- ROBLOX deviation END + return Promise.resolve():andThen(function() + -- ROBLOX deviation START: defaultProps not supported for function components yet + -- local Suspense = React.Suspense + -- local function Foo(props) + -- local value = React.useState(props.defaultValue:substr(0, 3))[1] + -- return React.createElement("div", nil, value) + -- end + -- Foo.defaultProps = { defaultValue = "default" } + -- local function fakeImport(result) + -- return Promise.resolve():andThen(function() + -- return { default = result } + -- end) + -- end + -- local LazyFoo = React.lazy(function() + -- return fakeImport(Foo) + -- end) + -- local renderer = ReactTestRenderer.create( + -- React.createElement( + -- Suspense, + -- { fallback = "Loading..." }, + -- React.createElement(LazyFoo, nil) + -- ) + -- ) + -- LazyFoo:expect() + -- Scheduler:unstable_flushAll() + -- local childFiber = renderer.root:_currentFiber() + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + -- expect(tree).toEqual({ + -- { + -- isStateEditable = true, + -- id = 0, + -- name = "State", + -- value = "def", + -- subHooks = {}, + -- }, + -- }) + -- ROBLOX deviation END + end) + end) + it("should support an injected dispatcher", function() + local function Foo(props) + -- ROBLOX deviation START: useState returns 2 values + -- local state = React.useState("hello world")[1] + local state = React.useState("hello world") + -- ROBLOX deviation END + -- ROBLOX deviation START: use Frame instead + -- return React.createElement("div", nil, state) + return React.createElement("Frame", nil, state) + -- ROBLOX deviation END + end + local initial = {} + local current = initial + local getterCalls = 0 + local setterCalls = {} + -- ROBLOX deviation START: implement getter and setter + -- local FakeDispatcherRef = { + -- current = function(self) + -- getterCalls += 1 + -- return current + -- end, + -- current = function(self, value) + -- table.insert(setterCalls, value) --[[ ROBLOX CHECK: check if 'setterCalls' is an Array ]] + -- current = value + -- end, + -- } + local FakeDispatcherRef = setmetatable({ + __getters = { + current = function(self) + getterCalls += 1 + return current + end, + }, + __setters = { + current = function(self, value) + table.insert(setterCalls, value) + current = value + end, + }, + }, { + __index = function(self, key) + if typeof(self.__getters[key]) == "function" then + return self.__getters[key](self) + else + return nil + end + end, + __newindex = function(self, key, value) + if typeof(self.__setters[key]) == "function" then + return self.__setters[key](self, value) + else + return nil + end + end, + }) :: any + -- ROBLOX deviation END + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + local childFiber = renderer.root:_currentFiber() + expect(function() + -- ROBLOX deviation START: use dot notation + -- ReactDebugTools:inspectHooksOfFiber(childFiber, FakeDispatcherRef) + ReactDebugTools.inspectHooksOfFiber(childFiber, FakeDispatcherRef) + -- ROBLOX deviation END + end).toThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + expect(getterCalls).toBe(1) + expect(setterCalls).toHaveLength(2) + expect(setterCalls[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + -- ROBLOX deviation START: use never instead of not + -- ])["not"].toBe(initial) + ]).never.toBe(initial) + -- ROBLOX deviation END + expect(setterCalls[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(initial) + end) -- This test case is based on an open source bug report: + -- facebookincubator/redux-react-hook/issues/34#issuecomment-466693787 + it("should properly advance the current hook for useContext", function() + local MyContext = React.createContext(1) + local incrementCount + local function Foo(props) + local context = React.useContext(MyContext) + -- ROBLOX deviation START: useState returns 2 values + -- local data, setData = table.unpack(React.useState({ count = context }), 1, 2) + local data, setData = React.useState({ count = context }) + -- ROBLOX deviation END + incrementCount = function() + return setData(function(ref0) + local count = ref0.count + return { count = count + 1 } + end) + end + -- ROBLOX deviation START: use FRame instead + -- return React.createElement("div", nil, "count: ", data.count) + return React.createElement("Frame", nil, "count: ", data.count) + -- ROBLOX deviation END + end + local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + children = { "count: ", "1" }, + }) + act(incrementCount) + expect(renderer:toJSON()).toEqual({ + -- ROBLOX deviation START: use Frame instead + -- type = "div", + type = "Frame", + -- ROBLOX deviation END + props = {}, + children = { "count: ", "2" }, + }) + local childFiber = renderer.root:_currentFiber() + -- ROBLOX deviation START: use dot notation + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + local tree = ReactDebugTools.inspectHooksOfFiber(childFiber) + -- ROBLOX deviation END + expect(tree).toEqual({ + { + isStateEditable = false, + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- id = nil, + id = nil :: number | nil, + -- ROBLOX deviation END + name = "Context", + -- ROBLOX deviation START: Luau doesn't support mixed arrays + -- value = 1, + value = 1 :: any, + -- ROBLOX deviation END + subHooks = {}, + }, + { + isStateEditable = true, + -- ROBLOX deviation START: adjust for 1-based indexing + -- id = 0, + id = 1, + -- ROBLOX deviation END + name = "State", + value = { count = 2 }, + subHooks = {}, + }, + }) + end) + -- ROBLOX deviation START: no experimental features + -- if Boolean.toJSBoolean(__EXPERIMENTAL__) then + -- it("should support composite useMutableSource hook", function() + -- local mutableSource = React.unstable_createMutableSource({}, function() + -- return 1 + -- end) + -- local function Foo(props) + -- React.unstable_useMutableSource(mutableSource, function() + -- return "snapshot" + -- end, function() end) + -- React.useMemo(function() + -- return "memo" + -- end, {}) + -- return React.createElement("div", nil) + -- end + -- local renderer = ReactTestRenderer.create(React.createElement(Foo, nil)) + -- local childFiber = renderer.root:findByType(Foo):_currentFiber() + -- local tree = ReactDebugTools:inspectHooksOfFiber(childFiber) + -- expect(tree).toEqual({ + -- { + -- id = 0, + -- isStateEditable = false, + -- name = "MutableSource", + -- value = "snapshot", + -- subHooks = {}, + -- }, + -- { + -- id = 1, + -- isStateEditable = false, + -- name = "Memo", + -- value = "memo", + -- subHooks = {}, + -- }, + -- }) + -- end) + -- end + -- ROBLOX deviation END +end) diff --git a/modules/react-debug-tools/src/init.lua b/modules/react-debug-tools/src/init.lua new file mode 100644 index 00000000..2c07d670 --- /dev/null +++ b/modules/react-debug-tools/src/init.lua @@ -0,0 +1,19 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-debug-tools/index.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. + ]] +-- ROBLOX deviation START: simplify and re-export types +-- local Packages --[[ ROBLOX comment: must define Packages module ]] +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Object = LuauPolyfill.Object +-- local exports = {} +-- Object.assign(exports, require(script.src.ReactDebugTools)) +-- return exports +local reactDebugToolsModule = require(script.ReactDebugTools) +export type HooksNode = reactDebugToolsModule.HooksNode +export type HooksTree = reactDebugToolsModule.HooksTree +return reactDebugToolsModule +-- ROBLOX deviation END diff --git a/modules/react-devtools-core/.luaurc b/modules/react-devtools-core/.luaurc new file mode 100644 index 00000000..89bffcfa --- /dev/null +++ b/modules/react-devtools-core/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} \ No newline at end of file diff --git a/modules/react-devtools-core/.robloxrc b/modules/react-devtools-core/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-devtools-core/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-devtools-core/README.md b/modules/react-devtools-core/README.md new file mode 100644 index 00000000..032886e7 --- /dev/null +++ b/modules/react-devtools-core/README.md @@ -0,0 +1,24 @@ +# `react-devtools-core` + +A React DevTools bridge implementation for React Lua. The implementation of this package deviates heavily from the upstream version. + +## API + +Requiring this package is similar to requiring `react-devtools`, but provides several configurable options. Unlike `react-devtools`, requiring `react-devtools-core` doesn't connect immediately but instead exports a function: + +```luau +local ReactDevtoolsCore = require(Packages.ReactDevtoolsCore) +local connectToDevtools = ReactDevtoolsCore.connectToDevtools +connectToDevTools(config) +``` + +Run `connectToDevTools()` in the same context as React to set up a connection to DevTools. +Be sure to run this function before importing *any* React package -- e.g. `react`, `react-roblox`. + +The `config` object may contain: + +- `host: string` (defaults to "localhost") - Websocket will connect to this host. +- `port: number` (defaults to `8097`) - Websocket will connect to this port. +- `useHttps: boolean` (defaults to `false`) - Websocket should use a secure protocol (wss). +- `resolveRNStyle: (style: number) => ?Object` - Used by the React Native style plug-in. +- `isAppActive: () => boolean` - If provided, DevTools will poll this method and wait until it returns true before connecting to React. diff --git a/modules/react-devtools-core/default.project.json b/modules/react-devtools-core/default.project.json new file mode 100644 index 00000000..ba42b45a --- /dev/null +++ b/modules/react-devtools-core/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsCore", + "tree": { + "$path": "src" + } +} diff --git a/modules/react-devtools-core/rotriever.toml b/modules/react-devtools-core/rotriever.toml new file mode 100644 index 00000000..c2d8c1e1 --- /dev/null +++ b/modules/react-devtools-core/rotriever.toml @@ -0,0 +1,16 @@ +[package] +name = "ReactDevtoolsCore" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" +files = ["*", "!**/__tests__/**"] + +[dependencies] +LuauPolyfill = { workspace = true } +ReactDevtoolsShared = { path = "../react-devtools-shared" } +ReactGlobals = { path = "../react-globals" } +ReactTelemetry = { path = "../react-telemetry" } + +[dev_dependencies] +JestGlobals = { workspace = true } diff --git a/modules/react-devtools-core/src/backend.lua b/modules/react-devtools-core/src/backend.lua new file mode 100644 index 00000000..6c16cbd9 --- /dev/null +++ b/modules/react-devtools-core/src/backend.lua @@ -0,0 +1,268 @@ +-- Based on: https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-devtools-core/src/backend.js + +local HttpService = game:GetService("HttpService") +local WebSocketService = game:GetService("WebSocketService") + +local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) +local ReactTelemetry = require(Packages.ReactTelemetry) +local LuauPolyfill = require(Packages.LuauPolyfill) + +local Object = LuauPolyfill.Object + +local Agent = ReactDevtoolsShared.backend.agent +local Bridge = ReactDevtoolsShared.bridge +local installHook = ReactDevtoolsShared.hook.installHook +local initBackend = ReactDevtoolsShared.backend.initBackend +local __DEBUG__ = ReactDevtoolsShared.constants.__DEBUG__ +local getDefaultComponentFilters = ReactDevtoolsShared.utils.getDefaultComponentFilters + +type BackendBridge = ReactDevtoolsShared.BackendBridge +type ComponentFilter = ReactDevtoolsShared.ComponentFilter +type DevtoolsHook = ReactDevtoolsShared.DevtoolsHook + +local serializeTable = require(script.Parent.utils.serializeTable) + +-- ROBLOX deviation: In order to support launching DevTools after the client has +-- started, the renderer needs to be injected before any other scripts. The hook +-- will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach +-- an injected renderer early. +require(script.Parent.setupAttachHook) + +type Array = { T } + +export type ConnectOptions = { + host: string?, + port: number?, + useHttps: boolean?, + isAppActive: (() -> boolean)?, + -- ROBLOX Deviation: Expose an option to start profiling as soon as we + -- setup the DevTools hook. + profileOnStart: boolean?, +} + +local hook = installHook(ReactGlobals) +local savedComponentFilters: Array = getDefaultComponentFilters() + +local function debugPrint(methodName: string, ...: any) + if __DEBUG__ then + print(`[core/backend] {methodName}`, ...) + end +end + +local function connectToDevtools(options_: ConnectOptions?) + if hook == nil then + -- Devtools wasn't injected into this context + return + end + + local options: ConnectOptions = options_ or {} + local host = options.host or "localhost" + local useHttps = if options.useHttps == nil then false else options.useHttps + local port = options.port or 8097 + local isAppActive = options.isAppActive or function() + return true + end + + local protocol = if useHttps then "wss" else "ws" + local retryTimeoutThread: thread? = nil + + local function scheduleRetry() + if retryTimeoutThread == nil then + retryTimeoutThread = task.delay(2, function() + connectToDevtools(options) + end) + end + end + + if not isAppActive() then + scheduleRetry() + return + end + + local bridge: BackendBridge? = nil + local agent + + local messageListeners = {} + local uri = `{protocol}://{host}:{port}` + + local function handleClose() + debugPrint("Socket.on('close')") + + if bridge ~= nil then + bridge:shutdown() + end + + ReactTelemetry.reportFailedDevtoolsConnection("socket_closed") + scheduleRetry() + end + + local function handleMessage(event: any) + local success, data = pcall(function() + if type(event) == "string" then + local data = HttpService:JSONDecode(event) + debugPrint("Socket.on('message')", data) + return data + else + error(`Bad data received from socket: {event} (of type {typeof(event)})`) + end + end) + + if not success then + error( + `[React DevTools] Failed to parse JSON: {event.data} (got error: {data})` + ) + end + + for _, fn in messageListeners do + local success, result = pcall(fn :: any, data) + if not success then + error( + `[React DevTools] Error calling listener with data: {data}\n{result}` + ) + end + end + end + + local success, socket = pcall(function() + return WebSocketService:CreateClient(uri) + end) + + if success == false then + warn( + `[React DevTools] Could not connect to DevTools. Attempted to connect to "{uri}" ({socket})` + ) + ReactTelemetry.reportFailedDevtoolsConnection("create_client_failed") + scheduleRetry() + return + end + + socket.Closed:Connect(handleClose) + socket.MessageReceived:Connect(handleMessage) + socket.Opened:Connect(function() + bridge = Bridge.new({ + listen = function(fn) + table.insert(messageListeners, fn) + return function() + local index = table.find(messageListeners, fn) + if index then + table.remove(messageListeners, index) + end + end + end, + send = function(event: string, payload: any, transferable: Array?) + if socket.ConnectionState == Enum.WebSocketState.Open then + debugPrint("wall.send()", event, payload) + + payload = serializeTable(payload) + + local SERDE_TO_NULL = newproxy() + + if event == "inspectedElement" then + if payload.type == "full-data" then + local defaultValue = { + displayName = SERDE_TO_NULL, + context = SERDE_TO_NULL, + hooks = SERDE_TO_NULL, + props = SERDE_TO_NULL, + state = SERDE_TO_NULL, + key = SERDE_TO_NULL, + owners = SERDE_TO_NULL, + source = SERDE_TO_NULL, + rootType = SERDE_TO_NULL, + rendererPackageName = SERDE_TO_NULL, + rendererVersion = SERDE_TO_NULL, + } + + payload.value = Object.assign(defaultValue, payload.value) + end + end + + socket:Send(HttpService:JSONEncode({ + event = event, + payload = payload, + })) + else + debugPrint( + "wall.send()", + "Shutting down bridge because of closed WebSocket connection" + ) + + if bridge ~= nil then + bridge:shutdown() + end + + scheduleRetry() + end + end, + }) + + assert(bridge, "Luau") + + bridge:addListener( + "inspectElement", + function(data: { id: number, rendererID: number }) + local id = data.id + local rendererId = data.rendererID + + if agent then + local renderer = agent._rendererInterfaces[rendererId] + if renderer ~= nil then + local nodes = renderer.findNativeNodesForFiberID(id) + if nodes ~= nil and next(nodes) ~= nil then + local node = nodes[1] + agent:emit("showNativeHighlight", node) + end + end + end + end + ) + + bridge:addListener( + "updateComponentFilters", + function(componentFilters: Array) + -- Save filter changes in memory, in case DevTools is reloaded. + -- In that case, the renderer will already be using the updated + -- values. We'll lose these in between backend reloads but that + -- can't be helped. + savedComponentFilters = componentFilters + end + ) + + -- The renderer interface doesn't read saved component filters directly, + -- because they are generally stored in localStorage within the context + -- of the extension. Because of this it relies on the extension to pass + -- filters. In the case of the standalone DevTools being used with a + -- website, saved filters are injected along with the backend script tag + -- so we shouldn't override them here. This injection strategy doesn't + -- work for React Native though. Ideally the backend would save the + -- filters itself, but RN doesn't provide a sync storage solution. So + -- for now we just fall back to using the default filters... + if ReactGlobals.__REACT_DEVTOOLS_COMPONENT_FILTERS__ == nil then + bridge:send("overrideComponentFilters", savedComponentFilters) + end + + agent = Agent.new(bridge) :: any + agent:addListener("shutdown", function() + -- If we received 'shutdown' from `agent`, we assume the `bridge` is + -- already shutting down, and that caused the 'shutdown' event on + -- the `agent`, so we don't need to call `bridge.shutdown()` here. + hook:emit("shutdown") + end) + + initBackend(hook, agent :: any, ReactGlobals) + + ReactTelemetry.reportNewDevtoolsConnection() + + -- ROBLOX Deviation: Expose an option to start profiling as soon as we + -- setup the DevTools hook. + if options.profileOnStart then + agent:startProfiling() + end + end) +end + +return { + connectToDevtools = connectToDevtools, +} diff --git a/modules/react-devtools-core/src/init.lua b/modules/react-devtools-core/src/init.lua new file mode 100644 index 00000000..d15d4ae7 --- /dev/null +++ b/modules/react-devtools-core/src/init.lua @@ -0,0 +1,5 @@ +local backend = require(script.backend) + +return { + backend = backend, +} diff --git a/modules/react-devtools-core/src/setupAttachHook.lua b/modules/react-devtools-core/src/setupAttachHook.lua new file mode 100644 index 00000000..bd4fee67 --- /dev/null +++ b/modules/react-devtools-core/src/setupAttachHook.lua @@ -0,0 +1,26 @@ +-- Based on: https://github.com/facebook/react/blob/8e5adfbd7e605bda9c5e96c10e015b3dc0df688e/packages/react-devtools-extensions/src/renderer.js + +--[[ + In order to support launching DevTools after the client has started, the + renderer needs to be injected before any other scripts. The hook will look + for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an + injected renderer early. +]] + +local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) + +local attach +ReactGlobals.__REACT_DEVTOOLS_ATTACH__ = function(...) + -- Importing the renderer module immediately causes React to initialize + -- prematurely and error. We import lazily here to avoid this, because + -- React will be initialized by the time this function is called. + if attach == nil then + attach = ReactDevtoolsShared.backend.getRendererLazy().attach + end + + return attach(...) +end + +return nil diff --git a/modules/react-devtools-core/src/utils/serializeTable.lua b/modules/react-devtools-core/src/utils/serializeTable.lua new file mode 100644 index 00000000..cd5561a1 --- /dev/null +++ b/modules/react-devtools-core/src/utils/serializeTable.lua @@ -0,0 +1,106 @@ +local HttpService = game:GetService("HttpService") + +type Record = { [string]: any } + +local ENCODE_TO_NULL = newproxy() + +--- Serializes a table such that it can be encoded as JSON via HttpService. +local function serializeTable(tbl: Record): Record + local seen = {} + + local function visitPropsRecursive(parent: Record): any + if type(parent) ~= "table" then + return parent + end + + -- Break cyclic references + if seen[parent] then + return nil + end + seen[parent] = true + + local newParent = {} + for key, value in parent do + if type(value) == "table" then + local newValue = visitPropsRecursive(value) + newParent[key] = newValue + elseif typeof(value) == "UDim" then + newParent[key] = `UDim({value.Scale}, {value.Offset})` + elseif typeof(value) == "UDim2" then + newParent[key] = + `UDim2({value.X.Scale}, {value.X.Offset}, {value.Y.Scale}, {value.Y.Offset})` + elseif typeof(value) == "Vector2" then + newParent[key] = `Vector2({value.X}, {value.Y})` + elseif typeof(value) == "Vector3" then + newParent[key] = `Vector3({value.X}, {value.Y}, {value.Z})` + elseif typeof(value) == "CFrame" then + newParent[key] = `CFrame({tostring(value)})` + elseif typeof(value) == "Color3" then + newParent[key] = `Color3({value.R}, {value.G}, {value.B})` + elseif typeof(value) == "Rect" then + newParent[key] = + `Rect({value.Min.X}, {value.Min.Y}, {value.Max.X}, {value.Max.Y})` + elseif typeof(value) == "EnumItem" then + newParent[key] = `EnumItem({value.EnumType}, {value.Name})` + elseif typeof(value) == "Instance" then + newParent[key] = `Instance({value:GetFullName()})` + elseif typeof(value) == "BrickColor" then + newParent[key] = `BrickColor({value.Name})` + elseif typeof(value) == "NumberRange" then + newParent[key] = `NumberRange({value.Min}, {value.Max})` + elseif typeof(value) == "NumberSequence" then + local keypoints = {} + for _, kp in value.Keypoints do + table.insert( + keypoints, + `NumberSequenceKeypoint({kp.Time}, {kp.Value}, {kp.Envelope})` + ) + end + newParent[key] = `NumberSequence({table.concat(keypoints, ", ")})` + elseif typeof(value) == "ColorSequence" then + local keypoints = {} + for _, kp in ipairs(value.Keypoints) do + table.insert( + keypoints, + `ColorSequenceKeypoint({kp.Time}, {kp.Value.R}, {kp.Value.G}, {kp.Value.B})` + ) + end + newParent[key] = `ColorSequence({table.concat(keypoints, ", ")})` + elseif typeof(value) == "PhysicalProperties" then + newParent[key] = + `PhysicalProperties({value.Density}, {value.Friction}, {value.Elasticity}, {value.FrictionWeight}, {value.ElasticityWeight})` + elseif typeof(value) == "Vector2int16" then + newParent[key] = `Vector2int16({value.X}, {value.Y})` + elseif typeof(value) == "Vector3int16" then + newParent[key] = `Vector3int16({value.X}, {value.Y}, {value.Z})` + elseif typeof(value) == "PathWaypoint" then + newParent[key] = + `PathWaypoint({value.Position.X}, {value.Position.Y}, {value.Position.Z}, {value.Action})` + elseif typeof(value) == "OverlapParams" then + newParent[key] = + `OverlapParams({value.FilterDescendantsInstances}, {value.FilterType}, {value.MaxParts}, {value.CollisionGroup})` + elseif typeof(value) == "RaycastParams" then + newParent[key] = + `RaycastParams({value.FilterDescendantsInstances}, {value.FilterType}, {value.IgnoreWater}, {value.CollisionGroup})` + else + local canSerialize = pcall(HttpService.JSONEncode, HttpService, { value }) + if canSerialize then + newParent[key] = value + else + warn( + `[React DevTools] Could not serialize value for key "{key}" (type '{typeof( + value + )}'):`, + value + ) + newParent[key] = ENCODE_TO_NULL + end + end + end + return newParent + end + + return visitPropsRecursive(tbl) +end + +return serializeTable diff --git a/modules/react-devtools-extensions/.robloxrc b/modules/react-devtools-extensions/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-devtools-extensions/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-devtools-extensions/default.project.json b/modules/react-devtools-extensions/default.project.json new file mode 100644 index 00000000..01014997 --- /dev/null +++ b/modules/react-devtools-extensions/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsExtensions", + "tree": { + "$path": "src" + } +} diff --git a/modules/react-devtools-extensions/rotriever.toml b/modules/react-devtools-extensions/rotriever.toml new file mode 100644 index 00000000..cc1c18b9 --- /dev/null +++ b/modules/react-devtools-extensions/rotriever.toml @@ -0,0 +1,22 @@ +[package] +name = "ReactDevtoolsExtensions" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" +files = ["*", "!**/__tests__/**"] + +[dependencies] +LuauPolyfill = { workspace = true } +Shared = { path = "../shared" } +React = { path = "../react" } +ReactDevtoolsShared = { path = "../react-devtools-shared" } +ReactGlobals = { path = "../react-globals" } +ReactRoblox = { path = "../react-roblox" } + +[dev_dependencies] +JestGlobals = { workspace = true } +# FIXME: Version 0.2.4 breaks the test "can connect to a Roact tree and inspect +# its children and child branch nodes" in devtools-integration.roblox.spec.lua +DeveloperTools = "github.com/Roblox/developer-tools@=0.2.3" +ReactTestRenderer = { path = "../react-test-renderer" } diff --git a/modules/react-devtools-extensions/src/__tests__/devtools-integration.roblox.spec.lua b/modules/react-devtools-extensions/src/__tests__/devtools-integration.roblox.spec.lua new file mode 100644 index 00000000..71865200 --- /dev/null +++ b/modules/react-devtools-extensions/src/__tests__/devtools-integration.roblox.spec.lua @@ -0,0 +1,122 @@ +--[[ + This test is currently run manually to verify the DeveloperTools library integrates into roact-alignment +]] +type Function = (...any) -> any? + +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local afterEach = JestGlobals.afterEach +local it = JestGlobals.it +local xit = JestGlobals.xit + +describe("Devtools Integration", function() + local inspector + local ReactDevtoolsExtensions + local React + + beforeEach(function() + React = require(Packages.React) + jest.resetModules() + jest.useFakeTimers() + local DeveloperTools = require(Packages.Dev.DeveloperTools) + ReactDevtoolsExtensions = require(Packages.ReactDevtoolsExtensions) + + inspector = DeveloperTools.forLibrary("UniversalApp", {}) + inspector:initRoact(ReactDevtoolsExtensions) + end) + + afterEach(function() + jest.resetModules() + jest.useRealTimers() + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = nil + end) + + -- Devtools naturally relies on using DEV mode, so this test only makes + -- sense when it's enabled + local itSkipIfNonDEV = it + if not ReactGlobals.__DEV__ then + itSkipIfNonDEV = xit :: any + end + + -- ROBLOX FIXME: This test doesn't seem to work when focused + itSkipIfNonDEV( + "can connect to a Roact tree and inspect its children and child branch nodes", + function() + local ReactRoblox = require(Packages.ReactRoblox) + + local function act(callback: Function): () + local actTestRenderer = require(Packages.Dev.ReactTestRenderer).act + + actTestRenderer(function() + callback() + end) + + while jest.getTimerCount() > 0 do + actTestRenderer(function() + jest.runAllTimers() + end) + end + end + + -- Create a Roact root + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render(React.createElement("TextLabel", { Text = "Test" })) + end) + + -- Deliver new root to the store + local devtools = inspector.workers.reactTargetWatcher.devtools + devtools.bridge:_flush() + + -- Check that the target has been added + local _, target = next(inspector.targets) + + jestExpect(target.name).toBe("#1") + + -- Attach to the tree + target.listener.onEvent("TEST") + + local worker = inspector.workers[target.id] + + -- Stub the message handler + local spy = jest.fn() + worker.send = spy + worker:showChildren({}) + + jestExpect(spy).toBeCalledWith(worker, { + eventName = "RoactInspector.ShowChildren", + path = {}, + children = { + ["1.2"] = { + Children = {}, + Icon = "Branch", + Name = "TextLabel", + Path = { "1.2" }, + }, + }, + }) + + worker:showBranch({ "1.2" }) + + jestExpect(spy.mock.calls[2][2].eventName).toBe("RoactInspector.ShowBranch") + jestExpect(spy.mock.calls[2][2].branch).toEqual({ + { + Icon = "Branch", + Name = "Root", + Link = "", + Source = "", + }, + { + Name = "TextLabel", + Link = "", + Source = "", + } :: any, -- TODO Luau: Allow elements of an array to have different types. + }) + end + ) +end) diff --git a/modules/react-devtools-extensions/src/backend.lua b/modules/react-devtools-extensions/src/backend.lua new file mode 100644 index 00000000..a1961d27 --- /dev/null +++ b/modules/react-devtools-extensions/src/backend.lua @@ -0,0 +1,148 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-devtools-extensions/src/backend.js +local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +-- ROBLOX deviation START: not needed +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +type Array = LuauPolyfill.Array +-- Do not use imports or top-level requires here! +-- Running module factories is intentionally delayed until we know the hook exists. +-- This is to avoid issues like: https://github.com/facebook/react-devtools/issues/1039 +--[[* @flow ]] + +-- ROBLOX deviation START: not needed +-- local function welcome(event) +-- if event.source ~= window or event.data.source ~= "react-devtools-content-script" then +-- return +-- end +-- window:removeEventListener("message", welcome) +-- setup(window.__REACT_DEVTOOLS_GLOBAL_HOOK__) +-- end +-- window:addEventListener("message", welcome) +-- ROBLOX deviation END +-- ROBLOX deviation START: add types for BridgeListener +type BridgeEvent = { + source: string, + event: any, + payload: any, +} +type BridgeListener = (BridgeEvent) -> () +-- ROBLOX deviation END +local function setup(hook) + -- ROBLOX deviation START: add bridgeListeners instead of listening to messages + local bridgeListeners: Array = {} + -- ROBLOX deviation END + -- ROBLOX deviation START: not needed + -- if + -- hook == nil --[[ ROBLOX CHECK: loose equality used upstream ]] + -- then + -- -- DevTools didn't get injected into this page (maybe b'c of the contentType). + -- return + -- end + -- ROBLOX deviation END + -- ROBLOX deviation START: fix imports + -- local Agent = require_("react-devtools-shared/src/backend/agent").default + -- local Bridge = require_("react-devtools-shared/src/bridge").default + -- local initBackend = require_("react-devtools-shared/src/backend").initBackend + local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) + local backend = ReactDevtoolsShared.backend + local Agent = backend.agent + local Bridge = ReactDevtoolsShared.bridge + local initBackend = backend.initBackend + -- ROBLOX deviation END + -- ROBLOX deviation START: not needed + -- local setupNativeStyleEditor = require_( + -- "react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor" + -- ).default + -- ROBLOX deviation END + local bridge = Bridge.new({ + -- ROBLOX deviation START: no self param needed + -- listen = function(self, fn) + listen = function(fn: BridgeListener) + -- ROBLOX deviation END + -- ROBLOX deviation START: Because we don't listen for all messages on window like in browser, we don't need to verify their source + -- local function listener(event) + -- if + -- event.source ~= window + -- or not Boolean.toJSBoolean(event.data) + -- or event.data.source ~= "react-devtools-content-script" + -- or not Boolean.toJSBoolean(event.data.payload) + -- then + -- return + -- end + -- fn(event.data.payload) + -- end + -- window:addEventListener("message", listener) + -- return function() + -- window:removeEventListener("message", listener) + -- end + table.insert(bridgeListeners, fn) + return function() + for index, value in bridgeListeners do + if value == fn then + table.remove(bridgeListeners, index) + return + end + end + end + -- ROBLOX deviation END + end, + -- ROBLOX deviation START: no self param needed + -- send = function(self, event: string, payload: any, transferable: Array?) + send = function(event: string, payload: any, transferable: Array?) + -- ROBLOX deviation END + -- ROBLOX deviation START: custom implementation + -- window:postMessage({ + -- source = "react-devtools-bridge", + -- payload = { event = event, payload = payload }, + -- }, "*", transferable) + for _, fn in bridgeListeners do + fn({ + source = "react-devtools-bridge", + event = event, + payload = payload, + }) + end + -- ROBLOX deviation END + end, + }) + local agent = Agent.new(bridge) + agent:addListener("shutdown", function() + -- If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, + -- and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + -- ROBLOX deviation START: use dot notation + -- hook:emit("shutdown") + hook.emit("shutdown") + -- ROBLOX deviation END + end) + -- ROBLOX deviation START: use _G instead of window + -- initBackend(hook, agent, window) -- Let the frontend know that the backend has attached listeners and is ready for messages. + initBackend(hook, agent, ReactGlobals) + -- ROBLOX deviation END + -- This covers the case of syncing saved values after reloading/navigating while DevTools remain open. + bridge:send("extensionBackendInitialized") -- Setup React Native style editor if a renderer like react-native-web has injected it. + -- ROBLOX deviation START: not needed + -- if Boolean.toJSBoolean(hook.resolveRNStyle) then + -- setupNativeStyleEditor( + -- bridge, + -- agent, + -- hook.resolveRNStyle, + -- hook.nativeStyleEditorValidAttributes + -- ) + -- end + -- ROBLOX deviation END + -- ROBLOX deviation START: add return + return { + agent = agent, + hook = hook, + bridge = bridge, + } + -- ROBLOX deviation END +end + +-- ROBLOX deviation START: add return +return { + setup = setup, +} +-- ROBLOX deviation END diff --git a/modules/react-devtools-extensions/src/init.lua b/modules/react-devtools-extensions/src/init.lua new file mode 100644 index 00000000..467cec5e --- /dev/null +++ b/modules/react-devtools-extensions/src/init.lua @@ -0,0 +1,53 @@ +-- ROBLOX note: no upstream +-- ROBLOX note: The setup function adds the glue required for DeveloperTools to initialize the Roact devtools correctly +local Packages = script.Parent + +return { + setup = function(debugMode: boolean) + local ReactGlobals = require(Packages.ReactGlobals) + + -- ROBLOX note: Set globals for React devtools to work + ReactGlobals.__DEV__ = true + ReactGlobals.__DEBUG__ = debugMode or false + ReactGlobals.__PROFILE__ = true + ReactGlobals.__EXPERIMENTAL__ = true + -- ROBLOX note: Don't hide host coomponents as the current Developer Inspector uses on these to preserve a + -- direct mapping between the Inspector tree and the Explorer tree as requested by design. + ReactGlobals.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = {} + + local ReactDevtoolsShared = require(Packages.ReactDevtoolsShared) + local setup = require(Packages.ReactDevtoolsExtensions.backend).setup + local installHook = ReactDevtoolsShared.hook.installHook + local Store = ReactDevtoolsShared.devtools.store + + -- ROBLOX note: Ensure that the global hook is installed before the injection into DevTools + installHook(ReactGlobals) + + -- ROBLOX note: Ensure that ReactRoblox is loaded after injection so that the ReactHostConfig is populated correctly + require(Packages.React) + require(Packages.ReactRoblox) + + local hook = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ + + -- ROBLOX note: Make sure that this method was called before ReactRoblox was first required, + -- otherwise the profiler will not be enabled for the session. + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + if not ReactFeatureFlags.enableSchedulingProfiler then + warn( + "[DeveloperTools] React was initialized before DeveloperTools. Call inspector.setupReactDevtools before requiring React to enable profiling." + ) + end + + local result = setup(hook) + + -- ROBLOX note: The DeveloperTools library is only passed the ReactDevtoolsExtensions API to keep the + -- devtools init process compact for users. Initialize the store so DeveloperTools doesn't also need to be + -- passed the ReactDevtoolsShared API. + return { + agent = result.agent, + bridge = result.bridge, + hook = result.hook, + store = Store.new(result.bridge), + } + end, +} diff --git a/modules/react-devtools-shared/.robloxrc b/modules/react-devtools-shared/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-devtools-shared/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-devtools-shared/default.project.json b/modules/react-devtools-shared/default.project.json new file mode 100644 index 00000000..6be6fdad --- /dev/null +++ b/modules/react-devtools-shared/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsShared", + "tree": { + "$path": "src" + } +} diff --git a/modules/react-devtools-shared/rotriever.toml b/modules/react-devtools-shared/rotriever.toml new file mode 100644 index 00000000..ebde2c1e --- /dev/null +++ b/modules/react-devtools-shared/rotriever.toml @@ -0,0 +1,23 @@ +[package] +name = "ReactDevtoolsShared" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" +files = ["*", "!**/__tests__/**"] + +[dependencies] +LuauPolyfill = { workspace = true } +Shared = { path = "../shared" } +React = { path = "../react" } +ReactReconciler = { path = "../react-reconciler" } +ReactRoblox = { path = "../react-roblox" } +ReactIs = { path = "../react-is" } +ReactDebugTools = { path = "../react-debug-tools" } +ReactGlobals = { path = "../react-globals" } + +[dev_dependencies] +JestGlobals = { workspace = true } +Scheduler = { path = "../scheduler" } +Promise = { workspace = true } +ReactTestRenderer = { path = "../react-test-renderer" } diff --git a/modules/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters.spec.snap.lua b/modules/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters.spec.snap.lua new file mode 100644 index 00000000..f403f1b7 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/__snapshots__/storeComponentFilters.spec.snap.lua @@ -0,0 +1,143 @@ +-- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing +local exports = {} +exports[ [=[Store component filters should filter HOCs: 1: mount 1]=] ] = [=[ + +"[root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ + " +]=] + +exports[ [=[Store component filters should filter HOCs: 2: hide all HOCs 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should filter HOCs: 3: disable HOC filter 1]=] ] = [=[ + +"[root] + ▾ [Bar][Foo] + ▾ [Foo] + ▾ + " +]=] + +exports[ [=[Store component filters should filter by display name: 1: mount 1]=] ] = [=[ + +"[root] + ▾ + + ▾ + + ▾ + " +]=] + +exports[ [=[Store component filters should filter by display name: 2: filter "Foo" 1]=] ] = [=[ + +"[root] + + ▾ + + ▾ + " +]=] + +exports[ [=[Store component filters should filter by display name: 3: filter "Ba" 1]=] ] = [=[ + +"[root] + ▾ + + + " +]=] + +exports[ [=[Store component filters should filter by display name: 4: filter "B.z" 1]=] ] = [=[ + +"[root] + ▾ + + ▾ + + " +]=] + +exports[ [=[Store component filters should filter by path: 1: mount 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should filter by path: 2: hide all components declared within this test filed 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should filter by path: 3: hide components in a made up fake path 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should support filtering by element type: 1: mount 1]=] ] = [=[ + +"[root] + ▾ + ▾ + ▾ + " +]=] + +exports[ [=[Store component filters should support filtering by element type: 2: hide host components 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should support filtering by element type: 3: hide class components 1]=] ] = [=[ + +"[root] + ▾ + ▾ + " +]=] + +exports[ [=[Store component filters should support filtering by element type: 4: hide class and function components 1]=] ] = [=[ + +"[root] + ▾ + " +]=] + +exports[ [=[Store component filters should support filtering by element type: 5: disable all filters 1]=] ] = [=[ + +"[root] + ▾ + ▾ + ▾ + " +]=] + +return exports 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..9f074ebd --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/bridge.spec.lua @@ -0,0 +1,62 @@ +-- ROBLOX 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. +]] +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local describe = JestGlobals.describe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest + +describe("bridge", function() + local Bridge + + beforeEach(function() + jest.resetModules() + jest.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") + jest.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() + jest.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 } + ) + jest.runAllTimers() + jestExpect(wall.send).never.toHaveBeenCalled() + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/console.spec.lua b/modules/react-devtools-shared/src/__tests__/console.spec.lua new file mode 100644 index 00000000..a85b8e88 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/console.spec.lua @@ -0,0 +1,505 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/console-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. +]] +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local describe = JestGlobals.describe +local xit = JestGlobals.xit +local beforeEach = JestGlobals.beforeEach +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest + +-- ROBLOX deviation: Stub for now +local Console = { + patch = function(...) + print("Console.patch", ...) + end, + unpatch = function(...) + print("Console.unpatch", ...) + end, + dangerous_setTargetConsoleForTesting = function(...) end, + registerRenderer = function(...) end, +} + +local React +local ReactRoblox +local utils = require(script.Parent.utils) + +describe("console", function() + local act + local fakeConsole + local mockError + local mockInfo + local mockLog + local mockWarn + local patchConsole + local unpatchConsole + + beforeEach(function() + jest.resetModules() + + patchConsole = Console.patch + unpatchConsole = Console.unpatch + + -- Patch a fake console so we can verify with tests below. + -- Patching the real console is too complicated, + -- because Jest itself has hooks into it as does our test env setup. + mockError = jest.fn() + mockInfo = jest.fn() + mockLog = jest.fn() + mockWarn = jest.fn() + fakeConsole = { + error = mockError, + info = mockInfo, + log = mockLog, + warn = mockWarn, + } + + Console.dangerous_setTargetConsoleForTesting(fakeConsole) + + -- Note the Console module only patches once, + -- so it's important to patch the test console before injection. + patchConsole({ + appendComponentStack = true, + breakOnWarn = false, + }) + + local inject = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject; + (ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ :: any).inject = function(internals) + inject(internals) + + Console.registerRenderer(internals) + end + + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + + act = utils.act + end) + + local function normalizeCodeLocInfo(str) + -- ROBLOX deviation: Lua stack traces won't match JS ones + -- ROBLOX TODO: verify this + return str + end + + xit( + "should not patch console methods that do not receive component stacks", + function() + jestExpect(fakeConsole.error).never.toBe(mockError) + jestExpect(fakeConsole.info).toBe(mockInfo) + jestExpect(fakeConsole.log).toBe(mockLog) + jestExpect(fakeConsole.warn).never.toBe(mockWarn) + end + ) + + xit("should only patch the console once", function() + local prevError = fakeConsole.error + local prevWarn = fakeConsole.warn + + patchConsole({ + appendComponentStack = true, + breakOnWarn = false, + }) + jestExpect(fakeConsole.error).toBe(prevError) + jestExpect(fakeConsole.warn).toBe(prevWarn) + end) + + xit("should un-patch when requested", function() + jestExpect(fakeConsole.error).never.toBe(mockError) + jestExpect(fakeConsole.warn).never.toBe(mockWarn) + unpatchConsole() + jestExpect(fakeConsole.error).toBe(mockError) + jestExpect(fakeConsole.warn).toBe(mockWarn) + end) + + xit("should pass through logs when there is no current fiber", function() + jestExpect(mockLog).toHaveBeenCalledTimes(0) + jestExpect(mockWarn).toHaveBeenCalledTimes(0) + jestExpect(mockError).toHaveBeenCalledTimes(0) + fakeConsole.log("log") + fakeConsole.warn("warn") + fakeConsole.error("error") + jestExpect(mockLog).toHaveBeenCalledTimes(1) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("log") + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(1) + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn") + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(1) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + end) + + xit("should not append multiple stacks", function() + local Child = function() + fakeConsole.warn("warn\n in Child (at fake.js:123)") + fakeConsole.error("error", "\n in Child (at fake.js:123)") + return nil + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Child)) + end) + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(1) + -- ROBLOX TODO: What is printed instead of this? + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn\n in Child (at fake.js:123)") + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + jestExpect(mockError.mock.calls[0][1]).toBe("\n in Child (at fake.js:123)") + end) + + xit( + "should append component stacks to errors and warnings logged during render", + function() + local Intermediate = function(_ref2) + local children = _ref2.children + return children + end + -- ROBLOX deviation: switched ordering for variable definition order + local Child = function(_ref4) + local _children = _ref4.children + + fakeConsole.error("error") + fakeConsole.log("log") + fakeConsole.warn("warn") + + return nil + end + local Parent = function(_ref3) + local _children = _ref3.children + return React.createElement( + Intermediate, + nil, + React.createElement(Child, nil) + ) + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Parent, nil)) + end) + jestExpect(mockLog).toHaveBeenCalledTimes(1) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("log") + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + end + ) + xit( + "should append component stacks to errors and warnings logged from effects", + function() + local Intermediate = function(_ref5) + local children = _ref5.children + return children + end + -- ROBLOX deviation: switched ordering for variable definition order + local Child = function(_ref7) + local _children = _ref7.children + + React.useLayoutEffect(function() + fakeConsole.error("active error") + fakeConsole.log("active log") + fakeConsole.warn("active warn") + end) + React.useEffect(function() + fakeConsole.error("passive error") + fakeConsole.log("passive log") + fakeConsole.warn("passive warn") + end) + + return nil + end + local Parent = function(_ref6) + local _children = _ref6.children + + return React.createElement( + Intermediate, + nil, + React.createElement(Child, nil) + ) + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Parent, nil)) + end) + jestExpect(mockLog).toHaveBeenCalledTimes(2) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("active log") + jestExpect(mockLog.mock.calls[1]).toHaveLength(1) + jestExpect(mockLog.mock.calls[1][0]).toBe("passive log") + jestExpect(mockWarn).toHaveBeenCalledTimes(2) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[0][0]).toBe("active warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockWarn.mock.calls[1]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[1][0]).toBe("passive warn") + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(2) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("active error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError.mock.calls[1]).toHaveLength(2) + jestExpect(mockError.mock.calls[1][0]).toBe("passive error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + end + ) + xit( + "should append component stacks to errors and warnings logged from commit hooks", + function() + local Intermediate = function(_ref8) + local children = _ref8.children + + return children + end + -- ROBLOX deviation: switched ordering for variable definition order + local Child = React.Component:extend("Child") + local Parent = function(_ref9) + local _children = _ref9.children + + return React.createElement( + Intermediate, + nil, + React.createElement(Child, nil) + ) + end + + function Child:componentDidMount() + fakeConsole.error("didMount error") + fakeConsole.log("didMount log") + fakeConsole.warn("didMount warn") + end + function Child:componentDidUpdate() + fakeConsole.error("didUpdate error") + fakeConsole.log("didUpdate log") + fakeConsole.warn("didUpdate warn") + end + function Child:render() + return nil + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Parent, nil)) + end) + act(function() + return root:render(React.createElement(Parent, nil)) + end) + jestExpect(mockLog).toHaveBeenCalledTimes(2) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("didMount log") + jestExpect(mockLog.mock.calls[1]).toHaveLength(1) + jestExpect(mockLog.mock.calls[1][0]).toBe("didUpdate log") + jestExpect(mockWarn).toHaveBeenCalledTimes(2) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[0][0]).toBe("didMount warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockWarn.mock.calls[1]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[1][0]).toBe("didUpdate warn") + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(2) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("didMount error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError.mock.calls[1]).toHaveLength(2) + jestExpect(mockError.mock.calls[1][0]).toBe("didUpdate error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + end + ) + xit( + "should append component stacks to errors and warnings logged from gDSFP", + function() + local Intermediate = function(props) + local children = props.children + return children + end + -- ROBLOX deviation: switched ordering for variable definition order + local Child = React.Component:extend("Child") + local Parent = function(props) + local _children = props.children + return React.createElement(Intermediate, nil, React.createElement(Child)) + end + + function Child.getDerivedStateFromProps() + fakeConsole.error("error") + fakeConsole.log("log") + fakeConsole.warn("warn") + return nil + end + function Child:render() + return nil + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Parent, nil)) + end) + jestExpect(mockLog).toHaveBeenCalledTimes(1) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("log") + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + end + ) + xit("should append stacks after being uninstalled and reinstalled", function() + local Child = function(_ref12) + local _children = _ref12.children + + fakeConsole.warn("warn") + fakeConsole.error("error") + + return nil + end + + unpatchConsole() + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Child, nil)) + end) + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(1) + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn") + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(1) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + patchConsole({ + appendComponentStack = true, + breakOnWarn = false, + }) + act(function() + return root:render(React.createElement(Child, nil)) + end) + jestExpect(mockWarn).toHaveBeenCalledTimes(2) + jestExpect(mockWarn.mock.calls[1]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[1][0]).toBe("warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[1][1])).toEqual( + "\n in Child (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(2) + jestExpect(mockError.mock.calls[1]).toHaveLength(2) + jestExpect(mockError.mock.calls[1][0]).toBe("error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[1][1])).toBe( + "\n in Child (at **)" + ) + end) + xit("should be resilient to prepareStackTrace", function() + -- ROBLOX TODO: what is a suitable alternative for this? + -- Error.prepareStackTrace = function(error, callsites) + -- local stack = { + -- 'An error occurred:', + -- error.message, + -- } + + -- for i=0, callsites.length - 1 do + -- local callsite = callsites[i] + + -- stack.push('\t' + callsite.getFunctionName(), '\t\tat ' + callsite.getFileName(), '\t\ton line ' + callsite.getLineNumber()) + -- end + + -- return stack.join('\n') + -- end + + local Intermediate = function(_ref13) + local children = _ref13.children + return children + end + -- ROBLOX deviation: switched ordering for variable definition order + local Child = function(_ref15) + fakeConsole.error("error") + fakeConsole.log("log") + fakeConsole.warn("warn") + + return nil + end + local Parent = function(_ref14) + local _children = _ref14.children + return React.createElement(Intermediate, nil, React.createElement(Child, nil)) + end + + -- ROBLOX deviation: Use createRoot instead of DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Parent, nil)) + end) + jestExpect(mockLog).toHaveBeenCalledTimes(1) + jestExpect(mockLog.mock.calls[0]).toHaveLength(1) + jestExpect(mockLog.mock.calls[0][0]).toBe("log") + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn.mock.calls[0]).toHaveLength(2) + jestExpect(mockWarn.mock.calls[0][0]).toBe("warn") + -- ROBLOX TODO: What is printed instead of this? + jestExpect(normalizeCodeLocInfo(mockWarn.mock.calls[0][1])).toEqual( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + jestExpect(mockError).toHaveBeenCalledTimes(1) + jestExpect(mockError.mock.calls[0]).toHaveLength(2) + jestExpect(mockError.mock.calls[0][0]).toBe("error") + jestExpect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe( + "\n in Child (at **)\n in Intermediate (at **)\n in Parent (at **)" + ) + 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..4b1155d4 --- /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 +-- */ + +local Packages = script.Parent.Parent.Parent +local Error = require(Packages.LuauPolyfill).Error +local JestGlobals = require(Packages.Dev.JestGlobals) +local describe = JestGlobals.describe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest + +describe("events", function() + local dispatcher + beforeEach(function() + jest.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) diff --git a/modules/react-devtools-shared/src/__tests__/profilerStore.spec.lua b/modules/react-devtools-shared/src/__tests__/profilerStore.spec.lua new file mode 100644 index 00000000..b51ddaab --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/profilerStore.spec.lua @@ -0,0 +1,175 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/profilerStore-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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jest = JestGlobals.jest +local jestExpect = JestGlobals.expect +local xdescribe = JestGlobals.xdescribe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach + +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store + +local global = ReactGlobals :: any + +xdescribe("ProfilerStore", function() + local React + local ReactRoblox + local LuauPolyfill + local store: Store + local utils + local act + + beforeEach(function() + utils = require(script.Parent.utils) + act = utils.act + + store = global.store + store:setCollapseNodesByDefault(false) + store:setRecordChangeDescriptions(true) + + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + LuauPolyfill = require(Packages.LuauPolyfill) + + utils.beforeEachProfiling() + end) + + it("should not remove profiling data when roots are unmounted", function() + local function Child() + return React.createElement("Frame") + end + + local function Parent(props) + local count = props.count + + local arr = table.create(count) :: any + for index = 1, count do + arr[index] = React.createElement( + Child, + { key = tostring(index), duration = index } + ) + end + return arr + end + + local containerA = ReactRoblox.createRoot(Instance.new("Frame")) + local containerB = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + containerA:render(React.createElement(Parent, { key = "A", count = 3 })) + containerB:render(React.createElement(Parent, { key = "B", count = 2 })) + end) + act(function() + store._profilerStore:startProfiling() + end) + act(function() + containerA:render(React.createElement(Parent, { key = "A", count = 4 })) + containerB:render(React.createElement(Parent, { key = "B", count = 1 })) + end) + act(function() + store._profilerStore:stopProfiling() + end) + local rootA = store:getRoots()[1] + local rootB = store:getRoots()[2] + act(function() + containerB:render(nil) + end) + jestExpect(store._profilerStore:getDataForRoot(rootA)).never.toBeNull() + act(function() + containerA:render(nil) + end) + jestExpect(store._profilerStore:getDataForRoot(rootB)).never.toBeNull() + end) + it( + "should not allow new/saved profiling data to be set while profiling is in progress", + function() + act(function() + return store._profilerStore:startProfiling() + end) + local fauxProfilingData = { + -- ROLBLOX deviation START: upstream doesn't typecheck, needs mandatory imported field + dataForRoots = LuauPolyfill.Map.new(), + imported = false, + -- ROBLOX deviation END + } + + -- ROBLOX deviation: spyOn console.warn workaround + local mockWarn = jest.fn().mockName("console.warn") + LuauPolyfill.console.warn = mockWarn + + store._profilerStore:profilingData(fauxProfilingData) + jestExpect(store._profilerStore.profilingData).never.toBe(fauxProfilingData) + jestExpect(mockWarn).toHaveBeenCalledTimes(1) + jestExpect(mockWarn).toHaveBeenCalledWith( + "Profiling data cannot be updated while profiling is in progress." + ) + + act(function() + return store._profilerStore:stopProfiling() + end) + + store._profilerStore:profilingData(fauxProfilingData) + jestExpect(store._profilerStore:profilingData()).toBe(fauxProfilingData) + end + ) + + --[[ ROBLOX note: This seems to test edges that aren't present in our environment + -- This test covers current broken behavior (arguably) with the synthetic event system. + it("should filter empty commits", function() + local inputRef = React.createRef() + local function ControlledInput() + local name, setName = React.useState("foo") + local function handleChange(event) + return setName(event.target.value) + end + return React.createElement( + "input", + { ref = inputRef, value = name, onChange = handleChange } + ) + end + + local container = Instance.new("Frame") -- This element has to be in the for the event system to work. + -document.body:appendChild(container) -- It's important that this test uses legacy sync mode. + -- The root API does not trigger this particular failing case. + ReactRoblox.createRoot(container):render(React.createElement(ControlledInput, nil)) + act(function() + return store._profilerStore:startProfiling() + end) + + -- Sets a value in a way that React doesn't see, + -- so that a subsequent "change" event will trigger the event handler. + local setUntrackedValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + ).set + + local target = inputRef.current + setUntrackedValue(target, "bar") + target:dispatchEvent( + Event.new("input", { bubbles = true, cancelable = true }) + ) + jestExpect(target.value).toBe("bar") + + act(function() + return store._profilerStore:stopProfiling() + end) + + -- Only one commit should have been recorded (in response to the "change" event). + local root = store:getRoots()[1] + local data = store._profilerStore:getDataForRoot(root) + jestExpect(data.commitData).toHaveLength(1) + jestExpect(data.operations).toHaveLength(1) + end) + --]] +end) diff --git a/modules/react-devtools-shared/src/__tests__/profilingCache.spec.lua b/modules/react-devtools-shared/src/__tests__/profilingCache.spec.lua new file mode 100644 index 00000000..4da3d8ab --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/profilingCache.spec.lua @@ -0,0 +1,939 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/profilingCache-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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local xdescribe = JestGlobals.xdescribe +local it = JestGlobals.it +local xit = JestGlobals.xit +local beforeEach = JestGlobals.beforeEach +local afterEach = JestGlobals.afterEach + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store + +local ProfilerTypes = require(script.Parent.Parent.devtools.views.Profiler.types) +type ProfilingDataFrontend = ProfilerTypes.ProfilingDataFrontend + +local global = ReactGlobals :: any + +-- ROBLOX deviation START: inline simplified PropTypes logic +-- ROBLOX FIXME luau: if not annotated, gets 'Failed ot unify type packs' +local function propTypes(value: any, expectedType): any + if value == nil then + return nil + end + if type(value) ~= expectedType then + return Error("expected " .. expectedType) + end + return nil +end +local PropTypes = { + number = function(props, typeSpecName) + return propTypes(props[typeSpecName], "number") + end, + string = function(props, typeSpecName) + return propTypes(props[typeSpecName], "string") + end, +} +-- ROBLOX deviation END + +xdescribe("ProfilingCache", function() + local React + local ReactRoblox + local Scheduler + local SchedulerTracing + local bridge: FrontendBridge + local store: Store + local utils + local act + + beforeEach(function() + ReactGlobals.__PROFILE__ = true + utils = require(script.Parent.utils) + act = utils.act + + bridge = global.bridge + store = global.store + store:setCollapseNodesByDefault(false) + store:setRecordChangeDescriptions(true) + + --PropTypes = require_("prop-types") + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + Scheduler = require(Packages.Dev.Scheduler) + SchedulerTracing = Scheduler.tracing + + utils.beforeEachProfiling() + end) + + afterEach(function() + ReactGlobals.__PROFILE__ = nil + end) + + it( + "should collect data for each root (including ones added or mounted after profiling started)", + function() + local function Child(props) + Scheduler.unstable_advanceTime(props.duration) + return nil + end + + local MemoizedChild = React.memo(Child) + + local function Parent(props) + Scheduler.unstable_advanceTime(10) + + local count = props.count + local children = table.create(count) :: any + for index = 0, count - 1 do + children[index + 1] = + React.createElement(Child, { key = index, duration = index }) + end + + return React.createElement( + React.Fragment, + nil, + children, + React.createElement(MemoizedChild, { duration = 1 }) + ) + end + + local containerA = ReactRoblox.createRoot(Instance.new("Frame")) + local containerB = ReactRoblox.createRoot(Instance.new("Frame")) + local containerC = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return containerA:render(React.createElement(Parent, { count = 2 })) + end) + + act(function() + return containerB:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return containerA:render(React.createElement(Parent, { count = 3 })) + end) + act(function() + return containerC:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + return containerA:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + return containerB:render(nil) + end) + act(function() + return containerA:render(React.createElement(Parent, { count = 0 })) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + + local allProfilingDataForRoots = {} + local function Validator(rootID, previousProfilingDataForRoot) + local profilingDataForRoot = store._profilerStore:getDataForRoot(rootID) + + if previousProfilingDataForRoot ~= nil then + jestExpect(profilingDataForRoot).toEqual(previousProfilingDataForRoot) + else + jestExpect(profilingDataForRoot).toMatchSnapshot( + string.format( + "Data for root %s", + tostring(profilingDataForRoot.displayName) + ) + ) + end + table.insert(allProfilingDataForRoots, profilingDataForRoot) + end + + local profilingData = store._profilerStore:profilingData() + local dataForRoots = if profilingData ~= nil + then profilingData.dataForRoots + else nil + jestExpect(dataForRoots).never.toBeNull() + + if dataForRoots ~= nil then + dataForRoots:forEach(function(dataForRoot) + Validator(dataForRoot.rootID, nil) + end) + end + jestExpect(#allProfilingDataForRoots).toBe(3) + utils.exportImportHelper(bridge, store) + + for _, profilingDataForRoot in allProfilingDataForRoots do + Validator(profilingDataForRoot.rootID, profilingDataForRoot) + end + end + ) + it("should collect data for each commit", function() + local MemoizedChild, Child + local function Parent(props) + Scheduler.unstable_advanceTime(10) + + local count = props.count + local children = table.create(count) :: any + for index = 0, count - 1 do + children[index + 1] = + React.createElement(Child, { key = index, duration = index }) + end + + return React.createElement( + React.Fragment, + nil, + children, + React.createElement(MemoizedChild, { key = -1, duration = 1 }) + ) + end + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + MemoizedChild = React.memo(Child) + + -- ROBLOX deviation START: use Roblox renderer + local container = ReactRoblox.createRoot(Instance.new("Frame")) + -- ROBLOX deviation END + + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return container:render(React.createElement(Parent, { count = 2 })) + end) + act(function() + return container:render(React.createElement(Parent, { count = 3 })) + end) + act(function() + return container:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + return container:render(React.createElement(Parent, { count = 0 })) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + local allCommitData = {} + local function Validator(ref) + local commitIndex, previousCommitDetails, rootID = + ref.commitIndex, ref.previousCommitDetails, ref.rootID + local commitData = store._profilerStore:getCommitData(rootID, commitIndex) + if previousCommitDetails ~= nil then + jestExpect(commitData).toEqual(previousCommitDetails) + else + table.insert(allCommitData, commitData) + jestExpect(commitData).toMatchSnapshot( + string.format("CommitDetails commitIndex: %d", commitIndex - 1) + ) + end + end + local rootID = store:getRoots()[1] + + for commitIndex = 1, 4 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = nil, + rootID = rootID, + }) + end + jestExpect(#allCommitData).toBe(4) + utils.exportImportHelper(bridge, store) + + for commitIndex = 1, 4 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = allCommitData[commitIndex], + rootID = rootID, + }) + end + end) + it("should record changed props/state/context/hooks", function() + local LegacyContextConsumer, ModernContextConsumer + local instance = nil + local ModernContext = React.createContext(0) + + local LegacyContextProvider = React.Component:extend("LegacyContextProvider") + LegacyContextProvider.childContextTypes = { count = PropTypes.number } + + function LegacyContextProvider:init() + self:setState({ count = 0 }) + end + function LegacyContextProvider:getChildContext() + return self.state + end + function LegacyContextProvider:render() + return React.createElement( + ModernContext.Provider, + { + value = self.state.count, + }, + React.createElement(React.Fragment, nil, { + React.createElement(ModernContextConsumer), + React.createElement(LegacyContextConsumer), + }) + ) + end + + local function FunctionComponentWithHooks(ref) + local count = ref.count + React.useMemo(function() + return count + end, { count }) + return nil + end + ModernContextConsumer = React.Component:extend("ModernContextConsumer") + function ModernContextConsumer:render() + return React.createElement( + FunctionComponentWithHooks, + { count = self.context } + ) + end + + LegacyContextConsumer = React.Component:extend("LegacyContextConsumer") + function LegacyContextConsumer:render() + instance = self + return React.createElement( + FunctionComponentWithHooks, + { count = self.context.count } + ) + end + + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return container:render(React.createElement(LegacyContextProvider, nil)) + end) + jestExpect(instance).never.toBeNull() + act(function() + return (instance :: any):setState({ count = 1 }) + end) + act(function() + return container:render( + React.createElement(LegacyContextProvider, { foo = 123 }) + ) + end) + act(function() + return container:render( + React.createElement(LegacyContextProvider, { bar = "abc" }) + ) + end) + act(function() + return container:render(React.createElement(LegacyContextProvider, nil)) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + local allCommitData = {} + local function Validator(ref) + local commitIndex, previousCommitDetails, rootID = + ref.commitIndex, ref.previousCommitDetails, ref.rootID + local commitData = store._profilerStore:getCommitData(rootID, commitIndex) + if previousCommitDetails ~= nil then + jestExpect(commitData).toEqual(previousCommitDetails) + else + table.insert(allCommitData, commitData) + jestExpect(commitData).toMatchSnapshot( + string.format("CommitDetails commitIndex: %d", commitIndex - 1) + ) + end + end + local rootID = store:getRoots()[1] + for commitIndex = 1, 5 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = nil, + rootID = rootID, + }) + end + jestExpect(allCommitData).toHaveLength(5) + utils.exportImportHelper(bridge, store) + for commitIndex = 1, 5 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = allCommitData[commitIndex], + rootID = rootID, + }) + end + end) + + -- ROBLOX FIXME: upstream has priorityLevel as "Immediate" + -- ROBLOX FIXME: upstream has didHooksChange as false in this step. maybe related to the priorityLevel difference below? + -- ROBLOX Note: These bugs only happen in CommitIndex 1 1 + it("should properly detect changed hooks", function() + local Context = React.createContext(0) + local function reducer(state, action) + if action.type == "invert" then + return { value = not state.value } + else + error(Error.new()) + end + end + local dispatch + local setState + local function Component(ref) + local string_ = ref.string + local _ + -- These hooks may change and initiate re-renders. + _, setState = React.useState("abc") + -- ROBLOX FIXME Luau: without this any cast, Type '{| value: boolean |}' could not be converted into 'string' + _, dispatch = React.useReducer(reducer :: any, { value = true }) + + -- This hook's return value may change between renders, + -- but the hook itself isn't stateful. + React.useContext(Context) + + -- These hooks and their dependencies may not change between renders. + -- We're using them to ensure that they don't trigger false positives. + React.useCallback(function() + return function() end + end, { string_ }) + React.useMemo(function() + return string_ + end, { string_ }) + + -- These hooks never "change". + React.useEffect(function() end, { string_ }) + React.useLayoutEffect(function() end, { string_ }) + + return nil + end + + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return container:render( + React.createElement( + Context.Provider, + { value = true }, + React.createElement(Component, { count = 1 }) + ) + ) + end) + + -- Second render has no changed hooks, only changed props. + act(function() + return container:render( + React.createElement( + Context.Provider, + { value = true }, + React.createElement(Component, { count = 2 }) + ) + ) + end) + + -- Third render has a changed reducer hook + act(function() + return dispatch({ type = "invert" }) + end) + + -- Fourth render has a changed state hook + act(function() + return setState("def") + end) + + -- Fifth render has a changed context value, but no changed hook. + -- Technically, DevTools will miss this "context" change since it only tracks legacy context. + act(function() + return container:render( + React.createElement( + Context.Provider, + { value = false }, + React.createElement(Component, { count = 2 }) + ) + ) + end) + + act(function() + return store._profilerStore:stopProfiling() + end) + + local allCommitData = {} + + local function Validator(ref) + local commitIndex, previousCommitDetails, rootID = + ref.commitIndex, ref.previousCommitDetails, ref.rootID + local commitData = store._profilerStore:getCommitData(rootID, commitIndex) + if previousCommitDetails ~= nil then + jestExpect(commitData).toEqual(previousCommitDetails) + else + table.insert(allCommitData, commitData) + jestExpect(commitData).toMatchSnapshot( + string.format( + "CommitDetails commitIndex: %s", + tostring(commitIndex - 1) + ) + ) + end + return nil + end + local rootID = store:getRoots()[1] + for commitIndex = 1, 5 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = nil, + rootID = rootID, + }) + end + + jestExpect(allCommitData).toHaveLength(5) + + -- Export and re-import profile data and make sure it is retained. + utils.exportImportHelper(bridge, store) + + for commitIndex = 1, 5 do + Validator({ + commitIndex = commitIndex, + previousCommitDetails = allCommitData[commitIndex], + rootID = rootID, + }) + end + end) + it( + "should calculate a self duration based on actual children (not filtered children)", + function() + local Parent, Child + store:setComponentFilters({ utils.createDisplayNameFilter("^Parent$") }) + local function Grandparent() + Scheduler.unstable_advanceTime(10) + return React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { key = "one" }), + React.createElement(Parent, { key = "two" }) + ) + end + function Parent() + Scheduler.unstable_advanceTime(2) + return React.createElement(Child, nil) + end + function Child() + Scheduler.unstable_advanceTime(1) + return nil + end + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return ReactRoblox.createRoot(Instance.new("Frame")) + :render(React.createElement(Grandparent, nil)) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + local commitData = nil + local function Validator(ref) + local commitIndex, rootID = ref.commitIndex, ref.rootID + commitData = store._profilerStore:getCommitData(rootID, commitIndex) + jestExpect(commitData).toMatchSnapshot( + "CommitDetails with filtered self durations" + ) + end + local rootID = store:getRoots()[1] + Validator({ commitIndex = 1, rootID = rootID }) + jestExpect(commitData).never.toBeNull() + end + ) + --[=[ + xit("should calculate self duration correctly for suspended views", function(done) + local Fallback, Async + return Promise.resolve():andThen(function() + local data + local function getData() + if data then + return data + else + error(Promise.new(function(resolve) + data = "abc" + resolve(data) + end)) + end + end + local function Parent() + Scheduler.unstable_advanceTime(10) + return React.createElement( + React.Suspense, + { fallback = React.createElement(Fallback, nil) }, + React.createElement(Async, nil) + ) + end + function Fallback() + Scheduler.unstable_advanceTime(2) + return "Fallback..." + end + function Async() + Scheduler.unstable_advanceTime(3) + return getData() + end + act(function() + return store._profilerStore:startProfiling() + end) + utils + :actAsync(function() + return ReactDOM:render( + React.createElement(Parent, nil), + document:createElement("div") + ) + end) + :jestExpect() + act(function() + return store._profilerStore:stopProfiling() + end) + local allCommitData = {} + local function Validator(ref) + local commitIndex, rootID = ref.commitIndex, ref.rootID + local commitData = store._profilerStore:getCommitData( + rootID, + commitIndex + ) + table.insert(allCommitData, commitData) --[[ ROBLOX CHECK: check if 'allCommitData' is an Array ]] + jestExpect(commitData).toMatchSnapshot( + "CommitDetails with filtered self durations" + ) + return nil + end + local rootID = store.roots[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + do + local function _loop(commitIndex) + act(function() + TestRenderer:create( + React.createElement( + Validator, + { commitIndex = commitIndex, rootID = rootID } + ) + ) + end) + end + local commitIndex = 0 + while + commitIndex + < 2 --[[ ROBLOX CHECK: operator '<' works only if either both arguments are strings or both are a number ]] + do + _loop(commitIndex) + commitIndex += 1 + end + end + jestExpect(allCommitData).toHaveLength(2) + done() + end) + end) + --]=] + it("should collect data for each rendered fiber", function() + local MemoizedChild, Child + local function Parent(props) + Scheduler.unstable_advanceTime(10) + + local count = props.count + local children = table.create(count) :: any + for index = 0, count - 1 do + children[index + 1] = + React.createElement(Child, { key = index, duration = index }) + end + + return React.createElement( + React.Fragment, + nil, + children, + React.createElement(MemoizedChild, { key = -1, duration = 1 }) + ) + end + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + MemoizedChild = React.memo(Child) + + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return container:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + return container:render(React.createElement(Parent, { count = 2 })) + end) + act(function() + return container:render(React.createElement(Parent, { count = 3 })) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + + local allFiberCommits = {} + local function Validator(ref) + local fiberID, previousFiberCommits, rootID = + ref.fiberID, ref.previousFiberCommits, ref.rootID + local fiberCommits = store._profilerStore:profilingCache():getFiberCommits({ + fiberID = fiberID, + rootID = rootID, + }) + if previousFiberCommits ~= nil then + jestExpect(fiberCommits).toEqual(previousFiberCommits) + else + table.insert(allFiberCommits, fiberCommits) + jestExpect(fiberCommits).toMatchSnapshot( + string.format("FiberCommits: element %d", fiberID) + ) + end + end + local rootID = store:getRoots()[1] + + for index = 0, store:getNumElements() - 1 do + local fiberID = store:getElementIDAtIndex(index) + if fiberID == nil then + error( + string.format( + "Unexpected null ID for element at index %s", + tostring(index) + ) + ) + end + + Validator({ + fiberID = fiberID, + previousFiberCommits = nil, + rootID = rootID, + }) + end + + jestExpect(allFiberCommits).toHaveLength(store:getNumElements()) + utils.exportImportHelper(bridge, store) + + --[=[ ROBLOX FIXME: 0-based indexing gets ruined by deserializing + for index = 0, store:getNumElements() - 1 do + local fiberID = store:getElementIDAtIndex(index) + if fiberID == nil then + error( + string.format( + "Unexpected null ID for element at index %s", + tostring(index) + ) + ) + end + + Validator({ + fiberID = fiberID, + previousFiberCommits = allFiberCommits[index], + rootID = rootID, + }) + end + --]=] + end) + it("should report every traced interaction", function() + local MemoizedChild, Child + local function Parent(props) + Scheduler.unstable_advanceTime(10) + + local count = props.count + local children = table.create(count) :: any + for index = 0, count - 1 do + children[index + 1] = + React.createElement(Child, { key = index, duration = index }) + end + + return React.createElement( + React.Fragment, + nil, + children, + React.createElement(MemoizedChild, { key = -1, duration = 1 }) + ) + end + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + MemoizedChild = React.memo(Child) + + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return SchedulerTracing.unstable_trace( + "mount: one child", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent, { count = 1 })) + end + ) + end) + act(function() + return SchedulerTracing.unstable_trace( + "update: two children", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent, { count = 2 })) + end + ) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + local interactions = nil + local function Validator(ref) + local previousInteractions, rootID = ref.previousInteractions, ref.rootID + interactions = + store._profilerStore:profilingCache():getInteractionsChartData({ + rootID = rootID, + }).interactions + -- ROBLOX FIXME: interactions[0] supposed to have __count=1, but it's 0 once it gets to the ProfilerStore. it's correct in WorkLoop and Tracing. + if previousInteractions ~= nil then + jestExpect(interactions).toEqual(previousInteractions) + else + jestExpect(interactions).toMatchSnapshot("Interactions") + end + return nil + end + local rootID = store:getRoots()[1] + Validator({ previousInteractions = nil, rootID = rootID }) + + jestExpect(interactions).never.toBeNull() + utils.exportImportHelper(bridge, store) + + Validator({ previousInteractions = interactions, rootID = rootID }) + end) + it("should handle unexpectedly shallow suspense trees", function() + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return store._profilerStore:startProfiling() + end) + act(function() + return container:render(React.createElement(React.Suspense, nil)) + end) + act(function() + return store._profilerStore:stopProfiling() + end) + + local rootID = store:getRoots()[1] + local profilingDataForRoot = store._profilerStore:getDataForRoot(rootID) + jestExpect(profilingDataForRoot).toMatchSnapshot("Empty Suspense node") + end) + + -- ROBLOX TODO: needs a textContent helper for ReactRoblox renderers + -- See https://github.com/facebook/react/issues/18831 + xit("should not crash during route transitions with Suspense", function() + local Router, Switch, Route, About, Home, Link + local RouterContext = React.createContext(nil) + local function App() + return React.createElement( + Router, + nil, + React.createElement( + Switch, + nil, + React.createElement( + Route, + { path = "/" }, + React.createElement(Home, nil) + ), + React.createElement( + Route, + { path = "/about" }, + React.createElement(About, nil) + ) + ) + ) + end + function Home() + return React.createElement( + React.Suspense, + nil, + React.createElement( + Link, + { path = "/about" }, + React.createElement("TextLabel", { Text = "Home" }) + ) + ) + end + function About() + return React.createElement("TextLabel", { Text = "About" }) + end + + -- Mimics https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Router.js + function Router(ref) + local children = ref.children + local path, setPath = React.useState("/") + return React.createElement( + RouterContext.Provider, + { value = { path = path, setPath = setPath } }, + children + ) + end + + -- Mimics https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Switch.js + function Switch(ref) + local children = ref.children + return React.createElement(RouterContext.Consumer, nil, function(context) + local element = nil + React.Children.forEach(children, function(child: any) + if context.path == child.props.path then + element = child.props.children + end + end) + return if element then React.cloneElement(element) else nil + end) + end + + -- Mimics https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js + function Route(ref) + return nil + end + local linkRef = React.createRef() + + -- Mimics https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js + function Link(ref) + local children, path = ref.children, ref.path + return React.createElement(RouterContext.Consumer, nil, function(context) + return React.createElement("TextButton", { + ref = linkRef, + [ReactRoblox.Event.Activated] = function() + return context:setPath(path) + end, + }, children) + end) + end + -- ROBLOX TODO: emulate this and uncomment the expect + -- local Simulate = require_("react-dom/test-utils").Simulate + local container = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return container:render(React.createElement(App)) + end) + jestExpect(container.textContent).toBe("Home") + act(function() + return store._profilerStore:startProfiling() + end) + -- act(function() + -- return Simulate:click(linkRef.current) + -- end) + act(function() + return store._profilerStore:stopProfiling() + end) + jestExpect(container.textContent).toBe("About") + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/profilingCharts.spec.lua b/modules/react-devtools-shared/src/__tests__/profilingCharts.spec.lua new file mode 100644 index 00000000..bc6dee2f --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/profilingCharts.spec.lua @@ -0,0 +1,289 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/profilingCharts-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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local xdescribe = JestGlobals.xdescribe +local beforeEach = JestGlobals.beforeEach +local afterEach = JestGlobals.afterEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local jestExpect = JestGlobals.expect + +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store + +xdescribe("profiling charts", function() + local React + local ReactRoblox + local Scheduler + local SchedulerTracing + -- local TestRenderer + local store: Store + local utils + beforeEach(function() + ReactGlobals.__PROFILE__ = true + utils = require(script.Parent.utils) + utils.beforeEachProfiling() + + store = (ReactGlobals :: any).store + store:setCollapseNodesByDefault(false) + store:setRecordChangeDescriptions(true) + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + Scheduler = require(Packages.Dev.Scheduler) + SchedulerTracing = Scheduler.tracing + end) + afterEach(function() + ReactGlobals.__PROFILE__ = nil + end) + describe("flamegraph chart", function() + it("should contain valid data", function() + local Child + local function Parent(_: {}) + Scheduler.unstable_advanceTime(10) + return React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "first", duration = 3 }), + React.createElement(Child, { key = "second", duration = 2 }), + React.createElement(Child, { key = "third", duration = 0 }) + ) + end + + -- Memoize children to verify that chart doesn't include in the update. + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + -- ROBLOX FIXME Luau: greediness means first type assignment wins, needs DCR + Child = React.memo(Child) :: any + + local container = ReactRoblox.createRoot(Instance.new("Frame")) + utils.act(function() + return store._profilerStore:startProfiling() + end) + utils.act(function() + return SchedulerTracing.unstable_trace( + "mount", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + utils.act(function() + return SchedulerTracing.unstable_trace( + "update", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + utils.act(function() + return store._profilerStore:stopProfiling() + end) + local renderFinished = false + local function Validator(ref) + local commitIndex, rootID = ref.commitIndex, ref.rootID + local commitTree = store._profilerStore:profilingCache():getCommitTree({ + commitIndex = commitIndex, + rootID = rootID, + }) + local chartData = + store._profilerStore:profilingCache():getFlamegraphChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + rootID = rootID, + }) + jestExpect(commitTree).toMatchSnapshot( + ("%s: CommitTree"):format(tostring(commitIndex - 1)) + ) + jestExpect(chartData).toMatchSnapshot( + ("%s: FlamegraphChartData"):format(tostring(commitIndex - 1)) + ) + renderFinished = true + return nil + end + local rootID = store:getRoots()[1] + + for commitIndex = 1, 2 do + renderFinished = false + Validator({ + commitIndex = commitIndex, + rootID = rootID, + }) + end + jestExpect(renderFinished).toBe(true) + end) + end) + + xdescribe("ranked chart", function() + -- ROBLOX FIXME: the "type" of the children in our snap is 5, but in upstream it's 8, every matches except... + it("should contain valid data", function() + local Child + local function Parent(_: {}) + Scheduler.unstable_advanceTime(10) + return React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "first", duration = 3 }), + React.createElement(Child, { key = "second", duration = 2 }), + React.createElement(Child, { key = "third", duration = 0 }) + ) + end + + -- Memoize children to verify that chart doesn't include in the update. + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + -- ROBLOX FIXME Luau: greediness means first type assignment wins, needs DCR + Child = React.memo(Child) :: any + local container = ReactRoblox.createRoot(Instance.new("Frame")) + utils.act(function() + return store._profilerStore:startProfiling() + end) + + utils.act(function() + return SchedulerTracing.unstable_trace( + "mount", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + + utils.act(function() + return SchedulerTracing.unstable_trace( + "update", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + + utils.act(function() + return store._profilerStore:stopProfiling() + end) + local renderFinished = false + local function Validator(ref) + local commitIndex, rootID = ref.commitIndex, ref.rootID + local commitTree = store._profilerStore:profilingCache():getCommitTree({ + commitIndex = commitIndex, + rootID = rootID, + }) + local chartData = + store._profilerStore:profilingCache():getRankedChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + rootID = rootID, + }) + jestExpect(commitTree).toMatchSnapshot( + ("%s: CommitTree"):format(tostring(commitIndex - 1)) + ) + jestExpect(chartData).toMatchSnapshot( + ("%s: RankedChartData"):format(tostring(commitIndex - 1)) + ) + renderFinished = true + return nil + end + local rootID = store:getRoots()[1] + + for commitIndex = 1, 2 do + renderFinished = false + Validator({ + commitIndex = commitIndex, + rootID = rootID, + }) + jestExpect(renderFinished).toBe(true) + end + jestExpect(renderFinished).toBe(true) + end) + end) + xdescribe("interactions", function() + it("should contain valid data", function() + local Child + local function Parent(_: {}) + Scheduler.unstable_advanceTime(10) + return React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "first", duration = 3 }), + React.createElement(Child, { key = "second", duration = 2 }), + React.createElement(Child, { key = "third", duration = 0 }) + ) + end + + -- Memoize children to verify that chart doesn't include in the update. + function Child(ref) + local duration = ref.duration + Scheduler.unstable_advanceTime(duration) + return nil + end + -- ROBLOX FIXME Luau: greediness means first type assignment wins, needs DCR + Child = React.memo(Child) :: any + local container = ReactRoblox.createRoot(Instance.new("Frame")) + utils.act(function() + return store._profilerStore:startProfiling() + end) + utils.act(function() + return SchedulerTracing.unstable_trace( + "mount", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + + utils.act(function() + return SchedulerTracing.unstable_trace( + "update", + Scheduler.unstable_now(), + function() + return container:render(React.createElement(Parent)) + end + ) + end) + utils.act(function() + return store._profilerStore:stopProfiling() + end) + local renderFinished = false + local function Validator(ref) + local _commitIndex, rootID = ref.commitIndex, ref.rootID + local chartData = + store._profilerStore:profilingCache():getInteractionsChartData({ + rootID = rootID, + }) + jestExpect(chartData).toMatchSnapshot("Interactions") + renderFinished = true + return nil + end + local rootID = store:getRoots()[1] + for commitIndex = 1, 2 do + renderFinished = false + Validator({ + commitIndex = commitIndex, + rootID = rootID, + }) + jestExpect(renderFinished).toBe(true) + end + jestExpect(renderFinished).toBe(true) + end) + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder.spec.lua b/modules/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder.spec.lua new file mode 100644 index 00000000..4547bfab --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder.spec.lua @@ -0,0 +1,100 @@ +--!nonstrict +-- ROBLOX FIXME: Type '({| count: number |}) -> {ReactElement<{ key: number }, a>}' could not be converted into '((React_ElementProps?, any) -> (Array<(Array<*CYCLE*> | React_Element | boolean | number | string | t1)?> | React_Element | boolean | number | string | t1)?) | LazyComponent?> | ReactContext | ReactProviderType | React_ComponentType?> | string where t1 = {| [string]: (boolean | number | string | t1 | {(*CYCLE* | boolean | number | string | t1 | {| key: (number | string)?, props: {| __source: {| fileName: string, lineNumber: number |}?, children: any?, key: (number | string)?, ref: (((any?) -> ()) | {| current: any? |})? |}?, ref: any, type: any |})?}... *TRUNCATED*'; none of the union options are compatible +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/profilingCOmmitTreeBuilder-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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local xdescribe = JestGlobals.xdescribe +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it +local jestExpect = JestGlobals.expect + +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store + +local global = ReactGlobals :: any + +xdescribe("commit tree", function() + local React + local ReactRoblox + local Scheduler + local store: Store + local utils + local act + + beforeEach(function() + utils = require(script.Parent.utils) + act = utils.act + utils.beforeEachProfiling() + + store = global.store + store:setCollapseNodesByDefault(false) + store:setRecordChangeDescriptions(true) + + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + Scheduler = require(Packages.Dev.Scheduler) + end) + it("should be able to rebuild the store tree for each commit", function() + local function Child() + Scheduler.unstable_advanceTime(2) + return nil + end + local ChildMemo = React.memo(Child) + + local function Parent(props: { count: number }) + local count = props.count + Scheduler.unstable_advanceTime(10) + + local Children = table.create(count) + for i = 1, count do + Children[i] = React.createElement(ChildMemo, { key = i - 1 }) + end + + return Children + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + store._profilerStore:startProfiling() + end) + act(function() + root:render(React.createElement(Parent, { count = 1 })) + end) + act(function() + root:render(React.createElement(Parent, { count = 3 })) + end) + act(function() + root:render(React.createElement(Parent, { count = 2 })) + end) + act(function() + root:render(React.createElement(Parent, { count = 0 })) + end) + act(function() + store._profilerStore:stopProfiling() + end) + + local rootID = store:getRoots()[1] + for commitIndex = 1, 4 do + local profilingCache = store._profilerStore:profilingCache() + local commitTree = profilingCache:getCommitTree({ + commitIndex = commitIndex, + rootID = rootID, + }) + + jestExpect(commitTree).toMatchSnapshot( + string.format("%d: CommitTree", commitIndex - 1) + ) + end + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/profilingUtils.spec.lua b/modules/react-devtools-shared/src/__tests__/profilingUtils.spec.lua new file mode 100644 index 00000000..a369ee16 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/profilingUtils.spec.lua @@ -0,0 +1,31 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/profilingUtils-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 +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach + +describe("profiling utils", function() + local utils + beforeEach(function() + utils = require(script.Parent.Parent.devtools.views.Profiler.utils) + end) + it("should throw if importing older/unsupported data", function() + jestExpect(function() + return utils.prepareProfilingDataFrontendFromExport({ + version = 0, + dataForRoots = {}, + }) + end).toThrow('Unsupported profiler export version "0"') + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/setupTests.lua b/modules/react-devtools-shared/src/__tests__/setupTests.lua new file mode 100644 index 00000000..aa122ac9 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/setupTests.lua @@ -0,0 +1,97 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/setupTests.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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local beforeAll = JestGlobals.beforeAll +local jest = require(Packages.Dev.JestGlobals).jest +local afterEach = JestGlobals.afterEach + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array + +local global = ReactGlobals :: any + +type Array = { [number]: T } + +beforeAll(function() + jestExpect.extend({ + toErrorDev = require(Packages.Parent.jest.matchers.toErrorDev), + toWarnDev = require(Packages.Parent.jest.matchers.toWarnDev), + }) +end) + +beforeEach(function() + -- Fake timers let us flush Bridge operations between setup and assertions. + jest.useFakeTimers() + + -- These files should be required (and re-required) before each test, + -- rather than imported at the head of the module. + -- That's because we reset modules between tests, + -- which disconnects the DevTool's cache from the current dispatcher ref. + local Agent = require(script.Parent.Parent.backend.agent) + local initBackend = require(script.Parent.Parent.backend).initBackend + local Bridge = require(script.Parent.Parent.bridge) + local Store = require(script.Parent.Parent.devtools.store) + local installHook = require(script.Parent.Parent.hook).installHook + local utils = require(script.Parent.Parent.utils) + local getDefaultComponentFilters = utils.getDefaultComponentFilters + local saveComponentFilters = utils.saveComponentFilters + + -- Initialize filters to a known good state. + saveComponentFilters(getDefaultComponentFilters()) + global.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getDefaultComponentFilters() + + installHook(global) + + local bridgeListeners = {} + local bridge = Bridge.new({ + listen = function(callback) + table.insert(bridgeListeners, callback) + return function() + local index = Array.indexOf(bridgeListeners, callback) + if index >= 0 then + Array.splice(bridgeListeners, index, 1) + end + end + end, + send = function(event: string, payload: any, transferable: Array?) + for _, callback in bridgeListeners do + callback({ event = event, payload = payload }) + end + end, + }) + + local agent = Agent.new(bridge) + local hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__ + + initBackend(hook, agent, global) + + local store = Store.new(bridge) + + global.agent = agent + global.bridge = bridge + global.store = store + + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = true +end) + +afterEach(function() + global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = nil + + -- It's important to reset modules between test runs; + -- Without this, ReactDOM won't re-inject itself into the new hook. + -- It's also important to reset after tests, rather than before, + -- so that we don't disconnect the ReactCurrentDispatcher ref. + jest.resetModules() +end) diff --git a/modules/react-devtools-shared/src/__tests__/store.spec.lua b/modules/react-devtools-shared/src/__tests__/store.spec.lua new file mode 100644 index 00000000..3f24ec57 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/store.spec.lua @@ -0,0 +1,1613 @@ +--!nonstrict +-- ROBLOX FIXME: TypeError: Type '({+ count: number +}) -> Array>' could not be converted into '((React_ElementProps?, any) -> (Array<(Array<*CYCLE*> | React_Element | boolean | number | string | t1)?> | React_Element | boolean | number | string | t1)?) | LazyComponent?> | ReactContext | ReactProviderType | React_ComponentType?> | string where t1 = {| [string]: (boolean | number | string | t1 | {(*CYCLE* | boolean | number | string | t1 | {| key: (number | string)?, props: {| __source: {| fileName: string, lineNumber: number |}?, children: any?, key: (number | string)?, ref: (((any?) -> ()) | {| current: any? |})? |}?, ref: any, type: any |})?}... *TRUNCATED*'; none of the union options are compatible +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/store-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. +]] + +local fill = function(count, value) + local output = {} + for i = 1, count do + output[i] = value + end + return output +end + +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jest = JestGlobals.jest +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local it = JestGlobals.it +local xit = JestGlobals.xit +local beforeEach = JestGlobals.beforeEach + +local Promise = require(Packages.Dev.Promise) +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Object = LuauPolyfill.Object + +local global = ReactGlobals :: any +type Array = { [number]: T } + +local ReactRoblox +local React +-- ROBLOX deviation: bring in the Agent type +local Agent = require(script.Parent.Parent.backend.agent) +type Agent = Agent.Agent + +describe("Store", function() + local agent: Agent + local act + local getRendererID + local store + local devtoolsUtils + local constants + + beforeEach(function(context) + agent = global.agent + store = global.store + + React = require(Packages.React) + jest.resetModules() + ReactRoblox = require(Packages.ReactRoblox) + devtoolsUtils = require(script.Parent.Parent.devtools.utils) + constants = require(script.Parent.Parent.constants) + + jest.useFakeTimers() + + local utils = require(script.Parent.utils) + act = utils.act + getRendererID = utils.getRendererID + end) + + it("should not allow a root node to be collapsed", function() + local function Component() + return React.createElement("TextLabel", { Text = "Hi" }) + end + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + root:render(React.createElement(Component, { + count = 4, + })) + end) + + jestExpect(devtoolsUtils.printStore(store)).toBe([[ +[root] + ]]) + jestExpect(store:getRoots()).toHaveLength(1) + + local rootID = store:getRoots()[1] + + jestExpect(function() + return store:toggleIsCollapsed(rootID, true) + end).toThrow("Root nodes cannot be collapsed") + end) + it("should properly handle a root with no visible nodes", function() + local function Root(props) + return props.children + end + -- ROBLOX deviation: use root:render instead of ReactDOM.render + local root = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + root:render(React.createElement(Root, nil, {})) + end) + + jestExpect(devtoolsUtils.printStore(store)).toBe([[ +[root] + ]]) + act(function() + root:render(React.createElement("Frame")) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("[root]") + end) + describe("collapseNodesByDefault:false", function() + beforeEach(function() + store:setCollapseNodesByDefault(false) + end) + it("should support mount and update operations", function() + constants.__DEBUG__ = true + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + -- ROBLOX deviation: switched ordering for variable definition order + local function Grandparent(props) + local count = props.count + return React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { count = count }), + React.createElement(Parent, { count = count }) + ) + end + + -- ROBLOX deviation: Use Frame instance rather than DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return root:render(React.createElement(Grandparent, { count = 4 })) + end) + -- ROBLOX deviation: we must key children implicitly due to the Roblox DOM being unordered + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + + + ▾ + + + + ]]) + act(function() + return root:render(React.createElement(Grandparent, { count = 2 })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + + ]]) + act(function() + return root:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("") + end) + it("should support mount and update operations for multiple roots", function() + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + + local rootA = ReactRoblox.createBlockingRoot(Instance.new("Frame")) + local rootB = ReactRoblox.createBlockingRoot(Instance.new("Frame")) + + act(function() + rootA:render(React.createElement(Parent, { + key = "A", + count = 3, + })) + rootB:render(React.createElement(Parent, { + key = "B", + count = 2, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + + +[root] + ▾ + + ]]) + act(function() + rootA:render(React.createElement(Parent, { + key = "A", + count = 4, + })) + rootB:render(React.createElement(Parent, { + key = "B", + count = 1, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + + + +[root] + ▾ + ]]) + act(function() + return rootB:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + + + ]]) + act(function() + return rootA:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("") + end) + it("should filter DOM nodes from the store tree", function() + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent() + return React.createElement("Frame", nil, React.createElement(Child)) + end + local function Grandparent() + return React.createElement( + "Frame", + nil, + React.createElement("Frame", nil, React.createElement(Parent)), + React.createElement(Parent) + ) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Grandparent, { count = 4 })) + end) + -- ROBLOX deviation: we must key children implicitly due to the Roblox DOM being unordered + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + ▾ + ]]) + end) + -- ROBLOX FIXME: Unskip when unhideInstance is implemented in react-roblox renderer + xit("should display Suspense nodes properly in various states", function() + local function Loading() + return React.createElement("TextLabel", { Text = "Loading..." }) + end + local function SuspendingComponent() + error(Promise.new(function() end)) + end + local function Component() + return React.createElement("TextLabel", { Text = "Hello" }) + end + local function Wrapper(props) + local shouldSuspense = props.shouldSuspense + + return React.createElement( + React.Fragment, + nil, + React.createElement(Component, { + key = "Outside", + }), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading), + }, + (function() + if shouldSuspense then + return React.createElement(SuspendingComponent) + end + + return React.createElement(Component, { + key = "Inside", + }) + end)() + ) + ) + end + -- ROBLOX deviation: Use Frame instance rather than DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return root:render( + React.createElement(Wrapper, { shouldSuspense = true }) + ) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + act(function() + -- ROBLOX FIXME: unsupported unhideInstance is triggered by this line + root:render(React.createElement(Wrapper, { shouldSuspense = false })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + end) + -- ROBLOX FIXME: store:886: Cannot remove node nil because no matching node was found in the Store. + xit("should support nested Suspense nodes", function() + local function Component() + return nil + end + local function Loading() + return React.createElement("TextLabel", { Text = "Loading..." }) + end + local Never = function() + error(Promise.new(function() end)) + end + local function Wrapper(props) + local suspendFirst = props.suspendFirst or false + local suspendSecond = props.suspendSecond or false + local suspendParent = props.suspendParent or false + + return React.createElement( + React.Fragment, + nil, + React.createElement(Component, { + key = "Outside", + }), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading, { + key = "Parent Fallback", + }), + }, + React.createElement(Component, { + key = "Unrelated at Start", + }), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading, { + key = "Suspense 1 Fallback", + }), + }, + (function() + if suspendFirst then + return React.createElement(Never) + end + + return React.createElement(Component, { + key = "Suspense 1 Content", + }) + end)() + ), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading, { + key = "Suspense 2 Fallback", + }), + }, + (function() + if suspendSecond then + return React.createElement(Never) + end + + return React.createElement(Component, { + key = "Suspense 2 Content", + }) + end)() + ), + React.createElement(React.Suspense, { + fallback = React.createElement(Loading, { + key = "Suspense 3 Fallback", + }), + }, React.createElement(Never)), + (function() + if suspendParent then + return React.createElement(Never) + end + return nil + end)(), + React.createElement(Component, { + key = "Unrelated at End", + }) + ) + ) + end + -- ROBLOX deviation: Use createRoot instead of DOM + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = false, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + -- ROBLOX FIXME: error triggered in this act: store:886: Cannot remove node nil because no matching node was found in the Store. + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = true, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = false, + suspendSecond = true, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = true, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = true, + suspendFirst = true, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = true, + suspendSecond = true, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = false, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + + local rendererID = getRendererID() + + act(function() + return agent:overrideSuspense({ + id = store:getElementIDAtIndex(4), + rendererID = rendererID, + forceFallback = true, + }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return agent:overrideSuspense({ + id = store:getElementIDAtIndex(2), + rendererID = rendererID, + forceFallback = true, + }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = true, + suspendSecond = true, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + act(function() + return agent:overrideSuspense({ + id = store:getElementIDAtIndex(2), + rendererID = rendererID, + forceFallback = false, + }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return agent:overrideSuspense({ + id = store:getElementIDAtIndex(4), + rendererID = rendererID, + forceFallback = false, + }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Wrapper, { + suspendParent = false, + suspendFirst = false, + suspendSecond = false, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + ]]) + end) + -- ROBLOX TODO: re-enable when unstable_SuspenseList is implemented + xit("should display a partially rendered SuspenseList", function() + local function Loading() + return React.createElement("TextLabel", { Text = "Loading..." }) + end + local function SuspendingComponent() + error(Promise.new(function() end)) + end + local function Component() + return React.createElement("TextLabel", { Text = "Hello" }) + end + local function Wrapper(_ref7) + local shouldSuspense = _ref7.shouldSuspense + + return React.createElement( + React.Fragment, + nil, + React.createElement( + React.unstable_SuspenseList, + { + revealOrder = "forwards", + tail = "collapsed", + }, + React.createElement(Component, { + key = "A", + }), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading), + }, + (function() + if shouldSuspense then + return React.createElement(SuspendingComponent) + end + + return React.createElement(Component, { + key = "B", + }) + end)() + ), + React.createElement(Component, { + key = "C", + }) + ) + ) + end + -- ROBLOX deviation: Use Frame instance rather than DOM element + local container = Instance.new("Frame") + local root = ReactRoblox.unstable_createRoot(container) + + act(function() + root:render(React.createElement(Wrapper, { shouldSuspense = true })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("1: loading") + act(function() + root:render(React.createElement(Wrapper, { shouldSuspense = false })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("2: resolved") + end) + it("should support collapsing parts of the tree", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + local function Grandparent(props) + local count = props.count + + return React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { count = count }), + React.createElement(Parent, { count = count }) + ) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Grandparent, { count = 2 })) + end) + -- ROBLOX deviation: we must key children implicitly due to the Roblox DOM being unordered + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + + ]]) + + local grandparentID = store:getElementIDAtIndex(0) + local parentOneID = store:getElementIDAtIndex(1) + local parentTwoID = store:getElementIDAtIndex(4) + + act(function() + return store:toggleIsCollapsed(parentOneID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▾ + + ]]) + act(function() + return store:toggleIsCollapsed(parentTwoID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▸ ]]) + act(function() + return store:toggleIsCollapsed(parentOneID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▸ ]]) + act(function() + return store:toggleIsCollapsed(grandparentID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(grandparentID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▸ ]]) + end) + it("should support reordering of children", function() + local function Root(props) + return props.children + end + local function Component() + return nil + end + local function Foo() + return { + React.createElement(Component, { + key = "0", + }), + } + end + local function Bar() + return { + React.createElement(Component, { + key = "0", + }), + React.createElement(Component, { + key = "1", + }), + } + end + local foo = React.createElement(Foo, { + key = "foo", + }) + local bar = React.createElement(Bar, { + key = "bar", + }) + -- ROBLOX deviation: Use createRoot instead of DOM + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Root, nil, { foo, bar })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + ▾ + + ]]) + act(function() + return root:render(React.createElement(Root, nil, { bar, foo })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + ]]) + end) + end) + describe("collapseNodesByDefault:true", function() + beforeEach(function() + store:setCollapseNodesByDefault(true) + end) + it("should support mount and update operations", function() + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + -- ROBLOX deviation: Use Frame instance rather than DOM element + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render( + React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { count = 1 }), + React.createElement(Parent, { count = 3 }) + ) + ) + end) + -- ROBLOX deviation: we must key children implicitly due to the Roblox DOM being unordered + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ + ▸ ]]) + act(function() + return root:render( + React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { count = 2 }), + React.createElement(Parent, { count = 1 }) + ) + ) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ + ▸ ]]) + act(function() + return root:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("") + end) + -- ROBLOX TODO: Tests using multiple roots don't currently work, because rootA and rootB + -- do not use the SyncLane which is checked in ReactFiberWorkLoop:680 upstream which proceeds + -- to call handleCommitFiberRoot in devtools renderer.lua for the two separate roots. + -- The upstream tests use a blocking root, which has a different path through the React + -- codebase. Using concurrent roots, rootB is placed in lane 512 (rather than 1) + -- Using blocking roots, rootB is placed in lane 2 (rather than 1) so is not registered. + it("should support mount and update operations for multiple roots", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + local rootA = ReactRoblox.createRoot(Instance.new("Frame")) + local rootB = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + rootA:render(React.createElement(Parent, { + key = "A", + count = 3, + })) + rootB:render(React.createElement(Parent, { + key = "B", + count = 2, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ +[root] + ▸ ]]) + act(function() + rootA:render(React.createElement(Parent, { + key = "A", + count = 4, + })) + rootB:render(React.createElement(Parent, { + key = "B", + count = 1, + })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ +[root] + ▸ ]]) + act(function() + return rootB:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return rootA:render(nil) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe("") + end) + it("should filter DOM nodes from the store tree", function() + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent() + return React.createElement("Frame", nil, React.createElement(Child)) + end + local function Grandparent() + return React.createElement( + "Frame", + nil, + React.createElement("Frame", nil, React.createElement(Parent)), + React.createElement(Parent) + ) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Grandparent, { count = 4 })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), false) + end) + -- ROBLOX deviation: we must key children implicitly due to the Roblox DOM being unordered + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(1), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + ▸ ]]) + end) + -- ROBLOX FIXME: unhideInstance is unimplemented + xit("should display Suspense nodes properly in various states", function() + local function Loading() + return React.createElement("TextLabel", { Text = "Loading..." }) + end + local function SuspendingComponent() + error(Promise.new(function() end)) + end + local function Component() + return React.createElement("TextLabel", { Text = "Hello" }) + end + local function Wrapper(props) + local shouldSuspense = props.shouldSuspense + + return React.createElement( + React.Fragment, + nil, + React.createElement(Component, { + key = "Outside", + }), + React.createElement( + React.Suspense, + { + fallback = React.createElement(Loading), + }, + (function() + if shouldSuspense then + return React.createElement(SuspendingComponent) + end + + return React.createElement(Component, { + key = "Inside", + }) + end)() + ) + ) + end + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render( + React.createElement(Wrapper, { shouldSuspense = true }) + ) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), false) + end) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(2), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + act(function() + root:render(React.createElement(Wrapper, { shouldSuspense = false })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + + ▾ + ]]) + end) + it("should support expanding parts of the tree", function() + local function Child() + return React.createElement("TextLabel", { Text = "Hi!" }) + end + local function Parent(props) + local count = props.count + return Array.map(fill(count, true), function(_, index) + return React.createElement(Child, { key = index }) + end) + end + local function Grandparent(props) + local count = props.count + return React.createElement( + React.Fragment, + nil, + React.createElement(Parent, { count = count }), + React.createElement(Parent, { count = count }) + ) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Grandparent, { count = 2 })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + + local grandparentID = store:getElementIDAtIndex(0) + + act(function() + return store:toggleIsCollapsed(grandparentID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▸ ]]) + + local parentOneID = store:getElementIDAtIndex(1) + local parentTwoID = store:getElementIDAtIndex(2) + + act(function() + return store:toggleIsCollapsed(parentOneID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▸ ]]) + act(function() + return store:toggleIsCollapsed(parentTwoID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + + ]]) + act(function() + return store:toggleIsCollapsed(parentOneID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▾ + + ]]) + act(function() + return store:toggleIsCollapsed(parentTwoID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▸ ]]) + act(function() + return store:toggleIsCollapsed(grandparentID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + end) + -- ROBLOX TODO: Support both ReactTestRenderer and ReactRoblox renderer as both renderers are needed + -- in this test to getIDForNode to return correctly, as findFiberByHostInstance is only implemented + -- in the ReactRoblox renderer. The next step to do this is implement ReactRoblox.act and uncomment + -- actDOM in __tests__/utils.lua + xit("should support expanding deep parts of the tree", function() + local function Nested(props) + local depth, forwardedRef = props.depth, props.forwardedRef + + return (function() + if depth > 0 then + return React.createElement(Nested, { + depth = depth - 1, + forwardedRef = forwardedRef, + }) + end + + return React.createElement("Frame", { ref = forwardedRef }) + end)() + end + local function Wrapper(props) + local forwardedRef = props.forwardedRef + + return React.createElement(Nested, { + depth = 3, + forwardedRef = forwardedRef, + }) + end + local ref = React.createRef() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Wrapper, { forwardedRef = ref })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + + local deepestedNodeID = agent:getIDForNode(ref.current) + + -- ROBLOX FIXME: next line fails with: store:432: invalid argument #2 to 'format' (string expected, got nil) + act(function() + return store:toggleIsCollapsed(deepestedNodeID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ▾ + ▾ + ]]) + + local rootID = store:getElementIDAtIndex(0) + + act(function() + return store:toggleIsCollapsed(rootID, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(rootID, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ▾ + ▾ + ]]) + + local id = store:getElementIDAtIndex(1) + + act(function() + return store:toggleIsCollapsed(id, true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ ]]) + act(function() + return store:toggleIsCollapsed(id, false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ▾ + ▾ + ]]) + end) + it("should support reordering of children", function() + local function Root(props) + return props.children + end + local function Component() + return nil + end + local function Foo() + return { + React.createElement(Component, { + key = "0", + }), + } + end + local function Bar() + return { + React.createElement(Component, { + key = "0", + }), + React.createElement(Component, { + key = "1", + }), + } + end + local foo = React.createElement(Foo, { + key = "foo", + }) + local bar = React.createElement(Bar, { + key = "bar", + }) + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Root, nil, { foo, bar })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return root:render(React.createElement(Root, nil, { bar, foo })) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▸ + ▸ ]]) + act(function() + store:toggleIsCollapsed(store:getElementIDAtIndex(2), false) + store:toggleIsCollapsed(store:getElementIDAtIndex(1), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + + + ▾ + ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), true) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + end) + -- ROBLOX FIXME: fails with Parent instead of Fallback, possibly bug in agent:overrideSuspense + xit("should not add new nodes when suspense is toggled", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Fallback() + return nil + end + local function Child() + return nil + end + local function Parent() + return React.createElement(Child) + end + local function SuspenseTree() + return React.createElement(React.Suspense, { + fallback = React.createElement(Fallback, nil, "Loading outer"), + }, React.createElement(Parent)) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(SuspenseTree)) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▸ ]]) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(0), false) + end) + act(function() + return store:toggleIsCollapsed(store:getElementIDAtIndex(1), false) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ▸ ]]) + + local rendererID = getRendererID() + local suspenseID = store:getElementIDAtIndex(1) + + act(function() + return agent:overrideSuspense({ + id = suspenseID, + rendererID = rendererID, + forceFallback = true, + }) + end) + -- ROBLOX FIXME: fails on the next line, gets Parent instead of Fallback + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ]]) + act(function() + return agent:overrideSuspense({ + id = suspenseID, + rendererID = rendererID, + forceFallback = false, + }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ▾ + ▾ + ▸ ]]) + end) + end) + describe("getIndexOfElementID", function() + beforeEach(function() + store:setCollapseNodesByDefault(false) + end) + it("should support a single root with a single child", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return nil + end + local function Parent() + return React.createElement(Child) + end + local function Grandparent() + return React.createElement( + React.Fragment, + nil, + React.createElement(Parent), + React.createElement(Parent) + ) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render(React.createElement(Grandparent)) + end) + + for i = 0, store:getNumElements() - 1 do + jestExpect(store:getIndexOfElementID(store:getElementIDAtIndex(i))).toBe( + i + ) + end + end) + it("should support multiple roots with one children each", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return nil + end + local function Parent() + return React.createElement(Child) + end + local function Grandparent() + return React.createElement(Parent) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + root:render(React.createElement(Grandparent)) + end) + + for i = 0, store:getNumElements() - 1 do + jestExpect(store:getIndexOfElementID(store:getElementIDAtIndex(i))).toBe( + i + ) + end + end) + it("should support a single root with multiple top level children", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return nil + end + local function Parent() + return React.createElement(Child) + end + local function Grandparent() + return React.createElement(Parent) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + return root:render( + React.createElement( + React.Fragment, + nil, + React.createElement(Grandparent), + React.createElement(Grandparent) + ) + ) + end) + + for i = 0, store:getNumElements() - 1 do + jestExpect(store:getIndexOfElementID(store:getElementIDAtIndex(i))).toBe( + i + ) + end + end) + it("should support multiple roots with multiple top level children", function() + -- ROBLOX deviation: switched ordering for variable definition order + local function Child() + return nil + end + local function Parent() + return React.createElement(Child) + end + local function Grandparent() + return React.createElement(Parent) + end + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + act(function() + root:render( + React.createElement( + React.Fragment, + nil, + React.createElement(Grandparent), + React.createElement(Grandparent) + ) + ) + end) + + for i = 0, store:getNumElements() - 1 do + jestExpect(store:getIndexOfElementID(store:getElementIDAtIndex(i))).toBe( + i + ) + end + end) + end) + -- ROBLOX deviation: Not necessary as we will always support profiling + -- it('detects and updates profiling support based on the attached roots', function() + -- local function Component() + -- return nil + -- end + + -- local rootB = Instance.new("Frame") + + -- jestExpect(store:getSupportsProfiling()).toBe(false) + -- act(function() + -- return root:render(React.createElement(Component), containerA) + -- end) + -- jestExpect(store:getSupportsProfiling()).toBe(true) + -- act(function() + -- return root:render(React.createElement(Component), containerB) + -- end) + -- act(function() + -- return ReactRoblox.unmountComponentAtNode(containerA) + -- end) + -- jestExpect(store:getSupportsProfiling()).toBe(true) + -- act(function() + -- return ReactRoblox.unmountComponentAtNode(containerB) + -- end) + -- jestExpect(store:getSupportsProfiling()).toBe(false) + -- end) + it("should properly serialize non-string key values", function() + local function Child() + return nil + end + local fauxElement = Object.assign({}, React.createElement(Child), { key = 123 }) + + local root = ReactRoblox.createRoot(Instance.new("Frame")) + act(function() + return root:render({ fauxElement }) + end) + jestExpect(devtoolsUtils.printStore(store)).toBe([[[root] + ]]) + end) + -- ROBLOX FIXME: displayName can't be assigned to a function component in lua + xit("should show the right display names for special component types", function() + local fakeImport = function(result) + return Promise.resolve({ default = result }) + end + local function MyComponent(props, ref) + return nil + end + local ForwardRefComponent = React.forwardRef(MyComponent) + local function MyComponent2(props, ref) + return nil + end + local ForwardRefComponentWithAnonymousFunction = React.forwardRef(function() + return React.createElement(MyComponent2) + end) + local function MyComponent3(props, ref) + return nil + end + local ForwardRefComponentWithCustomDisplayName = React.forwardRef(MyComponent3) + + ForwardRefComponentWithCustomDisplayName.displayName = "Custom" + + local function MyComponent4(props, ref) + return nil + end + local MemoComponent = React.memo(MyComponent4) + local MemoForwardRefComponent = React.memo(ForwardRefComponent) + local function MyComponent5(props, ref) + return nil + end + local LazyComponent = React.lazy(function() + return fakeImport(MyComponent5) + end) + local function FakeHigherOrderComponent() + return nil + end + + -- ROBLOX deviation: Lua can't set fields on functions, and this component *must* be a function component to be memoized + -- FakeHigherOrderComponent.displayName = 'withFoo(withBar(Baz))' + + local MemoizedFakeHigherOrderComponent = React.memo(FakeHigherOrderComponent) + local ForwardRefFakeHigherOrderComponent = + React.forwardRef(FakeHigherOrderComponent) + local function App() + return React.createElement( + React.Fragment, + nil, + React.createElement(MyComponent), + React.createElement(ForwardRefComponent), + React.createElement(ForwardRefComponentWithAnonymousFunction), + React.createElement(ForwardRefComponentWithCustomDisplayName), + React.createElement(MemoComponent), + React.createElement(MemoForwardRefComponent), + React.createElement(React.Suspense, { + fallback = "Loading...", + }, React.createElement(LazyComponent)), + React.createElement(FakeHigherOrderComponent), + React.createElement(MemoizedFakeHigherOrderComponent), + React.createElement(ForwardRefFakeHigherOrderComponent) + ) + end + local root = ReactRoblox.createRoot(Instance.new("Frame")) + + -- Render once to start fetching the lazy component + act(function() + return root:render(React.createElement(App)) + end) + + -- ROBLOX deviation: equivalent of await Promise.resolve() + Promise.delay(0):await() + + -- Render again after it resolves + act(function() + return root:render(React.createElement(App)) + end) + -- ROBLOX TODO: Work out how to customize component display names to correctly parse hook names e.g. withFoo(withBar(Baz)) + -- ROBLOX TODO: Work out why MyComponent is anonymous when wrapped by forwardRef & Memo + jestExpect(devtoolsUtils.printStore(store)).toBe([==[[root] + ▾ + + [ForwardRef] + ▾ [ForwardRef] + + [ForwardRef] + [Memo] + ▾ [Memo] + [ForwardRef] + ▾ + + [withFoo][withBar] + [Memo][withFoo][withBar] + [ForwardRef][withFoo][withBar]]==]) + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/storeComponentFilters.spec.lua b/modules/react-devtools-shared/src/__tests__/storeComponentFilters.spec.lua new file mode 100644 index 00000000..2380a828 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/storeComponentFilters.spec.lua @@ -0,0 +1,307 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/storeComponentFilters-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 + ]] + +type Function = (...any) -> ...any +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jest = JestGlobals.jest +local describe = JestGlobals.describe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach +local jestExpect = JestGlobals.expect + +local global = ReactGlobals :: any + +local bridgeModule = require(script.Parent.Parent.bridge) +type FrontendBridge = bridgeModule.FrontendBridge +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store + +describe("Store component filters", function() + local React + local ReactRoblox + local Types + local bridge: FrontendBridge + local store: Store + local devtoolsUtils + local utils + + local act = function(callback: Function) + ReactRoblox.act(function() + callback() + -- ROBLOX FIXME: flush should be happening when all timers are run + bridge:_flush() + end) + jest.runAllTimers() -- Flush Bridge operations + end + + beforeEach(function() + bridge = global.bridge + store = global.store + store:setCollapseNodesByDefault(false) + store:setComponentFilters({}) + store:setRecordChangeDescriptions(true) + + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = true + + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + Types = require(script.Parent.Parent.types) + utils = require(script.Parent.utils) + devtoolsUtils = require(script.Parent.Parent.devtools.utils) + end) + + it("should throw if filters are updated while profiling", function() + act(function() + return store:getProfilerStore():startProfiling() + end) + jestExpect(function() + store:setComponentFilters({}) + return store:getComponentFilters() + end).toThrow("Cannot modify filter preferences while profiling") + end) + + it("should support filtering by element type", function() + local Root = React.Component:extend("Root") + function Root:render() + return React.createElement("Frame", nil, self.props.children) + end + local function Component() + return React.createElement("TextLabel", { Text = "Hi" }) + end + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render( + React.createElement(Root, {}, React.createElement(Component)) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + act(function() + store:setComponentFilters({ + utils.createElementTypeFilter(Types.ElementTypeHostComponent), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: still shows the Frame and TextLabel, upstream only has [root] ▾ + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "2: hide host components" + ) + act(function() + store:setComponentFilters({ + utils.createElementTypeFilter(Types.ElementTypeClass), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: supposed to hide Root, but doesn't + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "3: hide class components" + ) + act(function() + store:setComponentFilters({ + utils.createElementTypeFilter(Types.ElementTypeClass), + utils.createElementTypeFilter(Types.ElementTypeFunction), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: should only show Frame and TextLabel + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "4: hide class and function components" + ) + act(function() + store:setComponentFilters({ + utils.createElementTypeFilter(Types.ElementTypeClass, false), + utils.createElementTypeFilter(Types.ElementTypeFunction, false), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "5: disable all filters" + ) + end) + it("should ignore invalid ElementTypeRoot filter", function() + local function Root(props) + return React.createElement("TextLabel", { Text = "Hi" }, props.children) + end + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + root:render(React.createElement(Root)) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + act(function() + store:setComponentFilters({ + utils.createElementTypeFilter(Types.ElementTypeRoot), + }) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "2: add invalid filter" + ) + end) + it("should filter by display name", function() + local function Text(props) + -- Roblox deviation: Text is a noop so we don't clutter our tree with TextLabels + -- return React.createElement("TextLabel", { Text = props.label }) + return nil + end + local function Foo() + return React.createElement(Text, { label = "foo" }) + end + local function Bar() + return React.createElement(Text, { label = "bar" }) + end + local function Baz() + return React.createElement(Text, { label = "baz" }) + end + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render( + React.createElement( + React.Fragment, + nil, + React.createElement(Foo), + React.createElement(Bar), + React.createElement(Baz) + ) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + act(function() + store:setComponentFilters({ utils.createDisplayNameFilter("Foo") }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: doens't filter FOo + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot('2: filter "Foo"') + act(function() + store:setComponentFilters({ utils.createDisplayNameFilter("Ba") }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot('3: filter "Ba"') + act(function() + store:setComponentFilters({ utils.createDisplayNameFilter("B.z") }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot('4: filter "B.z"') + end) + it("should filter by path", function() + local function Component() + return React.createElement("TextLabel", { Text = "Hi" }) + end + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + root:render(React.createElement(Component)) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + act(function() + store:setComponentFilters({ + -- utils.createLocationFilter(__filename:replace(__dirname, "")), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: upstream only has `[root]`, we still show the whole tree + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "2: hide all components declared within this test filed" + ) + act(function() + store:setComponentFilters({ + utils.createLocationFilter("this:is:a:made:up:path"), + }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "3: hide components in a made up fake path" + ) + end) + it("should filter HOCs", function() + local function Component() + return React.createElement("TextLabel", { Text = "Hi" }) + end + + local Foo = React.Component:extend("Foo(Component)") + function Foo:render() + return React.createElement(Component) + end + local Bar = React.Component:extend("Bar(Foo(Component))") + function Bar:render() + return React.createElement(Foo) + end + + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render(React.createElement(Bar)) + end) + + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + act(function() + store:setComponentFilters({ utils.createHOCFilter(true) }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + -- ROBLOX FIXME: still shows all components + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("2: hide all HOCs") + act(function() + store:setComponentFilters({ utils.createHOCFilter(false) }) + return store:getComponentFilters() + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "3: disable HOC filter" + ) + end) + it( + "should not send a bridge update if the set of enabled filters has not changed", + function() + act(function() + store:setComponentFilters({ utils.createHOCFilter(true) }) + return store:getComponentFilters() + end) + bridge:addListener("updateComponentFilters", function(componentFilters) + error("Unexpected component update") + end) + act(function() + store:setComponentFilters({ + utils.createHOCFilter(false), + utils.createHOCFilter(true), + }) + return store:getComponentFilters() + end) + act(function() + store:setComponentFilters({ + utils.createHOCFilter(true), + utils.createLocationFilter("abc", false), + }) + return store:getComponentFilters() + end) + act(function() + store:setComponentFilters({ + utils.createHOCFilter(true), + utils.createElementTypeFilter(Types.ElementTypeHostComponent, false), + }) + return store:getComponentFilters() + end) + end + ) +end) diff --git a/modules/react-devtools-shared/src/__tests__/storeOwners.spec.lua b/modules/react-devtools-shared/src/__tests__/storeOwners.spec.lua new file mode 100644 index 00000000..dc9b4d45 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/storeOwners.spec.lua @@ -0,0 +1,312 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/storeOwners-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 +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local it = JestGlobals.it +local beforeEach = JestGlobals.beforeEach + +local global = ReactGlobals :: any +local ReactRoblox + +-- ROBLOX Deviation: Skip test in nonDev mode +local describeIfDev = _G.DEV and describe or describe.skip :: any +describeIfDev("Store owners list", function() + local React + local LuauPolyfill + local Boolean + local act + local store + local devtoolsUtils + local printOwnersList + + beforeEach(function() + store = global.store + store:setCollapseNodesByDefault(false) + + LuauPolyfill = require(Packages.LuauPolyfill) + Boolean = LuauPolyfill.Boolean + React = require(Packages.React) + ReactRoblox = require(Packages.ReactRoblox) + devtoolsUtils = require(script.Parent.Parent.devtools.utils) + printOwnersList = devtoolsUtils.printOwnersList + + local utils = require(script.Parent.utils) + act = utils.act + end) + + it("should drill through intermediate components", function() + local Wrapper, Intermediate, Leaf + local function Root() + return React.createElement( + Intermediate, + nil, + React.createElement("Frame", nil, React.createElement(Leaf, nil)) + ) + end + function Wrapper(ref) + local children = ref.children + return children + end + function Leaf() + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Name = "Leaf" }) + ) + end + function Intermediate(ref) + local children = ref.children + return React.createElement(Wrapper, nil, children) + end + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render(React.createElement(Root, nil)) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + + local rootID = store:getElementIDAtIndex(0) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "2: components owned by " + ) + + local intermediateID = store:getElementIDAtIndex(1) + jestExpect(printOwnersList(store:getOwnersListForElement(intermediateID))).toMatchSnapshot( + "3: components owned by " + ) + end) + + it("should drill through interleaved intermediate components", function() + -- ROBLOX FIXME Luau: needs deferred constraint resolution to eliminate any cast + local Wrapper, Intermediate: any, Leaf + -- ROBLOX FIXME Luau: this cast couldn't be needed: Type '() -> {ReactElement}' could not be converted into '((React_ElementProps?, any) -> (Array<(Array<> | React_Element | boolean | number | string | t1)?> | React_Element | boolean | number | string | t1)?) | LazyComponent?> | ReactContext | ReactProviderType | React_ComponentType?> | string where t1 = {| [string]: (boolean | number | string | t1 | {( | boolean | number | string | t1 | {| key: (number | string)?, props: {| __source: {| fileName: string, lineNumber: number |}?, children: any?, key: (number | string)?, ref: (((any?) -> ()) | {| current: any? |})? |}?, ref: any, type: any |})?}... '; none of the union options are compatible + local function Root(): any + return { + React.createElement( + Intermediate, + { key = "intermediate" }, + React.createElement(Leaf) + ), + React.createElement(Leaf, { key = "leaf" }), + } + end + function Wrapper(ref) + local children = ref.children + return children + end + function Leaf() + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Name = "Leaf" }) + ) + end + function Intermediate(ref) + local children = ref.children + return { + React.createElement(Leaf, { key = "leaf" }), + -- ROBLOX FIXME Luau: without this any cast, Type '{ key: string }' could not be converted into '{ key: string }?' + React.createElement(Wrapper, { key = "wrapper" } :: any, children), + } + end + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + local root = ReactRoblox.createRoot(Instance.new("Frame")) + return root:render(React.createElement(Root)) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + + local rootID = store:getElementIDAtIndex(0) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "2: components owned by " + ) + local intermediateID = store:getElementIDAtIndex(1) + jestExpect(printOwnersList(store:getOwnersListForElement(intermediateID))).toMatchSnapshot( + "3: components owned by " + ) + end) + + -- ROBLOX Test Noise: This test alone prints numerous 'No element + -- found with id' errors that upstream does not + it( + "should show the proper owners list order and contents after insertions and deletions", + function() + local Wrapper, Leaf, Intermediate + local function Root(ref) + local includeDirect, includeIndirect = + ref.includeDirect, ref.includeIndirect + return React.createElement( + "Frame", + nil, + if Boolean.toJSBoolean(includeDirect) + then React.createElement(Leaf, nil) + else nil, + if Boolean.toJSBoolean(includeIndirect) + then React.createElement( + Intermediate, + nil, + React.createElement(Leaf, nil) + ) + else nil + ) + end + function Wrapper(ref) + local children = ref.children + return children + end + function Leaf() + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Name = "Leaf" }) + ) + end + function Intermediate(ref) + local children = ref.children + return React.createElement(Wrapper, nil, children) + end + + local container = Instance.new("Frame") + -- ROBLOX deviation: Create a root for rendering instead of using ReactDOM + local root = ReactRoblox.createRoot(container) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render( + React.createElement( + Root, + { includeDirect = false, includeIndirect = true } + ) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot("1: mount") + local rootID = store:getElementIDAtIndex(0) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "2: components owned by " + ) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render( + React.createElement( + Root, + { includeDirect = true, includeIndirect = true } + ) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "3: update to add direct" + ) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "4: components owned by " + ) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render( + React.createElement( + Root, + { includeDirect = true, includeIndirect = false } + ) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "5: update to remove indirect" + ) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "6: components owned by " + ) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render( + React.createElement( + Root, + { includeDirect = false, includeIndirect = false } + ) + ) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "7: update to remove both" + ) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "8: components owned by " + ) + end + ) + + it("should show the proper owners list ordering after reordered children", function() + local Leaf + -- ROBLOX FIXME Luau: shouldn't need this any cast + local function Root(ref): any + local ascending = ref.ascending + return if Boolean.toJSBoolean(ascending) + then { + React.createElement(Leaf, { key = "A" }), + React.createElement(Leaf, { key = "B" }), + React.createElement(Leaf, { key = "C" }), + } + else { + React.createElement(Leaf, { key = "C" }), + React.createElement(Leaf, { key = "B" }), + React.createElement(Leaf, { key = "A" }), + } + end + function Leaf() + return React.createElement( + "Frame", + nil, + React.createElement("TextLabel", { Name = "Leaf" }) + ) + end + + local container = Instance.new("Frame") + -- ROBLOX deviation: Create a root for rendering instead of using ReactDOM + local root = ReactRoblox.createRoot(container) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render(React.createElement(Root, { ascending = true })) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "1: mount (ascending)" + ) + local rootID = store:getElementIDAtIndex(0) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "2: components owned by " + ) + + -- ROBLOX deviation: use root:render to render instead of ReactDOM.render + act(function() + return root:render(React.createElement(Root, { ascending = false })) + end) + -- ROBLOX deviation: we use devtoolsUtils.printStore, upstream uses a jest serializer (storeSerializer) instead + jestExpect(devtoolsUtils.printStore(store)).toMatchSnapshot( + "3: update (descending)" + ) + jestExpect(printOwnersList(store:getOwnersListForElement(rootID))).toMatchSnapshot( + "4: components owned by " + ) + end) +end) diff --git a/modules/react-devtools-shared/src/__tests__/utils.lua b/modules/react-devtools-shared/src/__tests__/utils.lua new file mode 100644 index 00000000..48930c56 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/utils.lua @@ -0,0 +1,240 @@ +--!nonstrict +local HttpService = game:GetService("HttpService") +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/__tests__/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 Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jest = JestGlobals.jest +local jestExpect = JestGlobals.expect + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Number = LuauPolyfill.Number +local Object = LuauPolyfill.Object +type Function = (...any) -> any? +local global = ReactGlobals :: any +local exports = {} + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge +local devtoolsTypes = require(script.Parent.Parent.devtools.types) +type Store = devtoolsTypes.Store +local ProfilerTypes = require(script.Parent.Parent.devtools.views.Profiler.types) +type ProfilingDataFrontend = ProfilerTypes.ProfilingDataFrontend +local Types = require(script.Parent.Parent.types) +type ElementType = Types.ElementType + +exports.act = function(callback: () -> ()): () + -- ROBLOX deviation: TestRenderer and RobloxRenderer do not play nice with + -- one another right now. All of the ported tests in this package are + -- using only the ReactRoblox renderer, so we only wrap with it directly + + -- local actTestRenderer = require(Packages.Dev.ReactTestRenderer).act + + local actDOM = require(Packages.ReactRoblox).act + + actDOM(function() + -- actTestRenderer(function() + callback() + -- end) + end) + + while jest.getTimerCount() > 0 do + actDOM(function() + -- actTestRenderer(function() + jest.runAllTimers() + -- end) + end) + end +end + +exports.actAsync = function(cb: () -> any, recursivelyFlush: boolean?) + if recursivelyFlush == nil then + recursivelyFlush = true + end + + -- ROBLOX deviation: TestRenderer and RobloxRenderer do not play nice with + -- one another right now. All of the ported tests in this package are + -- using only the ReactRoblox renderer, so we only wrap with it directly + -- local actTestRenderer = require(Packages.Dev.ReactTestRenderer).act + + local actDOM = require(Packages.ReactRoblox).act + + if recursivelyFlush then + while jest.getTimerCount() > 0 do + -- $FlowFixMe Flow doesn't know about "await act()" yet + actDOM(function() + -- return actTestRenderer(function() + jest.runAllTimers() + -- end):await() + end) + end + else + -- $FlowFixMe Flow doesn't know about "await act()" yet + actDOM(function() + -- return actTestRenderer(function() + jest.runOnlyPendingTimers() + -- end):await() + end) + end +end + +exports.beforeEachProfiling = function(): () + -- ROBLOX deviation BEGIN: we handle this differently until jest-roblox 27.5 is available + -- Mock React's timing information so that test runs are predictable. + jest.mock(Packages.Dev.Scheduler, function() + return require(Packages.Parent.Scheduler.Scheduler.unstable_mock) + end) + -- DevTools itself uses performance.now() to offset commit times + -- so they appear relative to when profiling was started in the UI. + -- ROBLOX deviation: os.clock not performance + -- ROBLOX TODO: Can you actually spy on os.clock? + -- ROBLOX deviation BEGIN: We need to do slightly more targeted mocking until + local Scheduler = require(Packages.Dev.Scheduler) + jest.mockOsClock(Scheduler.unstable_now) + -- jest.spyOn(os, "clock").mockImplementation( + -- jest.requireActual("scheduler/unstable_mock").unstable_now + -- ) + -- ROBLOX deviation END +end + +exports.createDisplayNameFilter = function(source: string, isEnabled: boolean?) + if isEnabled == nil then + isEnabled = true + end + -- ROBLOX deviation: Cannot sanitize source as a RegExp so assume valid + local isValid = true + + return { + type = Types.ComponentFilterDisplayName, + isEnabled = isEnabled, + isValid = isValid, + value = source, + } +end + +exports.createHOCFilter = function(isEnabled: boolean?) + if isEnabled == nil then + isEnabled = true + end + + return { + type = Types.ComponentFilterHOC, + isEnabled = isEnabled, + isValid = true, + } +end + +exports.createElementTypeFilter = function(elementType: ElementType, isEnabled: boolean?) + if isEnabled == nil then + isEnabled = true + end + + return { + type = Types.ComponentFilterElementType, + isEnabled = isEnabled, + value = elementType, + } +end + +exports.createLocationFilter = function(source: string, isEnabled: boolean?) + if isEnabled == nil then + isEnabled = true + end + local isValid = true + + return { + type = Types.ComponentFilterLocation, + isEnabled = isEnabled, + isValid = isValid, + value = source, + } +end + +exports.getRendererID = function(): number + if global.agent == nil then + error("Agent unavailable.") + end + + local ids = Object.keys(global.agent._rendererInterfaces) + local id = Array.find(ids, function(innerID) + -- ROBLOX deviation: Let's return all renderers + -- local rendererInterface = global.agent._rendererInterfaces[innerID] + -- return rendererInterface.renderer.rendererPackageName == 'react-dom' + return true + end) + + if ids == nil then + error("Could not find renderer.") + end + + -- ROBLOX FIXME: create Number.parseInt() in luau-polyfill + -- ROBLOX FIXME Luau: if-expression boolean doesn't narrow id correctly + return if id then tonumber(id) :: number else Number.NaN +end +exports.requireTestRenderer = function(): any + -- Hide the hook before requiring TestRenderer, so we don't end up with a loop. + local hook = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = nil + + local success, module = pcall(function() + return require(Packages.Dev.ReactTestRenderer) + end) + + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook + + if not success then + warn("Failed to require TestRenderer", module) + return nil + else + return module + end +end + +exports.exportImportHelper = function(bridge: FrontendBridge, store: Store): () + local utils = require(script.Parent.Parent.devtools.views.Profiler.utils) + local prepareProfilingDataExport = utils.prepareProfilingDataExport + local prepareProfilingDataFrontendFromExport = + utils.prepareProfilingDataFrontendFromExport + local profilerStore = store._profilerStore + + local profilingDataFrontendInitial = profilerStore:profilingData() + jestExpect(profilingDataFrontendInitial).never.toBeNull() + -- ROBLOX deviation: luau needs the assert since .never.toBeNull() doesn't have narrowing side-effects right now + assert(profilingDataFrontendInitial, "profilingDataFrontendInitial was nil") + + jestExpect(profilingDataFrontendInitial.imported).toBe(false) + + local profilingDataExport = prepareProfilingDataExport(profilingDataFrontendInitial) + + -- Simulate writing/reading to disk. + local serializedProfilingDataExport = HttpService:JSONEncode(profilingDataExport) + local parsedProfilingDataExport = + HttpService:JSONDecode(serializedProfilingDataExport) + + local profilingDataFrontend = + prepareProfilingDataFrontendFromExport(parsedProfilingDataExport) + jestExpect(profilingDataFrontend.imported).toBe(true) + + -- Sanity check that profiling snapshots are serialized correctly. + jestExpect(profilingDataFrontendInitial.dataForRoots).toEqual( + profilingDataFrontend.dataForRoots + ) + + -- Snapshot the JSON-parsed object, rather than the raw string, because Jest formats the diff nicer. + jestExpect(parsedProfilingDataExport).toMatchSnapshot("imported data") + + exports.act(function() + -- Apply the new exported-then-imported data so tests can re-run assertions. + profilerStore:profilingData(profilingDataFrontend) + end) +end + +return exports 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..83748415 --- /dev/null +++ b/modules/react-devtools-shared/src/__tests__/utils.spec.lua @@ -0,0 +1,150 @@ +-- 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 +-- */ +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local it = JestGlobals.it +local xit = JestGlobals.xit +local beforeEach = JestGlobals.beforeEach +local LuauPolyfill = require(Packages.LuauPolyfill) +local Symbol = LuauPolyfill.Symbol + +local utils = require(script.Parent.Parent.utils) +local getDisplayName = utils.getDisplayName +local getDisplayNameForReactElement = utils.getDisplayNameForReactElement +local SuspenseList, StrictMode +local createElement + +beforeEach(function() + local ReactSymbols = require(Packages.Shared).ReactSymbols + SuspenseList = ReactSymbols.REACT_SUSPENSE_LIST_TYPE + StrictMode = ReactSymbols.REACT_STRICT_MODE_TYPE + local React = require(Packages.React) + createElement = React.createElement +end) + +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.__componentName = "OverrideDisplayName" + + jestExpect(getDisplayName(FauxComponent)).toEqual("OverrideDisplayName") + end) + + it("should return the fallback for invalid type", function() + jestExpect(getDisplayName(100, "Fallback")).toEqual("Fallback") + end) + + it( + "should return fileName:lineNumber for anonymous functions without a fallback", + function() + jestExpect(getDisplayName(function() end)).toMatch("utils.spec:[0-9]*") + 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 + -- ROBLOX Test Noise: This warning shouldn't be triggered + jestExpect(function() + element = createElement(SuspenseList) + end).toErrorDev("type is invalid", { withoutStack = true }) + + jestExpect(getDisplayNameForReactElement(element)).toEqual("SuspenseList") + end + ) + + it( + "should return NotImplementedInDevtools for an element with invalid symbol type", + function() + local element + -- ROBLOX Test Noise: This warning isn't captured in + -- upstream, so it may rely on test setup to suppress it + jestExpect(function() + element = (createElement :: any)(Symbol("foo")) + end).toErrorDev("type is invalid", { withoutStack = true }) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "NotImplementedInDevtools" + ) + end + ) + + it( + "should return NotImplementedInDevtools for an element with invalid type", + function() + local element + -- ROBLOX Test Noise: This warning isn't captured in + -- upstream, so it may rely on test setup to suppress it + jestExpect(function() + element = (createElement :: any)(true) + end).toErrorDev("type is invalid", { withoutStack = true }) + + jestExpect(getDisplayNameForReactElement(element)).toEqual( + "NotImplementedInDevtools" + ) + end + ) + + it("should return Element for null type", function() + local element + -- ROBLOX Test Noise: This warning isn't captured in + -- upstream, so it may rely on test setup to suppress it + jestExpect(function() + element = (createElement :: any)() + end).toErrorDev("type is invalid", { withoutStack = true }) + + jestExpect(getDisplayNameForReactElement(element)).toEqual("Element") + 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..1ccb8f05 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/NativeStyleEditor/types.lua @@ -0,0 +1,29 @@ +-- 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/ReactSymbols.lua b/modules/react-devtools-shared/src/backend/ReactSymbols.lua new file mode 100644 index 00000000..d7f86f2e --- /dev/null +++ b/modules/react-devtools-shared/src/backend/ReactSymbols.lua @@ -0,0 +1,59 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/ReactSymbols.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 = {} +exports.CONCURRENT_MODE_NUMBER = 0xeacf +exports.CONCURRENT_MODE_SYMBOL_STRING = "Symbol(react.concurrent_mode)" + +exports.CONTEXT_NUMBER = 0xeace +exports.CONTEXT_SYMBOL_STRING = "Symbol(react.context)" + +exports.DEPRECATED_ASYNC_MODE_SYMBOL_STRING = "Symbol(react.async_mode)" + +exports.ELEMENT_NUMBER = 0xeac7 +exports.ELEMENT_SYMBOL_STRING = "Symbol(react.element)" + +exports.DEBUG_TRACING_MODE_NUMBER = 0xeae1 +exports.DEBUG_TRACING_MODE_SYMBOL_STRING = "Symbol(react.debug_trace_mode)" + +exports.FORWARD_REF_NUMBER = 0xead0 +exports.FORWARD_REF_SYMBOL_STRING = "Symbol(react.forward_ref)" + +exports.FRAGMENT_NUMBER = 0xeacb +exports.FRAGMENT_SYMBOL_STRING = "Symbol(react.fragment)" + +exports.LAZY_NUMBER = 0xead4 +exports.LAZY_SYMBOL_STRING = "Symbol(react.lazy)" + +exports.MEMO_NUMBER = 0xead3 +exports.MEMO_SYMBOL_STRING = "Symbol(react.memo)" + +exports.OPAQUE_ID_NUMBER = 0xeae0 +exports.OPAQUE_ID_SYMBOL_STRING = "Symbol(react.opaque.id)" + +exports.PORTAL_NUMBER = 0xeaca +exports.PORTAL_SYMBOL_STRING = "Symbol(react.portal)" + +exports.PROFILER_NUMBER = 0xead2 +exports.PROFILER_SYMBOL_STRING = "Symbol(react.profiler)" + +exports.PROVIDER_NUMBER = 0xeacd +exports.PROVIDER_SYMBOL_STRING = "Symbol(react.provider)" + +exports.SCOPE_NUMBER = 0xead7 +exports.SCOPE_SYMBOL_STRING = "Symbol(react.scope)" + +exports.STRICT_MODE_NUMBER = 0xeacc +exports.STRICT_MODE_SYMBOL_STRING = "Symbol(react.strict_mode)" + +exports.SUSPENSE_NUMBER = 0xead1 +exports.SUSPENSE_SYMBOL_STRING = "Symbol(react.suspense)" + +exports.SUSPENSE_LIST_NUMBER = 0xead8 +exports.SUSPENSE_LIST_SYMBOL_STRING = "Symbol(react.suspense_list)" + +return exports diff --git a/modules/react-devtools-shared/src/backend/agent.lua b/modules/react-devtools-shared/src/backend/agent.lua new file mode 100644 index 00000000..3c858e00 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/agent.lua @@ -0,0 +1,851 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/agent.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 Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +type Set = LuauPolyfill.Set +local console = LuauPolyfill.console +local JSON = game:GetService("HttpService") +type Function = (...any) -> ...any +type Array = { [number]: T } +type Object = { [string]: any } + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter +-- ROBLOX FIXME: need to implement lodash.throttle, pass through for now +-- import throttle from 'lodash.throttle'; +local throttle = function(fn: Function, _limit: number): Function + return fn +end +local constants = require(script.Parent.Parent.constants) +local SESSION_STORAGE_LAST_SELECTION_KEY = constants.SESSION_STORAGE_LAST_SELECTION_KEY +local SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = + constants.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY +local SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + constants.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY +local __DEBUG__ = constants.__DEBUG__ +local storage = require(script.Parent.Parent.storage) +local sessionStorageGetItem = storage.sessionStorageGetItem +local sessionStorageRemoveItem = storage.sessionStorageRemoveItem +local sessionStorageSetItem = storage.sessionStorageSetItem +local Highlighter = require(script.Parent.views.Highlighter) +local setupHighlighter = Highlighter.setupHighlighter +-- local TraceUpdates = require(script.Parent.views.TraceUpdates) +-- local setupTraceUpdates = TraceUpdates.initialize +-- local setTraceUpdatesEnabled = TraceUpdates.toggleEnabled +-- ROBLOX TODO: stub these for now +local setupTraceUpdates = function(agent) end +local setTraceUpdatesEnabled = function(enabled: boolean) end + +-- local console = require(script.Parent.console) +-- local patchConsole = console.patch +-- local unpatchConsole = console.unpatch +-- ROBLOX TODO: stub these for now. they're used to force the debugger to break immediately when console.error is called +local patchConsole = function(obj) end +local unpatchConsole = function() end + +local Bridge = require(script.Parent.Parent.bridge) +type BackendBridge = Bridge.BackendBridge + +local BackendTypes = require(script.Parent.types) +type InstanceAndStyle = BackendTypes.InstanceAndStyle +type NativeType = BackendTypes.NativeType +type OwnersList = BackendTypes.OwnersList +type PathFrame = BackendTypes.PathFrame +type PathMatch = BackendTypes.PathMatch +type RendererID = BackendTypes.RendererID +type RendererInterface = BackendTypes.RendererInterface + +local SharedTypes = require(script.Parent.Parent.types) +type ComponentFilter = SharedTypes.ComponentFilter + +local debug_ = function(methodName, ...) + if __DEBUG__ then + -- ROBLOX deviation: simpler print + print(methodName, ...) + end +end + +type ElementAndRendererID = { id: number, rendererID: number } + +type StoreAsGlobalParams = { + count: number, + id: number, + path: Array, + rendererID: number, +} + +type CopyElementParams = { + id: number, + path: Array, + rendererID: number, +} + +type InspectElementParams = { + id: number, + path: Array?, + rendererID: number, +} + +type OverrideHookParams = { + id: number, + hookID: number, + path: Array, + rendererID: number, + wasForwarded: boolean?, + value: any, +} + +type SetInParams = { + id: number, + path: Array, + rendererID: number, + wasForwarded: boolean?, + value: any, +} + +-- ROBLOX deviation: Luau can't do literal enumerations: 'props' | 'hooks' | 'state' | 'context'; +type PathType = string + +type DeletePathParams = { + type: PathType, + hookID: number?, + id: number, + path: Array, + rendererID: number, +} + +type RenamePathParams = { + type: PathType, + hookID: number?, + id: number, + oldPath: Array, + newPath: Array, + rendererID: number, +} + +type OverrideValueAtPathParams = { + type: PathType, + hookID: number?, + id: number, + path: Array, + rendererID: number, + value: any, +} + +type OverrideSuspenseParams = { id: number, rendererID: number, forceFallback: boolean } + +type PersistedSelection = { rendererID: number, path: Array } + +export type Agent = EventEmitter<{ + hideNativeHighlight: Array, + showNativeHighlight: Array, + shutdown: any, + traceUpdates: Set, +}> & { + _bridge: BackendBridge, + _isProfiling: boolean, + _recordChangeDescriptions: boolean, + _rendererInterfaces: { [RendererID]: RendererInterface }, + _persistedSelection: PersistedSelection | nil, + _persistedSelectionMatch: PathMatch | nil, + _traceUpdatesEnabled: boolean, + + getRendererInterfaces: (self: Agent) -> { [RendererID]: RendererInterface }, + copyElementPath: (self: Agent, copyElementParams: CopyElementParams) -> (), + deletePath: (self: Agent, deletePathParams: DeletePathParams) -> (), + getInstanceAndStyle: ( + self: Agent, + elementAndRendererId: ElementAndRendererID + ) -> InstanceAndStyle | nil, + getIDForNode: (self: Agent, node: Object) -> number | nil, + getProfilingData: (self: Agent, rendererIdObject: { rendererID: RendererID }) -> (), + getProfilingStatus: (self: Agent) -> (), + getOwnersList: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + inspectElement: (self: Agent, inspectElementParams: InspectElementParams) -> (), + logElementToConsole: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + overrideSuspense: (self: Agent, overrideSuspenseParams: OverrideSuspenseParams) -> (), + overrideValueAtPath: ( + self: Agent, + overrideValueAtPathParams: OverrideValueAtPathParams + ) -> (), + overrideContext: (self: Agent, setInParams: SetInParams) -> (), + overrideHookState: (self: Agent, overrideHookParams: OverrideHookParams) -> (), + overrideProps: (self: Agent, setInParams: SetInParams) -> (), + overrideState: (self: Agent, setInParams: SetInParams) -> (), + reloadAndProfile: (self: Agent, recordChangeDescriptions: boolean) -> (), + renamePath: (self: Agent, renamePathParams: RenamePathParams) -> (), + selectNode: (self: Agent, target: Object) -> (), + setRendererInterface: ( + self: Agent, + rendererID: number, + rendererInterface: RendererInterface + ) -> (), + setTraceUpdatesEnabled: (self: Agent, traceUpdatesEnabled: boolean) -> (), + syncSelectionFromNativeElementsPanel: (self: Agent) -> (), + shutdown: (self: Agent) -> (), + startProfiling: (self: Agent, recordChangeDescriptions: boolean) -> (), + stopProfiling: (self: Agent) -> (), + storeAsGlobal: (self: Agent, storeAsGlobalParams: StoreAsGlobalParams) -> (), + updateConsolePatchSettings: ( + self: Agent, + _ref16: { appendComponentStack: boolean, breakOnConsoleErrors: boolean } + ) -> (), + updateComponentFilters: (self: Agent, componentFilters: Array) -> (), + viewAttributeSource: (self: Agent, copyElementParams: CopyElementParams) -> (), + viewElementSource: (self: Agent, elementAndRendererID: ElementAndRendererID) -> (), + onTraceUpdates: (self: Agent, nodes: Set) -> (), + onHookOperations: (self: Agent, operations: Array) -> (), + onUnsupportedRenderer: (self: Agent, rendererID: number) -> (), + + _throttledPersistSelection: (self: Agent, rendererID: number, id: number) -> (), +} + +type Agent_Statics = { + new: (bridge: BackendBridge) -> Agent, +} + +local Agent: Agent & Agent_Statics = setmetatable({}, { __index = EventEmitter }) :: any + +local AgentMetatable = { __index = Agent } +-- ROBLOX deviation: equivalent of sub-class + +function Agent.new(bridge: BackendBridge) + local self = setmetatable(EventEmitter.new() :: any, AgentMetatable) + + -- ROBLOX deviation: define fields in constructor + self._bridge = bridge + self._isProfiling = false + self._recordChangeDescriptions = false + self._rendererInterfaces = {} + self._persistedSelection = nil + self._persistedSelectionMatch = nil + self._traceUpdatesEnabled = false + + if sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) == "true" then + self._recordChangeDescriptions = sessionStorageGetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY + ) == "true" + self._isProfiling = true + + sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) + sessionStorageRemoveItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) + end + + local persistedSelectionString = + sessionStorageGetItem(SESSION_STORAGE_LAST_SELECTION_KEY) + + if persistedSelectionString ~= nil then + self._persistedSelection = JSON:JSONDecode(persistedSelectionString) + end + + local function wrapSelf(method: Function) + return function(...) + method(self, ...) + end + end + + bridge:addListener("copyElementPath", wrapSelf(self.copyElementPath)) + bridge:addListener("deletePath", wrapSelf(self.deletePath)) + bridge:addListener("getProfilingData", wrapSelf(self.getProfilingData)) + bridge:addListener("getProfilingStatus", wrapSelf(self.getProfilingStatus)) + bridge:addListener("getOwnersList", wrapSelf(self.getOwnersList)) + bridge:addListener("inspectElement", wrapSelf(self.inspectElement)) + bridge:addListener("logElementToConsole", wrapSelf(self.logElementToConsole)) + bridge:addListener("overrideSuspense", wrapSelf(self.overrideSuspense)) + bridge:addListener("overrideValueAtPath", wrapSelf(self.overrideValueAtPath)) + bridge:addListener("reloadAndProfile", wrapSelf(self.reloadAndProfile)) + bridge:addListener("renamePath", wrapSelf(self.renamePath)) + bridge:addListener("setTraceUpdatesEnabled", wrapSelf(self.setTraceUpdatesEnabled)) + bridge:addListener("startProfiling", wrapSelf(self.startProfiling)) + bridge:addListener("stopProfiling", wrapSelf(self.stopProfiling)) + bridge:addListener("storeAsGlobal", wrapSelf(self.storeAsGlobal)) + bridge:addListener( + "syncSelectionFromNativeElementsPanel", + wrapSelf(self.syncSelectionFromNativeElementsPanel) + ) + bridge:addListener("shutdown", wrapSelf(self.shutdown)) + bridge:addListener( + "updateConsolePatchSettings", + wrapSelf(self.updateConsolePatchSettings) + ) + bridge:addListener("updateComponentFilters", wrapSelf(self.updateComponentFilters)) + bridge:addListener("viewAttributeSource", wrapSelf(self.viewAttributeSource)) + bridge:addListener("viewElementSource", wrapSelf(self.viewElementSource)) + + -- 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. + bridge:addListener("overrideContext", wrapSelf(self.overrideContext)) + bridge:addListener("overrideHookState", wrapSelf(self.overrideHookState)) + bridge:addListener("overrideProps", wrapSelf(self.overrideProps)) + bridge:addListener("overrideState", wrapSelf(self.overrideState)) + + if self._isProfiling then + bridge:send("profilingStatus", true) + end + + -- Notify the frontend if the backend supports the Storage API (e.g. localStorage). + -- If not, features like reload-and-profile will not work correctly and must be disabled. + -- ROBLOX deviation: Storage is supported, but we don't use localStorage per se + local isBackendStorageAPISupported = true + + bridge:send("isBackendStorageAPISupported", isBackendStorageAPISupported) + + setupHighlighter(bridge, self) + setupTraceUpdates(self) + + return self +end + +-- ROBLOX FIXME: this needs to be a property getter via an __index override +function Agent:getRendererInterfaces() + return self._rendererInterfaces +end + +function Agent:copyElementPath(copyElementParams: CopyElementParams): () + local id, path, rendererID = + copyElementParams.id, copyElementParams.path, copyElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).copyElementPath(id, path) + end +end +function Agent:deletePath(deletePathParams: DeletePathParams): () + local hookID, id, path, rendererID, type_ = + deletePathParams.hookID, + deletePathParams.id, + deletePathParams.path, + deletePathParams.rendererID, + deletePathParams.type + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).deletePath(type_, id, hookID, path) + end +end +function Agent:getInstanceAndStyle( + elementAndRendererId: ElementAndRendererID +): InstanceAndStyle | nil + local id, rendererID = elementAndRendererId.id, elementAndRendererId.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + return nil + end + + return (renderer :: RendererInterface).getInstanceAndStyle(id) +end + +function Agent:getIDForNode(node: Object): number | nil + for _rendererID, renderer in self._rendererInterfaces do + local ok, result = pcall(renderer.getFiberIDForNative, node, true) + if ok and result ~= nil then + return result + end + -- Some old React versions might throw if they can't find a match. + -- If so we should ignore it... + end + return nil +end +function Agent:getProfilingData(rendererIdObject: { rendererID: RendererID }): () + local rendererID = rendererIdObject.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + end + + self._bridge:send("profilingData", (renderer :: RendererInterface).getProfilingData()) +end +function Agent:getProfilingStatus() + self._bridge:send("profilingStatus", self._isProfiling) +end +function Agent:getOwnersList(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + local owners = (renderer :: RendererInterface).getOwnersList(id) + + self._bridge:send("ownersList", { + id = id, + owners = owners, + }) + end +end +function Agent:inspectElement(inspectElementParams: InspectElementParams) + local id, path, rendererID = + inspectElementParams.id, + inspectElementParams.path, + inspectElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + self._bridge:send( + "inspectedElement", + (renderer :: RendererInterface).inspectElement(id, path) + ) + + -- When user selects an element, stop trying to restore the selection, + -- and instead remember the current selection for the next reload. + if + (self._persistedSelectionMatch :: PathMatch?) == nil + or (self._persistedSelectionMatch :: PathMatch).id ~= id + then + self._persistedSelection = nil + self._persistedSelectionMatch = nil; + + (renderer :: RendererInterface).setTrackedPath(nil) + self:_throttledPersistSelection(rendererID, id) + end + + -- TODO: If there was a way to change the selected DOM element + -- in native Elements tab without forcing a switch to it, we'd do it here. + -- For now, it doesn't seem like there is a way to do that: + -- https://github.com/bvaughn/react-devtools-experimental/issues/102 + -- (Setting $0 doesn't work, and calling inspect() switches the tab.) + end +end +function Agent:logElementToConsole(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).logElementToConsole(id) + end +end +function Agent:overrideSuspense(overrideSuspenseParams: OverrideSuspenseParams) + local id, rendererID, forceFallback = + overrideSuspenseParams.id, + overrideSuspenseParams.rendererID, + overrideSuspenseParams.forceFallback + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).overrideSuspense(id, forceFallback) + end +end +function Agent:overrideValueAtPath(overrideValueAtPathParams: OverrideValueAtPathParams) + local hookID, id, path, rendererID, type_, value = + overrideValueAtPathParams.hookID, + overrideValueAtPathParams.id, + overrideValueAtPathParams.path, + overrideValueAtPathParams.rendererID, + overrideValueAtPathParams.type, + overrideValueAtPathParams.value + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).overrideValueAtPath( + type_, + id, + hookID, + path, + value + ) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideContext(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, + setInParams.path, + setInParams.rendererID, + setInParams.wasForwarded, + setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "context", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideHookState(overrideHookParams: OverrideHookParams) + local id, _hookID, path, rendererID, wasForwarded, value = + overrideHookParams.id, + overrideHookParams.hookID, + overrideHookParams.path, + overrideHookParams.rendererID, + overrideHookParams.wasForwarded, + overrideHookParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "hooks", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideProps(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, + setInParams.path, + setInParams.rendererID, + setInParams.wasForwarded, + setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "props", + value = value, + }) + end +end + +-- Temporarily support older standalone front-ends by forwarding the older message types +-- to the new "overrideValueAtPath" command the backend is now listening to. +function Agent:overrideState(setInParams: SetInParams) + local id, path, rendererID, wasForwarded, value = + setInParams.id, + setInParams.path, + setInParams.rendererID, + setInParams.wasForwarded, + setInParams.value + + -- Don't forward a message that's already been forwarded by the front-end Bridge. + -- We only need to process the override command once! + if not wasForwarded then + self:overrideValueAtPath({ + id = id, + path = path, + rendererID = rendererID, + type = "state", + value = value, + }) + end +end +function Agent:reloadAndProfile(recordChangeDescriptions: boolean) + sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, "true") + sessionStorageSetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + (function() + if recordChangeDescriptions then + return "true" + end + + return "false" + end)() + ) + + -- This code path should only be hit if the shell has explicitly told the Store that it supports profiling. + -- In that case, the shell must also listen for this specific message to know when it needs to reload the app. + -- The agent can't do this in a way that is renderer agnostic. + self._bridge:send("reloadAppForProfiling") +end +function Agent:renamePath(renamePathParams: RenamePathParams) + local hookID, id, newPath, oldPath, rendererID, type_ = + renamePathParams.hookID, + renamePathParams.id, + renamePathParams.newPath, + renamePathParams.oldPath, + renamePathParams.rendererID, + renamePathParams.type + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).renamePath(type_, id, hookID, oldPath, newPath) + end +end +function Agent:selectNode(target: Object): () + local id = self:getIDForNode(target) + + if id ~= nil then + self._bridge:send("selectFiber", id) + end +end +function Agent:setRendererInterface( + rendererID: number, + rendererInterface: RendererInterface +) + self._rendererInterfaces[rendererID] = rendererInterface + + if self._isProfiling then + rendererInterface.startProfiling(self._recordChangeDescriptions) + end + + rendererInterface.setTraceUpdatesEnabled(self._traceUpdatesEnabled) + + -- When the renderer is attached, we need to tell it whether + -- we remember the previous selection that we'd like to restore. + -- It'll start tracking mounts for matches to the last selection path. + local selection: PersistedSelection? = self._persistedSelection + + if + selection ~= nil + and (selection :: PersistedSelection).rendererID == rendererID + then + rendererInterface.setTrackedPath((selection :: PersistedSelection).path) + end +end +function Agent:setTraceUpdatesEnabled(traceUpdatesEnabled: boolean) + self._traceUpdatesEnabled = traceUpdatesEnabled + + setTraceUpdatesEnabled(traceUpdatesEnabled) + + for _rendererID, renderer in self._rendererInterfaces do + renderer.setTraceUpdatesEnabled(traceUpdatesEnabled) + end +end +function Agent:syncSelectionFromNativeElementsPanel() + local target = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__["$0"] + + if target == nil then + return + end + + self:selectNode(target) +end +function Agent:shutdown() + -- Clean up the overlay if visible, and associated events. + self:emit("shutdown") +end +function Agent:startProfiling(recordChangeDescriptions: boolean) + self._recordChangeDescriptions = recordChangeDescriptions + self._isProfiling = true + + for _rendererID, renderer in self._rendererInterfaces do + renderer.startProfiling(recordChangeDescriptions) + end + + self._bridge:send("profilingStatus", self._isProfiling) +end +function Agent:stopProfiling() + self._isProfiling = false + self._recordChangeDescriptions = false + + for _rendererID, renderer in self._rendererInterfaces do + renderer.stopProfiling() + end + + self._bridge:send("profilingStatus", self._isProfiling) +end + +function Agent:storeAsGlobal(storeAsGlobalParams: StoreAsGlobalParams) + local count, id, path, rendererID = + storeAsGlobalParams.count, + storeAsGlobalParams.id, + storeAsGlobalParams.path, + storeAsGlobalParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).storeAsGlobal(id, path, count) + end +end + +function Agent:updateConsolePatchSettings(_ref16: { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +}) + local appendComponentStack, breakOnConsoleErrors = + _ref16.appendComponentStack, _ref16.breakOnConsoleErrors + + -- If the frontend preference has change, + -- or in the case of React Native- if the backend is just finding out the preference- + -- then install or uninstall the console overrides. + -- It's safe to call these methods multiple times, so we don't need to worry about that. + if appendComponentStack or breakOnConsoleErrors then + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + else + unpatchConsole() + end +end +function Agent:updateComponentFilters(componentFilters: Array) + for _rendererID, renderer in self._rendererInterfaces do + renderer.updateComponentFilters(componentFilters) + end +end +function Agent:viewAttributeSource(copyElementParams: CopyElementParams) + local id, path, rendererID = + copyElementParams.id, copyElementParams.path, copyElementParams.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).prepareViewAttributeSource(id, path) + end +end +function Agent:viewElementSource(elementAndRendererID: ElementAndRendererID) + local id, rendererID = elementAndRendererID.id, elementAndRendererID.rendererID + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn( + string.format('Invalid renderer id "%d" for element "%d"', rendererID, id) + ) + else + (renderer :: RendererInterface).prepareViewElementSource(id) + end +end +function Agent:onTraceUpdates(nodes: Set) + self:emit("traceUpdates", nodes) +end +function Agent:onHookOperations(operations: Array) + if ReactGlobals.__DEBUG__ then + debug_("onHookOperations", operations) + end + + -- TODO: + -- The chrome.runtime does not currently support transferables; it forces JSON serialization. + -- See bug https://bugs.chromium.org/p/chromium/issues/detail?id=927134 + -- + -- Regarding transferables, the postMessage doc states: + -- If the ownership of an object is transferred, it becomes unusable (neutered) + -- in the context it was sent from and becomes available only to the worker it was sent to. + -- + -- Even though Chrome is eventually JSON serializing the array buffer, + -- using the transferable approach also sometimes causes it to throw: + -- DOMException: Failed to execute 'postMessage' on 'Window': ArrayBuffer at index 0 is already neutered. + -- + -- See bug https://github.com/bvaughn/react-devtools-experimental/issues/25 + -- + -- The Store has a fallback in place that parses the message as JSON if the type isn't an array. + -- For now the simplest fix seems to be to not transfer the array. + -- This will negatively impact performance on Firefox so it's unfortunate, + -- but until we're able to fix the Chrome error mentioned above, it seems necessary. + -- + self._bridge:send("operations", operations) + + if self._persistedSelection ~= nil then + local rendererID = operations[1] + + if (self._persistedSelection :: PersistedSelection).rendererID == rendererID then + -- Check if we can select a deeper match for the persisted selection. + local renderer = self._rendererInterfaces[rendererID] + + if renderer == nil then + console.warn(string.format('Invalid renderer id "%d"', rendererID)) + else + local prevMatch = self._persistedSelectionMatch + local nextMatch = (renderer :: RendererInterface).getBestMatchForTrackedPath() + + self._persistedSelectionMatch = nextMatch + + local prevMatchID = if prevMatch ~= nil then prevMatch.id else nil + local nextMatchID = if nextMatch ~= nil then nextMatch.id else nil + + if prevMatchID ~= nextMatchID then + if nextMatchID ~= nil then + -- We moved forward, unlocking a deeper node. + self._bridge:send("selectFiber", nextMatchID) + end + end + if nextMatch ~= nil and (nextMatch :: PathMatch).isFullMatch then + -- We've just unlocked the innermost selected node. + -- There's no point tracking it further. + self._persistedSelection = nil + self._persistedSelectionMatch = nil; + + (renderer :: RendererInterface).setTrackedPath(nil) + end + end + end + end +end + +function Agent:onUnsupportedRenderer(rendererID: number) + self._bridge:send("unsupportedRendererVersion", rendererID) +end + +Agent._throttledPersistSelection = throttle(function(self, rendererID: number, id: number) + -- This is throttled, so both renderer and selected ID + -- might not be available by the time we read them. + -- This is why we need the defensive checks here. + local renderer = self._rendererInterfaces[rendererID] + local path = (function() + if renderer ~= nil then + return (renderer :: RendererInterface).getPathForElement(id) + end + + return nil + end)() + + if path ~= nil then + sessionStorageSetItem( + SESSION_STORAGE_LAST_SELECTION_KEY, + JSON:JSONEncode({ + rendererID = rendererID, + path = path, + }) + ) + else + sessionStorageRemoveItem(SESSION_STORAGE_LAST_SELECTION_KEY) + end +end, 1000) + +return Agent diff --git a/modules/react-devtools-shared/src/backend/console.lua b/modules/react-devtools-shared/src/backend/console.lua new file mode 100644 index 00000000..e1581fab --- /dev/null +++ b/modules/react-devtools-shared/src/backend/console.lua @@ -0,0 +1,38 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/console.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 Types = require(script.Parent.types) +type ReactRenderer = Types.ReactRenderer + +local exports = {} + +-- ROBLOX FIXME: Stub for now +function exports.patch(_object: { + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, +}): () end + +function exports.unpatch(): () end + +function exports.error(...) + error(...) +end + +function exports.warn(...) + warn(...) +end + +function exports.log(...) + print(...) +end + +function exports.registerRenderer(_renderer: ReactRenderer): () end + +return exports diff --git a/modules/react-devtools-shared/src/backend/init.lua b/modules/react-devtools-shared/src/backend/init.lua new file mode 100644 index 00000000..a4991c3c --- /dev/null +++ b/modules/react-devtools-shared/src/backend/init.lua @@ -0,0 +1,142 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/index.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 Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array + +local Agent = require(script.agent) +type Agent = Agent.Agent + +local types = require(script.types) +export type DevToolsHook = types.DevToolsHook +export type ReactRenderer = types.ReactRenderer +export type RendererInterface = types.RendererInterface + +type Object = { [string]: any } + +local function initBackend(hook: DevToolsHook, agent: Agent, global: Object): () -> () + if hook == nil then + -- DevTools didn't get injected into this page (maybe b'c of the contentType). + return function() end + end + local subs = { + hook.sub("renderer-attached", function(args: { + id: number, + renderer: ReactRenderer, + rendererInterface: RendererInterface, + }) + local id = args.id + local rendererInterface = args.rendererInterface + + agent:setRendererInterface(id, rendererInterface) + + -- Now that the Store and the renderer interface are connected, + -- it's time to flush the pending operation codes to the frontend. + rendererInterface.flushInitialOperations() + end), + hook.sub("unsupported-renderer-version", function(id: number) + agent:onUnsupportedRenderer(id) + end), + + hook.sub("operations", function(...) + agent:onHookOperations(...) + end), + hook.sub("traceUpdates", function(...) + agent:onTraceUpdates(...) + end), + + -- TODO Add additional subscriptions required for profiling mode + } + + local attachRenderer = function(id: number, renderer: ReactRenderer) + -- ROBLOX deviation: require attach lazily to avoid the require of renderer causing Roact to initialize prematurely. + local attach = require(script.renderer).attach + + local rendererInterface = hook.rendererInterfaces:get(id) + + -- Inject any not-yet-injected renderers (if we didn't reload-and-profile) + if rendererInterface == nil then + if type(renderer.findFiberByHostInstance) == "function" then + -- react-reconciler v16+ + rendererInterface = attach(hook, id, renderer, global) + elseif renderer.ComponentTree then + -- react-dom v15 + -- ROBLOX deviation: Not needed + -- rendererInterface = attachLegacy(hook, id, renderer, global) + else + -- Older react-dom or other unsupported renderer version + end + if rendererInterface ~= nil then + hook.rendererInterfaces:set(id, rendererInterface) + end + end + + -- Notify the DevTools frontend about new renderers. + -- This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__). + if rendererInterface ~= nil then + hook.emit("renderer-attached", { + id = id, + renderer = renderer, + rendererInterface = rendererInterface, + }) + else + hook.emit("unsupported-renderer-version", id) + end + end + + -- Connect renderers that have already injected themselves. + hook.renderers:forEach(function(renderer, id) + attachRenderer(id, renderer) + end) + + -- Connect any new renderers that injected themselves. + table.insert( + subs, + hook.sub("renderer", function(args: { id: number, renderer: ReactRenderer }) + local id = args.id + local renderer = args.renderer + attachRenderer(id, renderer) + end) + ) + + hook.emit("react-devtools", agent) + hook.reactDevtoolsAgent = agent + local function onAgentShutdown() + Array.forEach(subs, function(fn) + fn() + end) + hook.rendererInterfaces:forEach(function(rendererInterface) + rendererInterface.cleanup() + end) + hook.reactDevtoolsAgent = nil + end + agent:addListener("shutdown", onAgentShutdown) + table.insert(subs, function() + agent:removeListener("shutdown", onAgentShutdown) + end) + + return function() + for _, fn in subs do + fn() + end + end +end + +return { + initBackend = initBackend, + agent = require(script.agent), + -- ROBLOX deviation: require renderer lazily to avoid causing React to initialize prematurely. + getRendererLazy = function() + return require(script.renderer) + end, + NativeStyleEditor = { + types = require(script.NativeStyleEditor.types), + }, +} diff --git a/modules/react-devtools-shared/src/backend/renderer.lua b/modules/react-devtools-shared/src/backend/renderer.lua new file mode 100644 index 00000000..4b18f2ca --- /dev/null +++ b/modules/react-devtools-shared/src/backend/renderer.lua @@ -0,0 +1,3539 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/renderer.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 Packages = script.Parent.Parent.Parent + +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local Shared = require(Packages.Shared) +local console = Shared.console +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Object = LuauPolyfill.Object +local Number = LuauPolyfill.Number +local String = LuauPolyfill.String +type Symbol = any + +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Set = LuauPolyfill.Set +type Object = LuauPolyfill.Object + +-- ROBLOX deviation: Use _G as a catch all for global for now +-- ROBLOX TODO: Work out a better capability-based solution +local window = ReactGlobals +local exports = {} + +local invariant = require(Packages.Shared).invariant + +-- ROBLOX deviation: we don't currently need semver, as we only support one version of React +-- local semver = require(semver) +-- local gte = semver.gte +local types = require(script.Parent.Parent.types) +local ComponentFilterDisplayName = types.ComponentFilterDisplayName +local ComponentFilterElementType = types.ComponentFilterElementType +local ComponentFilterHOC = types.ComponentFilterHOC +local ComponentFilterLocation = types.ComponentFilterLocation +local ElementTypeClass = types.ElementTypeClass +local ElementTypeContext = types.ElementTypeContext +local ElementTypeFunction = types.ElementTypeFunction +local ElementTypeForwardRef = types.ElementTypeForwardRef +local ElementTypeHostComponent = types.ElementTypeHostComponent +local ElementTypeMemo = types.ElementTypeMemo +local ElementTypeOtherOrUnknown = types.ElementTypeOtherOrUnknown +local ElementTypeProfiler = types.ElementTypeProfiler +local ElementTypeRoot = types.ElementTypeRoot +local ElementTypeSuspense = types.ElementTypeSuspense +local ElementTypeSuspenseList = types.ElementTypeSuspenseList +local utils = require(script.Parent.Parent.utils) +local deletePathInObject = utils.deletePathInObject +local getDisplayName = utils.getDisplayName +local getWrappedDisplayName = utils.getWrappedDisplayName +local getDefaultComponentFilters = utils.getDefaultComponentFilters +local getInObject = utils.getInObject +local getUID = utils.getUID +local renamePathInObject = utils.renamePathInObject +local setInObject = utils.setInObject +local utfEncodeString = utils.utfEncodeString +local storage = require(script.Parent.Parent.storage) +local sessionStorageGetItem = storage.sessionStorageGetItem +local backendUtils = require(script.Parent.utils) +local cleanForBridge = backendUtils.cleanForBridge +local copyToClipboard = backendUtils.copyToClipboard +local copyWithDelete = backendUtils.copyWithDelete +local copyWithRename = backendUtils.copyWithRename +local copyWithSet = backendUtils.copyWithSet +local constants = require(script.Parent.Parent.constants) +local __DEBUG__ = constants.__DEBUG__ +local SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = + constants.SESSION_STORAGE_RELOAD_AND_PROFILE_KEY +local SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + constants.SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY +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 ReactDebugTools = require(Packages.ReactDebugTools) +local inspectHooksOfFiber = ReactDebugTools.inspectHooksOfFiber +local Console = require(script.Parent.console) +local patchConsole = Console.patch +local registerRendererWithConsole = Console.registerRenderer +local ReactSymbols = require(script.Parent.ReactSymbols) +local CONCURRENT_MODE_NUMBER = ReactSymbols.CONCURRENT_MODE_NUMBER +local CONCURRENT_MODE_SYMBOL_STRING = ReactSymbols.CONCURRENT_MODE_SYMBOL_STRING +local DEPRECATED_ASYNC_MODE_SYMBOL_STRING = + ReactSymbols.DEPRECATED_ASYNC_MODE_SYMBOL_STRING +local PROVIDER_NUMBER = ReactSymbols.PROVIDER_NUMBER +local PROVIDER_SYMBOL_STRING = ReactSymbols.PROVIDER_SYMBOL_STRING +local CONTEXT_NUMBER = ReactSymbols.CONTEXT_NUMBER +local CONTEXT_SYMBOL_STRING = ReactSymbols.CONTEXT_SYMBOL_STRING +local STRICT_MODE_NUMBER = ReactSymbols.STRICT_MODE_NUMBER +local STRICT_MODE_SYMBOL_STRING = ReactSymbols.STRICT_MODE_SYMBOL_STRING +local PROFILER_NUMBER = ReactSymbols.PROFILER_NUMBER +local PROFILER_SYMBOL_STRING = ReactSymbols.PROFILER_SYMBOL_STRING +local SCOPE_NUMBER = ReactSymbols.SCOPE_NUMBER +local SCOPE_SYMBOL_STRING = ReactSymbols.SCOPE_SYMBOL_STRING +local FORWARD_REF_NUMBER = ReactSymbols.FORWARD_REF_NUMBER +local FORWARD_REF_SYMBOL_STRING = ReactSymbols.FORWARD_REF_SYMBOL_STRING +local MEMO_NUMBER = ReactSymbols.MEMO_NUMBER +local MEMO_SYMBOL_STRING = ReactSymbols.MEMO_SYMBOL_STRING +local is = Shared.objectIs +-- ROBLOX FIXME: pass in a real host config, or make this able to use basic enums without initializing +local ReactReconciler = require(Packages.ReactReconciler)({}) + +-- ROBLOX deviation: Require shared functionality rather than copying and pasting it inline +local getNearestMountedFiber = ReactReconciler.getNearestMountedFiber + +-- ROBLOX deviation: ReactInternalTypes is re-exported from top-level reconciler to respect the module encapsulation boundary +local ReactInternalTypes = require(Packages.ReactReconciler) +type Fiber = ReactInternalTypes.Fiber +local BackendTypes = require(script.Parent.types) +type ChangeDescription = BackendTypes.ChangeDescription +type CommitDataBackend = BackendTypes.CommitDataBackend +type DevToolsHook = BackendTypes.DevToolsHook +type InspectedElement = BackendTypes.InspectedElement +type InspectedElementPayload = BackendTypes.InspectedElementPayload +type InstanceAndStyle = BackendTypes.InstanceAndStyle +type NativeType = BackendTypes.NativeType +type Owner = BackendTypes.Owner +type PathFrame = BackendTypes.PathFrame +type PathMatch = BackendTypes.PathMatch +type ProfilingDataBackend = BackendTypes.ProfilingDataBackend +type ProfilingDataForRootBackend = BackendTypes.ProfilingDataForRootBackend +type ReactRenderer = BackendTypes.ReactRenderer +type RendererInterface = BackendTypes.RendererInterface +type WorkTagMap = BackendTypes.WorkTagMap + +local ProfilerTypes = require(script.Parent.Parent.devtools.views.Profiler.types) +type Interaction = ProfilerTypes.Interaction +local TypesModules = require(script.Parent.Parent.types) +type ComponentFilter = TypesModules.ComponentFilter +type ElementType = TypesModules.ElementType + +local ENCODE_TO_NULL = newproxy() :: { __T: "ENCODE_TO_NULL" } + +type RegExpComponentFilter = TypesModules.RegExpComponentFilter +type ElementTypeComponentFilter = TypesModules.ElementTypeComponentFilter + +type getDisplayNameForFiberType = (fiber: Fiber) -> string | nil +type getTypeSymbolType = (type: any) -> Symbol | number + +type ReactPriorityLevelsType = { + ImmediatePriority: number, + UserBlockingPriority: number, + NormalPriority: number, + LowPriority: number, + IdlePriority: number, + NoPriority: number, +} + +type ReactTypeOfSideEffectType = { + NoFlags: number, + PerformedWork: number, + Placement: number, +} + +local function getFiberFlags(fiber: Fiber): number + -- The name of this field changed from "effectTag" to "flags" + if fiber.flags ~= nil then + return fiber.flags + else + return (fiber :: any).effectTag + end +end + +local getCurrentTime = function() + -- ROBLOX deviation: use os.clock not performance + return os.clock() +end + +exports.getInternalReactConstants = function(version: string): { + getDisplayNameForFiber: getDisplayNameForFiberType, + getTypeSymbol: getTypeSymbolType, + ReactPriorityLevels: ReactPriorityLevelsType, + ReactTypeOfSideEffect: ReactTypeOfSideEffectType, + ReactTypeOfWork: WorkTagMap, +} + local ReactTypeOfSideEffect = { + NoFlags = 0, + PerformedWork = 1, + Placement = 2, + } + + -- ********************************************************** + -- The section below is copied from files in React repo. + -- Keep it in sync, and add version guards if it changes. + -- + -- Technically these priority levels are invalid for versions before 16.9, + -- but 16.9 is the first version to report priority level to DevTools, + -- so we can avoid checking for earlier versions and support pre-16.9 canary releases in the process. + local ReactPriorityLevels = { + ImmediatePriority = 99, + UserBlockingPriority = 98, + NormalPriority = 97, + LowPriority = 96, + IdlePriority = 95, + NoPriority = 90, + } + + -- ROBLOX deviation: we don't need to support older versions + -- if gte(version, '17.0.0-alpha') then + local ReactTypeOfWork: WorkTagMap = { + Block = 22, + ClassComponent = 1, + ContextConsumer = 9, + ContextProvider = 10, + CoroutineComponent = -1, + CoroutineHandlerPhase = -1, + DehydratedSuspenseComponent = 18, + ForwardRef = 11, + Fragment = 7, + FunctionComponent = 0, + HostComponent = 5, + HostPortal = 4, + HostRoot = 3, + HostText = 6, + IncompleteClassComponent = 17, + IndeterminateComponent = 2, + LazyComponent = 16, + MemoComponent = 14, + Mode = 8, + OffscreenComponent = 23, + Profiler = 12, + SimpleMemoComponent = 15, + SuspenseComponent = 13, + SuspenseListComponent = 19, + YieldComponent = -1, + } + -- elseif gte(version, '16.6.0-beta.0') then + -- ReactTypeOfWork = { + -- Block = 22, + -- ClassComponent = 1, + -- ContextConsumer = 9, + -- ContextProvider = 10, + -- CoroutineComponent = -1, + -- CoroutineHandlerPhase = -1, + -- DehydratedSuspenseComponent = 18, + -- ForwardRef = 11, + -- Fragment = 7, + -- FunctionComponent = 0, + -- HostComponent = 5, + -- HostPortal = 4, + -- HostRoot = 3, + -- HostText = 6, + -- IncompleteClassComponent = 17, + -- IndeterminateComponent = 2, + -- LazyComponent = 16, + -- MemoComponent = 14, + -- Mode = 8, + -- OffscreenComponent = -1, + -- Profiler = 12, + -- SimpleMemoComponent = 15, + -- SuspenseComponent = 13, + -- SuspenseListComponent = 19, + -- YieldComponent = -1, + -- } + -- elseif gte(version, '16.4.3-alpha') then + -- ReactTypeOfWork = { + -- Block = -1, + -- ClassComponent = 2, + -- ContextConsumer = 11, + -- ContextProvider = 12, + -- CoroutineComponent = -1, + -- CoroutineHandlerPhase = -1, + -- DehydratedSuspenseComponent = -1, + -- ForwardRef = 13, + -- Fragment = 9, + -- FunctionComponent = 0, + -- HostComponent = 7, + -- HostPortal = 6, + -- HostRoot = 5, + -- HostText = 8, + -- IncompleteClassComponent = -1, + -- IndeterminateComponent = 4, + -- LazyComponent = -1, + -- MemoComponent = -1, + -- Mode = 10, + -- OffscreenComponent = -1, + -- Profiler = 15, + -- SimpleMemoComponent = -1, + -- SuspenseComponent = 16, + -- SuspenseListComponent = -1, + -- YieldComponent = -1, + -- } + -- else + -- ReactTypeOfWork = { + -- Block = -1, + -- ClassComponent = 2, + -- ContextConsumer = 12, + -- ContextProvider = 13, + -- CoroutineComponent = 7, + -- CoroutineHandlerPhase = 8, + -- DehydratedSuspenseComponent = -1, + -- ForwardRef = 14, + -- Fragment = 10, + -- FunctionComponent = 1, + -- HostComponent = 5, + -- HostPortal = 4, + -- HostRoot = 3, + -- HostText = 6, + -- IncompleteClassComponent = -1, + -- IndeterminateComponent = 0, + -- LazyComponent = -1, + -- MemoComponent = -1, + -- Mode = 11, + -- OffscreenComponent = -1, + -- Profiler = 15, + -- SimpleMemoComponent = -1, + -- SuspenseComponent = 16, + -- SuspenseListComponent = -1, + -- YieldComponent = 9, + -- } + -- end + + -- // ********************************************************** + -- // End of copied code. + -- // ********************************************************** + + local function getTypeSymbol(type_: any): Symbol | number + local symbolOrNumber = if typeof(type_) == "table" + then type_["$$typeof"] + else type_ + + -- ROBLOX deviation: symbol is not a native Luau type + return if typeof(symbolOrNumber) == "table" + then tostring(symbolOrNumber) + else symbolOrNumber + end + + local ClassComponent, IncompleteClassComponent, FunctionComponent, IndeterminateComponent, ForwardRef, HostRoot, HostComponent, HostPortal, HostText, Fragment, MemoComponent, SimpleMemoComponent, SuspenseComponent, SuspenseListComponent = + ReactTypeOfWork.ClassComponent, + ReactTypeOfWork.IncompleteClassComponent, + ReactTypeOfWork.FunctionComponent, + ReactTypeOfWork.IndeterminateComponent, + ReactTypeOfWork.ForwardRef, + ReactTypeOfWork.HostRoot, + ReactTypeOfWork.HostComponent, + ReactTypeOfWork.HostPortal, + ReactTypeOfWork.HostText, + ReactTypeOfWork.Fragment, + ReactTypeOfWork.MemoComponent, + ReactTypeOfWork.SimpleMemoComponent, + ReactTypeOfWork.SuspenseComponent, + ReactTypeOfWork.SuspenseListComponent + + local function resolveFiberType(type_: any) + local typeSymbol = getTypeSymbol(type_) + if typeSymbol == MEMO_NUMBER or typeSymbol == MEMO_SYMBOL_STRING then + -- recursively resolving memo type in case of memo(forwardRef(Component)) + return resolveFiberType(type_.type) + elseif + typeSymbol == FORWARD_REF_NUMBER + or typeSymbol == FORWARD_REF_SYMBOL_STRING + then + return type_.render + else + return type_ + end + end + + -- NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + local function getDisplayNameForFiber(fiber: Fiber): string | nil + local type_, tag = fiber.type, fiber.tag + local resolvedType = type_ + + if typeof(type_) == "table" and type_ ~= nil then + resolvedType = resolveFiberType(type_) + end + + local resolvedContext = nil + if tag == ClassComponent or tag == IncompleteClassComponent then + return getDisplayName(resolvedType) + elseif tag == FunctionComponent or tag == IndeterminateComponent then + return getDisplayName(resolvedType) + elseif tag == ForwardRef then + return getWrappedDisplayName( + fiber.elementType, + resolvedType, + "ForwardRef", + "Anonymous" + ) + elseif tag == HostRoot then + return nil + elseif tag == HostComponent then + return type_ + elseif tag == HostPortal or tag == HostText or tag == Fragment then + return nil + elseif tag == MemoComponent or tag == SimpleMemoComponent then + return getDisplayName(resolvedType, "Anonymous") + elseif tag == SuspenseComponent then + return "Suspense" + elseif tag == SuspenseListComponent then + return "SuspenseList" + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + then + return nil + elseif + typeSymbol == PROVIDER_NUMBER or typeSymbol == PROVIDER_SYMBOL_STRING + then + -- 16.3.0 exposed the context object as "context" + -- PR #12501 changed it to "_context" for 16.3.1+ + -- NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context or fiber.type.context + return string.format( + "%s.Provider", + resolvedContext.displayName or "Context" + ) + elseif + typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING + then + -- 16.3-16.5 read from "type" because the Consumer is the actual context object. + -- 16.6+ should read from "type._context" because Consumer can be different (in DEV). + -- NOTE Keep in sync with inspectElementRaw() + resolvedContext = fiber.type._context or fiber.type + + -- NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer' + -- If you change the name, figure out a more resilient way to detect it. + return string.format( + "%s.Consumer", + resolvedContext.displayName or "Context" + ) + elseif + typeSymbol == STRICT_MODE_NUMBER + or typeSymbol == STRICT_MODE_SYMBOL_STRING + then + return nil + elseif + typeSymbol == PROFILER_NUMBER or typeSymbol == PROFILER_SYMBOL_STRING + then + return string.format("Profiler(%s)", fiber.memoizedProps.id) + elseif typeSymbol == SCOPE_NUMBER or typeSymbol == SCOPE_SYMBOL_STRING then + return "Scope" + else + -- Unknown element type. + -- This may mean a new element type that has not yet been added to DevTools. + return nil + end + end + end + + return { + getDisplayNameForFiber = getDisplayNameForFiber, + getTypeSymbol = getTypeSymbol, + ReactPriorityLevels = ReactPriorityLevels, + ReactTypeOfWork = ReactTypeOfWork, + ReactTypeOfSideEffect = ReactTypeOfSideEffect, + } +end + +exports.attach = function( + hook: DevToolsHook, + rendererID: number, + renderer: ReactRenderer, + global: Object +): RendererInterface + -- ROBLOX deviation: these definitions have been hoisted to top of function for earlier use + local fiberToIDMap: Map = Map.new() :: Map + local idToFiberMap: Map = Map.new() :: Map + local primaryFibers: Set = Set.new() :: Set + + -- When profiling is supported, we store the latest tree base durations for each Fiber. + -- This is so that we can quickly capture a snapshot of those values if profiling starts. + -- If we didn't store these values, we'd have to crawl the tree when profiling started, + -- and use a slow path to find each of the current Fibers. + local idToTreeBaseDurationMap: Map = Map.new() :: Map + + -- When profiling is supported, we store the latest tree base durations for each Fiber. + -- This map enables us to filter these times by root when sending them to the frontend. + local idToRootMap: Map = Map.new() :: Map + + -- When a mount or update is in progress, this value tracks the root that is being operated on. + local currentRootID: number = -1 + + local function getFiberID(primaryFiber: Fiber) + if not fiberToIDMap:has(primaryFiber) then + local id = getUID() + fiberToIDMap:set(primaryFiber, id) + idToFiberMap:set(id, primaryFiber) + end + + return (fiberToIDMap:get(primaryFiber) :: any) :: number + end + + local _getInternalReactCons = exports.getInternalReactConstants(renderer.version) + local getDisplayNameForFiber, getTypeSymbol, ReactPriorityLevels, ReactTypeOfWork, ReactTypeOfSideEffect = + _getInternalReactCons.getDisplayNameForFiber, + _getInternalReactCons.getTypeSymbol, + _getInternalReactCons.ReactPriorityLevels, + _getInternalReactCons.ReactTypeOfWork, + _getInternalReactCons.ReactTypeOfSideEffect + local PerformedWork = ReactTypeOfSideEffect.PerformedWork + local FunctionComponent, ClassComponent, ContextConsumer, DehydratedSuspenseComponent, Fragment, ForwardRef, HostRoot, HostPortal, HostComponent, HostText, IncompleteClassComponent, IndeterminateComponent, MemoComponent, OffscreenComponent, SimpleMemoComponent, SuspenseComponent, SuspenseListComponent = + ReactTypeOfWork.FunctionComponent, + ReactTypeOfWork.ClassComponent, + ReactTypeOfWork.ContextConsumer, + ReactTypeOfWork.DehydratedSuspenseComponent, + ReactTypeOfWork.Fragment, + ReactTypeOfWork.ForwardRef, + ReactTypeOfWork.HostRoot, + ReactTypeOfWork.HostPortal, + ReactTypeOfWork.HostComponent, + ReactTypeOfWork.HostText, + ReactTypeOfWork.IncompleteClassComponent, + ReactTypeOfWork.IndeterminateComponent, + ReactTypeOfWork.MemoComponent, + ReactTypeOfWork.OffscreenComponent, + ReactTypeOfWork.SimpleMemoComponent, + ReactTypeOfWork.SuspenseComponent, + ReactTypeOfWork.SuspenseListComponent + local ImmediatePriority, UserBlockingPriority, NormalPriority, LowPriority, IdlePriority = + ReactPriorityLevels.ImmediatePriority, + ReactPriorityLevels.UserBlockingPriority, + ReactPriorityLevels.NormalPriority, + ReactPriorityLevels.LowPriority, + ReactPriorityLevels.IdlePriority + + -- ROBLOX deviation: these need binding to self + local overrideHookState = renderer.overrideHookState + local overrideHookStateDeletePath = renderer.overrideHookStateDeletePath + local overrideHookStateRenamePath = renderer.overrideHookStateRenamePath + local overrideProps = renderer.overrideProps + local overridePropsDeletePath = renderer.overridePropsDeletePath + local overridePropsRenamePath = renderer.overridePropsRenamePath + local setSuspenseHandler = renderer.setSuspenseHandler + local scheduleUpdate = renderer.scheduleUpdate + + local supportsTogglingSuspense = typeof(setSuspenseHandler) == "function" + and typeof(scheduleUpdate) == "function" + + -- Patching the console enables DevTools to do a few useful things: + -- * Append component stacks to warnings and error messages + -- * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber) + -- + -- Don't patch in test environments because we don't want to interfere with Jest's own console overrides. + -- ROBLOX deviation: instead of checking if `process.env.NODE_ENV ~= "production"` + -- we use the __DEV__ global + if ReactGlobals.__DEV__ then + registerRendererWithConsole(renderer) + + -- The renderer interface can't read these preferences directly, + -- because it is stored in localStorage within the context of the extension. + -- It relies on the extension to pass the preference through via the global. + local appendComponentStack = window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ + ~= false + local breakOnConsoleErrors = window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ + == true + + if appendComponentStack or breakOnConsoleErrors then + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + end + end + + local debug_ = function(name: string, fiber: Fiber, parentFiber: Fiber?): () + if __DEBUG__ then + -- ROBLOX deviation: Use string nil rather than null as it is Roblox convenion + local displayName = getDisplayNameForFiber(fiber) or "nil" + local id = getFiberID(fiber) + local parentDisplayName = if parentFiber ~= nil + then getDisplayNameForFiber(parentFiber :: Fiber) + else "nil" + local parentID = if parentFiber then getFiberID(parentFiber :: Fiber) else "" + -- NOTE: calling getFiberID or getPrimaryFiber is unsafe here + -- because it will put them in the map. For now, we'll omit them. + -- TODO: better debugging story for this. + -- ROBLOX deviation: avoid incompatible log formatting + console.log( + string.format( + "[renderer] %s %s (%d) %s", + name, + displayName, + id, + if parentFiber + then string.format( + "%s (%s)", + tostring(parentDisplayName), + tostring(parentID) + ) + else "" + ) + ) + end + end + + -- Configurable Components tree filters. + -- ROBLOX deviation: adjusted to use Lua patterns, but we may actually want original RegExp + local hideElementsWithDisplayNames: Set = Set.new() + local hideElementsWithPaths: Set = Set.new() + local hideElementsWithTypes: Set = Set.new() + + -- ROBLOX deviation: local variables need to be defined above their use in closures + -- Roots don't have a real persistent identity. + -- A root's "pseudo key" is "childDisplayName:indexWithThatName". + -- For example, "App:0" or, in case of similar roots, "Story:0", "Story:1", etc. + -- We will use this to try to disambiguate roots when restoring selection between reloads. + local rootPseudoKeys: Map = Map.new() + local rootDisplayNameCounter: Map = Map.new() + + -- ROBLOX deviation: definitions hoisted earlier in function + local currentCommitProfilingMetadata: CommitProfilingData | nil = nil + local displayNamesByRootID: DisplayNamesByRootID | nil = nil + local idToContextsMap: Map | nil = nil + local initialTreeBaseDurationsMap: Map | nil = nil + local initialIDToRootMap: Map | nil = nil + local isProfiling: boolean = false + local profilingStartTime: number = 0 + local recordChangeDescriptions: boolean = false + local rootToCommitProfilingMetadataMap: CommitProfilingMetadataMap | nil = nil + + local mostRecentlyInspectedElement: InspectedElement | nil = nil + local hasElementUpdatedSinceLastInspected: boolean = false + local currentlyInspectedPaths: Object = {} + + local forceFallbackForSuspenseIDs = Set.new() + + -- Highlight updates + local traceUpdatesEnabled: boolean = false + local traceUpdatesForNodes: Set = Set.new() + + -- ROBLOX deviation: hoise local variables + -- Remember if we're trying to restore the selection after reload. + -- In that case, we'll do some extra checks for matching mounts. + local trackedPath: Array | nil = nil + local trackedPathMatchFiber: Fiber | nil = nil + local trackedPathMatchDepth = -1 + local mightBeOnTrackedPath = false + + -- ROBLOX deviation: hoist function variables + local getChangedKeys: (prev: any, next_: any) -> nil | Array + local mountFiberRecursively: ( + fiber: Fiber, + parentFiber: Fiber | nil, + traverseSiblings: boolean, + traceNearestHostComponentUpdate: boolean + ) -> () + local findAllCurrentHostFibers: (id: number) -> Array + local setTrackedPath: (path: Array | nil) -> () + local getPrimaryFiber, unmountFiberChildrenRecursively, recordUnmount, setRootPseudoKey, removeRootPseudoKey, flushPendingEvents, getElementTypeForFiber, getContextChangedKeys, didHooksChange, getContextsForFiber, getDisplayNameForRoot, recordProfilingDurations, updateTrackedPathStateBeforeMount, updateTrackedPathStateAfterMount, findReorderedChildrenRecursively, findCurrentFiberUsingSlowPathById, isMostRecentlyInspectedElementCurrent, getPathFrame + + local function applyComponentFilters(componentFilters: Array) + hideElementsWithTypes:clear() + hideElementsWithDisplayNames:clear() + hideElementsWithPaths:clear() + -- ROBLOX TODO: translate to Array.forEach + for _, componentFilter in componentFilters do + if not componentFilter.isEnabled then + continue + end + if componentFilter.type == ComponentFilterDisplayName then + -- ROBLOX deviation: use value directly as pattern rather than creating a RegExp + hideElementsWithDisplayNames:add( + (componentFilter :: RegExpComponentFilter).value + ) + elseif componentFilter.type == ComponentFilterElementType then + hideElementsWithTypes:add( + (componentFilter :: ElementTypeComponentFilter).value + ) + elseif componentFilter.type == ComponentFilterLocation then + if + (componentFilter :: RegExpComponentFilter).isValid + and (componentFilter :: RegExpComponentFilter).value ~= "" + then + -- ROBLOX deviation: use value directly as pattern rather than creating a RegExp + hideElementsWithPaths:add( + (componentFilter :: RegExpComponentFilter).value + ) + end + elseif componentFilter.type == ComponentFilterHOC then + hideElementsWithDisplayNames:add("%(") + else + console.warn( + string.format( + 'Invalid component filter type "%d"', + componentFilter.type + ) + ) + end + end + end + + -- The renderer interface can't read saved component filters directly, + -- because they are stored in localStorage within the context of the extension. + -- Instead it relies on the extension to pass filters through. + if window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ ~= nil then + applyComponentFilters(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__) + else + -- Unfortunately this feature is not expected to work for React Native for now. + -- It would be annoying for us to spam YellowBox warnings with unactionable stuff, + -- so for now just skip this message... + --console.warn('⚛️ DevTools: Could not locate saved component filters'); + + -- Fallback to assuming the default filters in this case. + applyComponentFilters(getDefaultComponentFilters()) + end + + -- If necessary, we can revisit optimizing this operation. + -- For example, we could add a new recursive unmount tree operation. + -- The unmount operations are already significantly smaller than mount operations though. + -- This is something to keep in mind for later. + local function updateComponentFilters(componentFilters: Array) + if isProfiling then + -- Re-mounting a tree while profiling is in progress might break a lot of assumptions. + -- If necessary, we could support this- but it doesn't seem like a necessary use case. + error("Cannot modify filter preferences while profiling") + end + + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current)) + unmountFiberChildrenRecursively(root.current) + recordUnmount(root.current, false) + currentRootID = -1 + end) + + applyComponentFilters(componentFilters) + + -- Reset pseudo counters so that new path selections will be persisted. + rootDisplayNameCounter:clear() + + -- Recursively re-mount all roots with new filter criteria applied. + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current :: Fiber)) + + setRootPseudoKey(currentRootID, root.current :: Fiber) + mountFiberRecursively(root.current :: Fiber, nil, false, false) + flushPendingEvents(root) + + currentRootID = -1 + end) + end + + -- NOTICE Keep in sync with get*ForFiber methods + local function shouldFilterFiber(fiber: Fiber): boolean + local _debugSource, tag, type_ = fiber._debugSource, fiber.tag, fiber.type + + if tag == DehydratedSuspenseComponent then + -- TODO: ideally we would show dehydrated Suspense immediately. + -- However, it has some special behavior (like disconnecting + -- an alternate and turning into real Suspense) which breaks DevTools. + -- For now, ignore it, and only show it once it gets hydrated. + -- https://github.com/bvaughn/react-devtools-experimental/issues/197 + return true + elseif + tag == HostPortal + or tag == HostText + or tag == Fragment + or tag == OffscreenComponent + then + return true + elseif tag == HostRoot then + -- It is never valid to filter the root element. + return false + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + or typeSymbol == STRICT_MODE_NUMBER + or typeSymbol == STRICT_MODE_SYMBOL_STRING + then + return true + end + end + + local elementType = getElementTypeForFiber(fiber) + + if hideElementsWithTypes:has(elementType) then + return true + end + if hideElementsWithDisplayNames.size > 0 then + local displayName = getDisplayNameForFiber(fiber) + if displayName ~= nil then + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, displayNameRegExp in hideElementsWithDisplayNames do + -- ROBLOX deviation: these are patterns not RegExps + if string.match(displayName :: string, displayNameRegExp) then + return true + end + end + end + end + if _debugSource ~= nil and hideElementsWithPaths.size > 0 then + local fileName = _debugSource.fileName + + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, pathRegExp in hideElementsWithPaths do + -- ROBLOX deviation: these are patterns not RegExps + if string.match(fileName, pathRegExp) then + return true + end + end + end + + return false + end + + -- NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods + getElementTypeForFiber = function(fiber: Fiber): ElementType + local type_, tag = fiber.type, fiber.tag + + if tag == ClassComponent or tag == IncompleteClassComponent then + return ElementTypeClass + elseif tag == FunctionComponent or tag == IndeterminateComponent then + return ElementTypeFunction + elseif tag == ForwardRef then + return ElementTypeForwardRef + elseif tag == HostRoot then + return ElementTypeRoot + elseif tag == HostComponent then + return ElementTypeHostComponent + elseif tag == HostPortal or tag == HostText or tag == Fragment then + return ElementTypeOtherOrUnknown + elseif tag == MemoComponent or tag == SimpleMemoComponent then + return ElementTypeMemo + elseif tag == SuspenseComponent then + return ElementTypeSuspense + elseif tag == SuspenseListComponent then + return ElementTypeSuspenseList + else + local typeSymbol = getTypeSymbol(type_) + if + typeSymbol == CONCURRENT_MODE_NUMBER + or typeSymbol == CONCURRENT_MODE_SYMBOL_STRING + or typeSymbol == DEPRECATED_ASYNC_MODE_SYMBOL_STRING + then + return ElementTypeContext + elseif + typeSymbol == PROVIDER_NUMBER or typeSymbol == PROVIDER_SYMBOL_STRING + then + return ElementTypeContext + elseif + typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING + then + return ElementTypeContext + elseif + typeSymbol == STRICT_MODE_NUMBER + or typeSymbol == STRICT_MODE_SYMBOL_STRING + then + return ElementTypeOtherOrUnknown + elseif + typeSymbol == PROFILER_NUMBER or typeSymbol == PROFILER_SYMBOL_STRING + then + return ElementTypeProfiler + else + return ElementTypeOtherOrUnknown + end + end + end + + -- This is a slightly annoying indirection. + -- It is currently necessary because DevTools wants to use unique objects as keys for instances. + -- However fibers have two versions. + -- We use this set to remember first encountered fiber for each conceptual instance. + getPrimaryFiber = function(fiber: Fiber): Fiber + if primaryFibers:has(fiber) then + return fiber + end + + local alternate = fiber.alternate + + if alternate ~= nil and primaryFibers:has(alternate) then + return alternate :: Fiber + end + + primaryFibers:add(fiber) + + return fiber + end + + local function getChangeDescription( + prevFiber: Fiber | nil, + nextFiber: Fiber + ): ChangeDescription | nil + local fiberType = getElementTypeForFiber(nextFiber) + if + fiberType == ElementTypeClass + or fiberType == ElementTypeFunction + or fiberType == ElementTypeMemo + or fiberType == ElementTypeForwardRef + -- ROBLOX deviation: Include host components in the report + or fiberType == ElementTypeHostComponent + then + if prevFiber == nil then + return { + context = ENCODE_TO_NULL, + didHooksChange = false, + isFirstMount = true, + props = ENCODE_TO_NULL, + state = ENCODE_TO_NULL, + } + else + local context = getContextChangedKeys(nextFiber) + local props = + getChangedKeys(prevFiber.memoizedProps, nextFiber.memoizedProps) + local state = + getChangedKeys(prevFiber.memoizedState, nextFiber.memoizedState) + + return { + context = if context == nil then ENCODE_TO_NULL else context, + didHooksChange = didHooksChange( + (prevFiber :: Fiber).memoizedState, + nextFiber.memoizedState + ), + isFirstMount = false, + props = if props == nil then ENCODE_TO_NULL else props, + state = if state == nil then ENCODE_TO_NULL else state, + } + end + else + return nil + end + end + + local function updateContextsForFiber(fiber: Fiber) + if getElementTypeForFiber(fiber) == ElementTypeClass then + if idToContextsMap ~= nil then + local id = getFiberID(getPrimaryFiber(fiber)) + local contexts = getContextsForFiber(fiber) + if contexts ~= nil then + idToContextsMap:set(id, contexts) + end + end + end + end + + -- Differentiates between a null context value and no context. + local NO_CONTEXT = {} + + -- ROBLOX deviation: Luau can't express return type: [Object, any] + getContextsForFiber = function(fiber: Fiber): Array | nil + if getElementTypeForFiber(fiber) == ElementTypeClass then + local instance = fiber.stateNode + local legacyContext = NO_CONTEXT + local modernContext = NO_CONTEXT + if instance ~= nil then + if instance.constructor and instance.constructor.contextType ~= nil then + modernContext = instance.context + else + legacyContext = instance.context + if legacyContext and #Object.keys(legacyContext) == 0 then + legacyContext = NO_CONTEXT + end + end + end + return { legacyContext, modernContext } + end + return nil + end + + -- Record all contexts at the time profiling is started. + -- Fibers only store the current context value, + -- so we need to track them separately in order to determine changed keys. + local function crawlToInitializeContextsMap(fiber: Fiber) + updateContextsForFiber(fiber) + local current = fiber.child + while current ~= nil do + crawlToInitializeContextsMap(current :: Fiber) + current = (current :: Fiber).sibling + end + end + + getContextChangedKeys = function(fiber: Fiber): nil | boolean | Array + if getElementTypeForFiber(fiber) == ElementTypeClass then + if idToContextsMap ~= nil then + local id = getFiberID(getPrimaryFiber(fiber)) + -- ROBLOX TODO? optimize this pattern into just the get + local prevContexts = if idToContextsMap:has(id) + then idToContextsMap:get(id) + else nil + local nextContexts = getContextsForFiber(fiber) + + if prevContexts == nil or nextContexts == nil then + return nil + end + + local prevLegacyContext, prevModernContext = + prevContexts[1], prevContexts[2] + local nextLegacyContext, nextModernContext = + (nextContexts :: Array)[1], (nextContexts :: Array)[2] + + if nextLegacyContext ~= NO_CONTEXT then + return getChangedKeys(prevLegacyContext, nextLegacyContext) + elseif nextModernContext ~= NO_CONTEXT then + return prevModernContext ~= nextModernContext + end + end + end + return nil + end + local function getHighestIndex(array: Array) + local highestIndex = 0 + for k, v in array do + highestIndex = if k > highestIndex then k else highestIndex + end + return highestIndex + end + local function areHookInputsEqual(nextDeps: Array, prevDeps_: Array?) + if prevDeps_ == nil then + return false + end + local prevDeps = prevDeps_ :: Array + + local prevDepLength = getHighestIndex(prevDeps) + local nextDepLength = getHighestIndex(nextDeps) + + if prevDepLength ~= nextDepLength then + return false + end + + for i = 1, prevDepLength do + if not is(nextDeps[i], prevDeps[i]) then + return false + end + end + return true + end + + local function isEffect(memoizedState) + return memoizedState ~= nil + and typeof(memoizedState) == "table" + and memoizedState.tag ~= nil + and memoizedState.create ~= nil + and memoizedState.destroy ~= nil + and memoizedState.deps ~= nil + and (memoizedState.deps == nil or Array.isArray(memoizedState.deps)) + and memoizedState.next + end + + local function didHookChange(prev: any, next: any): boolean + local prevMemoizedState = prev.memoizedState + local nextMemoizedState = next.memoizedState + + if isEffect(prevMemoizedState) and isEffect(nextMemoizedState) then + return prevMemoizedState ~= nextMemoizedState + and not areHookInputsEqual(nextMemoizedState.deps, prevMemoizedState.deps) + end + return nextMemoizedState ~= prevMemoizedState + end + didHooksChange = function(prev: any, next_: any): boolean + if prev == nil or next_ == nil then + return false + end + -- We can't report anything meaningful for hooks changes. + -- ROBLOX deviation: hasOwnProperty doesn't exist + if + next_["baseState"] + and next_["memoizedState"] + and next_["next"] + and next_["queue"] + then + while next_ ~= nil do + -- ROBLOX deviation START: use didHookChange instead of equality check + if didHookChange(prev, next_) then + -- ROBLOX deviation END + return true + else + next_ = next_.next + prev = prev.next + end + end + end + + return false + end + getChangedKeys = function(prev: any, next_: any): nil | Array + if prev == nil or next_ == nil then + return nil + end + -- We can't report anything meaningful for hooks changes. + -- ROBLOX deviation: hasOwnProperty doesn't exist + if + next_["baseState"] ~= nil + and next_["memoizedState"] ~= nil + and next_["next"] ~= nil + and next_["queue"] ~= nil + then + return nil + end + + local keys = Set.new(Array.concat(Object.keys(prev), Object.keys(next_))) + local changedKeys = {} + -- -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, key in keys do + if prev[key] ~= next_[key] then + table.insert(changedKeys, key) + end + end + + return changedKeys + end + + -- eslint-disable-next-line no-unused-vars + local function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean + local tag = nextFiber.tag + if + tag == ClassComponent + or tag == FunctionComponent + or tag == ContextConsumer + or tag == MemoComponent + or tag == SimpleMemoComponent + then + -- For types that execute user code, we check PerformedWork effect. + -- We don't reflect bailouts (either referential or sCU) in DevTools. + -- eslint-disable-next-line no-bitwise + return bit32.band(getFiberFlags(nextFiber), PerformedWork) == PerformedWork + else + -- Note: ContextConsumer only gets PerformedWork effect in 16.3.3+ + -- so it won't get highlighted with React 16.3.0 to 16.3.2. + -- For host components and other types, we compare inputs + -- to determine whether something is an update. + return prevFiber.memoizedProps ~= nextFiber.memoizedProps + or prevFiber.memoizedState ~= nextFiber.memoizedState + or prevFiber.ref ~= nextFiber.ref + end + end + + local pendingOperations: Array = {} + local pendingRealUnmountedIDs: Array = {} + local pendingSimulatedUnmountedIDs: Array = {} + local pendingOperationsQueue: Array> | nil = {} + local pendingStringTable: Map = Map.new() + local pendingStringTableLength: number = 0 + local pendingUnmountedRootID: number | nil = nil + + local function pushOperation(op: number): () + -- ROBLOX deviation: Use global + if global.__DEV__ then + if not Number.isInteger(op) then + console.error( + "pushOperation() was called but the value is not an integer.", + op + ) + end + end + table.insert(pendingOperations, op) + end + flushPendingEvents = function(root: Object): () + if + #pendingOperations == 0 + and #pendingRealUnmountedIDs == 0 + and #pendingSimulatedUnmountedIDs == 0 + and pendingUnmountedRootID == nil + then + -- If we aren't profiling, we can just bail out here. + -- No use sending an empty update over the bridge. + -- + -- The Profiler stores metadata for each commit and reconstructs the app tree per commit using: + -- (1) an initial tree snapshot and + -- (2) the operations array for each commit + -- Because of this, it's important that the operations and metadata arrays align, + -- So it's important not to omit even empty operations while profiling is active. + if not isProfiling then + return + end + end + + local numUnmountIDs = #pendingRealUnmountedIDs + + #pendingSimulatedUnmountedIDs + + (if pendingUnmountedRootID == nil then 0 else 1) + local operations: Array = {} + -- ROBLOX deviation: don't create an array of specified length + -- Identify which renderer this update is coming from. + -- 2 -- [rendererID, rootFiberID] + -- -- How big is the string table? + -- + 1 -- [stringTableLength] + -- -- Then goes the actual string table. + -- + pendingStringTableLength + -- -- All unmounts are batched in a single message. + -- -- [TREE_OPERATION_REMOVE, removedIDLength, ...ids] + -- + numUnmountIDs + -- > 0 + -- and (2 + numUnmountIDs) + -- or 0 + -- -- Regular operations + -- + #pendingOperations + + -- Identify which renderer this update is coming from. + -- This enables roots to be mapped to renderers, + -- Which in turn enables fiber props, states, and hooks to be inspected. + local i = 1 + + -- ROBLOX deviation: instead of i++ + local function POSTFIX_INCREMENT() + local prevI = i + i += 1 + return prevI + end + + operations[POSTFIX_INCREMENT()] = rendererID + operations[POSTFIX_INCREMENT()] = currentRootID -- Use this ID in case the root was unmounted! + + -- Now fill in the string table. + -- [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...] + operations[POSTFIX_INCREMENT()] = pendingStringTableLength + pendingStringTable:forEach(function(_value, key) + local encodedKey = utfEncodeString(key) + operations[POSTFIX_INCREMENT()] = #encodedKey + for _, codepoint in encodedKey do + operations[POSTFIX_INCREMENT()] = codepoint + end + end) + + if numUnmountIDs > 0 then + -- All unmounts except roots are batched in a single message. + operations[POSTFIX_INCREMENT()] = TREE_OPERATION_REMOVE :: number + -- The first number is how many unmounted IDs we're gonna send. + operations[POSTFIX_INCREMENT()] = numUnmountIDs :: number + + -- Fill in the real unmounts in the reverse order. + -- They were inserted parents-first by React, but we want children-first. + -- So we traverse our array backwards. + for j = #pendingRealUnmountedIDs, 1, -1 do + operations[POSTFIX_INCREMENT()] = pendingRealUnmountedIDs[j] :: number + end + + -- Fill in the simulated unmounts (hidden Suspense subtrees) in their order. + -- (We want children to go before parents.) + -- They go *after* the real unmounts because we know for sure they won't be + -- children of already pushed "real" IDs. If they were, we wouldn't be able + -- to discover them during the traversal, as they would have been deleted. + for j = 1, #pendingSimulatedUnmountedIDs do + operations[i + j - 1] = pendingSimulatedUnmountedIDs[j] :: number + end + + i = i + #pendingSimulatedUnmountedIDs + + -- The root ID should always be unmounted last. + if pendingUnmountedRootID ~= nil then + operations[i] = pendingUnmountedRootID :: number + i = i + 1 + end + end + + -- Fill in the rest of the operations. + for j = 1, #pendingOperations do + -- ROBLOX deviation: 1-indexing math + operations[i + j - 1] = pendingOperations[j] :: number + end + + i = i + #pendingOperations + + -- Let the frontend know about tree operations. + -- The first value in this array will identify which root it corresponds to, + -- so we do no longer need to dispatch a separate root-committed event. + if pendingOperationsQueue ~= nil then + -- Until the frontend has been connected, store the tree operations. + -- This will let us avoid walking the tree later when the frontend connects, + -- and it enables the Profiler's reload-and-profile functionality to work as well. + table.insert(pendingOperationsQueue :: Array, operations) + else + -- If we've already connected to the frontend, just pass the operations through. + hook.emit("operations", operations) + end + + -- ROBLOX deviation: replace table instead of truncating it + pendingOperations = {} + pendingRealUnmountedIDs = {} + pendingSimulatedUnmountedIDs = {} + pendingUnmountedRootID = nil + pendingStringTable:clear() + pendingStringTableLength = 0 + end + + local function getStringID(str: string | nil): number + if str == nil then + return 0 + end + + local existingEntry = pendingStringTable:get(str) + if existingEntry ~= nil then + return existingEntry + end + + local id = pendingStringTable.size + 1 + local encodedString = utfEncodeString(str) + + pendingStringTable:set(str, id) + + -- The string table total length needs to account + -- both for the string length, and for the array item + -- that contains the length itself. Hence + 1. + pendingStringTableLength += #encodedString + 1 + return id + end + + local function recordMount(fiber: Fiber, parentFiber: Fiber | nil) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("recordMount()", fiber, parentFiber) + end + + local isRoot = fiber.tag == HostRoot + local id = getFiberID(getPrimaryFiber(fiber)) + local hasOwnerMetadata = fiber["_debugOwner"] ~= nil + local isProfilingSupported = fiber["treeBaseDuration"] ~= nil + + if isRoot then + pushOperation(TREE_OPERATION_ADD) + pushOperation(id) + pushOperation(ElementTypeRoot) + pushOperation(if isProfilingSupported then 1 else 0) + pushOperation(if hasOwnerMetadata then 1 else 0) + + if isProfiling then + if displayNamesByRootID ~= nil then + (displayNamesByRootID :: Map):set( + id, + getDisplayNameForRoot(fiber) + ) + end + end + else + local key = fiber.key + local displayName = getDisplayNameForFiber(fiber) + local elementType = getElementTypeForFiber(fiber) + local _debugOwner = fiber._debugOwner + local ownerID = if _debugOwner ~= nil + then getFiberID(getPrimaryFiber(_debugOwner :: Fiber)) + else 0 + local parentID = if Boolean.toJSBoolean(parentFiber) + then getFiberID(getPrimaryFiber(parentFiber :: Fiber)) + else 0 + + local displayNameStringID = getStringID(displayName) + + -- This check is a guard to handle a React element that has been modified + -- in such a way as to bypass the default stringification of the "key" property. + local keyString = if key == nil then nil else tostring(key) + local keyStringID = getStringID(keyString) + + pushOperation(TREE_OPERATION_ADD) + pushOperation(id) + pushOperation(elementType) + pushOperation(parentID) + pushOperation(ownerID) + pushOperation(displayNameStringID) + pushOperation(keyStringID) + end + if isProfilingSupported then + idToRootMap:set(id, currentRootID) + recordProfilingDurations(fiber) + end + end + recordUnmount = function(fiber: Fiber, isSimulated: boolean) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("recordUnmount()", fiber) + end + + if trackedPathMatchFiber ~= nil then + -- We're in the process of trying to restore previous selection. + -- If this fiber matched but is being unmounted, there's no use trying. + -- Reset the state so we don't keep holding onto it. + if + fiber == trackedPathMatchFiber + or fiber == (trackedPathMatchFiber :: Fiber).alternate + then + setTrackedPath(nil) + end + end + + local isRoot = fiber.tag == HostRoot + local primaryFiber = getPrimaryFiber(fiber) + if not fiberToIDMap:has(primaryFiber) then + -- If we've never seen this Fiber, it might be because + -- it is inside a non-current Suspense fragment tree, + -- and so the store is not even aware of it. + -- In that case we can just ignore it, or otherwise + -- there will be errors later on. + primaryFibers:delete(primaryFiber) + -- TODO: this is fragile and can obscure actual bugs. + return + end + + local id = getFiberID(primaryFiber) + + if isRoot then + -- Roots must be removed only after all children (pending and simulated) have been removed. + -- So we track it separately. + pendingUnmountedRootID = id + elseif not shouldFilterFiber(fiber) then + -- To maintain child-first ordering, + -- we'll push it into one of these queues, + -- and later arrange them in the correct order. + if isSimulated then + table.insert(pendingSimulatedUnmountedIDs, id) + else + table.insert(pendingRealUnmountedIDs, id) + end + end + + fiberToIDMap:delete(primaryFiber) + idToFiberMap:delete(id) + primaryFibers:delete(primaryFiber) + + -- ROBLOX deviation: hasOwnProperty doesn't exist + local isProfilingSupported = fiber["treeBaseDuration"] ~= nil + + if isProfilingSupported then + idToRootMap:delete(id) + idToTreeBaseDurationMap:delete(id) + end + end + mountFiberRecursively = function( + fiber: Fiber, + parentFiber: Fiber | nil, + traverseSiblings: boolean, + traceNearestHostComponentUpdate: boolean + ): () + if __DEBUG__ then + debug_("mountFiberRecursively()", fiber, parentFiber) + end + + -- If we have the tree selection from previous reload, try to match this Fiber. + -- Also remember whether to do the same for siblings. + local mightSiblingsBeOnTrackedPath = updateTrackedPathStateBeforeMount(fiber) + local shouldIncludeInTree = not shouldFilterFiber(fiber) + + if shouldIncludeInTree then + recordMount(fiber, parentFiber) + end + if traceUpdatesEnabled then + if traceNearestHostComponentUpdate then + local elementType = getElementTypeForFiber(fiber) + -- If an ancestor updated, we should mark the nearest host nodes for highlighting. + if elementType == ElementTypeHostComponent then + traceUpdatesForNodes:add(fiber.stateNode) + + traceNearestHostComponentUpdate = false + end + end + + -- We intentionally do not re-enable the traceNearestHostComponentUpdate flag in this branch, + -- because we don't want to highlight every host node inside of a newly mounted subtree. + end + + local isSuspense = fiber.tag == ReactTypeOfWork.SuspenseComponent + + if isSuspense then + local isTimedOut = fiber.memoizedState ~= nil + + if isTimedOut then + -- Special case: if Suspense mounts in a timed-out state, + -- get the fallback child from the inner fragment and mount + -- it as if it was our own child. Updates handle this too. + local primaryChildFragment = fiber.child + local fallbackChildFragment = if primaryChildFragment + then primaryChildFragment.sibling + else nil + local fallbackChild = if fallbackChildFragment + then fallbackChildFragment.child + else nil + + if fallbackChild ~= nil then + mountFiberRecursively( + fallbackChild, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + else + local primaryChild = nil + local areSuspenseChildrenConditionallyWrapped = OffscreenComponent == -1 + + if areSuspenseChildrenConditionallyWrapped then + primaryChild = fiber.child + elseif fiber.child ~= nil then + primaryChild = (fiber.child :: Fiber).child + end + if primaryChild ~= nil then + mountFiberRecursively( + primaryChild, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + end + else + if fiber.child ~= nil then + mountFiberRecursively( + fiber.child, + if shouldIncludeInTree then fiber else parentFiber, + true, + traceNearestHostComponentUpdate + ) + end + end + + -- We're exiting this Fiber now, and entering its siblings. + -- If we have selection to restore, we might need to re-activate tracking. + updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath) + + if traverseSiblings and fiber.sibling ~= nil then + mountFiberRecursively( + fiber.sibling, + parentFiber :: Fiber, + true, + traceNearestHostComponentUpdate + ) + end + end + + -- We use this to simulate unmounting for Suspense trees + -- when we switch from primary to fallback. + unmountFiberChildrenRecursively = function(fiber: Fiber) + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("unmountFiberChildrenRecursively()", fiber) + end + + -- We might meet a nested Suspense on our way. + local isTimedOutSuspense = fiber.tag == ReactTypeOfWork.SuspenseComponent + and fiber.memoizedState ~= nil + local child = fiber.child + + if isTimedOutSuspense then + -- If it's showing fallback tree, let's traverse it instead. + local primaryChildFragment = fiber.child + local fallbackChildFragment = if primaryChildFragment + then primaryChildFragment.sibling + else nil + + -- Skip over to the real Fiber child. + child = if fallbackChildFragment then fallbackChildFragment.child else nil + end + + while child ~= nil do + -- Record simulated unmounts children-first. + -- We skip nodes without return because those are real unmounts. + if (child :: Fiber).return_ ~= nil then + unmountFiberChildrenRecursively(child :: Fiber) + recordUnmount(child :: Fiber, true) + end + + child = (child :: Fiber).sibling + end + end + recordProfilingDurations = function(fiber: Fiber) + local id = getFiberID(getPrimaryFiber(fiber)) + local actualDuration, treeBaseDuration = + fiber.actualDuration, fiber.treeBaseDuration + + idToTreeBaseDurationMap:set(id, treeBaseDuration or 0) + + if isProfiling then + local alternate = fiber.alternate + + -- It's important to update treeBaseDuration even if the current Fiber did not render, + -- because it's possible that one of its descendants did. + if + alternate == nil + or treeBaseDuration ~= (alternate :: Fiber).treeBaseDuration + then + local convertedTreeBaseDuration = + math.floor((treeBaseDuration or 0) * 1000) + + pushOperation(TREE_OPERATION_UPDATE_TREE_BASE_DURATION) + pushOperation(id) + pushOperation(convertedTreeBaseDuration) + end + if alternate == nil or didFiberRender(alternate :: Fiber, fiber) then + if actualDuration ~= nil then + -- The actual duration reported by React includes time spent working on children. + -- This is useful information, but it's also useful to be able to exclude child durations. + -- The frontend can't compute this, since the immediate children may have been filtered out. + -- So we need to do this on the backend. + -- Note that this calculated self duration is not the same thing as the base duration. + -- The two are calculated differently (tree duration does not accumulate). + local selfDuration = actualDuration :: number + local child = fiber.child + + while child ~= nil do + selfDuration = selfDuration + - ((child :: Fiber).actualDuration or 0) + child = (child :: Fiber).sibling + end + + -- If profiling is active, store durations for elements that were rendered during the commit. + -- Note that we should do this for any fiber we performed work on, regardless of its actualDuration value. + -- In some cases actualDuration might be 0 for fibers we worked on (particularly if we're using Date.now) + -- In other cases (e.g. Memo) actualDuration might be greater than 0 even if we "bailed out". + local metadata = currentCommitProfilingMetadata :: CommitProfilingData + table.insert(metadata.durations, id) + table.insert(metadata.durations, actualDuration :: number) + table.insert(metadata.durations, selfDuration) + metadata.maxActualDuration = + math.max(metadata.maxActualDuration, actualDuration :: number) + + if recordChangeDescriptions then + local changeDescription = getChangeDescription(alternate, fiber) + if changeDescription ~= nil then + if metadata.changeDescriptions ~= nil then + ( + metadata.changeDescriptions :: Map< + number, + ChangeDescription + > + ):set(id, changeDescription :: ChangeDescription) + end + end + + updateContextsForFiber(fiber) + end + end + end + end + end + local function recordResetChildren(fiber: Fiber, childSet: Fiber) + -- The frontend only really cares about the displayName, key, and children. + -- The first two don't really change, so we are only concerned with the order of children here. + -- This is trickier than a simple comparison though, since certain types of fibers are filtered. + local nextChildren: Array = {} + + -- This is a naive implementation that shallowly recourses children. + -- We might want to revisit this if it proves to be too inefficient. + local child: Fiber? = childSet + + while child ~= nil do + findReorderedChildrenRecursively(child :: Fiber, nextChildren) + + child = (child :: Fiber).sibling + end + + local numChildren = #nextChildren + + if numChildren < 2 then + -- No need to reorder. + return + end + + pushOperation(TREE_OPERATION_REORDER_CHILDREN) + pushOperation(getFiberID(getPrimaryFiber(fiber))) + pushOperation(numChildren) + + for i = 1, #nextChildren do + pushOperation(nextChildren[i]) + end + end + + findReorderedChildrenRecursively = function(fiber: Fiber, nextChildren: Array) + if not shouldFilterFiber(fiber) then + table.insert(nextChildren, getFiberID(getPrimaryFiber(fiber))) + else + local child = fiber.child + while child ~= nil do + findReorderedChildrenRecursively(child, nextChildren) + child = (child :: Fiber).sibling + end + end + end + + -- Returns whether closest unfiltered fiber parent needs to reset its child list. + local function updateFiberRecursively( + nextFiber: Fiber, + prevFiber: Fiber, + parentFiber: Fiber | nil, + traceNearestHostComponentUpdate: boolean + ): boolean + -- ROBLOX deviation: use global + if global.__DEBUG__ then + debug_("updateFiberRecursively()", nextFiber, parentFiber) + end + if traceUpdatesEnabled then + local elementType = getElementTypeForFiber(nextFiber) + + if traceNearestHostComponentUpdate then + -- If an ancestor updated, we should mark the nearest host nodes for highlighting. + if elementType == ElementTypeHostComponent then + traceUpdatesForNodes:add(nextFiber.stateNode) + + traceNearestHostComponentUpdate = false + end + else + if + elementType == ElementTypeFunction + or elementType == ElementTypeClass + or elementType == ElementTypeContext + then + -- Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). + traceNearestHostComponentUpdate = didFiberRender(prevFiber, nextFiber) + end + end + end + if + mostRecentlyInspectedElement ~= nil + and (mostRecentlyInspectedElement :: InspectedElement).id == getFiberID( + getPrimaryFiber(nextFiber) + ) + and didFiberRender(prevFiber, nextFiber) + then + -- If this Fiber has updated, clear cached inspected data. + -- If it is inspected again, it may need to be re-run to obtain updated hooks values. + hasElementUpdatedSinceLastInspected = true + end + + local shouldIncludeInTree = not shouldFilterFiber(nextFiber) + local isSuspense = nextFiber.tag == SuspenseComponent + local shouldResetChildren = false + -- The behavior of timed-out Suspense trees is unique. + -- Rather than unmount the timed out content (and possibly lose important state), + -- React re-parents this content within a hidden Fragment while the fallback is showing. + -- This behavior doesn't need to be observable in the DevTools though. + -- It might even result in a bad user experience for e.g. node selection in the Elements panel. + -- The easiest fix is to strip out the intermediate Fragment fibers, + -- so the Elements panel and Profiler don't need to special case them. + -- Suspense components only have a non-null memoizedState if they're timed-out. + local prevDidTimeout = isSuspense and prevFiber.memoizedState ~= nil + local nextDidTimeOut = isSuspense and nextFiber.memoizedState ~= nil + + -- The logic below is inspired by the code paths in updateSuspenseComponent() + -- inside ReactFiberBeginWork in the React source code. + if prevDidTimeout and nextDidTimeOut then + -- Fallback -> Fallback: + -- 1. Reconcile fallback set. + local nextFiberChild = nextFiber.child + local nextFallbackChildSet = if nextFiberChild + then nextFiberChild.sibling + else nil + -- Note: We can't use nextFiber.child.sibling.alternate + -- because the set is special and alternate may not exist. + local prevFiberChild = prevFiber.child + local prevFallbackChildSet = if prevFiberChild + then prevFiberChild.sibling + else nil + + if + nextFallbackChildSet ~= nil + and prevFallbackChildSet ~= nil + and updateFiberRecursively( + nextFallbackChildSet :: Fiber, + prevFallbackChildSet :: Fiber, + nextFiber :: Fiber, + traceNearestHostComponentUpdate + ) + then + shouldResetChildren = true + end + elseif prevDidTimeout and not nextDidTimeOut then + -- Fallback -> Primary: + -- 1. Unmount fallback set + -- Note: don't emulate fallback unmount because React actually did it. + -- 2. Mount primary set + local nextPrimaryChildSet = nextFiber.child + + if nextPrimaryChildSet ~= nil then + mountFiberRecursively( + nextPrimaryChildSet :: Fiber, + nextFiber :: Fiber, + true, + traceNearestHostComponentUpdate + ) + end + + shouldResetChildren = true + elseif not prevDidTimeout and nextDidTimeOut then + -- Primary -> Fallback: + -- 1. Hide primary set + -- This is not a real unmount, so it won't get reported by React. + -- We need to manually walk the previous tree and record unmounts. + unmountFiberChildrenRecursively(prevFiber) + + -- 2. Mount fallback set + local nextFiberChild = nextFiber.child + local nextFallbackChildSet = if nextFiberChild + then nextFiberChild.sibling + else nil + + if nextFallbackChildSet ~= nil then + mountFiberRecursively( + nextFallbackChildSet, + nextFiber, + true, + traceNearestHostComponentUpdate + ) + + shouldResetChildren = true + end + else + -- Common case: Primary -> Primary. + -- This is the same code path as for non-Suspense fibers. + if nextFiber.child ~= prevFiber.child then + -- If the first child is different, we need to traverse them. + -- Each next child will be either a new child (mount) or an alternate (update). + local nextChild: Fiber? = nextFiber.child + local prevChildAtSameIndex = prevFiber.child + + while nextChild do + -- We already know children will be referentially different because + -- they are either new mounts or alternates of previous children. + -- Schedule updates and mounts depending on whether alternates exist. + -- We don't track deletions here because they are reported separately. + if (nextChild :: Fiber).alternate then + local prevChild = (nextChild :: Fiber).alternate + + if + updateFiberRecursively( + nextChild :: Fiber, + prevChild :: Fiber, + if shouldIncludeInTree + then nextFiber + else parentFiber :: Fiber, + traceNearestHostComponentUpdate + ) + then + -- If a nested tree child order changed but it can't handle its own + -- child order invalidation (e.g. because it's filtered out like host nodes), + -- propagate the need to reset child order upwards to this Fiber. + shouldResetChildren = true + end + -- However we also keep track if the order of the children matches + -- the previous order. They are always different referentially, but + -- if the instances line up conceptually we'll want to know that. + if prevChild ~= prevChildAtSameIndex then + shouldResetChildren = true + end + else + mountFiberRecursively( + nextChild :: Fiber, + if shouldIncludeInTree then nextFiber else parentFiber, + false, + traceNearestHostComponentUpdate + ) + + shouldResetChildren = true + end + + -- Try the next child. + nextChild = nextChild.sibling :: Fiber + + -- Advance the pointer in the previous list so that we can + -- keep comparing if they line up. + if not shouldResetChildren and prevChildAtSameIndex ~= nil then + prevChildAtSameIndex = (prevChildAtSameIndex :: Fiber).sibling + end + end + + -- If we have no more children, but used to, they don't line up. + if prevChildAtSameIndex ~= nil then + shouldResetChildren = true + end + else + if traceUpdatesEnabled then + -- If we're tracing updates and we've bailed out before reaching a host node, + -- we should fall back to recursively marking the nearest host descendants for highlight. + if traceNearestHostComponentUpdate then + local hostFibers = findAllCurrentHostFibers( + getFiberID(getPrimaryFiber(nextFiber)) + ) + + for _, hostFiber in hostFibers do + traceUpdatesForNodes:add(hostFiber.stateNode) + end + end + end + end + end + if shouldIncludeInTree then + -- ROBLOX deviation: hasOwnProperty doesn't exist + local isProfilingSupported = nextFiber["treeBaseDuration"] ~= nil + + if isProfilingSupported then + recordProfilingDurations(nextFiber) + end + end + if shouldResetChildren then + -- We need to crawl the subtree for closest non-filtered Fibers + -- so that we can display them in a flat children set. + if shouldIncludeInTree then + -- Normally, search for children from the rendered child. + local nextChildSet = nextFiber.child + + if nextDidTimeOut then + -- Special case: timed-out Suspense renders the fallback set. + local nextFiberChild = nextFiber.child + + nextChildSet = if nextFiberChild then nextFiberChild.sibling else nil + end + if nextChildSet ~= nil then + recordResetChildren(nextFiber, nextChildSet :: Fiber) + end + + -- We've handled the child order change for this Fiber. + -- Since it's included, there's no need to invalidate parent child order. + return false + else + -- Let the closest unfiltered parent Fiber reset its child order instead. + return true + end + else + return false + end + end + local function cleanup() + -- We don't patch any methods so there is no cleanup. + end + + local function flushInitialOperations() + local localPendingOperationsQueue = pendingOperationsQueue + + pendingOperationsQueue = nil + + if + localPendingOperationsQueue ~= nil + and #(localPendingOperationsQueue :: Array>) > 0 + then + for _, operations in localPendingOperationsQueue :: Array> do + hook.emit("operations", operations) + end + else + -- Before the traversals, remember to start tracking + -- our path in case we have selection to restore. + if trackedPath ~= nil then + mightBeOnTrackedPath = true + end + + -- If we have not been profiling, then we can just walk the tree and build up its current state as-is. + hook.getFiberRoots(rendererID):forEach(function(root) + currentRootID = getFiberID(getPrimaryFiber(root.current)) + setRootPseudoKey(currentRootID, root.current) + + -- Checking root.memoizedInteractions handles multi-renderer edge-case- + -- where some v16 renderers support profiling and others don't. + if isProfiling and root.memoizedInteractions ~= nil then + -- If profiling is active, store commit time and duration, and the current interactions. + -- The frontend may request this information after profiling has stopped. + local _tmp = Array.from(root.memoizedInteractions) + + currentCommitProfilingMetadata = { + -- ROBLOX deviation: use bare table instead of Map type + changeDescriptions = if recordChangeDescriptions + then Map.new() + else nil, + durations = {}, + commitTime = getCurrentTime() - profilingStartTime, + -- ROBLOX TODO: Work out how to deviate this assignment, it's messy + interactions = Array.map( + Array.from(root.memoizedInteractions), + function(interaction: Interaction) + local tmp2 = Object.assign({}, interaction, { + timestamp = interaction.timestamp + - profilingStartTime, + }) + return tmp2 + end + ), + maxActualDuration = 0, + priorityLevel = nil, + } + end + + mountFiberRecursively(root.current, nil, false, false) + flushPendingEvents(root) + currentRootID = -1 + end) + end + end + + local function handleCommitFiberUnmount(fiber) + -- This is not recursive. + -- We can't traverse fibers after unmounting so instead + -- we rely on React telling us about each unmount. + recordUnmount(fiber, false) + end + + local formatPriorityLevel = function(priorityLevel: number?) + if priorityLevel == nil then + return "Unknown" + end + 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" + -- ROBLOX deviation: no need to check for NoPriority + else + return "Unknown" + end + end + + local function handleCommitFiberRoot(root: Object, priorityLevel: number?) + local current = root.current + local alternate = current.alternate + + currentRootID = getFiberID(getPrimaryFiber(current)) + + -- Before the traversals, remember to start tracking + -- our path in case we have selection to restore. + if trackedPath ~= nil then + mightBeOnTrackedPath = true + end + if traceUpdatesEnabled then + traceUpdatesForNodes:clear() + end + + -- Checking root.memoizedInteractions handles multi-renderer edge-case- + -- where some v16 renderers support profiling and others don't. + local isProfilingSupported = root.memoizedInteractions ~= nil + + if isProfiling and isProfilingSupported then + local _tmp = Array.from(root.memoizedInteractions) + -- If profiling is active, store commit time and duration, and the current interactions. + -- The frontend may request this information after profiling has stopped. + currentCommitProfilingMetadata = { + -- ROBLOX deviation: use bare table instead of Map type + changeDescriptions = if recordChangeDescriptions then Map.new() else nil, + durations = {}, + commitTime = getCurrentTime() - profilingStartTime, + interactions = Array.map( + Array.from(root.memoizedInteractions), + -- ROBLOX FIXME Luau: shouldn't need this manual annotation + function(interaction: Interaction) + local _tmp2 = Object.assign({}, interaction, { + timestamp = interaction.timestamp - profilingStartTime, + }) + return _tmp2 + end + ), + maxActualDuration = 0, + priorityLevel = if priorityLevel == nil + then nil + else formatPriorityLevel(priorityLevel), + } + end + if alternate then + -- TODO: relying on this seems a bit fishy. + local wasMounted = (alternate :: Fiber).memoizedState ~= nil + and (alternate :: Fiber).memoizedState.element ~= nil + local isMounted = current.memoizedState ~= nil + and current.memoizedState.element ~= nil + + if not wasMounted and isMounted then + -- Mount a new root. + setRootPseudoKey(currentRootID, current) + mountFiberRecursively(current :: Fiber, nil, false, false) + elseif wasMounted and isMounted then + -- Update an existing root. + updateFiberRecursively(current, alternate, nil, false) + elseif wasMounted and not isMounted then + -- Unmount an existing root. + removeRootPseudoKey(currentRootID) + recordUnmount(current, false) + end + else + -- Mount a new root. + setRootPseudoKey(currentRootID, current) + mountFiberRecursively(current :: Fiber, nil, false, false) + end + if isProfiling and isProfilingSupported then + local commitProfilingMetadata = ( + (rootToCommitProfilingMetadataMap :: any) :: CommitProfilingMetadataMap + ):get(currentRootID) + + if commitProfilingMetadata ~= nil then + table.insert( + commitProfilingMetadata, + (currentCommitProfilingMetadata :: any) :: CommitProfilingData + ) + else + ((rootToCommitProfilingMetadataMap :: any) :: CommitProfilingMetadataMap):set( + currentRootID, + { + (currentCommitProfilingMetadata :: any) :: CommitProfilingData, + } + ) + end + end + + -- We're done here. + flushPendingEvents(root) + + if traceUpdatesEnabled then + hook.emit("traceUpdates", traceUpdatesForNodes) + end + + currentRootID = -1 + end + findAllCurrentHostFibers = function(id: number): Array + local fibers = {} + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if not fiber then + return fibers + end + + -- Next we'll drill down this component to find all HostComponent/Text. + local node: Fiber = fiber + + while true do + if node.tag == HostComponent or node.tag == HostText then + table.insert(fibers, node) + elseif node.child then + node.child.return_ = node + node = node.child :: Fiber + continue + end + if node == fiber then + return fibers + end + + while not node.sibling do + if not node.return_ or node.return_ == fiber then + return fibers + end + + node = node.return_ :: Fiber + end + + (node.sibling :: Fiber).return_ = node.return_ :: Fiber + node = node.sibling :: Fiber + end + + -- Flow needs the return here, but ESLint complains about it. + -- eslint-disable-next-line no-unreachable + return fibers + end + local function findNativeNodesForFiberID(id: number) + -- ROBLOX try + local ok, result = pcall(function() + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + if fiber == nil then + return nil + end + -- Special case for a timed-out Suspense. + local isTimedOutSuspense = (fiber :: Fiber).tag == SuspenseComponent + and (fiber :: Fiber).memoizedState ~= nil + if isTimedOutSuspense then + -- A timed-out Suspense's findDOMNode is useless. + -- Try our best to find the fallback directly. + local maybeFallbackFiber = (fiber :: Fiber).child + and ((fiber :: Fiber).child :: Fiber).sibling + if maybeFallbackFiber ~= nil then + fiber = maybeFallbackFiber :: Fiber + end + end + local hostFibers = findAllCurrentHostFibers(id) + -- ROBLOX deviation: filter for Boolean doesn't make sense + return Array.map(hostFibers, function(hostFiber: Fiber) + return hostFiber.stateNode + -- ROBLOX FIXME Luau: remove this any once deferred constraint resolution replaces greedy algorithms + end) :: any + end) + -- ROBLOX catch + if not ok then + -- The fiber might have unmounted by now. + return nil + end + return result + end + + local function getDisplayNameForFiberID(id) + local fiber = idToFiberMap:get(id) + return if fiber ~= nil then getDisplayNameForFiber(fiber) else nil + end + + local function getFiberIDForNative( + hostInstance, + findNearestUnfilteredAncestor: boolean? + ): number? + findNearestUnfilteredAncestor = findNearestUnfilteredAncestor or false + local fiber = renderer.findFiberByHostInstance(hostInstance) + + if fiber ~= nil then + if findNearestUnfilteredAncestor then + while fiber ~= nil and shouldFilterFiber(fiber :: Fiber) do + fiber = (fiber :: Fiber).return_ + end + end + return getFiberID(getPrimaryFiber(fiber :: Fiber)) + end + + return nil + end + + -- ROBLOX deviation: The copied code is indeed copied, but from ReactFiberTreeReflection.lua + + -- This function is copied from React and should be kept in sync: + -- https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberTreeReflection.js + -- It would be nice if we updated React to inject this function directly (vs just indirectly via findDOMNode). + -- BEGIN copied code + + -- ROBLOX NOTE: Copied these supporting functions from ReactFiberTreeReflection + local function assertIsMounted(fiber) + invariant( + getNearestMountedFiber(fiber) == fiber, + "Unable to find node on an unmounted component." + ) + end + + findCurrentFiberUsingSlowPathById = function(id: number): Fiber | nil + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return nil + end + + -- ROBLOX NOTE: Copied from ReactFiberTreeReflection.lua + local alternate = (fiber :: Fiber).alternate + if not alternate then + -- If there is no alternate, then we only need to check if it is mounted. + local nearestMounted = getNearestMountedFiber(fiber :: Fiber) + invariant( + nearestMounted ~= nil, + "Unable to find node on an unmounted component." + ) + if nearestMounted ~= (fiber :: Fiber) then + return nil + end + return fiber :: Fiber + end + -- If we have two possible branches, we'll walk backwards up to the root + -- to see what path the root points to. On the way we may hit one of the + -- special cases and we'll deal with them. + local a = fiber :: Fiber + local b = alternate :: Fiber + while true do + local parentA = a.return_ + if parentA == nil then + -- We're at the root. + break + end + local parentB = (parentA :: Fiber).alternate + if parentB == nil then + -- There is no alternate. This is an unusual case. Currently, it only + -- happens when a Suspense component is hidden. An extra fragment fiber + -- is inserted in between the Suspense fiber and its children. Skip + -- over this extra fragment fiber and proceed to the next parent. + local nextParent = (parentA :: Fiber).return_ + if nextParent ~= nil then + a = nextParent :: Fiber + b = nextParent :: Fiber + continue + end + -- If there's no parent, we're at the root. + break + end + + -- If both copies of the parent fiber point to the same child, we can + -- assume that the child is current. This happens when we bailout on low + -- priority: the bailed out fiber's child reuses the current child. + if (parentA :: Fiber).child == (parentB :: Fiber).child then + local child = (parentA :: Fiber).child + while child do + if child == a then + -- We've determined that A is the current branch. + assertIsMounted(parentA) + return fiber + end + if child == b then + -- We've determined that B is the current branch. + assertIsMounted(parentA) + return alternate + end + child = child.sibling :: Fiber + end + -- We should never have an alternate for any mounting node. So the only + -- way this could possibly happen is if this was unmounted, if at all. + invariant(false, "Unable to find node on an unmounted component.") + end + + if a.return_ ~= b.return_ then + -- The return pointer of A and the return pointer of B point to different + -- fibers. We assume that return pointers never criss-cross, so A must + -- belong to the child set of A.return_, and B must belong to the child + -- set of B.return_. + a = parentA :: Fiber + b = parentB :: Fiber + else + -- The return pointers point to the same fiber. We'll have to use the + -- default, slow path: scan the child sets of each parent alternate to see + -- which child belongs to which set. + -- + -- Search parent A's child set + local didFindChild = false + local child = (parentA :: Fiber).child + while child do + if child == a then + didFindChild = true + a = parentA :: Fiber + b = parentB :: Fiber + break + end + if child == b then + didFindChild = true + b = parentA :: Fiber + a = parentB :: Fiber + break + end + child = child.sibling :: Fiber + end + if not didFindChild then + -- Search parent B's child set + child = (parentB :: Fiber).child + while child do + if child == a then + didFindChild = true + a = parentB :: Fiber + b = parentA :: Fiber + break + end + if child == b then + didFindChild = true + b = parentB :: Fiber + a = parentA :: Fiber + break + end + child = child.sibling :: Fiber + end + invariant( + didFindChild, + "Child was not found in either parent set. This indicates a bug " + .. "in React related to the return pointer. Please file an issue." + ) + end + end + + invariant( + a.alternate == b, + "Return fibers should always be each others' alternates. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + -- If the root is not a host container, we're in a disconnected tree. I.e. + -- unmounted. + invariant(a.tag == HostRoot, "Unable to find node on an unmounted component.") + if a.stateNode.current == a then + -- We've determined that A is the current branch. + return fiber + end + -- Otherwise B has to be current branch. + return alternate + end + -- END copied code + + local function prepareViewAttributeSource( + id: number, + path: Array + ): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + window["$attribute"] = getInObject(mostRecentlyInspectedElement :: any, path) + end + end + local function prepareViewElementSource(id: number): () + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return + end + + local elementType, tag, type_ = + (fiber :: Fiber).elementType, (fiber :: Fiber).tag, (fiber :: Fiber).type + + if + tag == ClassComponent + or tag == FunctionComponent + or tag == IncompleteClassComponent + or tag == IndeterminateComponent + then + global["$type"] = type_ + elseif tag == ForwardRef then + global["$type"] = type_.render + elseif tag == MemoComponent or tag == SimpleMemoComponent then + global["$type"] = elementType ~= nil + and elementType.type ~= nil + and elementType.type + or type_ + else + global["$type"] = nil + end + end + + local function getOwnersList(id: number): Array | nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber == nil then + return nil + end + + local _debugOwner = (fiber :: Fiber)._debugOwner + local owners = { + { + displayName = getDisplayNameForFiber(fiber :: Fiber) or "Anonymous", + id = id, + type = getElementTypeForFiber(fiber :: Fiber), + }, + } + + if _debugOwner then + local owner: Fiber? = _debugOwner + + while owner ~= nil do + Array.unshift(owners, { + displayName = getDisplayNameForFiber(owner :: Fiber) or "Anonymous", + id = getFiberID(getPrimaryFiber(owner :: Fiber)), + type = getElementTypeForFiber(owner :: Fiber), + }) + + owner = (owner :: Fiber)._debugOwner or nil + end + end + + return owners + end + + -- Fast path props lookup for React Native style editor. + -- Could use inspectElementRaw() but that would require shallow rendering hooks components, + -- and could also mess with memoization. + local function getInstanceAndStyle(id: number): InstanceAndStyle + local instance = nil + local style = nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + instance = (fiber :: Fiber).stateNode + + if (fiber :: Fiber).memoizedProps ~= nil then + style = (fiber :: Fiber).memoizedProps.style + end + end + + return { + instance = instance, + style = style, + } + end + + local function inspectElementRaw(id: number): InspectedElement | nil + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber == nil then + return nil + end + + local _debugOwner, _debugSource, stateNode, key, memoizedProps, memoizedState, dependencies, tag, type_ = + (fiber :: Fiber)._debugOwner, + (fiber :: Fiber)._debugSource, + (fiber :: Fiber).stateNode, + (fiber :: Fiber).key, + (fiber :: Fiber).memoizedProps, + (fiber :: Fiber).memoizedState, + (fiber :: Fiber).dependencies, + (fiber :: Fiber).tag, + (fiber :: Fiber).type + + local elementType = getElementTypeForFiber(fiber :: Fiber) + + local usesHooks = ( + tag == FunctionComponent + or tag == SimpleMemoComponent + or tag == ForwardRef + ) and (not not memoizedState or not not dependencies) + + local typeSymbol = getTypeSymbol(type_) + local canViewSource = false + local context = nil + + if + tag == ClassComponent + or tag == FunctionComponent + or tag == IncompleteClassComponent + or tag == IndeterminateComponent + or tag == MemoComponent + or tag == ForwardRef + or tag == SimpleMemoComponent + then + canViewSource = true + + if stateNode and stateNode.context ~= nil then + -- Don't show an empty context object for class components that don't use the context API. + local shouldHideContext = elementType == ElementTypeClass + and not (type_.contextTypes or type_.contextType) + + if not shouldHideContext then + context = stateNode.context + end + end + elseif typeSymbol == CONTEXT_NUMBER or typeSymbol == CONTEXT_SYMBOL_STRING then + -- 16.3-16.5 read from "type" because the Consumer is the actual context object. + -- 16.6+ should read from "type._context" because Consumer can be different (in DEV). + -- NOTE Keep in sync with getDisplayNameForFiber() + local consumerResolvedContext = type_._context or type_ + + -- Global context value. + context = consumerResolvedContext._currentValue or nil + + -- Look for overridden value. + local current = (fiber :: Fiber).return_ + + while current ~= nil do + local currentType = (current :: Fiber).type + local currentTypeSymbol = getTypeSymbol(currentType) + + if + currentTypeSymbol == PROVIDER_NUMBER + or currentTypeSymbol == PROVIDER_SYMBOL_STRING + then + -- 16.3.0 exposed the context object as "context" + -- PR #12501 changed it to "_context" for 16.3.1+ + -- NOTE Keep in sync with getDisplayNameForFiber() + local providerResolvedContext = currentType._context + or currentType.context + + if providerResolvedContext == consumerResolvedContext then + context = (current :: Fiber).memoizedProps.value + + break + end + end + + current = (current :: Fiber).return_ + end + end + + local hasLegacyContext = false + + if context ~= nil then + hasLegacyContext = not not type_.contextTypes + -- To simplify hydration and display logic for context, wrap in a value object. + -- Otherwise simple values (e.g. strings, booleans) become harder to handle. + context = { value = context } + end + + local owners: Array? = nil + + if _debugOwner then + owners = {} + local owner: Fiber? = _debugOwner + while owner ~= nil do + table.insert(owners :: Array, { + displayName = getDisplayNameForFiber(owner :: Fiber) or "Anonymous", + id = getFiberID(getPrimaryFiber(owner :: Fiber)), + type = getElementTypeForFiber(owner :: Fiber), + }) + owner = (owner :: Fiber)._debugOwner or nil + end + end + + local isTimedOutSuspense = tag == SuspenseComponent and memoizedState ~= nil + local hooks = nil + + if usesHooks then + local originalConsoleMethods = {} + + -- Temporarily disable all console logging before re-running the hook. + -- ROBLOX TODO: Is iterating over console methods be sensible here? + for method, _ in pairs(console) do + pcall(function() + originalConsoleMethods[method] = console[method] + console[method] = function() end + end) + end + + pcall(function() + hooks = inspectHooksOfFiber(fiber :: Fiber, renderer.currentDispatcherRef) + end) + + -- Restore original console functionality. + for method, _ in pairs(console) do + pcall(function() + console[method] = originalConsoleMethods[method] + end) + end + end + + local rootType: string? = nil + local current = fiber :: Fiber + + while current.return_ ~= nil do + current = current.return_ :: Fiber + end + local fiberRoot = current.stateNode + if fiberRoot ~= nil and fiberRoot._debugRootType ~= nil then + rootType = fiberRoot._debugRootType + end + + return { + id = id, + -- Does the current renderer support editable hooks and function props? + canEditHooks = typeof(overrideHookState) == "function", + canEditFunctionProps = typeof(overrideProps) == "function", + -- Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths = typeof(overrideHookStateDeletePath) + == "function", + canEditHooksAndRenamePaths = typeof(overrideHookStateRenamePath) + == "function", + canEditFunctionPropsDeletePaths = typeof(overridePropsDeletePath) + == "function", + canEditFunctionPropsRenamePaths = typeof(overridePropsRenamePath) + == "function", + canToggleSuspense = supportsTogglingSuspense + -- If it's showing the real content, we can always flip fallback. + and ( + not isTimedOutSuspense + -- If it's showing fallback because we previously forced it to, + -- allow toggling it back to remove the fallback override. + or forceFallbackForSuspenseIDs[id] + ), + + -- Can view component source location. + canViewSource = canViewSource, + + -- Does the component have legacy contexted to it. + hasLegacyContext = hasLegacyContext, + -- ROBLOX TODO: upstream has a buggy ternary for this + key = key, + displayName = getDisplayNameForFiber(fiber :: Fiber), + type_ = elementType, + + -- Inspectable properties. + -- TODO Review sanitization approach for the below inspectable values. + context = context, + -- ROBLOX deviation: Luau won't coerce HooksTree to Object + hooks = hooks :: any, + props = memoizedProps, + state = if usesHooks then nil else memoizedState, + + -- List of owners + owners = owners, + + -- Location of component in source code. + source = _debugSource or nil, + + rootType = rootType, + rendererPackageName = renderer.rendererPackageName, + rendererVersion = renderer.version, + } + end + + isMostRecentlyInspectedElementCurrent = function(id: number): boolean + return mostRecentlyInspectedElement ~= nil + and (mostRecentlyInspectedElement :: InspectedElement).id == id + and not hasElementUpdatedSinceLastInspected + end + + -- Track the intersection of currently inspected paths, + -- so that we can send their data along if the element is re-rendered. + local function mergeInspectedPaths(path) + local current = currentlyInspectedPaths + + for _, key in path do + if not Boolean.toJSBoolean(current[key]) then + current[key] = {} + end + current = current[key] + end + end + + local function createIsPathAllowed( + key: string | nil, + secondaryCategory: string | nil -- ROBLOX TODO: Luau can't express literal type: 'hooks' + ) + -- This function helps prevent previously-inspected paths from being dehydrated in updates. + -- This is important to avoid a bad user experience where expanded toggles collapse on update. + return function(path): boolean + if secondaryCategory == "hooks" then + if #path == 1 then + -- Never dehydrate the "hooks" object at the top levels. + return true + end + if path[#path] == "subHooks" or path[#path - 1] == "subHooks" then + -- Dehydrating the 'subHooks' property makes the HooksTree UI a lot more complicated, + -- so it's easiest for now if we just don't break on this boundary. + -- We can always dehydrate a level deeper (in the value object). + return true + end + end + + local current = if key == nil + then currentlyInspectedPaths + else currentlyInspectedPaths[key] + + if not Boolean.toJSBoolean(current) then + return false + end + + for i = 1, #path do + current = current[path[i]] + if not Boolean.toJSBoolean(current) then + return false + end + end + return true + end + end + + local function updateSelectedElement(inspectedElement: InspectedElement): () + local hooks, id, props = + inspectedElement.hooks, inspectedElement.id, inspectedElement.props + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + + return + end + + local elementType, stateNode, tag, type_ = + (fiber :: Fiber).elementType, + (fiber :: Fiber).stateNode, + (fiber :: Fiber).tag, + (fiber :: Fiber).type + + if + tag == ClassComponent + or tag == IncompleteClassComponent + or tag == IndeterminateComponent + then + global["$r"] = stateNode + elseif tag == FunctionComponent then + global["$r"] = { + hooks = hooks, + props = props, + type = type_, + } + elseif tag == ForwardRef then + global["$r"] = { + props = props, + type = type_.render, + } + elseif tag == MemoComponent or tag == SimpleMemoComponent then + global["$r"] = { + props = props, + type = elementType ~= nil + and elementType.type ~= nil + and elementType.type + or type_, + } + else + global["$r"] = nil + end + end + + local function storeAsGlobal( + id: number, + path: Array, + count: number + ): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + local value = getInObject(mostRecentlyInspectedElement :: any, path) + local key = string.format("$reactTemp%s", tostring(count)) + + window[key] = value + + console.log(key) + console.log(value) + end + end + + local function copyElementPath(id: number, path: Array): () + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + copyToClipboard(getInObject(mostRecentlyInspectedElement :: any, path)) + end + end + + local function inspectElement( + id: number, + path: Array? + ): InspectedElementPayload + local isCurrent = isMostRecentlyInspectedElementCurrent(id) + + if isCurrent then + if path ~= nil then + mergeInspectedPaths(path :: Array) + + local secondaryCategory = nil + + if (path :: Array)[1] == "hooks" then + secondaryCategory = "hooks" + end + + -- If this element has not been updated since it was last inspected, + -- we can just return the subset of data in the newly-inspected path. + return { + id = id, + type = "hydrated-path", + path = path, + value = cleanForBridge( + getInObject(mostRecentlyInspectedElement :: any, path), + createIsPathAllowed(nil, secondaryCategory), + path + ), + } + else + -- If this element has not been updated since it was last inspected, we don't need to re-run it. + -- Instead we can just return the ID to indicate that it has not changed. + return { + id = id, + type = "no-change", + } + end + else + hasElementUpdatedSinceLastInspected = false + + if + mostRecentlyInspectedElement == nil + or (mostRecentlyInspectedElement :: InspectedElement).id ~= id + then + currentlyInspectedPaths = {} + end + + mostRecentlyInspectedElement = inspectElementRaw(id) + + if mostRecentlyInspectedElement == nil then + return { + id = id, + type = "not-found", + } + end + if path ~= nil then + mergeInspectedPaths(path :: Array) + end + + -- Any time an inspected element has an update, + -- we should update the selected $r value as wel. + -- Do this before dehydration (cleanForBridge). + updateSelectedElement(mostRecentlyInspectedElement :: InspectedElement) + + -- Clone before cleaning so that we preserve the full data. + -- This will enable us to send patches without re-inspecting if hydrated paths are requested. + -- (Reducing how often we shallow-render is a better DX for function components that use hooks.) + local cleanedInspectedElement = + Object.assign({}, mostRecentlyInspectedElement) + + cleanedInspectedElement.context = cleanForBridge( + cleanedInspectedElement.context, + createIsPathAllowed("context", nil) + ) + cleanedInspectedElement.hooks = cleanForBridge( + cleanedInspectedElement.hooks, + createIsPathAllowed("hooks", "hooks") + ) + cleanedInspectedElement.props = cleanForBridge( + cleanedInspectedElement.props, + createIsPathAllowed("props", nil) + ) + cleanedInspectedElement.state = cleanForBridge( + cleanedInspectedElement.state, + createIsPathAllowed("state", nil) + ) + + return { + id = id, + type = "full-data", + value = cleanedInspectedElement, + } + end + end + + local function logElementToConsole(id: number) + local result: InspectedElement? = if isMostRecentlyInspectedElementCurrent(id) + then mostRecentlyInspectedElement + else inspectElementRaw(id) + + if result == nil then + console.warn(string.format('Could not find Fiber with id "%s"', tostring(id))) + return + end + + -- ROBLOX TODO: Do we want to support this? Seems out of scope + -- local supportsGroup = typeof(console.groupCollapsed) == 'function' + + -- if supportsGroup then + -- console.groupCollapsed(string.format('[Click to expand] %c<%s />', result.displayName or 'Component'), 'color: var(--dom-tag-name-color); font-weight: normal;') + -- end + if (result :: InspectedElement).props ~= nil then + console.log("Props:", (result :: InspectedElement).props) + end + if (result :: InspectedElement).state ~= nil then + console.log("State:", (result :: InspectedElement).state) + end + if (result :: InspectedElement).hooks ~= nil then + console.log("Hooks:", (result :: InspectedElement).hooks) + end + + local nativeNodes = findNativeNodesForFiberID(id) + + if nativeNodes ~= nil then + console.log("Nodes:", nativeNodes) + end + if (result :: InspectedElement).source ~= nil then + console.log("Location:", (result :: InspectedElement).source) + end + + -- ROBLOX deviation: not needed + -- if (window.chrome || /firefox/i.test(navigator.userAgent)) { + -- console.log( + -- 'Right-click any value to save it as a global variable for further inspection.', + -- ); + -- } + + -- if supportsGroup then + -- console.groupEnd() + -- end + end + + local function deletePath( + type_: string, -- ROBLOX TODO: Luau can't express literal types: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + path: Array + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + path = Array.slice(path, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #path == 0 then + -- Simple context value (noop) + else + deletePathInObject(instance.context, path) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookStateDeletePath) == "function" then + assert(hookID, "Expected hookID to be defined") + overrideHookStateDeletePath(fiber :: Fiber, hookID, path) + end + elseif type_ == "props" then + if instance == nil then + if type(overridePropsDeletePath) == "function" then + overridePropsDeletePath(fiber :: Fiber, path) + end + else + (fiber :: Fiber).pendingProps = copyWithDelete(instance.props, path) + instance:forceUpdate() + end + elseif type_ == "state" then + deletePathInObject(instance.state, path) + instance:forceUpdate() + end + end + end + + local function renamePath( + type_: string, -- ROBLOX deviation: Luau can't express: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + oldPath: Array, + newPath: Array + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + oldPath = Array.slice(oldPath, 1) + newPath = Array.slice(newPath, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #oldPath == 0 then + -- Simple context value (noop) + else + renamePathInObject(instance.context, oldPath, newPath) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookStateRenamePath) == "function" then + assert(hookID, "Expected hookID to be defined") + overrideHookStateRenamePath(fiber, hookID, oldPath, newPath) + end + elseif type_ == "props" then + if instance == nil then + if type(overridePropsRenamePath) == "function" then + overridePropsRenamePath(fiber, oldPath, newPath) + end + else + (fiber :: Fiber).pendingProps = + copyWithRename(instance.props, oldPath, newPath) + instance:forceUpdate() + end + elseif type_ == "state" then + renamePathInObject(instance.state, oldPath, newPath) + instance:forceUpdate() + end + end + end + + local function overrideValueAtPath( + type_: string, -- ROBLOX deviation: Luau can't express: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: number?, + path: Array, + value: any + ): () + local fiber: Fiber? = findCurrentFiberUsingSlowPathById(id) + + if fiber ~= nil then + local instance = (fiber :: Fiber).stateNode + + if type_ == "context" then + -- To simplify hydration and display of primitive context values (e.g. number, string) + -- the inspectElement() method wraps context in a {value: ...} object. + -- We need to remove the first part of the path (the "value") before continuing. + path = Array.slice(path, 1) + + if (fiber :: Fiber).tag == ClassComponent then + if #path == 0 then + -- Simple context value + instance.context = value + else + setInObject(instance.context, path, value) + end + instance:forceUpdate() + elseif (fiber :: Fiber).tag == FunctionComponent then + -- Function components using legacy context are not editable + -- because there's no instance on which to create a cloned, mutated context. + end + elseif type_ == "hooks" then + if type(overrideHookState) == "function" then + assert(hookID, "Expected hookID to be defined") + overrideHookState(fiber :: Fiber, hookID, path, value) + end + elseif type_ == "props" then + if instance == nil then + if type(overrideProps) == "function" then + overrideProps(fiber :: Fiber, path, value) + end + else + (fiber :: Fiber).pendingProps = + copyWithSet(instance.props, path, value) + instance:forceUpdate() + end + elseif type_ == "state" then + setInObject(instance.state, path, value) + instance:forceUpdate() + end + end + end + + type CommitProfilingData = { + changeDescriptions: Map | nil, + commitTime: number, + durations: Array, + interactions: Array, + maxActualDuration: number, + priorityLevel: string | nil, + } + + type CommitProfilingMetadataMap = Map> + type DisplayNamesByRootID = Map + + local function getProfilingData(): ProfilingDataBackend + local dataForRoots: Array = {} + + if rootToCommitProfilingMetadataMap == nil then + error("getProfilingData() called before any profiling data was recorded") + end + + -- ROBLOX FIXME Luau: need type states to not need this manual cast + (rootToCommitProfilingMetadataMap :: CommitProfilingMetadataMap):forEach( + function(commitProfilingMetadata, rootID) + local commitData: Array = {} + local initialTreeBaseDurations: Array> = {} + local allInteractions: Map = Map.new() + local interactionCommits: Map> = Map.new() + + local displayName = if displayNamesByRootID ~= nil + then displayNamesByRootID:get(rootID) or "Unknown" + else "Unknown" + + if initialTreeBaseDurationsMap ~= nil then + initialTreeBaseDurationsMap:forEach(function(treeBaseDuration, id) + if + initialIDToRootMap ~= nil + and (initialIDToRootMap :: Map):get(id) + == rootID + then + -- We don't need to convert milliseconds to microseconds in this case, + -- because the profiling summary is JSON serialized. + table.insert( + initialTreeBaseDurations, + { id, treeBaseDuration } + ) + end + end) + end + + for commitIndex, commitProfilingData in commitProfilingMetadata do + local changeDescriptions, durations, interactions, maxActualDuration, priorityLevel, commitTime = + commitProfilingData.changeDescriptions, + commitProfilingData.durations, + commitProfilingData.interactions, + commitProfilingData.maxActualDuration, + commitProfilingData.priorityLevel, + commitProfilingData.commitTime + local interactionIDs: Array = {} + + for _, interaction in interactions do + if not allInteractions:has(interaction.id) then + allInteractions:set(interaction.id, interaction) + end + + table.insert(interactionIDs, interaction.id) + + local commitIndices = interactionCommits:get(interaction.id) + + if commitIndices ~= nil then + table.insert(commitIndices, commitIndex) + else + interactionCommits:set(interaction.id, { commitIndex }) + end + end + + local fiberActualDurations: Array> = {} + local fiberSelfDurations: Array> = {} + + for i = 1, #durations, 3 do + local fiberID = durations[i] + table.insert(fiberActualDurations, { fiberID, durations[i + 1] }) + table.insert(fiberSelfDurations, { fiberID, durations[i + 2] }) + end + + table.insert(commitData, { + changeDescriptions = if changeDescriptions ~= nil + -- ROBLOX FIXME: types don't flow from entries through Array.from() return value + then Array.from(changeDescriptions:entries()) :: Array> + else ENCODE_TO_NULL, + duration = maxActualDuration, + fiberActualDurations = fiberActualDurations, + fiberSelfDurations = fiberSelfDurations, + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = commitTime, + }) + end + + local _tmpCommits = Array.from(interactionCommits:entries()) + local _tmp = Array.from(allInteractions:entries()) + table.insert(dataForRoots, { + commitData = commitData, + displayName = displayName, + initialTreeBaseDurations = initialTreeBaseDurations, + interactionCommits = Array.from(interactionCommits:entries()), + interactions = Array.from(allInteractions:entries()), + rootID = rootID, + }) + end + ) + + return { + dataForRoots = dataForRoots, + rendererID = rendererID, + } + end + + local function startProfiling(shouldRecordChangeDescriptions: boolean) + if isProfiling then + return + end + + recordChangeDescriptions = shouldRecordChangeDescriptions + + -- Capture initial values as of the time profiling starts. + -- It's important we snapshot both the durations and the id-to-root map, + -- since either of these may change during the profiling session + -- (e.g. when a fiber is re-rendered or when a fiber gets removed). + displayNamesByRootID = Map.new() + initialTreeBaseDurationsMap = Map.new(idToTreeBaseDurationMap) + initialIDToRootMap = Map.new(idToRootMap) + idToContextsMap = Map.new() + + hook.getFiberRoots(rendererID):forEach(function(root) + local rootID = getFiberID(getPrimaryFiber(root.current)); + ((displayNamesByRootID :: any) :: DisplayNamesByRootID):set( + rootID, + getDisplayNameForRoot(root.current) + ) + + if shouldRecordChangeDescriptions then + -- Record all contexts at the time profiling is started. + -- Fibers only store the current context value, + -- so we need to track them separately in order to determine changed keys. + crawlToInitializeContextsMap(root.current) + end + end) + + isProfiling = true + profilingStartTime = getCurrentTime() + rootToCommitProfilingMetadataMap = Map.new() + end + + local function stopProfiling() + isProfiling = false + recordChangeDescriptions = false + end + + -- Automatically start profiling so that we don't miss timing info from initial "mount". + if sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) == "true" then + startProfiling( + sessionStorageGetItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY) + == "true" + ) + end + + -- React will switch between these implementations depending on whether + -- we have any manually suspended Fibers or not. + local function shouldSuspendFiberAlwaysFalse() + return false + end + + local function shouldSuspendFiberAccordingToSet(fiber: Fiber) + local id = getFiberID(getPrimaryFiber(fiber)) + return forceFallbackForSuspenseIDs:has(id) + end + -- ROBLOX FIXME Luau: infers this as (number, a) -> (), but it doesn't later normalize to (number, boolean) -> () + local function overrideSuspense(id: number, forceFallback: boolean): () + if + typeof(setSuspenseHandler) ~= "function" + or typeof(scheduleUpdate) ~= "function" + then + error( + "Expected overrideSuspense() to not get called for earlier React versions." + ) + end + if forceFallback then + forceFallbackForSuspenseIDs:add(id) + + if forceFallbackForSuspenseIDs.size == 1 then + -- First override is added. Switch React to slower path. + setSuspenseHandler(shouldSuspendFiberAccordingToSet) + end + else + forceFallbackForSuspenseIDs:delete(id) + + if forceFallbackForSuspenseIDs.size == 0 then + -- Last override is gone. Switch React back to fast path. + setSuspenseHandler(shouldSuspendFiberAlwaysFalse) + end + end + + local fiber: Fiber? = idToFiberMap:get(id) + + if fiber ~= nil then + scheduleUpdate(fiber :: Fiber) + end + end + + setTrackedPath = function(path: Array | nil): () + if path == nil then + trackedPathMatchFiber = nil + trackedPathMatchDepth = -1 + mightBeOnTrackedPath = false + end + + trackedPath = path + end + + -- We call this before traversing a new mount. + -- It remembers whether this Fiber is the next best match for tracked path. + -- The return value signals whether we should keep matching siblings or not. + updateTrackedPathStateBeforeMount = function(fiber: Fiber): boolean + if trackedPath == nil or not mightBeOnTrackedPath then + -- Fast path: there's nothing to track so do nothing and ignore siblings. + return false + end + + local returnFiber = fiber.return_ + local returnAlternate = if returnFiber ~= nil then returnFiber.alternate else nil + -- By now we know there's some selection to restore, and this is a new Fiber. + -- Is this newly mounted Fiber a direct child of the current best match? + -- (This will also be true for new roots if we haven't matched anything yet.) + if + trackedPathMatchFiber == returnFiber + or trackedPathMatchFiber == returnAlternate and returnAlternate ~= nil + then + -- Is this the next Fiber we should select? Let's compare the frames. + local actualFrame = getPathFrame(fiber) + local expectedFrame: PathFrame? = (trackedPath :: Array)[trackedPathMatchDepth + 1] + + if expectedFrame == nil then + error("Expected to see a frame at the next depth.") + end + if + actualFrame.index == (expectedFrame :: PathFrame).index + and actualFrame.key == (expectedFrame :: PathFrame).key + and actualFrame.displayName + == (expectedFrame :: PathFrame).displayName + then + -- We have our next match. + trackedPathMatchFiber = fiber + trackedPathMatchDepth = trackedPathMatchDepth + 1 + -- Are we out of frames to match? + if trackedPathMatchDepth == #(trackedPath :: Array) - 1 then + -- There's nothing that can possibly match afterwards. + -- Don't check the children. + mightBeOnTrackedPath = false + else + -- Check the children, as they might reveal the next match. + mightBeOnTrackedPath = true + end + -- In either case, since we have a match, we don't need + -- to check the siblings. They'll never match. + return false + end + end + + -- This Fiber's parent is on the path, but this Fiber itself isn't. + -- There's no need to check its children--they won't be on the path either. + mightBeOnTrackedPath = false + -- However, one of its siblings may be on the path so keep searching. + return true + end + + updateTrackedPathStateAfterMount = function(mightSiblingsBeOnTrackedPath) + -- updateTrackedPathStateBeforeMount() told us whether to match siblings. + -- Now that we're entering siblings, let's use that information. + mightBeOnTrackedPath = mightSiblingsBeOnTrackedPath + end + + -- ROBLOX deviation: rootPseudoKeys and rootDisplayNameCounter defined earlier in the file + setRootPseudoKey = function(id: number, fiber: Fiber) + local name = getDisplayNameForRoot(fiber) + local counter = rootDisplayNameCounter:get(name) or 0 + rootDisplayNameCounter:set(name, counter + 1) + local pseudoKey = string.format("%s:%d", name, counter) + rootPseudoKeys:set(id, pseudoKey) + end + removeRootPseudoKey = function(id: number) + local pseudoKey: string? = rootPseudoKeys:get(id) + + if pseudoKey == nil then + error("Expected root pseudo key to be known.") + end + + -- Luau FIXME: `pseudoKey == nil` above should narrow pseudoKey from string? to string + local name = string.sub( + pseudoKey :: string, + 1, + String.lastIndexOf(pseudoKey :: string, ":") - 1 + ) + local counter = rootDisplayNameCounter:get(name) + + -- ROBLOX FIXME Luau: needs type states to know past this branch count is non-nil + if counter == nil then + error("Expected counter to be known.") + end + if counter :: number > 1 then + rootDisplayNameCounter:set(name, counter :: number - 1) + else + rootDisplayNameCounter:delete(name) + end + + rootPseudoKeys:delete(id) + end + + getDisplayNameForRoot = function(fiber: Fiber): string + local preferredDisplayName = nil + local fallbackDisplayName = nil + local child = fiber.child + -- Go at most three levels deep into direct children + -- while searching for a child that has a displayName. + for i = 0, 3 - 1 do + if child == nil then + break + end + + local displayName = if type(child.key) == "string" + then child.key + else getDisplayNameForFiber(child :: Fiber) + + if displayName ~= nil then + -- Prefer display names that we get from user-defined components. + -- We want to avoid using e.g. 'Suspense' unless we find nothing else. + if typeof((child :: Fiber).type) == "function" then + -- There's a few user-defined tags, but we'll prefer the ones + -- that are usually explicitly named (function or class components). + preferredDisplayName = displayName + elseif fallbackDisplayName == nil then + fallbackDisplayName = displayName + end + end + if preferredDisplayName ~= nil then + break + end + + child = (child :: Fiber).child + end + + return preferredDisplayName or fallbackDisplayName or "Anonymous" + end + + getPathFrame = function(fiber: Fiber): PathFrame + local key = fiber.key + local displayName = getDisplayNameForFiber(fiber) + local index = fiber.index + + if fiber.tag == HostRoot then + -- Roots don't have a real displayName, index, or key. + -- Instead, we'll use the pseudo key (childDisplayName:indexWithThatName). + local id = getFiberID(getPrimaryFiber(fiber)) + local pseudoKey: string? = rootPseudoKeys:get(id) + if pseudoKey == nil then + error("Expected mounted root to have known pseudo key.") + end + displayName = pseudoKey :: string + elseif fiber.tag == HostComponent then + displayName = fiber.type + end + + return { + displayName = displayName, + key = key, + index = index, + } + end + + -- Produces a serializable representation that does a best effort + -- of identifying a particular Fiber between page reloads. + -- The return path will contain Fibers that are "invisible" to the store + -- because their keys and indexes are important to restoring the selection. + local function getPathForElement(id: number): Array | nil + local fiber: Fiber? = idToFiberMap:get(id) + if fiber == nil then + return nil + end + + local keyPath = {} + while fiber ~= nil do + table.insert(keyPath, getPathFrame(fiber :: Fiber)) + fiber = (fiber :: Fiber).return_ + end + + Array.reverse(keyPath) + return keyPath + end + + local function getBestMatchForTrackedPath(): PathMatch | nil + if trackedPath == nil then + -- Nothing to match. + return nil + end + if trackedPathMatchFiber == nil then + -- We didn't find anything. + return nil + end + + -- Find the closest Fiber store is aware of. + local fiber: Fiber? = trackedPathMatchFiber + while fiber ~= nil and shouldFilterFiber(fiber :: Fiber) do + fiber = (fiber :: Fiber).return_ + end + + if fiber == nil then + return nil + end + + return { + id = getFiberID(getPrimaryFiber(fiber :: Fiber)), + isFullMatch = trackedPathMatchDepth == #(trackedPath :: Array), + } + end + + local function setTraceUpdatesEnabled(isEnabled: boolean): () + traceUpdatesEnabled = isEnabled + end + + return { + cleanup = cleanup, + copyElementPath = copyElementPath, + deletePath = deletePath, + findNativeNodesForFiberID = findNativeNodesForFiberID, + flushInitialOperations = flushInitialOperations, + getBestMatchForTrackedPath = getBestMatchForTrackedPath, + getDisplayNameForFiberID = getDisplayNameForFiberID, + getFiberIDForNative = getFiberIDForNative, + getInstanceAndStyle = getInstanceAndStyle, + getOwnersList = getOwnersList, + getPathForElement = getPathForElement, + getProfilingData = getProfilingData, + handleCommitFiberRoot = handleCommitFiberRoot, + handleCommitFiberUnmount = handleCommitFiberUnmount, + inspectElement = inspectElement, + logElementToConsole = logElementToConsole, + prepareViewAttributeSource = prepareViewAttributeSource, + prepareViewElementSource = prepareViewElementSource, + overrideSuspense = overrideSuspense, + overrideValueAtPath = overrideValueAtPath, + renamePath = renamePath, + renderer = renderer, + setTraceUpdatesEnabled = setTraceUpdatesEnabled, + setTrackedPath = setTrackedPath, + startProfiling = startProfiling, + stopProfiling = stopProfiling, + storeAsGlobal = storeAsGlobal, + updateComponentFilters = updateComponentFilters, + -- ROBLOX deviation: expose extra function for Roblox Studio use + getDisplayNameForRoot = getDisplayNameForRoot, + } +end + +return exports 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..82b2acdc --- /dev/null +++ b/modules/react-devtools-shared/src/backend/types.lua @@ -0,0 +1,380 @@ +-- 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 Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +type Function = (...any) -> any +type Map = LuauPolyfill.Map +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(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType + +local DevToolsViewsProfilerTypes = + require(script.Parent.Parent.devtools.views.Profiler.types) +type Interaction = DevToolsViewsProfilerTypes.Interaction + +type ResolveNativeStyle = (any) -> Object? + +-- ROBLOX deviation: 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 = ReactShared.Dispatcher +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, + -- ... +} + +-- ROBLOX deviation: most of the instance methods are nil-able upstream, but we can't typecheck inline when using the colon call operator +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: (shouldSuspend: (fiber: 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?, + -- ... +} + +type null = { __T: "ENCODE_TO_NULL" } + +export type ChangeDescription = { + context: Array | boolean | null, + didHooksChange: boolean, + isFirstMount: boolean, + props: Array | null, + state: Array | null, +} + +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> | null, + duration: number, + -- Tuple of fiber ID and actual duration + fiberActualDurations: Array>, + -- Tuple of fiber ID and computed "self" duration + 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, +} + +-- ROBLOX deviation: Roact stable keys - slightly widen the type definition of a +-- stable key so that it's likely to work with existing Roact code. Includes +-- numbers for mixed/sparse tables +type RoactStableKey = string | number + +export type PathFrame = { + key: RoactStableKey | 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) -> (), + -- ROBLOX TODO: once we are back up to 70% coverage, use [string]: any to approximate the ... below + -- ... + -- ROBLOX deviation: add specific exports needed so the contract is explcit and explicitly typed + getDisplayNameForRoot: (fiber: Fiber) -> string, +} + +export type Handler = (any) -> () + +-- ROBLOX TODO? move these types into shared so reconciler and devtools don't have circlar dep? +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? + ) -> (), + -- ROBLOX deviation: track specific additions to interface needed instead of catch-all + supportsFiber: boolean, + isDisabled: boolean?, + -- ... +} + +return exports diff --git a/modules/react-devtools-shared/src/backend/utils.lua b/modules/react-devtools-shared/src/backend/utils.lua new file mode 100644 index 00000000..31ff4f26 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/utils.lua @@ -0,0 +1,178 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/backend/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 Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Set = LuauPolyfill.Set +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +local hydration = require(script.Parent.Parent.hydration) +local dehydrate = hydration.dehydrate + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type DehydratedData = ComponentsTypes.DehydratedData + +-- ROBLOX deviation: Use HttpService for JSON +local JSON = game:GetService("HttpService") + +local exports: any = {} + +exports.cleanForBridge = function( + data: Object | nil, + isPathAllowed: (path: Array) -> boolean, + path: Array? +): DehydratedData | nil + path = path or {} + if data ~= nil then + local cleanedPaths: Array> = {} + local unserializablePaths: Array> = {} + local cleanedData = dehydrate( + data :: Object, + cleanedPaths, + unserializablePaths, + path :: Array, + isPathAllowed + ) + return { + data = cleanedData, + cleaned = cleanedPaths, + unserializable = unserializablePaths, + } + else + return nil + end +end +exports.copyToClipboard = function(value: any): () + -- ROBLOX TODO: we will need a different implementation for this + -- local safeToCopy = serializeToString(value) + -- local text = (function() + -- if safeToCopy == nil then + -- return'undefined' + -- end + + -- return safeToCopy + -- end)() + -- local clipboardCopyText = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.clipboardCopyText + + -- if typeof(clipboardCopyText) == 'function' then + -- clipboardCopyText(text).catch(function(err) end) + -- else + -- copy(text) + -- end +end + +exports.copyWithDelete = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + path: Array, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + local key = path[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + -- ROBLOX deviation: 1-indexed, check for last element + if index == #path then + if Array.isArray(updated) then + Array.splice(updated, key :: number, 1) + else + updated[key] = nil + end + else + updated[key] = exports.copyWithDelete(obj[key], path, index + 1) + end + + return updated +end + +-- This function expects paths to be the same except for the final value. +-- e.g. ['path', 'to', 'foo'] and ['path', 'to', 'bar'] +exports.copyWithRename = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + oldPath: Array, + newPath: Array, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + local oldKey = oldPath[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + -- ROBLOX deviation: 1-indexed, check for last element + if index == #oldPath then + local newKey = newPath[index] + + updated[newKey] = updated[oldKey] + + if Array.isArray(updated) then + Array.splice(updated, oldKey :: number, 1) + else + updated[oldKey] = nil + end + else + updated[oldKey] = exports.copyWithRename(obj[oldKey], oldPath, newPath, index + 1) + end + + return updated +end + +exports.copyWithSet = function( + -- ROBLOX FIXME Luau: workaround for Expected type table, got 'Array | Object' instead + obj: { [any]: any }, --Object | Array, + path: Array, + value: any, + index: number +): Object | Array + -- ROBLOX deviation: 1-indexed + index = index or 1 + + -- ROBLOX deviation: 1-indexed, check for out of bounds + if index > #path then + return value + end + + local key = path[index] + -- ROBLOX deviation START: combine [].slice() and spread into single op, because we can + local updated = table.clone(obj) + -- ROBLOX deviation END + + updated[key] = exports.copyWithSet(obj[key], path, value, index + 1) + + return updated +end + +exports.serializeToString = function(data: any): string + local cache = Set.new() + + return JSON.JSONEncode(data, function(key, value) + -- ROBLOX deviation: use 'table' not object + if typeof(value) == "table" and value ~= nil then + if cache:has(value) then + return + end + + cache:add(value) + end + -- ROBLOX deviation: not Luau + -- if typeof(value) == 'bigint' then + -- return tostring(value) + 'n' + -- end + + return value + end) +end + +return exports diff --git a/modules/react-devtools-shared/src/backend/views/Highlighter/Highlighter.lua b/modules/react-devtools-shared/src/backend/views/Highlighter/Highlighter.lua new file mode 100644 index 00000000..b872d669 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/views/Highlighter/Highlighter.lua @@ -0,0 +1,64 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/b8390310b14cce89fd26df83f969505b5f129f10/packages/react-devtools-shared/src/backend/views/Highlighter/Highlighter.js + +local Overlay = require(script.Parent.Overlay.Overlay) +type Overlay = Overlay.Overlay + +local exports = {} + +local timeoutThread: thread? = nil +local overlay: Overlay? = nil + +local SHOW_DURATION = 2 + +function exports.hideOverlay() + if timeoutThread then + task.cancel(timeoutThread) + end + timeoutThread = nil + + if overlay ~= nil then + overlay:remove() + overlay = nil + end +end + +function exports.showOverlay( + elements: { GuiBase2d }?, + componentName: string?, + hideAfterTimeout: boolean +) + if timeoutThread ~= nil then + task.cancel(timeoutThread) + timeoutThread = nil + end + + if elements == nil then + return + end + + if overlay == nil then + overlay = Overlay.new() + end + + -- Sometimes the object the overlay is attached to is destroyed + -- In that case, create a new overlay + if overlay and overlay.container.Parent == nil then + overlay:remove() + overlay = Overlay.new() + end + + assert(overlay, "Luau") + overlay:inspect(elements, componentName) + + if hideAfterTimeout then + timeoutThread = task.delay(SHOW_DURATION, function() + exports.hideOverlay() + end) + end +end + +function exports.getOverlay(): Overlay? + return overlay +end + +return exports diff --git a/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/Overlay.lua b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/Overlay.lua new file mode 100644 index 00000000..c0f16102 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/Overlay.lua @@ -0,0 +1,175 @@ +-- Based on: https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js + +local PackageRoot = script.Parent.Parent.Parent.Parent.Parent + +local Packages = PackageRoot.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array + +local BackendTypes = require(PackageRoot.backend.types) +type DevToolsHook = BackendTypes.DevToolsHook + +local OverlayRect = require(script.Parent.OverlayRect) +local OverlayTip = require(script.Parent.OverlayTip) +type OverlayRect = OverlayRect.OverlayRect + +local function getNestedBoundingClientRect(node: GuiBase2d): Rect + local bounds = node.AbsoluteSize + local position = node.AbsolutePosition + return Rect.new(position, position + bounds) +end + +local Overlay = {} +Overlay.__index = Overlay + +function Overlay.new() + local self = setmetatable({}, Overlay) + self.layerCollector = nil :: LayerCollector? + + -- We use a Folder to exempt the overlay from any external layout + -- influences. + self.container = Instance.new("Folder") + self.container.Name = "REACT_DEVTOOLS_OVERLAY" + + -- Use a button to sink inputs under the overlay when clicking + self.containerFrame = Instance.new("ImageButton") + self.containerFrame.Name = "OverlayContainer" + self.containerFrame.Size = UDim2.fromScale(1, 1) + self.containerFrame.BackgroundTransparency = 1 + self.containerFrame.Image = "" + self.containerFrame.ZIndex = 1_000_000 + self.containerFrame.Parent = self.container + + self.rectContainer = Instance.new("CanvasGroup") + self.rectContainer.Name = "OverlayRects" + self.rectContainer.Size = UDim2.fromScale(1, 1) + self.rectContainer.BackgroundTransparency = 1 + self.rectContainer.GroupTransparency = 0.3 + self.rectContainer.ZIndex = 1_000_000 + 1 + self.rectContainer.Parent = self.containerFrame + + self.tip = OverlayTip.new(self.containerFrame) + self.rects = {} :: { OverlayRect } + + return self +end + +export type Overlay = typeof(Overlay.new(...)) + +function Overlay.remove(self: Overlay) + self.tip:remove() + for _, rect in self.rects do + rect:remove() + end + table.clear(self.rects) + self.container.Parent = nil +end + +function Overlay.inspect(self: Overlay, nodes: { GuiBase2d }, name: string?) + -- Get a common layer collector and discard any nodes with a different one + -- It should be a rare case where one fiber has multiple layer collectors + -- but it can happen with portals. In that case, some of the nodes won't + -- be highlighted. + self.layerCollector = nodes[1]:FindFirstAncestorWhichIsA("LayerCollector") + if self.layerCollector == nil then + for _, node in nodes do + if node:IsA("LayerCollector") then + self.layerCollector = node + break + end + end + end + + self.container.Parent = self.layerCollector + local elements = Array.filter(nodes, function(node: GuiBase2d) + return node:IsA("LayerCollector") + or ( + if self.layerCollector + then node:IsDescendantOf(self.layerCollector) + else false + ) + end) + + -- Remove extra rects + for i = #elements + 1, #self.rects do + self.rects[i]:remove() + end + + if #elements == 0 then + return + end + + while #self.rects < #elements do + table.insert(self.rects, OverlayRect.new(self.rectContainer)) + end + + local outerBox = { + top = math.huge, + right = -math.huge, + bottom = -math.huge, + left = math.huge, + } + for index, element in elements do + local box = getNestedBoundingClientRect(element) + + outerBox.top = math.min(outerBox.top, box.Min.Y) + outerBox.right = math.max(outerBox.right, box.Max.X) + outerBox.bottom = math.max(outerBox.bottom, box.Max.Y) + outerBox.left = math.min(outerBox.left, box.Min.X) + + local rect = self.rects[index] + rect:update(element) + end + + if name == nil or name == "" then + local node = elements[1] + name = node.Name + + local hook: DevToolsHook? = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if hook ~= nil and hook.rendererInterfaces ~= nil then + local ownerName = nil + for _, rendererInterface in hook.rendererInterfaces:values() do + local id = rendererInterface.getFiberIDForNative(node, true) + if id ~= nil then + ownerName = rendererInterface.getDisplayNameForFiberID(id, true) + break + end + end + + if ownerName ~= nil then + name = `{name} (in {ownerName})` + end + end + end + + self.tip:updateText( + name, + outerBox.right - outerBox.left, + outerBox.bottom - outerBox.top + ) + + local tipBoundsWindow: GuiBase2d = if self.layerCollector + then self.layerCollector + elseif elements[1]:IsA("LayerCollector") then elements[1] + else nil + local tipBounds = if tipBoundsWindow + then getNestedBoundingClientRect(tipBoundsWindow) + else Rect.new(Vector2.new(0, 0), Vector2.new(math.huge, math.huge)) + + self.tip:updatePosition({ + top = outerBox.top, + left = outerBox.left, + height = outerBox.bottom - outerBox.top, + width = outerBox.right - outerBox.left, + }, { + top = tipBounds.Min.Y, + left = tipBounds.Min.X, + height = tipBounds.Height, + width = tipBounds.Width, + }) +end + +return { + new = Overlay.new, +} diff --git a/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayRect.lua b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayRect.lua new file mode 100644 index 00000000..09c2f1bb --- /dev/null +++ b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayRect.lua @@ -0,0 +1,66 @@ +local OverlayRect = {} +OverlayRect.__index = OverlayRect + +function OverlayRect.new(container: GuiBase2d) + local self = setmetatable({}, OverlayRect) + self.container = container + + local node = Instance.new("Frame") + node.Name = "OverlayRect" + node.BackgroundTransparency = 1 + node.Parent = container + self.node = node + + local padding = Instance.new("Frame") + padding.Name = "OverlayRectPadding" + padding.BackgroundColor3 = Color3.fromRGB(77, 200, 0) + padding.Size = UDim2.fromScale(1, 1) + padding.BackgroundTransparency = 0.4 + padding.BorderSizePixel = 0 + padding.Parent = node + self.padding = padding + + local content = Instance.new("Frame") + content.Name = "OverlayRectContent" + content.BackgroundColor3 = Color3.fromRGB(120, 170, 210) + content.Size = UDim2.fromScale(1, 1) + content.BorderSizePixel = 0 + content.ZIndex = 2 + content.Parent = node + self.content = content + + return self +end + +export type OverlayRect = typeof(OverlayRect.new(...)) + +function OverlayRect.remove(self: OverlayRect) + self.node:Destroy() +end + +function OverlayRect.update(self: OverlayRect, element: GuiBase2d) + local size = element.AbsoluteSize + local position = element.AbsolutePosition + + local padding = element:FindFirstChildOfClass("UIPadding") + if padding then + local top = (padding.PaddingTop.Scale * size.Y) + padding.PaddingTop.Offset + local left = (padding.PaddingLeft.Scale * size.X) + padding.PaddingLeft.Offset + local bottom = (padding.PaddingBottom.Scale * size.Y) + + padding.PaddingBottom.Offset + local right = (padding.PaddingRight.Scale * size.X) + padding.PaddingRight.Offset + + self.content.Position = UDim2.fromOffset(left, top) + self.content.Size = UDim2.fromOffset(size.X - left - right, size.Y - top - bottom) + else + self.content.Position = UDim2.fromOffset(0, 0) + self.content.Size = UDim2.fromOffset(size.X, size.Y) + end + + self.node.Size = UDim2.fromOffset(size.X, size.Y) + self.node.Position = UDim2.fromOffset(position.X, position.Y) +end + +return { + new = OverlayRect.new, +} diff --git a/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayTip.lua b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayTip.lua new file mode 100644 index 00000000..7c7c49ad --- /dev/null +++ b/modules/react-devtools-shared/src/backend/views/Highlighter/Overlay/OverlayTip.lua @@ -0,0 +1,132 @@ +type Box = { top: number, left: number, width: number, height: number } + +local function calculatePosition(dims: Box, bounds: Box, size: Vector2) + local tipHeight = math.max(size.Y, 20) + local tipWidth = math.max(size.X, 60) + local margin = 5 + + local top: number + if dims.top + dims.height + tipHeight <= bounds.top + bounds.height then + if dims.top + dims.height < bounds.top + 0 then + top = bounds.top + margin + else + top = dims.top + dims.height + margin + end + elseif dims.top - tipHeight <= bounds.top + bounds.height then + if dims.top - tipHeight - margin < bounds.top + margin then + top = bounds.top + margin + else + top = dims.top - tipHeight - margin + end + else + top = bounds.top + bounds.height - tipHeight - margin + end + + local left = dims.left + margin + if dims.left < bounds.left then + left = bounds.left + margin + end + if dims.left + tipWidth > bounds.left + bounds.width then + left = bounds.left + bounds.width - tipWidth - margin + end + + return Vector2.new(left, top) +end + +local OverlayTip = {} +OverlayTip.__index = OverlayTip + +function OverlayTip.new(container: GuiBase2d) + local self = setmetatable({}, OverlayTip) + + local background = Instance.new("Frame") + background.Name = "OverlayTip" + background.BackgroundColor3 = Color3.fromHex("#333740") + background.AutomaticSize = Enum.AutomaticSize.XY + background.Size = UDim2.fromScale(0, 0) + background.BorderSizePixel = 0 + background.ZIndex = 1_000_000 + 1 + self.background = background + + local layout = Instance.new("UIListLayout") + layout.SortOrder = Enum.SortOrder.LayoutOrder + layout.FillDirection = Enum.FillDirection.Horizontal + layout.VerticalFlex = Enum.UIFlexAlignment.Fill + layout.Padding = UDim.new(0, 6) + layout.Parent = background + + local padding = Instance.new("UIPadding") + padding.PaddingTop = UDim.new(0, 4) + padding.PaddingBottom = UDim.new(0, 4) + padding.PaddingLeft = UDim.new(0, 6) + padding.PaddingRight = UDim.new(0, 6) + padding.Parent = background + + local cornerRadius = Instance.new("UICorner") + cornerRadius.CornerRadius = UDim.new(0, 2) + cornerRadius.Parent = background + + local name = Instance.new("TextLabel") + name.Name = "Name" + name.Size = UDim2.fromScale(0, 0) + name.AutomaticSize = Enum.AutomaticSize.XY + name.BackgroundTransparency = 1 + name.LayoutOrder = 1 + name.Font = Enum.Font.BuilderSansBold + name.TextColor3 = Color3.fromHex("#ee78e6") + name.TextSize = 16 + name.ZIndex = 1_000_000 + 2 + name.Parent = background + self.nameLabel = name + + local divider = Instance.new("Frame") + divider.Name = "Divider" + divider.Size = UDim2.fromOffset(1, 0) + divider.BackgroundColor3 = Color3.fromHex("#aaaaaa") + divider.BorderSizePixel = 0 + divider.LayoutOrder = 2 + divider.ZIndex = 1_000_000 + 2 + divider.Parent = background + + local dimensions = Instance.new("TextLabel") + dimensions.Name = "Dimensions" + dimensions.Size = UDim2.fromScale(0, 0) + dimensions.AutomaticSize = Enum.AutomaticSize.XY + dimensions.BackgroundTransparency = 1 + dimensions.LayoutOrder = 3 + dimensions.Font = Enum.Font.BuilderSansBold + dimensions.TextColor3 = Color3.fromHex("#d7d7d7") + dimensions.TextSize = 16 + dimensions.ZIndex = 1_000_000 + 2 + dimensions.Parent = background + self.dimensionsLabel = dimensions + + background.Parent = container + + return self +end + +export type OverlayTip = typeof(OverlayTip.new(...)) + +function OverlayTip.remove(self: OverlayTip) + self.background:Destroy() +end + +function OverlayTip.updateText( + self: OverlayTip, + name: string, + width: number, + height: number +) + self.nameLabel.Text = name + self.dimensionsLabel.Text = `{math.round(width)}px x {math.round(height)}px` +end + +function OverlayTip.updatePosition(self: OverlayTip, dims: Box, bounds: Box) + local position = calculatePosition(dims, bounds, self.background.AbsoluteSize) + self.background.Position = UDim2.fromOffset(position.X, position.Y) +end + +return { + new = OverlayTip.new, +} diff --git a/modules/react-devtools-shared/src/backend/views/Highlighter/init.lua b/modules/react-devtools-shared/src/backend/views/Highlighter/init.lua new file mode 100644 index 00000000..dbf5d475 --- /dev/null +++ b/modules/react-devtools-shared/src/backend/views/Highlighter/init.lua @@ -0,0 +1,243 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/e706721490e50d0bd6af2cd933dbf857fd8b61ed/packages/react-devtools-shared/src/backend/views/Highlighter/index.js + +local UserInputService = game:GetService("UserInputService") +local Players = game:GetService("Players") + +local PackageRoot = script.Parent.Parent.Parent + +local Packages = PackageRoot.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local console = LuauPolyfill.console + +local Highlighter = require(script.Highlighter) +local hideOverlay = Highlighter.hideOverlay +local showOverlay = Highlighter.showOverlay + +local Bridge = require(PackageRoot.bridge) +type BackendBridge = Bridge.BackendBridge + +-- TODO-Roblox: Importing Agent is a cyclic dependency - need to refactor +type Agent = any + +local function isVisible(obj: GuiObject) + local basicCheck = obj.Visible == true + + if obj:IsA("Frame") then + return basicCheck and obj.BackgroundTransparency < 1 + elseif obj:IsA("CanvasGroup") then + return basicCheck and obj.BackgroundTransparency < 1 and obj.GroupTransparency < 1 + elseif obj:IsA("TextLabel") and obj:IsA("TextButton") then + return basicCheck and obj.TextTransparency < 1 and obj.Text ~= "" + elseif obj:IsA("ImageLabel") and obj:IsA("ImageButton") then + return basicCheck and obj.ImageTransparency < 1 and obj.Image ~= "" + else + return basicCheck + end +end + +local function pickGuiObjectNodes(nodes: { Instance }): { GuiBase2d } + local relevantNodes = {} + for _, node in nodes do + if node:IsA("GuiBase2d") then + table.insert(relevantNodes, node) + end + end + return relevantNodes +end + +local function isInputValid(input: InputObject) + return input.UserInputType == Enum.UserInputType.MouseMovement + or input.UserInputType == Enum.UserInputType.MouseButton1 + or input.UserInputType == Enum.UserInputType.Touch +end + +local exports = {} + +function exports.setupHighlighter(bridge: BackendBridge, agent: Agent) + local listenerConnections: { RBXScriptConnection } = {} + local stopInspectingNative + + -- TODO: Intelligently pick the right container based on context + local guiContainer: BasePlayerGui? = nil + local isCoreGui = true + if isCoreGui then + guiContainer = game:GetService("CoreGui") + else + local localPlayer = Players.LocalPlayer + if localPlayer ~= nil then + guiContainer = localPlayer:FindFirstChildOfClass("PlayerGui") + else + console.warn("No PlayerGui found for LocalPlayer") + end + end + + -- TODO: Throttle this function + local function selectFiberForNode(node: GuiObject?) + if node == nil then + return + end + + local id = agent:getIDForNode(node) + if id ~= nil then + bridge:send("selectFiber", id) + end + end + + local function onInputChanged(input: InputObject) + if guiContainer == nil then + return + end + + if not isInputValid(input) then + return + end + + local position = input.Position + local guiObjects = guiContainer:GetGuiObjectsAtPosition(position.X, position.Y) + + -- Get target GUI element + local target = nil + local overlay = Highlighter.getOverlay() + local overlayObj = overlay and overlay.container + + for index, guiObject in guiObjects do + if overlayObj and guiObject:IsDescendantOf(overlayObj) then + -- Don't highlight the overlay itself + continue + end + + local isLastElement = index == #guiObjects + if not isVisible(guiObject) and not isLastElement then + continue + end + + target = guiObject + break + end + + if target == nil then + hideOverlay() + return + end + + -- Don't pass the name explicitly. + -- It will be inferred from DOM tag and Fiber owner. + showOverlay({ target }, nil, nil) + + selectFiberForNode(target) + end + + local lastInputProcessed = false + local function onInputBegan(input: InputObject, gameProcessedEvent: boolean) + if not isInputValid(input) then + return + end + + lastInputProcessed = gameProcessedEvent + end + + local function onInputEnded(input: InputObject) + if not isInputValid(input) then + return + end + + -- If the event wasn't processed then the user most likely clicked to + -- focus the viewport, so we don't want to select any fiber. + if not lastInputProcessed then + return + end + + stopInspectingNative() + bridge:send("stopInspectingNative", true) + end + + local function removeInputListeners() + for _, connection in listenerConnections do + connection:Disconnect() + end + table.clear(listenerConnections) + end + + local function addInputListeners() + table.insert( + listenerConnections, + UserInputService.InputChanged:Connect(onInputChanged) + ) + table.insert( + listenerConnections, + UserInputService.InputBegan:Connect(onInputBegan) + ) + table.insert( + listenerConnections, + UserInputService.InputEnded:Connect(onInputEnded) + ) + end + + local function clearNativeElementHighlight() + hideOverlay() + end + + local function highlightNativeElement(props: { + displayName: string | nil, + hideAfterTimeout: boolean, + id: number, + openNativeElementsPanel: boolean, + rendererID: number, + scrollIntoView: boolean, + }) + local displayName = props.displayName + local hideAfterTimeout = props.hideAfterTimeout + local id = props.id + local openNativeElementsPanel = props.openNativeElementsPanel + local rendererID = props.rendererID + local _scrollIntoView = props.scrollIntoView + + local renderer = agent._rendererInterfaces[rendererID] + if renderer == nil then + console.warn(`Invalid renderer id "{rendererID}" for element "{id}"`) + end + + local nodes: { Instance }? = nil + if renderer ~= nil then + nodes = renderer.findNativeNodesForFiberID(id) + end + + if nodes ~= nil and nodes[1] ~= nil then + -- TODO-Roblox: Support scrolling node into view + -- This would require checking if the node is in a scrollable + -- container and then scrolling those containers to center the node. + local relevantNodes = pickGuiObjectNodes(nodes) + if #relevantNodes == 0 then + hideOverlay() + return + end + + showOverlay(relevantNodes, displayName, hideAfterTimeout) + + if openNativeElementsPanel then + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__["$0"] = relevantNodes[1] + bridge:send("syncSelectionToNativeElementsPanel") + end + else + hideOverlay() + end + end + + local function startInspectingNative() + addInputListeners() + end + + stopInspectingNative = function() + hideOverlay() + removeInputListeners() + end + + bridge:addListener("clearNativeElementHighlight", clearNativeElementHighlight) + bridge:addListener("highlightNativeElement", highlightNativeElement) + bridge:addListener("shutdown", stopInspectingNative) + bridge:addListener("startInspectingNative", startInspectingNative) + bridge:addListener("stopInspectingNative", stopInspectingNative) +end + +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..cb96dd96 --- /dev/null +++ b/modules/react-devtools-shared/src/bridge.lua @@ -0,0 +1,399 @@ +--!strict +-- 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 Packages = script.Parent.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 deviation: Luau can't spread keys of a type as string +type EventName = string -- $Keys +-- ROBLOX deviation: Luau can't express +-- type $ElementType = T[K]; +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: ( + self: Bridge, + eventName: EventName, + ...ElementType + ) -> (), + shutdown: (self: Bridge) -> (), + _flush: (self: Bridge) -> (), + overrideValueAtPath: ( + self: Bridge, + _ref: OverrideValueAtPath + ) -> (), +} + +type Bridge_Statics = { + new: (wall: Wall) -> Bridge, +} + +-- ROBLOX deviation: not sure where TimeoutID comes from in upstream +type TimeoutID = any +local Bridge: Bridge & Bridge_Statics = setmetatable( + {}, + { __index = EventEmitter } +) :: any +local BridgeMetatable = { __index = Bridge } + +function Bridge.new(wall: Wall) + local self = setmetatable(EventEmitter.new() :: any, BridgeMetatable) + + -- ROBLOX deviation: initializers from class declaration + self._isShutdown = false + self._messageQueue = {} :: Array> + 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", function(...) + return self:overrideValueAtPath(...) + end) + + -- 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( + string.format( + 'Cannot send message "%s" through a Bridge that has been shutdown.', + 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 + -- ROBLOX deviation: Use a while loop instead of for loop to handle new insertions during the loop + local i = 1 + while i < #self._messageQueue do + self._wall.send( + self._messageQueue[i], + table.unpack(self._messageQueue[i + 1]) + ) + i += 2 + 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 = id, + path = path, + rendererID = rendererID, + wasForwarded = true, + value = value, + }) + end +end + +export type BackendBridge = Bridge +export type FrontendBridge = Bridge + +return Bridge diff --git a/modules/react-devtools-shared/src/clipboardjs.mock.lua b/modules/react-devtools-shared/src/clipboardjs.mock.lua new file mode 100644 index 00000000..b1000cd0 --- /dev/null +++ b/modules/react-devtools-shared/src/clipboardjs.mock.lua @@ -0,0 +1,2 @@ +-- ROBLOX TODO: stub for clipboardjs, remove when we know how we'll handle its intent in a Roblox way +return {} diff --git a/modules/react-devtools-shared/src/constants.lua b/modules/react-devtools-shared/src/constants.lua new file mode 100644 index 00000000..051c40d7 --- /dev/null +++ b/modules/react-devtools-shared/src/constants.lua @@ -0,0 +1,66 @@ +-- 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 Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) + +local exports = {} + +-- Flip this flag to true to enable verbose console debug logging. +exports.__DEBUG__ = ReactGlobals.__DEBUG__ + +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/ProfilerStore.lua b/modules/react-devtools-shared/src/devtools/ProfilerStore.lua new file mode 100644 index 00000000..6eaf46d7 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/ProfilerStore.lua @@ -0,0 +1,319 @@ +--!strict +--[[* + * 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 +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +local console = LuauPolyfill.console +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local prepareProfilingDataFrontendFromBackendAndStore = require( + script.Parent.views.Profiler.utils +).prepareProfilingDataFrontendFromBackendAndStore + +local devtoolsTypes = require(script.Parent.types) +type ProfilingCache = devtoolsTypes.ProfilingCache +export type ProfilerStore = devtoolsTypes.ProfilerStore +type Store = devtoolsTypes.Store + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local backendTypes = require(script.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend + +local profilerTypes = require(script.Parent.views.Profiler.types) +type CommitDataFrontend = profilerTypes.CommitDataFrontend +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +type ProfilerStore_statics = { + new: ( + bridge: FrontendBridge, + store: Store, + defaultIsProfiling: boolean + ) -> ProfilerStore, + __index: {}, +} + +local ProfilingCache = require(script.Parent.ProfilingCache) + +local ProfilerStore: ProfilerStore & ProfilerStore_statics = ( + setmetatable({}, { __index = EventEmitter }) :: any +) :: ProfilerStore & ProfilerStore_statics +ProfilerStore.__index = ProfilerStore + +function ProfilerStore.new( + bridge: FrontendBridge, + store: Store, + defaultIsProfiling: boolean +): ProfilerStore + local profilerStore: ProfilerStore = + setmetatable(EventEmitter.new() :: any, ProfilerStore) + profilerStore._dataBackends = {} + profilerStore._dataFrontend = nil + profilerStore._initialRendererIDs = Set.new() + profilerStore._initialSnapshotsByRootID = Map.new() + profilerStore._inProgressOperationsByRootID = Map.new() + profilerStore._isProfiling = defaultIsProfiling + profilerStore._rendererIDsThatReportedProfilingData = Set.new() + profilerStore._rendererQueue = Set.new() + profilerStore._bridge = bridge + profilerStore._store = store + + function profilerStore:_takeProfilingSnapshotRecursive( + elementID: number, + profilingSnapshots: Map + ) + local element = self._store:getElementByID(elementID) + if element ~= nil then + local snapshotNode: SnapshotNode = { + id = elementID, + children = Array.slice(element.children, 0), + displayName = element.displayName, + hocDisplayNames = element.hocDisplayNames, + key = element.key, + type = element.type, + } + profilingSnapshots:set(elementID, snapshotNode) + Array.forEach(element.children, function(childID) + return self:_takeProfilingSnapshotRecursive(childID, profilingSnapshots) + end) + end + end + function profilerStore:onBridgeOperations(operations: Array) + -- The first two values are always rendererID and rootID + local rendererID = operations[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + local rootID = operations[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ] + if self._isProfiling then + local profilingOperations = self._inProgressOperationsByRootID:get(rootID) + if profilingOperations == nil then + profilingOperations = { operations } + -- ROBLOX FIXME Luau: nil-ability always remove due to assignment if nil + self._inProgressOperationsByRootID:set( + rootID, + profilingOperations :: Array> + ) + else + table.insert(profilingOperations, operations) + end + + if not self._initialRendererIDs:has(rendererID) then + self._initialRendererIDs:add(rendererID) + end + + if not self._initialSnapshotsByRootID:has(rootID) then + self._initialSnapshotsByRootID:set(rootID, Map.new()) + end + self._rendererIDsThatReportedProfilingData:add(rendererID) + end + end + function profilerStore:onBridgeProfilingData(dataBackend: ProfilingDataBackend) + if self._isProfiling then + -- This should never happen, but if it does- ignore previous profiling data. + return + end + local rendererID = dataBackend.rendererID + if not self._rendererQueue:has(rendererID) then + error( + string.format( + 'Unexpected profiling data update from renderer "%s"', + tostring(rendererID) + ) + ) + end + table.insert(self._dataBackends, dataBackend) + self._rendererQueue:delete(rendererID) + if self._rendererQueue.size == 0 then + self._dataFrontend = prepareProfilingDataFrontendFromBackendAndStore( + self._dataBackends, + self._inProgressOperationsByRootID, + self._initialSnapshotsByRootID + ) + Array.splice(self._dataBackends, 0) + self:emit("isProcessingData") + end + end + function profilerStore:onBridgeShutdown() + self._bridge:removeListener("operations", self.onBridgeOperations) + self._bridge:removeListener("profilingData", self.onBridgeProfilingData) + self._bridge:removeListener("profilingStatus", self.onProfilingStatus) + self._bridge:removeListener("shutdown", self.onBridgeShutdown) + end + function profilerStore:onProfilingStatus(isProfiling: boolean) + if isProfiling then + Array.splice(self._dataBackends, 0) + self._dataFrontend = nil + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._rendererIDsThatReportedProfilingData:clear() + self._rendererQueue:clear() + -- Record all renderer IDs initially too (in case of unmount) + -- eslint-disable-next-line no-for-of-loops/no-for-of-loops + for _, rendererID in self._store:getRootIDToRendererID() do + if not self._initialRendererIDs:has(rendererID) then + self._initialRendererIDs:add(rendererID) + end + end + -- Record snapshot of tree at the time profiling is started. + -- This info is required to handle cases of e.g. nodes being removed during profiling. + for _, rootID in self._store:getRoots() do + local profilingSnapshots = Map.new() + self._initialSnapshotsByRootID:set(rootID, profilingSnapshots) + self:_takeProfilingSnapshotRecursive(rootID, profilingSnapshots) + end + end + if self._isProfiling ~= isProfiling then + self._isProfiling = isProfiling -- Invalidate suspense cache if profiling data is being (re-)recorded. + -- Note that we clear again, in case any views read from the cache while profiling. + -- (That would have resolved a now-stale value without any profiling data.) + self._cache:invalidate() + self:emit("isProfiling") -- If we've just finished a profiling session, we need to fetch data stored in each renderer interface + -- and re-assemble it on the front-end into a format (ProfilingDataFrontend) that can power the Profiler UI. + -- During this time, DevTools UI should probably not be interactive. + if not isProfiling then + Array.splice(self._dataBackends, 0) + self._rendererQueue:clear() -- Only request data from renderers that actually logged it. + -- This avoids unnecessary bridge requests and also avoids edge case mixed renderer bugs. + -- (e.g. when v15 and v16 are both present) + for _, rendererID in self._rendererIDsThatReportedProfilingData do + if not self._rendererQueue:has(rendererID) then + self._rendererQueue:add(rendererID) + self._bridge:send("getProfilingData", { + rendererID = rendererID, + }) + end + end + self:emit("isProcessingData") + end + end + end + + bridge:addListener("operations", function(...) + return profilerStore:onBridgeOperations(...) + end) + bridge:addListener("profilingData", function(...) + return profilerStore:onBridgeProfilingData(...) + end) + bridge:addListener("profilingStatus", function(...) + return profilerStore:onProfilingStatus(...) + end) + bridge:addListener("shutdown", function(...) + return profilerStore:onBridgeShutdown(...) + end) + + -- It's possible that profiling has already started (e.g. "reload and start profiling") + -- so the frontend needs to ask the backend for its status after mounting. + bridge:send("getProfilingStatus") + profilerStore._cache = ProfilingCache.new(profilerStore) + + return profilerStore +end +function ProfilerStore:getCommitData( + rootID: number, + commitIndex: number +): CommitDataFrontend + if self._dataFrontend ~= nil then + local dataForRoot = self._dataFrontend.dataForRoots:get(rootID) + if dataForRoot ~= nil then + local commitDatum = dataForRoot.commitData[commitIndex] + if commitDatum ~= nil then + return commitDatum + end + end + end + error( + string.format( + 'Could not find commit data for root "%s" and commit %s', + tostring(rootID), + tostring(commitIndex) + ) + ) +end +function ProfilerStore:getDataForRoot(rootID: number): ProfilingDataForRootFrontend + if self._dataFrontend ~= nil then + local dataForRoot = self._dataFrontend.dataForRoots:get(rootID) + if dataForRoot ~= nil then + return dataForRoot + end + end + error(string.format('Could not find commit data for root "%s"', tostring(rootID))) +end +function ProfilerStore:didRecordCommits(): boolean + return self._dataFrontend ~= nil and self._dataFrontend.dataForRoots.size > 0 +end +function ProfilerStore:isProcessingData(): boolean + return self._rendererQueue.size > 0 or #self._dataBackends > 0 +end +function ProfilerStore:isProfiling(): boolean + return self._isProfiling +end +function ProfilerStore:profilingCache(): ProfilingCache + return self._cache +end +function ProfilerStore:profilingData( + value: ProfilingDataFrontend | nil +): ...ProfilingDataFrontend? + if value == nil then + return self._dataFrontend + end + + if self._isProfiling then + console.warn("Profiling data cannot be updated while profiling is in progress.") + return + end + Array.splice(self._dataBackends, 0) + self._dataFrontend = value + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._cache:invalidate() + self:emit("profilingData") + return +end +function ProfilerStore:clear(): ...any? + Array.splice(self._dataBackends, 0) + self._dataFrontend = nil + self._initialRendererIDs:clear() + self._initialSnapshotsByRootID:clear() + self._inProgressOperationsByRootID:clear() + self._rendererQueue:clear() -- Invalidate suspense cache if profiling data is being (re-)recorded. + -- Note that we clear now because any existing data is "stale". + self._cache:invalidate() + self:emit("profilingData") +end +function ProfilerStore:startProfiling(): ...any? + self._bridge:send("startProfiling", self._store:getRecordChangeDescriptions()) -- Don't actually update the local profiling boolean yet! + -- Wait for onProfilingStatus() to confirm the status has changed. + -- This ensures the frontend and backend are in sync wrt which commits were profiled. + -- We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. +end +function ProfilerStore:stopProfiling(): ...any? + self._bridge:send("stopProfiling") -- Don't actually update the local profiling boolean yet! + -- Wait for onProfilingStatus() to confirm the status has changed. + -- This ensures the frontend and backend are in sync wrt which commits were profiled. + -- We do this to avoid mismatches on e.g. CommitTreeBuilder that would cause errors. +end + +return ProfilerStore diff --git a/modules/react-devtools-shared/src/devtools/ProfilingCache.lua b/modules/react-devtools-shared/src/devtools/ProfilingCache.lua new file mode 100644 index 00000000..ed05cbe1 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/ProfilingCache.lua @@ -0,0 +1,133 @@ +--!strict +--[[* + * 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 +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local ProfilerViews = script.Parent.views.Profiler + +local CommitTreeBuilderModule = require(ProfilerViews.CommitTreeBuilder) +local getCommitTree = CommitTreeBuilderModule.getCommitTree +local invalidateCommitTrees = CommitTreeBuilderModule.invalidateCommitTrees + +local FlamegraphChartBuilderModule = require(ProfilerViews.FlamegraphChartBuilder) +local getFlamegraphChartData = FlamegraphChartBuilderModule.getChartData +local invalidateFlamegraphChartData = FlamegraphChartBuilderModule.invalidateChartData + +local InteractionsChartBuilderModule = require(ProfilerViews.InteractionsChartBuilder) +local getInteractionsChartData = InteractionsChartBuilderModule.getChartData +local invalidateInteractionsChartData = InteractionsChartBuilderModule.invalidateChartData + +local RankedChartBuilderModule = require(ProfilerViews.RankedChartBuilder) +local getRankedChartData = RankedChartBuilderModule.getChartData +local invalidateRankedChartData = RankedChartBuilderModule.invalidateChartData + +local typesModule = require(ProfilerViews.types) +type CommitTree = typesModule.CommitTree + +type FlamegraphChartData = FlamegraphChartBuilderModule.ChartData +type InteractionsChartData = InteractionsChartBuilderModule.ChartData +type RankedChartData = RankedChartBuilderModule.ChartData + +local devtoolsTypes = require(script.Parent.types) +type ProfilingCache = devtoolsTypes.ProfilingCache +type ProfilerStore = devtoolsTypes.ProfilerStore + +type ProfilingCache_statics = { new: (profilerStore: ProfilerStore) -> ProfilingCache } + +local ProfilingCache = {} :: ProfilingCache & ProfilingCache_statics; +(ProfilingCache :: any).__index = ProfilingCache + +function ProfilingCache.new(profilerStore: ProfilerStore): ProfilingCache + local profilingCache: ProfilingCache = ( + setmetatable({}, ProfilingCache) :: any + ) :: ProfilingCache + profilingCache._fiberCommits = Map.new() + profilingCache._profilerStore = profilerStore + + function profilingCache:getCommitTree(ref: { commitIndex: number, rootID: number }) + local commitIndex, rootID = ref.commitIndex, ref.rootID + return getCommitTree({ + commitIndex = commitIndex, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getFiberCommits( + ref: { fiberID: number, rootID: number } + ): Array + local fiberID, rootID = ref.fiberID, ref.rootID + local cachedFiberCommits = self._fiberCommits:get(fiberID) + if cachedFiberCommits ~= nil then + return cachedFiberCommits + end + local fiberCommits = {} :: Array + local dataForRoot = self._profilerStore:getDataForRoot(rootID) + Array.forEach(dataForRoot.commitData, function(commitDatum, commitIndex) + if commitDatum.fiberActualDurations:has(fiberID) then + table.insert(fiberCommits, commitIndex) + end + end) + self._fiberCommits:set(fiberID, fiberCommits) + return fiberCommits + end + function profilingCache:getFlamegraphChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + rootID: number, + }): FlamegraphChartData + local commitIndex, commitTree, rootID = + ref.commitIndex, ref.commitTree, ref.rootID + return getFlamegraphChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getInteractionsChartData( + ref: { rootID: number } + ): InteractionsChartData + local rootID = ref.rootID + return getInteractionsChartData({ + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + function profilingCache:getRankedChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + rootID: number, + }): RankedChartData + local commitIndex, commitTree, rootID = + ref.commitIndex, ref.commitTree, ref.rootID + return getRankedChartData({ + commitIndex = commitIndex, + commitTree = commitTree, + profilerStore = self._profilerStore, + rootID = rootID, + }) + end + + return profilingCache +end +function ProfilingCache:invalidate() + self._fiberCommits:clear() + invalidateCommitTrees() + invalidateFlamegraphChartData() + invalidateInteractionsChartData() + invalidateRankedChartData() +end + +return ProfilingCache diff --git a/modules/react-devtools-shared/src/devtools/cache.lua b/modules/react-devtools-shared/src/devtools/cache.lua new file mode 100644 index 00000000..f64a6137 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/cache.lua @@ -0,0 +1,220 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/cache.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 Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +type Map = LuauPolyfill.Map +local WeakMap = LuauPolyfill.WeakMap +type WeakMap = LuauPolyfill.WeakMap + +local ReactTypes = require(Packages.Shared) +export type Thenable = ReactTypes.Thenable + +local React = require(Packages.React) +local createContext = React.createContext + +-- Cache implementation was forked from the React repo: +-- https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js +-- +-- This cache is simpler than react-cache in that: +-- 1. Individual items don't need to be invalidated. +-- Profiling data is invalidated as a whole. +-- 2. We didn't need the added overhead of an LRU cache. +-- The size of this cache is bounded by how many renders were profiled, +-- and it will be fully reset between profiling sessions. + +-- ROBLOX deviation START: Suspender needs a generic param to be type compatible with Thenable +export type Suspender = { + andThen: ( + self: Thenable, + onFulfill: (R) -> () | U, + onReject: (error: any) -> () | U + ) -> (), +} +-- ROBLOX deviation END + +type PendingResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 0 + value: Suspender, +} + +type ResolvedResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 1 + value: Value, +} + +type RejectedResult = { + status: number, -- ROBLOX TODO: Luau doesn't support literal: 2 + value: any, +} + +type Result = PendingResult | ResolvedResult | RejectedResult + +export type Resource = { + clear: () -> (), + invalidate: (Key) -> (), + read: (Input) -> Value, + preload: (Input) -> (), + write: (Key, Value) -> (), +} + +local Pending = 0 +local Resolved = 1 +local Rejected = 2 + +local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + +local function readContext(Context, observedBits: boolean?) + local dispatcher = ReactCurrentDispatcher.current + if dispatcher == nil then + error( + Error.new( + "react-cache: read and preload may only be called from within a " + .. "component's render. They are not supported in event handlers or " + .. "lifecycle methods." + ) + ) + end + assert(dispatcher ~= nil, "assert needed until Luau understands if nil then error()") + return dispatcher.readContext(Context, observedBits) +end + +local CacheContext = createContext(nil) + +type Config = { useWeakMap: boolean? } + +-- ROBLOX deviation START: only use WeakMap +local entries: Map, WeakMap> = Map.new() +local resourceConfigs: Map, Config> = Map.new() + +local function getEntriesForResource(resource: any): WeakMap + local entriesForResource = entries:get(resource) :: WeakMap + if entriesForResource == nil then + -- ROBLOX deviation START: skip the check and just use WeakMap + -- local config = resourceConfigs:get(resource) + entriesForResource = WeakMap.new() + -- ROBLOX deviation END + + entries:set(resource, entriesForResource :: WeakMap) + end + + return entriesForResource :: WeakMap +end +-- ROBLOX deviation END + +local function accessResult( + resource: any, + fetch: (Input) -> Thenable, + input: Input, + key: Key +): Result + local entriesForResource = getEntriesForResource(resource) + local entry = entriesForResource:get(key) + + if entry == nil then + local thenable = fetch(input) + + local newResult: PendingResult + + thenable:andThen(function(value) + if newResult.status == Pending then + local resolvedResult: ResolvedResult = newResult :: any + + resolvedResult.status = Resolved + resolvedResult.value = value + end + -- ROBLOX deviation START: explicit return type + -- end, function(error_) + end, function(error_): () + -- ROBLOX deviation END + if newResult.status == Pending then + local rejectedResult: RejectedResult = newResult :: any + + rejectedResult.status = Rejected + rejectedResult.value = error_ + end + end) + + newResult = { + status = Pending, + -- ROBLOX deviation START: needs cast + -- value = thenable, + value = thenable :: any, + -- ROBLOX deviation END + } + entriesForResource:set(key, newResult) + return newResult + else + return entry + end +end + +local exports = {} + +exports.createResource = function( + fetch: (Input) -> Thenable, + hashInput: (Input) -> Key, + _config: Config? +): Resource + local config = _config or {} + -- ROBLOX deviation: define before reference + local resource + resource = { + clear = function(): () + entries[resource] = nil + end, + invalidate = function(key: Key): () + local entriesForResource = getEntriesForResource(resource) + entriesForResource[key] = nil + end, + read = function(input: Input): Value + readContext(CacheContext) + local key = hashInput(input) + local result: Result = accessResult(resource, fetch, input, key) + if result.status == Pending then + error(result.value) + elseif result.status == Resolved then + return result.value + elseif result.status == Rejected then + error(result.value) + else + -- Should be unreachable + return nil :: any + end + end, + preload = function(input: Input): () + readContext(CacheContext) + + local key = hashInput(input) + accessResult(resource, fetch, input, key) + end, + write = function(key: Key, value: Value): () + local entriesForResource = getEntriesForResource(resource) + local resolvedResult: ResolvedResult = { + status = Resolved, + value = value, + } + + entriesForResource:set(key, resolvedResult) + end, + } + + resourceConfigs:set(resource, config) + + return resource +end + +exports.invalidateResources = function(): () + entries:clear() +end + +return exports diff --git a/modules/react-devtools-shared/src/devtools/init.lua b/modules/react-devtools-shared/src/devtools/init.lua new file mode 100644 index 00000000..72c22ee7 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/init.lua @@ -0,0 +1,12 @@ +return { + utils = require(script.utils), + store = require(script.store), + cache = require(script.cache), + devtools = { + Components = { + views = { + types = require(script.views.Components.types), + }, + }, + }, +} diff --git a/modules/react-devtools-shared/src/devtools/store.lua b/modules/react-devtools-shared/src/devtools/store.lua new file mode 100644 index 00000000..0b9b1ec3 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/store.lua @@ -0,0 +1,1145 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/store.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 Packages = script.Parent.Parent.Parent + +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local inspect = LuauPolyfill.util.inspect +local Array = LuauPolyfill.Array +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Object = LuauPolyfill.Object +local Set = LuauPolyfill.Set + +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set +local console = require(Packages.Shared).console + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter +local constants = require(script.Parent.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.Parent.types) +local ElementTypeRoot = types.ElementTypeRoot +local utils = require(script.Parent.Parent.utils) +local getSavedComponentFilters = utils.getSavedComponentFilters +local saveComponentFilters = utils.saveComponentFilters +local separateDisplayNameAndHOCs = utils.separateDisplayNameAndHOCs +local shallowDiffers = utils.shallowDiffers +local utfDecodeString = utils.utfDecodeString +local storage = require(script.Parent.Parent.storage) +local localStorageGetItem = storage.localStorageGetItem +local localStorageSetItem = storage.localStorageSetItem +local __DEBUG__ = constants.__DEBUG__ + +local ProfilerStore = require(script.Parent.ProfilerStore) +type ProfilerStore = ProfilerStore.ProfilerStore + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type Element = ComponentsTypes.Element +local Types = require(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local devtoolsTypes = require(script.Parent.types) +type Store = devtoolsTypes.Store +type Capabilities = devtoolsTypes.Capabilities + +local debug_ = function(methodName, ...) + if __DEBUG__ then + print("Store", methodName, ...) + end +end + +local LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY = + "React::DevTools::collapseNodesByDefault" +local LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = + "React::DevTools::recordChangeDescriptions" + +type Config = { + isProfiling: boolean?, + supportsNativeInspection: boolean?, + supportsReloadAndProfile: boolean?, + supportsProfiling: boolean?, + supportsTraceUpdates: boolean?, +} + +-- /** +-- * The store is the single source of truth for updates from the backend. +-- * ContextProviders can subscribe to the Store for specific things they want to provide. +-- */ + +-- ROBLOX deviation: equivalent of sub-class +type Store_static = { + new: (bridge: FrontendBridge, config: Config?) -> Store, +} +local Store: Store & Store_static = ( + setmetatable({}, { __index = EventEmitter }) :: any +) :: Store & Store_static +local StoreMetatable = { __index = Store } + +function Store.new(bridge: FrontendBridge, config: Config?): Store + local self = setmetatable(EventEmitter.new() :: any, StoreMetatable) :: any + config = config or {} + + -- ROBLOX deviation: define fields in constructor + self._bridge = bridge + + -- Should new nodes be collapsed by default when added to the tree? + self._collapseNodesByDefault = true + + self._componentFilters = {} + + -- At least one of the injected renderers contains (DEV only) owner metadata. + self._hasOwnerMetadata = false + + -- Map of ID to (mutable) Element. + -- Elements are mutated to avoid excessive cloning during tree updates. + -- The InspectedElementContext also relies on this mutability for its WeakMap usage. + self._idToElement = Map.new() :: Map + + -- Should the React Native style editor panel be shown? + self._isNativeStyleEditorSupported = false + + -- Can the backend use the Storage API (e.g. localStorage)? + -- If not, features like reload-and-profile will not work correctly and must be disabled. + self._isBackendStorageAPISupported = false + + self._nativeStyleEditorValidAttributes = nil + + -- Map of element (id) to the set of elements (ids) it owns. + -- This map enables getOwnersListForElement() to avoid traversing the entire tree. + self._ownersMap = Map.new() :: Map> + + self._recordChangeDescriptions = false + + -- Incremented each time the store is mutated. + -- This enables a passive effect to detect a mutation between render and commit phase. + self._revision = 0 + + -- This Array must be treated as immutable! + -- Passive effects will check it for changes between render and mount. + self._roots = {} :: Array + + self._rootIDToCapabilities = Map.new() :: Map + + -- Renderer ID is needed to support inspection fiber props, state, and hooks. + self._rootIDToRendererID = Map.new() :: Map + + -- These options may be initially set by a confiugraiton option when constructing the Store. + -- In the case of "supportsProfiling", the option may be updated based on the injected renderers. + self._supportsNativeInspection = true + self._supportsProfiling = false + self._supportsReloadAndProfile = false + self._supportsTraceUpdates = false + + self._unsupportedRendererVersionDetected = false + + -- Total number of visible elements (within all roots). + -- Used for windowing purposes. + self._weightAcrossRoots = 0 + + if __DEBUG__ then + debug_("constructor", "subscribing to Bridge") + end + + self._collapseNodesByDefault = localStorageGetItem( + LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY + ) == "true" + + self._recordChangeDescriptions = localStorageGetItem( + LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY + ) == "true" + + self._componentFilters = getSavedComponentFilters() + + local isProfiling = false + if config ~= nil then + isProfiling = (config :: Config).isProfiling == true + + local supportsNativeInspection = (config :: Config).supportsNativeInspection + local supportsProfiling = (config :: Config).supportsProfiling + local supportsReloadAndProfile = (config :: Config).supportsReloadAndProfile + local supportsTraceUpdates = (config :: Config).supportsTraceUpdates + + self._supportsNativeInspection = supportsNativeInspection ~= false + if supportsProfiling then + self._supportsProfiling = true + end + if supportsReloadAndProfile then + self._supportsReloadAndProfile = true + end + if supportsTraceUpdates then + self._supportsTraceUpdates = true + end + end + + self._profilerStore = ProfilerStore.new(bridge, self, isProfiling) + + -- ROBLOX deviation: bind methods which don't pass self to this instance + self._onBridgeOperations = self.onBridgeOperations + self.onBridgeOperations = function(...) + self:_onBridgeOperations(...) + end + self._onBridgeOverrideComponentFilters = self.onBridgeOverrideComponentFilters + self.onBridgeOverrideComponentFilters = function(...) + self:_onBridgeOverrideComponentFilters(...) + end + self._onBridgeShutdown = self.onBridgeShutdown + self.onBridgeShutdown = function(...) + self:_onBridgeShutdown(...) + end + self._onBridgeStorageSupported = self.onBridgeStorageSupported + self.onBridgeStorageSupported = function(...) + self:_onBridgeStorageSupported(...) + end + self._onBridgeNativeStyleEditorSupported = self.onBridgeNativeStyleEditorSupported + self.onBridgeNativeStyleEditorSupported = function(...) + self:_onBridgeNativeStyleEditorSupported(...) + end + self._onBridgeUnsupportedRendererVersion = self.onBridgeUnsupportedRendererVersion + self.onBridgeUnsupportedRendererVersion = function(...) + self:_onBridgeUnsupportedRendererVersion(...) + end + + bridge:addListener("operations", self.onBridgeOperations) + bridge:addListener("overrideComponentFilters", self.onBridgeOverrideComponentFilters) + bridge:addListener("shutdown", self.onBridgeShutdown) + bridge:addListener("isBackendStorageAPISupported", self.onBridgeStorageSupported) + bridge:addListener( + "isNativeStyleEditorSupported", + self.onBridgeNativeStyleEditorSupported + ) + bridge:addListener( + "unsupportedRendererVersion", + self.onBridgeUnsupportedRendererVersion + ) + + return self +end + +-- This is only used in tests to avoid memory leaks. +function Store:assertExpectedRootMapSizes() + if #self._roots == 0 then + -- The only safe time to assert these maps are empty is when the store is empty. + self:assertMapSizeMatchesRootCount(self._idToElement, "_idToElement") + self:assertMapSizeMatchesRootCount(self._ownersMap, "_ownersMap") + end + + -- These maps should always be the same size as the number of roots + self:assertMapSizeMatchesRootCount( + self._rootIDToCapabilities, + "_rootIDToCapabilities" + ) + self:assertMapSizeMatchesRootCount(self._rootIDToRendererID, "_rootIDToRendererID") +end + +-- This is only used in tests to avoid memory leaks. +function Store:assertMapSizeMatchesRootCount(map: Map, mapName: string) + local expectedSize = #self._roots + if map.size ~= expectedSize then + error( + Error.new( + string.format( + "Expected %s to contain %s items, but it contains %s items\n\n%s", + mapName, + tostring(expectedSize), + tostring(map.size), + inspect(map, { depth = 20 }) + ) + ) + ) + end +end + +-- ROBLOX deviation: get / setters not supported in luau +function Store:getCollapseNodesByDefault(): boolean + return self._collapseNodesByDefault +end + +function Store:setCollapseNodesByDefault(value: boolean) + self._collapseNodesByDefault = value + + localStorageSetItem( + LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY, + if value then "true" else "false" + ) + self:emit("collapseNodesByDefault") +end +function Store:getComponentFilters(): Array + return self._componentFilters +end + +function Store:setComponentFilters(value: Array): () + if self._profilerStore:isProfiling() then + -- Re-mounting a tree while profiling is in progress might break a lot of assumptions. + -- If necessary, we could support this- but it doesn't seem like a necessary use case. + error("Cannot modify filter preferences while profiling") + end + + -- Filter updates are expensive to apply (since they impact the entire tree). + -- Let's determine if they've changed and avoid doing this work if they haven't. + local prevEnabledComponentFilters = Array.filter( + self._componentFilters, + function(filter) + return filter.isEnabled + end + ) + local nextEnabledComponentFilters = Array.filter(value, function(filter) + return filter.isEnabled + end) + local haveEnabledFiltersChanged = #prevEnabledComponentFilters + ~= #nextEnabledComponentFilters + + if not haveEnabledFiltersChanged then + -- ROBLOX deviation: 1-indexing use 1 not 0 + for i = 1, #nextEnabledComponentFilters do + local prevFilter = prevEnabledComponentFilters[i] + local nextFilter = nextEnabledComponentFilters[i] + + if shallowDiffers(prevFilter, nextFilter) then + haveEnabledFiltersChanged = true + break + end + end + end + + self._componentFilters = value + + -- Update persisted filter preferences stored in localStorage. + saveComponentFilters(value) + + -- Notify the renderer that filter prefernces have changed. + -- This is an expensive opreation; it unmounts and remounts the entire tree, + -- so only do it if the set of enabled component filters has changed. + if haveEnabledFiltersChanged then + self._bridge:send("updateComponentFilters", value) + end + + self:emit("componentFilters") +end +function Store:getHasOwnerMetadata(): boolean + return self._hasOwnerMetadata +end +function Store:getNativeStyleEditorValidAttributes(): Array | nil + return self._nativeStyleEditorValidAttributes +end +function Store:getNumElements(): number + return self._weightAcrossRoots +end +function Store:getProfilerStore(): ProfilerStore + return self._profilerStore +end +function Store:getRecordChangeDescriptions(): boolean + return self._recordChangeDescriptions +end +function Store:setRecordChangeDescriptions(value: boolean): () + self._recordChangeDescriptions = value + + localStorageSetItem( + LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + if value then "true" else "false" + ) + self:emit("recordChangeDescriptions") +end +function Store:getRevision(): number + return self._revision +end +function Store:getRootIDToRendererID(): Map + return self._rootIDToRendererID +end +function Store:getRoots(): Array + return self._roots +end +function Store:getSupportsNativeInspection(): boolean + return self._supportsNativeInspection +end +function Store:getSupportsNativeStyleEditor(): boolean + return self._isNativeStyleEditorSupported +end +function Store:getSupportsProfiling(): boolean + return self._supportsProfiling +end +function Store:getSupportsReloadAndProfile(): boolean + return self._supportsReloadAndProfile and self._isBackendStorageAPISupported +end +function Store:getSupportsTraceUpdates(): boolean + return self._supportsTraceUpdates +end +function Store:getUnsupportedRendererVersionDetected(): boolean + return self._unsupportedRendererVersionDetected +end +function Store:containsElement(id: number): boolean + return self._idToElement:get(id) ~= nil +end +function Store:getElementAtIndex(index: number): Element? + if index < 0 or index >= self:getNumElements() then + console.warn( + string.format( + "Invalid index %d specified; store contains %d items.", + index, + self:getNumElements() + ) + ) + return nil + end + + -- Find which root this element is in... + local rootID + local root + local rootWeight = 0 + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for i = 1, #self._roots do + rootID = self._roots[i] + root = (self._idToElement:get(rootID) :: any) :: Element + if #root.children == 0 then + continue + elseif rootWeight + root.weight > index then + break + else + rootWeight += root.weight + end + end + + -- Find the element in the tree using the weight of each node... + -- Skip over the root itself, because roots aren't visible in the Elements tree. + local currentElement = (root :: any) :: Element + local currentWeight = rootWeight - 1 + + while index ~= currentWeight do + local numChildren = #currentElement.children + + for i = 1, numChildren do + local childID = currentElement.children[i] + local child = (self._idToElement:get(childID) :: any) :: Element + local childWeight = if child.isCollapsed then 1 else child.weight + + if index <= currentWeight + childWeight then + currentWeight += 1 + currentElement = child + break + else + currentWeight += childWeight + end + end + end + return currentElement or nil +end + +function Store:getElementIDAtIndex(index: number): number | nil + local element: Element? = self:getElementAtIndex(index) + + return (function(): number? + if element == nil then + return nil + end + return (element :: Element).id + end)() +end +function Store:getElementByID(id: number): Element | nil + local element = self._idToElement:get(id) + + if element == nil then + console.warn(string.format('No element found with id "%s"', tostring(id))) + return nil + end + + return element +end +function Store:getIndexOfElementID(id: number): number | nil + local element: Element? = self:getElementByID(id) + + if element == nil or (element :: Element).parentID == 0 then + return nil + end + + -- Walk up the tree to the root. + -- Increment the index by one for each node we encounter, + -- and by the weight of all nodes to the left of the current one. + -- This should be a relatively fast way of determining the index of a node within the tree. + local previousID = id + local currentID = (element :: Element).parentID + local index = 0 + + while true do + local current = (self._idToElement:get(currentID) :: any) :: Element + local children = current.children + + for i = 1, #children do + local childID = children[i] + if childID == previousID then + break + end + + local child = (self._idToElement:get(childID) :: any) :: Element + index += if child.isCollapsed then 1 else child.weight + end + + -- We found the root; stop crawling. + if current.parentID == 0 then + break + end + + index += 1 + previousID = current.id + currentID = current.parentID + end + + -- At this point, the current ID is a root (from the previous loop). + -- We also need to offset the index by previous root weights. + for i = 1, #self._roots do + local rootID = self._roots[i] + if rootID == currentID then + break + end + local root = (self._idToElement:get(rootID) :: any) :: Element + index += root.weight + end + + return index +end + +function Store:getOwnersListForElement(ownerID: number): Array + local list = {} + local element = self._idToElement:get(ownerID) + if element ~= nil then + table.insert(list, Object.assign({}, element, { depth = 0 })) + + local unsortedIDs = self._ownersMap:get(ownerID) + + -- ROBLOX FIXME Luau: without manual annotation: Types Set and nil cannot be compared with ~= because they do not have the same metatable + if unsortedIDs ~= nil then + local depthMap: Map = Map.new({ { ownerID, 0 } }) + + -- Items in a set are ordered based on insertion. + -- This does not correlate with their order in the tree. + -- So first we need to order them. + -- I wish we could avoid this sorting operation; we could sort at insertion time, + -- but then we'd have to pay sorting costs even if the owners list was never used. + -- Seems better to defer the cost, since the set of ids is probably pretty small. + local sortedIDs = Array.sort( + Array.from(unsortedIDs), + -- ROBLOX FIXME Luau: shouldn't need this annotation? + function(idA: number, idB: number) + return (self:getIndexOfElementID(idA) or 0) + - (self:getIndexOfElementID(idB) or 0) + end + ) + + -- Next we need to determine the appropriate depth for each element in the list. + -- The depth in the list may not correspond to the depth in the tree, + -- because the list has been filtered to remove intermediate components. + -- Perhaps the easiest way to do this is to walk up the tree until we reach either: + -- (1) another node that's already in the tree, or (2) the root (owner) + -- at which point, our depth is just the depth of that node plus one. + for _, id in sortedIDs do + local innerElement = self._idToElement:get(id) + + if innerElement ~= nil then + local parentID = innerElement.parentID + local depth = 0 + + while parentID > 0 do + if parentID == ownerID or unsortedIDs:has(parentID) then + depth = depthMap:get(parentID) :: number + 1 + depthMap:set(id, depth) + break + end + local parent = self._idToElement:get(parentID) + if parent == nil then + break + end + -- ROBLOX FIXME Luau: need type states to understand parent isn't nil due to break + parentID = (parent :: Element).parentID + end + + if depth == 0 then + error("Invalid owners list") + end + + table.insert(list, Object.assign({}, innerElement, { depth = depth })) + end + end + end + end + + return list +end + +function Store:getRendererIDForElement(id: number): number | nil + local current = self._idToElement:get(id) + + while current ~= nil do + if current.parentID == 0 then + local rendererID = self._rootIDToRendererID:get(current.id) + if rendererID == nil then + return nil + end + return rendererID + else + current = self._idToElement:get(current.parentID) + end + end + + return nil +end + +function Store:getRootIDForElement(id: number): number | nil + local current = self._idToElement:get(id) + while current ~= nil do + if current.parentID == 0 then + return current.id + else + current = self._idToElement:get(current.parentID) + end + end + return nil +end + +function Store:isInsideCollapsedSubTree(id: number): boolean + local current = self._idToElement:get(id) + while current ~= nil do + if (current :: Element).parentID == 0 then + return false + else + current = self._idToElement:get(current.parentID) + if current ~= nil and (current :: Element).isCollapsed then + return true + end + end + end + return false +end + +-- TODO Maybe split this into two methods: expand() and collapse() +function Store:toggleIsCollapsed(id: number, isCollapsed: boolean): () + local didMutate = false + local element: Element? = self:getElementByID(id) + + if element ~= nil then + if isCollapsed then + if (element :: Element).type == ElementTypeRoot then + error("Root nodes cannot be collapsed") + end + if not (element :: Element).isCollapsed then + didMutate = true; + (element :: Element).isCollapsed = true + + local weightDelta = 1 - (element :: Element).weight + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local parentElement: Element? = ( + self._idToElement:get(element.parentID) :: any + ) :: Element + while parentElement ~= nil do + -- We don't need to break on a collapsed parent in the same way as the expand case below. + -- That's because collapsing a node doesn't "bubble" and affect its parents. + parentElement.weight += weightDelta + parentElement = self._idToElement:get(parentElement.parentID) + end + end + else + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local currentElement: Element? = element + + while currentElement ~= nil do + local oldWeight = if (currentElement :: Element).isCollapsed + then 1 + else currentElement.weight + + if (currentElement :: Element).isCollapsed then + didMutate = true; + (currentElement :: Element).isCollapsed = false + + local newWeight = if (currentElement :: Element).isCollapsed + then 1 + else (currentElement :: Element).weight + local weightDelta = newWeight - oldWeight + -- ROBLOX FIXME Luau: shouldn't need this annoatation, should infer correctly + local parentElement: Element? = ( + self._idToElement:get(currentElement.parentID) :: any + ) :: Element + + while parentElement ~= nil do + parentElement.weight += weightDelta + + if (parentElement :: Element).isCollapsed then + -- It's important to break on a collapsed parent when expanding nodes. + -- That's because expanding a node "bubbles" up and expands all parents as well. + -- Breaking in this case prevents us from over-incrementing the expanded weights. + break + end + parentElement = self._idToElement:get(parentElement.parentID) + end + end + + currentElement = if (currentElement :: Element).parentID ~= 0 + then self:getElementByID((currentElement :: Element).parentID) + else nil + end + end + + -- Only re-calculate weights and emit an "update" event if the store was mutated. + if didMutate then + local weightAcrossRoots = 0 + for _i, rootID in self._roots do + local elementById: Element? = self:getElementByID(rootID) + local weight = (elementById :: Element).weight + weightAcrossRoots = weightAcrossRoots + weight + end + self._weightAcrossRoots = weightAcrossRoots + + -- The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed. + -- In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden). + -- Updating the selected search index later may require auto-expanding a collapsed subtree though. + self:emit("mutated", { + {}, + {}, + }) + end + end +end + +function Store:_adjustParentTreeWeight(parentElement: Element | nil, weightDelta: number) + local isInsideCollapsedSubTree = false + + while parentElement ~= nil do + (parentElement :: Element).weight += weightDelta + + -- Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. + -- Their weight will bubble up when the parent is expanded. + if (parentElement :: Element).isCollapsed then + isInsideCollapsedSubTree = true + break + end + + parentElement = (self._idToElement:get(parentElement.parentID) :: any) :: Element + end + + -- Additions and deletions within a collapsed subtree should not affect the overall number of elements. + if not isInsideCollapsedSubTree then + self._weightAcrossRoots += weightDelta + end +end + +function Store:onBridgeNativeStyleEditorSupported(options: { + isSupported: boolean, + validAttributes: Array, +}) + local isSupported, validAttributes = options.isSupported, options.validAttributes + + self._isNativeStyleEditorSupported = isSupported + self._nativeStyleEditorValidAttributes = validAttributes or nil + + self:emit("supportsNativeStyleEditor") +end + +function Store:onBridgeOperations(operations: Array): () + if __DEBUG__ then + console.groupCollapsed("onBridgeOperations") + debug_("onBridgeOperations", table.concat(operations, ",")) + end + + local haveRootsChanged = false + + -- The first two values are always rendererID and rootID + local rendererID = operations[1] + local addedElementIDs = {} + -- This is a mapping of removed ID -> parent ID: + local removedElementIDs = {} + -- We'll use the parent ID to adjust selection if it gets deleted. + -- ROBLOX deviation: 1-indexed means this is 3, not 2 + local i = 3 + local stringTable: Array = { + -- ROBLOX deviation: element 1 corresponds to empty string + "", -- ID = 0 corresponds to the null string. + } + + -- ROBLOX deviation: use postfix as a function + local function POSTFIX_INCREMENT() + local prevI = i + i += 1 + return prevI + end + + local stringTableSize = operations[POSTFIX_INCREMENT()] + local stringTableEnd = i + stringTableSize + + while i < stringTableEnd do + local nextLength = operations[POSTFIX_INCREMENT()] + local nextString = utfDecodeString(Array.slice(operations, i, i + nextLength)) + + table.insert(stringTable, nextString) + i = i + nextLength + end + + -- ROBLOX deviation: 1-indexing, use <= not < + while i <= #operations do + local operation = operations[i] + if operation == TREE_OPERATION_ADD then + local id = operations[i + 1] + local type_ = operations[i + 2] + + i += 3 + + if self._idToElement:has(id) then + error( + Error.new( + ("Cannot add node %s because a node with that id is already in the Store."):format( + tostring(id) + ) + ) + ) + end + + local ownerID: number = 0 + local parentID: number = (nil :: any) :: number + + if type_ == ElementTypeRoot then + if __DEBUG__ then + debug_("Add", string.format("new root node %s", tostring(id))) + end + + local supportsProfiling = operations[i] > 0 + i += 1 + + local hasOwnerMetadata = operations[i] > 0 + + i += 1 + self._roots = Array.concat(self._roots, id) + + self._rootIDToRendererID:set(id, rendererID) + self._rootIDToCapabilities:set(id, { + hasOwnerMetadata = hasOwnerMetadata, + supportsProfiling = supportsProfiling, + }) + + self._idToElement:set(id, { + children = {}, + depth = -1, + displayName = nil, + hocDisplayNames = nil, + id = id, + isCollapsed = false, -- Never collapse roots; it would hide the entire tree. + key = nil, + ownerID = 0, + parentID = 0, + type = type_, + weight = 0, + }) + haveRootsChanged = true + else + parentID = (operations[i] :: any) :: number + i += 1 + ownerID = (operations[i] :: any) :: number + i += 1 + + local displayNameStringID = operations[i] + -- ROBLOX deviation: 1-indexed + local displayName = stringTable[displayNameStringID + 1] + + i += 1 + + local keyStringID = operations[i] + -- ROBLOX deviation: 1-indexed + local key = stringTable[keyStringID + 1] + + i += 1 + + if __DEBUG__ then + debug_( + "Add", + string.format( + "node %s (%s) as child of %s", + tostring(id), + displayName or "null", + tostring(parentID) + ) + ) + end + if not self._idToElement:has(parentID) then + error( + Error.new( + ("Cannot add child %s to parent %s because parent node was not found in the Store."):format( + tostring(id), + tostring(parentID) + ) + ) + ) + end + + local parentElement = (self._idToElement:get(parentID) :: any) :: Element + + table.insert(parentElement.children, id) + + local displayNameWithoutHOCs, hocDisplayNames = + separateDisplayNameAndHOCs(displayName, type_) + + local element = { + children = {}, + depth = parentElement.depth + 1, + displayName = displayNameWithoutHOCs, + hocDisplayNames = hocDisplayNames, + id = id, + isCollapsed = self._collapseNodesByDefault, + key = key, + ownerID = ownerID, + parentID = parentElement.id, + type = type_, + weight = 1, + } + + self._idToElement:set(id, element) + table.insert(addedElementIDs, id) + self:_adjustParentTreeWeight(parentElement, 1) + + if ownerID > 0 then + local set = self._ownersMap:get(ownerID) + + -- ROBLOX FIXME Luau: needs type states to eliminate the manual cast + if set == nil then + set = Set.new() + self._ownersMap:set(ownerID, set :: Set) + end + + (set :: Set):add(id) + end + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[i + 1] + i += 2 + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for removeIndex = 1, removeLength do + local id = (operations[i] :: any) :: number + + if not self._idToElement:has(id) then + error( + Error.new( + ("Cannot remove node %s because no matching node was found in the Store."):format( + tostring(id) + ) + ) + ) + end + i += 1 + + local element = (self._idToElement:get(id) :: any) :: Element + local children, ownerID, parentID, weight = + element.children, element.ownerID, element.parentID, element.weight + + if #children > 0 then + error( + Error.new( + string.format( + "Node %s was removed before its children.", + tostring(id) + ) + ) + ) + end + + self._idToElement:delete(id) + + local parentElement: Element? = nil + + if parentID == 0 then + if __DEBUG__ then + debug_("Remove", string.format("node %s root", tostring(id))) + end + + self._roots = Array.filter(self._roots, function(rootID) + return rootID ~= id + end) + + self._rootIDToRendererID:delete(id) + self._rootIDToCapabilities:delete(id) + + haveRootsChanged = true + else + if __DEBUG__ then + debug_( + "Remove", + string.format( + "node %s from parent %s", + tostring(id), + tostring(parentID) + ) + ) + end + + parentElement = (self._idToElement:get(parentID) :: any) :: Element + + if parentElement == nil then + error( + ("Cannot remove node %s from parent %s because no matching node was found in the Store."):format( + tostring(id), + tostring(parentID) + ) + ) + end + + local index = Array.indexOf((parentElement :: Element).children, id) + Array.splice((parentElement :: Element).children, index, 1) + end + + self:_adjustParentTreeWeight(parentElement, -weight) + removedElementIDs[id] = parentID + self._ownersMap:delete(id) + + if ownerID > 0 then + local set = self._ownersMap:get(ownerID) + -- ROBLOX FIXME Luau: without any cast below, we get: Types Set and nil cannot be compared with ~= because they do not have the same metatable + if set :: any ~= nil then + (set :: Set):delete(id) + end + end + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + local id = (operations[i + 1] :: any) :: number + local numChildren = (operations[i + 2] :: any) :: number + + i += 3 + + if not self._idToElement:has(id) then + error( + Error.new( + ("Cannot reorder children for node %s because no matching node was found in the Store."):format( + tostring(id) + ) + ) + ) + end + + local element = (self._idToElement:get(id) :: any) :: Element + local children = element.children + + if #children ~= numChildren then + error("Children cannot be added or removed during a reorder operation.") + end + + -- ROBLOX deviation: 1-indexing use 1 not 0 + for j = 1, numChildren do + local childID = operations[i + j - 1] + + children[j] = childID + + if ReactGlobals.__DEV__ then + local childElement: Element? = self._idToElement:get(childID) + + if + childElement == nil + or (childElement :: Element).parentID ~= id + then + console.error( + "Children cannot be added or removed during a reorder operation." + ) + end + end + end + + i = i + numChildren + + if ReactGlobals.__DEBUG__ then + debug_( + "Re-order", + string.format( + "Node %s children %s", + tostring(id), + Array.join(children, ",") + ) + ) + end + 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 " .. tostring(operation)) + end + end + + self._revision += 1 + + if haveRootsChanged then + local prevSupportsProfiling = self._supportsProfiling + + self._hasOwnerMetadata = false + self._supportsProfiling = false + + for _, capabilities in self._rootIDToCapabilities do + local hasOwnerMetadata, supportsProfiling = + capabilities.hasOwnerMetadata, capabilities.supportsProfiling + + if hasOwnerMetadata then + self._hasOwnerMetadata = true + end + if supportsProfiling then + self._supportsProfiling = true + end + end + self:emit("roots") + + if self._supportsProfiling ~= prevSupportsProfiling then + self:emit("supportsProfiling") + end + end + if __DEBUG__ then + -- ROBLOX deviation: inline require here to work around circular dependency + local devtoolsUtils = require(script.Parent.utils) :: any + local printStore = devtoolsUtils.printStore + console.log(printStore(self, true)) + console.groupEnd() + end + + self:emit("mutated", { addedElementIDs, removedElementIDs }) +end + +function Store:onBridgeOverrideComponentFilters( + componentFilters: Array +): () + self._componentFilters = componentFilters + + saveComponentFilters(componentFilters) +end + +function Store:onBridgeShutdown(): () + if __DEBUG__ then + debug_("onBridgeShutdown", "unsubscribing from Bridge") + end + + self._bridge:removeListener("operations", self.onBridgeOperations) + self._bridge:removeListener("shutdown", self.onBridgeShutdown) + self._bridge:removeListener( + "isBackendStorageAPISupported", + self.onBridgeStorageSupported + ) +end + +function Store:onBridgeStorageSupported(isBackendStorageAPISupported: boolean): () + self._isBackendStorageAPISupported = isBackendStorageAPISupported + self:emit("supportsReloadAndProfile") +end + +function Store:onBridgeUnsupportedRendererVersion(): () + self._unsupportedRendererVersionDetected = true + self:emit("unsupportedRendererVersionDetected") +end + +return Store diff --git a/modules/react-devtools-shared/src/devtools/types.lua b/modules/react-devtools-shared/src/devtools/types.lua new file mode 100644 index 00000000..d21bebe7 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/types.lua @@ -0,0 +1,231 @@ +--!strict +local Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = LuauPolyfill.Object +type Set = LuauPolyfill.Set + +local ComponentsTypes = require(script.Parent.Parent.devtools.views.Components.types) +type Element = ComponentsTypes.Element + +local Types = require(script.Parent.Parent.types) +type ComponentFilter = Types.ComponentFilter +type ElementType = Types.ElementType + +local EventEmitter = require(script.Parent.Parent.events) +type EventEmitter = EventEmitter.EventEmitter + +local Bridge = require(script.Parent.Parent.bridge) +type FrontendBridge = Bridge.FrontendBridge + +local backendTypes = require(script.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend + +local profilerTypes = require(script.Parent.views.Profiler.types) +type CommitDataFrontend = profilerTypes.CommitDataFrontend +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +export type Capabilities = { hasOwnerMetadata: boolean, supportsProfiling: boolean } + +export type Store = EventEmitter<{ + collapseNodesByDefault: Array, + componentFilters: Array, + mutated: Array, -- ROBLOX deviation: can't express jagged array types in Luau + recordChangeDescriptions: Array, + roots: Array, + supportsNativeStyleEditor: Array, + supportsProfiling: Array, + supportsReloadAndProfile: Array, + unsupportedRendererVersionDetected: Array, +}> & { + _bridge: FrontendBridge, + + -- Should new nodes be collapsed by default when added to the tree? + _collapseNodesByDefault: boolean, + + _componentFilters: Array, + + -- At least one of the injected renderers contains (DEV only) owner metadata. + _hasOwnerMetadata: boolean, + + -- Map of ID to (mutable) Element. + -- Elements are mutated to avoid excessive cloning during tree updates. + -- The InspectedElementContext also relies on this mutability for its WeakMap usage. + _idToElement: Map, + + -- Should the React Native style editor panel be shown? + _isNativeStyleEditorSupported: boolean, + + -- Can the backend use the Storage API (e.g. localStorage)? + -- If not, features like reload-and-profile will not work correctly and must be disabled. + _isBackendStorageAPISupported: boolean, + + _nativeStyleEditorValidAttributes: Array | nil, + + -- Map of element (id) to the set of elements (ids) it owns. + -- This map enables getOwnersListForElement() to avoid traversing the entire tree. + _ownersMap: Map>, + + _profilerStore: ProfilerStore, + + _recordChangeDescriptions: boolean, + + -- Incremented each time the store is mutated. + -- This enables a passive effect to detect a mutation between render and commit phase. + _revision: number, + + -- This Array must be treated as immutable! + -- Passive effects will check it for changes between render and mount. + _roots: Array, + + _rootIDToCapabilities: Map, + + -- Renderer ID is needed to support inspection fiber props, state, and hooks. + _rootIDToRendererID: Map, + + -- These options may be initially set by a confiugraiton option when constructing the Store. + -- In the case of "supportsProfiling", the option may be updated based on the injected renderers. + _supportsNativeInspection: boolean, + _supportsProfiling: boolean, + _supportsReloadAndProfile: boolean, + _supportsTraceUpdates: boolean, + + _unsupportedRendererVersionDetected: boolean, + + -- Total number of visible elements (within all roots). + -- Used for windowing purposes. + _weightAcrossRoots: number, + assertExpectedRootMapSizes: (self: Store) -> (), + assertMapSizeMatchesRootCount: ( + self: Store, + map: Map, + mapName: string + ) -> (), + getCollapseNodesByDefault: (self: Store) -> boolean, + setCollapseNodesByDefault: (self: Store, boolean) -> (), + getComponentFilters: (self: Store) -> Array, + setComponentFilters: (self: Store, Array) -> (), + getHasOwnerMetadata: (self: Store) -> boolean, + getNativeStyleEditorValidAttributes: (self: Store) -> Array | nil, + getNumElements: (self: Store) -> number, + getProfilerStore: (self: Store) -> ProfilerStore, + getRecordChangeDescriptions: (self: Store) -> boolean, + setRecordChangeDescriptions: (self: Store, value: boolean) -> (), + getRevision: (self: Store) -> number, + getRootIDToRendererID: (self: Store) -> Map, + getRoots: (self: Store) -> Array, + getSupportsNativeInspection: (self: Store) -> boolean, + getSupportsNativeStyleEditor: (self: Store) -> boolean, + getSupportsProfiling: (self: Store) -> boolean, + getSupportsReloadAndProfile: (self: Store) -> boolean, + getSupportsTraceUpdates: (self: Store) -> boolean, + getUnsupportedRendererVersionDetected: (self: Store) -> boolean, + containsElement: (self: Store, id: number) -> boolean, + getElementAtIndex: (self: Store, index: number) -> Element | nil, + getElementIDAtIndex: (self: Store, index: number) -> number | nil, + getElementByID: (self: Store, id: number) -> Element | nil, + getIndexOfElementID: (self: Store, id: number) -> number | nil, + getOwnersListForElement: (self: Store, ownerID: number) -> Array, + getRendererIDForElement: (self: Store, id: number) -> number | nil, + getRootIDForElement: (self: Store, id: number) -> number | nil, + isInsideCollapsedSubTree: (self: Store, id: number) -> boolean, + toggleIsCollapsed: (self: Store, id: number, isCollapsed: boolean) -> (), + _adjustParentTreeWeight: ( + self: Store, + parentElement: Element | nil, + weightDelta: number + ) -> (), + onBridgeNativeStyleEditorSupported: ( + self: Store, + options: { + isSupported: boolean, + validAttributes: Array, + } + ) -> (), + onBridgeOperations: (self: Store, operations: Array) -> (), + onBridgeOverrideComponentFilters: ( + self: Store, + componentFilters: Array + ) -> (), + onBridgeShutdown: (self: Store) -> (), + onBridgeStorageSupported: (self: Store, isBackendStorageAPISupported: boolean) -> (), + onBridgeUnsupportedRendererVersion: (self: Store) -> (), +} + +export type ProfilingCache = { + _fiberCommits: Map>, + _profilerStore: ProfilerStore, + getCommitTree: any, + getFiberCommits: any, + getFlamegraphChartData: any, + getInteractionsChartData: any, + getRankedChartData: any, + invalidate: (self: ProfilingCache) -> (), +} + +export type ProfilerStore = EventEmitter<{ + isProcessingData: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] + isProfiling: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] + profilingData: any, --[[ ROBLOX TODO: Unhandled node for type: TupleTypeAnnotation ]] --[[ [] ]] +}> & { + _bridge: FrontendBridge, -- Suspense cache for lazily calculating derived profiling data. + _cache: ProfilingCache, -- Temporary store of profiling data from the backend renderer(s). + -- This data will be converted to the ProfilingDataFrontend format after being collected from all renderers. + _dataBackends: Array, -- Data from the most recently completed profiling session, + -- or data that has been imported from a previously exported session. + -- This object contains all necessary data to drive the Profiler UI interface, + -- even though some of it is lazily parsed/derived via the ProfilingCache. + _dataFrontend: ProfilingDataFrontend | nil, -- Snapshot of all attached renderer IDs. + -- Once profiling is finished, this snapshot will be used to query renderers for profiling data. + -- + -- This map is initialized when profiling starts and updated when a new root is added while profiling; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialRendererIDs: Set, -- Snapshot of the state of the main Store (including all roots) when profiling started. + -- Once profiling is finished, this snapshot can be used along with "operations" messages emitted during profiling, + -- to reconstruct the state of each root for each commit. + -- It's okay to use a single root to store this information because node IDs are unique across all roots. + -- + -- This map is initialized when profiling starts and updated when a new root is added while profiling; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _initialSnapshotsByRootID: Map>, -- Map of root (id) to a list of tree mutation that occur during profiling. + -- Once profiling is finished, these mutations can be used, along with the initial tree snapshots, + -- to reconstruct the state of each root for each commit. + -- + -- This map is only updated while profiling is in progress; + -- Upon completion, it is converted into the exportable ProfilingDataFrontend format. + _inProgressOperationsByRootID: Map>>, -- The backend is currently profiling. + -- When profiling is in progress, operations are stored so that we can later reconstruct past commit trees. + _isProfiling: boolean, -- Tracks whether a specific renderer logged any profiling data during the most recent session. + _rendererIDsThatReportedProfilingData: Set, -- After profiling, data is requested from each attached renderer using this queue. + -- So long as this queue is not empty, the store is retrieving and processing profiling data from the backend. + _rendererQueue: Set, + _store: Store, + getCommitData: ( + self: ProfilerStore, + rootID: number, + commitIndex: number + ) -> CommitDataFrontend, + getDataForRoot: (self: ProfilerStore, rootID: number) -> ProfilingDataForRootFrontend, -- Profiling data has been recorded for at least one root. + didRecordCommits: (self: ProfilerStore) -> boolean, + isProcessingData: (self: ProfilerStore) -> boolean, + isProfiling: (self: ProfilerStore) -> boolean, + profilingCache: (self: ProfilerStore) -> ProfilingCache, + profilingData: ( + self: ProfilerStore, + value: ProfilingDataFrontend? + ) -> ...ProfilingDataFrontend?, + clear: (self: ProfilerStore) -> (), + startProfiling: (self: ProfilerStore) -> (), + stopProfiling: (self: ProfilerStore) -> (), + _takeProfilingSnapshotRecursive: any, + onBridgeOperations: (self: ProfilerStore, operations: Array) -> (), + onBridgeProfilingData: (self: ProfilerStore, dataBackend: ProfilingDataBackend) -> (), + onBridgeShutdown: (self: ProfilerStore) -> (), + onProfilingStatus: (self: ProfilerStore, isProfiling: boolean) -> (), +} + +return true diff --git a/modules/react-devtools-shared/src/devtools/utils.lua b/modules/react-devtools-shared/src/devtools/utils.lua new file mode 100644 index 00000000..a854dbdf --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/utils.lua @@ -0,0 +1,165 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/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 Packages = script.Parent.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +type Array = LuauPolyfill.Array +-- ROBLOX deviation: Use HttpService for JSON +local JSON = game:GetService("HttpService") + +local exports = {} + +local ViewsComponentsTypes = require(script.Parent.views.Components.types) +type Element = ViewsComponentsTypes.Element +local devtoolsTypes = require(script.Parent.types) +type Store = devtoolsTypes.Store + +exports.printElement = function(element: Element, includeWeight: boolean?) + includeWeight = includeWeight or false + local prefix = " " + + if #element.children > 0 then + prefix = if element.isCollapsed then "▸" else "▾" + end + + local key = "" + + if element.key ~= nil and element.key ~= "" then + key = string.format(' key="%s"', tostring(element.key)) + end + + local hocDisplayNames = nil + + if element.hocDisplayNames ~= nil then + hocDisplayNames = table.clone(element.hocDisplayNames) + end + + local hocs = if hocDisplayNames == nil + then "" + else string.format(" [%s]", table.concat(hocDisplayNames, "][")) + local suffix = "" + + if includeWeight then + suffix = string.format( + " (%s)", + if element.isCollapsed then "1" else tostring(element.weight) + ) + end + return string.format( + "%s%s <%s%s>%s%s", + (" "):rep(element.depth + 1), + prefix, + element.displayName or "null", + key, + hocs, + suffix + ) +end + +exports.printOwnersList = function(elements: Array, includeWeight: boolean) + includeWeight = includeWeight or false + return table.concat( + Array.map(elements, function(element) + return exports.printElement(element, includeWeight) + end), + "\n" + ) +end + +exports.printStore = function(store: Store, includeWeight: boolean?) + includeWeight = includeWeight or false + local snapshotLines: Array = {} + local rootWeight = 0 + + Array.forEach(store:getRoots(), function(rootID) + local weight = ((store:getElementByID(rootID) :: any) :: Element).weight + + table.insert( + snapshotLines, + "[root]" .. (if includeWeight then string.format(" (%d)", weight) else "") + ) + for i = rootWeight, rootWeight + weight - 1 do + local element: Element? = store:getElementAtIndex(i) + + if element == nil then + error(string.format("Could not find element at index %d", i)) + end + + table.insert( + snapshotLines, + exports.printElement(element :: Element, includeWeight :: boolean) + ) + end + rootWeight += weight + end) + + -- Make sure the pretty-printed test align with the Store's reported number of total rows. + if rootWeight ~= store:getNumElements() then + error( + ("Inconsistent Store state. Individual root weights (%s) do not match total weight (%s)"):format( + tostring(rootWeight), + tostring(store:getNumElements()) + ) + ) + end + + -- If roots have been unmounted, verify that they've been removed from maps. + -- This helps ensure the Store doesn't leak memory. + store:assertExpectedRootMapSizes() + + return table.concat(snapshotLines, "\n") +end + +-- We use JSON.parse to parse string values +-- e.g. 'foo' is not valid JSON but it is a valid string +-- so this method replaces e.g. 'foo' with "foo" +exports.sanitizeForParse = function(value) + if typeof(value) == "string" then + if + #value >= 2 + and string.sub(value, 1, 1) == "'" + and string.sub(value, #value) == "'" + then + return '"' .. string.sub(value, 1, #value - 2) .. '"' + end + end + return value +end + +exports.smartParse = function(value): number? + if value == "Infinity" then + return math.huge + elseif value == "NaN" then + -- ROBLOX deviation: no NaN + return 0 + elseif value == "undefined" then + return nil + else + return JSON:JSONDecode(exports.sanitizeForParse(value)) + end +end + +exports.smartStringify = function(value) + if typeof(value) == "number" then + -- ROBLOX deviation: these numbers don't exist + -- if Number.isNaN(value) then + -- return'NaN' + -- elseif not Number.isFinite(value) then + -- return'Infinity' + -- end + elseif value == nil then + return "undefined" + end + + return JSON:JSONEncode(value) +end + +return exports diff --git a/modules/react-devtools-shared/src/devtools/views/Components/types.lua b/modules/react-devtools-shared/src/devtools/views/Components/types.lua new file mode 100644 index 00000000..a979aecd --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Components/types.lua @@ -0,0 +1,122 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.1/packages/react-devtools-shared/src/devtools/views/Components/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 Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +type Object = LuauPolyfill.Object + +local ReactShared = require(Packages.Shared) +type Source = ReactShared.Source +local Hydration = require(script.Parent.Parent.Parent.Parent.hydration) +type Dehydrated = Hydration.Dehydrated +type Unserializable = Hydration.Unserializable + +local ReactDevtoolsSharedTypes = require(script.Parent.Parent.Parent.Parent.types) +type ElementType = ReactDevtoolsSharedTypes.ElementType + +-- Each element on the frontend corresponds to a Fiber on the backend. +-- Some of its information (e.g. id, type, displayName) come from the backend. +-- Other bits (e.g. weight and depth) are computed on the frontend for windowing and display purposes. +-- Elements are updated on a push basis– meaning the backend pushes updates to the frontend when needed. +export type Element = { + id: number, + parentID: number, + children: Array, + type: ElementType, + displayName: string | nil, + key: number | string | nil, + + hocDisplayNames: nil | Array, + + -- Should the elements children be visible in the tree? + isCollapsed: boolean, + + -- Owner (if available) + ownerID: number, + + -- How many levels deep within the tree is this element? + -- This determines how much indentation (left padding) should be used in the Elements tree. + depth: number, + + -- How many nodes (including itself) are below this Element within the tree. + -- This property is used to quickly determine the total number of Elements, + -- and the Element at any given index (for windowing purposes). + weight: number, +} + +export type Owner = { + displayName: string | nil, + id: number, + hocDisplayNames: Array | nil, + type: ElementType, +} + +export type OwnersList = { id: number, owners: Array | nil } + +export type InspectedElement = { + id: number, + + -- 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, +} + +-- TODO: Add profiling type + +export type DehydratedData = { + cleaned: Array>, + data: string + | Dehydrated + | Unserializable + | Array + | Array + | { [string]: string | Dehydrated | Unserializable }, + unserializable: Array>, +} + +return {} diff --git a/modules/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua new file mode 100644 index 00000000..2f0491b2 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.lua @@ -0,0 +1,380 @@ +--!strict +--[[* + * 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.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local console = LuauPolyfill.console + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} +--[[* + * 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 constantsModule = require(script.Parent.Parent.Parent.Parent.constants) +local __DEBUG__ = constantsModule.__DEBUG__ +local TREE_OPERATION_ADD = constantsModule.TREE_OPERATION_ADD +local TREE_OPERATION_REMOVE = constantsModule.TREE_OPERATION_REMOVE +local TREE_OPERATION_REORDER_CHILDREN = constantsModule.TREE_OPERATION_REORDER_CHILDREN +local TREE_OPERATION_UPDATE_TREE_BASE_DURATION = + constantsModule.TREE_OPERATION_UPDATE_TREE_BASE_DURATION + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local ElementTypeRoot = require(script.Parent.Parent.Parent.Parent.types).ElementTypeRoot +local typesModule = require(script.Parent.Parent.Parent.Parent.types) +type ElementType = typesModule.ElementType + +local Profiler_typesModule = require(script.Parent.types) +type CommitTree = Profiler_typesModule.CommitTree +type CommitTreeNode = Profiler_typesModule.CommitTreeNode +type ProfilingDataForRootFrontend = Profiler_typesModule.ProfilingDataForRootFrontend +type ProfilingDataFrontend = Profiler_typesModule.ProfilingDataFrontend + +local function debug_(methodName, ...: any) + if __DEBUG__ then + print("[CommitTreeBuilder]", methodName, ...) + end +end + +local function __printTree(commitTree: CommitTree) + if __DEBUG__ then + local nodes, rootID = commitTree.nodes, commitTree.rootID + console.group("__printTree()") + local queue = { rootID, 0 } + -- ROBLOX TODO Luau? if length check > 0, remove() nil-ability could be removed + while #queue > 0 do + local id = table.remove(queue, 1) :: number + local depth = table.remove(queue, 1) :: number + local node = nodes:get(id) + -- ROBLOX FIXME Luau: need to understand error() narrows node nil-ability + if node == nil then + error( + string.format( + 'Could not find node with id "%s" in commit tree', + tostring(id) + ) + ) + end + console.log( + string.format( + "%s%s:%s %s (%s)", + string.rep("\u{2022}", depth), + tostring((node :: CommitTreeNode).id), + tostring((node :: CommitTreeNode).displayName or ""), + if (node :: CommitTreeNode).key + then string.format( + 'key:"%s"', + tostring((node :: CommitTreeNode).key) + ) + else "", + tostring((node :: CommitTreeNode).treeBaseDuration) + ) + ) + Array.forEach((node :: CommitTreeNode).children, function(childID) + Array.concat(queue, { childID, depth + 1 }) + end) + end + console.groupEnd() + end +end + +local function updateTree(commitTree: CommitTree, operations: Array): CommitTree + -- Clone the original tree so edits don't affect it. + local nodes = Map.new(commitTree.nodes) + + -- Clone nodes before mutating them so edits don't affect the original. + local function getClonedNode(id: number): CommitTreeNode + local clonedNode = table.clone((nodes:get(id) :: any) :: CommitTreeNode) + nodes:set(id, clonedNode) + return clonedNode + end + + local i = 3 -- Skip rendererID and currentRootID + local function POSTFIX_INCREMENT() + local x = i + i += 1 + return x + end + + local id: number = (nil :: any) :: number -- Reassemble the string table. + local stringTable: Array = { + -- ROBLOX deviation: element 1 corresponds to empty string, this is why key is "" instead of nil in snapshots + "", -- ID = 0 corresponds to the null string. + } + + local stringTableSize = operations[POSTFIX_INCREMENT()] + local stringTableEnd = i + stringTableSize + + while i < stringTableEnd do + -- ROBLOX deviation: don't binary encode strings, so store string directly rather than length + -- local nextLength = operations[POSTFIX_INCREMENT()] + -- local nextString = utfDecodeString(Array.slice(operations, i, i + nextLength)) + local nextString = operations[POSTFIX_INCREMENT()] + + table.insert(stringTable, nextString) + -- ROBLOX deviation: don't binary encode strings, so no need to move pointer + -- i = i + nextLength + end + + while i <= #operations do + local operation = operations[POSTFIX_INCREMENT()] + + if operation == TREE_OPERATION_ADD then + id = operations[POSTFIX_INCREMENT()] + local type_ = (operations[POSTFIX_INCREMENT()] :: any) :: ElementType + if nodes:has(id) then + error( + "Commit tree already contains fiber " + .. tostring(id) + .. ". This is a bug in React DevTools." + ) + end + if type_ == ElementTypeRoot then + i += 2 -- supportsProfiling flag and hasOwnerMetadata flag + if __DEBUG__ then + debug_("Add", ("new root fiber %s"):format(tostring(id))) + end + local node: CommitTreeNode = { + children = {}, + displayName = nil, + hocDisplayNames = nil, + id = id, + key = nil, + parentID = 0, + treeBaseDuration = 0, -- This will be updated by a subsequent operation + type = type_, + } + nodes:set(id, node) + else + local parentID = operations[POSTFIX_INCREMENT()] + i += 1 -- skip ownerID + local displayNameStringID = operations[POSTFIX_INCREMENT()] + local displayName = stringTable[displayNameStringID + 1] + + local keyStringID = operations[POSTFIX_INCREMENT()] + local key = stringTable[keyStringID + 1] -- 1 indexed stringtable + + if __DEBUG__ then + debug_( + "Add", + ("fiber %s (%s) as child of %s"):format( + tostring(id), + tostring(displayName or "null"), + tostring(parentID) + ) + ) + end + local parentNode = getClonedNode(parentID) + parentNode.children = Array.concat(parentNode.children, id) + local node: CommitTreeNode = { + children = {}, + displayName = displayName, + hocDisplayNames = nil, + id = id, + key = key, + parentID = parentID, + treeBaseDuration = 0, -- This will be updated by a subsequent operation + type = type_, + } + nodes:set(id, node) + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[POSTFIX_INCREMENT()] + for _ = 1, removeLength do + id = operations[POSTFIX_INCREMENT()] + if not nodes:has(id) then + error( + "Commit tree does not contain fiber " + .. tostring(id) + .. ". This is a bug in React DevTools." + ) + end + local node = getClonedNode(id) + local parentID = node.parentID + nodes:delete(id) + if not nodes:has(parentID) then + -- No-op + else + local parentNode = getClonedNode(parentID) + if __DEBUG__ then + debug_( + "Remove", + ("fiber %s from parent %s"):format( + tostring(id), + tostring(parentID) + ) + ) + end + parentNode.children = Array.filter( + parentNode.children, + function(childID) + return childID ~= id + end + ) + end + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + id = operations[POSTFIX_INCREMENT()] + local numChildren = operations[POSTFIX_INCREMENT()] + local children = ( + Array.slice(operations, i, i + numChildren) :: any + ) :: Array + i += numChildren + if __DEBUG__ then + debug_( + "Re-order", + ("fiber %s children %s"):format( + tostring(id), + tostring(Array.join(children, ",")) + ) + ) + end + local node = getClonedNode(id) + -- ROBLOX FIXME Luau: this cast shouldn't be necessary + node.children = Array.from(children) :: Array + elseif operation == TREE_OPERATION_UPDATE_TREE_BASE_DURATION then + id = operations[POSTFIX_INCREMENT()] + local node = getClonedNode(id) + node.treeBaseDuration = operations[POSTFIX_INCREMENT()] / 1000 -- Convert microseconds back to milliseconds; + if __DEBUG__ then + debug_( + "Update", + ("fiber %s treeBaseDuration to %s"):format( + tostring(id), + tostring(node.treeBaseDuration) + ) + ) + end + else + error( + string.format( + "Unsupported Bridge operation %s at operation index %d", + tostring(operation), + i + ) + ) + end + end + return { nodes = nodes, rootID = commitTree.rootID } +end + +local function recursivelyInitializeTree( + id: number, + parentID: number, + nodes: Map, + dataForRoot: ProfilingDataForRootFrontend +): () + local node = dataForRoot.snapshots:get(id) + if node ~= nil then + nodes:set(id, { + id = id, + children = node.children, + displayName = node.displayName, + hocDisplayNames = node.hocDisplayNames, + key = node.key, + parentID = parentID, + treeBaseDuration = ( + dataForRoot.initialTreeBaseDurations:get(id) :: any + ) :: number, + type = node.type, + }) + for _, childID in node.children do + recursivelyInitializeTree(childID, id, nodes, dataForRoot) + end + end +end + +local rootToCommitTreeMap: Map> = Map.new() +local function getCommitTree(ref: { + commitIndex: number, + profilerStore: ProfilerStore, + rootID: number, +}): CommitTree + local commitIndex, profilerStore, rootID = + ref.commitIndex, ref.profilerStore, ref.rootID + if not rootToCommitTreeMap:has(rootID) then + rootToCommitTreeMap:set(rootID, {}) + end + local commitTrees = (rootToCommitTreeMap:get(rootID) :: any) :: Array + if commitIndex <= #commitTrees then + return commitTrees[commitIndex] + end + local profilingData = profilerStore:profilingData() + -- ROBLOX FIXME Luau: need to understand error() means profilingData gets nil-ability stripped. needs type states. + if profilingData == nil then + error("No profiling data available") + end + local dataForRoot = (profilingData :: ProfilingDataFrontend).dataForRoots:get(rootID) + -- ROBLOX FIXME Luau: need to understand error() means profilingData gets nil-ability stripped. needs type states. + if dataForRoot == nil then + error( + string.format('Could not find profiling data for root "%s"', tostring(rootID)) + ) + end + local operations = (dataForRoot :: ProfilingDataForRootFrontend).operations -- Commits are generated sequentially and cached. + -- If this is the very first commit, start with the cached snapshot and apply the first mutation. + -- Otherwise load (or generate) the previous commit and append a mutation to it. + if commitIndex == 1 then + local nodes = Map.new() -- Construct the initial tree. + recursivelyInitializeTree( + rootID, + 0, + nodes, + dataForRoot :: ProfilingDataForRootFrontend + ) -- Mutate the tree + if operations ~= nil and commitIndex <= #operations then + local commitTree = + updateTree({ nodes = nodes, rootID = rootID }, operations[commitIndex]) + if __DEBUG__ then + __printTree(commitTree) + end + table.insert(commitTrees, commitTree) + return commitTree + end + else + local previousCommitTree = getCommitTree({ + commitIndex = commitIndex - 1, + profilerStore = profilerStore, + rootID = rootID, + }) + if operations ~= nil and commitIndex <= #operations then + local commitTree = updateTree(previousCommitTree, operations[commitIndex]) + if __DEBUG__ then + __printTree(commitTree) + end + table.insert(commitTrees, commitTree) + return commitTree + end + end + error( + string.format( + 'getCommitTree(): Unable to reconstruct tree for root "%s" and commit %s', + tostring(rootID), + tostring(commitIndex) + ) + ) +end +exports.getCommitTree = getCommitTree + +local function invalidateCommitTrees(): any? + return rootToCommitTreeMap:clear() +end +exports.invalidateCommitTrees = invalidateCommitTrees -- DEBUG + +return exports diff --git a/modules/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua new file mode 100644 index 00000000..2b27fc7c --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/FlamegraphChartBuilder.lua @@ -0,0 +1,217 @@ +--!strict +--[[* + * 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.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Boolean = LuauPolyfill.Boolean +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Set = LuauPolyfill.Set + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local formatDuration = require(script.Parent.utils).formatDuration +local typesModule = require(script.Parent.types) +type CommitTree = typesModule.CommitTree +type CommitTreeNode = typesModule.CommitTreeNode + +export type ChartNode = { + actualDuration: number, + didRender: boolean, + id: number, + label: string, + name: string, + offset: number, + selfDuration: number, + treeBaseDuration: number, +} +export type ChartData = { + baseDuration: number, + depth: number, + idToDepthMap: Map, + maxSelfDuration: number, + renderPathNodes: Set, + rows: Array>, +} +local cachedChartData: Map = Map.new() +local function getChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + profilerStore: ProfilerStore, + rootID: number, +}): ChartData + local commitIndex, commitTree, profilerStore, rootID = + ref.commitIndex, ref.commitTree, ref.profilerStore, ref.rootID + local commitDatum = profilerStore:getCommitData(rootID, commitIndex) + local fiberActualDurations, fiberSelfDurations = + commitDatum.fiberActualDurations, commitDatum.fiberSelfDurations + local nodes = commitTree.nodes + local chartDataKey = ("%s-%s"):format(tostring(rootID), tostring(commitIndex)) + if cachedChartData:has(chartDataKey) then + return (cachedChartData:get(chartDataKey) :: any) :: ChartData + end + local idToDepthMap: Map = Map.new() + local renderPathNodes: Set = Set.new() + local rows: Array> = {} + local maxDepth = 0 + local maxSelfDuration = 0 + + -- Generate flame graph structure using tree base durations. + local function walkTree(id: number, rightOffset: number, currentDepth: number) + idToDepthMap:set(id, currentDepth) + + local node = nodes:get(id) + if node == nil then + error( + string.format( + 'Could not find node with id "%s" in commit tree', + tostring(id) + ) + ) + end + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + local children, displayName, hocDisplayNames, key, treeBaseDuration = + (node :: CommitTreeNode).children, + (node :: CommitTreeNode).displayName, + (node :: CommitTreeNode).hocDisplayNames, + (node :: CommitTreeNode).key, + (node :: CommitTreeNode).treeBaseDuration + + local actualDuration = fiberActualDurations:get(id) or 0 + local selfDuration = fiberSelfDurations:get(id) or 0 + local didRender = fiberActualDurations:has(id) + + local name = displayName or "Anonymous" + local maybeKey = if Boolean.toJSBoolean(key) + then (' key="%s"'):format(tostring(key)) + else "" + + local maybeBadge = "" + if hocDisplayNames ~= nil and #hocDisplayNames > 0 then + maybeBadge = string.format(" (%s)", tostring(hocDisplayNames[1])) + end + + local label = string.format( + "%s%s%s%s", + tostring(name), + tostring(maybeBadge), + tostring(maybeKey), + if didRender + then string.format( + " (%sms of %sms)", + tostring(formatDuration(selfDuration)), + tostring(formatDuration(actualDuration)) + ) + else "" + ) + + maxDepth = math.max(maxDepth, currentDepth) + maxSelfDuration = math.max(maxSelfDuration, selfDuration) + local chartNode: ChartNode = { + actualDuration = actualDuration, + didRender = didRender, + id = id, + label = label, + name = name, + offset = rightOffset - treeBaseDuration, + selfDuration = selfDuration, + treeBaseDuration = treeBaseDuration, + } + if currentDepth > #rows then + table.insert(rows, { chartNode }) + else + table.insert(rows[currentDepth - 1], chartNode) + end + do + local i = #children + while i >= 1 do + local childID = children[i] + local childChartNode = walkTree(childID, rightOffset, currentDepth) + rightOffset -= childChartNode.treeBaseDuration + i -= 1 + end + end + return chartNode + end + local baseDuration = 0 -- Special case to handle unmounted roots. + if nodes.size > 0 then + -- Skip over the root; we don't want to show it in the flamegraph. + local root = nodes:get(rootID) + if root == nil then + error( + string.format( + 'Could not find root node with id "%s" in commit tree', + tostring(rootID) + ) + ) + end + -- Don't assume a single root. + -- Component filters or Fragments might lead to multiple "roots" in a flame graph. + do + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + local i = #(root :: CommitTreeNode).children + while i >= 1 do + local id = (root :: CommitTreeNode).children[i] + local node = nodes:get(id) + if node == nil then + error( + string.format( + 'Could not find node with id "%s" in commit tree', + tostring(id) + ) + ) + end + -- ROBLOX FIXME Luau: Luau doesn't understand error() narrows, needs type states + baseDuration += (node :: CommitTreeNode).treeBaseDuration + -- ROBLOX deviation START: walkTree does table.insert(tbl, currentDepth - 1), so the parameter here needs to be a valid index with after substracting 1 at the start + walkTree(id, baseDuration, 2) + -- ROBLOX deviation END + i -= 1 + end + end + for id, duration in fiberActualDurations do + local node = nodes:get(id) + if node ~= nil then + local currentID = node.parentID + while currentID ~= 0 do + if renderPathNodes:has(currentID) then + -- We've already walked this path; we can skip it. + break + else + renderPathNodes:add(currentID) + end + node = nodes:get(currentID) + currentID = if node ~= nil then node.parentID else 0 + end + end + end + end + local chartData = { + baseDuration = baseDuration, + depth = maxDepth, + idToDepthMap = idToDepthMap, + maxSelfDuration = maxSelfDuration, + renderPathNodes = renderPathNodes, + rows = rows, + } + cachedChartData:set(chartDataKey, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +return exports diff --git a/modules/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua new file mode 100644 index 00000000..e6958d00 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/InteractionsChartBuilder.lua @@ -0,0 +1,65 @@ +--!strict +--[[* + * 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.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore +local typesModule = require(script.Parent.types) +type Interaction = typesModule.Interaction +export type ChartData = { + interactions: Array, + lastInteractionTime: number, + maxCommitDuration: number, +} +local cachedChartData: Map = Map.new() +local function getChartData( + ref: { profilerStore: ProfilerStore, rootID: number } +): ChartData + local profilerStore, rootID = ref.profilerStore, ref.rootID + if cachedChartData:has(rootID) then + return (cachedChartData:get(rootID) :: any) :: ChartData + end + local dataForRoot = profilerStore:getDataForRoot(rootID) + if dataForRoot == nil then + error( + string.format('Could not find profiling data for root "%s"', tostring(rootID)) + ) + end + -- ROBLOX FIXME Luau: any cast necessary to work around: Property 'interactions' is not compatible. Type 'Array | Array | Array' could not be converted into 'Array' + local commitData, interactions: any = dataForRoot.commitData, dataForRoot.interactions + local lastInteractionTime = if #commitData > 0 + then commitData[#commitData].timestamp + else 0 + local maxCommitDuration = 0 + Array.forEach(commitData, function(commitDatum) + maxCommitDuration = math.max(maxCommitDuration, commitDatum.duration) + end) + local chartData = { + interactions = Array.from(interactions:values()) :: Array, + lastInteractionTime = lastInteractionTime, + maxCommitDuration = maxCommitDuration, + } + cachedChartData:set(rootID, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any? + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +return exports diff --git a/modules/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua new file mode 100644 index 00000000..2b7de115 --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/RankedChartBuilder.lua @@ -0,0 +1,111 @@ +--!strict +--[[* + * 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.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Map = LuauPolyfill.Map + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Set = LuauPolyfill.Set + +local exports = {} + +local devtoolsTypes = require(script.Parent.Parent.Parent.types) +type ProfilerStore = devtoolsTypes.ProfilerStore + +local typesModule = require(script.Parent.Parent.Parent.Parent.types) +local ElementTypeForwardRef = typesModule.ElementTypeForwardRef +local ElementTypeMemo = typesModule.ElementTypeMemo +local formatDuration = require(script.Parent.utils).formatDuration +local Profiler_typesModule = require(script.Parent.types) +type CommitTree = Profiler_typesModule.CommitTree +type CommitTreeNode = Profiler_typesModule.CommitTreeNode + +export type ChartNode = { id: number, label: string, name: string, value: number } +export type ChartData = { maxValue: number, nodes: Array } +local cachedChartData: Map = Map.new() +local function getChartData(ref: { + commitIndex: number, + commitTree: CommitTree, + profilerStore: ProfilerStore, + rootID: number, +}): ChartData + local commitIndex, commitTree, profilerStore, rootID = + ref.commitIndex, ref.commitTree, ref.profilerStore, ref.rootID + local commitDatum = profilerStore:getCommitData(rootID, commitIndex) + local fiberActualDurations: Map, fiberSelfDurations: Map = + commitDatum.fiberActualDurations, commitDatum.fiberSelfDurations + local nodes = commitTree.nodes + local chartDataKey = ("%s-%s"):format(tostring(rootID), tostring(commitIndex)) + if cachedChartData:has(chartDataKey) then + return (cachedChartData:get(chartDataKey) :: any) :: ChartData + end + local maxSelfDuration = 0 + local chartNodes: Array = {} + -- ROBLOX deviation? this is a simple Map, but could .forEach() always be generalized into genealized for-in if the loop is 'simple'? + for id, actualDuration in fiberActualDurations do + local node = nodes:get(id) + if node == nil then + error( + string.format( + 'Could not find node with id "%s" in commit tree', + tostring(id) + ) + ) + end + -- ROBLOX FIXME Luau: need to understand that error() means `node` has nil-ability stripped + local displayName, key, parentID, type_ = + (node :: CommitTreeNode).displayName, + (node :: CommitTreeNode).key, + (node :: CommitTreeNode).parentID, + (node :: CommitTreeNode).type -- Don't show the root node in this chart. + if parentID == 0 then + continue + end + local selfDuration = fiberSelfDurations:get(id) or 0 + maxSelfDuration = math.max(maxSelfDuration, selfDuration) + local name = displayName or "Anonymous" + local maybeKey = if Boolean.toJSBoolean(key) + then (' key="%s"'):format(tostring(key)) + else "" + local maybeBadge = "" + if type_ == ElementTypeForwardRef then + maybeBadge = " (ForwardRef)" + elseif type_ == ElementTypeMemo then + maybeBadge = " (Memo)" + end + local label = ("%s%s%s (%sms)"):format( + tostring(name), + tostring(maybeBadge), + tostring(maybeKey), + tostring(formatDuration(selfDuration)) + ) + table.insert( + chartNodes, + { id = id, label = label, name = name, value = selfDuration } + ) + end + local chartData = { + maxValue = maxSelfDuration, + nodes = Array.sort(chartNodes, function(a, b) + return b.value - a.value + end), + } + cachedChartData:set(chartDataKey, chartData) + return chartData +end +exports.getChartData = getChartData +local function invalidateChartData(): any? + return cachedChartData:clear() +end +exports.invalidateChartData = invalidateChartData +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..1a53a69b --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/types.lua @@ -0,0 +1,151 @@ +--!strict +-- 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 Packages = script.Parent.Parent.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +local exports = {} + +local ReactDevtoolsSharedTypes = require(script.Parent.Parent.Parent.Parent.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 + fiberActualDurations: Array>, + -- Tuple of fiber ID and computed "self" duration + fiberSelfDurations: Array>, + interactionIDs: Array, + priorityLevel: string | nil, + timestamp: number, +} + +export type ProfilingDataForRootExport = { + commitData: Array, + displayName: string, + -- Tuple of Fiber ID and base duration + initialTreeBaseDurations: Array>, + -- Tuple of Interaction ID and commit indices + 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/devtools/views/Profiler/utils.lua b/modules/react-devtools-shared/src/devtools/views/Profiler/utils.lua new file mode 100644 index 00000000..f13910ef --- /dev/null +++ b/modules/react-devtools-shared/src/devtools/views/Profiler/utils.lua @@ -0,0 +1,288 @@ +--!strict +--[[* + * 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.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Error = LuauPolyfill.Error +local Map = LuauPolyfill.Map +local Number = LuauPolyfill.Number + +type Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array + +local exports = {} + +local PROFILER_EXPORT_VERSION = + require(script.Parent.Parent.Parent.Parent.constants).PROFILER_EXPORT_VERSION +local backendTypes = require(script.Parent.Parent.Parent.Parent.backend.types) +type ProfilingDataBackend = backendTypes.ProfilingDataBackend +local profilerTypes = require(script.Parent.types) +type ProfilingDataExport = profilerTypes.ProfilingDataExport +type ProfilingDataForRootExport = profilerTypes.ProfilingDataForRootExport +type ProfilingDataForRootFrontend = profilerTypes.ProfilingDataForRootFrontend +type ProfilingDataFrontend = profilerTypes.ProfilingDataFrontend +type SnapshotNode = profilerTypes.SnapshotNode + +local commitGradient = { + "var(--color-commit-gradient-0)", + "var(--color-commit-gradient-1)", + "var(--color-commit-gradient-2)", + "var(--color-commit-gradient-3)", + "var(--color-commit-gradient-4)", + "var(--color-commit-gradient-5)", + "var(--color-commit-gradient-6)", + "var(--color-commit-gradient-7)", + "var(--color-commit-gradient-8)", + "var(--color-commit-gradient-9)", +} -- Combines info from the Store (frontend) and renderer interfaces (backend) into the format required by the Profiler UI. +-- This format can then be quickly exported (and re-imported). +local function prepareProfilingDataFrontendFromBackendAndStore( + dataBackends: Array< + ProfilingDataBackend + >, + operationsByRootID: Map< + number, + Array> + >, + snapshotsByRootID: Map< + number, + Map + > +): ProfilingDataFrontend + local dataForRoots: Map = Map.new() + for _, dataBackend in dataBackends do + for _, ref in dataBackend.dataForRoots do + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, rootID = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.rootID + local operations = operationsByRootID:get(rootID) + if operations == nil then + error( + Error.new( + string.format( + "Could not find profiling operations for root %s", + tostring(rootID) + ) + ) + ) + end + local snapshots = snapshotsByRootID:get(rootID) + if snapshots == nil then + error( + Error.new( + string.format( + "Could not find profiling snapshots for root %s", + tostring(rootID) + ) + ) + ) + end + + -- Do not filter empty commits from the profiler data! + -- We used to do this, but it was error prone (see #18798). + -- A commit may appear to be empty (no actual durations) because of component filters, + -- but filtering these empty commits causes interaction commit indices to be off by N. + -- This not only corrupts the resulting data, but also potentially causes runtime errors. + -- + -- For that matter, hiding "empty" commits might cause confusion too. + -- A commit *did happen* even if none of the components the Profiler is showing were involved. + local convertedCommitData = Array.map( + commitData, + function(commitDataBackend, commitIndex) + return { + changeDescriptions = if commitDataBackend.changeDescriptions + ~= nil + then Map.new(commitDataBackend.changeDescriptions) + else nil, + duration = commitDataBackend.duration, + fiberActualDurations = Map.new( + commitDataBackend.fiberActualDurations + ), + fiberSelfDurations = Map.new( + commitDataBackend.fiberSelfDurations + ), + interactionIDs = commitDataBackend.interactionIDs, + priorityLevel = commitDataBackend.priorityLevel, + timestamp = commitDataBackend.timestamp, + } + end + ) + dataForRoots:set(rootID, { + commitData = convertedCommitData, + displayName = displayName, + initialTreeBaseDurations = Map.new(initialTreeBaseDurations), + interactionCommits = Map.new(interactionCommits), + interactions = Map.new(interactions), + -- ROBLOX FIXME Luau: need type states to not need manual annotation + operations = operations :: Array>, + rootID = rootID, + -- ROBLOX FIXME Luau: need type states to not need manual annotation + snapshots = snapshots :: Map, + }) + end + end + return { + dataForRoots = dataForRoots, + imported = false, + } +end + +-- Converts a Profiling data export into the format required by the Store. +exports.prepareProfilingDataFrontendFromBackendAndStore = + prepareProfilingDataFrontendFromBackendAndStore +local function prepareProfilingDataFrontendFromExport( + profilingDataExport: ProfilingDataExport +): ProfilingDataFrontend + local version_ = profilingDataExport.version + if version_ ~= PROFILER_EXPORT_VERSION then + error( + string.format('Unsupported profiler export version "%s"', tostring(version_)) + ) + end + local dataForRoots: Map = Map.new() + Array.forEach(profilingDataExport.dataForRoots, function(ref) + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, operations, rootID, snapshots = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.operations, + ref.rootID, + ref.snapshots + dataForRoots:set(rootID, { + commitData = Array.map(commitData, function(ref) + local changeDescriptions, duration, fiberActualDurations, fiberSelfDurations, interactionIDs, priorityLevel, timestamp = + ref.changeDescriptions, + ref.duration, + ref.fiberActualDurations, + ref.fiberSelfDurations, + ref.interactionIDs, + ref.priorityLevel, + ref.timestamp + return { + changeDescriptions = if changeDescriptions ~= nil + then Map.new(changeDescriptions) + else nil, + duration = duration, + fiberActualDurations = Map.new(fiberActualDurations), + fiberSelfDurations = Map.new(fiberSelfDurations), + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = timestamp, + } + end), + displayName = displayName, + initialTreeBaseDurations = Map.new(initialTreeBaseDurations), + interactionCommits = Map.new(interactionCommits), + interactions = Map.new(interactions), + operations = operations, + rootID = rootID, + snapshots = Map.new(snapshots), + }) + end) + return { dataForRoots = dataForRoots, imported = true } +end +exports.prepareProfilingDataFrontendFromExport = prepareProfilingDataFrontendFromExport -- Converts a Store Profiling data into a format that can be safely (JSON) serialized for export. +local function prepareProfilingDataExport( + profilingDataFrontend: ProfilingDataFrontend +): ProfilingDataExport + local dataForRoots: Array = {} + profilingDataFrontend.dataForRoots:forEach(function(ref) + local commitData, displayName, initialTreeBaseDurations, interactionCommits, interactions, operations, rootID, snapshots = + ref.commitData, + ref.displayName, + ref.initialTreeBaseDurations, + ref.interactionCommits, + ref.interactions, + ref.operations, + ref.rootID, + ref.snapshots + table.insert(dataForRoots, { + commitData = Array.map(commitData, function(ref) + local changeDescriptions, duration, fiberActualDurations, fiberSelfDurations, interactionIDs, priorityLevel, timestamp = + ref.changeDescriptions, + ref.duration, + ref.fiberActualDurations, + ref.fiberSelfDurations, + ref.interactionIDs, + ref.priorityLevel, + ref.timestamp + return { + changeDescriptions = if changeDescriptions ~= nil + -- ROBLOX FIXME: types aren't flowing from entries through to return value of Array.from + then Array.from(changeDescriptions:entries()) :: Array> + else nil, + duration = duration, + fiberActualDurations = Array.from(fiberActualDurations:entries()) :: Array>, + fiberSelfDurations = Array.from(fiberSelfDurations:entries()) :: Array>, + interactionIDs = interactionIDs, + priorityLevel = priorityLevel, + timestamp = timestamp, + } + end), + displayName = displayName, + -- ROBLOX FIXME: types aren't flowing from entries through to return value of Array.from + initialTreeBaseDurations = Array.from(initialTreeBaseDurations:entries()) :: Array>, + interactionCommits = Array.from(interactionCommits:entries()) :: Array | number>>, + interactions = Array.from(interactions:entries()) :: Array>, + operations = operations, + rootID = rootID, + snapshots = Array.from(snapshots:entries()) :: Array>, + }) + end) + return { version = PROFILER_EXPORT_VERSION, dataForRoots = dataForRoots } +end +exports.prepareProfilingDataExport = prepareProfilingDataExport +local function getGradientColor(value: number) + local maxIndex = #commitGradient + local index + if Number.isNaN(value) then + index = 0 + elseif not Number.isFinite(value) then + index = maxIndex + else + index = math.max(0, math.min(maxIndex, value)) * maxIndex + end + return commitGradient[math.round(index)] +end +exports.getGradientColor = getGradientColor +local function formatDuration(duration: number) + local ref = math.round(duration * 10) / 10 + return if Boolean.toJSBoolean(ref) then ref else "<0.1" +end +exports.formatDuration = formatDuration +local function formatPercentage(percentage: number) + return math.round(percentage * 100) +end +exports.formatPercentage = formatPercentage +local function formatTime(timestamp: number) + return math.round(math.round(timestamp) / 100) / 10 +end +exports.formatTime = formatTime +local function scale( + minValue: number, + maxValue: number, + minRange: number, + maxRange: number +) + return function(value: number, fallbackValue: number) + return if maxValue - minValue == 0 + then fallbackValue + else (value - minValue) / (maxValue - minValue) * (maxRange - minRange) + end +end +exports.scale = scale +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..d572b6df --- /dev/null +++ b/modules/react-devtools-shared/src/events.lua @@ -0,0 +1,109 @@ +--!strict +-- 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 Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Function = (...any) -> ...any +type ElementType = any +type EventListener = (...ElementType) -> ...any + +export type EventEmitter = { + listenersMap: Map>, + -- ROBLOX TODO: function generics >( + addListener: ( + self: EventEmitter, + event: string, + listener: EventListener + ) -> (), + -- ROBLOX TODO: function generics >( + emit: (EventEmitter, string, ...ElementType) -> (), + removeAllListeners: (EventEmitter) -> (), + -- ROBLOX deviation: Luau doesn't support $Keys for first non-self param + removeListener: (self: EventEmitter, event: string, listener: Function) -> (), +} +type EventEmitter_statics = { + new: () -> EventEmitter, +} +local EventEmitter: EventEmitter & EventEmitter_statics = ( + {} :: any +) :: EventEmitter & EventEmitter_statics +local EventEmitterMetatable = { __index = EventEmitter } + +function EventEmitter.new(): EventEmitter + local self = {} + self.listenersMap = Map.new() + + return (setmetatable(self, EventEmitterMetatable) :: any) :: EventEmitter +end + +function EventEmitter:addListener(event: string, listener: EventListener): () + local listeners = self.listenersMap:get(event) + if listeners == nil then + self.listenersMap:set(event, { listener }) + else + local index = Array.indexOf(listeners :: Array, listener) + if index < 1 then + table.insert(listeners, listener) + end + end +end + +-- ROBLOX deviation: Luau doesn't support $Keys for first non-self param +function EventEmitter:emit(event: string, ...: ElementType): () + local listeners = self.listenersMap:get(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 = table.clone(listeners) + for _, listener in clonedListeners do + local ok, error_ = pcall(function(...) + listener(...) + return nil + end, ...) + if not ok then + didThrow = true + caughtError = error_ + end + end + if didThrow then + -- ROBLOX note: stringify error to avoid "nil output from lua" error + error(tostring(caughtError)) + end + end + end +end + +function EventEmitter:removeAllListeners(): () + self.listenersMap:clear() +end + +-- ROBLOX deviation: Luau doesn't support $Keys for first non-self param +function EventEmitter:removeListener(event: string, listener: Function): () + local listeners = self.listenersMap:get(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/hook.lua b/modules/react-devtools-shared/src/hook.lua new file mode 100644 index 00000000..ef05f526 --- /dev/null +++ b/modules/react-devtools-shared/src/hook.lua @@ -0,0 +1,206 @@ +--!strict +-- ROBLOX upstream: https://raw.githubusercontent.com/facebook/react/v17.0.1/packages/react-devtools-shared/src/hook.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 Packages = script.Parent.Parent + +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Map = LuauPolyfill.Map +local Set = LuauPolyfill.Set +type Set = LuauPolyfill.Set +type Map = LuauPolyfill.Map +type Function = (...any) -> any +local exports = {} + +local console = require(script.Parent.backend.console) +local patchConsole = console.patch +local registerRendererWithConsole = console.registerRenderer + +local BackendTypes = require(script.Parent.backend.types) +type DevToolsHook = BackendTypes.DevToolsHook + +local window = ReactGlobals + +exports.installHook = function(target: any): DevToolsHook | nil + if target["__REACT_DEVTOOLS_GLOBAL_HOOK__"] then + return nil + end + + -- ROBLOX deviation: hoist decls to top + local hook: DevToolsHook + -- ROBLOX deviation: always false, only relevant in context of optimizing bundler + local hasDetectedBadDCE = false + -- TODO: More meaningful names for "rendererInterfaces" and "renderers". + local fiberRoots = {} + local rendererInterfaces = Map.new() + local listeners = {} + local renderers = Map.new() + + local function detectReactBuildType(renderer) + -- ROBLOX TODO? do we need to distinguish between prod and dev bundles? + return "production" + end + local function checkDCE(fn: Function) + -- ROBLOX deviation: not needed in the absence of optimizing bundler + end + + -- ROBLOX deviation: start at 1 + local uidCounter = 1 + local function PREFIX_INCREMENT() + uidCounter += 1 + return uidCounter + end + + local function inject(renderer) + local id = PREFIX_INCREMENT() + + renderers:set(id, renderer) + + local reactBuildType = if hasDetectedBadDCE + then "deadcode" + else detectReactBuildType(renderer) + + -- ROBLOX deviation: instead of checking if `process.env.NODE_ENV ~= "production"` + -- we use the __DEV__ global + if ReactGlobals.__DEV__ then + pcall(function() + local appendComponentStack = window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ + ~= false + local breakOnConsoleErrors = window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ + == true + + -- The installHook() function is injected by being stringified in the browser, + -- so imports outside of this function do not get included. + -- + -- Normally we could check "type patchConsole === 'function'", + -- but Webpack wraps imports with an object (e.g. _backend_console__WEBPACK_IMPORTED_MODULE_0__) + -- and the object itself will be undefined as well for the reasons mentioned above, + -- so we use try/catch instead. + if appendComponentStack or breakOnConsoleErrors then + registerRendererWithConsole(renderer) + patchConsole({ + appendComponentStack = appendComponentStack, + breakOnConsoleErrors = breakOnConsoleErrors, + }) + end + end) + end + + local attach = target.__REACT_DEVTOOLS_ATTACH__ + + if type(attach) == "function" then + local rendererInterface = attach(hook, id, renderer, target) + hook.rendererInterfaces:set(id, rendererInterface) + end + + hook.emit("renderer", { + id = id, + renderer = renderer, + reactBuildType = reactBuildType, + }) + return id + end + + local function sub(event: string, fn: (any) -> ()) + hook.on(event, fn) + return function() + return hook.off(event, fn) + end + end + local function on(event, fn) + if not listeners[event] then + listeners[event] = {} + end + table.insert(listeners[event], fn) + end + local function off(event, fn) + if not listeners[event] then + return + end + + local index = Array.indexOf(listeners[event], fn) + + if index ~= -1 then + Array.splice(listeners[event], index, 1) + end + if #listeners[event] == 0 then + listeners[event] = nil + end + end + local function emit(event, data) + if listeners[event] then + for _, fn in listeners[event] do + fn(data) + end + end + end + local function getFiberRoots(rendererID) + local roots = fiberRoots + + if not roots[rendererID] then + roots[rendererID] = Set.new() + end + + return roots[rendererID] + end + local function onCommitFiberUnmount(rendererID, fiber) + local rendererInterface = rendererInterfaces:get(rendererID) + + if rendererInterface ~= nil then + rendererInterface.handleCommitFiberUnmount(fiber) + end + end + local function onCommitFiberRoot(rendererID, root, priorityLevel) + local mountedRoots = hook.getFiberRoots(rendererID) + local current = root.current + local isKnownRoot = mountedRoots:has(root) + local isUnmounting = current.memoizedState == nil + or current.memoizedState.element == nil + + if not isKnownRoot and not isUnmounting then + mountedRoots:add(root) + elseif isKnownRoot and isUnmounting then + mountedRoots:delete(root) + end + + local rendererInterface = rendererInterfaces:get(rendererID) + + if rendererInterface ~= nil then + rendererInterface.handleCommitFiberRoot(root, priorityLevel) + end + end + + hook = { + rendererInterfaces = rendererInterfaces, + listeners = listeners, + -- Fast Refresh for web relies on this. + renderers = renderers, + + emit = emit, + getFiberRoots = getFiberRoots, + inject = inject, + on = on, + off = off, + sub = sub, + + -- This is a legacy flag. + -- React v16 checks the hook for this to ensure DevTools is new enough. + supportsFiber = true, + + -- React calls these methods. + checkDCE = checkDCE, + onCommitFiberUnmount = onCommitFiberUnmount, + onCommitFiberRoot = onCommitFiberRoot, + } + + target["__REACT_DEVTOOLS_GLOBAL_HOOK__"] = hook + return hook +end + +return exports diff --git a/modules/react-devtools-shared/src/hydration.lua b/modules/react-devtools-shared/src/hydration.lua new file mode 100644 index 00000000..139fc394 --- /dev/null +++ b/modules/react-devtools-shared/src/hydration.lua @@ -0,0 +1,413 @@ +-- 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 Packages = script.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Symbol = LuauPolyfill.Symbol +local Object = LuauPolyfill.Object +local String = LuauPolyfill.String +type Array = { [number]: T } +type Object = { [string]: any } + +local Utils = require(script.Parent.utils) +local formatDataForPreview = Utils.formatDataForPreview +local getDisplayNameForReactElement = Utils.getDisplayNameForReactElement +local getAllEnumerableKeys = Utils.getAllEnumerableKeys +local getDataType = Utils.getDataType + +-- ROBLOX FIXME: !!! THIS FILE IS A STUB WITH BAREBONES FOR UTILS TEST +local function unimplemented(functionName: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!! " .. functionName .. " was called, but is stubbed! ") +end + +local exports = {} + +--ROBLOX TODO: circular dependency, inline for now and submit PR to fix upstream +--local ComponentsTypes = require(script.Parent.devtools.views.Components.types) +export type DehydratedData = { + cleaned: Array>, + data: string + | Dehydrated + | Unserializable + | Array + | Array + | { [string]: string | Dehydrated | Unserializable }, + unserializable: Array>, +} + +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 + table.insert(cleaned, path) + + local dehydrated: Dehydrated = { + inspectable = inspectable, + type = type, + preview_long = formatDataForPreview(data, true), + preview_short = formatDataForPreview(data, false), + name = if not data.constructor or data.constructor.name == "Object" + then "" + else data.constructor.name, + } + + if type == "array" or type == "typed_array" then + dehydrated.size = data.length + elseif type == "object" then + dehydrated.size = Object.keys(data).length + end + + if type == "iterator" or type == "typed_array" then + dehydrated.readonly = true + end + + return dehydrated +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: any, + cleaned: Array>, + unserializable: Array>, + path: Array, + isPathAllowed: (Array) -> boolean, + level_: number? +): string | Dehydrated | Unserializable | Array | Array | { + [string]: string | Dehydrated | Unserializable, --[[...]] +} + local level = level_ or 0 + + local type_ = getDataType(data) + local isPathAllowedCheck + + -- switch (type) { + -- case 'html_element': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.tagName, + -- type, + -- }; + + if type_ == "function" then + table.insert(cleaned, path) + local functionName = debug.info(data, "n") + return { + inspectable = false, + preview_short = formatDataForPreview(data, false), + preview_long = formatDataForPreview(data, true), + name = functionName, + type = type_, + } + elseif type_ == "string" then + return if #data <= 500 then data else String.slice(data, 0, 500) .. "..." + + -- case 'bigint': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- case 'symbol': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- React Elements aren't very inspector-friendly, + -- and often contain private fields or circular references. + elseif type_ == "react_element" then + table.insert(cleaned, path) + return { + inspectable = false, + preview_short = formatDataForPreview(data, false), + preview_long = formatDataForPreview(data, true), + name = getDisplayNameForReactElement(data) or "Unknown", + type = type_, + } + + -- // ArrayBuffers error if you try to inspect them. + -- case 'array_buffer': + -- case 'data_view': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: type === 'data_view' ? 'DataView' : 'ArrayBuffer', + -- size: data.byteLength, + -- type, + -- }; + elseif type_ == "array" then + isPathAllowedCheck = isPathAllowed(path) + if level >= LEVEL_THRESHOLD and not isPathAllowedCheck then + return exports.createDehydrated(type_, true, data, cleaned, path) + end + + return Array.map(data, function(item, i) + return exports.dehydrate( + item, + cleaned, + unserializable, + Array.concat(path, i), + isPathAllowed, + if isPathAllowedCheck then 1 else level + 1 + ) + end) + + -- case 'html_all_collection': + -- case 'typed_array': + -- case 'iterator': + -- isPathAllowedCheck = isPathAllowed(path); + -- if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + -- return createDehydrated(type, true, data, cleaned, path); + -- } else { + -- const unserializableValue: Unserializable = { + -- unserializable: true, + -- type: type, + -- readonly: true, + -- size: type === 'typed_array' ? data.length : undefined, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: + -- !data.constructor || data.constructor.name === 'Object' + -- ? '' + -- : data.constructor.name, + -- }; + + -- // TRICKY + -- // Don't use [...spread] syntax for this purpose. + -- // This project uses @babel/plugin-transform-spread in "loose" mode which only works with Array values. + -- // Other types (e.g. typed arrays, Sets) will not spread correctly. + -- Array.from(data).forEach( + -- (item, i) => + -- (unserializableValue[i] = dehydrate( + -- item, + -- cleaned, + -- unserializable, + -- path.concat([i]), + -- isPathAllowed, + -- isPathAllowedCheck ? 1 : level + 1, + -- )), + -- ); + + -- unserializable.push(path); + + -- return unserializableValue; + -- } + + -- case 'opaque_iterator': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data[Symbol.toStringTag], + -- type, + -- }; + + -- case 'date': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + + -- case 'regexp': + -- cleaned.push(path); + -- return { + -- inspectable: false, + -- preview_short: formatDataForPreview(data, false), + -- preview_long: formatDataForPreview(data, true), + -- name: data.toString(), + -- type, + -- }; + elseif type_ == "table" then + isPathAllowedCheck = isPathAllowed(path) + if level >= LEVEL_THRESHOLD and not isPathAllowedCheck then + return exports.createDehydrated(type_, true, data, cleaned, path) + end + + local object = {} + + Array.forEach(getAllEnumerableKeys(data), function(key) + local name = tostring(key) + object[name] = exports.dehydrate( + data[key], + cleaned, + unserializable, + Array.concat(path, name), + isPathAllowed, + if isPathAllowedCheck then 1 else level + 1 + ) + end) + + return object + elseif type_ == "infinity" or type_ == "nan" or type_ == "nil" then + table.insert(cleaned, path) + return { + type = type_, + } + else + return data + end +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 + -- ROBLOX TODO: port this properly later, for now return the default + -- const length = path.length; + -- const last = path[length - 1]; + -- const parent = getInObject(object, path.slice(0, length - 1)); + -- if (!parent || !parent.hasOwnProperty(last)) { + -- return; + -- } + + -- const value = parent[last]; + + -- if (value.type === 'infinity') { + -- parent[last] = Infinity; + -- } else if (value.type === 'nan') { + -- parent[last] = NaN; + -- } else if (value.type === 'undefined') { + -- parent[last] = undefined; + -- } else { + -- // Replace the string keys with Symbols so they're non-enumerable. + -- const replaced: {[key: Symbol]: boolean | string, ...} = {}; + -- replaced[meta.inspectable] = !!value.inspectable; + -- replaced[meta.inspected] = false; + -- replaced[meta.name] = value.name; + -- replaced[meta.preview_long] = value.preview_long; + -- replaced[meta.preview_short] = value.preview_short; + -- replaced[meta.size] = value.size; + -- replaced[meta.readonly] = !!value.readonly; + -- replaced[meta.type] = value.type; + + -- parent[last] = replaced; + -- } + -- }); + -- unserializable.forEach((path: Array) => { + -- const length = path.length; + -- const last = path[length - 1]; + -- const parent = getInObject(object, path.slice(0, length - 1)); + -- if (!parent || !parent.hasOwnProperty(last)) { + -- return; + -- } + + -- const node = parent[last]; + + -- const replacement = { + -- ...node, + -- }; + + -- upgradeUnserializable(replacement, node); + + -- parent[last] = replacement; + -- }); + + return object +end + +return exports diff --git a/modules/react-devtools-shared/src/init.lua b/modules/react-devtools-shared/src/init.lua new file mode 100644 index 00000000..df6337a5 --- /dev/null +++ b/modules/react-devtools-shared/src/init.lua @@ -0,0 +1,19 @@ +-- ROBLOX note: upstream doesn't have a root index.js, we may want to contribute a proper contract upstream + +local Bridge = require(script.bridge) +local Types = require(script.types) +local Backend = require(script.backend) + +export type BackendBridge = Bridge.BackendBridge +export type ComponentFilter = Types.ComponentFilter +export type DevtoolsHook = Backend.DevToolsHook + +return { + constants = require(script.constants), + backend = require(script.backend), + bridge = require(script.bridge), + devtools = require(script.devtools), + hydration = require(script.hydration), + hook = require(script.hook), + utils = require(script.utils), +} diff --git a/modules/react-devtools-shared/src/jest.config.lua b/modules/react-devtools-shared/src/jest.config.lua new file mode 100644 index 00000000..be07cbc8 --- /dev/null +++ b/modules/react-devtools-shared/src/jest.config.lua @@ -0,0 +1,4 @@ +return { + setupFilesAfterEnv = { script.Parent.__tests__.setupTests }, + testMatch = { "**/*.(spec|test)" }, +} diff --git a/modules/react-devtools-shared/src/storage.lua b/modules/react-devtools-shared/src/storage.lua new file mode 100644 index 00000000..058f3398 --- /dev/null +++ b/modules/react-devtools-shared/src/storage.lua @@ -0,0 +1,45 @@ +-- 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 Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) + +local exports = {} +if ReactGlobals.__LOCALSTORAGE__ == nil then + ReactGlobals.__LOCALSTORAGE__ = {} +end + +if ReactGlobals.__SESSIONSTORAGE__ == nil then + ReactGlobals.__SESSIONSTORAGE__ = {} +end + +-- ROBLOX FIXME: what's a high-performance storage that for temporal (current DM lifetime) and permanent (beyond current DM lifetime) +local localStorage = ReactGlobals.__LOCALSTORAGE__ +local sessionStorage = ReactGlobals.__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..37f2e37c --- /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..d3a84d0e --- /dev/null +++ b/modules/react-devtools-shared/src/utils.lua @@ -0,0 +1,812 @@ +--!strict +-- 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 Packages = script.Parent.Parent + +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local WeakMap = LuauPolyfill.WeakMap +local Number = LuauPolyfill.Number +local Object = LuauPolyfill.Object +type WeakMap = LuauPolyfill.WeakMap +type Function = (...any) -> ...any +type Object = LuauPolyfill.Object +type Array = LuauPolyfill.Array +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 +type ComponentFilter = types.ComponentFilter +type ElementType = types.ElementType + +local cachedDisplayNames: WeakMap = WeakMap.new() + +-- On large trees, encoding takes significant time. +-- Try to reuse the already encoded strings. +-- 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:get(type_) + + if nameFromCache ~= nil then + return nameFromCache :: string + end + + -- ROBLOX FIXME: Luau type narrowing doesn't understand the or "anonymous" above + local displayName: string = fallbackName :: string + + -- 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 + -- ROBLOX deviation START: Luau datatypes don't have a displayName property, so we use .__componentName + if typeof(type_) == "table" and typeof(type_.__componentName) == "string" then + displayName = type_.__componentName + -- ROBLOX deviation END + 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:set(type_, displayName) + + return displayName +end + +-- Mirror https://github.com/facebook/react/blob/7c21bf72ace77094fd1910cc350a548287ef8350/packages/shared/getComponentName.js#L27-L37 +-- This is from a newer version of React and solves some bugs with the current +-- version's implementation. +exports.getWrappedDisplayName = function( + outerType: any, + innerType: any, + wrapperName: string, + fallbackName: string? +): string + local displayName = outerType and outerType.displayName + return displayName + or `{wrapperName}({exports.getDisplayName(innerType, fallbackName)})` +end + +local uidCounter: number = 0 + +exports.getUID = function(): number + uidCounter += 1 + return uidCounter +end + +exports.utfDecodeString = function(codePoints: { number }): string + -- Rebuild the actual code-point sequence by merging any > 0xFFFF entries + local seq = {} + local i = 1 + while i <= #codePoints do + local cp = codePoints[i] + if cp >= 0x10000 then + -- This was a paired surrogate in the encode step. + -- Skip the next “low surrogate” placeholder so we don’t double-count. + table.insert(seq, cp) + i = i + 2 + else + -- BMP or standalone surrogate — just push it. + table.insert(seq, cp) + i = i + 1 + end + end + + return utf8.char(table.unpack(seq)) +end + +exports.utfEncodeString = function(str): Array + -- First, convert the UTF-8 string to a table of UTF-16 code units. + -- JavaScript’s strings are UTF-16 encoded. Lua strings are UTF-8 encoded, + -- so we need to "simulate" that conversion. + local utf16Units = {} + for pos, codepoint in utf8.codes(str) do + -- If the codepoint is in the BMP, it maps directly. + if codepoint < 0x10000 then + table.insert(utf16Units, codepoint) + else + -- For codepoints outside the BMP, convert them to surrogate pairs. + local cp = codepoint - 0x10000 + local high = math.floor(cp / 0x400) + 0xD800 + local low = (cp % 0x400) + 0xDC00 + table.insert(utf16Units, high) + table.insert(utf16Units, low) + end + end + + -- Now, mimic the JS loop: + -- For each index in the UTF-16 table, call codePointAt logic. + local result = {} + for i = 1, #utf16Units do + local current = utf16Units[i] + -- Check for a high surrogate followed by a low surrogate. + if current >= 0xD800 and current <= 0xDBFF and i < #utf16Units then + local nextUnit = utf16Units[i + 1] + if nextUnit >= 0xDC00 and nextUnit <= 0xDFFF then + -- Combine them as JS's codePointAt does. + result[i] = ((current - 0xD800) * 0x400) + (nextUnit - 0xDC00) + 0x10000 + else + result[i] = current + end + else + result[i] = current + end + end + + -- Optionally store in cache. + -- if encodedStringCache then + -- encodedStringCache.set(str, result) + -- end + + return result +end + +-- ROBLOX deviation: don't binary encode strings, so operations Array can include strings +exports.printOperationsArray = function(operations: Array) + -- The first two values are always rendererID and rootID + local rendererID = operations[1] :: number + local rootID = operations[2] :: number + local logs = { + string.format( + "operations for renderer:%s and root:%s", + tostring(rendererID), + tostring(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: Array = { + -- ROBLOX deviation: Use the empty string + "", -- ID = 0 corresponds to the empty string. + } + local stringTableSize = operations[POSTFIX_INCREMENT()] :: number + local stringTableEnd = i + stringTableSize + + -- ROBLOX deviation: adjust bounds due to 1-based indexing + while i < stringTableEnd do + -- ROBLOX deviation: don't binary encode strings, so store string directly rather than length + -- local nextLength = operations[POSTFIX_INCREMENT()] + -- local nextString = exports.utfDecodeString(Array.slice(operations, i, i + nextLength) + local nextString = operations[POSTFIX_INCREMENT()] :: string + table.insert(stringTable, nextString) + end + + while i < #operations do + local operation = operations[i] :: number + + if operation == TREE_OPERATION_ADD then + local id = operations[i + 1] :: number + local type_ = operations[i + 2] :: ElementType + + i += 3 + + if type_ == ElementTypeRoot then + table.insert(logs, string.format("Add new root node %d", id)) + + i += 1 -- supportsProfiling + i += 1 -- hasOwnerMetadata + else + local parentID = operations[i] :: number + i += 1 + + i += 1 -- ownerID + + local displayNameStringID = operations[i] :: number + local displayName = stringTable[displayNameStringID + 1] + i += 1 + + i += 1 -- key + + table.insert( + logs, + string.format( + "Add node %d (%s) as child of %d", + id, + displayName or "null", + parentID + ) + ) + end + elseif operation == TREE_OPERATION_REMOVE then + local removeLength = operations[i + 1] :: number + i += 2 + + for removeIndex = 1, removeLength do + local id = operations[i] :: number + i += 1 + + table.insert(logs, string.format("Remove node %d", id)) + end + elseif operation == TREE_OPERATION_REORDER_CHILDREN then + local id = operations[i + 1] :: number + local numChildren = operations[i + 2] :: number + i += 3 + local children = Array.slice(operations, i, i + numChildren) + i += numChildren + + table.insert( + logs, + string.format( + "Re-order node %d children %s", + 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(string.format("Unsupported Bridge operation %d", operation)) + end + end + + print(table.concat(logs, "\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(): boolean + 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 +): (string | nil, Array | nil) + if displayName == nil then + return nil, nil + end + + local hocDisplayNames: Array? = nil + + if + type_ == ElementTypeClass + or type_ == ElementTypeForwardRef + or type_ == ElementTypeFunction + or type_ == ElementTypeMemo + then + -- ROBLOX deviation START: use find instead of indexOf and gmatch instead of /[^()]+/g + if string.find(displayName :: string, "(", 1, true) then + local hocTable: Array = {} + for match in string.gmatch(displayName :: string, "[^()]+") do + table.insert(hocTable, match) + end + + -- ROBLOX note: Pull the last one out as the displayName + local count = #hocTable + local lastMatch = hocTable[count] + hocTable[count] = nil + + displayName = lastMatch + hocDisplayNames = hocTable + end + -- ROBLOX Deviation END + end + + if type_ == ElementTypeMemo then + if hocDisplayNames == nil then + hocDisplayNames = { "Memo" } + else + Array.unshift(hocDisplayNames :: Array, "Memo") + end + elseif type_ == ElementTypeForwardRef then + if hocDisplayNames == nil then + hocDisplayNames = { "ForwardRef" } + else + Array.unshift(hocDisplayNames :: Array, "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 prev do + if next_[key] ~= value then + return true + end + end + return false +end + +exports.getInObject = function(object: Object, path: Array): any + return Array.reduce(path, function(reduced: Object, attr: any): any + 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] :: number + + if object ~= nil then + local parent = exports.getInObject(object :: 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 :: Object, Array.slice(oldPath, 1, length)) + + if parent then + local lastOld = oldPath[length] :: number + local lastNew = newPath[length] :: number + + 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 :: 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 + -- ROBLOX deviation: null is nil in Luau + return "nil" + -- 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) + -- ROBLOX deviation: no bigint in Luau + -- if type_ == "bigint" then + -- return "bigint" + if 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 + -- ROBLOX deviation: no object type in Luau + -- elseif type_ == "object" then + elseif type_ == "table" 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 "table" + 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_ = if element then element.type else 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: number?) + length = length or MAX_PREVIEW_STRING_LENGTH + + if string.len(string_) > (length :: number) then + return string.sub(string_, 1, (length :: number) + 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: any, showFormattedValue: boolean): string + -- UIBLOX-2077: This is a cache for performance but relies on adding data + -- onto the object, but in Luau we can't add fields to any old object. + -- Investigate this later. + + -- if 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 string.format("<%s />", truncateForDisplay(string.lower(data.tagName))) + elseif type_ == "function" then + -- ROBLOX deviation: use debug.info to get the function name + local functionName = debug.info(data, "n") + return truncateForDisplay(`ƒ {functionName}()`) + elseif type_ == "string" then + return string.format('"%s"', tostring(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 string.format( + "<%s />", + truncateForDisplay(exports.getDisplayNameForReactElement(data) or "Unknown") + ) + -- elseif type_ == 'array_buffer' then + -- elseif type_ == 'data_view' then + elseif type_ == "array" then + local array: Array = data :: any + if showFormattedValue then + local formatted = "" + for i = 1, #array do + if i > 1 then + formatted ..= ", " + end + formatted = formatted .. exports.formatDataForPreview(array[i], false) + if string.len(formatted) > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return string.format("[%s]", truncateForDisplay(formatted)) + else + return `Array({#array})` + end + -- ROBLOX 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_ == "table" 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] :: string + if i > 1 then + formatted = formatted .. ", " + end + formatted = formatted + .. string.format( + "%s: %s", + tostring(key), + exports.formatDataForPreview(data[key], false) + ) + if string.len(formatted) > MAX_PREVIEW_STRING_LENGTH then + -- Prevent doing a lot of unnecessary iteration... + break + end + end + return string.format("{%s}", 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 tostring(data) + else + local ok, result = pcall(truncateForDisplay, "" .. tostring(data)) + return if ok then result else "unserializable" + end +end + +return exports diff --git a/modules/react-devtools-timeline/.luaurc b/modules/react-devtools-timeline/.luaurc new file mode 100644 index 00000000..89bffcfa --- /dev/null +++ b/modules/react-devtools-timeline/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} \ No newline at end of file diff --git a/modules/react-devtools-timeline/.robloxrc b/modules/react-devtools-timeline/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-devtools-timeline/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-devtools-timeline/default.project.json b/modules/react-devtools-timeline/default.project.json new file mode 100644 index 00000000..2a867143 --- /dev/null +++ b/modules/react-devtools-timeline/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtoolsTimeline", + "tree": { + "$path": "src" + } +} \ No newline at end of file diff --git a/modules/react-devtools-timeline/rotriever.toml b/modules/react-devtools-timeline/rotriever.toml new file mode 100644 index 00000000..cb5424ff --- /dev/null +++ b/modules/react-devtools-timeline/rotriever.toml @@ -0,0 +1,7 @@ +[package] +name = "ReactDevtoolsTimeline" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" +files = ["*", "!**/__tests__/**"] diff --git a/modules/react-devtools-timeline/src/constants.lua b/modules/react-devtools-timeline/src/constants.lua new file mode 100644 index 00000000..8e3b52e3 --- /dev/null +++ b/modules/react-devtools-timeline/src/constants.lua @@ -0,0 +1,12 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v19.1.1/packages/react-devtools-timeline/src/constants.js + +local Constants = {} + +local REACT_TOTAL_NUM_LANES = 31 +Constants.REACT_TOTAL_NUM_LANES = REACT_TOTAL_NUM_LANES + +-- Increment this number any time a backwards breaking change is made to the profiler metadata. +local SCHEDULING_PROFILER_VERSION = 1 +Constants.SCHEDULING_PROFILER_VERSION = SCHEDULING_PROFILER_VERSION + +return table.freeze(Constants) diff --git a/modules/react-devtools-timeline/src/init.lua b/modules/react-devtools-timeline/src/init.lua new file mode 100644 index 00000000..cebe523b --- /dev/null +++ b/modules/react-devtools-timeline/src/init.lua @@ -0,0 +1,37 @@ +-- ROBLOX note: no upstream + +local exports = {} + +local constants = require(script.constants) +exports.REACT_TOTAL_NUM_LANES = constants.REACT_TOTAL_NUM_LANES +exports.SCHEDULING_PROFILER_VERSION = constants.SCHEDULING_PROFILER_VERSION + +local types = require(script.types) +export type ScrollState = types.ScrollState +export type ErrorStackFrame = types.ErrorStackFrame +export type Milliseconds = types.Milliseconds +export type ReactLane = types.ReactLane +export type NativeEvent = types.NativeEvent +export type ReactScheduleRenderEvent = types.ReactScheduleRenderEvent +export type ReactScheduleStateUpdateEvent = types.ReactScheduleStateUpdateEvent +export type ReactScheduleForceUpdateEvent = types.ReactScheduleForceUpdateEvent +export type Phase = types.Phase +export type SuspenseEvent = types.SuspenseEvent +export type ThrownError = types.ThrownError +export type SchedulingEvent = types.SchedulingEvent +export type SchedulingEventType = types.SchedulingEventType +export type ReactMeasureType = types.ReactMeasureType +export type BatchUID = types.BatchUID +export type ReactMeasure = types.ReactMeasure +export type NetworkMeasure = types.NetworkMeasure +export type ReactComponentMeasureType = types.ReactComponentMeasureType +export type ReactComponentMeasure = types.ReactComponentMeasure +export type FlamechartStackFrame = types.FlamechartStackFrame +export type UserTimingMark = types.UserTimingMark +export type Snapshot = types.Snapshot +export type FlamechartStackLayer = types.FlamechartStackLayer +export type Flamechart = types.Flamechart +export type HorizontalScrollStateChangeCallback = types.HorizontalScrollStateChangeCallback +export type SearchRegExpStateChangeCallback = types.SearchRegExpStateChangeCallback + +return table.freeze(exports) diff --git a/modules/react-devtools-timeline/src/types.lua b/modules/react-devtools-timeline/src/types.lua new file mode 100644 index 00000000..013888b9 --- /dev/null +++ b/modules/react-devtools-timeline/src/types.lua @@ -0,0 +1,164 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/v19.1.1/packages/react-devtools-timeline/src/types.js + +type null = nil +type RegExp = any +type Image = any + +-- deviation: inline ScrollState import here +export type ScrollState = { + offset: number, + length: number, +} + +export type ErrorStackFrame = { + fileName: string, + lineNumber: number, + columnNumber: number, +} + +export type Milliseconds = number + +export type ReactLane = number + +export type NativeEvent = { + depth: number, + duration: Milliseconds, + timestamp: Milliseconds, + type: string, + warning: string | null, +} + +type BaseReactEvent = { + componentName: string?, + timestamp: Milliseconds, + warning: string | null, +} + +type BaseReactScheduleEvent = BaseReactEvent & { + lanes: { ReactLane }, +} +export type ReactScheduleRenderEvent = BaseReactScheduleEvent & { + type: "schedule-render", +} +export type ReactScheduleStateUpdateEvent = BaseReactScheduleEvent & { + componentStack: string?, + type: "schedule-state-update", +} +export type ReactScheduleForceUpdateEvent = BaseReactScheduleEvent & { + type: "schedule-force-update", +} + +export type Phase = "mount" | "update" + +export type SuspenseEvent = BaseReactEvent & { + depth: number, + duration: number | null, + id: string, + phase: Phase | null, + promiseName: string | null, + resolution: "rejected" | "resolved" | "unresolved", + type: "suspense", +} + +export type ThrownError = { + componentName: string?, + message: string, + phase: Phase, + timestamp: Milliseconds, + type: "thrown-error", +} + +export type SchedulingEvent = + ReactScheduleRenderEvent + | ReactScheduleStateUpdateEvent + | ReactScheduleForceUpdateEvent +export type SchedulingEventType = + "schedule-render" + | "schedule-state-update" + | "schedule-force-update" + +export type ReactMeasureType = + "commit" + -- render-idle: A measure spanning the time when a render starts, through all + -- yields and restarts, and ends when commit stops OR render is cancelled. + | "render-idle" + | "render" + | "layout-effects" + | "passive-effects" + +export type BatchUID = number + +export type ReactMeasure = { + type: ReactMeasureType, + lanes: { ReactLane }, + timestamp: Milliseconds, + duration: Milliseconds, + batchUID: BatchUID, + depth: number, +} + +export type NetworkMeasure = { + depth: number, + finishTimestamp: Milliseconds, + firstReceivedDataTimestamp: Milliseconds, + lastReceivedDataTimestamp: Milliseconds, + priority: string, + receiveResponseTimestamp: Milliseconds, + requestId: string, + requestMethod: string, + sendRequestTimestamp: Milliseconds, + url: string, +} + +export type ReactComponentMeasureType = + "render" + | "layout-effect-mount" + | "layout-effect-unmount" + | "passive-effect-mount" + | "passive-effect-unmount" + +export type ReactComponentMeasure = { + componentName: string, + duration: Milliseconds, + timestamp: Milliseconds, + type: ReactComponentMeasureType, + warning: string | null, +} + +--[[ + A flamechart stack frame belonging to a stack trace. +]] +export type FlamechartStackFrame = { + name: string, + timestamp: Milliseconds, + duration: Milliseconds, + scriptUrl: string?, + locationLine: number?, + locationColumn: number?, +} + +export type UserTimingMark = { + name: string, + timestamp: Milliseconds, +} + +export type Snapshot = { + height: number, + image: Image | null, + imageSource: string, + timestamp: Milliseconds, + width: number, +} + +--[[ + A "layer" of stack frames in the profiler UI, i.e. all stack frames of the + same depth across all stack traces. Displayed as a flamechart row in the UI. +]] +export type FlamechartStackLayer = { FlamechartStackFrame } + +export type Flamechart = { FlamechartStackLayer } + +export type HorizontalScrollStateChangeCallback = (scrollState: ScrollState) -> () +export type SearchRegExpStateChangeCallback = (searchRegExp: RegExp | null) -> () + +return nil diff --git a/modules/react-devtools/.luaurc b/modules/react-devtools/.luaurc new file mode 100644 index 00000000..89bffcfa --- /dev/null +++ b/modules/react-devtools/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} \ No newline at end of file diff --git a/modules/react-devtools/.robloxrc b/modules/react-devtools/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-devtools/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-devtools/default.project.json b/modules/react-devtools/default.project.json new file mode 100644 index 00000000..f02f9d13 --- /dev/null +++ b/modules/react-devtools/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactDevtools", + "tree": { + "$path": "src" + } +} diff --git a/modules/react-devtools/rotriever.toml b/modules/react-devtools/rotriever.toml new file mode 100644 index 00000000..f95d0319 --- /dev/null +++ b/modules/react-devtools/rotriever.toml @@ -0,0 +1,10 @@ +[package] +name = "ReactDevtools" +version = { workspace = true } +authors = { workspace = true } +publish = true +content_root = "src" +files = ["*", "!**/__tests__/**"] + +[dependencies] +ReactDevtoolsCore = { path = "../react-devtools-core" } diff --git a/modules/react-devtools/src/init.lua b/modules/react-devtools/src/init.lua new file mode 100644 index 00000000..08e4bbbd --- /dev/null +++ b/modules/react-devtools/src/init.lua @@ -0,0 +1,10 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-devtools/index.js + +local Packages = script.Parent +local ReactDevtoolsCore = require(Packages.ReactDevtoolsCore) + +local connectToDevtools = ReactDevtoolsCore.backend.connectToDevtools + +-- Connect immediately with default options. +-- If you need more control, use `react-devtools-core` directly instead of `react-devtools`. +connectToDevtools() diff --git a/modules/react-globals/.robloxrc b/modules/react-globals/.robloxrc new file mode 100644 index 00000000..8f779576 --- /dev/null +++ b/modules/react-globals/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "strict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-globals/README.md b/modules/react-globals/README.md new file mode 100644 index 00000000..1ebb7aae --- /dev/null +++ b/modules/react-globals/README.md @@ -0,0 +1,3 @@ +# react-globals + +This package is unique to react-lua. It houses React's global environment variables in a way that can be scoped to each copy of React running inside the application. diff --git a/modules/react-globals/default.project.json b/modules/react-globals/default.project.json new file mode 100644 index 00000000..a7dafeac --- /dev/null +++ b/modules/react-globals/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "ReactGlobals", + "tree": { + "$path": "src" + } +} \ No newline at end of file diff --git a/modules/roblox-jest/rotriever.toml b/modules/react-globals/rotriever.toml similarity index 50% rename from modules/roblox-jest/rotriever.toml rename to modules/react-globals/rotriever.toml index 7850a3e5..9b17d597 100644 --- a/modules/roblox-jest/rotriever.toml +++ b/modules/react-globals/rotriever.toml @@ -1,12 +1,12 @@ [package] -name = "RobloxJest" +name = "ReactGlobals" version = { workspace = true } authors = { workspace = true } -publish = false content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] -LuauPolyfill = { workspace = true } +SafeFlags = { workspace = true } [dev_dependencies] -JestRoblox = { workspace = true } +JestGlobals = { workspace = true } diff --git a/modules/react-globals/src/ReactGlobals.global.lua b/modules/react-globals/src/ReactGlobals.global.lua new file mode 100644 index 00000000..9e57b5d2 --- /dev/null +++ b/modules/react-globals/src/ReactGlobals.global.lua @@ -0,0 +1,99 @@ +-- NOTE: No upstream + +--[[ + Defines all React globals in a type-safe manner that's scoped to each + copy of React. For backwards compatibility, this module will load the + initial values for each flag from _G. However, this is considered legacy + behavior and new code should import this package and set the flags directly. + + ## Globals Reference + + General debug-related flags: + __DEV__ + __PROFILE__ + __EXPERIMENTAL__ + __DEBUG__ + __YOLO__ + __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ + + Devtools flags: + __REACT_DEVTOOLS_GLOBAL_HOOK__ + __REACT_DEVTOOLS_ATTACH__ + __REACT_DEVTOOLS_COMPONENT_FILTERS__ + __REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ + __REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ + __LOCALSTORAGE__ + __SESSIONSTORAGE__ + + Misc flags: + __COMPAT_WARNINGS__ + __TESTEZ_RUNNING_TEST__ + __ROACT_17_MOCK_SCHEDULER__ + __ROACT_17_INLINE_ACT__ +]] + +local Packages = script.Parent.Parent +local SafeFlags = require(Packages.SafeFlags) + +local FFlagReactIsolatedGlobalsEnabled = + SafeFlags.createGetFFlag("ReactIsolatedGlobalsEnabled")() + +if not FFlagReactIsolatedGlobalsEnabled then + return _G :: ReactGlobals +end + +type DevToolsHook = any +type DevToolsAttach = any +type DevToolsComponentFilters = { any } + +local function loadFromGlobal(globalValueKey: string, defaultValue: T): T + local globalValue = _G[globalValueKey] + if globalValue ~= nil then + return globalValue + end + return defaultValue +end + +local ReactGlobals = { + __DEV__ = loadFromGlobal("__DEV__", false), + __PROFILE__ = loadFromGlobal("__PROFILE__", false), + __EXPERIMENTAL__ = loadFromGlobal("__EXPERIMENTAL__", false), + __DEBUG__ = loadFromGlobal("__DEBUG__", false), + __YOLO__ = loadFromGlobal("__YOLO__", false), + __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ = loadFromGlobal( + "__DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__", + false + ), + + __REACT_DEVTOOLS_GLOBAL_HOOK__ = loadFromGlobal( + "__REACT_DEVTOOLS_GLOBAL_HOOK__", + nil :: DevToolsHook? + ), + __REACT_DEVTOOLS_ATTACH__ = loadFromGlobal( + "__REACT_DEVTOOLS_ATTACH__", + nil :: DevToolsAttach? + ), + __REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = loadFromGlobal( + "__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__", + false + ), + __REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = loadFromGlobal( + "__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__", + false + ), + __REACT_DEVTOOLS_COMPONENT_FILTERS__ = loadFromGlobal( + "__REACT_DEVTOOLS_COMPONENT_FILTERS__", + nil :: DevToolsComponentFilters? + ), + __LOCALSTORAGE__ = loadFromGlobal("__LOCALSTORAGE__", nil :: any?), + __SESSIONSTORAGE__ = loadFromGlobal("__SESSIONSTORAGE__", nil :: any?), + + __COMPAT_WARNINGS__ = loadFromGlobal("__COMPAT_WARNINGS__", false), + __TESTEZ_RUNNING_TEST__ = loadFromGlobal("__TESTEZ_RUNNING_TEST__", false), + __ROACT_17_MOCK_SCHEDULER__ = loadFromGlobal("__ROACT_17_MOCK_SCHEDULER__", false), + __ROACT_17_INLINE_ACT__ = loadFromGlobal("__ROACT_17_INLINE_ACT__", false), +} + +export type ReactGlobals = typeof(ReactGlobals) + +return ReactGlobals diff --git a/modules/react-globals/src/init.lua b/modules/react-globals/src/init.lua new file mode 100644 index 00000000..8f82d39c --- /dev/null +++ b/modules/react-globals/src/init.lua @@ -0,0 +1,3 @@ +local ReactGlobals = require(script["ReactGlobals.global"]) + +return ReactGlobals diff --git a/modules/react-is/.robloxrc b/modules/react-is/.robloxrc new file mode 100644 index 00000000..b0fb3562 --- /dev/null +++ b/modules/react-is/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "nonstrict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-is/rotriever.toml b/modules/react-is/rotriever.toml index dd51cb9f..6ddd1319 100644 --- a/modules/react-is/rotriever.toml +++ b/modules/react-is/rotriever.toml @@ -4,12 +4,16 @@ version = { workspace = true } authors = { workspace = true } publish = true content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] +ReactGlobals = { path = "../react-globals" } +SafeFlags = { workspace = true } Shared = { path = "../shared" } [dev_dependencies] -JestRoblox = { workspace = true } -RobloxJest = { path = "../roblox-jest" } +JestGlobals = { workspace = true } +LuauPolyfill = { workspace = true } +Promise = { workspace = true } React = { path = "../react" } ReactRoblox = { path = "../react-roblox" } diff --git a/modules/react-is/src/__tests__/ReactIs.spec.lua b/modules/react-is/src/__tests__/ReactIs.spec.lua index 542ca4c0..6af35068 100644 --- a/modules/react-is/src/__tests__/ReactIs.spec.lua +++ b/modules/react-is/src/__tests__/ReactIs.spec.lua @@ -1,222 +1,315 @@ --- upstream: https://github.com/facebook/react/blob/c57fe4a2c1402acdbf31ac48cfc6a6bf336c4067/react-is/src/__tests__/ReactIs-test.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-is/src/__tests__/ReactIs-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. + * + * @emails react-core + ]] +local Packages = script.Parent.Parent.Parent +-- ROBLOX deviation START: fix import +-- local LuauPolyfill = require(Packages.LuauPolyfill) +local LuauPolyfill = require(Packages.Dev.LuauPolyfill) +-- ROBLOX deviation END +-- ROBLOX deviation START: not used +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +local Object = LuauPolyfill.Object +local JestGlobals = require(Packages.Dev.JestGlobals) +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it +local jest = JestGlobals.jest -return function() - local Packages = script.Parent.Parent.Parent - local jestModule = require(Packages.Dev.JestRoblox) - local jestExpect = jestModule.Globals.expect - local jest = jestModule.Globals.jest - local RobloxJest = require(Packages.Dev.RobloxJest) - local React - local ReactIs - local ReactRoblox - local ReactFeatureFlags - - describe("ReactIs", function() - beforeEach(function() - RobloxJest.resetModules() - ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - - React = require(Packages.Dev.React) - ReactIs = require(Packages.ReactIs) - ReactRoblox = require(Packages.Dev.ReactRoblox) - end) - - it("should return nil for unknown/invalid types", function() - jestExpect(ReactIs.typeOf("abc")).toBe(nil) - jestExpect(ReactIs.typeOf(true)).toBe(nil) - jestExpect(ReactIs.typeOf(123)).toBe(nil) - jestExpect(ReactIs.typeOf({})).toBe(nil) - jestExpect(ReactIs.typeOf(nil)).toBe(nil) - -- ROBLOX deviation: no undefined in Lua, we only support nil - -- expect(ReactIs.typeOf(undefined)).toBe(undefined) - end) - - it("identifies valid element types", function() - local Component = React.Component:extend("MyComponent") - Component.render = function() - return React.createElement("TextLabel") - end - - local PureComponent = React.PureComponent:extend("MyPureComponent") - PureComponent.render = function() - return React.createElement("TextLabel") - end - - local FunctionComponent = function() - return React.createElement("TextLabel") - end - - local ForwardRefComponent = React.forwardRef(function(_props, ref) - React.createElement(Component, { forwardedRef = ref }) - end) - - local LazyComponent = React.lazy(function() - return Component - end) - local MemoComponent = React.memo(Component) - local Context = React.createContext(false) - - jestExpect(ReactIs.isValidElementType("div")).toEqual(true) - jestExpect(ReactIs.isValidElementType(Component)).toEqual(true) - jestExpect(ReactIs.isValidElementType(PureComponent)).toEqual(true) - jestExpect(ReactIs.isValidElementType(FunctionComponent)).toEqual(true) - jestExpect(ReactIs.isValidElementType(ForwardRefComponent)).toEqual(true) - jestExpect(ReactIs.isValidElementType(LazyComponent)).toEqual(true) - jestExpect(ReactIs.isValidElementType(MemoComponent)).toEqual(true) - jestExpect(ReactIs.isValidElementType(Context.Provider)).toEqual(true) - jestExpect(ReactIs.isValidElementType(Context.Consumer)).toEqual(true) - -- ROBLOX deviation: we don't support things that are already deprecated - --if (!__EXPERIMENTAL__) { - --let factory - --jestExpect(() => { - --factory = React.createFactory('TextLabel') - --}).toWarnDev( - --'Warning: React.createFactory() is deprecated and will be removed in a ' + - --'future major release. Consider using JSX or use React.createElement() ' + - --'directly instead.', - --{withoutStack: true}, - --) - --jestExpect(ReactIs.isValidElementType(factory)).toEqual(true) - --} - jestExpect(ReactIs.isValidElementType(React.Fragment)).toEqual(true) - jestExpect(ReactIs.isValidElementType(React.StrictMode)).toEqual(true) - jestExpect(ReactIs.isValidElementType(React.Suspense)).toEqual(true) - jestExpect(ReactIs.isValidElementType(true)).toEqual(false) - jestExpect(ReactIs.isValidElementType(123)).toEqual(false) - jestExpect(ReactIs.isValidElementType({})).toEqual(false) - 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) - 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.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.isContextProvider(React.createElement("div"))).toBe(false) - end) - - it("should identify elements", function() - jestExpect(ReactIs.typeOf(React.createElement("div"))).toBe(ReactIs.Element) - jestExpect(ReactIs.isElement(React.createElement("div"))).toBe(true) - jestExpect(ReactIs.isElement("div")).toBe(false) - jestExpect(ReactIs.isElement(true)).toBe(false) - jestExpect(ReactIs.isElement(123)).toBe(false) - jestExpect(ReactIs.isElement(nil)).toBe(false) - -- ROBLOX deviation: no difference between nil and undefined in Lua - -- expect(ReactIs.isElement(undefined)).toBe(false) - jestExpect(ReactIs.isElement({})).toBe(false) +-- ROBLOX deviation START: add imports +local Promise = require(Packages.Dev.Promise) +local ReactTypes = require(Packages.Shared) +type React_Component = ReactTypes.React_Component +-- ROBLOX deviation END +local React +local ReactDOM +local ReactIs +describe("ReactIs", function() + beforeEach(function() + jest.resetModules() + -- ROBLOX deviation START: additional flag to switch for tests + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false + -- ROBLOX deviation END + -- ROBLOX deviation START: fix imports + -- React = require_("react") + -- ReactDOM = require_("react-dom") + -- ReactIs = require_("react-is") + React = require(Packages.Dev.React) + ReactIs = require(Packages.ReactIs) + ReactDOM = require(Packages.Dev.ReactRoblox) + -- ROBLOX deviation END + end) + it("should return undefined for unknown/invalid types", function() + expect(ReactIs.typeOf("abc")).toBe(nil) + expect(ReactIs.typeOf(true)).toBe(nil) + expect(ReactIs.typeOf(123)).toBe(nil) + expect(ReactIs.typeOf({})).toBe(nil) + expect(ReactIs.typeOf(nil)).toBe(nil) + -- ROBLOX deviation START: no undefined in Lua, we only support nil + -- expect(ReactIs.typeOf(nil)).toBe(nil) + -- ROBLOX deviation END + end) + it("identifies valid element types", function() + type Component = React_Component & {} + type Component_statics = {} + local Component = + React.Component:extend("Component") :: Component & Component_statics + function Component.render(self: Component) + -- ROBLOX deviation START: replace div with TextLabel + -- return React.createElement("div") + return React.createElement("TextLabel") + -- ROBLOX deviation END + end - -- 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(React.Fragment))).toBe(true) - jestExpect(ReactIs.isElement(React.createElement(React.StrictMode))).toBe(true) - jestExpect(ReactIs.isElement(React.createElement(React.Suspense))).toBe(true) + local function FunctionComponent() + -- ROBLOX deviation START: replace div with TextLabel + -- return React.createElement("div") + return React.createElement("TextLabel") + -- ROBLOX deviation END + end + local ForwardRefComponent = React.forwardRef(function(props, ref) + return React.createElement( + Component, + Object.assign({}, { forwardedRef = ref }, props) + ) end) - - it("should identify ref forwarding component", function() - local RefForwardingComponent = React.forwardRef(function(props, ref) - return nil + -- ROBLOX TODO: this is incorrect in upstream + -- ROBLOX note: Lazy will need deeper adaptation for the Luau module system + -- ROBLOX deviation START: convert return type to thenable + -- local LazyComponent = React.lazy(function() + -- return Component + -- end) + local LazyComponent = React.lazy(function() + return Promise.delay(0):andThen(function() + return { default = Component } 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.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.isFragment(React.createElement(React.Fragment))).toBe(true) - jestExpect(ReactIs.isFragment({ type = ReactIs.Fragment })).toBe(false) - jestExpect(ReactIs.isFragment("React.Fragment")).toBe(false) - jestExpect(ReactIs.isFragment(React.createElement("div"))).toBe(false) - jestExpect(ReactIs.isFragment({})).toBe(false) - end) - - it("should identify portals", function() - local ScreenGui = Instance.new("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) - jestExpect(ReactIs.isPortal("Frame")).toBe(false) end) - - it("should identify memo", function() - local Component = function() - return React.createElement("div") - end - local Memoized = React.memo(Component) - jestExpect(ReactIs.isValidElementType(Memoized)).toBe(true) - jestExpect(ReactIs.typeOf(React.createElement(Memoized))).toBe(ReactIs.Memo) - jestExpect(ReactIs.isMemo(React.createElement(Memoized))).toBe(true) - jestExpect(ReactIs.isMemo(React.createElement(Component))).toBe(false) + -- ROBLOX deviation END + -- ROBLOX note: Should memo accept a LazyComponent? + local MemoComponent = React.memo(Component) + local Context = React.createContext(false) + expect(ReactIs.isValidElementType("div")).toEqual(true) + expect(ReactIs.isValidElementType(Component)).toEqual(true) + expect(ReactIs.isValidElementType(FunctionComponent)).toEqual(true) + expect(ReactIs.isValidElementType(ForwardRefComponent)).toEqual(true) + expect(ReactIs.isValidElementType(LazyComponent)).toEqual(true) + expect(ReactIs.isValidElementType(MemoComponent)).toEqual(true) + expect(ReactIs.isValidElementType(Context.Provider)).toEqual(true) + expect(ReactIs.isValidElementType(Context.Consumer)).toEqual(true) + -- ROBLOX deviation START: we don't support things that are already deprecated + -- if not Boolean.toJSBoolean(__EXPERIMENTAL__) then + -- local factory + -- expect(function() + -- factory = React.createFactory("div") + -- end).toWarnDev( + -- "Warning: React.createFactory() is deprecated and will be removed in a " + -- .. "future major release. Consider using JSX or use React.createElement() " + -- .. "directly instead.", + -- { withoutStack = true } + -- ) + -- expect(ReactIs:isValidElementType(factory)).toEqual(true) + -- end + -- ROBLOX deviation END + expect(ReactIs.isValidElementType(React.Fragment)).toEqual(true) + expect(ReactIs.isValidElementType(React.StrictMode)).toEqual(true) + expect(ReactIs.isValidElementType(React.Suspense)).toEqual(true) + expect(ReactIs.isValidElementType(true)).toEqual(false) + expect(ReactIs.isValidElementType(123)).toEqual(false) + expect(ReactIs.isValidElementType({})).toEqual(false) + expect(ReactIs.isValidElementType(nil)).toEqual(false) + -- ROBLOX deviation START: no difference between nil and undefined in Lua, skip + -- expect(ReactIs.isValidElementType(nil)).toEqual(false) + -- ROBLOX deviation END + -- ROBLOX deviation START: replace "div" with "TextLabel", use dot notation + -- expect(ReactIs.isValidElementType({ type = "div", props = {} })).toEqual(false) + expect(ReactIs.isValidElementType({ type = "TextLabel", props = {} })).toEqual( + false + ) + -- ROBLOX deviation END + end) + it("should identify context consumers", function() + local Context = React.createContext(false) + expect(ReactIs.isValidElementType(Context.Consumer)).toBe(true) + expect(ReactIs.typeOf(React.createElement(Context.Consumer, nil))).toBe( + ReactIs.ContextConsumer + ) + expect(ReactIs.isContextConsumer(React.createElement(Context.Consumer, nil))).toBe( + true + ) + expect(ReactIs.isContextConsumer(React.createElement(Context.Provider, nil))).toBe( + false + ) + expect(ReactIs.isContextConsumer(React.createElement("div", nil))).toBe(false) + end) + it("should identify context providers", function() + local Context = React.createContext(false) + expect(ReactIs.isValidElementType(Context.Provider)).toBe(true) + expect(ReactIs.typeOf(React.createElement(Context.Provider, nil))).toBe( + ReactIs.ContextProvider + ) + expect(ReactIs.isContextProvider(React.createElement(Context.Provider, nil))).toBe( + true + ) + expect(ReactIs.isContextProvider(React.createElement(Context.Consumer, nil))).toBe( + false + ) + expect(ReactIs.isContextProvider(React.createElement("div", nil))).toBe(false) + end) + it("should identify elements", function() + expect(ReactIs.typeOf(React.createElement("div", nil))).toBe(ReactIs.Element) + expect(ReactIs.isElement(React.createElement("div", nil))).toBe(true) + expect(ReactIs.isElement("div")).toBe(false) + expect(ReactIs.isElement(true)).toBe(false) + expect(ReactIs.isElement(123)).toBe(false) + expect(ReactIs.isElement(nil)).toBe(false) + -- ROBLOX deviation START: no difference between nil and undefined in Lua + -- expect(ReactIs.isElement(nil)).toBe(false) + -- ROBLOX deviation END + expect(ReactIs.isElement({})).toBe(false) -- It should also identify more specific types as elements + local Context = React.createContext(false) + expect(ReactIs.isElement(React.createElement(Context.Provider, nil))).toBe(true) + expect(ReactIs.isElement(React.createElement(Context.Consumer, nil))).toBe(true) + expect(ReactIs.isElement(React.createElement(React.Fragment, nil))).toBe(true) + expect(ReactIs.isElement(React.createElement(React.StrictMode, nil))).toBe(true) + expect(ReactIs.isElement(React.createElement(React.Suspense, nil))).toBe(true) + end) + it("should identify ref forwarding component", function() + local RefForwardingComponent = React.forwardRef(function(props, ref) + return nil end) - - it("should identify lazy", function() - local Component = function() - return React.createElement("div") - end - local LazyComponent = React.lazy(function() - return Component + expect(ReactIs.isValidElementType(RefForwardingComponent)).toBe(true) + expect(ReactIs.typeOf(React.createElement(RefForwardingComponent, nil))).toBe( + ReactIs.ForwardRef + ) + expect(ReactIs.isForwardRef(React.createElement(RefForwardingComponent, nil))).toBe( + true + ) + expect(ReactIs.isForwardRef({ type = ReactIs.StrictMode })).toBe(false) + expect(ReactIs.isForwardRef(React.createElement("div", nil))).toBe(false) + end) + it("should identify fragments", function() + expect(ReactIs.isValidElementType(React.Fragment)).toBe(true) + expect(ReactIs.typeOf(React.createElement(React.Fragment, nil))).toBe( + ReactIs.Fragment + ) + expect(ReactIs.isFragment(React.createElement(React.Fragment, nil))).toBe(true) + expect(ReactIs.isFragment({ type = ReactIs.Fragment })).toBe(false) + expect(ReactIs.isFragment("React.Fragment")).toBe(false) + expect(ReactIs.isFragment(React.createElement("div", nil))).toBe(false) + expect(ReactIs.isFragment({})).toBe(false) + end) + it("should identify portals", function() + -- ROBLOX deviation START: replace created element attachaed to DOM + -- local div = document:createElement("div") + local div = Instance.new("ScreenGui") + -- ROBLOX deviation END + -- ROBLOX deviation START: replace "div" with "Frame" + -- local portal = ReactDOM:createPortal(React.createElement("div", nil), div) + local portal = ReactDOM.createPortal(React.createElement("Frame"), div) + -- ROBLOX deviation END + expect(ReactIs.isValidElementType(portal)).toBe(false) + expect(ReactIs.typeOf(portal)).toBe(ReactIs.Portal) + expect(ReactIs.isPortal(portal)).toBe(true) + expect(ReactIs.isPortal(div)).toBe(false) + end) + it("should identify memo", function() + local function Component() + return React.createElement("div") + end + local Memoized = React.memo(Component) + expect(ReactIs.isValidElementType(Memoized)).toBe(true) + expect(ReactIs.typeOf(React.createElement(Memoized, nil))).toBe(ReactIs.Memo) + expect(ReactIs.isMemo(React.createElement(Memoized, nil))).toBe(true) + expect(ReactIs.isMemo(React.createElement(Component, nil))).toBe(false) + end) + it("should identify lazy", function() + local function Component() + return React.createElement("div") + end + -- ROBLOX TODO: this is incorrect in upstream + -- ROBLOX deviation START: return thenable + -- local LazyComponent = React.lazy(function() + -- return Component + -- end) + local LazyComponent = React.lazy(function() + return Promise.delay(0):andThen(function() + return { default = Component } end) - jestExpect(ReactIs.isValidElementType(LazyComponent)).toBe(true) - 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.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.isSuspense(React.createElement(React.Suspense))).toBe(true) - jestExpect(ReactIs.isSuspense({ type = ReactIs.Suspense })).toBe(false) - jestExpect(ReactIs.isSuspense("React.Suspense")).toBe(false) - jestExpect(ReactIs.isSuspense(React.createElement("div"))).toBe(false) - end) - - 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.isProfiler({ type = ReactIs.Profiler })).toBe(false) - jestExpect(ReactIs.isProfiler(React.createElement("div"))).toBe(false) - end) - - -- ROBLOX deviation: added this test to cover deprecation warning - it("should warn for deprecated functions", function() - jestExpect(function() - ReactIs.isConcurrentMode(nil) - end).toWarnDev('deprecated', {withoutStack = true}) - - jestExpect(function() - ReactIs.isAsyncMode(nil) - end).toWarnDev('deprecated', {withoutStack = true}) end) + -- ROBLOX deviation END + expect(ReactIs.isValidElementType(LazyComponent)).toBe(true) + expect(ReactIs.typeOf(React.createElement(LazyComponent, nil))).toBe(ReactIs.Lazy) + expect(ReactIs.isLazy(React.createElement(LazyComponent, nil))).toBe(true) + expect(ReactIs.isLazy(React.createElement(Component, nil))).toBe(false) + end) + it("should identify strict mode", function() + expect(ReactIs.isValidElementType(React.StrictMode)).toBe(true) + expect(ReactIs.typeOf(React.createElement(React.StrictMode, nil))).toBe( + ReactIs.StrictMode + ) + expect(ReactIs.isStrictMode(React.createElement(React.StrictMode, nil))).toBe( + true + ) + expect(ReactIs.isStrictMode({ type = ReactIs.StrictMode })).toBe(false) + expect(ReactIs.isStrictMode(React.createElement("div", nil))).toBe(false) + end) + it("should identify suspense", function() + expect(ReactIs.isValidElementType(React.Suspense)).toBe(true) + expect(ReactIs.typeOf(React.createElement(React.Suspense, nil))).toBe( + ReactIs.Suspense + ) + expect(ReactIs.isSuspense(React.createElement(React.Suspense, nil))).toBe(true) + expect(ReactIs.isSuspense({ type = ReactIs.Suspense })).toBe(false) + expect(ReactIs.isSuspense("React.Suspense")).toBe(false) + expect(ReactIs.isSuspense(React.createElement("div", nil))).toBe(false) + end) + it("should identify profile root", function() + expect(ReactIs.isValidElementType(React.Profiler)).toBe(true) + expect( + ReactIs.typeOf( + React.createElement(React.Profiler, { id = "foo", onRender = jest.fn() }) + ) + ).toBe(ReactIs.Profiler) + expect( + ReactIs.isProfiler( + React.createElement(React.Profiler, { id = "foo", onRender = jest.fn() }) + ) + ).toBe(true) + expect(ReactIs.isProfiler({ type = ReactIs.Profiler })).toBe(false) + expect(ReactIs.isProfiler(React.createElement("div", nil))).toBe(false) + end) + -- ROBLOX deviation START: added this test to cover deprecation warning + it("should warn for deprecated functions", function() + expect(function() + ReactIs.isConcurrentMode(nil) + end).toWarnDev("deprecated", { withoutStack = true }) + expect(function() + ReactIs.isAsyncMode(nil) + end).toWarnDev("deprecated", { withoutStack = true }) + end) + -- ROBLOX deviation END + -- ROBLOX deviation START: add Roblox specific tests - bindings are a feature migrated from Roact + it("should identify bindings", function() + local binding, _ = React.createBinding(nil) + expect(ReactIs.isBinding(binding)).toBe(true) + local mappedBinding = React.createBinding(nil):map(tostring) + expect(ReactIs.isBinding(mappedBinding)).toBe(true) + local joinedBinding = React.joinBindings({ + X = React.createBinding(0), + Y = React.createBinding(0), + }) + expect(ReactIs.isBinding(joinedBinding)).toBe(true) + -- In Roact 17, `ref` objects are implemented in terms of bindings! + expect(ReactIs.isBinding(React.createRef())).toBe(true) end) -end + -- ROBLOX deviation END +end) diff --git a/modules/react-is/src/init.lua b/modules/react-is/src/init.lua index d95b85ee..b08c80c8 100644 --- a/modules/react-is/src/init.lua +++ b/modules/react-is/src/init.lua @@ -1,133 +1,268 @@ --- upstream: https://github.com/facebook/react/blob/60ba723bf78b9a28f60dce854e88e206fab52301/packages/react-is/src/ReactIs.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-is/src/ReactIs.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 --- ROBLOX: use patched console from shared +local ReactGlobals = require(Packages.ReactGlobals) +-- ROBLOX deviation START: not used +-- local LuauPolyfill = require(Packages.LuauPolyfill) +-- local Boolean = LuauPolyfill.Boolean +-- ROBLOX deviation END +-- ROBLOX deviation START: use patched console from shared +-- local console = LuauPolyfill.console local console = require(Packages.Shared).console -local ReactSymbols = require(Packages.Shared).ReactSymbols +-- ROBLOX deviation END +local exports = {} +-- ROBLOX deviation START: fix import +-- local sharedReactSymbolsModule = require(Packages.shared.ReactSymbols) +local sharedReactSymbolsModule = require(Packages.Shared).ReactSymbols +-- ROBLOX deviation END +local REACT_CONTEXT_TYPE = sharedReactSymbolsModule.REACT_CONTEXT_TYPE +local REACT_ELEMENT_TYPE = sharedReactSymbolsModule.REACT_ELEMENT_TYPE +local REACT_FORWARD_REF_TYPE = sharedReactSymbolsModule.REACT_FORWARD_REF_TYPE +local REACT_FRAGMENT_TYPE = sharedReactSymbolsModule.REACT_FRAGMENT_TYPE +local REACT_LAZY_TYPE = sharedReactSymbolsModule.REACT_LAZY_TYPE +local REACT_MEMO_TYPE = sharedReactSymbolsModule.REACT_MEMO_TYPE +local REACT_PORTAL_TYPE = sharedReactSymbolsModule.REACT_PORTAL_TYPE +local REACT_PROFILER_TYPE = sharedReactSymbolsModule.REACT_PROFILER_TYPE +local REACT_PROVIDER_TYPE = sharedReactSymbolsModule.REACT_PROVIDER_TYPE +local REACT_STRICT_MODE_TYPE = sharedReactSymbolsModule.REACT_STRICT_MODE_TYPE +local REACT_SUSPENSE_TYPE = sharedReactSymbolsModule.REACT_SUSPENSE_TYPE +local REACT_SUSPENSE_LIST_TYPE = sharedReactSymbolsModule.REACT_SUSPENSE_LIST_TYPE +-- ROBLOX deviation START: fix import +-- local isValidElementType = require(Packages.shared.isValidElementType).default local isValidElementType = require(Packages.Shared).isValidElementType +-- ROBLOX deviation END +-- ROBLOX deviation START: additional imports +local REACT_BINDING_TYPE = sharedReactSymbolsModule.REACT_BINDING_TYPE +-- ROBLOX deviation END -local exports = {} +local SafeFlags = require(Packages.SafeFlags) +local FFlagReactIsProtectedTypeOf = + SafeFlags.createGetFFlag("ReactIsProtectedTypeOf", false)() -exports.typeOf = function(object) - if typeof(object) == 'table' and object ~= nil then - local __typeof = object['$$typeof'] +local function typeOf(object: any) + if typeof(object) == "table" and object ~= nil then + local __typeof: any + if FFlagReactIsProtectedTypeOf then + -- ROBLOX note: The pcall is necessary because some tables we might + -- index are wrapped in a metatable that throws if you try to index + -- a bad property. + local couldIndex + couldIndex, __typeof = pcall(function() + return object["$$typeof"] + end) - if __typeof == ReactSymbols.REACT_ELEMENT_TYPE then + if not couldIndex then + return nil + end + else + __typeof = object["$$typeof"] + end + -- ROBLOX deviation START: simplified switch statement conversion, adds Binding type check + -- repeat --[[ ROBLOX comment: switch statement conversion ]] + -- local entered_, break_ = false, false + -- local condition_ = __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- for _, v in ipairs({ REACT_ELEMENT_TYPE, REACT_PORTAL_TYPE }) do + -- if condition_ == v then + -- if v == REACT_ELEMENT_TYPE then + -- entered_ = true + -- local type_ = object.type + -- local condition_ = type_ + -- if + -- condition_ == REACT_FRAGMENT_TYPE + -- or condition_ == REACT_PROFILER_TYPE + -- or condition_ == REACT_STRICT_MODE_TYPE + -- or condition_ == REACT_SUSPENSE_TYPE + -- or condition_ == REACT_SUSPENSE_LIST_TYPE + -- then + -- return type_ + -- else + -- local __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] = if Boolean.toJSBoolean( + -- type_ + -- ) + -- then type_["$$typeof"] + -- else type_ + -- local condition_ = __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] + -- if + -- condition_ == REACT_CONTEXT_TYPE + -- or condition_ == REACT_FORWARD_REF_TYPE + -- or condition_ == REACT_LAZY_TYPE + -- or condition_ == REACT_MEMO_TYPE + -- or condition_ == REACT_PROVIDER_TYPE + -- then + -- return __typeofType --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeofType ]] + -- else + -- return __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- end + -- end + -- end + -- if v == REACT_PORTAL_TYPE or entered_ then + -- entered_ = true + -- return __typeof --[[ ROBLOX CHECK: replaced unhandled characters in identifier. Original identifier: $$typeof ]] + -- end + -- end + -- end + -- until true + if __typeof == 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 == REACT_FRAGMENT_TYPE + or __type == REACT_PROFILER_TYPE + or __type == REACT_STRICT_MODE_TYPE + or __type == REACT_SUSPENSE_TYPE + or __type == REACT_SUSPENSE_LIST_TYPE then return __type else - -- deviation: We need to check that __type is a table before we + -- ROBLOX note: 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 == REACT_CONTEXT_TYPE + or __typeofType == REACT_FORWARD_REF_TYPE + or __typeofType == REACT_LAZY_TYPE + or __typeofType == REACT_MEMO_TYPE + or __typeofType == REACT_PROVIDER_TYPE then return __typeofType else return __typeof end end - elseif __typeof == ReactSymbols.REACT_PORTAL_TYPE then + elseif + __typeof == REACT_PORTAL_TYPE + -- ROBLOX note: Bindings are a feature migrated from Roact + or __typeof == REACT_BINDING_TYPE + then return __typeof end + -- ROBLOX deviation END end - return nil end - -exports.ContextConsumer = ReactSymbols.REACT_CONTEXT_TYPE -exports.ContextProvider = ReactSymbols.REACT_PROVIDER_TYPE -exports.Element = ReactSymbols.REACT_ELEMENT_TYPE -exports.ForwardRef = ReactSymbols.REACT_FORWARD_REF_TYPE -exports.Fragment = ReactSymbols.REACT_FRAGMENT_TYPE -exports.Lazy = ReactSymbols.REACT_LAZY_TYPE -exports.Memo = ReactSymbols.REACT_MEMO_TYPE -exports.Portal = ReactSymbols.REACT_PORTAL_TYPE -exports.Profiler = ReactSymbols.REACT_PROFILER_TYPE -exports.StrictMode = ReactSymbols.REACT_STRICT_MODE_TYPE -exports.Suspense = ReactSymbols.REACT_SUSPENSE_TYPE +exports.typeOf = typeOf +local ContextConsumer = REACT_CONTEXT_TYPE +exports.ContextConsumer = ContextConsumer +local ContextProvider = REACT_PROVIDER_TYPE +exports.ContextProvider = ContextProvider +local Element = REACT_ELEMENT_TYPE +exports.Element = Element +local ForwardRef = REACT_FORWARD_REF_TYPE +exports.ForwardRef = ForwardRef +local Fragment = REACT_FRAGMENT_TYPE +exports.Fragment = Fragment +local Lazy = REACT_LAZY_TYPE +exports.Lazy = Lazy +local Memo = REACT_MEMO_TYPE +exports.Memo = Memo +local Portal = REACT_PORTAL_TYPE +exports.Portal = Portal +local Profiler = REACT_PROFILER_TYPE +exports.Profiler = Profiler +local StrictMode = REACT_STRICT_MODE_TYPE +exports.StrictMode = StrictMode +local Suspense = REACT_SUSPENSE_TYPE +exports.Suspense = Suspense +-- ROBLOX deviation START: export Roblox Only type +exports.Binding = sharedReactSymbolsModule.REACT_BINDING_TYPE +-- ROBLOX deviation END exports.isValidElementType = isValidElementType local hasWarnedAboutDeprecatedIsAsyncMode = false local hasWarnedAboutDeprecatedIsConcurrentMode = false -- AsyncMode should be deprecated -exports.isAsyncMode = function(object) - if _G.__DEV__ then +local function isAsyncMode(object: any) + -- ROBLOX deviation START: remove toJSBoolean, use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + -- if not Boolean.toJSBoolean(hasWarnedAboutDeprecatedIsAsyncMode) then + if ReactGlobals.__DEV__ then if not hasWarnedAboutDeprecatedIsAsyncMode then - 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+.') + -- ROBLOX deviation END + 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+." + ) end end - return false end - -exports.isConcurrentMode = function(object) - if _G.__DEV__ then +exports.isAsyncMode = isAsyncMode +local function isConcurrentMode(object: any) + -- ROBLOX deviation START: remove toJSBoolean, use ReactGlobals.__DEV__ + -- if Boolean.toJSBoolean(__DEV__) then + -- if not Boolean.toJSBoolean(hasWarnedAboutDeprecatedIsConcurrentMode) then + if ReactGlobals.__DEV__ then if not hasWarnedAboutDeprecatedIsConcurrentMode then - 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+.') + -- ROBLOX deviation END + 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+." + ) end end - return false end - -exports.isContextConsumer = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_CONTEXT_TYPE +exports.isConcurrentMode = isConcurrentMode +local function isContextConsumer(object: any) + return typeOf(object) == REACT_CONTEXT_TYPE end -exports.isContextProvider = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_PROVIDER_TYPE +exports.isContextConsumer = isContextConsumer +local function isContextProvider(object: any) + return typeOf(object) == REACT_PROVIDER_TYPE end -exports.isElement = function(object) - return ((typeof(object) == 'table' and object ~= nil) and object['$$typeof'] == ReactSymbols.REACT_ELEMENT_TYPE) +exports.isContextProvider = isContextProvider +local function isElement(object: any) + return typeof(object) == "table" + and object ~= nil + and object["$$typeof"] == REACT_ELEMENT_TYPE end -exports.isForwardRef = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_FORWARD_REF_TYPE +exports.isElement = isElement +local function isForwardRef(object: any) + return typeOf(object) == REACT_FORWARD_REF_TYPE end -exports.isFragment = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_FRAGMENT_TYPE +exports.isForwardRef = isForwardRef +local function isFragment(object: any) + return typeOf(object) == REACT_FRAGMENT_TYPE end -exports.isLazy = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_LAZY_TYPE +exports.isFragment = isFragment +local function isLazy(object: any) + return typeOf(object) == REACT_LAZY_TYPE end -exports.isMemo = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_MEMO_TYPE +exports.isLazy = isLazy +local function isMemo(object: any) + return typeOf(object) == REACT_MEMO_TYPE end -exports.isPortal = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_PORTAL_TYPE +exports.isMemo = isMemo +local function isPortal(object: any) + return typeOf(object) == REACT_PORTAL_TYPE end -exports.isProfiler = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_PROFILER_TYPE +exports.isPortal = isPortal +local function isProfiler(object: any) + return typeOf(object) == REACT_PROFILER_TYPE end -exports.isStrictMode = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_STRICT_MODE_TYPE +exports.isProfiler = isProfiler +local function isStrictMode(object: any) + return typeOf(object) == REACT_STRICT_MODE_TYPE end -exports.isSuspense = function(object) - return exports.typeOf(object) == ReactSymbols.REACT_SUSPENSE_TYPE +exports.isStrictMode = isStrictMode +local function isSuspense(object: any) + return typeOf(object) == REACT_SUSPENSE_TYPE end - +exports.isSuspense = isSuspense +-- ROBLOX deviation START: Bindings are a feature migrated from Roact +exports.isBinding = function(object: any) + return typeOf(object) == REACT_BINDING_TYPE +end +-- ROBLOX deviation END return exports diff --git a/modules/react-noop-renderer/.robloxrc b/modules/react-noop-renderer/.robloxrc new file mode 100644 index 00000000..b0fb3562 --- /dev/null +++ b/modules/react-noop-renderer/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "nonstrict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-noop-renderer/rotriever.toml b/modules/react-noop-renderer/rotriever.toml index cae11504..789f8bb6 100644 --- a/modules/react-noop-renderer/rotriever.toml +++ b/modules/react-noop-renderer/rotriever.toml @@ -4,12 +4,12 @@ version = { workspace = true } authors = { workspace = true } publish = false content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] LuauPolyfill = { workspace = true } -Cryo = { workspace = true } Scheduler = { path = "../scheduler" } Shared = { path = "../shared" } +ReactGlobals = { path = "../react-globals" } ReactReconciler = { path = "../react-reconciler" } -# For mock timer playback -RobloxJest = { path = "../roblox-jest" } +JestGlobals = { workspace = true } diff --git a/modules/react-noop-renderer/src/ReactNoop.lua b/modules/react-noop-renderer/src/ReactNoop.lua index d6735a7e..ddb8b9c8 100644 --- a/modules/react-noop-renderer/src/ReactNoop.lua +++ b/modules/react-noop-renderer/src/ReactNoop.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-noop-renderer/src/ReactNoop.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-noop-renderer/src/ReactNoop.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * diff --git a/modules/react-noop-renderer/src/createReactNoop.lua b/modules/react-noop-renderer/src/createReactNoop.lua index 270afd02..8e46def0 100644 --- a/modules/react-noop-renderer/src/createReactNoop.lua +++ b/modules/react-noop-renderer/src/createReactNoop.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/e7b255341b059b4e2a109847395d0d0ba2633999/packages/react-noop-renderer/src/createReactNoop.js +--!nonstrict +-- ROBLOX upstream: https://github.com/facebook/react/blob/e7b255341b059b4e2a109847395d0d0ba2633999/packages/react-noop-renderer/src/createReactNoop.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,8 +8,6 @@ * * @flow ]] ---!nolint LocalShadowPedantic ---!nolint LocalShadow --[[* * This is a renderer of React that doesn't have a render target output. @@ -18,26 +17,32 @@ ]] local Packages = script.Parent.Parent -local RobloxJest = require(Packages.RobloxJest) -local Cryo = require(Packages.Cryo) +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local Error = LuauPolyfill.Error local Object = LuauPolyfill.Object +type Function = (...any) -> ...any local setTimeout = LuauPolyfill.setTimeout local clearTimeout = LuauPolyfill.clearTimeout - --- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local jest = require(Packages.JestGlobals).jest + +local ReactReconciler = require(Packages.ReactReconciler) +type Fiber = ReactReconciler.Fiber +type UpdateQueue = ReactReconciler.UpdateQueue +local ReactShared = require(Packages.Shared) +type ReactNodeList = ReactShared.ReactNodeList +type Thenable = ReactShared.Thenable +type RootTag = ReactReconciler.RootTag + local Scheduler = require(Packages.Scheduler) -- deviation: These are only used for the JSX logic that's currently omitted local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_FRAGMENT_TYPE = ReactSymbols.REACT_FRAGMENT_TYPE local REACT_ELEMENT_TYPE = ReactSymbols.REACT_ELEMENT_TYPE --- TODO (roblox): Figure out what the top-level interface of the reconciler is --- local ReactRootTags = require(Packages.ReactReconciler).ReactRootTags -- local ConcurrentRoot = ReactRootTags.ConcurrentRoot -- local BlockingRoot = ReactRootTags.BlockingRoot -- local LegacyRoot = ReactRootTags.LegacyRoot @@ -46,16 +51,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 +70,7 @@ type Props = { right: number?, top: number?, -- ... -}; +} type Instance = { type: string, id: number, @@ -74,18 +79,18 @@ type Instance = { prop: any, hidden: boolean, context: HostContext, -}; +} type TextInstance = { text: string, id: number, hidden: boolean, context: HostContext, -}; +} local NO_CONTEXT = {} local UPPERCASE_CONTEXT = {} local UPDATE_SIGNAL = {} -if _G.__DEV__ then +if ReactGlobals.__DEV__ then Object.freeze(NO_CONTEXT) Object.freeze(UPDATE_SIGNAL) end @@ -117,9 +122,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 @@ -128,8 +131,7 @@ local function createReactNoop(reconciler, useMutation: boolean) parentInstance: Instance, child: Instance | TextInstance ): () - local parentInstance: any = parentInstance - if typeof(parentInstance.rootID) == "string" then + if typeof((parentInstance :: any).rootID) == "string" then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. error(Error("appendChild() first argument is not an instance.")) @@ -161,9 +163,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 @@ -173,8 +173,7 @@ local function createReactNoop(reconciler, useMutation: boolean) child: Instance | TextInstance, beforeChild: Instance | TextInstance ) - local parentInstance: any = parentInstance - if typeof(parentInstance.rootID) ~= "string" then + if typeof((parentInstance :: any).rootID) ~= "string" then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. error(Error("insertBefore() first argument is not an instance.")) @@ -201,22 +200,19 @@ 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 parentInstance: any = parentInstance - if typeof(parentInstance.rootID) == "string" then + local function removeChild(parentInstance: Instance, child: Instance | TextInstance) + if typeof((parentInstance :: any).rootID) == "string" then -- Some calls to this aren't typesafe. -- This helps surface mistakes in tests. error(Error("removeChild() first argument is not an instance.")) @@ -256,10 +252,10 @@ local function createReactNoop(reconciler, useMutation: boolean) -- ? computeText((newProps.children: any) + '', instance.context) -- : null, text = shouldSetTextContent(type, newProps) - and computeText(tostring(newProps.children), instance.context) + and computeText(tostring(newProps.children), instance.context) or nil, context = instance.context, - } + }, }) hostCloneCounter += 1 return clone @@ -273,7 +269,8 @@ local function createReactNoop(reconciler, useMutation: boolean) end computeText = function(rawText, hostContext) - return hostContext == UPPERCASE_CONTEXT and string.upper(rawText) or rawText + -- ROBLOX FIXME Luau: TypeError: Type 'string' could not be converted into 'nil' + return if hostContext == UPPERCASE_CONTEXT then string.upper(rawText) else rawText end local sharedHostConfig = { @@ -321,10 +318,10 @@ local function createReactNoop(reconciler, useMutation: boolean) -- ? computeText((props.children: any) + '', hostContext) -- : null, text = shouldSetTextContent(type, props) - and computeText(tostring(props.children), hostContext) + and computeText(tostring(props.children), hostContext) or nil, context = hostContext, - } + }, }) instanceCounter += 1 return inst @@ -386,7 +383,7 @@ local function createReactNoop(reconciler, useMutation: boolean) __index = { id = instanceCounter, context = hostContext, - } + }, }) instanceCounter += 1 return inst @@ -503,7 +500,7 @@ local function createReactNoop(reconciler, useMutation: boolean) -- deviation: replace spread with manual table creation local hostConfig if useMutation then - hostConfig = Cryo.Dictionary.join(sharedHostConfig, { + hostConfig = Object.assign({}, sharedHostConfig, { supportsMutation = true, supportsPersistence = false, @@ -526,10 +523,8 @@ local function createReactNoop(reconciler, useMutation: boolean) instance.hidden = not not newProps.hidden if shouldSetTextContent(type, newProps) then -- deviation: Not sure about this one - instance.text = computeText( - tostring(newProps.children), - instance.context - ) + instance.text = + computeText(tostring(newProps.children), instance.context) end end, @@ -573,7 +568,7 @@ local function createReactNoop(reconciler, useMutation: boolean) end, }) else - hostConfig = Cryo.Dictionary.join(sharedHostConfig, { + hostConfig = Object.assign({}, sharedHostConfig, { supportsMutation = false, supportsPersistence = true, @@ -599,8 +594,8 @@ local function createReactNoop(reconciler, useMutation: boolean) ) 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")) @@ -648,7 +643,7 @@ local function createReactNoop(reconciler, useMutation: boolean) __index = { id = instanceCounter, context = instance.context, - } + }, }) instanceCounter += 1 return clone @@ -683,14 +678,16 @@ local function createReactNoop(reconciler, useMutation: boolean) return nil end if #child == 1 then - return childToJSX(child[1], nil) + return childToJSX(child[1]) end local children = Array.map(child, function(c) - return childToJSX(c, nil) + return childToJSX(c) 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 +699,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 @@ -710,7 +707,7 @@ local function createReactNoop(reconciler, useMutation: boolean) props.children = children end local store = nil - if _G.__DEV__ then + if ReactGlobals.__DEV__ then store = {} end return { @@ -749,13 +746,13 @@ local function createReactNoop(reconciler, useMutation: boolean) end local function getChildrenAsJSX(root) - local children = childToJSX(getChildren(root), nil) + local children = childToJSX(getChildren(root)) if children == nil then return nil end if Array.isArray(children) then local store = nil - if _G.__DEV__ then + if ReactGlobals.__DEV__ then store = {} end return { @@ -763,9 +760,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 @@ -776,7 +773,7 @@ local function createReactNoop(reconciler, useMutation: boolean) error(Error("JSX Unsupported")) end -- function getPendingChildrenAsJSX(root) { - -- local children = childToJSX(getChildren(root), nil) + -- local children = childToJSX(getChildren(root)) -- if (children == nil) { -- return nil -- } @@ -788,7 +785,7 @@ local function createReactNoop(reconciler, useMutation: boolean) -- ref: nil, -- props: {children}, -- _owner: nil, - -- _store: _G.__DEV__ ? {} : undefined, + -- _store: ReactGlobals.__DEV__ ? {} : undefined, -- } -- } -- return children @@ -812,19 +809,17 @@ local function createReactNoop(reconciler, useMutation: boolean) return getPendingChildren(container) end, - -- ROBLOX FIXME: Types across package boundaries - -- getOrCreateRootContainer = function(rootID: string?, tag: ReactRootTags.RootTag) - getOrCreateRootContainer = function(rootID: string?, tag: number) + getOrCreateRootContainer = function(rootID: string?, tag: RootTag) rootID = rootID or DEFAULT_ROOT_ID local root = roots[rootID] if not root then local container = { - rootID = rootID, + rootID = rootID :: string, pendingChildren = {}, children = {}, } rootContainers[rootID] = container - root = NoopRenderer.createContainer(container, tag, false, nil) + root = NoopRenderer.createContainer(container, tag, false) roots[rootID] = root end return root.current.stateNode.containerInfo @@ -838,16 +833,12 @@ local function createReactNoop(reconciler, useMutation: boolean) children = {}, } idCounter += 1 - local fiberRoot = NoopRenderer.createContainer( - container, - ConcurrentRoot, - false, - nil - ) + local fiberRoot = + NoopRenderer.createContainer(container, ConcurrentRoot, false, nil) return { _Scheduler = Scheduler, render = function(children) - NoopRenderer.updateContainer(children, fiberRoot, nil, nil) + NoopRenderer.updateContainer(children, fiberRoot, nil) end, getChildren = function() return getChildren(container) @@ -865,16 +856,12 @@ local function createReactNoop(reconciler, useMutation: boolean) children = {}, } idCounter += 1 - local fiberRoot = NoopRenderer.createContainer( - container, - BlockingRoot, - false, - nil - ) + local fiberRoot = + NoopRenderer.createContainer(container, BlockingRoot, false, nil) return { _Scheduler = Scheduler, render = function(children) - NoopRenderer.updateContainer(children, fiberRoot, nil, nil) + NoopRenderer.updateContainer(children, fiberRoot, nil) end, getChildren = function() return getChildren(container) @@ -892,16 +879,12 @@ local function createReactNoop(reconciler, useMutation: boolean) children = {}, } idCounter += 1 - local fiberRoot = NoopRenderer.createContainer( - container, - LegacyRoot, - false, - nil - ) + local fiberRoot = + NoopRenderer.createContainer(container, LegacyRoot, false, nil) return { _Scheduler = Scheduler, render = function(children) - NoopRenderer.updateContainer(children, fiberRoot, nil, nil) + NoopRenderer.updateContainer(children, fiberRoot, nil) end, getChildren = function() return getChildren(container) @@ -924,35 +907,24 @@ 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, -- Shortcut for testing a single root - render = function(element, callback) + render = function(element, callback: Function?) ReactNoop.renderToRootWithID(element, DEFAULT_ROOT_ID, callback) end, - renderLegacySyncRoot = function(element, callback) + renderLegacySyncRoot = function(element, callback: Function?) local rootID = DEFAULT_ROOT_ID local container = ReactNoop.getOrCreateRootContainer(rootID, LegacyRoot) local root = roots[container.rootID] 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: Function?) + local container = ReactNoop.getOrCreateRootContainer(rootID, ConcurrentRoot) local root = roots[container.rootID] NoopRenderer.updateContainer(element, root, nil, callback) end, @@ -967,7 +939,7 @@ local function createReactNoop(reconciler, useMutation: boolean) end end, - findInstance = function(componentOrElement) + findInstance = function(componentOrElement): nil | Instance | TextInstance if componentOrElement == nil then return nil end @@ -976,23 +948,24 @@ local function createReactNoop(reconciler, useMutation: boolean) if typeof(component.id) == "number" then return component end - if _G.__DEV__ then - return NoopRenderer.findHostInstanceWithWarning( - component, - "findInstance" - ) + if ReactGlobals.__DEV__ then + return NoopRenderer.findHostInstanceWithWarning(component, "findInstance") end return NoopRenderer.findHostInstance(component) end, - flushNextYield = function() + flushNextYield = function(): Array Scheduler.unstable_flushNumberOfYields(1) return Scheduler.unstable_clearYields() end, - flushWithHostCounters = function( - fn: () -> () - ) + flushWithHostCounters = function(_fn: () -> ()): { + hostDiffCounter: number, + hostUpdateCounter: number, + } | { + hostDiffCounter: number, + hostCloneCounter: number, + } hostDiffCounter = 0 hostUpdateCounter = 0 hostCloneCounter = 0 @@ -1006,7 +979,7 @@ local function createReactNoop(reconciler, useMutation: boolean) else return { hostDiffCounter = hostDiffCounter, - hostCloneCounter = hostUpdateCounter, + hostCloneCounter = hostCloneCounter, } end end) @@ -1023,7 +996,7 @@ local function createReactNoop(reconciler, useMutation: boolean) expire = Scheduler.unstable_advanceTime, - flushExpired = function() + flushExpired = function(): Array return Scheduler.unstable_flushExpired() end, @@ -1070,33 +1043,42 @@ local function createReactNoop(reconciler, useMutation: boolean) table.insert(bufferedLog, "\n") end - -- FIXME (roblox): This likely needs to be adopted to Roblox + -- ROBLOX FIXME: This likely needs to be adopted to Roblox -- Instance structure as opposed to HTML DOM nodes - local function logHostInstances(children, depth) - -- deviation: May not be able to assume children is an array in + local function logHostInstances( + children: Array, + depth: number + ) + -- ROBLOX deviation: May not be able to assume children is an array in -- Roblox (we use keys as names), so iterate with `pairs` - -- FIXME (roblox): Might want to iterate in array order if + -- ROBLOX FIXME: Might want to iterate in array order if -- children _is_ an array - for _, child in pairs(children) do + for _, child in children do local indent = string.rep(" ", depth) if typeof(child.text) == "string" then - log(indent .. "- " .. child.text) + log(indent .. "- " .. (child :: TextInstance).text) else -- $FlowFixMe - The child should've been refined now. - log(indent .. "- " .. child.type .. "#" .. child.id) + log( + indent + .. "- " + .. (child :: Instance).type + .. "#" + .. tostring(child.id) + ) -- $FlowFixMe - The child should've been refined now. - logHostInstances(child.children, depth + 1) + logHostInstances((child :: Instance).children, depth + 1) end end end - local function logContainer(container, depth) + local function logContainer(container: Container, depth: number) log(string.rep(" ", depth) .. "- [root#" .. container.rootID .. "]") logHostInstances(container.children, depth + 1) end - local function logUpdateQueue(updateQueue, depth) + local function logUpdateQueue(updateQueue: UpdateQueue, depth: number) log(string.rep(" ", depth + 1) .. "QUEUED UPDATES") local first = updateQueue.firstBaseUpdate local update = first @@ -1104,7 +1086,10 @@ local function createReactNoop(reconciler, useMutation: boolean) repeat log( string.rep(" ", depth + 1) .. "~", - "[" .. update.expirationTime .. "]" + -- ROBLOX TODO: this is a bogus field, even in upstream + "[" + .. tostring((update :: any).expirationTime) + .. "]" ) until update == nil end @@ -1117,39 +1102,45 @@ local function createReactNoop(reconciler, useMutation: boolean) repeat log( string.rep(" ", depth + 1) .. "~", - "[" .. pendingUpdate.expirationTime .. "]" + -- ROBLOX TODO: this is a bogus field, even in upstream + "[" + .. tostring((update :: any).expirationTime) + .. "]" ) until pendingUpdate == nil or pendingUpdate == firstPending end end end - local function logFiber(fiber, depth) + local function logFiber(fiber: Fiber, depth: number) log( - string.rep(" ", depth) .. - "- " .. + 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.type + then (fiber.type.name or tostring(fiber.type)) + else "[root]", + "[" + -- ROBLOX TODO: this field is bogus even in upstream, will always be nil + .. tostring((fiber :: any).childExpirationTime) + .. (if fiber.pendingProps then "*" else "") + .. "]" ) if fiber.updateQueue then logUpdateQueue(fiber.updateQueue, depth) end -- local childInProgress = fiber.progressedChild - -- if (childInProgress and childInProgress ~= fiber.child) { + -- if childInProgress and childInProgress ~= fiber.child then -- log( - -- ' '.repeat(depth + 1) + 'IN PROGRESS: ' + fiber.pendingWorkPriority, + -- string.rep(' ', depth + 1) .. 'IN PROGRESS: ' .. tostring(fiber.pendingWorkPriority) -- ) -- logFiber(childInProgress, depth + 1) - -- if (fiber.child) { - -- log(' '.repeat(depth + 1) + 'CURRENT') - -- } - -- } else if (fiber.child and fiber.updateQueue) { - -- log(' '.repeat(depth + 1) + 'CHILDREN') - -- } + -- if fiber.child then + -- log(string.rep(' ', depth + 1) .. 'CURRENT') + -- end + -- elseif fiber.child and fiber.updateQueue then + -- log(string.rep(' ', depth + 1) .. 'CHILDREN') + -- end if fiber.child then logFiber(fiber.child, depth + 1) end @@ -1164,7 +1155,7 @@ local function createReactNoop(reconciler, useMutation: boolean) logFiber(root.current, 0) -- eslint-disable-next-line react-internal/no-production-logging - for _, line in ipairs(bufferedLog) do + for _, line in bufferedLog do console.log(line) end end, @@ -1186,17 +1177,19 @@ local function createReactNoop(reconciler, useMutation: boolean) local IsThisRendererActing = NoopRenderer.IsThisRendererActing local actingUpdatesScopeDepth = 0 - local function noopAct(scope) + local function noopAct(scope: (() -> Thenable) | () -> ()) 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 @@ -1211,13 +1204,13 @@ local function createReactNoop(reconciler, useMutation: boolean) IsSomeRendererActing.current = previousIsSomeRendererActing IsThisRendererActing.current = previousIsThisRendererActing - if _G.__DEV__ then + if ReactGlobals.__DEV__ then if actingUpdatesScopeDepth > previousActingUpdatesScopeDepth then -- 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 +1221,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 @@ -1267,6 +1251,7 @@ local function createReactNoop(reconciler, useMutation: boolean) if not ok then error(result) end + -- ROBLOX deviation: upstream flowtype doesn't mind the inconsistent return, but Luau does return nil end end) @@ -1280,11 +1265,8 @@ local function createReactNoop(reconciler, useMutation: boolean) flushActWork = function(resolve, reject) -- Flush suspended fallbacks - -- deviation: FIXME: figure out the difference between runAllTimers and - -- runOnlyPendingTimers - RobloxJest.runAllTimers() -- $FlowFixMe: Flow doesn't know about global Jest object - -- jest.runOnlyPendingTimers() + jest.runOnlyPendingTimers() enqueueTask(function() local ok, result = pcall(function() diff --git a/modules/react-noop-renderer/src/init.lua b/modules/react-noop-renderer/src/init.lua index 2415b7b3..7bfa2459 100644 --- a/modules/react-noop-renderer/src/init.lua +++ b/modules/react-noop-renderer/src/init.lua @@ -1,2 +1,12 @@ --- deviation: simulates `index.js` and exports ReactNoop's public interface +-- ROBLOX upstream: https://github.com/facebook/react/blob/e7b255341b059b4e2a109847395d0d0ba2633999/packages/react-noop-renderer/src/index.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 + *]] + +--!strict return require(script.ReactNoop) diff --git a/modules/react-reconciler/.robloxrc b/modules/react-reconciler/.robloxrc new file mode 100644 index 00000000..b0fb3562 --- /dev/null +++ b/modules/react-reconciler/.robloxrc @@ -0,0 +1,8 @@ +{ + "language": { + "mode": "nonstrict" + }, + "lint": { + "*": "enabled" + } +} \ No newline at end of file diff --git a/modules/react-reconciler/README.md b/modules/react-reconciler/README.md index b979a9de..cbc0fa15 100644 --- a/modules/react-reconciler/README.md +++ b/modules/react-reconciler/README.md @@ -9,5 +9,36 @@ Source: https://github.com/facebook/react/tree/master/packages/react-reconciler ### ✏️ Notes +#### Profiling + +``` +src/SchedulingProfiler.js +src/__tests__/SchedulingProfiler-test.internal.js +``` + +Profiling logic used for debugging the Scheduler. Includes tests that produce flamegraphs of processed tasks. This functionality is gated behind the `enableProfiling` flag defined in `SchedulerFeatureFlags.js`. Additional functionality is gated behind the enableSchedulingProfiler in ReactFeatureFlags. When enabling the Scheduling Profiler, you'll need to plug in a table with a `mark` function to the `_G.performance` global, like this: +```lua +_G.performance = { + mark = function(str) + debug.profileBegin(str) + debug.profileEnd(str) +} +``` + +#### Debug Tracing + +``` +src/DebugTracing.js +src/__tests__/DebugTracing-test.internal.js +``` + +Debug Tracing is enabled with the enableDebugTracing ReactFeatureFlag. The current Lua implementation outputs using Lua `print`, and strips out the color and styling versus upstream. We may want to more deeply customize this based on real-world use cases of Roblox UI developers. + ### ❌ Excluded + +``` +src/__tests__/ReactSuspenseList-test.js +``` + +The initial release of Roact 17 includes support for Suspense, but not the unstable SuspenseList API. This was purely to pull in the delivery schedule and narrow the support surface for the initial release. diff --git a/modules/react-reconciler/rotriever.toml b/modules/react-reconciler/rotriever.toml index f4761afb..4a19577d 100644 --- a/modules/react-reconciler/rotriever.toml +++ b/modules/react-reconciler/rotriever.toml @@ -4,20 +4,22 @@ version = { workspace = true } authors = { workspace = true } publish = true content_root = "src" +files = ["*", "!**/__tests__/**"] [dependencies] LuauPolyfill = { workspace = true } -Cryo = { workspace = true } Promise = { workspace = true } +SafeFlags = { workspace = true } +ReactGlobals = { path = "../react-globals" } Scheduler = { path = "../scheduler" } Shared = { path = "../shared" } React = { path = "../react" } [dev_dependencies] -JestRoblox = { workspace = true } +JestGlobals = { workspace = true } JestReact = { path = "../jest-react" } -RobloxJest = { path = "../roblox-jest" } ReactCache = { path = "../react-cache" } ReactNoopRenderer = { path = "../react-noop-renderer" } ReactTestRenderer = { path = "../react-test-renderer" } ReactRoblox = { path = "../react-roblox" } +ReactDevtoolsShared = { path = "../react-devtools-shared" } diff --git a/modules/react-reconciler/src/DebugTracing.lua b/modules/react-reconciler/src/DebugTracing.lua new file mode 100644 index 00000000..36053904 --- /dev/null +++ b/modules/react-reconciler/src/DebugTracing.lua @@ -0,0 +1,286 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/43363e2795393a00fd77312a16d6b80e626c29de/packages/react-reconciler/src/DebugTracing.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 +local ReactGlobals = require(Packages.ReactGlobals) +type Array = { [number]: T } +type Map = { [K]: V } +type Object = { [string]: any } +type Function = (any) -> any? +local Shared = require(Packages.Shared) +local console = Shared.console +local exports = {} + +-- ROBLOX deviation: hoist log so it's visible +local log +-- ROBLOX deviation: the nucelus emoji `(%c\u{269B}\u{FE0F}%c)` has been replaced with `*` + +local ReactFiberLaneModule = require(script.Parent.ReactFiberLane) +type Lane = ReactFiberLaneModule.Lane +type Lanes = ReactFiberLaneModule.Lanes +type Wakeable = Shared.Wakeable + +local enableDebugTracing = require(Packages.Shared).ReactFeatureFlags.enableDebugTracing + +local nativeConsole: Object = console +local nativeConsoleLog: nil | Function = nil + +local pendingGroupArgs: Array = {} +-- ROBLOX deviation: adjust starting indea for Lua 1-based arrays +local printedGroupIndex: number = 0 + +-- ROBLOX deviation: Luau has no built-in way to convert decimal number to binary string +function decimalToBinaryString(decimal: number): string + local result = "" + repeat + local divres = decimal / 2 + local int, frac = math.modf(divres) + decimal = int + result = math.ceil(frac) .. result + until decimal == 0 + + local nbZero = 31 - string.len(result) + return string.rep("0", nbZero) .. result +end + +local function formatLanes(laneOrLanes: Lane | Lanes): string + return "0b" + -- ROBLOX deviation: Luau has no built-in way to convert decimal number to binary string + .. decimalToBinaryString(laneOrLanes) +end + +local function group(...): () + for _, groupArg in { ... } do + table.insert(pendingGroupArgs, groupArg) + end + if nativeConsoleLog == nil then + nativeConsoleLog = nativeConsole.log + nativeConsole.log = log + end +end + +local function groupEnd(): () + table.remove(pendingGroupArgs, 1) + while printedGroupIndex > #pendingGroupArgs do + nativeConsole.groupEnd() + printedGroupIndex -= 1 + end + if #pendingGroupArgs == 0 then + nativeConsole.log = nativeConsoleLog + nativeConsoleLog = nil + end +end + +function log(...): () + if printedGroupIndex < #pendingGroupArgs then + for i = printedGroupIndex + 1, #pendingGroupArgs do + local groupArgs = pendingGroupArgs[i] + nativeConsole.group(groupArgs) + end + printedGroupIndex = #pendingGroupArgs + end + if typeof(nativeConsoleLog) == "function" then + (nativeConsoleLog :: any)(...) + else + nativeConsole.log(...) + end +end + +-- ROBLOX deviation: remove color styling +local REACT_LOGO_STYLE = "" + +local function logCommitStarted(lanes: Lanes): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + group( + string.format("* commit (%s)", formatLanes(lanes)), + REACT_LOGO_STYLE, + "", + -- ROBLOX deviation: remove style + "" + ) + end + end +end +exports.logCommitStarted = logCommitStarted + +local function logCommitStopped(): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + groupEnd() + end + end +end +exports.logCommitStopped = logCommitStopped + +-- ROBLOX deviation: use raw Lua table +-- const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +-- $FlowFixMe: Flow cannot handle polymorphic WeakMaps + +-- ROBLOX TODO: restore the color message formatting from upstream +-- local wakeableIDs: Map = {} +-- local wakeableID: number = 0 +-- local function getWakeableID(wakeable: Wakeable): number +-- if not wakeableIDs[wakeable] ~= nil then +-- wakeableIDs[wakeable] = +-- (function() +-- local result = wakeableID +-- wakeableID += 1 +-- return result +-- end)() +-- end +-- return wakeableIDs[wakeable] +-- end + +local function logComponentSuspended(componentName: string, wakeable: Wakeable): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + -- local _id = getWakeableID(wakeable) + -- ROBLOX deviation: our Wakeable can be a function or a callable table + -- local _display = wakeable.displayName or wakeable + log( + string.format("* %s suspended", componentName) + -- REACT_LOGO_STYLE, + -- ROBLOX deviation: remove color styling + -- "", + -- id, + -- display + ) + wakeable:andThen(function() + log( + string.format("* %s resolved", componentName) + -- REACT_LOGO_STYLE, + -- ROBLOX deviation: remove color styling + -- "", + -- id, + -- display + ) + end, function() + log( + string.format("* %s rejected", componentName) + -- REACT_LOGO_STYLE, + -- ROBLOX deviation: remove color styling + -- "", + -- id, + -- display + ) + end) + end + end +end +exports.logComponentSuspended = logComponentSuspended + +local function logLayoutEffectsStarted(lanes: Lanes): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + group( + string.format("* layout effects (%s)", formatLanes(lanes)) + -- REACT_LOGO_STYLE, + -- "", + -- ROBLOX deviation: strip color styling + -- "" + ) + end + end +end +exports.logLayoutEffectsStarted = logLayoutEffectsStarted + +local function logLayoutEffectsStopped(): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + groupEnd() + end + end +end +exports.logLayoutEffectsStopped = logLayoutEffectsStopped + +local function logPassiveEffectsStarted(lanes: Lanes): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + group( + string.format("* passive effects (%s)", formatLanes(lanes)) + -- REACT_LOGO_STYLE, + -- "", + -- ROBLOX deviation: strip color styling + -- "" + ) + end + end +end +exports.logPassiveEffectsStarted = logPassiveEffectsStarted + +local function logPassiveEffectsStopped(): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + groupEnd() + end + end +end +exports.logPassiveEffectsStopped = logPassiveEffectsStopped + +local function logRenderStarted(lanes: Lanes): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + group( + string.format("* render (%s)", formatLanes(lanes)) + -- REACT_LOGO_STYLE, + -- "", + -- ROBLOX deviation: strip color styling + -- "" + ) + end + end +end +exports.logRenderStarted = logRenderStarted + +local function logRenderStopped(): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + groupEnd() + end + end +end +exports.logRenderStopped = logRenderStopped + +local function logForceUpdateScheduled(componentName: string, lane: Lane): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + log( + string.format("* %s forced update (%s)", componentName, formatLanes(lane)) + -- REACT_LOGO_STYLE, + -- ROBLOX deviation: strip color styling + -- "", + -- "" + ) + end + end +end +exports.logForceUpdateScheduled = logForceUpdateScheduled + +local function logStateUpdateScheduled( + componentName: string, + lane: Lane, + payloadOrAction: any +): () + if ReactGlobals.__DEV__ then + if enableDebugTracing then + log( + string.format("* %s updated state (%s)", componentName, formatLanes(lane)) + -- REACT_LOGO_STYLE, + -- ROBLOX deviation: strip color styling + -- "", + -- "", + -- payloadOrAction + ) + end + end +end +exports.logStateUpdateScheduled = logStateUpdateScheduled +return exports diff --git a/modules/react-reconciler/src/MaxInts.lua b/modules/react-reconciler/src/MaxInts.lua index a385a00c..7e12f309 100644 --- a/modules/react-reconciler/src/MaxInts.lua +++ b/modules/react-reconciler/src/MaxInts.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/c5d2fc7127654e43de59fff865b74765a103c4a5/packages/react-reconciler/src/MaxInts.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/c5d2fc7127654e43de59fff865b74765a103c4a5/packages/react-reconciler/src/MaxInts.js -- /** -- * Copyright (c) Facebook, Inc. and its affiliates. -- * diff --git a/modules/react-reconciler/src/ReactCapturedValue.lua b/modules/react-reconciler/src/ReactCapturedValue.lua index d5c41d26..04db7bc0 100644 --- a/modules/react-reconciler/src/ReactCapturedValue.lua +++ b/modules/react-reconciler/src/ReactCapturedValue.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactCapturedValue.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactCapturedValue.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -11,27 +12,25 @@ local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber -local getStackByFiberInDevAndProd = require(script.Parent.ReactFiberComponentStack).getStackByFiberInDevAndProd +local getStackByFiberInDevAndProd = + require(script.Parent.ReactFiberComponentStack).getStackByFiberInDevAndProd export type CapturedValue = { - value: T, - source: Fiber | nil, - stack: string | nil, + value: T, + source: Fiber | nil, + stack: string | nil, } local exports = {} -exports.createCapturedValue = function( - value, - source -) - -- If the value is an error, call this function immediately after it is thrown - -- so the stack is accurate. - return { - value = value, - source = source, - stack = getStackByFiberInDevAndProd(source), - } +exports.createCapturedValue = function(value: T, source: Fiber | nil): CapturedValue + -- If the value is an error, call this function immediately after it is thrown + -- so the stack is accurate. + return { + value = value, + source = source, + stack = getStackByFiberInDevAndProd(source), + } end return exports diff --git a/modules/react-reconciler/src/ReactChildFiber.new.lua b/modules/react-reconciler/src/ReactChildFiber.new.lua index 549b5ffd..ac7e33ac 100644 --- a/modules/react-reconciler/src/ReactChildFiber.new.lua +++ b/modules/react-reconciler/src/ReactChildFiber.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactChildFiber.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactChildFiber.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,41 +8,39 @@ * * @flow ]] ---!nolint LocalShadowPedantic --- 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 Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array +local Error = LuauPolyfill.Error type Array = { [number]: T } type Set = { [T]: boolean } type Object = { [any]: any } type Map = { [K]: V } -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local describeError = require(Packages.Shared).describeError + +local __DEV__ = ReactGlobals.__DEV__ :: boolean + +local SafeFlags = require(Packages.SafeFlags) +local FFlagReactPreventAssigningKeyToChildren = + SafeFlags.createGetFFlag("ReactPreventAssigningKeyToChildren")() local ReactTypes = require(Packages.Shared) -- ROBLOX deviation: ReactElement is defined at the top level of Shared along -- with the rest of the ReactTypes -type ReactElement = ReactTypes.ReactElement +type ReactElement = ReactTypes.ReactElement type ReactPortal = ReactTypes.ReactPortal local React = require(Packages.React) type LazyComponent = React.LazyComponent local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type RoactStableKey = ReactInternalTypes.RoactStableKey; +type Fiber = ReactInternalTypes.Fiber +type RoactStableKey = ReactInternalTypes.RoactStableKey local ReactFiberLanes = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLanes.Lanes; +type Lanes = ReactFiberLanes.Lanes local getComponentName = require(Packages.Shared).getComponentName local ReactFiberFlags = require(script.Parent.ReactFiberFlags) @@ -65,7 +64,8 @@ local SimpleMemoComponent = ReactWorkTags.SimpleMemoComponent local Block = ReactWorkTags.Block local invariant = require(Packages.Shared).invariant local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -local warnAboutStringRefs = ReactFeatureFlags.warnAboutStringRefs +-- ROBLOX deviation: we do not support string refs +-- local warnAboutStringRefs = ReactFeatureFlags.warnAboutStringRefs local enableLazyElements = ReactFeatureFlags.enableLazyElements local enableBlocksAPI = ReactFeatureFlags.enableBlocksAPI @@ -76,253 +76,281 @@ local createFiberFromElement = ReactFiber.createFiberFromElement local createFiberFromFragment = ReactFiber.createFiberFromFragment local createFiberFromText = ReactFiber.createFiberFromText local createFiberFromPortal = ReactFiber.createFiberFromPortal -local emptyRefsObject = require(script.Parent["ReactFiberClassComponent.new"]).emptyRefsObject +-- ROBLOX deviation: we do not support string refs +-- local emptyRefsObject = +-- require(script.Parent["ReactFiberClassComponent.new"]).emptyRefsObject local ReactFiberHotReloading = require(script.Parent["ReactFiberHotReloading.new"]) -local isCompatibleFamilyForHotReloading = ReactFiberHotReloading.isCompatibleFamilyForHotReloading -local StrictMode = require(script.Parent.ReactTypeOfMode).StrictMode +local isCompatibleFamilyForHotReloading = + ReactFiberHotReloading.isCompatibleFamilyForHotReloading +-- ROBLOX deviation: we do not support string refs, which removes our use of StrictMode +-- local StrictMode = require(script.Parent.ReactTypeOfMode).StrictMode local exports = {} local didWarnAboutMaps -- ROBLOX deviation: Lua doesn't have built-in generators -- local didWarnAboutGenerators -local didWarnAboutStringRefs +-- ROBLOX deviation: we do not support string refs +-- local didWarnAboutStringRefs local ownerHasKeyUseWarning local ownerHasFunctionTypeWarning -local warnForMissingKey = function(child: any, returnFiber: Fiber) -end +local warnForMissingKey = function(child: any, returnFiber: Fiber) end -if _G.__DEV__ then - didWarnAboutMaps = false --- ROBLOX deviation: Lua doesn't have built-in generators --- didWarnAboutGenerators = false - didWarnAboutStringRefs = {} +if __DEV__ then + didWarnAboutMaps = false + -- ROBLOX deviation: Lua doesn't have built-in generators + -- didWarnAboutGenerators = false + + -- ROBLOX deviation: we do not support string refs + -- didWarnAboutStringRefs = {} - --[[ - Warn if there's no key explicitly set on dynamic arrays of children or - object keys are not valid. This allows us to keep track of children between - updates. + --[[ + Warn if there's no key explicitly set on dynamic arrays of children or + object keys are not valid. This allows us to keep track of children between + updates. ]] - ownerHasKeyUseWarning = {} - ownerHasFunctionTypeWarning = {} - - -- ROBLOX FIXME: This may need to change depending on how we want children to - -- be passed. Current Roact accepts a table (keys are built-in) and leaves - -- ordering up to users via LayoutOrder, but if we accept arrays (and attempt - -- to somehow map them to LayoutOrder??) we'll need keys for stability - warnForMissingKey = function(child: any, returnFiber: Fiber) - if child == nil or typeof(child) ~= "table" then - return - end - if not child._store or child._store.validated or child.key ~= nil then - return - end - invariant( - typeof(child._store) == "table", - "React Component in warnForMissingKey should have a _store. " .. - "This error is likely caused by a bug in React. Please file an issue." - ) - child._store.validated = true - - local componentName = getComponentName(returnFiber.type) or "Component" - - if ownerHasKeyUseWarning[componentName] then - return - end - ownerHasKeyUseWarning[componentName] = true - - console.error( - 'Each child in a list should have a unique ' .. - '"key" prop. See https://reactjs.org/link/warning-keys for ' .. - 'more information.' - ) - end + ownerHasKeyUseWarning = {} + ownerHasFunctionTypeWarning = {} + + -- ROBLOX FIXME: This may need to change depending on how we want children to + -- be passed. Current Roact accepts a table (keys are built-in) and leaves + -- ordering up to users via LayoutOrder, but if we accept arrays (and attempt + -- to somehow map them to LayoutOrder??) we'll need keys for stability + warnForMissingKey = function(child: any, returnFiber: Fiber) + if child == nil or type(child) ~= "table" then + return + end + if not child._store or child._store.validated or child.key ~= nil then + return + end + invariant( + child._store ~= nil and type(child._store) == "table", + "React Component in warnForMissingKey should have a _store. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + child._store.validated = true + + local componentName = getComponentName(returnFiber.type) or "Component" + + if ownerHasKeyUseWarning[componentName] then + return + end + ownerHasKeyUseWarning[componentName] = true + + console.error( + "Each child in a list should have a unique " + .. '"key" prop. See https://reactjs.org/link/warning-keys for ' + .. "more information." + ) + end end local isArray = Array.isArray -function coerceRef( - returnFiber: Fiber, - current: Fiber | nil, - element: ReactElement -) - local mixedRef = element.ref - if - mixedRef ~= nil and - typeof(mixedRef) ~= 'function' and - typeof(mixedRef) ~= "table" then - - if _G.__DEV__ then - -- TODO: Clean this up once we turn on the string ref warning for - -- everyone, because the strict mode case will no longer be relevant - if - (bit32.band(returnFiber.mode, StrictMode) ~= 0 or warnAboutStringRefs) and - -- We warn in ReactElement.js if owner and self are equal for string refs - -- because these cannot be automatically converted to an arrow function - -- using a codemod. Therefore, we don't have to warn about string refs again. - not ( - element._owner and - element._self and - element._owner.stateNode ~= element._self - ) then - local componentName = getComponentName(returnFiber.type) or 'Component' - if not didWarnAboutStringRefs[componentName] then - if warnAboutStringRefs then - console.error( - 'Component "%s" contains the string ref "%s". Support for string refs ' .. - 'will be removed in a future major release. We recommend using ' .. - 'useRef() or createRef() instead. ' .. - 'Learn more about using refs safely here: ' .. - 'https://reactjs.org/link/strict-mode-string-ref', - componentName, - mixedRef - ) - else - console.error( - 'A string ref, "%s", has been found within a strict mode tree. ' .. - 'String refs are a source of potential bugs and should be avoided. ' .. - 'We recommend using useRef() or createRef() instead. ' .. - 'Learn more about using refs safely here: ' .. - 'https://reactjs.org/link/strict-mode-string-ref', - mixedRef - ) - end - didWarnAboutStringRefs[componentName] = true - end - end - end - - if element._owner then - local owner: Fiber? = element._owner - local inst - if owner then - local ownerFiber = owner - invariant( - ownerFiber.tag == ClassComponent, - 'Function components cannot have string refs. ' .. - 'We recommend using useRef() instead. ' .. - 'Learn more about using refs safely here: ' .. - 'https://reactjs.org/link/strict-mode-string-ref' - ) - inst = ownerFiber.stateNode - end - invariant( - inst, - 'Missing owner for string ref %s. This error is likely caused by a ' .. - 'bug in React. Please file an issue.', - mixedRef - ) - - -- ROBLOX deviation: explicitly convert to string - local stringRef = tostring(mixedRef) - -- Check if previous string ref matches new string ref - if - current ~= nil and - (current :: Fiber).ref ~= nil and - -- ROBLOX deviation: Lua doesn't support fields on functions, so invert this check - -- typeof((current :: Fiber).ref) == 'function' and - typeof((current :: Fiber).ref) ~= 'function' and - -- ROBLOX deviation: this partially inlines the ref type from Fiber to workaround Luau refinement issues - ((current :: Fiber).ref :: {_stringRef: string?})._stringRef == stringRef - then - return (current :: Fiber).ref - end - -- ROBLOX deviation: make ref a callable table rather than a function - local callableRef = function(value) - local refs = inst.refs - if refs == emptyRefsObject then - -- This is a lazy pooled frozen object, so we need to initialize. - inst.refs = {} - refs = inst.refs - end - if value == nil then - refs[stringRef] = nil - else - refs[stringRef] = value - end - end - local ref = setmetatable({}, {__call = callableRef}) - ref._stringRef = stringRef - return ref - else - invariant( - typeof(mixedRef) == 'string', - 'Expected ref to be a function, a string, an object returned by React.createRef(), or nil.' - ) - invariant( - element._owner, - 'Element ref was specified as a string (%s) but no owner was set. This could happen for one of' .. - ' the following reasons:\n' .. - '1. You may be adding a ref to a function component\n' .. - "2. You may be adding a ref to a component that was not created inside a component's render method\n" .. - '3. You have multiple copies of React loaded\n' .. - 'See https://reactjs.org/link/refs-must-have-owner for more information.', - mixedRef - ) - end - end - return mixedRef +function coerceRef(returnFiber: Fiber, current: Fiber | nil, element: ReactElement) + local mixedRef = element.ref + if mixedRef ~= nil and type(mixedRef) == "string" then + -- ROBLOX deviation: we do not support string refs, and will not coerce + if + not element._owner + or not element._self + or element._owner.stateNode == element._self + then + -- ROBLOX performance: don't get component name unless we have to use it + local componentName + if __DEV__ then + componentName = getComponentName(returnFiber.type) or "Component" + else + componentName = "" + end + error( + Error.new( + string.format( + 'Component "%s" contains the string ref "%s". Support for string refs ' + -- ROBLOX deviation: we removed string ref support ahead of upstream schedule + .. "has been removed. We recommend using " + .. "useRef() or createRef() instead. " + .. "Learn more about using refs safely here: " + .. "https://reactjs.org/link/strict-mode-string-ref", + componentName, + tostring(mixedRef) + ) + ) + ) + end + + if not element._owner then + error( + "Expected ref to be a function or an object returned by React.createRef(), or nil." + ) + end + + -- if __DEV__ then + -- -- TODO: Clean this up once we turn on the string ref warning for + -- -- everyone, because the strict mode case will no longer be relevant + -- if + -- (bit32.band(returnFiber.mode, StrictMode) ~= 0 or warnAboutStringRefs) + -- -- We warn in ReactElement.js if owner and self are equal for string refs + -- -- because these cannot be automatically converted to an arrow function + -- -- using a codemod. Therefore, we don't have to warn about string refs again. + -- and not ( + -- element._owner + -- and element._self + -- and element._owner.stateNode ~= element._self + -- ) + -- then + -- local componentName = getComponentName(returnFiber.type) or "Component" + -- if not didWarnAboutStringRefs[componentName] then + -- if warnAboutStringRefs then + -- console.error( + -- 'Component "%s" contains the string ref "%s". Support for string refs ' + -- .. "will be removed in a future major release. We recommend using " + -- .. "useRef() or createRef() instead. " + -- .. "Learn more about using refs safely here: " + -- .. "https://reactjs.org/link/strict-mode-string-ref", + -- componentName, + -- mixedRef + -- ) + -- else + -- console.error( + -- 'A string ref, "%s", has been found within a strict mode tree. ' + -- .. "String refs are a source of potential bugs and should be avoided. " + -- .. "We recommend using useRef() or createRef() instead. " + -- .. "Learn more about using refs safely here: " + -- .. "https://reactjs.org/link/strict-mode-string-ref", + -- mixedRef + -- ) + -- end + -- didWarnAboutStringRefs[componentName] = true + -- end + -- end + -- end + + -- if element._owner then + -- local owner: Fiber? = element._owner + -- local inst + -- if owner then + -- local ownerFiber = owner + -- invariant( + -- ownerFiber.tag == ClassComponent, + -- "Function components cannot have string refs. " + -- .. "We recommend using useRef() instead. " + -- .. "Learn more about using refs safely here: " + -- .. "https://reactjs.org/link/strict-mode-string-ref" + -- ) + -- inst = ownerFiber.stateNode + -- end + -- invariant( + -- inst, + -- "Missing owner for string ref %s. This error is likely caused by a " + -- .. "bug in React. Please file an issue.", + -- mixedRef + -- ) + + -- -- ROBLOX deviation: explicitly convert to string + -- local stringRef = tostring(mixedRef) + -- -- Check if previous string ref matches new string ref + -- if + -- current ~= nil + -- and (current :: Fiber).ref ~= nil + -- -- ROBLOX deviation: Lua doesn't support fields on functions, so invert this check + -- -- typeof((current :: Fiber).ref) == 'function' and + -- and typeof((current :: Fiber).ref) ~= "function" + -- -- ROBLOX deviation: this partially inlines the ref type from Fiber to workaround Luau refinement issues + -- and ((current :: Fiber).ref :: { _stringRef: string? })._stringRef + -- == stringRef + -- then + -- return (current :: Fiber).ref + -- end + -- -- ROBLOX deviation: make ref a callable table rather than a function + -- local callableRef = function(value) + -- local refs = inst.__refs + -- if refs == emptyRefsObject then + -- -- This is a lazy pooled frozen object, so we need to initialize. + -- inst.__refs = {} + -- refs = inst.__refs + -- end + -- if value == nil then + -- refs[stringRef] = nil + -- else + -- refs[stringRef] = value + -- end + -- end + -- local ref = setmetatable({}, { __call = callableRef }) + -- ref._stringRef = stringRef + -- return ref + -- else + -- invariant( + -- typeof(mixedRef) == "string", + -- "Expected ref to be a function, a string, an object returned by React.createRef(), or nil." + -- ) + -- invariant( + -- element._owner, + -- "Element ref was specified as a string (%s) but no owner was set. This could happen for one of" + -- .. " the following reasons:\n" + -- .. "1. You may be adding a ref to a function component\n" + -- .. "2. You may be adding a ref to a component that was not created inside a component's render method\n" + -- .. "3. You have multiple copies of React loaded\n" + -- .. "See https://reactjs.org/link/refs-must-have-owner for more information.", + -- mixedRef + -- ) + -- end + end + return mixedRef end -local function throwOnInvalidObjectType(returnFiber: Fiber, newChild: { [any]: any }) - if returnFiber.type ~= "textarea" then - -- ROBLOX FIXME: Need to adjust this to check for "table:
" instead - -- and print appropriately - unimplemented("throwOnInvalidObjectType textarea") - - -- ROBLOX TODO: This is likely a bigger deviation; in Roact today, we allow - -- tables and use the keys as equivalents to the `key` prop - -- invariant( - -- false, - -- "Objects are not valid as a React child (found: %s). " .. - -- "If you meant to render a collection of children, use an array " .. - -- "instead.", - -- tostring(newChild) == "[object Object]" - -- ? "object with keys {" + Object.keys(newChild).join(", ") + "}" - -- : newChild, - -- ) - end -end +-- ROBLOX performance: all uses commented out +-- local function throwOnInvalidObjectType(returnFiber: Fiber, newChild: { [any]: any }) +-- if returnFiber.type ~= "textarea" then +-- ROBLOX FIXME: Need to adjust this to check for "table:
" instead +-- and print appropriately +-- unimplemented("throwOnInvalidObjectType textarea") + +-- ROBLOX TODO: This is likely a bigger deviation; in Roact today, we allow +-- tables and use the keys as equivalents to the `key` prop +-- invariant( +-- false, +-- "Objects are not valid as a React child (found: %s). " .. +-- "If you meant to render a collection of children, use an array " .. +-- "instead.", +-- tostring(newChild) == "[object Object]" +-- ? "object with keys {" + Object.keys(newChild).join(", ") + "}" +-- : newChild, +-- ) +-- end +-- end local function warnOnFunctionType(returnFiber: Fiber) - if _G.__DEV__ then - local componentName = getComponentName(returnFiber.type) or "Component" - - if ownerHasFunctionTypeWarning[componentName] then - return - end - ownerHasFunctionTypeWarning[componentName] = true - - console.error( - "Functions are not valid as a React child. This may happen if " .. - "you return a Component instead of from render. " .. - "Or maybe you meant to call this function rather than return it." - ) - end + if __DEV__ then + local componentName = getComponentName(returnFiber.type) or "Component" + + if ownerHasFunctionTypeWarning[componentName] then + return + end + ownerHasFunctionTypeWarning[componentName] = true + + console.error( + "Functions are not valid as a React child. This may happen if " + .. "you return a Component instead of from render. " + .. "Or maybe you meant to call this function rather than return it." + ) + end end - -- // We avoid inlining this to avoid potential deopts from using try/catch. -- /** @noinline */ --- function resolveLazyType( --- lazyComponent: LazyComponent --- ): LazyComponent | T --- ROBLOX TODO: needs Luau function generics -function resolveLazyType( - lazyComponent: LazyComponent -): LazyComponent | any - local ok, result = pcall(function() - -- If we can, let's peek at the resulting type. - local payload = lazyComponent._payload - local init = lazyComponent._init - return init(payload) - end) - if not ok then - -- Leave it in place and let it throw again in the begin phase. - return lazyComponent - end - - return result +function resolveLazyType(lazyComponent: LazyComponent): LazyComponent | T + -- ROBLOX performance: hoist non-throwable lines so we eliminate an anon function for the pcall + -- If we can, let's peek at the resulting type. + local payload = lazyComponent._payload + local init = lazyComponent._init + local ok, result = xpcall(init, describeError, payload) + if not ok then + -- Leave it in place and let it throw again in the begin phase. + return lazyComponent + end + + return result end -- This wrapper function exists because I expect to clone the code in each path @@ -330,1239 +358,1325 @@ end -- a compiler or we can do it manually. Helpers that don't need this branching -- live outside of this function. local function ChildReconciler(shouldTrackSideEffects) - local function deleteChild(returnFiber: Fiber, childToDelete: Fiber) - if not shouldTrackSideEffects then - -- Noop. - return - end - local deletions = returnFiber.deletions - if deletions == nil then - returnFiber.deletions = { childToDelete } - returnFiber.flags = bit32.bor(returnFiber.flags, Deletion) - else - table.insert(deletions, childToDelete) - end - end - - local function deleteRemainingChildren( - returnFiber: Fiber, - currentFirstChild: Fiber | nil - ) - if not shouldTrackSideEffects then - -- Noop. - return nil - end - - -- TODO: For the shouldClone case, this could be micro-optimized a bit by - -- assuming that after the first child we've already added everything. - local childToDelete = currentFirstChild - while childToDelete ~= nil do - deleteChild(returnFiber, childToDelete) - childToDelete = childToDelete.sibling - end - return nil - end - - local function mapRemainingChildren( - returnFiber: Fiber, - currentFirstChild: Fiber - ): { [string | number]: Fiber } - -- Add the remaining children to a temporary map so that we can find them by - -- keys quickly. Implicit (null) keys get added to this set with their index - -- instead. - local existingChildren: { [string | number]: Fiber } = {} - - local existingChild = currentFirstChild - while existingChild ~= nil do - if existingChild.key ~= nil then - existingChildren[existingChild.key] = existingChild - else - existingChildren[existingChild.index] = existingChild - end - existingChild = existingChild.sibling - end - return existingChildren - end - - local function useFiber(fiber: Fiber, pendingProps: any): Fiber - -- We currently set sibling to nil and index to 0 here because it is easy - -- to forget to do before returning it. E.g. for the single child case. - local clone = createWorkInProgress(fiber, pendingProps) - -- ROBLOX deviation: set index to 1 for 1-indexing - clone.index = 1 - clone.sibling = nil - return clone - end - - local function placeChild( - newFiber: Fiber, - lastPlacedIndex: number, - newIndex: number - ): number - newFiber.index = newIndex - if not shouldTrackSideEffects then - -- Noop. - return lastPlacedIndex - end - local current = newFiber.alternate - if current ~= nil then - local oldIndex = current.index - if oldIndex < lastPlacedIndex then - -- This is a move. - newFiber.flags = Placement - return lastPlacedIndex - else - -- This item can stay in place. - return oldIndex - end - else - -- This is an insertion. - newFiber.flags = Placement - return lastPlacedIndex - end - end - - local function placeSingleChild(newFiber: Fiber): Fiber - -- This is simpler for the single child case. We only need to do a - -- placement for inserting new children. - if shouldTrackSideEffects and newFiber.alternate == nil then - newFiber.flags = Placement - end - return newFiber - end - - local function updateTextNode( - returnFiber: Fiber, - current: Fiber | nil, - textContent: string, - lanes: Lanes - ) - -- ROBLOX FIXME: Luau narrowing issue - if current == nil or (current :: Fiber).tag ~= HostText then - -- Insert - local created = createFiberFromText(textContent, returnFiber.mode, lanes) - created.return_ = returnFiber - return created - else - -- Update - local existing = useFiber(current :: Fiber, textContent) - existing.return_ = returnFiber - return existing - end - end - - local function updateElement( - returnFiber: Fiber, - current: Fiber | nil, - element: ReactElement, - lanes: Lanes - ): Fiber - if current ~= nil then - if - (current :: Fiber).elementType == element.type or - -- Keep this check inline so it only runs on the false path: - (_G.__DEV__ and isCompatibleFamilyForHotReloading(current, element)) - then - -- Move based on index - local existing = useFiber(current :: Fiber, element.props) - existing.ref = coerceRef(returnFiber, current, element) - existing.return_ = returnFiber - if _G.__DEV__ then - existing._debugSource = element._source - existing._debugOwner = element._owner - end - return existing - elseif enableBlocksAPI and (current :: Fiber).tag == Block then - -- The new Block might not be initialized yet. We need to initialize - -- it in case initializing it turns out it would match. - local type = element.type - if type["$$typeof"] == REACT_LAZY_TYPE then - type = resolveLazyType(type) - end - if - type["$$typeof"] == REACT_BLOCK_TYPE and - type._render == (current :: Fiber).type._render - then - -- Same as above but also update the .type field. - local existing = useFiber((current :: Fiber), element.props) - existing.return_ = returnFiber - existing.type = type - if _G.__DEV__ then - existing._debugSource = element._source - existing._debugOwner = element._owner - end - return existing - end - end - end - -- Insert - local created = createFiberFromElement(element, returnFiber.mode, lanes) - created.ref = coerceRef(returnFiber, current, element) - created.return_ = returnFiber - return created - end - - local function updatePortal( - returnFiber: Fiber, - current: Fiber | nil, - portal: ReactPortal, - lanes: Lanes - ): Fiber - -- ROBLOX FIXME: type narrowing. - if current == nil or - (current :: Fiber).tag ~= HostPortal or - (current :: Fiber).stateNode.containerInfo ~= portal.containerInfo or - (current :: Fiber).stateNode.implementation ~= portal.implementation - then - -- Insert - local created = createFiberFromPortal(portal, returnFiber.mode, lanes) - created.return_ = returnFiber - return created - else - -- Update - local existing = useFiber(current :: Fiber, portal.children or {}) - existing.return_ = returnFiber - return existing - end - end - - local function updateFragment( - returnFiber: Fiber, - current: Fiber | nil, - -- ROBLOX TODO: figure out how we should define our Iterable type - -- fragment: Iterable<*>, - fragment: any, - lanes: Lanes, - key: nil | string - ): Fiber - if current == nil or (current :: Fiber).tag ~= Fragment then - -- Insert - local created = createFiberFromFragment( - fragment, - returnFiber.mode, - lanes, - key - ) - created.return_ = returnFiber - return created - else - -- Update - local existing = useFiber(current :: Fiber, fragment) - existing.return_ = returnFiber - return existing - end - end - - -- ROBLOX deviation: Roact stable keys - Support Roact's implementation of - -- stable keys, wherein the key used in the `children` table is used as if it - -- were a `key` prop. Child order doesn't matter in Roblox, so a vast majority - -- of existing Roact code used table keys in this way. - local function assignStableKey(tableKey: any?, newChild: Object): () - -- If there's no assigned key in the element, and the table key is valid, - -- assign it as the element's key. - - -- ROBLOX TODO: Investigate if this is safe; maybe we need to shallow-copy - -- the object if we have a new key, to preserve immutability, but that cost - -- may be severe - if - newChild.key == nil and - (typeof(tableKey) == "string" or typeof(tableKey) == "number") - then - newChild.key = tableKey - end - end - - local function createChild( - returnFiber: Fiber, - newChild: any, - lanes: Lanes, - -- ROBLOX deviation: children table key for compat with Roact's stable keys - tableKey: any? - ): Fiber | nil - if typeof(newChild) == "string" or typeof(newChild) == "number" then - -- Text nodes don't have keys. If the previous node is implicitly keyed - -- we can continue to replace it without aborting even if it is not a text - -- node. - local created = createFiberFromText( - tostring(newChild), - returnFiber.mode, - lanes - ) - created.return_ = returnFiber - return created - end - - if typeof(newChild) == "table" and newChild ~= nil then - -- ROBLOX deviation: Roact stable keys - forward children table key to - -- child if applicable - assignStableKey(tableKey, newChild) - if newChild["$$typeof"] == REACT_ELEMENT_TYPE then - local created = createFiberFromElement( - newChild, - returnFiber.mode, - lanes - ) - created.ref = coerceRef(returnFiber, nil, newChild) - created.return_ = returnFiber - return created - elseif newChild["$$typeof"] == REACT_PORTAL_TYPE then - local created = createFiberFromPortal( - newChild, - returnFiber.mode, - lanes - ) - created.return_ = returnFiber - return created - elseif newChild["$$typeof"] == REACT_LAZY_TYPE then - if enableLazyElements then - local payload = newChild._payload - local init = newChild._init - -- ROBLOX deviation: Roact stable keys - Since the table key was - -- already applied to `newChild` above, we don't need to pass it along - return createChild(returnFiber, init(payload), lanes) - end - end - - if isArray(newChild) or getIteratorFn(newChild) then - local created = createFiberFromFragment( - newChild, - returnFiber.mode, - lanes, - nil - ) - created.return_ = returnFiber - return created - end - - throwOnInvalidObjectType(returnFiber, newChild) - end - - if _G.__DEV__ then - if typeof(newChild) == "function" then - warnOnFunctionType(returnFiber) - end - end - - return nil - end - - local function updateSlot( - returnFiber: Fiber, - oldFiber: Fiber | nil, - newChild: any, - lanes: Lanes, - -- ROBLOX deviation: children table key for compat with Roact's stable keys - tableKey: any? - ): Fiber | nil - -- Update the fiber if the keys match, otherwise return nil. - - local key = nil - if oldFiber then - key = oldFiber.key - end - - if typeof(newChild) == "string" or typeof(newChild) == "number" then - -- Text nodes don't have keys. If the previous node is implicitly keyed - -- we can continue to replace it without aborting even if it is not a text - -- node. - if key ~= nil then - return nil - end - return updateTextNode(returnFiber, oldFiber, tostring(newChild), lanes) - end - - if typeof(newChild) == "table" and newChild ~= nil then - -- ROBLOX deviation: Roact stable keys - forward children table key to - -- child if applicable - assignStableKey(tableKey, newChild) - if newChild["$$typeof"] == REACT_ELEMENT_TYPE then - if newChild.key == key then - if newChild.type == REACT_FRAGMENT_TYPE then - return updateFragment( - returnFiber, - oldFiber, - newChild.props.children, - lanes, - key - ) - end - return updateElement(returnFiber, oldFiber, newChild, lanes) - else - return nil - end - elseif newChild["$$typeof"] == REACT_PORTAL_TYPE then - if newChild.key == key then - return updatePortal(returnFiber, oldFiber, newChild, lanes) - else - return nil - end - elseif newChild["$$typeof"] == REACT_LAZY_TYPE then - if enableLazyElements then - local payload = newChild._payload - local init = newChild._init - -- ROBLOX deviation: Roact stable keys - Since the table key was - -- already applied to `newChild` above, we don't need to pass it along - return updateSlot(returnFiber, oldFiber, init(payload), lanes, nil) - end - end - - if isArray(newChild) or getIteratorFn(newChild) then - if key ~= nil then - return nil - end - - return updateFragment(returnFiber, oldFiber, newChild, lanes, nil) - end - - throwOnInvalidObjectType(returnFiber, newChild) - end - - if _G.__DEV__ then - if typeof(newChild) == "function" then - warnOnFunctionType(returnFiber) - end - end - - return nil - end - - local function updateFromMap( - existingChildren: Map, - returnFiber: Fiber, - newIdx: number, - newChild: any, - lanes: Lanes, - -- ROBLOX deviation: children table key for compat with Roact's stable keys - tableKey: any? - ): Fiber | nil - if typeof(newChild) == "string" or typeof(newChild) == "number" then - -- Text nodes don't have keys, so we neither have to check the old nor - -- new node for the key. If both are text nodes, they match. - local matchedFiber = existingChildren[newIdx] or nil - return updateTextNode(returnFiber, matchedFiber, tostring(newChild), lanes) - end - - if typeof(newChild) == "table" and newChild ~= nil then - -- ROBLOX deviation: Roact stable keys - forward children table key to - -- child if applicable - assignStableKey(tableKey, newChild) - local existingChildrenKey - if newChild["$$typeof"] == REACT_ELEMENT_TYPE then - if newChild.key == nil then - existingChildrenKey = newIdx - else - existingChildrenKey = newChild.key - end - local matchedFiber = - existingChildren[existingChildrenKey] - if newChild.type == REACT_FRAGMENT_TYPE then - return updateFragment( - returnFiber, - matchedFiber, - newChild.props.children, - lanes, - newChild.key - ) - end - return updateElement(returnFiber, matchedFiber, newChild, lanes) - elseif newChild["$$typeof"] == REACT_PORTAL_TYPE then - if newChild.key == nil then - existingChildrenKey = newIdx - else - existingChildrenKey = newChild.key - end - local matchedFiber = - existingChildren[existingChildrenKey] - return updatePortal(returnFiber, matchedFiber, newChild, lanes) - elseif newChild["$$typeof"] == REACT_LAZY_TYPE then - if enableLazyElements then - local payload = newChild._payload - local init = newChild._init - -- ROBLOX deviation: Roact stable keys - Since the table key was - -- already applied to `newChild` above, we don't need to pass it along - return updateFromMap( - existingChildren, - returnFiber, - newIdx, - init(payload), - lanes - ) - end - end - - if isArray(newChild) or getIteratorFn(newChild) then - local matchedFiber = existingChildren[newIdx] - return updateFragment(returnFiber, matchedFiber, newChild, lanes, nil) - end - - throwOnInvalidObjectType(returnFiber, newChild) - end - - if _G.__DEV__ then - if typeof(newChild) == "function" then - warnOnFunctionType(returnFiber) - end - end - - return nil - end - - --[[ - Warns if there is a duplicate or missing key + local function deleteChild(returnFiber: Fiber, childToDelete: Fiber) + if not shouldTrackSideEffects then + -- Noop. + return + end + local deletions = returnFiber.deletions + if deletions == nil then + returnFiber.deletions = { childToDelete } + returnFiber.flags = bit32.bor(returnFiber.flags, Deletion) + else + table.insert(deletions, childToDelete) + end + end + + local function deleteRemainingChildren( + returnFiber: Fiber, + currentFirstChild: Fiber | nil + ) + if not shouldTrackSideEffects then + -- Noop. + return nil + end + + -- TODO: For the shouldClone case, this could be micro-optimized a bit by + -- assuming that after the first child we've already added everything. + local childToDelete = currentFirstChild + while childToDelete ~= nil do + deleteChild(returnFiber, childToDelete) + childToDelete = childToDelete.sibling + end + return nil + end + + local function mapRemainingChildren( + returnFiber: Fiber, + currentFirstChild: Fiber + ): { [string | number]: Fiber } + -- Add the remaining children to a temporary map so that we can find them by + -- keys quickly. Implicit (null) keys get added to this set with their index + -- instead. + local existingChildren: { [string | number]: Fiber } = {} + + -- ROBLOX FIXME Luau: Luau doesn't correctly infer in repeat until nil scenarios + local existingChild: Fiber? = currentFirstChild + while existingChild ~= nil do + if existingChild.key ~= nil then + existingChildren[existingChild.key] = existingChild + else + existingChildren[existingChild.index] = existingChild + end + existingChild = existingChild.sibling + end + return existingChildren + end + + local function useFiber(fiber: Fiber, pendingProps: any): Fiber + -- We currently set sibling to nil and index to 0 here because it is easy + -- to forget to do before returning it. E.g. for the single child case. + local clone = createWorkInProgress(fiber, pendingProps) + -- ROBLOX deviation: set index to 1 for 1-indexing + clone.index = 1 + clone.sibling = nil + return clone + end + + local function placeChild( + newFiber: Fiber, + lastPlacedIndex: number, + newIndex: number + ): number + newFiber.index = newIndex + if not shouldTrackSideEffects then + -- Noop. + return lastPlacedIndex + end + local current = newFiber.alternate + if current ~= nil then + local oldIndex = current.index + if oldIndex < lastPlacedIndex then + -- This is a move. + newFiber.flags = bit32.bor(newFiber.flags, Placement) + return lastPlacedIndex + else + -- This item can stay in place. + return oldIndex + end + else + -- This is an insertion. + newFiber.flags = bit32.bor(newFiber.flags, Placement) + return lastPlacedIndex + end + end + + local function placeSingleChild(newFiber: Fiber): Fiber + -- This is simpler for the single child case. We only need to do a + -- placement for inserting new children. + if shouldTrackSideEffects and newFiber.alternate == nil then + newFiber.flags = bit32.bor(newFiber.flags, Placement) + end + return newFiber + end + + local function updateTextNode( + returnFiber: Fiber, + current: Fiber | nil, + textContent: string, + lanes: Lanes + ) + -- ROBLOX FIXME: Luau narrowing issue + if current == nil or (current :: Fiber).tag ~= HostText then + -- Insert + local created = createFiberFromText(textContent, returnFiber.mode, lanes) + created.return_ = returnFiber + return created + else + -- Update + local existing = useFiber(current :: Fiber, textContent) + existing.return_ = returnFiber + return existing + end + end + + local function updateElement( + returnFiber: Fiber, + current: Fiber | nil, + element: ReactElement, + lanes: Lanes + ): Fiber + if current ~= nil then + if + (current :: Fiber).elementType == element.type + or (__DEV__ and isCompatibleFamilyForHotReloading(current, element)) + then + -- Move based on index + local existing = useFiber(current :: Fiber, element.props) + existing.ref = coerceRef(returnFiber, current, element) + existing.return_ = returnFiber + if __DEV__ then + existing._debugSource = element._source + existing._debugOwner = element._owner + end + return existing + elseif enableBlocksAPI and (current :: Fiber).tag == Block then + -- The new Block might not be initialized yet. We need to initialize + -- it in case initializing it turns out it would match. + -- ROBLOX FIXME Luau: Luau should analyze closure and create union of assignments + local type_: any = element.type + if type(type_) == "table" and type_["$$typeof"] == REACT_LAZY_TYPE then + type_ = resolveLazyType(type_) :: LazyComponent + end + if + type_["$$typeof"] == REACT_BLOCK_TYPE + and type_._render == (current :: Fiber).type._render + then + -- Same as above but also update the .type field. + local existing = useFiber(current :: Fiber, element.props) + existing.return_ = returnFiber + existing.type = type_ + if __DEV__ then + existing._debugSource = element._source + existing._debugOwner = element._owner + end + return existing + end + end + end + -- Insert + local created = createFiberFromElement(element, returnFiber.mode, lanes) + created.ref = coerceRef(returnFiber, current, element) + created.return_ = returnFiber + return created + end + + local function updatePortal( + returnFiber: Fiber, + current: Fiber | nil, + portal: ReactPortal, + lanes: Lanes + ): Fiber + -- ROBLOX FIXME: type narrowing. + if + current == nil + or (current :: Fiber).tag ~= HostPortal + or (current :: Fiber).stateNode.containerInfo ~= portal.containerInfo + or (current :: Fiber).stateNode.implementation ~= portal.implementation + then + -- Insert + local created = createFiberFromPortal(portal, returnFiber.mode, lanes) + created.return_ = returnFiber + return created + else + -- Update + local existing = useFiber(current :: Fiber, portal.children or {}) + existing.return_ = returnFiber + return existing + end + end + + local function updateFragment( + returnFiber: Fiber, + current: Fiber | nil, + -- ROBLOX TODO: figure out how we should define our Iterable type + -- fragment: Iterable<*>, + fragment: any, + lanes: Lanes, + key: nil | string + ): Fiber + if current == nil or (current :: Fiber).tag ~= Fragment then + -- Insert + local created = + createFiberFromFragment(fragment, returnFiber.mode, lanes, key) + created.return_ = returnFiber + return created + else + -- Update + local existing = useFiber(current :: Fiber, fragment) + existing.return_ = returnFiber + return existing + end + end + + -- ROBLOX deviation: Roact stable keys - Support Roact's implementation of + -- stable keys, wherein the key used in the `children` table is used as if it + -- were a `key` prop. Child order doesn't matter in Roblox, so a vast majority + -- of existing Roact code used table keys in this way. + local function assignStableKey(tableKey: any?, newChild: Object): () + -- If there's no assigned key in the element, and the table key is valid, + -- assign it as the element's key. + -- If the key is a table, convert it to a string. + + -- ROBLOX TODO: Investigate if this is safe; maybe we need to shallow-copy + -- the object if we have a new key, to preserve immutability, but that cost + -- may be severe + if newChild.key == nil then + -- ROBLOX performance? only call typeof once, and only if first condition is true + local typeOfTableKey = type(tableKey) + if typeOfTableKey == "string" or typeOfTableKey == "number" then + newChild.key = tableKey + elseif typeOfTableKey == "table" then + newChild.key = tostring(tableKey) + end + end + end + + local function createChild( + returnFiber: Fiber, + newChild: any, + lanes: Lanes, + -- ROBLOX deviation: children table key for compat with Roact's stable keys + tableKey: any? + ): Fiber | nil + -- ROBLOX performance: early exit for nil newChild since no actions will be taken + if newChild == nil then + return nil + end + + -- ROBLOX performance: avoid repeated calls to typeof, since Luau doesn't optimize + local typeOfNewChild = type(newChild) + + -- ROBLOX performance: hoist more common ROblox case (non-string/number) first to reduce cmp in hot path + if typeOfNewChild == "table" then + -- ROBLOX deviation: Roact stable keys - forward children table key to + -- child if applicable + assignStableKey(tableKey, newChild) + -- ROBLOX performance: avoid repeated indexing to $$typeof + local newChildTypeof = newChild["$$typeof"] + if newChildTypeof == REACT_ELEMENT_TYPE then + local created = createFiberFromElement(newChild, returnFiber.mode, lanes) + created.ref = coerceRef(returnFiber, nil, newChild) + created.return_ = returnFiber + return created + elseif newChildTypeof == REACT_PORTAL_TYPE then + local created = createFiberFromPortal(newChild, returnFiber.mode, lanes) + created.return_ = returnFiber + return created + elseif newChildTypeof == REACT_LAZY_TYPE then + if enableLazyElements then + local payload = newChild._payload + local init = newChild._init + -- ROBLOX deviation: Roact stable keys - Since the table key was + -- already applied to `newChild` above, we don't need to pass it along + return createChild(returnFiber, init(payload), lanes) + end + end + + -- ROBLOX deviation peformance: this is the equiv of checking for a table, and we already know typeof(newChild) is a table in this branch + -- if isArray(newChild) or getIteratorFn(newChild) then + local created = + createFiberFromFragment(newChild, returnFiber.mode, lanes, nil) + created.return_ = returnFiber + return created + + -- ROBLOX performance deviation: unreachable with the above table check + -- throwOnInvalidObjectType(returnFiber, newChild) + end + + if typeOfNewChild == "string" or typeOfNewChild == "number" then + -- Text nodes don't have keys. If the previous node is implicitly keyed + -- we can continue to replace it without aborting even if it is not a text + -- node. + local created = + createFiberFromText(tostring(newChild), returnFiber.mode, lanes) + created.return_ = returnFiber + return created + end + + if __DEV__ then + if typeOfNewChild == "function" then + warnOnFunctionType(returnFiber) + end + end + + return nil + end + + local function updateSlot( + returnFiber: Fiber, + oldFiber: Fiber | nil, + newChild: any, + lanes: Lanes, + -- ROBLOX deviation: children table key for compat with Roact's stable keys + tableKey: any? + ): Fiber | nil + -- ROBLOX performance: early exit for nil newChild since no actions will be taken + if newChild == nil then + return nil + end + + -- Update the fiber if the keys match, otherwise return nil. + + local key = if oldFiber ~= nil then oldFiber.key else nil + -- ROBLOX performance: avoid repeated calls to typeof since Luau doesn't cache + local typeOfNewChild = type(newChild) + + if typeOfNewChild == "table" then + -- ROBLOX deviation: Roact stable keys - forward children table key to + -- child if applicable + assignStableKey(tableKey, newChild) + -- ROBLOX performance: avoid repeated indexing to $$typeof + local newChildTypeof = newChild["$$typeof"] + if newChildTypeof == REACT_ELEMENT_TYPE then + if newChild.key == key then + if newChild.type == REACT_FRAGMENT_TYPE then + return updateFragment( + returnFiber, + oldFiber, + newChild.props.children, + lanes, + key :: string? + ) + end + return updateElement(returnFiber, oldFiber, newChild, lanes) + else + return nil + end + elseif newChildTypeof == REACT_PORTAL_TYPE then + if newChild.key == key then + return updatePortal(returnFiber, oldFiber, newChild, lanes) + else + return nil + end + elseif newChildTypeof == REACT_LAZY_TYPE then + if enableLazyElements then + local payload = newChild._payload + local init = newChild._init + -- ROBLOX deviation: Roact stable keys - Since the table key was + -- already applied to `newChild` above, we don't need to pass it along + return updateSlot(returnFiber, oldFiber, init(payload), lanes) + end + end + + -- ROBLOX deviation peformance: this is the equiv of checking for a table, and we already know typeof(newChild) is a table in this branch + -- if isArray(newChild) or getIteratorFn(newChild) then + if key ~= nil then + return nil + end + + return updateFragment(returnFiber, oldFiber, newChild, lanes) + + -- ROBLOX performance deviation: unreachable with the above table check + -- throwOnInvalidObjectType(returnFiber, newChild) + end + + -- ROBLOX performance: do this compare last to save 2 string cmp in typical Roblox hot path + if typeOfNewChild == "string" or typeOfNewChild == "number" then + -- Text nodes don't have keys. If the previous node is implicitly keyed + -- we can continue to replace it without aborting even if it is not a text + -- node. + if key ~= nil then + return nil + end + return updateTextNode(returnFiber, oldFiber, tostring(newChild), lanes) + end + + if __DEV__ then + if typeOfNewChild == "function" then + warnOnFunctionType(returnFiber) + end + end + + return nil + end + + local function updateFromMap( + existingChildren: Map, + returnFiber: Fiber, + newIdx: number, + newChild: any, + lanes: Lanes, + -- ROBLOX deviation: children table key for compat with Roact's stable keys + tableKey: any? + ): Fiber | nil + -- ROBLOX performance: early exit for nil newChild since no actions will be taken + if newChild == nil then + return nil + end + + -- ROBLOX performance: avoid repeated calls to typeof since Luau doesn't cache + local typeOfNewChild = type(newChild) + + if typeOfNewChild == "table" then + -- ROBLOX deviation: Roact stable keys - forward children table key to + -- child if applicable + assignStableKey(tableKey, newChild) + local existingChildrenKey + -- ROBLOX performance: avoid repeated indexing to $$typeof + local newChildTypeof = newChild["$$typeof"] + if newChildTypeof == REACT_ELEMENT_TYPE then + if newChild.key == nil then + existingChildrenKey = newIdx + else + existingChildrenKey = newChild.key + end + local matchedFiber = existingChildren[existingChildrenKey] + if newChild.type == REACT_FRAGMENT_TYPE then + return updateFragment( + returnFiber, + matchedFiber, + newChild.props.children, + lanes, + newChild.key + ) + end + return updateElement(returnFiber, matchedFiber, newChild, lanes) + elseif newChildTypeof == REACT_PORTAL_TYPE then + if newChild.key == nil then + existingChildrenKey = newIdx + else + existingChildrenKey = newChild.key + end + local matchedFiber = existingChildren[existingChildrenKey] + return updatePortal(returnFiber, matchedFiber, newChild, lanes) + elseif newChildTypeof == REACT_LAZY_TYPE then + if enableLazyElements then + local payload = newChild._payload + local init = newChild._init + -- ROBLOX deviation: Roact stable keys - Since the table key was + -- already applied to `newChild` above, we don't need to pass it along + return updateFromMap( + existingChildren, + returnFiber, + newIdx, + init(payload), + lanes + ) + end + end + + -- ROBLOX deviation peformance: this is the equiv of checking for a table, and we already know typeof(newChild) is a table in this branch + -- if isArray(newChild) or getIteratorFn(newChild) then + local matchedFiber = existingChildren[newIdx] + return updateFragment(returnFiber, matchedFiber, newChild, lanes) + + -- ROBLOX performance deviation: unreachable with the above table check + -- throwOnInvalidObjectType(returnFiber, newChild) + end + + -- ROBLOX performance: do this compare last, as Roblox won't really support text nodes directly + if typeOfNewChild == "string" or typeOfNewChild == "number" then + -- Text nodes don't have keys, so we neither have to check the old nor + -- new node for the key. If both are text nodes, they match. + local matchedFiber = existingChildren[newIdx] or nil + return updateTextNode(returnFiber, matchedFiber, tostring(newChild), lanes) + end + + if __DEV__ then + if typeOfNewChild == "function" then + warnOnFunctionType(returnFiber) + end + end + + return nil + end + + --[[ + Warns if there is a duplicate or missing key ]] - local function warnOnInvalidKey( - child: any, - knownKeys: Set | nil, - returnFiber: Fiber - ): Set | nil - if _G.__DEV__ then - if typeof(child) ~= "table" or child == nil then - return knownKeys - end - if child["$$typeof"] == REACT_ELEMENT_TYPE or child["$$typeof"] == REACT_PORTAL_TYPE then - warnForMissingKey(child, returnFiber) - local key = child.key - if typeof(key) ~= "string" then - -- break - elseif knownKeys == nil then - knownKeys = {} - (knownKeys :: Set)[key] = true - elseif not (knownKeys :: Set)[key] then - (knownKeys :: Set)[key] = true - else - console.error( - "Encountered two children with the same key, `%s`. " .. - "Keys should be unique so that components maintain their identity " .. - "across updates. Non-unique keys may cause children to be " .. - "duplicated and/or omitted — the behavior is unsupported and " .. - "could change in a future version.", - key - ) - end - elseif child["$$typeof"] == REACT_LAZY_TYPE then - if enableLazyElements then - local payload = child._payload - local init = child._init - warnOnInvalidKey(init(payload), knownKeys, returnFiber) - end - end - end - return knownKeys - end - - local function reconcileChildrenArray( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - newChildren: Array, - lanes: Lanes - ): Fiber | nil - -- This algorithm can't optimize by searching from both ends since we - -- don't have backpointers on fibers. I'm trying to see how far we can get - -- with that model. If it ends up not being worth the tradeoffs, we can - -- add it later. - - -- Even with a two ended optimization, we'd want to optimize for the case - -- where there are few changes and brute force the comparison instead of - -- going for the Map. It'd like to explore hitting that path first in - -- forward-only mode and only go for the Map once we notice that we need - -- lots of look ahead. This doesn't handle reversal as well as two ended - -- search but that's unusual. Besides, for the two ended optimization to - -- work on Iterables, we'd need to copy the whole set. - - -- In this first iteration, we'll just live with hitting the bad case - -- (adding everything to a Map) in for every insert/move. - - -- If you change this code, also update reconcileChildrenIterator() which - -- uses the same algorithm. - - if _G.__DEV__ then - -- First, validate keys. - local knownKeys = nil - for i, child in ipairs(newChildren) do - knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber) - end - end - - local resultingFirstChild: Fiber | nil = nil - local previousNewFiber: Fiber | nil = nil - - local oldFiber = currentFirstChild - local lastPlacedIndex = 1 - local newIdx = 1 - local nextOldFiber = nil - -- ROBLOX deviation: use while loop in place of modified for loop - while oldFiber ~= nil and newIdx <= #newChildren do - if oldFiber.index > newIdx then - nextOldFiber = oldFiber - oldFiber = nil - else - nextOldFiber = oldFiber.sibling - end - local newFiber = updateSlot( - returnFiber, - oldFiber, - newChildren[newIdx], - lanes - ) - if newFiber == nil then - -- TODO: This breaks on empty slots like nil children. That's - -- unfortunate because it triggers the slow path all the time. We need - -- a better way to communicate whether this was a miss or nil, - -- boolean, undefined, etc. - if oldFiber == nil then - oldFiber = nextOldFiber - end - break - end - if shouldTrackSideEffects then - if oldFiber and newFiber.alternate == nil then - -- We matched the slot, but we didn't reuse the existing fiber, so we - -- need to delete the existing child. - deleteChild(returnFiber, oldFiber) - end - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - -- TODO: Move out of the loop. This only happens for the first run. - resultingFirstChild = newFiber - else - -- TODO: Defer siblings if we're not at the right index for this slot. - -- I.e. if we had nil values before, then we want to defer this - -- for each nil value. However, we also don't want to call updateSlot - -- with the previous one. - (previousNewFiber :: Fiber).sibling = newFiber - end - previousNewFiber = newFiber - oldFiber = nextOldFiber - -- deviation: increment manually since we're not using a modified for loop - newIdx += 1 - end - - if newIdx > #newChildren then - -- We've reached the end of the new children. We can delete the rest. - deleteRemainingChildren(returnFiber, oldFiber) - return resultingFirstChild - end - - if oldFiber == nil then - -- If we don't have any more existing children we can choose a fast path - -- since the rest will all be insertions. - -- deviation: use while loop in place of modified for loop - while newIdx <= #newChildren do - local newFiber = createChild(returnFiber, newChildren[newIdx], lanes) - if newFiber == nil then - -- deviation: increment manually since we're not using a modified for loop - newIdx += 1; - continue - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - -- TODO: Move out of the loop. This only happens for the first run. - resultingFirstChild = newFiber - else - (previousNewFiber :: Fiber).sibling = newFiber - end - previousNewFiber = newFiber - -- deviation: increment manually since we're not using a modified for loop - newIdx += 1; - end - return resultingFirstChild - end - - -- Add all children to a key map for quick lookups. - local existingChildren = mapRemainingChildren(returnFiber, oldFiber) - -- Keep scanning and use the map to restore deleted items as moves. - -- ROBLOX deviation: use while loop in place of modified for loop - while newIdx <= #newChildren do - local newFiber = updateFromMap( - existingChildren, - returnFiber, - newIdx, - newChildren[newIdx], - lanes - ) - if newFiber ~= nil then - if shouldTrackSideEffects then - if newFiber.alternate ~= nil then - -- The new fiber is a work in progress, but if there exists a - -- current, that means that we reused the fiber. We need to delete - -- it from the child list so that we don't add it to the deletion - -- list. - -- deviation: Split out ternary into safer/more readable logic - local key - if newFiber.key == nil then - key = newIdx - else - key = newFiber.key - end - existingChildren[key] = nil - end - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - resultingFirstChild = newFiber - else - (previousNewFiber :: Fiber).sibling = newFiber - end - previousNewFiber = newFiber - end - -- deviation: increment manually since we're not using a modified for loop - newIdx += 1 - end - - if shouldTrackSideEffects then - -- Any existing children that weren't consumed above were deleted. We need - -- to add them to the deletion list. - for _, child in pairs(existingChildren) do - deleteChild(returnFiber, child) - end - end - - return resultingFirstChild - end - - -- ROBLOX TODO: LUAFDN-254 - local function reconcileChildrenIterator( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - -- ROBLOX TODO: figure out our Iterable<> interface - -- newChildrenIterable: Iterable<*>, - newChildrenIterable: any, - lanes: Lanes - ): Fiber | nil - -- This is the same implementation as reconcileChildrenArray(), - -- but using the iterator instead. - - local iteratorFn = getIteratorFn(newChildrenIterable) - invariant( - typeof(iteratorFn) == 'function', - 'An object is not an iterable. This error is likely caused by a bug in ' .. - 'React. Please file an issue.' - ) - - if _G.__DEV__ then - -- We don't support rendering Generators because it's a mutation. - -- See https://github.com/facebook/react/issues/12995 - -- ROBLOX deviation: Lua doesn't have built-in generators - -- if - -- typeof(Symbol) == 'function' and - -- -- $FlowFixMe Flow doesn't know about toStringTag - -- newChildrenIterable[Symbol.toStringTag] == 'Generator' - -- then - -- if not didWarnAboutGenerators then - -- console.error( - -- 'Using Generators as children is unsupported and will likely yield ' .. - -- 'unexpected results because enumerating a generator mutates it. ' .. - -- 'You may convert it to an array with `Array.from()` or the ' .. - -- '`[...spread]` operator before rendering. Keep in mind ' .. - -- 'you might need to polyfill these features for older browsers.' - -- ) - -- end - -- didWarnAboutGenerators = true - -- end - - -- Warn about using Maps as children - if newChildrenIterable.entries == iteratorFn then - if not didWarnAboutMaps then - console.error( - 'Using Maps as children is not supported. ' .. - 'Use an array of keyed ReactElements instead.' - ) - end - didWarnAboutMaps = true - end - - -- First, validate keys. - -- We'll get a different iterator later for the main pass. - local newChildren = iteratorFn(newChildrenIterable) - if newChildren then - local knownKeys = nil - local step = newChildren.next() - while not step.done do - step = newChildren.next() - local child = step.value - knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber) - end - end - end - - local newChildren = iteratorFn(newChildrenIterable) - invariant(newChildren ~= nil, 'An iterable object provided no iterator.') - - local resultingFirstChild: Fiber | nil = nil - local previousNewFiber: Fiber = nil - - local oldFiber = currentFirstChild - local lastPlacedIndex = 1 - local newIdx = 1 - local nextOldFiber = nil - - local step = newChildren.next() - while oldFiber ~= nil and not step.done do - if oldFiber.index > newIdx then - nextOldFiber = oldFiber - oldFiber = nil - else - nextOldFiber = oldFiber.sibling - end - local newFiber = updateSlot(returnFiber, oldFiber, step.value, lanes, step.key) - if newFiber == nil then - -- TODO: This breaks on empty slots like nil children. That's - -- unfortunate because it triggers the slow path all the time. We need - -- a better way to communicate whether this was a miss or nil, - -- boolean, undefined, etc. - if oldFiber == nil then - oldFiber = nextOldFiber - end - break - end - if shouldTrackSideEffects then - if oldFiber and newFiber.alternate == nil then - -- We matched the slot, but we didn't reuse the existing fiber, so we - -- need to delete the existing child. - deleteChild(returnFiber, oldFiber) - end - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - -- TODO: Move out of the loop. This only happens for the first run. - resultingFirstChild = newFiber - else - -- TODO: Defer siblings if we're not at the right index for this slot. - -- I.e. if we had nil values before, then we want to defer this - -- for each nil value. However, we also don't want to call updateSlot - -- with the previous one. - previousNewFiber.sibling = newFiber - end - previousNewFiber = newFiber - oldFiber = nextOldFiber - - newIdx += 1 - step = newChildren.next() - end - - if step.done then - -- We've reached the end of the new children. We can delete the rest. - deleteRemainingChildren(returnFiber, oldFiber) - return resultingFirstChild - end - - if oldFiber == nil then - -- If we don't have any more existing children we can choose a fast path - -- since the rest will all be insertions. - while not step.done do - local newFiber = createChild(returnFiber, step.value, lanes, step.key) - if newFiber == nil then - newIdx += 1 - step = newChildren.next() - continue - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - -- TODO: Move out of the loop. This only happens for the first run. - resultingFirstChild = newFiber - else - previousNewFiber.sibling = newFiber - end - previousNewFiber = newFiber - - newIdx += 1 - step = newChildren.next() - end - return resultingFirstChild - end - - -- Add all children to a key map for quick lookups. - local existingChildren = mapRemainingChildren(returnFiber, oldFiber) - - -- Keep scanning and use the map to restore deleted items as moves. - while not step.done do - local newFiber = updateFromMap( - existingChildren, - returnFiber, - newIdx, - step.value, - lanes, - step.key - ) - if newFiber ~= nil then - if shouldTrackSideEffects then - if newFiber.alternate ~= nil then - -- The new fiber is a work in progress, but if there exists a - -- current, that means that we reused the fiber. We need to delete - -- it from the child list so that we don't add it to the deletion - -- list. - if newFiber.key == nil then - existingChildren[newIdx] = nil - else - existingChildren[newFiber.key] = nil - end - end - end - lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) - if previousNewFiber == nil then - resultingFirstChild = newFiber - else - previousNewFiber.sibling = newFiber - end - previousNewFiber = newFiber - end - - newIdx += 1 - step = newChildren.next() - end - - if shouldTrackSideEffects then - -- Any existing children that weren't consumed above were deleted. We need - -- to add them to the deletion list. - for _, child in pairs(existingChildren) do - deleteChild(returnFiber, child) - end - end - - return resultingFirstChild - end - - local function reconcileSingleTextNode( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - textContent: string, - lanes: Lanes - ): Fiber - -- There's no need to check for keys on text nodes since we don't have a - -- way to define them. - -- ROBLOX FIXME: Luau narrowing issue - if currentFirstChild ~= nil and (currentFirstChild :: Fiber).tag == HostText then - -- We already have an existing node so let's just update it and delete - -- the rest. - deleteRemainingChildren(returnFiber, (currentFirstChild :: Fiber).sibling) - local existing = useFiber(currentFirstChild :: Fiber, textContent) - existing.return_ = returnFiber - return existing - end - -- The existing first child is not a text node so we need to create one - -- and delete the existing ones. - deleteRemainingChildren(returnFiber, currentFirstChild) - local created = createFiberFromText(textContent, returnFiber.mode, lanes) - created.return_ = returnFiber - return created - end - - local function reconcileSingleElement( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - element: ReactElement, - lanes: Lanes - ): Fiber - local key = element.key - local child = currentFirstChild - while child ~= nil do - -- TODO: If key == nil and child.key == nil, then this only applies to - -- the first item in the list. - if child.key == key then - if child.tag == Fragment then - if element.type == REACT_FRAGMENT_TYPE then - deleteRemainingChildren(returnFiber, child.sibling) - local existing = useFiber(child, element.props.children) - existing.return_ = returnFiber - if _G.__DEV__ then - existing._debugSource = element._source - existing._debugOwner = element._owner - end - return existing - end - elseif child.tag == Block then - unimplemented("reconcileSingleElement: Block") - -- if (enableBlocksAPI) { - -- let type = element.type; - -- if (type.$$typeof === REACT_LAZY_TYPE) { - -- type = resolveLazyType(type); - -- } - -- if (type.$$typeof === REACT_BLOCK_TYPE) { - -- // The new Block might not be initialized yet. We need to initialize - -- // it in case initializing it turns out it would match. - -- if ( - -- ((type: any): BlockComponent)._render === - -- (child.type: BlockComponent)._render - -- ) { - -- deleteRemainingChildren(returnFiber, child.sibling); - -- const existing = useFiber(child, element.props); - -- existing.type = type; - -- existing.return = returnFiber; - -- if (__DEV__) { - -- existing._debugSource = element._source; - -- existing._debugOwner = element._owner; - -- } - -- return existing; - -- } - -- } - -- } - -- // We intentionally fallthrough here if enableBlocksAPI is not on. - -- // eslint-disable-next-lined no-fallthrough - else - if - child.elementType == element.type or - -- Keep this check inline so it only runs on the false path: - (_G.__DEV__ and isCompatibleFamilyForHotReloading(child, element)) - then - deleteRemainingChildren(returnFiber, child.sibling) - local existing = useFiber(child, element.props) - existing.ref = coerceRef(returnFiber, child, element) - existing.return_ = returnFiber - if _G.__DEV__ then - existing._debugSource = element._source - existing._debugOwner = element._owner - end - return existing - end - end - -- Didn't match. - deleteRemainingChildren(returnFiber, child) - break - else - deleteChild(returnFiber, child) - end - child = child.sibling - end - - if element.type == REACT_FRAGMENT_TYPE then - local created = createFiberFromFragment( - element.props.children, - returnFiber.mode, - lanes, - element.key - ) - created.return_ = returnFiber - return created - else - local created = createFiberFromElement(element, returnFiber.mode, lanes) - created.ref = coerceRef(returnFiber, currentFirstChild, element) - created.return_ = returnFiber - return created - end - end - - local function reconcileSinglePortal( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - portal: ReactPortal, - lanes: Lanes - ): Fiber - local key = portal.key - local child = currentFirstChild - while child ~= nil do - -- TODO: If key == nil and child.key == nil, then this only applies to - -- the first item in the list. - if child.key == key then - if - child.tag == HostPortal and - child.stateNode.containerInfo == portal.containerInfo and - child.stateNode.implementation == portal.implementation - then - deleteRemainingChildren(returnFiber, child.sibling) - local existing = useFiber(child, portal.children or {}) - existing.return_ = returnFiber - return existing - else - deleteRemainingChildren(returnFiber, child) - break - end - else - deleteChild(returnFiber, child) - end - child = child.sibling - end - - local created = createFiberFromPortal(portal, returnFiber.mode, lanes) - created.return_ = returnFiber - return created - end - - -- This API will tag the children with the side-effect of the reconciliation - -- itself. They will be added to the side-effect list as we pass through the - -- children and the parent. - local function reconcileChildFibers( - returnFiber: Fiber, - currentFirstChild: Fiber | nil, - newChild: any, - lanes: Lanes - ): Fiber | nil - -- This function is not recursive. - -- If the top level item is an array, we treat it as a set of children, - -- not as a fragment. Nested arrays on the other hand will be treated as - -- fragment nodes. Recursion happens at the normal flow. - - -- Handle top level unkeyed fragments as if they were arrays. - -- This leads to an ambiguity between <>{[...]} and <>.... - -- We treat the ambiguous cases above the same. - local isUnkeyedTopLevelFragment = - typeof(newChild) == "table" and - newChild.type == REACT_FRAGMENT_TYPE and - newChild.key == nil - if isUnkeyedTopLevelFragment then - newChild = newChild.props.children - end - - -- Handle object types - local isObject = typeof(newChild) == "table" and newChild ~= nil - - if isObject then - if newChild["$$typeof"] == REACT_ELEMENT_TYPE then - return placeSingleChild( - reconcileSingleElement( - returnFiber, - currentFirstChild, - newChild, - lanes - ) - ) - elseif newChild["$$typeof"] == REACT_PORTAL_TYPE then - return placeSingleChild( - reconcileSinglePortal( - returnFiber, - currentFirstChild, - newChild, - lanes - ) - ) - elseif newChild["$$typeof"] == REACT_LAZY_TYPE then - if enableLazyElements then - local payload = newChild._payload - local init = newChild._init - -- TODO: This function is supposed to be non-recursive. - return reconcileChildFibers( - returnFiber, - currentFirstChild, - init(payload), - lanes - ) - end - end - end - - if typeof(newChild) == "string" or typeof(newChild) == "number" then - return placeSingleChild( - reconcileSingleTextNode( - returnFiber, - currentFirstChild, - "" .. newChild, - lanes - ) - ) - end - - if isArray(newChild) then - return reconcileChildrenArray( - returnFiber, - currentFirstChild, - newChild, - lanes - ) - end - - if getIteratorFn(newChild) then - return reconcileChildrenIterator( - returnFiber, - currentFirstChild, - newChild, - lanes - ) - end - - if isObject then - unimplemented("throwOnInvalidObjectType") - -- throwOnInvalidObjectType(returnFiber, newChild) - end - - if _G.__DEV__ then - if typeof(newChild) == "function" then - warnOnFunctionType(returnFiber) - end - end - if typeof(newChild) == "nil" and not isUnkeyedTopLevelFragment then - -- deviation: need a flag here to simulate switch/case fallthrough + break - local shouldFallThrough = false - -- If the new child is undefined, and the return fiber is a composite - -- component, throw an error. If Fiber return types are disabled, - -- we already threw above. - -- ROBLOX deviation: With coercion of no returns to `nil`, it - -- if returnFiber.tag == ClassComponent then - -- if _G.__DEV__ then - -- isn't necessary to special case this scenario - -- local instance = returnFiber.stateNode - -- if instance.render._isMockFunction then - -- -- We allow auto-mocks to proceed as if they're returning nil. - -- shouldFallThrough = true - -- end - -- end - -- end - -- Intentionally fall through to the next case, which handles both - -- functions and classes - -- eslint-disable-next-lined no-fallthrough - if - shouldFallThrough and - (returnFiber.tag == ClassComponent - or returnFiber.tag == FunctionComponent - or returnFiber.tag == ForwardRef - or returnFiber.tag == SimpleMemoComponent) - then - invariant( - false, - "%s(...): Nothing was returned from render. This usually means a " .. - "return statement is missing. Or, to render nothing, " .. - "return nil.", - getComponentName(returnFiber.type) or "Component" - ) - end - end - - -- Remaining cases are all treated as empty. - return deleteRemainingChildren(returnFiber, currentFirstChild) - end - - return reconcileChildFibers + local function warnOnInvalidKey( + child: any, + knownKeys: Set | nil, + returnFiber: Fiber + ): Set | nil + if __DEV__ then + if child == nil or type(child) ~= "table" then + return knownKeys + end + -- ROBLOX performance: avoid repeated indexing to $$typeof + local childTypeof = child["$$typeof"] + if childTypeof == REACT_ELEMENT_TYPE or childTypeof == REACT_PORTAL_TYPE then + warnForMissingKey(child, returnFiber) + local key = child.key + if type(key) ~= "string" then + -- break + elseif knownKeys == nil then + knownKeys = {}; + (knownKeys :: Set)[key] = true + elseif not (knownKeys :: Set)[key] then + (knownKeys :: Set)[key] = true + else + console.error( + "Encountered two children with the same key, `%s`. " + .. "Keys should be unique so that components maintain their identity " + .. "across updates. Non-unique keys may cause children to be " + .. "duplicated and/or omitted — the behavior is unsupported and " + .. "could change in a future version.", + key + ) + end + elseif childTypeof == REACT_LAZY_TYPE then + if enableLazyElements then + local payload = child._payload + local init = child._init + warnOnInvalidKey(init(payload), knownKeys, returnFiber) + end + end + end + return knownKeys + end + + local function reconcileChildrenArray( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + newChildren: Array, + lanes: Lanes + ): Fiber | nil + -- This algorithm can't optimize by searching from both ends since we + -- don't have backpointers on fibers. I'm trying to see how far we can get + -- with that model. If it ends up not being worth the tradeoffs, we can + -- add it later. + + -- Even with a two ended optimization, we'd want to optimize for the case + -- where there are few changes and brute force the comparison instead of + -- going for the Map. It'd like to explore hitting that path first in + -- forward-only mode and only go for the Map once we notice that we need + -- lots of look ahead. This doesn't handle reversal as well as two ended + -- search but that's unusual. Besides, for the two ended optimization to + -- work on Iterables, we'd need to copy the whole set. + + -- In this first iteration, we'll just live with hitting the bad case + -- (adding everything to a Map) in for every insert/move. + + -- If you change this code, also update reconcileChildrenIterator() which + -- uses the same algorithm. + + if __DEV__ then + -- First, validate keys. + local knownKeys = nil + for i, child in newChildren do + knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber) + end + end + + local resultingFirstChild: Fiber | nil = nil + local previousNewFiber: Fiber | nil = nil + + local oldFiber: Fiber | nil = currentFirstChild + local lastPlacedIndex = 1 + local newIdx = 1 + local nextOldFiber: Fiber | nil = nil + -- ROBLOX performance: don't re-evaluate length of newChildren on each iteration through the loop + local newChildrenCount = #newChildren + -- ROBLOX deviation: use while loop in place of modified for loop + while oldFiber ~= nil and newIdx <= newChildrenCount do + if oldFiber.index > newIdx then + nextOldFiber = oldFiber + oldFiber = nil + else + nextOldFiber = oldFiber.sibling + end + --[[ + ROBLOX DEVIATION: We pass newIdx to createChild to ensure that children are + assigned a key, assuming the child is not an array itself. We only need to + pass newIdx if the child is actually a React element. If the child is a + string or number, a key is never assigned, so we do not pass newIdx as a key. + ]] + local newFiber + -- ROBLOX performance: avoid repeated indexing of newChildren to newIdx + local newChildNewIdx = newChildren[newIdx] + if + newChildNewIdx ~= nil + and type(newChildNewIdx) == "table" + and newChildNewIdx["$$typeof"] ~= nil + then + newFiber = + updateSlot(returnFiber, oldFiber, newChildNewIdx, lanes, newIdx) + else + newFiber = updateSlot(returnFiber, oldFiber, newChildNewIdx, lanes) + end + if newFiber == nil then + -- TODO: This breaks on empty slots like nil children. That's + -- unfortunate because it triggers the slow path all the time. We need + -- a better way to communicate whether this was a miss or nil, + -- boolean, undefined, etc. + if oldFiber == nil then + oldFiber = nextOldFiber + end + break + end + if shouldTrackSideEffects then + -- ROBLOX FIXME Luau: needs type states to understand the continue above + if oldFiber and (newFiber :: Fiber).alternate == nil then + -- We matched the slot, but we didn't reuse the existing fiber, so we + -- need to delete the existing child. + -- ROBLOX FIXME Luau: needs type states to understand the break above + deleteChild(returnFiber, oldFiber :: Fiber) + end + end + lastPlacedIndex = placeChild(newFiber :: Fiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + -- TODO: Move out of the loop. This only happens for the first run. + resultingFirstChild = newFiber + else + -- TODO: Defer siblings if we're not at the right index for this slot. + -- I.e. if we had nil values before, then we want to defer this + -- for each nil value. However, we also don't want to call updateSlot + -- with the previous one. + (previousNewFiber :: Fiber).sibling = newFiber + end + previousNewFiber = newFiber + oldFiber = nextOldFiber + -- deviation: increment manually since we're not using a modified for loop + newIdx += 1 + end + + if newIdx > newChildrenCount then + -- We've reached the end of the new children. We can delete the rest. + deleteRemainingChildren(returnFiber, oldFiber) + return resultingFirstChild + end + + if oldFiber == nil then + -- If we don't have any more existing children we can choose a fast path + -- since the rest will all be insertions. + -- deviation: use while loop in place of modified for loop + while newIdx <= newChildrenCount do + --[[ + ROBLOX DEVIATION: We pass newIdx to createChild to ensure that children are + assigned a key, assuming the child is not an array itself. We only need to + pass newIdx if the child is actually a React element. If the child is a + string or number, a key is never assigned, so we do not pass newIdx as a key. + ]] + local newFiber + -- ROBLOX performance: avoid repeated indexing of newChildren to newIdx + local newChildNewIdx = newChildren[newIdx] + if + newChildNewIdx ~= nil + and type(newChildNewIdx) == "table" + and newChildNewIdx["$$typeof"] ~= nil + then + newFiber = createChild(returnFiber, newChildNewIdx, lanes, newIdx) + else + newFiber = createChild(returnFiber, newChildNewIdx, lanes) + end + if newFiber == nil then + -- ROBLOX deviation: increment manually since we're not using a modified for loop + newIdx += 1 + continue + end + -- ROBLOX FIXME Luau: needs type state to understand the continue above + lastPlacedIndex = placeChild(newFiber :: Fiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + -- TODO: Move out of the loop. This only happens for the first run. + resultingFirstChild = newFiber + else + (previousNewFiber :: Fiber).sibling = newFiber + end + previousNewFiber = newFiber + -- deviation: increment manually since we're not using a modified for loop + newIdx += 1 + end + return resultingFirstChild + end + + -- Add all children to a key map for quick lookups. + -- ROBLOX FIXME Luau: need type state to understand the if/return above + local existingChildren = mapRemainingChildren(returnFiber, oldFiber :: Fiber) + + -- Keep scanning and use the map to restore deleted items as moves. + -- ROBLOX deviation: use while loop in place of modified for loop + while newIdx <= newChildrenCount do + local newFiber + -- ROBLOX performance: avoid repeated indexing of newChildren to newIdx + local newChildNewIdx = newChildren[newIdx] + if + not FFlagReactPreventAssigningKeyToChildren + or ( + newChildNewIdx ~= nil + and type(newChildNewIdx) == "table" + and newChildNewIdx["$$typeof"] ~= nil + ) + then + newFiber = updateFromMap( + existingChildren, + returnFiber, + newIdx, + newChildNewIdx, + lanes, + -- ROBLOX deviation: pass newIdx to be used as the key of the element + newIdx + ) + else + newFiber = updateFromMap( + existingChildren, + returnFiber, + newIdx, + newChildNewIdx, + lanes + ) + end + if newFiber ~= nil then + if shouldTrackSideEffects then + if newFiber.alternate ~= nil then + -- The new fiber is a work in progress, but if there exists a + -- current, that means that we reused the fiber. We need to delete + -- it from the child list so that we don't add it to the deletion + -- list. + existingChildren[if newFiber.key == nil + then newIdx + else newFiber.key] = + nil + end + end + lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + resultingFirstChild = newFiber + else + (previousNewFiber :: Fiber).sibling = newFiber + end + previousNewFiber = newFiber + end + -- deviation: increment manually since we're not using a modified for loop + newIdx += 1 + end + + if shouldTrackSideEffects then + -- Any existing children that weren't consumed above were deleted. We need + -- to add them to the deletion list. + for _, child in existingChildren do + deleteChild(returnFiber, child) + end + end + + return resultingFirstChild + end + + -- ROBLOX TODO: LUAFDN-254 + local function reconcileChildrenIterator( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + -- ROBLOX TODO: figure out our Iterable<> interface + -- newChildrenIterable: Iterable<*>, + newChildrenIterable: any, + lanes: Lanes, + -- ROBLOX performance? pass in iteratorFn to avoid two calls to getIteratorFn + iteratorFn: (...any) -> any + ): Fiber | nil + -- This is the same implementation as reconcileChildrenArray(), + -- but using the iterator instead. + + -- local iteratorFn = getIteratorFn(newChildrenIterable) + -- ROBLOX performance? eliminate 'nice to have' strcmp in hot path + -- invariant( + -- typeof(iteratorFn) == "function", + -- "An object is not an iterable. This error is likely caused by a bug in " + -- .. "React. Please file an issue." + -- ) + + if __DEV__ then + -- We don't support rendering Generators because it's a mutation. + -- See https://github.com/facebook/react/issues/12995 + -- ROBLOX deviation: Lua doesn't have built-in generators + -- if + -- typeof(Symbol) == 'function' and + -- -- $FlowFixMe Flow doesn't know about toStringTag + -- newChildrenIterable[Symbol.toStringTag] == 'Generator' + -- then + -- if not didWarnAboutGenerators then + -- console.error( + -- 'Using Generators as children is unsupported and will likely yield ' .. + -- 'unexpected results because enumerating a generator mutates it. ' .. + -- 'You may convert it to an array with `Array.from()` or the ' .. + -- '`[...spread]` operator before rendering. Keep in mind ' .. + -- 'you might need to polyfill these features for older browsers.' + -- ) + -- end + -- didWarnAboutGenerators = true + -- end + + -- Warn about using Maps as children + if newChildrenIterable.entries == iteratorFn then + if not didWarnAboutMaps then + console.error( + "Using Maps as children is not supported. " + .. "Use an array of keyed ReactElements instead." + ) + end + didWarnAboutMaps = true + end + + -- First, validate keys. + -- We'll get a different iterator later for the main pass. + local newChildren = iteratorFn(newChildrenIterable) + if newChildren then + local knownKeys = nil + local step = newChildren.next() + while not step.done do + step = newChildren.next() + local child = step.value + knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber) + end + end + end + + local newChildren = iteratorFn(newChildrenIterable) + -- ROBLOX performance? eliminate 'nice to have' cmp in hot path + -- invariant(newChildren ~= nil, "An iterable object provided no iterator.") + + local resultingFirstChild: Fiber | nil = nil + local previousNewFiber: Fiber = nil + + local oldFiber = currentFirstChild + local lastPlacedIndex = 1 + local newIdx = 1 + local nextOldFiber: Fiber | nil = nil + + local step = newChildren.next() + while oldFiber ~= nil and not step.done do + if oldFiber.index > newIdx then + nextOldFiber = oldFiber + oldFiber = nil + else + nextOldFiber = oldFiber.sibling + end + local newFiber = + updateSlot(returnFiber, oldFiber, step.value, lanes, step.key) + if newFiber == nil then + -- TODO: This breaks on empty slots like nil children. That's + -- unfortunate because it triggers the slow path all the time. We need + -- a better way to communicate whether this was a miss or nil, + -- boolean, undefined, etc. + if oldFiber == nil then + oldFiber = nextOldFiber + end + break + end + if shouldTrackSideEffects then + -- ROBLOX FIXME Luau: need type states to understand the break above + if oldFiber and (newFiber :: Fiber).alternate == nil then + -- We matched the slot, but we didn't reuse the existing fiber, so we + -- need to delete the existing child. + deleteChild(returnFiber, oldFiber) + end + end + lastPlacedIndex = placeChild(newFiber :: Fiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + -- TODO: Move out of the loop. This only happens for the first run. + resultingFirstChild = newFiber + else + -- TODO: Defer siblings if we're not at the right index for this slot. + -- I.e. if we had nil values before, then we want to defer this + -- for each nil value. However, we also don't want to call updateSlot + -- with the previous one. + previousNewFiber.sibling = newFiber :: Fiber + end + previousNewFiber = newFiber :: Fiber + oldFiber = nextOldFiber + + newIdx += 1 + step = newChildren.next() + end + + if step.done then + -- We've reached the end of the new children. We can delete the rest. + deleteRemainingChildren(returnFiber, oldFiber) + return resultingFirstChild + end + + if oldFiber == nil then + -- If we don't have any more existing children we can choose a fast path + -- since the rest will all be insertions. + while not step.done do + local newFiber = createChild(returnFiber, step.value, lanes, step.key) + if newFiber == nil then + newIdx += 1 + step = newChildren.next() + continue + end + -- ROBLOX FIXME Luau: need type states to understand the continue above + lastPlacedIndex = placeChild(newFiber :: Fiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + -- TODO: Move out of the loop. This only happens for the first run. + resultingFirstChild = newFiber + else + previousNewFiber.sibling = newFiber + end + previousNewFiber = newFiber :: Fiber + + newIdx += 1 + step = newChildren.next() + end + return resultingFirstChild + end + + -- Add all children to a key map for quick lookups. + -- ROBLOX performance? defer initialization into the loop. extra cmp per loop iter, but avoid call if no loop iter + local existingChildren + + -- Keep scanning and use the map to restore deleted items as moves. + while not step.done do + if not existingChildren then + -- ROBLOX FIXME LUau: need type states to understand the guard+return above + existingChildren = mapRemainingChildren(returnFiber, oldFiber :: Fiber) + end + local newFiber = updateFromMap( + existingChildren, + returnFiber, + newIdx, + step.value, + lanes, + step.key + ) + if newFiber ~= nil then + if shouldTrackSideEffects then + if newFiber.alternate ~= nil then + -- The new fiber is a work in progress, but if there exists a + -- current, that means that we reused the fiber. We need to delete + -- it from the child list so that we don't add it to the deletion + -- list. + if newFiber.key == nil then + existingChildren[newIdx] = nil + else + existingChildren[newFiber.key] = nil + end + end + end + lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx) + if previousNewFiber == nil then + resultingFirstChild = newFiber + else + previousNewFiber.sibling = newFiber + end + previousNewFiber = newFiber + end + + newIdx += 1 + step = newChildren.next() + end + + if shouldTrackSideEffects then + -- Any existing children that weren't consumed above were deleted. We need + -- to add them to the deletion list. + for _, child in existingChildren do + deleteChild(returnFiber, child) + end + end + + return resultingFirstChild + end + + local function reconcileSingleTextNode( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + textContent: string, + lanes: Lanes + ): Fiber + -- There's no need to check for keys on text nodes since we don't have a + -- way to define them. + -- ROBLOX FIXME: Luau narrowing issue + if currentFirstChild ~= nil and (currentFirstChild :: Fiber).tag == HostText then + -- We already have an existing node so let's just update it and delete + -- the rest. + deleteRemainingChildren(returnFiber, (currentFirstChild :: Fiber).sibling) + local existing = useFiber(currentFirstChild :: Fiber, textContent) + existing.return_ = returnFiber + return existing + end + -- The existing first child is not a text node so we need to create one + -- and delete the existing ones. + deleteRemainingChildren(returnFiber, currentFirstChild) + local created = createFiberFromText(textContent, returnFiber.mode, lanes) + created.return_ = returnFiber + return created + end + + local function reconcileSingleElement( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + element: ReactElement, + lanes: Lanes + ): Fiber + local key = element.key + local child = currentFirstChild + while child ~= nil do + -- TODO: If key == nil and child.key == nil, then this only applies to + -- the first item in the list. + if child.key == key then + if child.tag == Fragment then + if element.type == REACT_FRAGMENT_TYPE then + deleteRemainingChildren(returnFiber, child.sibling) + local existing = useFiber(child, element.props.children) + existing.return_ = returnFiber + if __DEV__ then + existing._debugSource = element._source + existing._debugOwner = element._owner + end + return existing + end + -- ROBLOX performance: avoid always-false cmp in hot path + -- elseif child.tag == Block then + -- unimplemented("reconcileSingleElement: Block") + -- if (enableBlocksAPI) { + -- let type = element.type; + -- if (type.$$typeof === REACT_LAZY_TYPE) { + -- type = resolveLazyType(type); + -- } + -- if (type.$$typeof === REACT_BLOCK_TYPE) { + -- // The new Block might not be initialized yet. We need to initialize + -- // it in case initializing it turns out it would match. + -- if ( + -- ((type: any): BlockComponent)._render === + -- (child.type: BlockComponent)._render + -- ) { + -- deleteRemainingChildren(returnFiber, child.sibling); + -- const existing = useFiber(child, element.props); + -- existing.type = type; + -- existing.return = returnFiber; + -- if (__DEV__) { + -- existing._debugSource = element._source; + -- existing._debugOwner = element._owner; + -- } + -- return existing; + -- } + -- } + -- } + -- // We intentionally fallthrough here if enableBlocksAPI is not on. + -- // eslint-disable-next-lined no-fallthrough + else + if + child.elementType == element.type + or (__DEV__ and isCompatibleFamilyForHotReloading(child, element)) + then + deleteRemainingChildren(returnFiber, child.sibling) + local existing = useFiber(child, element.props) + existing.ref = coerceRef(returnFiber, child, element) + existing.return_ = returnFiber + if __DEV__ then + existing._debugSource = element._source + existing._debugOwner = element._owner + end + return existing + end + end + -- Didn't match. + deleteRemainingChildren(returnFiber, child) + break + else + deleteChild(returnFiber, child) + end + child = child.sibling + end + + if element.type == REACT_FRAGMENT_TYPE then + local created = createFiberFromFragment( + element.props.children, + returnFiber.mode, + lanes, + -- ROBLOX FIXME Luau: needs normalization: TypeError: Type '(number | string)?' could not be converted into 'string?' + element.key :: string + ) + created.return_ = returnFiber + return created + else + local created = createFiberFromElement(element, returnFiber.mode, lanes) + created.ref = coerceRef(returnFiber, currentFirstChild, element) + created.return_ = returnFiber + return created + end + end + + local function reconcileSinglePortal( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + portal: ReactPortal, + lanes: Lanes + ): Fiber + local key = portal.key + local child = currentFirstChild + while child ~= nil do + -- TODO: If key == nil and child.key == nil, then this only applies to + -- the first item in the list. + if child.key == key then + if + child.tag == HostPortal + and child.stateNode.containerInfo == portal.containerInfo + and child.stateNode.implementation == portal.implementation + then + deleteRemainingChildren(returnFiber, child.sibling) + local existing = useFiber(child, portal.children or {}) + existing.return_ = returnFiber + return existing + else + deleteRemainingChildren(returnFiber, child) + break + end + else + deleteChild(returnFiber, child) + end + child = child.sibling + end + + local created = createFiberFromPortal(portal, returnFiber.mode, lanes) + created.return_ = returnFiber + return created + end + + -- This API will tag the children with the side-effect of the reconciliation + -- itself. They will be added to the side-effect list as we pass through the + -- children and the parent. + local function reconcileChildFibers( + returnFiber: Fiber, + currentFirstChild: Fiber | nil, + newChild: any, + lanes: Lanes + ): Fiber | nil + -- This function is not recursive. + -- If the top level item is an array, we treat it as a set of children, + -- not as a fragment. Nested arrays on the other hand will be treated as + -- fragment nodes. Recursion happens at the normal flow. + + -- ROBLOX performance: avoid repeated calls to typeof since Luau doesn't cache + local typeOfNewChild = type(newChild) + + -- Handle top level unkeyed fragments as if they were arrays. + -- This leads to an ambiguity between <>{[...]} and <>.... + -- We treat the ambiguous cases above the same. + local isUnkeyedTopLevelFragment = newChild ~= nil + and typeOfNewChild == "table" + and newChild.type == REACT_FRAGMENT_TYPE + and newChild.key == nil + if isUnkeyedTopLevelFragment then + newChild = newChild.props.children + typeOfNewChild = type(newChild) + end + local newChildIsArray = isArray(newChild) + + -- Handle object types + -- ROBLOX deviation: upstream checks for `object`, but we need to manually exclude array + local isObject = newChild ~= nil + and typeOfNewChild == "table" + and not newChildIsArray + + if isObject then + -- ROBLOX performance: avoid repeated indexing of $$typeof + local newChildTypeof = newChild["$$typeof"] + if newChildTypeof == REACT_ELEMENT_TYPE then + return placeSingleChild( + reconcileSingleElement( + returnFiber, + currentFirstChild, + newChild, + lanes + ) + ) + elseif newChildTypeof == REACT_PORTAL_TYPE then + return placeSingleChild( + reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes) + ) + elseif newChildTypeof == REACT_LAZY_TYPE then + if enableLazyElements then + local payload = newChild._payload + local init = newChild._init + -- TODO: This function is supposed to be non-recursive. + return reconcileChildFibers( + returnFiber, + currentFirstChild, + init(payload), + lanes + ) + end + end + -- ROBLOX performance: make these next blocks `elseif`, as they're mutually exclusive to `isObject` above + elseif newChildIsArray then + return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes) + elseif typeOfNewChild == "string" or typeOfNewChild == "number" then + return placeSingleChild( + reconcileSingleTextNode( + returnFiber, + currentFirstChild, + tostring(newChild), + lanes + ) + ) + end + + -- ROBLOX performance? only call getIteratorFn once, pass in the value + local newChildIteratorFn = getIteratorFn(newChild) + if newChildIteratorFn then + return reconcileChildrenIterator( + returnFiber, + currentFirstChild, + newChild, + lanes, + newChildIteratorFn + ) + end + + -- ROBLOX performance? eliminate a cmp in hot path for something unimplemented anyway + -- if isObject then + -- unimplemented("throwOnInvalidObjectType") + -- -- throwOnInvalidObjectType(returnFiber, newChild) + -- end + + if __DEV__ then + if typeOfNewChild == "function" then + warnOnFunctionType(returnFiber) + end + end + if newChild == nil and not isUnkeyedTopLevelFragment then + -- deviation: need a flag here to simulate switch/case fallthrough + break + local shouldFallThrough = false + -- If the new child is undefined, and the return fiber is a composite + -- component, throw an error. If Fiber return types are disabled, + -- we already threw above. + -- ROBLOX deviation: With coercion of no returns to `nil`, it + -- if returnFiber.tag == ClassComponent then + -- if __DEV__ then + -- isn't necessary to special case this scenario + -- local instance = returnFiber.stateNode + -- if instance.render._isMockFunction then + -- -- We allow auto-mocks to proceed as if they're returning nil. + -- shouldFallThrough = true + -- end + -- end + -- end + -- Intentionally fall through to the next case, which handles both + -- functions and classes + -- eslint-disable-next-lined no-fallthrough + if + shouldFallThrough + and ( + returnFiber.tag == ClassComponent + or returnFiber.tag == FunctionComponent + or returnFiber.tag == ForwardRef + or returnFiber.tag == SimpleMemoComponent + ) + then + invariant( + false, + "%s(...): Nothing was returned from render. This usually means a " + .. "return statement is missing. Or, to render nothing, " + .. "return nil.", + getComponentName(returnFiber.type) or "Component" + ) + end + end + + -- Remaining cases are all treated as empty. + return deleteRemainingChildren(returnFiber, currentFirstChild) + end + + return reconcileChildFibers end exports.reconcileChildFibers = ChildReconciler(true) exports.mountChildFibers = ChildReconciler(false) -exports.cloneChildFibers = function( - current: Fiber | nil, - workInProgress: Fiber -) - invariant( - current == nil or workInProgress.child == (current :: Fiber).child, - "Resuming work not yet implemented." - ) - - if workInProgress.child == nil then - return - end - - local currentChild = workInProgress.child - local newChild = createWorkInProgress(currentChild, currentChild.pendingProps) - workInProgress.child = newChild - - newChild.return_ = workInProgress - while currentChild.sibling ~= nil do - currentChild = currentChild.sibling - newChild.sibling = createWorkInProgress( - currentChild, - currentChild.pendingProps - ) - newChild = newChild.sibling - newChild.return_ = workInProgress - end - newChild.sibling = nil +exports.cloneChildFibers = function(current: Fiber | nil, workInProgress: Fiber) + -- ROBLOX deviation: This message isn't tested upstream, remove for hot path optimization + -- invariant( + -- current == nil or workInProgress.child == (current :: Fiber).child, + -- "Resuming work not yet implemented." + -- ) + + if workInProgress.child == nil then + return + end + + local currentChild = workInProgress.child :: Fiber + local newChild = createWorkInProgress(currentChild, currentChild.pendingProps) + workInProgress.child = newChild + + newChild.return_ = workInProgress + while currentChild.sibling ~= nil do + currentChild = currentChild.sibling + newChild.sibling = createWorkInProgress(currentChild, currentChild.pendingProps) + -- ROBLOX FIXME Luau: luau doesn't track/narrow the direct assignment on the line above + newChild = newChild.sibling :: Fiber + newChild.return_ = workInProgress + end + newChild.sibling = nil end -- Reset a workInProgress child set to prepare it for a second pass. exports.resetChildFibers = function(workInProgress: Fiber, lanes: Lanes): () - local child = workInProgress.child - while child ~= nil do - resetWorkInProgress(child, lanes) - child = child.sibling - end + local child = workInProgress.child + while child ~= nil do + resetWorkInProgress(child, lanes) + child = child.sibling + end end return exports diff --git a/modules/react-reconciler/src/ReactCurrentFiber.lua b/modules/react-reconciler/src/ReactCurrentFiber.lua index 39b78dae..46041068 100644 --- a/modules/react-reconciler/src/ReactCurrentFiber.lua +++ b/modules/react-reconciler/src/ReactCurrentFiber.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/b0cb137bcbd3a11d8eff3c2229cd6b8379d29785/packages/react-reconciler/src/ReactCurrentFiber.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/b0cb137bcbd3a11d8eff3c2229cd6b8379d29785/packages/react-reconciler/src/ReactCurrentFiber.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,11 +8,13 @@ * * @flow ]] - local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) + +local __DEV__ = ReactGlobals.__DEV__ :: boolean local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals local ReactFiberComponentStack = require(script.Parent.ReactFiberComponentStack) @@ -28,17 +31,18 @@ type Exports = { [string]: any, } -local exports = {} - -exports.current = nil -exports.isRendering = false +local exports: Exports = { + current = nil, + isRendering = false, +} exports.getCurrentFiberOwnerNameInDevOrNull = function(): string? - if _G.__DEV__ then + if __DEV__ then if exports.current == nil then return nil end - local owner = exports.current._debugOwner + -- ROBLOX FIXME Luau: Luau doesn't understand guard above + local owner = (exports.current :: Fiber)._debugOwner if owner then return getComponentName(owner.type) end @@ -47,44 +51,47 @@ exports.getCurrentFiberOwnerNameInDevOrNull = function(): string? end local function getCurrentFiberStackInDev(): string - if _G.__DEV__ then + if __DEV__ then if exports.current == nil then return "" end -- Safe because if current fiber exists, we are reconciling, -- and it is guaranteed to be the work-in-progress version. - return getStackByFiberInDevAndProd(exports.current) + -- ROBLOX FIXME Luau: Luau doesn't understand guard above + return getStackByFiberInDevAndProd(exports.current :: Fiber) end return "" end -exports.resetCurrentFiber = function() - if _G.__DEV__ then - ReactDebugCurrentFrame.getCurrentStack = nil +exports.resetCurrentFiber = function(): () + if __DEV__ then + -- ROBLOX FIXME Luau: Expected type table, got 'ReactDebugCurrentFrame | { setExtraStackFrame: () -> () }' instead + (ReactDebugCurrentFrame :: any).getCurrentStack = nil exports.current = nil exports.isRendering = false end end -exports.setCurrentFiber = function(fiber: Fiber) - if _G.__DEV__ then - ReactDebugCurrentFrame.getCurrentStack = getCurrentFiberStackInDev +exports.setCurrentFiber = function(fiber: Fiber): () + if __DEV__ then + -- ROBLOX FIXME Luau: Expected type table, got 'ReactDebugCurrentFrame | { setExtraStackFrame: () -> () }' instead + (ReactDebugCurrentFrame :: any).getCurrentStack = getCurrentFiberStackInDev exports.current = fiber exports.isRendering = false end end -exports.setIsRendering = function(rendering: boolean) - if _G.__DEV__ then +exports.setIsRendering = function(rendering: boolean): () + if __DEV__ then exports.isRendering = rendering end end -exports.getIsRendering = function() - if _G.__DEV__ then +exports.getIsRendering = function(): boolean + if __DEV__ then return exports.isRendering end - return nil + return false end return exports diff --git a/modules/react-reconciler/src/ReactFiber.new.lua b/modules/react-reconciler/src/ReactFiber.new.lua index 02ab471c..3ad255bd 100644 --- a/modules/react-reconciler/src/ReactFiber.new.lua +++ b/modules/react-reconciler/src/ReactFiber.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiber.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiber.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,11 +8,15 @@ * * @flow ]] ---!nolint LocalShadowPedantic local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Object = LuauPolyfill.Object +local Array = LuauPolyfill.Array +local inspect = LuauPolyfill.util.inspect + +local __DEV__ = ReactGlobals.__DEV__ -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console @@ -19,7 +24,7 @@ local console = require(Packages.Shared).console local ReactTypes = require(Packages.Shared) -- ROBLOX deviation: ReactElement is defined at the top level of Shared along -- with the rest of the ReactTypes -type ReactElement = ReactTypes.ReactElement +type ReactElement = ReactTypes.ReactElement type ReactFragment = ReactTypes.ReactFragment type ReactPortal = ReactTypes.ReactPortal type ReactFundamentalComponent = ReactTypes.ReactFundamentalComponent @@ -30,24 +35,23 @@ export type Fiber = ReactInternalTypes.Fiber -- ROBLOX deviation: Allow number keys for sparse arrays type RoactStableKey = ReactInternalTypes.RoactStableKey local ReactRootTags = require(script.Parent.ReactRootTags) -type RootTag = ReactRootTags.RootTag; +type RootTag = ReactRootTags.RootTag local ReactWorkTags = require(script.Parent.ReactWorkTags) -type WorkTag = ReactWorkTags.WorkTag; +type WorkTag = ReactWorkTags.WorkTag local ReactTypeOfMode = require(script.Parent.ReactTypeOfMode) -type TypeOfMode = ReactTypeOfMode.TypeOfMode; +type TypeOfMode = ReactTypeOfMode.TypeOfMode local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes; +type Lanes = ReactFiberLane.Lanes local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance; --- local ReactFiberOffscreenComponent = require(script.Parent.ReactFiberOffscreenComponent) --- type OffscreenProps = ReactFiberOffscreenComponent.OffscreenProps; -type OffscreenProps = any; -- FIXME (roblox): types +type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance +local ReactFiberOffscreenComponent = require(script.Parent.ReactFiberOffscreenComponent) +type OffscreenProps = ReactFiberOffscreenComponent.OffscreenProps local invariant = require(Packages.Shared).invariant local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags local enableProfilerTimer = ReactFeatureFlags.enableProfilerTimer -local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI -local enableScopeAPI = ReactFeatureFlags.enableScopeAPI +-- local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI +-- local enableScopeAPI = ReactFeatureFlags.enableScopeAPI local ReactFiberFlags = require(script.Parent.ReactFiberFlags) local NoFlags = ReactFiberFlags.NoFlags local Placement = ReactFiberFlags.Placement @@ -83,8 +87,10 @@ local ReactFiberDevToolsHook = require(script.Parent["ReactFiberDevToolsHook.new local isDevToolsPresent = ReactFiberDevToolsHook.isDevToolsPresent local ReactFiberHotReloading = require(script.Parent["ReactFiberHotReloading.new"]) local resolveClassForHotReloading = ReactFiberHotReloading.resolveClassForHotReloading -local resolveFunctionForHotReloading = ReactFiberHotReloading.resolveFunctionForHotReloading -local resolveForwardRefForHotReloading = ReactFiberHotReloading.resolveForwardRefForHotReloading +local resolveFunctionForHotReloading = + ReactFiberHotReloading.resolveFunctionForHotReloading +local resolveForwardRefForHotReloading = + ReactFiberHotReloading.resolveForwardRefForHotReloading local NoLanes = ReactFiberLane.NoLanes local NoMode = ReactTypeOfMode.NoMode local ConcurrentMode = ReactTypeOfMode.ConcurrentMode @@ -95,6 +101,7 @@ local BlockingMode = ReactTypeOfMode.BlockingMode local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_FORWARD_REF_TYPE = ReactSymbols.REACT_FORWARD_REF_TYPE local REACT_FRAGMENT_TYPE = ReactSymbols.REACT_FRAGMENT_TYPE +local REACT_ELEMENT_TYPE = ReactSymbols.REACT_ELEMENT_TYPE local REACT_DEBUG_TRACING_MODE_TYPE = ReactSymbols.REACT_DEBUG_TRACING_MODE_TYPE local REACT_STRICT_MODE_TYPE = ReactSymbols.REACT_STRICT_MODE_TYPE local REACT_PROFILER_TYPE = ReactSymbols.REACT_PROFILER_TYPE @@ -104,8 +111,8 @@ local REACT_SUSPENSE_TYPE = ReactSymbols.REACT_SUSPENSE_TYPE local REACT_SUSPENSE_LIST_TYPE = ReactSymbols.REACT_SUSPENSE_LIST_TYPE local REACT_MEMO_TYPE = ReactSymbols.REACT_MEMO_TYPE local REACT_LAZY_TYPE = ReactSymbols.REACT_LAZY_TYPE -local REACT_FUNDAMENTAL_TYPE = ReactSymbols.REACT_FUNDAMENTAL_TYPE -local REACT_SCOPE_TYPE = ReactSymbols.REACT_SCOPE_TYPE +-- local REACT_FUNDAMENTAL_TYPE = ReactSymbols.REACT_FUNDAMENTAL_TYPE +-- local REACT_SCOPE_TYPE = ReactSymbols.REACT_SCOPE_TYPE local REACT_OFFSCREEN_TYPE = ReactSymbols.REACT_OFFSCREEN_TYPE local REACT_LEGACY_HIDDEN_TYPE = ReactSymbols.REACT_LEGACY_HIDDEN_TYPE @@ -114,7 +121,7 @@ local REACT_LEGACY_HIDDEN_TYPE = ReactSymbols.REACT_LEGACY_HIDDEN_TYPE -- local hasBadMapPolyfill --- if _G.__DEV__ then +-- if __DEV__ then -- hasBadMapPolyfill = false -- try { -- local nonExtensibleObject = Object.preventExtensions({}) @@ -128,53 +135,81 @@ local REACT_LEGACY_HIDDEN_TYPE = ReactSymbols.REACT_LEGACY_HIDDEN_TYPE -- end -- end -local createFiberFromScope, createFiberFromProfiler, createFiberFromFragment, - createFiberFromFundamental, createFiberFromSuspense, createFiberFromOffscreen, - createFiberFromLegacyHidden, createFiberFromSuspenseList +local createFiberFromScope, createFiberFromProfiler, createFiberFromFragment, createFiberFromFundamental, createFiberFromSuspense, createFiberFromOffscreen, createFiberFromLegacyHidden, createFiberFromSuspenseList local debugCounter = 1 -function FiberNode( +-- ROBLOX deviation START: inline this into its only caller to save hot path performance +-- function FiberNode( +-- tag: WorkTag, +-- pendingProps: any, +-- key: RoactStableKey?, +-- mode: TypeOfMode +-- ): Fiber +-- return {} :: any +-- end +-- ROBLOX deviation END + +-- This is a constructor function, rather than a POJO constructor, still +-- please ensure we do the following: +-- 1) Nobody should add any instance methods on this. Instance methods can be +-- more difficult to predict when they get optimized and they are almost +-- never inlined properly in static compilers. +-- 2) Nobody should rely on `instanceof Fiber` for type testing. We should +-- always know when it is a fiber. +-- 3) We might want to experiment with using numeric keys since they are easier +-- to optimize in a non-JIT environment. +-- 4) We can easily go from a constructor to a createFiber object literal if that +-- is faster. +-- 5) It should be easy to port this to a C struct and keep a C implementation +-- compatible. +-- ROBLOX deviation START: add elementType, type, and lanes arguments so the table is created in a one-shot to avoid rehashing +local function createFiber( tag: WorkTag, pendingProps: any, key: RoactStableKey?, - mode: TypeOfMode -) - local node = {} - - -- Instance - node.tag = tag - node.key = key - node.elementType = nil - node.type = nil - node.stateNode = nil - - -- Fiber - -- deviation: Lua doesn't allow `return` keyword as key - node.return_ = nil - node.child = nil - node.sibling = nil - node.index = 1 - - node.ref = nil - - node.pendingProps = pendingProps - node.memoizedProps = nil - node.updateQueue = nil - node.memoizedState = nil - node.dependencies = nil - - node.mode = mode - - -- Effects - node.flags = NoFlags - node.subtreeFlags = NoFlags - node.deletions = nil - - node.lanes = NoLanes - node.childLanes = NoLanes - - node.alternate = nil + mode: TypeOfMode, + elementType: any?, + type_: any?, + stateNode: any?, + lanes: Lanes? +): Fiber + -- $FlowFixMe: the shapes are exact here but Flow doesn't like constructors + -- ROBLOX deviation START: inline FiberNode(), do the table as a one-shot and avoid initializing nil fields for hot-path performance + local node: Fiber = { + -- Instance + tag = tag, + key = key, + elementType = elementType, + type = type_, + stateNode = stateNode, + + -- Fiber + -- node.return_ = nil + -- node.child = nil + -- node.sibling = nil + index = 1, + + -- node.ref = nil + + pendingProps = pendingProps, + -- memoizedProps = nil + -- updateQueue = nil + -- memoizedState = nil + -- dependencies = nil + + mode = mode, + + -- Effects + flags = NoFlags, + subtreeFlags = NoFlags, + -- deletions = nil + + lanes = if lanes then lanes else NoLanes, + childLanes = NoLanes, + + -- alternate = nil + } :: any if enableProfilerTimer then -- deviation: Unlikely that we have this same performance problem @@ -206,7 +241,7 @@ function FiberNode( node.treeBaseDuration = 0 end - if _G.__DEV__ then + if __DEV__ then -- This isn't directly used but is handy for debugging internals: node._debugID = debugCounter debugCounter += 1 @@ -224,55 +259,36 @@ function FiberNode( -- end end - return node + -- ROBLOX deviation END end --- This is a constructor function, rather than a POJO constructor, still --- please ensure we do the following: --- 1) Nobody should add any instance methods on this. Instance methods can be --- more difficult to predict when they get optimized and they are almost --- never inlined properly in static compilers. --- 2) Nobody should rely on `instanceof Fiber` for type testing. We should --- always know when it is a fiber. --- 3) We might want to experiment with using numeric keys since they are easier --- to optimize in a non-JIT environment. --- 4) We can easily go from a constructor to a createFiber object literal if that --- is faster. --- 5) It should be easy to port this to a C struct and keep a C implementation --- compatible. -local function createFiber( - tag: WorkTag, - pendingProps: any, - key: RoactStableKey?, - mode: TypeOfMode -): Fiber - -- $FlowFixMe: the shapes are exact here but Flow doesn't like constructors - return FiberNode(tag, pendingProps, key, mode) -end - --- deviation: FIXME: `Component: Function` - need to lock down component def -function shouldConstruct(Component) +-- ROBLOX deviation START: we inline all uses of this function for performance in hot path +function _shouldConstruct(Component) -- deviation: With Lua metatables, members of the "prototype" can be -- accessed directly. so we don't need to check for a prototype separately - return typeof(Component) ~= "function" and (not not Component.isReactComponent) + return type(Component) ~= "function" and not not Component.isReactComponent end - -local function isSimpleFunctionComponent(type: any) - return - typeof(type) == "function" and - not shouldConstruct(type) - -- deviation: function components don't support this anyway - -- type.defaultProps == undefined +-- ROBLOX deviation END + +local function isSimpleFunctionComponent(type_: any) + -- ROBLOX deviation START: inline shouldConstruct logic for hot path performance + return type(type_) == "function" + -- deviation: function components don't support this anyway + -- type.defaultProps == undefined + -- ROBLOX deviation END: inline shouldConstruct logic for hot path performance 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 - -- ROBLOX deviation: we can only index ["$$typeof"] on a table - elseif Component ~= nil and typeof(Component) == 'table' then + local typeofComponent = typeof(Component) + if typeofComponent == "function" then + return FunctionComponent + end + + if typeofComponent == "table" then + if Component.isReactComponent then + return ClassComponent + end local __typeof = Component["$$typeof"] if __typeof == REACT_FORWARD_REF_TYPE then return ForwardRef @@ -281,12 +297,14 @@ local function resolveLazyComponentTag(Component: any): WorkTag return MemoComponent end end + return IndeterminateComponent end -- This is used to create an alternate fiber to do work on. local function createWorkInProgress(current: Fiber, pendingProps: any): Fiber - local workInProgress = current.alternate + -- ROBLOX FIXME Luau: Luau doesn't understand if nil then create pattern + local workInProgress = current.alternate :: Fiber if workInProgress == nil then -- We use a double buffering pooling technique because we know that we'll -- only ever need at most two versions of a tree. We pool the "other" unused @@ -297,13 +315,13 @@ local function createWorkInProgress(current: Fiber, pendingProps: any): Fiber current.tag, pendingProps, current.key, - current.mode + current.mode, + current.elementType, + current.type, + current.stateNode ) - workInProgress.elementType = current.elementType - workInProgress.type = current.type - workInProgress.stateNode = current.stateNode - if _G.__DEV__ then + if __DEV__ then -- DEV-only fields workInProgress._debugID = current._debugID workInProgress._debugSource = current._debugSource @@ -319,6 +337,10 @@ local function createWorkInProgress(current: Fiber, pendingProps: any): Fiber workInProgress.type = current.type -- We already have an alternate. + -- Reset the effect tag. + workInProgress.flags = NoFlags + + -- The current effects are no longer valid workInProgress.subtreeFlags = NoFlags workInProgress.deletions = nil @@ -365,12 +387,12 @@ local function createWorkInProgress(current: Fiber, pendingProps: any): Fiber workInProgress.treeBaseDuration = current.treeBaseDuration end - if _G.__DEV__ then + if __DEV__ then workInProgress._debugNeedsRemount = current._debugNeedsRemount if - workInProgress.tag == IndeterminateComponent or - workInProgress.tag == FunctionComponent or - workInProgress.tag == SimpleMemoComponent + workInProgress.tag == IndeterminateComponent + or workInProgress.tag == FunctionComponent + or workInProgress.tag == SimpleMemoComponent then workInProgress.type = resolveFunctionForHotReloading(current.type) elseif workInProgress.tag == ClassComponent then @@ -395,7 +417,10 @@ local function resetWorkInProgress(workInProgress: Fiber, renderLanes: Lanes) -- Reset the effect tag but keep any Placement tags, since that's something -- that child fiber is setting, not the reconciliation. - workInProgress.flags = bit32.band(workInProgress.flags, Placement) + workInProgress.flags = + bit32.band(workInProgress.flags, bit32.bor(StaticMask, Placement)) + + -- The effects are no longer valid local current = workInProgress.alternate if current == nil then @@ -466,7 +491,8 @@ local function createHostRootFiber(tag: RootTag): Fiber mode = NoMode end - if enableProfilerTimer and isDevToolsPresent then + -- ROBLOX deviation: We use a function for isDevtoolsPresent to handle the hook being changed at runtime + if enableProfilerTimer and isDevToolsPresent() then -- Always collect profile timings when DevTools are present. -- This enables DevTools to start capturing timing at any point– -- Without some nodes in the tree having empty base times. @@ -477,7 +503,7 @@ local function createHostRootFiber(tag: RootTag): Fiber end local function createFiberFromTypeAndProps( - type: any, -- React$ElementType + type_: any, -- React$ElementType key: string?, pendingProps: any, owner: nil | Fiber, @@ -487,117 +513,143 @@ local function createFiberFromTypeAndProps( local fiberTag = IndeterminateComponent -- The resolved type is set if we know what the final type will be. I.e. it's not lazy. -- deviation: FIXME: Account for deviated class v. function component type logic - local resolvedType = type + local resolvedType = type_ + local typeOfType_ = type(type_) -- deviation: since our class components aren't functions, we have to look -- for them more explicitly (inlines logic from `shouldConstruct`) - if typeof(type) == "function" then - if _G.__DEV__ then + if typeOfType_ == "function" then + if __DEV__ then resolvedType = resolveFunctionForHotReloading(resolvedType) end - elseif typeof(type) == "table" and (not not type.isReactComponent) then + elseif typeOfType_ == "table" and not not type_.isReactComponent then fiberTag = ClassComponent - if _G.__DEV__ then + if __DEV__ then resolvedType = resolveClassForHotReloading(resolvedType) end - elseif typeof(type) == "string" then + elseif typeOfType_ == "string" then fiberTag = HostComponent else - if type == REACT_FRAGMENT_TYPE then + if type_ == REACT_FRAGMENT_TYPE then return createFiberFromFragment(pendingProps.children, mode, lanes, key) - elseif type == REACT_DEBUG_TRACING_MODE_TYPE then + elseif type_ == REACT_DEBUG_TRACING_MODE_TYPE then fiberTag = Mode mode = bit32.bor(mode, DebugTracingMode) - elseif type == REACT_STRICT_MODE_TYPE then + elseif type_ == REACT_STRICT_MODE_TYPE then fiberTag = Mode mode = bit32.bor(mode, StrictMode) - elseif type == REACT_PROFILER_TYPE then + elseif type_ == REACT_PROFILER_TYPE then return createFiberFromProfiler(pendingProps, mode, lanes, key) - elseif type == REACT_SUSPENSE_TYPE then + elseif type_ == REACT_SUSPENSE_TYPE then return createFiberFromSuspense(pendingProps, mode, lanes, key) - elseif type == REACT_SUSPENSE_LIST_TYPE then - return createFiberFromSuspenseList(pendingProps, mode, lanes, key) - elseif type == REACT_OFFSCREEN_TYPE then + -- elseif type_ == REACT_SUSPENSE_LIST_TYPE then + -- return createFiberFromSuspenseList(pendingProps, mode, lanes, key) + elseif type_ == REACT_OFFSCREEN_TYPE then return createFiberFromOffscreen(pendingProps, mode, lanes, key) - elseif type == REACT_LEGACY_HIDDEN_TYPE then + elseif type_ == REACT_LEGACY_HIDDEN_TYPE then return createFiberFromLegacyHidden(pendingProps, mode, lanes, key) - elseif type == REACT_SCOPE_TYPE then - if enableScopeAPI then - return createFiberFromScope(type, pendingProps, mode, lanes, key) - end + -- elseif type_ == REACT_SCOPE_TYPE then + -- if enableScopeAPI then + -- return createFiberFromScope(type_, pendingProps, mode, lanes, key) + -- end else - local shouldBreak = false; - if typeof(type) == "table" then - if type["$$typeof"] == REACT_PROVIDER_TYPE then + local shouldBreak = false + local type_typeof + if typeOfType_ == "table" then + type_typeof = type_["$$typeof"] + if type_typeof == REACT_PROVIDER_TYPE then fiberTag = ContextProvider shouldBreak = true - elseif type["$$typeof"] == REACT_CONTEXT_TYPE then + elseif type_typeof == REACT_CONTEXT_TYPE then -- This is a consumer fiberTag = ContextConsumer shouldBreak = true - elseif type["$$typeof"] == REACT_FORWARD_REF_TYPE then + elseif type_typeof == REACT_FORWARD_REF_TYPE then fiberTag = ForwardRef - if _G.__DEV__ then + if __DEV__ then resolvedType = resolveForwardRefForHotReloading(resolvedType) end shouldBreak = true - elseif type["$$typeof"] == REACT_MEMO_TYPE then + elseif type_typeof == REACT_MEMO_TYPE then fiberTag = MemoComponent shouldBreak = true - elseif type["$$typeof"] == REACT_LAZY_TYPE then + elseif type_typeof == REACT_LAZY_TYPE then fiberTag = LazyComponent resolvedType = nil shouldBreak = true - elseif type["$$typeof"] == REACT_FUNDAMENTAL_TYPE then - if enableFundamentalAPI then - return createFiberFromFundamental( - type, - pendingProps, - mode, - lanes, - key - ) - end + -- elseif type_typeof == REACT_FUNDAMENTAL_TYPE then + -- if enableFundamentalAPI then + -- return createFiberFromFundamental( + -- type_, + -- pendingProps, + -- mode, + -- lanes, + -- key + -- ) + -- end end end if not shouldBreak then local info = "" - if _G.__DEV__ then + if __DEV__ then if - type == nil or - (typeof(type) == "table" and - #Object.keys(type) == 0) + type_ == nil + or (typeOfType_ == "table" and #Object.keys(type_) == 0) then - info ..= - " You likely forgot to export your component from the file " .. - "it's defined in, or you might have mixed up default and " .. - "named imports." + info ..= " You likely forgot to export your component from the file " .. "it's defined in, or you might have mixed up default and " .. "named imports." + elseif type_ ~= nil and typeOfType_ == "table" then + -- ROBLOX deviation: print the table/string in readable form to give a clue, if no other info was gathered + info ..= "\n" .. inspect(type_) end local ownerName if owner then ownerName = getComponentName(owner.type) end - if ownerName then + if ownerName ~= nil and ownerName ~= "" then info ..= "\n\nCheck the render method of `" .. ownerName .. "`." + 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 + + -- ROBLOX deviation: make output logic consistent across ReactFiber, ElementValidator, Memo, Context, and Lazy + local typeString + if type_ == nil then + typeString = "nil" + elseif Array.isArray(type_) then + typeString = "array" + elseif typeOfType_ == "table" and type_typeof == REACT_ELEMENT_TYPE then + typeString = + string.format("<%s />", getComponentName(type_.type) or "Unknown") + info = + " Did you accidentally export a JSX literal or Element instead of a component?" + else + typeString = typeOfType_ + 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", - typeof(type), + "Element type is invalid: expected a string (for built-in " + .. "components) or a class/function (for composite components) " + .. "but got: %s.%s", + typeString, info ) end end end - local fiber = createFiber(fiberTag, pendingProps, key, mode) - fiber.elementType = type - fiber.type = resolvedType - fiber.lanes = lanes + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = + createFiber(fiberTag, pendingProps, key, mode, type_, resolvedType, nil, lanes) + + -- fiber.elementType = type_ + -- fiber.type = resolvedType + -- fiber.lanes = lanes + -- ROBLOX deviation END - if _G.__DEV__ then + if __DEV__ then fiber._debugOwner = owner end @@ -610,7 +662,7 @@ local function createFiberFromElement( lanes: Lanes ): Fiber local owner = nil - if _G.__DEV__ then + if __DEV__ then owner = element._owner end local type = element.type @@ -618,181 +670,259 @@ local function createFiberFromElement( local pendingProps = element.props local fiber = createFiberFromTypeAndProps( type, - key, + -- ROBLOX FIXME: according to upstream types, key can only be string?, but RoactStableKey deviation also says number + key :: string, pendingProps, owner, mode, lanes ) - if _G.__DEV__ then + if __DEV__ then fiber._debugSource = element._source fiber._debugOwner = element._owner end return fiber end -createFiberFromFragment = function( +function createFiberFromFragment( elements: ReactFragment, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(Fragment, elements, key, mode) - fiber.lanes = lanes + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber(Fragment, elements, key, mode, nil, nil, nil, lanes) + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromFundamental = function( +function createFiberFromFundamental( fundamentalComponent: ReactFundamentalComponent, pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(FundamentalComponent, pendingProps, key, mode) - fiber.elementType = fundamentalComponent - fiber.type = fundamentalComponent - fiber.lanes = lanes + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + FundamentalComponent, + pendingProps, + key, + mode, + fundamentalComponent, + fundamentalComponent, + nil, + lanes + ) + -- fiber.elementType = fundamentalComponent + -- fiber.type = fundamentalComponent + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromScope = function( +function createFiberFromScope( scope: ReactScope, pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(ScopeComponent, pendingProps, key, mode) - fiber.type = scope - fiber.elementType = scope - fiber.lanes = lanes + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = + createFiber(ScopeComponent, pendingProps, key, mode, scope, scope, nil, lanes) + -- fiber.type = scope + -- fiber.elementType = scope + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromProfiler = function( +function createFiberFromProfiler( pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - if _G.__DEV__ then + if __DEV__ then if typeof(pendingProps.id) ~= "string" then - console.error("Profiler must specify an \"id\" as a prop") + console.error('Profiler must specify an "id" as a prop') end end - local fiber = createFiber(Profiler, pendingProps, key, bit32.bor(mode, ProfileMode)) + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + Profiler, + pendingProps, + key, + bit32.bor(mode, ProfileMode), + REACT_PROFILER_TYPE, + REACT_PROFILER_TYPE, + if enableProfilerTimer + then { + effectDuration = 0, + passiveEffectDuration = 0, + } + else nil, + lanes + ) -- TODO: The Profiler fiber shouldn't have a type. It has a tag. - fiber.elementType = REACT_PROFILER_TYPE - fiber.type = REACT_PROFILER_TYPE - fiber.lanes = lanes - - if enableProfilerTimer then - fiber.stateNode = { - effectDuration = 0, - passiveEffectDuration = 0, - } - end + -- fiber.elementType = REACT_PROFILER_TYPE + -- fiber.type = REACT_PROFILER_TYPE + -- fiber.lanes = lanes + -- ROBLOX deviation END + + -- if enableProfilerTimer then + -- fiber.stateNode = { + -- effectDuration = 0, + -- passiveEffectDuration = 0, + -- } + -- end return fiber end -createFiberFromSuspense = function( +function createFiberFromSuspense( pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(SuspenseComponent, pendingProps, key, mode) + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + SuspenseComponent, + pendingProps, + key, + mode, + REACT_SUSPENSE_TYPE, + REACT_SUSPENSE_TYPE, + nil, + lanes + ) -- TODO: The SuspenseComponent fiber shouldn't have a type. It has a tag. -- This needs to be fixed in getComponentName so that it relies on the tag -- instead. - fiber.type = REACT_SUSPENSE_TYPE - fiber.elementType = REACT_SUSPENSE_TYPE + -- fiber.type = REACT_SUSPENSE_TYPE + -- fiber.elementType = REACT_SUSPENSE_TYPE - fiber.lanes = lanes + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromSuspenseList = function( +function createFiberFromSuspenseList( pendingProps: any, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(SuspenseListComponent, pendingProps, key, mode) - if _G.__DEV__ then - -- TODO: The SuspenseListComponent fiber shouldn't have a type. It has a tag. - -- This needs to be fixed in getComponentName so that it relies on the tag - -- instead. - fiber.type = REACT_SUSPENSE_LIST_TYPE - end - fiber.elementType = REACT_SUSPENSE_LIST_TYPE - fiber.lanes = lanes + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + SuspenseListComponent, + pendingProps, + key, + mode, + REACT_SUSPENSE_LIST_TYPE, + if __DEV__ then REACT_SUSPENSE_LIST_TYPE else nil, + nil, + lanes + ) + -- if __DEV__ then + -- -- TODO: The SuspenseListComponent fiber shouldn't have a type. It has a tag. + -- -- This needs to be fixed in getComponentName so that it relies on the tag + -- -- instead. + -- fiber.type = REACT_SUSPENSE_LIST_TYPE + -- end + -- fiber.elementType = REACT_SUSPENSE_LIST_TYPE + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromOffscreen = function( +function createFiberFromOffscreen( pendingProps: OffscreenProps, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(OffscreenComponent, pendingProps, key, mode) + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + OffscreenComponent, + pendingProps, + key, + mode, + REACT_OFFSCREEN_TYPE, + if __DEV__ then REACT_OFFSCREEN_TYPE else nil, + nil, + lanes + ) -- TODO: The OffscreenComponent fiber shouldn't have a type. It has a tag. -- This needs to be fixed in getComponentName so that it relies on the tag -- instead. - if _G.__DEV__ then - fiber.type = REACT_OFFSCREEN_TYPE - end - fiber.elementType = REACT_OFFSCREEN_TYPE - fiber.lanes = lanes + -- if __DEV__ then + -- fiber.type = REACT_OFFSCREEN_TYPE + -- end + -- fiber.elementType = REACT_OFFSCREEN_TYPE + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -createFiberFromLegacyHidden = function( +function createFiberFromLegacyHidden( pendingProps: OffscreenProps, mode: TypeOfMode, lanes: Lanes, key: string? ): Fiber - local fiber = createFiber(LegacyHiddenComponent, pendingProps, key, mode) + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber( + LegacyHiddenComponent, + pendingProps, + key, + mode, + REACT_LEGACY_HIDDEN_TYPE, + if __DEV__ then REACT_LEGACY_HIDDEN_TYPE else nil, + nil, + lanes + ) -- TODO: The LegacyHidden fiber shouldn't have a type. It has a tag. -- This needs to be fixed in getComponentName so that it relies on the tag -- instead. - if _G.__DEV__ then - fiber.type = REACT_LEGACY_HIDDEN_TYPE - end - fiber.elementType = REACT_LEGACY_HIDDEN_TYPE - fiber.lanes = lanes + -- if __DEV__ then + -- fiber.type = REACT_LEGACY_HIDDEN_TYPE + -- end + -- fiber.elementType = REACT_LEGACY_HIDDEN_TYPE + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end -local function createFiberFromText( - content: string, - mode: TypeOfMode, - lanes: Lanes -): Fiber - local fiber = createFiber(HostText, content, nil, mode) - fiber.lanes = lanes +local function createFiberFromText(content: string, mode: TypeOfMode, lanes: Lanes): Fiber + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber(HostText, content, nil, mode, nil, nil, nil, lanes) + -- fiber.lanes = lanes + -- ROBLOX deviation END return fiber end local function createFiberFromHostInstanceForDeletion(): Fiber - local fiber = createFiber(HostComponent, nil, nil, NoMode) + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber(HostComponent, nil, nil, NoMode, "DELETED", "DELETED") -- TODO: These should not need a type. - fiber.elementType = "DELETED" - fiber.type = "DELETED" + -- fiber.elementType = "DELETED" + -- fiber.type = "DELETED" + -- ROBLOX deviation END return fiber end -local function createFiberFromDehydratedFragment( - dehydratedNode: SuspenseInstance -): Fiber - local fiber = createFiber(DehydratedFragment, nil, nil, NoMode) - fiber.stateNode = dehydratedNode +local function createFiberFromDehydratedFragment(dehydratedNode: SuspenseInstance): Fiber + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = + createFiber(DehydratedFragment, nil, nil, NoMode, nil, nil, dehydratedNode) + -- fiber.stateNode = dehydratedNode + -- ROBLOX deviation END return fiber end @@ -801,28 +931,26 @@ local function createFiberFromPortal( mode: TypeOfMode, lanes: Lanes ): Fiber - local pendingProps - if portal.children ~= nil then - pendingProps = portal.children - else - pendingProps = {} - end - local fiber = createFiber(HostPortal, pendingProps, portal.key, mode) - fiber.lanes = lanes - fiber.stateNode = { + local pendingProps = if portal.children ~= nil then portal.children else {} + -- ROBLOX deviation START: we pass in all needed values so the table creation+field assignment is a one-shot + local fiber = createFiber(HostPortal, pendingProps, portal.key, mode, nil, nil, { containerInfo = portal.containerInfo, pendingChildren = nil, -- Used by persistent updates implementation = portal.implementation, - } + }, lanes) + -- fiber.lanes = lanes + -- fiber.stateNode = { + -- containerInfo = portal.containerInfo, + -- pendingChildren = nil, -- Used by persistent updates + -- implementation = portal.implementation, + -- } + -- ROBLOX deviation END return fiber end -- Used for stashing WIP properties to replay failed work in DEV. -- ROBLOX FIXME: `target: Fiber | nil` - Narrowing doesn't work even with nil check -local function assignFiberPropertiesInDEV( - target: Fiber, - source: Fiber -): Fiber +local function assignFiberPropertiesInDEV(target: Fiber, source: Fiber): Fiber if target == nil then -- This Fiber's initial properties will always be overwritten. -- We only use a Fiber to ensure the same hidden class so DEV isn't slow. diff --git a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua index 3de4d84d..7b248896 100644 --- a/modules/react-reconciler/src/ReactFiberBeginWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberBeginWork.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberBeginWork.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberBeginWork.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,43 +8,50 @@ * * @flow ]] ---!nolint LocalShadowPedantic -- ROBLOX TODO remove this when CLI-38793 lands --!nolint LocalShadow -- 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) +local function unimplemented(message: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("UNIMPLEMENTED ERROR: " .. tostring(message)) + error("FIXME (roblox): " .. message .. " is unimplemented", 2) end 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 LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local Object = LuauPolyfill.Object +local inspect = LuauPolyfill.util.inspect + +-- Note: This file is right on Luau's local register limit. Adding the globals +-- import to its own variable literally breaks everything. +local __DEV__ = require(Packages.ReactGlobals).__DEV__ +local __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ = + require(Packages.ReactGlobals).__DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ +local __COMPAT_WARNINGS__ = require(Packages.ReactGlobals).__COMPAT_WARNINGS__ local ReactTypes = require(Packages.Shared) type ReactProviderType = ReactTypes.ReactProviderType type ReactContext = ReactTypes.ReactContext +type React_Component = ReactTypes.React_Component local React = require(Packages.React) type LazyComponentType = React.LazyComponent local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type FiberRoot = ReactInternalTypes.FiberRoot; +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes; +type Lanes = ReactFiberLane.Lanes -- type Lane = ReactFiberLane.Lane; -- local type {MutableSource} = require(Packages.Shared.ReactTypes) -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState type SuspenseListRenderState = ReactFiberSuspenseComponent.SuspenseListRenderState type SuspenseListTailMode = ReactFiberSuspenseComponent.SuspenseListTailMode @@ -57,7 +65,7 @@ type OffscreenState = ReactFiberOffscreenComponent.OffscreenState local checkPropTypes = require(Packages.Shared).checkPropTypes local ReactWorkTags = require(script.Parent.ReactWorkTags) -local IndeterminateComponent = ReactWorkTags.IndeterminateComponent +-- local IndeterminateComponent = ReactWorkTags.IndeterminateComponent local FunctionComponent = ReactWorkTags.FunctionComponent local ClassComponent = ReactWorkTags.ClassComponent local HostRoot = ReactWorkTags.HostRoot @@ -76,8 +84,8 @@ local MemoComponent = ReactWorkTags.MemoComponent local SimpleMemoComponent = ReactWorkTags.SimpleMemoComponent local LazyComponent = ReactWorkTags.LazyComponent local IncompleteClassComponent = ReactWorkTags.IncompleteClassComponent -local FundamentalComponent = ReactWorkTags.FundamentalComponent -local ScopeComponent = ReactWorkTags.ScopeComponent +-- local FundamentalComponent = ReactWorkTags.FundamentalComponent +-- local ScopeComponent = ReactWorkTags.ScopeComponent local OffscreenComponent = ReactWorkTags.OffscreenComponent local LegacyHiddenComponent = ReactWorkTags.LegacyHiddenComponent local ReactFiberFlags = require(script.Parent.ReactFiberFlags) @@ -94,16 +102,19 @@ local Deletion = ReactFiberFlags.Deletion local ForceUpdateForLegacySuspense = ReactFiberFlags.ForceUpdateForLegacySuspense local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -local debugRenderPhaseSideEffectsForStrictMode = ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode +local debugRenderPhaseSideEffectsForStrictMode = + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode local disableLegacyContext = ReactFeatureFlags.disableLegacyContext local disableModulePatternComponents = ReactFeatureFlags.disableModulePatternComponents local enableProfilerTimer = ReactFeatureFlags.enableProfilerTimer local enableSchedulerTracing = ReactFeatureFlags.enableSchedulerTracing local enableSuspenseServerRenderer = ReactFeatureFlags.enableSuspenseServerRenderer -local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI -local warnAboutDefaultPropsOnFunctionComponents = ReactFeatureFlags.warnAboutDefaultPropsOnFunctionComponents -local enableScopeAPI = ReactFeatureFlags.enableScopeAPI +-- local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI +local warnAboutDefaultPropsOnFunctionComponents = + ReactFeatureFlags.warnAboutDefaultPropsOnFunctionComponents +-- local enableScopeAPI = ReactFeatureFlags.enableScopeAPI local invariant = require(Packages.Shared).invariant +local describeError = require(Packages.Shared).describeError local shallowEqual = require(Packages.Shared).shallowEqual local getComponentName = require(Packages.Shared).getComponentName local ReactSymbols = require(Packages.Shared).ReactSymbols @@ -111,12 +122,16 @@ local REACT_LAZY_TYPE = ReactSymbols.REACT_LAZY_TYPE local _getIteratorFn = ReactSymbols.getIteratorFn local ReactStrictModeWarnings = require(script.Parent["ReactStrictModeWarnings.new"]) local ReactCurrentFiber = require(script.Parent.ReactCurrentFiber) -local getCurrentFiberOwnerNameInDevOrNull = ReactCurrentFiber.getCurrentFiberOwnerNameInDevOrNull +local getCurrentFiberOwnerNameInDevOrNull = + ReactCurrentFiber.getCurrentFiberOwnerNameInDevOrNull local setIsRendering = ReactCurrentFiber.setIsRendering local ReactFiberHotReloadingModule = require(script.Parent["ReactFiberHotReloading.new"]) -local resolveFunctionForHotReloading = ReactFiberHotReloadingModule.resolveFunctionForHotReloading -local resolveForwardRefForHotReloading = ReactFiberHotReloadingModule.resolveForwardRefForHotReloading -local resolveClassForHotReloading = ReactFiberHotReloadingModule.resolveClassForHotReloading +local resolveFunctionForHotReloading = + ReactFiberHotReloadingModule.resolveFunctionForHotReloading +local resolveForwardRefForHotReloading = + ReactFiberHotReloadingModule.resolveForwardRefForHotReloading +local resolveClassForHotReloading = + ReactFiberHotReloadingModule.resolveClassForHotReloading local ReactChildFiber = require(script.Parent["ReactChildFiber.new"]) :: any local mountChildFibers = ReactChildFiber.mountChildFibers @@ -147,10 +162,12 @@ local suspenseStackCursor = ReactFiberSuspenseContext.suspenseStackCursor local hasSuspenseContext = ReactFiberSuspenseContext.hasSuspenseContext local ForceSuspenseFallback = ReactFiberSuspenseContext.ForceSuspenseFallback local addSubtreeSuspenseContext = ReactFiberSuspenseContext.addSubtreeSuspenseContext -local InvisibleParentSuspenseContext = ReactFiberSuspenseContext.InvisibleParentSuspenseContext +local InvisibleParentSuspenseContext = + ReactFiberSuspenseContext.InvisibleParentSuspenseContext local pushSuspenseContext = ReactFiberSuspenseContext.pushSuspenseContext -- local setShallowSuspenseContext = ReactFiberSuspenseContext.setShallowSuspenseContext -local setDefaultShallowSuspenseContext = ReactFiberSuspenseContext.setDefaultShallowSuspenseContext +local setDefaultShallowSuspenseContext = + ReactFiberSuspenseContext.setDefaultShallowSuspenseContext -- local {findFirstSuspended} = require(script.Parent.ReactFiberSuspenseComponent.new) -- local { -- , @@ -164,42 +181,44 @@ local pushProvider = ReactFiberNewContext.pushProvider -- ROBLOX deviation: Lazy init all methods from ReactFiberHooks local lazyRefs = { - renderWithHooksRef = nil, - bailoutHooksRef = nil, - shouldSuspendRef = nil + renderWithHooksRef = nil :: any, + bailoutHooksRef = nil :: any, + shouldSuspendRef = nil :: any, } -local shouldSuspend = function(...) - if not lazyRefs.shouldSuspendRef then - lazyRefs.shouldSuspendRef = require(script.Parent.ReactFiberReconciler).shouldSuspend - end - return lazyRefs.shouldSuspendRef(...) +local function shouldSuspend(fiber: Fiber): boolean + if not lazyRefs.shouldSuspendRef then + lazyRefs.shouldSuspendRef = + require(script.Parent.ReactFiberReconciler).shouldSuspend + end + return lazyRefs.shouldSuspendRef(fiber) end -- ROBLOX deviation: collective lazy init methods from ReactFiberHooks -local initReactFiberHooks = function() - local ReactFiberHooks = require(script.Parent["ReactFiberHooks.new"]) - lazyRefs.renderWithHooksRef = ReactFiberHooks.renderWithHooks - lazyRefs.bailoutHooksRef = ReactFiberHooks.bailoutHooks +local function initReactFiberHooks() + local ReactFiberHooks = require(script.Parent["ReactFiberHooks.new"]) + lazyRefs.renderWithHooksRef = ReactFiberHooks.renderWithHooks + lazyRefs.bailoutHooksRef = ReactFiberHooks.bailoutHooks end -- ROBLOX deviation: Lazy init renderWithHooks from ReactFiberHooks -local renderWithHooks = function(...) - if not lazyRefs.renderWithHooksRef then - initReactFiberHooks() - end - return lazyRefs.renderWithHooksRef(...) +local function renderWithHooks(...) + if not lazyRefs.renderWithHooksRef then + initReactFiberHooks() + end + return lazyRefs.renderWithHooksRef(...) end -- ROBLOX deviation: Lazy init bailoutHooks from ReactFiberHooks -local bailoutHooks = function(...) - if not lazyRefs.bailoutHooksRef then - initReactFiberHooks() - end - return lazyRefs.bailoutHooksRef(...) +local function bailoutHooks(...) + if not lazyRefs.bailoutHooksRef then + initReactFiberHooks() + end + return lazyRefs.bailoutHooksRef(...) end -local stopProfilerTimerIfRunning = require(script.Parent["ReactProfilerTimer.new"]).stopProfilerTimerIfRunning +local stopProfilerTimerIfRunning = + require(script.Parent["ReactProfilerTimer.new"]).stopProfilerTimerIfRunning local ReactFiberContext = require(script.Parent["ReactFiberContext.new"]) local getMaskedContext = ReactFiberContext.getMaskedContext local getUnmaskedContext = ReactFiberContext.getUnmaskedContext @@ -209,13 +228,17 @@ local isLegacyContextProvider = ReactFiberContext.isContextProvider local pushTopLevelContextObject = ReactFiberContext.pushTopLevelContextObject local invalidateContextProvider = ReactFiberContext.invalidateContextProvider -local ReactFiberHydrationContext = require(script.Parent["ReactFiberHydrationContext.new"]) +local ReactFiberHydrationContext = + require(script.Parent["ReactFiberHydrationContext.new"]) local resetHydrationState = ReactFiberHydrationContext.resetHydrationState local enterHydrationState = ReactFiberHydrationContext.enterHydrationState -local reenterHydrationStateFromDehydratedSuspenseInstance = ReactFiberHydrationContext.reenterHydrationStateFromDehydratedSuspenseInstance -local tryToClaimNextHydratableInstance = ReactFiberHydrationContext.tryToClaimNextHydratableInstance +local reenterHydrationStateFromDehydratedSuspenseInstance = + ReactFiberHydrationContext.reenterHydrationStateFromDehydratedSuspenseInstance +local tryToClaimNextHydratableInstance = + ReactFiberHydrationContext.tryToClaimNextHydratableInstance local warnIfHydrating = ReactFiberHydrationContext.warnIfHydrating -local ReactFiberClassComponent = require(script.Parent["ReactFiberClassComponent.new"]) :: any +local ReactFiberClassComponent = + require(script.Parent["ReactFiberClassComponent.new"]) :: any local adoptClassInstance = ReactFiberClassComponent.adoptClassInstance local applyDerivedStateFromProps = ReactFiberClassComponent.applyDerivedStateFromProps local constructClassInstance = ReactFiberClassComponent.constructClassInstance @@ -223,7 +246,8 @@ local mountClassInstance = ReactFiberClassComponent.mountClassInstance local resumeMountClassInstance = ReactFiberClassComponent.resumeMountClassInstance local updateClassInstance = ReactFiberClassComponent.updateClassInstance -local resolveDefaultProps = require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps +local resolveDefaultProps = + require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps local ReactFiber = require(script.Parent["ReactFiber.new"]) local resolveLazyComponentTag = ReactFiber.resolveLazyComponentTag local createFiberFromFragment = ReactFiber.createFiberFromFragment @@ -243,498 +267,508 @@ local getExecutionContext = ReactFiberWorkLoop.getExecutionContext local RetryAfterError = ReactFiberWorkLoop.RetryAfterError local NoContext = ReactFiberWorkLoop.NoContext -local Schedule_tracing_wrap = require(Packages.Scheduler).tracing.unstable_wrap -local setWorkInProgressVersion = require(script.Parent["ReactMutableSource.new"]).setWorkInProgressVersion -local markSkippedUpdateLanes = require(script.Parent.ReactFiberWorkInProgress).markSkippedUpdateLanes +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 local disableLogs = ConsolePatchingDev.disableLogs local reenableLogs = ConsolePatchingDev.reenableLogs local ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner -local exports: {[string]: any} = {} +local exports: { [string]: any } = {} -- ROBLOX deviation: Pre-declare functions local bailoutOnAlreadyFinishedWork, updateFunctionComponent local didReceiveUpdate: boolean = false - -- ROBLOX deviation: put didWarns in table to reduce number of local variables local DidWarn = { - didWarnAboutBadClass = nil, - didWarnAboutModulePatternComponent = nil, - didWarnAboutContextTypeOnFunctionComponent = nil, - didWarnAboutGetDerivedStateOnFunctionComponent = nil, - didWarnAboutFunctionRefs = nil, - didWarnAboutDefaultPropsOnFunctionComponent = nil + didWarnAboutBadClass = {} :: { [string]: boolean }, + didWarnAboutModulePatternComponent = {} :: { [string]: boolean }, + didWarnAboutContextTypeOnFunctionComponent = {} :: { [string]: boolean }, + didWarnAboutGetDerivedStateOnFunctionComponent = {} :: { [string]: boolean }, + didWarnAboutFunctionRefs = {} :: { [string]: boolean }, + didWarnAboutDefaultPropsOnFunctionComponent = {} :: { [string]: boolean }, } -- export local didWarnAboutReassigningProps -- local didWarnAboutRevealOrder -- local didWarnAboutTailOptions local updateSimpleMemoComponent -if _G.__DEV__ then - DidWarn.didWarnAboutBadClass = {} - DidWarn.didWarnAboutModulePatternComponent = {} - DidWarn.didWarnAboutContextTypeOnFunctionComponent = {} - DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent = {} - DidWarn.didWarnAboutFunctionRefs = {} - exports.didWarnAboutReassigningProps = false --- didWarnAboutRevealOrder = {} --- didWarnAboutTailOptions = {} - DidWarn.didWarnAboutDefaultPropsOnFunctionComponent = {} +if __DEV__ then + DidWarn.didWarnAboutBadClass = {} + DidWarn.didWarnAboutModulePatternComponent = {} + DidWarn.didWarnAboutContextTypeOnFunctionComponent = {} + DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent = {} + DidWarn.didWarnAboutFunctionRefs = {} + exports.didWarnAboutReassigningProps = false + -- didWarnAboutRevealOrder = {} + -- didWarnAboutTailOptions = {} + DidWarn.didWarnAboutDefaultPropsOnFunctionComponent = {} end --- FIXME (roblox): type refinements, reintroduce parameter annotation --- current: Fiber | nil, local function reconcileChildren( - current, - workInProgress: Fiber, - nextChildren: any, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + nextChildren: any, + renderLanes: Lanes ) - if current == nil then - -- If this is a fresh new component that hasn't been rendered yet, we - -- won't update its child set by applying minimal side-effects. Instead, - -- we will add them all to the child before it gets rendered. That means - -- we can optimize this reconciliation pass by not tracking side-effects. - workInProgress.child = mountChildFibers( - workInProgress, - nil, - nextChildren, - renderLanes - ) - else - -- If the current child is the same as the work in progress, it means that - -- we haven't yet started any work on these children. Therefore, we use - -- the clone algorithm to create a copy of all the current children. - - -- If we had any progressed work already, that is invalid at this point so - -- let's throw it out. - workInProgress.child = reconcileChildFibers( - workInProgress, - current.child, - nextChildren, - renderLanes - ) - end + if current == nil then + -- If this is a fresh new component that hasn't been rendered yet, we + -- won't update its child set by applying minimal side-effects. Instead, + -- we will add them all to the child before it gets rendered. That means + -- we can optimize this reconciliation pass by not tracking side-effects. + workInProgress.child = + mountChildFibers(workInProgress, nil, nextChildren, renderLanes) + else + -- If the current child is the same as the work in progress, it means that + -- we haven't yet started any work on these children. Therefore, we use + -- the clone algorithm to create a copy of all the current children. + + -- If we had any progressed work already, that is invalid at this point so + -- let's throw it out. + workInProgress.child = reconcileChildFibers( + workInProgress, + (current :: Fiber).child, + nextChildren, + renderLanes + ) + end end local function forceUnmountCurrentAndReconcile( - current: Fiber, - workInProgress: Fiber, - nextChildren: any, - renderLanes: Lanes + current: Fiber, + workInProgress: Fiber, + nextChildren: any, + renderLanes: Lanes ) - -- This function is fork of reconcileChildren. It's used in cases where we - -- want to reconcile without matching against the existing set. This has the - -- effect of all current children being unmounted; even if the type and key - -- are the same, the old child is unmounted and a new child is created. - -- - -- To do this, we're going to go through the reconcile algorithm twice. In - -- the first pass, we schedule a deletion for all the current children by - -- passing nil. - workInProgress.child = reconcileChildFibers( - workInProgress, - current.child, - nil, - renderLanes - ) - -- In the second pass, we mount the new children. The trick here is that we - -- pass nil in place of where we usually pass the current child set. This has - -- the effect of remounting all children regardless of whether their - -- identities match. - workInProgress.child = reconcileChildFibers( - workInProgress, - nil, - nextChildren, - renderLanes - ) + -- This function is fork of reconcileChildren. It's used in cases where we + -- want to reconcile without matching against the existing set. This has the + -- effect of all current children being unmounted; even if the type and key + -- are the same, the old child is unmounted and a new child is created. + -- + -- To do this, we're going to go through the reconcile algorithm twice. In + -- the first pass, we schedule a deletion for all the current children by + -- passing nil. + workInProgress.child = + reconcileChildFibers(workInProgress, current.child, nil, renderLanes) + -- In the second pass, we mount the new children. The trick here is that we + -- pass nil in place of where we usually pass the current child set. This has + -- the effect of remounting all children regardless of whether their + -- identities match. + workInProgress.child = + reconcileChildFibers(workInProgress, nil, nextChildren, renderLanes) end local function updateForwardRef( - current: Fiber | nil, - workInProgress: Fiber, - Component: any, - nextProps: any, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + Component: any, + nextProps: any, + renderLanes: Lanes ) - -- TODO: current can be non-null here even if the component - -- hasn't yet mounted. This happens after the first render suspends. - -- We'll need to figure out if this is fine or can cause issues. - - if _G.__DEV__ then - - if workInProgress.type ~= workInProgress.elementType then - -- Lazy component props can't be validated in createElement - -- because they're only guaranteed to be resolved here. - local innerPropTypes = Component.propTypes - if innerPropTypes then - checkPropTypes( - innerPropTypes, - nextProps, -- Resolved props - "prop", - getComponentName(Component) - ) - end - end - end - - local render = Component.render - local ref = workInProgress.ref - - -- The rest is a fork of updateFunctionComponent - local nextChildren - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - if _G.__DEV__ then - ReactCurrentOwner.current = workInProgress - setIsRendering(true) - nextChildren = renderWithHooks( - current, - workInProgress, - render, - nextProps, - ref, - renderLanes - ) - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - nextChildren = renderWithHooks( - current, - workInProgress, - render, - nextProps, - ref, - renderLanes - ) - end) - -- finally - reenableLogs() - - if not ok then - error(result) - end - end - setIsRendering(false) - else - nextChildren = renderWithHooks( - current, - workInProgress, - render, - nextProps, - ref, - renderLanes - ) - end - - if current ~= nil and not didReceiveUpdate then - bailoutHooks(current, workInProgress, renderLanes) - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - end - - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child + -- TODO: current can be non-null here even if the component + -- hasn't yet mounted. This happens after the first render suspends. + -- We'll need to figure out if this is fine or can cause issues. + + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + 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 + local validateProps = Component.validateProps + if innerPropTypes or validateProps then + checkPropTypes( + innerPropTypes, + validateProps, + nextProps, -- Resolved props + "prop", + getComponentName(Component) + ) + end + end + end + + local render = Component.render + local ref = workInProgress.ref + + -- The rest is a fork of updateFunctionComponent + local nextChildren + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + if __DEV__ then + ReactCurrentOwner.current = workInProgress + setIsRendering(true) + nextChildren = + renderWithHooks(current, workInProgress, render, nextProps, ref, renderLanes) + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + local ok, result = xpcall( + renderWithHooks, + describeError, + current, + workInProgress, + render, + nextProps, + ref, + renderLanes + ) + if ok then + nextChildren = result + end + -- finally + reenableLogs() + + if not ok then + error(result) + end + end + setIsRendering(false) + else + nextChildren = + renderWithHooks(current, workInProgress, render, nextProps, ref, renderLanes) + end + + if current ~= nil and not didReceiveUpdate then + bailoutHooks(current, workInProgress, renderLanes) + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end local function updateMemoComponent( - current: Fiber | nil, - workInProgress: Fiber, - Component: any, - nextProps: any, - updateLanes: Lanes, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + Component: any, + nextProps: any, + updateLanes: Lanes, + renderLanes: Lanes ): nil | Fiber - if current == nil then - local type = Component.type - if - isSimpleFunctionComponent(type) and - Component.compare == nil and - -- SimpleMemoComponent codepath doesn't resolve outer props either. - Component.defaultProps == nil - then - local resolvedType = type - if _G.__DEV__ then - resolvedType = resolveFunctionForHotReloading(type) - end - -- If this is a plain function component without default props, - -- and with only the default shallow comparison, we upgrade it - -- to a SimpleMemoComponent to allow fast path updates. - workInProgress.tag = SimpleMemoComponent - workInProgress.type = resolvedType - if _G.__DEV__ then - validateFunctionComponentInDev(workInProgress, type) - end - return updateSimpleMemoComponent( - current, - workInProgress, - resolvedType, - nextProps, - updateLanes, - renderLanes - ) - end - if _G.__DEV__ then - -- 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 - -- 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, - nextProps, -- Resolved props - "prop", - getComponentName(type) - ) - end - end - local child = createFiberFromTypeAndProps( - Component.type, - nil, - nextProps, - workInProgress, - workInProgress.mode, - renderLanes - ) - child.ref = workInProgress.ref - child.return_ = workInProgress - workInProgress.child = child - return child - end - -- ROBLOX TODO Deviation: remove redefinition + typecast when this lands: CLI-38793 - -- ROBLOX the if clause above returns early if current is nil - local current = (current :: Fiber) - if _G.__DEV__ then - local type = Component.type - -- 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 - -- 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, - nextProps, -- Resolved props - 'prop', - getComponentName(type) - ) - end - end - local currentChild = ((current.child :: any) :: Fiber) -- This is always exactly one child - if not ReactFiberLane.includesSomeLane(updateLanes, renderLanes) then - -- This will be the props with resolved defaultProps, - -- unlike current.memoizedProps which will be the unresolved ones. - local prevProps = currentChild.memoizedProps - -- Default to shallow comparison - local compare = Component.compare - if compare == nil then - compare = shallowEqual - end - if compare(prevProps, nextProps) and current.ref == workInProgress.ref then - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - end - end - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - local newChild = createWorkInProgress(currentChild, nextProps) - newChild.ref = workInProgress.ref - newChild.return_ = workInProgress - workInProgress.child = newChild - return newChild + if current == nil then + local type_ = Component.type + if + isSimpleFunctionComponent(type_) + and Component.compare == nil + -- SimpleMemoComponent codepath doesn't resolve outer props either. + and Component.defaultProps == nil + then + local resolvedType = type_ + if __DEV__ then + resolvedType = resolveFunctionForHotReloading(type_) + end + -- If this is a plain function component without default props, + -- and with only the default shallow comparison, we upgrade it + -- to a SimpleMemoComponent to allow fast path updates. + workInProgress.tag = SimpleMemoComponent + workInProgress.type = resolvedType + if __DEV__ then + validateFunctionComponentInDev(workInProgress, type_) + end + return updateSimpleMemoComponent( + current, + workInProgress, + resolvedType, + nextProps, + updateLanes, + renderLanes + ) + end + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ 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 + if type(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_) + ) + end + end + local child = createFiberFromTypeAndProps( + Component.type, + nil, + nextProps, + workInProgress, + workInProgress.mode, + renderLanes + ) + child.ref = workInProgress.ref + child.return_ = workInProgress + workInProgress.child = child + return child + end + -- ROBLOX TODO Deviation: remove redefinition + typecast when this lands: CLI-38793 + -- ROBLOX the if clause above returns early if current is nil + local current = current :: Fiber + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ 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 + if type(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_) + ) + end + end + local currentChild = (current.child :: any) :: Fiber -- This is always exactly one child + if not ReactFiberLane.includesSomeLane(updateLanes, renderLanes) then + -- This will be the props with resolved defaultProps, + -- unlike current.memoizedProps which will be the unresolved ones. + local prevProps = currentChild.memoizedProps + -- Default to shallow comparison + local compare = Component.compare + if compare == nil then + compare = shallowEqual + end + if compare(prevProps, nextProps) and current.ref == workInProgress.ref then + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + end + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + local newChild = createWorkInProgress(currentChild, nextProps) + newChild.ref = workInProgress.ref + newChild.return_ = workInProgress + workInProgress.child = newChild + return newChild end function updateSimpleMemoComponent( - current: Fiber | nil, - workInProgress: Fiber, - Component: any, - nextProps: any, - updateLanes: Lanes, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + Component: any, + nextProps: any, + updateLanes: Lanes, + renderLanes: Lanes ): nil | Fiber - -- TODO: current can be non-null here even if the component - -- hasn't yet mounted. This happens when the inner render suspends. - -- We'll need to figure out if this is fine or can cause issues. - - if _G.__DEV__ then - if workInProgress.type ~= workInProgress.elementType then - -- Lazy component props can't be validated in createElement - -- because they're only guaranteed to be resolved here. - local outerMemoType = workInProgress.elementType - if outerMemoType["$$typeof"] == REACT_LAZY_TYPE then - -- We warn when you define propTypes on lazy() - -- so let's just skip over it to find memo() outer wrapper. - -- Inner props for memo are validated later. - local lazyComponent: LazyComponentType = outerMemoType - local payload = lazyComponent._payload - local init = lazyComponent._init - local ok, _result = pcall(function() - outerMemoType = init(payload) - end) - if not ok then - outerMemoType = nil - end - -- Inner propTypes will be validated in the function component path. - -- 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 - checkPropTypes( - outerPropTypes, - nextProps, -- Resolved (SimpleMemoComponent has no defaultProps) - "prop", - getComponentName(outerMemoType) - ) - end - end - end - end - if current ~= nil then - -- ROBLOX TODO Deviation: remove redefinition + typecast when this lands: CLI-38793 - -- This unfortunately breaks if current gets reassigned somewhere in this if clause - local current = (current :: Fiber) - local prevProps = current.memoizedProps - -- ROBLOX Deviation: replacing ternary operator - local preventBailout = true - if _G.__DEV__ then - preventBailout = workInProgress.type == current.type - end - if - shallowEqual(prevProps, nextProps) and - current.ref == workInProgress.ref and - -- Prevent bailout if the implementation changed due to hot reload. - preventBailout - then - didReceiveUpdate = false - if not ReactFiberLane.includesSomeLane(renderLanes, updateLanes) then - -- The pending lanes were cleared at the beginning of beginWork. We're - -- about to bail out, but there might be other lanes that weren't - -- included in the current render. Usually, the priority level of the - -- remaining updates is accumlated during the evaluation of the - -- component (i.e. when processing the update queue). But since since - -- we're bailing out early *without* evaluating the component, we need - -- to account for it here, too. Reset to the value of the current fiber. - -- NOTE: This only applies to SimpleMemoComponent, not MemoComponent, - -- because a MemoComponent fiber does not have hooks or an update queue - -- rather, it wraps around an inner component, which may or may not - -- contains hooks. - -- TODO: Move the reset at in beginWork out of the common path so that - -- this is no longer necessary. - workInProgress.lanes = current.lanes - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderLanes - ) - elseif bit32.band(current.flags, ForceUpdateForLegacySuspense) ~= NoFlags then - -- This is a special case that only exists for legacy mode. - -- See https://github.com/facebook/react/pull/19216. - didReceiveUpdate = true - end - end - end - return updateFunctionComponent( - current, - workInProgress, - Component, - nextProps, - renderLanes - ) + -- TODO: current can be non-null here even if the component + -- hasn't yet mounted. This happens when the inner render suspends. + -- We'll need to figure out if this is fine or can cause issues. + + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + if workInProgress.type ~= workInProgress.elementType then + -- Lazy component props can't be validated in createElement + -- because they're only guaranteed to be resolved here. + local outerMemoType = workInProgress.elementType + if outerMemoType["$$typeof"] == REACT_LAZY_TYPE then + -- We warn when you define propTypes on lazy() + -- so let's just skip over it to find memo() outer wrapper. + -- Inner props for memo are validated later. + local lazyComponent: LazyComponentType = outerMemoType + local payload = lazyComponent._payload + local init = lazyComponent._init + local ok, result = xpcall(init, describeError, payload) + if ok then + outerMemoType = result + else + 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 + if outerMemoType ~= nil and type(outerMemoType) == "table" then + outerPropTypes = (outerMemoType :: any).propTypes + -- ROBLOX deviation: support legacy Roact's equivalent of propTypes + validateProps = (outerMemoType :: any).validateProps + end + + if outerPropTypes or validateProps then + checkPropTypes( + outerPropTypes, + validateProps, + nextProps, -- Resolved (SimpleMemoComponent has no defaultProps) + "prop", + getComponentName(outerMemoType) + ) + end + end + end + end + if current ~= nil then + -- ROBLOX TODO Deviation: remove redefinition + typecast when this lands: CLI-38793 + -- This unfortunately breaks if current gets reassigned somewhere in this if clause + local current = current :: Fiber + local prevProps = current.memoizedProps + -- ROBLOX Deviation: replacing ternary operator + local preventBailout = true + if __DEV__ then + preventBailout = workInProgress.type == current.type + end + if + shallowEqual(prevProps, nextProps) + and current.ref == workInProgress.ref + -- Prevent bailout if the implementation changed due to hot reload. + and preventBailout + then + didReceiveUpdate = false + if not ReactFiberLane.includesSomeLane(renderLanes, updateLanes) then + -- The pending lanes were cleared at the beginning of beginWork. We're + -- about to bail out, but there might be other lanes that weren't + -- included in the current render. Usually, the priority level of the + -- remaining updates is accumlated during the evaluation of the + -- component (i.e. when processing the update queue). But since since + -- we're bailing out early *without* evaluating the component, we need + -- to account for it here, too. Reset to the value of the current fiber. + -- NOTE: This only applies to SimpleMemoComponent, not MemoComponent, + -- because a MemoComponent fiber does not have hooks or an update queue + -- rather, it wraps around an inner component, which may or may not + -- contains hooks. + -- TODO: Move the reset at in beginWork out of the common path so that + -- this is no longer necessary. + workInProgress.lanes = current.lanes + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + elseif bit32.band(current.flags, ForceUpdateForLegacySuspense) ~= NoFlags then + -- This is a special case that only exists for legacy mode. + -- See https://github.com/facebook/react/pull/19216. + didReceiveUpdate = true + end + end + end + return updateFunctionComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) end local function updateOffscreenComponent( - current: Fiber?, - workInProgress: Fiber, - renderLanes: Lanes -) - local nextProps: OffscreenProps = workInProgress.pendingProps - local nextChildren = nextProps.children - - local prevState: OffscreenState? - if current ~= nil then - -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil checks - prevState = (current :: Fiber).memoizedState - end - - if - nextProps.mode == 'hidden' or - nextProps.mode == 'unstable-defer-without-hiding' then - if bit32.band(workInProgress.mode, ConcurrentMode) == NoMode then - -- In legacy sync mode, don't defer the subtree. Render it now. - -- TODO: Figure out what we should do in Blocking mode. - local nextState: OffscreenState = { - baseLanes = ReactFiberLane.NoLanes - } - workInProgress.memoizedState = nextState - pushRenderLanes(workInProgress, renderLanes) - -- ROBLOX TODO: recast ReactFiberLane.OffscreenLane to type Lane - elseif not ReactFiberLane.includesSomeLane(renderLanes, ReactFiberLane.OffscreenLane) then - local nextBaseLanes - if prevState ~= nil then - -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check - local prevBaseLanes = (prevState :: OffscreenState).baseLanes - nextBaseLanes = ReactFiberLane.mergeLanes(prevBaseLanes, renderLanes) - else - nextBaseLanes = renderLanes - end - - -- Schedule this fiber to re-render at offscreen priority. Then bailout. - if enableSchedulerTracing then - markSpawnedWork(ReactFiberLane.OffscreenLane) - end - - -- deviation: unchain multiple assignment into two discrete assignments. - workInProgress.childLanes = ReactFiberLane.laneToLanes( - ReactFiberLane.OffscreenLane - ) - workInProgress.lanes = workInProgress.childLanes - - local nextState: OffscreenState = { - baseLanes = nextBaseLanes - } - workInProgress.memoizedState = nextState - -- We're about to bail out, but we need to push this to the stack anyway - -- to avoid a push/pop misalignment. - pushRenderLanes(workInProgress, nextBaseLanes) - return nil - else - -- Rendering at offscreen, so we can clear the base lanes. - local nextState: OffscreenState = { - baseLanes = ReactFiberLane.NoLanes - } - workInProgress.memoizedState = nextState - -- Push the lanes that were skipped when we bailed out. - local subtreeRenderLanes = renderLanes - - -- deviation: ternary converted to if statement - if prevState ~= nil then - -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check - subtreeRenderLanes = (prevState :: OffscreenState).baseLanes - end - - pushRenderLanes(workInProgress, subtreeRenderLanes) - end - else - local subtreeRenderLanes - if prevState ~= nil then - -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check - subtreeRenderLanes = ReactFiberLane.mergeLanes((prevState :: OffscreenState).baseLanes, renderLanes) - -- Since we're not hidden anymore, reset the state - workInProgress.memoizedState = nil - else - -- We weren't previously hidden, and we still aren't, so there's nothing - -- special to do. Need to push to the stack regardless, though, to avoid - -- a push/pop misalignment. - subtreeRenderLanes = renderLanes - end - pushRenderLanes(workInProgress, subtreeRenderLanes) - end - - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child + current: Fiber?, + workInProgress: Fiber, + renderLanes: Lanes +): Fiber | nil + local nextProps: OffscreenProps = workInProgress.pendingProps + local nextChildren = nextProps.children + + local prevState: OffscreenState? + if current ~= nil then + -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil checks + prevState = (current :: Fiber).memoizedState + end + + if + nextProps.mode == "hidden" + or nextProps.mode == "unstable-defer-without-hiding" + then + if bit32.band(workInProgress.mode, ConcurrentMode) == NoMode then + -- In legacy sync mode, don't defer the subtree. Render it now. + -- TODO: Figure out what we should do in Blocking mode. + local nextState: OffscreenState = { + baseLanes = ReactFiberLane.NoLanes, + } + workInProgress.memoizedState = nextState + pushRenderLanes(workInProgress, renderLanes) + -- ROBLOX TODO: recast ReactFiberLane.OffscreenLane to type Lane + elseif + not ReactFiberLane.includesSomeLane(renderLanes, ReactFiberLane.OffscreenLane) + then + local nextBaseLanes + if prevState ~= nil then + -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check + local prevBaseLanes = (prevState :: OffscreenState).baseLanes + nextBaseLanes = ReactFiberLane.mergeLanes(prevBaseLanes, renderLanes) + else + nextBaseLanes = renderLanes + end + + -- Schedule this fiber to re-render at offscreen priority. Then bailout. + if enableSchedulerTracing then + markSpawnedWork(ReactFiberLane.OffscreenLane) + end + + -- deviation: unchain multiple assignment into two discrete assignments. + workInProgress.childLanes = + ReactFiberLane.laneToLanes(ReactFiberLane.OffscreenLane) + workInProgress.lanes = workInProgress.childLanes + + local nextState: OffscreenState = { + baseLanes = nextBaseLanes, + } + workInProgress.memoizedState = nextState + -- We're about to bail out, but we need to push this to the stack anyway + -- to avoid a push/pop misalignment. + pushRenderLanes(workInProgress, nextBaseLanes) + return nil + else + -- Rendering at offscreen, so we can clear the base lanes. + local nextState: OffscreenState = { + baseLanes = ReactFiberLane.NoLanes, + } + workInProgress.memoizedState = nextState + -- Push the lanes that were skipped when we bailed out. + local subtreeRenderLanes = renderLanes + + -- deviation: ternary converted to if statement + if prevState ~= nil then + -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check + subtreeRenderLanes = (prevState :: OffscreenState).baseLanes + end + + pushRenderLanes(workInProgress, subtreeRenderLanes) + end + else + local subtreeRenderLanes + if prevState ~= nil then + -- ROBLOX FIXME: remove :: recast once Luau understands if-statement nil check + subtreeRenderLanes = ReactFiberLane.mergeLanes( + (prevState :: OffscreenState).baseLanes, + renderLanes + ) + -- Since we're not hidden anymore, reset the state + workInProgress.memoizedState = nil + else + -- We weren't previously hidden, and we still aren't, so there's nothing + -- special to do. Need to push to the stack regardless, though, to avoid + -- a push/pop misalignment. + subtreeRenderLanes = renderLanes + end + pushRenderLanes(workInProgress, subtreeRenderLanes) + end + + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end -- Note: These happen to have identical begin phases, for now. We shouldn't hold @@ -742,144 +776,146 @@ end -- fork the function. local updateLegacyHiddenComponent = updateOffscreenComponent -function updateFragment( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes -) - local nextChildren = workInProgress.pendingProps - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child +function updateFragment(current: Fiber | nil, workInProgress: Fiber, renderLanes: Lanes) + local nextChildren = workInProgress.pendingProps + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end -function updateMode( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes -) - local nextChildren = workInProgress.pendingProps.children - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child +function updateMode(current: Fiber | nil, workInProgress: Fiber, renderLanes: Lanes) + local nextChildren = workInProgress.pendingProps.children + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end -function updateProfiler( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes -) - if enableProfilerTimer then - -- Reset effect durations for the next eventual effect phase. - -- These are reset during render to allow the DevTools commit hook a chance to read them, - local stateNode = workInProgress.stateNode - stateNode.effectDuration = 0 - stateNode.passiveEffectDuration = 0 - end - local nextProps = workInProgress.pendingProps - local nextChildren = nextProps.children - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child +function updateProfiler(current: Fiber | nil, workInProgress: Fiber, renderLanes: Lanes) + if enableProfilerTimer then + -- Reset effect durations for the next eventual effect phase. + -- These are reset during render to allow the DevTools commit hook a chance to read them, + local stateNode = workInProgress.stateNode + stateNode.effectDuration = 0 + stateNode.passiveEffectDuration = 0 + end + local nextProps = workInProgress.pendingProps + local nextChildren = nextProps.children + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end local function markRef(current: Fiber | nil, workInProgress: Fiber) - local ref = workInProgress.ref - if - (current == nil and ref ~= nil) or - (current ~= nil and (current :: Fiber).ref ~= ref) - then - -- Schedule a Ref effect - workInProgress.flags = bit32.bor(workInProgress.flags, Ref) - end + local ref = workInProgress.ref + if + (current == nil and ref ~= nil) + or (current ~= nil and (current :: Fiber).ref ~= ref) + then + -- Schedule a Ref effect + workInProgress.flags = bit32.bor(workInProgress.flags, Ref) + end end -updateFunctionComponent = function( - current, - workInProgress, - Component, - nextProps: any, - renderLanes +function updateFunctionComponent( + current, + workInProgress, + Component, + nextProps: any, + renderLanes ) - if _G.__DEV__ then - -- ROBLOX deviation: function components can't have props in Lua - 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. - local innerPropTypes - -- ROBLOX deviation: Roact won't support propTypes on functional components - if typeof(Component) == "table" then - innerPropTypes = Component.propTypes - end - if innerPropTypes then - checkPropTypes( - innerPropTypes, - nextProps, -- Resolved props - 'prop', - getComponentName(Component) - ) - end - end - end - - local context - if not disableLegacyContext then - local unmaskedContext = getUnmaskedContext(workInProgress, Component, true) - context = getMaskedContext(workInProgress, unmaskedContext) - end - - local nextChildren - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - if _G.__DEV__ then - ReactCurrentOwner.current = workInProgress - setIsRendering(true) - nextChildren = renderWithHooks( - current, - workInProgress, - Component, - nextProps, - context, - renderLanes - ) - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - nextChildren = renderWithHooks( - current, - workInProgress, - Component, - nextProps, - context, - renderLanes - ) - end) - -- finally - reenableLogs() - if not ok then - error(result) - end - end - setIsRendering(false) - else - nextChildren = renderWithHooks( - current, - workInProgress, - Component, - nextProps, - context, - renderLanes - ) - end - - if current ~= nil and not didReceiveUpdate then - bailoutHooks(current, workInProgress, renderLanes) - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - end - - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + -- ROBLOX deviation: function components can't have props in Lua + if + type(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 type(Component) == "table" then + innerPropTypes = (Component :: any).propTypes + validateProps = (Component :: any).validateProps + end + + if innerPropTypes or validateProps then + checkPropTypes( + innerPropTypes, + validateProps, + nextProps, -- Resolved props + "prop", + getComponentName(Component) + ) + end + end + end + + local context + if not disableLegacyContext then + local unmaskedContext = getUnmaskedContext(workInProgress, Component, true) + context = getMaskedContext(workInProgress, unmaskedContext) + end + + local nextChildren + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + if __DEV__ then + ReactCurrentOwner.current = workInProgress + setIsRendering(true) + nextChildren = renderWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + renderLanes + ) + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + local ok, result = xpcall( + renderWithHooks, + describeError, + current, + workInProgress, + Component, + nextProps, + context, + renderLanes + ) + -- finally + reenableLogs() + if ok then + nextChildren = result + else + error(result) + end + end + setIsRendering(false) + else + nextChildren = renderWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + renderLanes + ) + end + + if current ~= nil and not didReceiveUpdate then + bailoutHooks(current, workInProgress, renderLanes) + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end -- function updateBlock( @@ -899,7 +935,7 @@ end -- -- The rest is a fork of updateFunctionComponent -- local nextChildren -- prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) --- if _G.__DEV__ then +-- if __DEV__ then -- ReactCurrentOwner.current = workInProgress -- setIsRendering(true) -- nextChildren = renderWithHooks( @@ -957,285 +993,285 @@ end -- ... -- ) local function updateClassComponent( - current: any, - workInProgress: Fiber, - Component: any, - nextProps: any, - renderLanes: Lanes + current: any, + workInProgress: Fiber, + Component: any, + nextProps: any, + renderLanes: Lanes ) - if _G.__DEV__ then - if workInProgress.type ~= workInProgress.elementType then - -- Lazy component props can't be validated in createElement - -- because they're only guaranteed to be resolved here. - local innerPropTypes = Component.propTypes - if innerPropTypes then - checkPropTypes( - innerPropTypes, - nextProps, -- Resolved props - "prop", - getComponentName(Component) - ) - end - end - end - - -- Push context providers early to prevent context stack mismatches. - -- During mounting we don't know the child context yet as the instance doesn't exist. - -- We will invalidate the child context in finishClassComponent() right after rendering. - local hasContext - if isLegacyContextProvider(Component) then - hasContext = true - pushLegacyContextProvider(workInProgress) - else - hasContext = false - end - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - - local instance = workInProgress.stateNode - local shouldUpdate - if instance == nil then - if current ~= nil then - -- A class component without an instance only mounts if it suspended - -- inside a non-concurrent tree, in an inconsistent state. We want to - -- treat it like a new mount, even though an empty version of it already - -- committed. Disconnect the alternate pointers. - current.alternate = nil - workInProgress.alternate = nil - -- Since this is conceptually a new fiber, schedule a Placement effect - workInProgress.flags = bit32.bor(workInProgress.flags, Placement) - end - -- In the initial pass we might need to construct the instance. - constructClassInstance(workInProgress, Component, nextProps) - mountClassInstance(workInProgress, Component, nextProps, renderLanes) - shouldUpdate = true - elseif current == nil then - -- In a resume, we'll already have an instance we can reuse. - shouldUpdate = resumeMountClassInstance( - workInProgress, - Component, - nextProps, - renderLanes - ) - else - shouldUpdate = updateClassInstance( - current, - workInProgress, - Component, - nextProps, - renderLanes - ) - end - local nextUnitOfWork = finishClassComponent( - current, - workInProgress, - Component, - shouldUpdate, - hasContext, - renderLanes - ) - if _G.__DEV__ then - local inst = workInProgress.stateNode - if shouldUpdate and inst.props ~= nextProps then - if not exports.didWarnAboutReassigningProps then - console.error( - "It looks like %s is reassigning its own `this.props` while rendering. " .. - "This is not supported and can lead to confusing bugs.", - getComponentName(workInProgress.type) or "a component" - ) - end - exports.didWarnAboutReassigningProps = true - end - end - return nextUnitOfWork + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + 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 + local validateProps = Component.validateProps + if innerPropTypes or validateProps then + checkPropTypes( + innerPropTypes, + validateProps, + nextProps, -- Resolved props + "prop", + getComponentName(Component) + ) + end + end + end + + -- Push context providers early to prevent context stack mismatches. + -- During mounting we don't know the child context yet as the instance doesn't exist. + -- We will invalidate the child context in finishClassComponent() right after rendering. + local hasContext + if isLegacyContextProvider(Component) then + hasContext = true + pushLegacyContextProvider(workInProgress) + else + hasContext = false + end + -- ROBLOX deviation: pass in function to break cyclic require dependency + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + + local instance = workInProgress.stateNode + local shouldUpdate + if instance == nil then + if current ~= nil then + -- A class component without an instance only mounts if it suspended + -- inside a non-concurrent tree, in an inconsistent state. We want to + -- treat it like a new mount, even though an empty version of it already + -- committed. Disconnect the alternate pointers. + current.alternate = nil + workInProgress.alternate = nil + -- Since this is conceptually a new fiber, schedule a Placement effect + workInProgress.flags = bit32.bor(workInProgress.flags, Placement) + end + -- In the initial pass we might need to construct the instance. + constructClassInstance(workInProgress, Component, nextProps) + mountClassInstance(workInProgress, Component, nextProps, renderLanes) + shouldUpdate = true + elseif current == nil then + -- In a resume, we'll already have an instance we can reuse. + shouldUpdate = + resumeMountClassInstance(workInProgress, Component, nextProps, renderLanes) + else + shouldUpdate = updateClassInstance( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) + end + local nextUnitOfWork = finishClassComponent( + current, + workInProgress, + Component, + shouldUpdate, + hasContext, + renderLanes + ) + if __DEV__ then + local inst = workInProgress.stateNode + if shouldUpdate and inst.props ~= nextProps then + if not exports.didWarnAboutReassigningProps then + console.error( + "It looks like %s is reassigning its own `this.props` while rendering. " + .. "This is not supported and can lead to confusing bugs.", + getComponentName(workInProgress.type) or "a component" + ) + end + exports.didWarnAboutReassigningProps = true + end + end + return nextUnitOfWork end function finishClassComponent( - current: Fiber | nil, - workInProgress: Fiber, - Component: any, - shouldUpdate: boolean, - hasContext: boolean, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + Component: any, + shouldUpdate: boolean, + hasContext: boolean, + renderLanes: Lanes ) - -- Refs should update even if shouldComponentUpdate returns false - markRef(current, workInProgress) - - local didCaptureError = bit32.band(workInProgress.flags, DidCapture) ~= NoFlags - - if not shouldUpdate and not didCaptureError then - -- Context providers should defer to sCU for rendering - if hasContext then - invalidateContextProvider(workInProgress, Component, false) - end - - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - end - - local instance = workInProgress.stateNode - - -- Rerender - ReactCurrentOwner.current = workInProgress - local nextChildren - if - didCaptureError and - typeof(Component.getDerivedStateFromError) ~= "function" - then - -- If we captured an error, but getDerivedStateFromError is not defined, - -- unmount all the children. componentDidCatch will schedule an update to - -- re-render a fallback. This is temporary until we migrate everyone to - -- the new API. - -- TODO: Warn in a future release. - nextChildren = nil - - if enableProfilerTimer then - stopProfilerTimerIfRunning(workInProgress) - end - else - if _G.__DEV__ then - setIsRendering(true) - -- deviation: Call with ':' instead of '.' so that render can access self - nextChildren = instance:render() - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - -- deviation: Call with ':' instead of '.' so that render can access self - instance:render() - end) - -- finally - reenableLogs() - if not ok then - error(result) - end - end - setIsRendering(false) - else - -- deviation: Call with ':' instead of '.' so that render can access self - nextChildren = instance:render() - end - end - - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - if current ~= nil and didCaptureError then - -- If we're recovering from an error, reconcile without reusing any of - -- the existing children. Conceptually, the normal children and the children - -- that are shown on error are two different sets, so we shouldn't reuse - -- normal children even if their identities match. - forceUnmountCurrentAndReconcile( - (current :: Fiber), - workInProgress, - nextChildren, - renderLanes - ) - else - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - end - - -- Memoize state using the values we just used to render. - -- TODO: Restructure so we never read values from the instance. - workInProgress.memoizedState = instance.state - - -- The context might have changed so we need to recalculate it. - if hasContext then - invalidateContextProvider(workInProgress, Component, true) - end - - return workInProgress.child + -- Refs should update even if shouldComponentUpdate returns false + markRef(current, workInProgress) + + local didCaptureError = bit32.band(workInProgress.flags, DidCapture) ~= NoFlags + + if not shouldUpdate and not didCaptureError then + -- Context providers should defer to sCU for rendering + if hasContext then + invalidateContextProvider(workInProgress, Component, false) + end + + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + + local instance = workInProgress.stateNode + + -- Rerender + ReactCurrentOwner.current = workInProgress + local nextChildren + if + didCaptureError + and ( + Component.getDerivedStateFromError == nil + or type(Component.getDerivedStateFromError) ~= "function" + ) + then + -- If we captured an error, but getDerivedStateFromError is not defined, + -- unmount all the children. componentDidCatch will schedule an update to + -- re-render a fallback. This is temporary until we migrate everyone to + -- the new API. + -- TODO: Warn in a future release. + nextChildren = nil + + if enableProfilerTimer then + stopProfilerTimerIfRunning(workInProgress) + end + else + if __DEV__ then + setIsRendering(true) + -- deviation: Call with ':' instead of '.' so that render can access self + nextChildren = instance:render() + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + -- deviation: Pass instance so that render can access self + local ok, result = xpcall(instance.render, describeError, instance) + -- finally + reenableLogs() + if not ok then + error(result) + end + end + setIsRendering(false) + else + -- deviation: Call with ':' instead of '.' so that render can access self + nextChildren = instance:render() + end + end + + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + if current ~= nil and didCaptureError then + -- If we're recovering from an error, reconcile without reusing any of + -- the existing children. Conceptually, the normal children and the children + -- that are shown on error are two different sets, so we shouldn't reuse + -- normal children even if their identities match. + forceUnmountCurrentAndReconcile( + current :: Fiber, + workInProgress, + nextChildren, + renderLanes + ) + else + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + end + + -- Memoize state using the values we just used to render. + -- TODO: Restructure so we never read values from the instance. + workInProgress.memoizedState = instance.state + + -- The context might have changed so we need to recalculate it. + if hasContext then + invalidateContextProvider(workInProgress, Component, true) + end + + return workInProgress.child end local function pushHostRootContext(workInProgress) - -- FIXME (roblox): type refinement '(workInProgress.stateNode: FiberRoot)' - local root = workInProgress.stateNode - if root.pendingContext then - pushTopLevelContextObject( - workInProgress, - root.pendingContext, - root.pendingContext ~= root.context - ) - elseif root.context then - -- Should always be set - pushTopLevelContextObject(workInProgress, root.context, false) - end - pushHostContainer(workInProgress, root.containerInfo) + -- FIXME (roblox): type refinement '(workInProgress.stateNode: FiberRoot)' + local root = workInProgress.stateNode + if root.pendingContext then + pushTopLevelContextObject( + workInProgress, + root.pendingContext, + root.pendingContext ~= root.context + ) + elseif root.context then + -- Should always be set + pushTopLevelContextObject(workInProgress, root.context, false) + end + pushHostContainer(workInProgress, root.containerInfo) end local function updateHostRoot(current, workInProgress, renderLanes) - pushHostRootContext(workInProgress) - local updateQueue = workInProgress.updateQueue - invariant( - current ~= nil and updateQueue ~= nil, - "If the root does not have an updateQueue, we should have already " .. - "bailed out. This error is likely caused by a bug in React. Please " .. - "file an issue." - ) - local nextProps = workInProgress.pendingProps - local prevState = workInProgress.memoizedState - local prevChildren - if prevState ~= nil then - prevChildren = prevState.element - end - cloneUpdateQueue(current, workInProgress) - processUpdateQueue(workInProgress, nextProps, nil, renderLanes) - local nextState = workInProgress.memoizedState - -- Caution: React DevTools currently depends on this property - -- being called "element". - local nextChildren = nextState.element - if nextChildren == prevChildren then - resetHydrationState() - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - end - local root: FiberRoot = workInProgress.stateNode - if root.hydrate and enterHydrationState(workInProgress) then - -- If we don't have any current children this might be the first pass. - -- We always try to hydrate. If this isn't a hydration pass there won't - -- be any children to hydrate which is effectively the same thing as - -- not hydrating. - - if supportsHydration then - local mutableSourceEagerHydrationData = - root.mutableSourceEagerHydrationData - if mutableSourceEagerHydrationData ~= nil then - for i = 1, #mutableSourceEagerHydrationData, 2 do - -- FIXME (roblox): type refinement - -- local mutableSource = ((mutableSourceEagerHydrationData[ - -- i - -- ]: any): MutableSource) - local mutableSource = mutableSourceEagerHydrationData[i] - local version = mutableSourceEagerHydrationData[i + 1] - setWorkInProgressVersion(mutableSource, version) - end - end - end - - local child = mountChildFibers( - workInProgress, - nil, - nextChildren, - renderLanes - ) - workInProgress.child = child - - local node = child - while node do - -- Mark each child as hydrating. This is a fast path to know whether this - -- tree is part of a hydrating tree. This is used to determine if a child - -- node has fully mounted yet, and for scheduling event replaying. - -- Conceptually this is similar to Placement in that a new subtree is - -- inserted into the React tree here. It just happens to not need DOM - -- mutations because it already exists. - node.flags = bit32.bor(bit32.band(node.flags, bit32.bnot(Placement)), Hydrating) - node = node.sibling - end - else - -- Otherwise reset hydration state in case we aborted and resumed another - -- root. - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - resetHydrationState() - end - return workInProgress.child + pushHostRootContext(workInProgress) + local updateQueue = workInProgress.updateQueue + invariant( + current ~= nil and updateQueue ~= nil, + "If the root does not have an updateQueue, we should have already " + .. "bailed out. This error is likely caused by a bug in React. Please " + .. "file an issue." + ) + local nextProps = workInProgress.pendingProps + local prevState = workInProgress.memoizedState + local prevChildren + if prevState ~= nil then + prevChildren = prevState.element + end + cloneUpdateQueue(current, workInProgress) + processUpdateQueue(workInProgress, nextProps, nil, renderLanes) + local nextState = workInProgress.memoizedState + -- Caution: React DevTools currently depends on this property + -- being called "element". + local nextChildren = nextState.element + if nextChildren == prevChildren then + resetHydrationState() + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + local root: FiberRoot = workInProgress.stateNode + if root.hydrate and enterHydrationState(workInProgress) then + -- If we don't have any current children this might be the first pass. + -- We always try to hydrate. If this isn't a hydration pass there won't + -- be any children to hydrate which is effectively the same thing as + -- not hydrating. + + if supportsHydration then + local mutableSourceEagerHydrationData = root.mutableSourceEagerHydrationData + if mutableSourceEagerHydrationData ~= nil then + for i = 1, #mutableSourceEagerHydrationData, 2 do + -- FIXME (roblox): type refinement + -- local mutableSource = ((mutableSourceEagerHydrationData[ + -- i + -- ]: any): MutableSource) + local mutableSource = mutableSourceEagerHydrationData[i] + local version = mutableSourceEagerHydrationData[i + 1] + setWorkInProgressVersion(mutableSource, version) + end + end + end + + local child = mountChildFibers(workInProgress, nil, nextChildren, renderLanes) + workInProgress.child = child + + local node = child + while node do + -- Mark each child as hydrating. This is a fast path to know whether this + -- tree is part of a hydrating tree. This is used to determine if a child + -- node has fully mounted yet, and for scheduling event replaying. + -- Conceptually this is similar to Placement in that a new subtree is + -- inserted into the React tree here. It just happens to not need DOM + -- mutations because it already exists. + node.flags = + bit32.bor(bit32.band(node.flags, bit32.bnot(Placement)), Hydrating) + node = node.sibling + end + else + -- Otherwise reset hydration state in case we aborted and resumed another + -- root. + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + resetHydrationState() + end + return workInProgress.child end -- FIXME (roblox): type refinement @@ -1245,593 +1281,595 @@ end -- renderLanes: Lanes -- ) local function updateHostComponent( - current: any, - workInProgress: Fiber, - renderLanes: Lanes + current: any, + workInProgress: Fiber, + renderLanes: Lanes ) - pushHostContext(workInProgress) - - if current == nil then - tryToClaimNextHydratableInstance(workInProgress) - end - - local type = workInProgress.type - local nextProps = workInProgress.pendingProps - local prevProps - if current ~= nil then - prevProps = current.memoizedProps - end - - local nextChildren = nextProps.children - local isDirectTextChild = shouldSetTextContent(type, nextProps) - - if isDirectTextChild then - -- We special case a direct text child of a host node. This is a common - -- case. We won't handle it as a reified child. We will instead handle - -- this in the host environment that also has access to this prop. That - -- avoids allocating another HostText fiber and traversing it. - nextChildren = nil - elseif prevProps ~= nil and shouldSetTextContent(type, prevProps) then - -- If we're switching from a direct text child to a normal child, or to - -- empty, we need to schedule the text content to be reset. - workInProgress.flags = bit32.bor(workInProgress.flags, ContentReset) - end - - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - - markRef(current, workInProgress) - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child + pushHostContext(workInProgress) + + if current == nil then + tryToClaimNextHydratableInstance(workInProgress) + end + + local type_ = workInProgress.type + local nextProps = workInProgress.pendingProps + local prevProps + if current ~= nil then + prevProps = current.memoizedProps + end + + local nextChildren = nextProps.children + local isDirectTextChild = shouldSetTextContent(type_, nextProps) + + if isDirectTextChild then + -- We special case a direct text child of a host node. This is a common + -- case. We won't handle it as a reified child. We will instead handle + -- this in the host environment that also has access to this prop. That + -- avoids allocating another HostText fiber and traversing it. + nextChildren = nil + elseif prevProps ~= nil and shouldSetTextContent(type_, prevProps) then + -- If we're switching from a direct text child to a normal child, or to + -- empty, we need to schedule the text content to be reset. + workInProgress.flags = bit32.bor(workInProgress.flags, ContentReset) + end + + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + + markRef(current, workInProgress) + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + return workInProgress.child end local function updateHostText(current, workInProgress) - if current == nil then - tryToClaimNextHydratableInstance(workInProgress) - end - -- Nothing to do here. This is terminal. We'll do the completion step - -- immediately after. - return nil + if current == nil then + tryToClaimNextHydratableInstance(workInProgress) + end + -- Nothing to do here. This is terminal. We'll do the completion step + -- immediately after. + return nil end local function mountLazyComponent( - _current, - workInProgress, - elementType, - updateLanes, - renderLanes + _current, + workInProgress, + elementType, + updateLanes, + renderLanes ) - if _current ~= nil then - -- A lazy component only mounts if it suspended inside a non- - -- concurrent tree, in an inconsistent state. We want to treat it like - -- a new mount, even though an empty version of it already committed. - -- Disconnect the alternate pointers. - _current.alternate = nil - workInProgress.alternate = nil - -- Since this is conceptually a new fiber, schedule a Placement effect - workInProgress.flags = bit32.bor(workInProgress.flags, Placement) - end - - local props = workInProgress.pendingProps - local lazyComponent: LazyComponentType = elementType - local payload = lazyComponent._payload - local init = lazyComponent._init - local Component = init(payload) - -- Store the unwrapped component in the type. - workInProgress.type = Component - workInProgress.tag = resolveLazyComponentTag(Component) - local resolvedTag = workInProgress.tag - local resolvedProps = resolveDefaultProps(Component, props) - local child - if resolvedTag == FunctionComponent then - if _G.__DEV__ then - validateFunctionComponentInDev(workInProgress, Component) - Component = resolveFunctionForHotReloading( - Component - ) - workInProgress.type = Component - end - child = updateFunctionComponent( - nil, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - return child - elseif resolvedTag == ClassComponent then - if _G.__DEV__ then - Component = resolveClassForHotReloading( - Component - ) - workInProgress.type = Component - end - child = updateClassComponent( - nil, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - return child - elseif resolvedTag == ForwardRef then - if _G.__DEV__ then - Component = resolveForwardRefForHotReloading( - Component - ) - workInProgress.type = Component - end - child = updateForwardRef( - nil, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - return child - elseif resolvedTag == MemoComponent then - if _G.__DEV__ then - if workInProgress.type ~= workInProgress.elementType then - local outerPropTypes = Component.propTypes - if outerPropTypes then - checkPropTypes( - outerPropTypes, - resolvedProps, -- Resolved for outer only - 'prop', - getComponentName(Component) - ) - end - end - end - child = updateMemoComponent( - nil, - workInProgress, - Component, - resolveDefaultProps(Component.type, resolvedProps), -- The inner type can have defaults too - updateLanes, - renderLanes - ) - return child - -- elseif resolvedTag == Block then - -- unimplemented("Blocks API") - -- if enableBlocksAPI then - -- -- TODO: Resolve for Hot Reloading. - -- child = updateBlock( - -- nil, - -- workInProgress, - -- Component, - -- props, - -- renderLanes, - -- ) - -- return child - -- end - -- -- ROBLOX deviation: break - end - local hint = '' - if _G.__DEV__ then - if - Component ~= nil and - typeof(Component) == 'table' and - Component["$$typeof"] == REACT_LAZY_TYPE - then - hint = ' Did you wrap a component in React.lazy() more than once?' - end - end - -- This message intentionally doesn't mention ForwardRef or MemoComponent - -- because the fact that it's a separate type of work is an - -- implementation detail. - invariant( - false, - 'Element type is invalid. Received a promise that resolves to: %s. ' .. - 'Lazy element type must resolve to a class or function.%s', - tostring(Component), - hint - ) - -- ROBLOX deviation: add nil to satisfy Luau, which doesn't doesn't bubble up the unconditional error() inside invariant - return nil + if _current ~= nil then + -- A lazy component only mounts if it suspended inside a non- + -- concurrent tree, in an inconsistent state. We want to treat it like + -- a new mount, even though an empty version of it already committed. + -- Disconnect the alternate pointers. + _current.alternate = nil + workInProgress.alternate = nil + -- Since this is conceptually a new fiber, schedule a Placement effect + workInProgress.flags = bit32.bor(workInProgress.flags, Placement) + end + + local props = workInProgress.pendingProps + local lazyComponent: LazyComponentType = elementType + local payload = lazyComponent._payload + local init = lazyComponent._init + local Component = init(payload) + -- Store the unwrapped component in the type. + workInProgress.type = Component + workInProgress.tag = resolveLazyComponentTag(Component) + local resolvedTag = workInProgress.tag + local resolvedProps = resolveDefaultProps(Component, props) + local child + if resolvedTag == FunctionComponent then + if __DEV__ then + validateFunctionComponentInDev(workInProgress, Component) + Component = resolveFunctionForHotReloading(Component) + workInProgress.type = Component + end + child = updateFunctionComponent( + nil, + workInProgress, + Component, + resolvedProps, + renderLanes + ) + return child + elseif resolvedTag == ClassComponent then + if __DEV__ then + Component = resolveClassForHotReloading(Component) + workInProgress.type = Component + end + child = updateClassComponent( + nil, + workInProgress, + Component, + resolvedProps, + renderLanes + ) + return child + elseif resolvedTag == ForwardRef then + if __DEV__ then + Component = resolveForwardRefForHotReloading(Component) + workInProgress.type = Component + end + child = + updateForwardRef(nil, workInProgress, Component, resolvedProps, renderLanes) + return child + elseif resolvedTag == MemoComponent then + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + if workInProgress.type ~= workInProgress.elementType then + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local outerPropTypes = Component.propTypes + local validateProps = Component.validateProps + if outerPropTypes or validateProps then + checkPropTypes( + outerPropTypes, + validateProps, + resolvedProps, -- Resolved for outer only + "prop", + getComponentName(Component) + ) + end + end + end + child = updateMemoComponent( + nil, + workInProgress, + Component, + resolveDefaultProps(Component.type, resolvedProps), -- The inner type can have defaults too + updateLanes, + renderLanes + ) + return child + -- elseif resolvedTag == Block then + -- unimplemented("Blocks API") + -- if enableBlocksAPI then + -- -- TODO: Resolve for Hot Reloading. + -- child = updateBlock( + -- nil, + -- workInProgress, + -- Component, + -- props, + -- renderLanes, + -- ) + -- return child + -- end + -- -- ROBLOX deviation: break + end + local hint = "" + if __DEV__ then + if + Component ~= nil + and type(Component) == "table" + and Component["$$typeof"] == REACT_LAZY_TYPE + then + hint = " Did you wrap a component in React.lazy() more than once?" + elseif type(Component) == "table" and Component["$$typeof"] == nil then + hint = "\n" .. inspect(Component) + end + end + -- This message intentionally doesn't mention ForwardRef or MemoComponent + -- because the fact that it's a separate type of work is an + -- implementation detail. + invariant( + false, + "Element type is invalid. Received a promise that resolves to: %s. " + .. "Lazy element type must resolve to a class or function.%s", + tostring(Component), + hint + ) + -- ROBLOX deviation: add nil to satisfy Luau, which doesn't doesn't bubble up the unconditional error() inside invariant + return nil end function mountIncompleteClassComponent( - _current, - workInProgress, - Component, - nextProps, - renderLanes + _current, + workInProgress, + Component, + nextProps, + renderLanes ) - if _current ~= nil then - -- An incomplete component only mounts if it suspended inside a non- - -- concurrent tree, in an inconsistent state. We want to treat it like - -- a new mount, even though an empty version of it already committed. - -- Disconnect the alternate pointers. - _current.alternate = nil - workInProgress.alternate = nil - -- Since this is conceptually a new fiber, schedule a Placement effect - workInProgress.flags = bit32.bor(workInProgress.flags, Placement) - end - - -- Promote the fiber to a class and try rendering again. - workInProgress.tag = ClassComponent - - -- The rest of this function is a fork of `updateClassComponent` - - -- Push context providers early to prevent context stack mismatches. - -- During mounting we don't know the child context yet as the instance doesn't exist. - -- We will invalidate the child context in finishClassComponent() right after rendering. - local hasContext - if isLegacyContextProvider(Component) then - hasContext = true - pushLegacyContextProvider(workInProgress) - else - hasContext = false - end - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - - constructClassInstance(workInProgress, Component, nextProps) - mountClassInstance(workInProgress, Component, nextProps, renderLanes) - - return finishClassComponent( - nil, - workInProgress, - Component, - true, - hasContext, - renderLanes - ) + if _current ~= nil then + -- An incomplete component only mounts if it suspended inside a non- + -- concurrent tree, in an inconsistent state. We want to treat it like + -- a new mount, even though an empty version of it already committed. + -- Disconnect the alternate pointers. + _current.alternate = nil + workInProgress.alternate = nil + -- Since this is conceptually a new fiber, schedule a Placement effect + workInProgress.flags = bit32.bor(workInProgress.flags, Placement) + end + + -- Promote the fiber to a class and try rendering again. + workInProgress.tag = ClassComponent + + -- The rest of this function is a fork of `updateClassComponent` + + -- Push context providers early to prevent context stack mismatches. + -- During mounting we don't know the child context yet as the instance doesn't exist. + -- We will invalidate the child context in finishClassComponent() right after rendering. + local hasContext + if isLegacyContextProvider(Component) then + hasContext = true + pushLegacyContextProvider(workInProgress) + else + hasContext = false + end + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + + constructClassInstance(workInProgress, Component, nextProps) + mountClassInstance(workInProgress, Component, nextProps, renderLanes) + + return finishClassComponent( + nil, + workInProgress, + Component, + true, + hasContext, + renderLanes + ) end local function mountIndeterminateComponent( - current, - workInProgress, - Component, - renderLanes + current, + workInProgress, + Component, + renderLanes ) - if current ~= nil then - -- An indeterminate component only mounts if it suspended inside a non- - -- concurrent tree, in an inconsistent state. We want to treat it like - -- a new mount, even though an empty version of it already committed. - -- Disconnect the alternate pointers. - current.alternate = nil - workInProgress.alternate = nil - -- Since this is conceptually a new fiber, schedule a Placement effect - workInProgress.flags = bit32.bor(workInProgress.flags, Placement) - end - - local props = workInProgress.pendingProps - local context - if not disableLegacyContext then - local unmaskedContext = getUnmaskedContext( - workInProgress, - Component, - false - ) - context = getMaskedContext(workInProgress, unmaskedContext) - end - - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - local value - - if _G.__DEV__ then - if - -- deviation: Instead of checking for the prototype, see if Component is a - -- table with a render function - typeof(Component) == "table" and - typeof(Component.render) == "function" - then - local componentName = getComponentName(Component) or "Unknown" - - if not DidWarn.didWarnAboutBadClass[componentName] then - console.error( - "The <%s /> component appears to have a render method, but doesn't extend React.Component. " .. - "This is likely to cause errors. Change %s to extend React.Component instead.", - componentName, - componentName - ) - DidWarn.didWarnAboutBadClass[componentName] = true - end - end - - if bit32.band(workInProgress.mode, StrictMode) ~= 0 then - ReactStrictModeWarnings.recordLegacyContextWarning(workInProgress, nil) - end - - setIsRendering(true) - ReactCurrentOwner.current = workInProgress - value = renderWithHooks( - nil, - workInProgress, - Component, - props, - context, - renderLanes - ) - setIsRendering(false) - else - value = renderWithHooks( - nil, - workInProgress, - Component, - props, - context, - renderLanes - ) - end - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - - if _G.__DEV__ then - -- Support for module components is deprecated and is removed behind a flag. - -- Whether or not it would crash later, we want to show a good message in DEV first. - if - typeof(value) == "table" and - value ~= nil and - typeof(value.render) == "function" and - value["$$typeof"] == nil - then - local componentName = getComponentName(Component) or "Unknown" - if not DidWarn.didWarnAboutModulePatternComponent[componentName] then - console.error( - "The <%s /> component appears to be a function component that returns a class instance. " .. - "Change %s to a class that extends React.Component instead. " .. - "If you can't use a class try assigning the prototype on the function as a workaround. " .. - "`%s.prototype = React.Component.prototype`. Don't use an arrow function since it " .. - "cannot be called with `new` by React.", - componentName, - componentName, - componentName - ) - DidWarn.didWarnAboutModulePatternComponent[componentName] = true - end - end - end - - if - -- Run these checks in production only if the flag is off. - -- Eventually we'll delete this branch altogether. - not disableModulePatternComponents and - typeof(value) == "table" and - typeof(value.render) == "function" and - value["$$typeof"] == nil - then - if _G.__DEV__ then - local componentName = getComponentName(Component) or "Unknown" - if not DidWarn.didWarnAboutModulePatternComponent[componentName] then - console.error( - "The <%s /> component appears to be a function component that returns a class instance. " .. - "Change %s to a class that extends React.Component instead. " .. - "If you can't use a class try assigning the prototype on the function as a workaround. " .. - "`%s.prototype = React.Component.prototype`. Don't use an arrow function since it " .. - "cannot be called with `new` by React.", - componentName, - componentName, - componentName - ) - DidWarn.didWarnAboutModulePatternComponent[componentName] = true - end - end - - -- Proceed under the assumption that this is a class instance - workInProgress.tag = ClassComponent - - -- Throw out any hooks that were used. - workInProgress.memoizedState = nil - workInProgress.updateQueue = nil - - -- Push context providers early to prevent context stack mismatches. - -- During mounting we don't know the child context yet as the instance doesn't exist. - -- We will invalidate the child context in finishClassComponent() right after rendering. - local hasContext = false - if isLegacyContextProvider(Component) then - hasContext = true - pushLegacyContextProvider(workInProgress) - else - hasContext = false - end - - -- deviation: Lua doesn't need to coerce `T | null | undefined` to `T | null` - workInProgress.memoizedState = value.state - - initializeUpdateQueue(workInProgress) - - -- ROBLOX deviation: don't access field on function - local getDerivedStateFromProps - if typeof(Component) ~= "function" then - getDerivedStateFromProps = Component.getDerivedStateFromProps - end - if typeof(getDerivedStateFromProps) == "function" then - applyDerivedStateFromProps( - workInProgress, - Component, - getDerivedStateFromProps, - props - ) - end - - adoptClassInstance(workInProgress, value) - mountClassInstance(workInProgress, Component, props, renderLanes) - return finishClassComponent( - nil, - workInProgress, - Component, - true, - hasContext, - renderLanes - ) - else - -- Proceed under the assumption that this is a function component - workInProgress.tag = FunctionComponent - if _G.__DEV__ then - if disableLegacyContext and Component.contextTypes then - console.error( - "%s uses the legacy contextTypes API which is no longer supported. " .. - "Use React.createContext() with React.useContext() instead.", - getComponentName(Component) or "Unknown" - ) - end - - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - value = renderWithHooks( - nil, - workInProgress, - Component, - props, - context, - renderLanes - ) - end) - -- finally - reenableLogs() - if not ok then - error(result) - end - end - end - reconcileChildren(nil, workInProgress, value, renderLanes) - if _G.__DEV__ then - validateFunctionComponentInDev(workInProgress, Component) - end - return workInProgress.child - end + if current ~= nil then + -- An indeterminate component only mounts if it suspended inside a non- + -- concurrent tree, in an inconsistent state. We want to treat it like + -- a new mount, even though an empty version of it already committed. + -- Disconnect the alternate pointers. + current.alternate = nil + workInProgress.alternate = nil + -- Since this is conceptually a new fiber, schedule a Placement effect + workInProgress.flags = bit32.bor(workInProgress.flags, Placement) + end + + local props = workInProgress.pendingProps + local context + if not disableLegacyContext then + local unmaskedContext = getUnmaskedContext(workInProgress, Component, false) + context = getMaskedContext(workInProgress, unmaskedContext) + end + + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + local value + + if __DEV__ then + if + -- deviation: Instead of checking for the prototype, see if Component is a + -- table with a render function + type(Component) == "table" and type(Component.render) == "function" + then + local componentName = getComponentName(Component) or "Unknown" + + if not DidWarn.didWarnAboutBadClass[componentName] then + console.error( + "The <%s /> component appears to have a render method, but doesn't extend React.Component. " + .. "This is likely to cause errors. Change %s to extend React.Component instead.", + componentName, + componentName + ) + DidWarn.didWarnAboutBadClass[componentName] = true + end + end + + if bit32.band(workInProgress.mode, StrictMode) ~= 0 then + ReactStrictModeWarnings.recordLegacyContextWarning(workInProgress) + end + + setIsRendering(true) + ReactCurrentOwner.current = workInProgress + value = + renderWithHooks(nil, workInProgress, Component, props, context, renderLanes) + setIsRendering(false) + else + value = + renderWithHooks(nil, workInProgress, Component, props, context, renderLanes) + end + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + -- ROBLOX deviation START: cache type(value) + local typeofValue = type(value) + -- ROBLOX deviation END + + if __DEV__ then + -- Support for module components is deprecated and is removed behind a flag. + -- Whether or not it would crash later, we want to show a good message in DEV first. + if + value ~= nil + and typeofValue == "table" + and type(value.render) == "function" + and value["$$typeof"] == nil + then + local componentName = getComponentName(Component) or "Unknown" + if not DidWarn.didWarnAboutModulePatternComponent[componentName] then + console.error( + "The <%s /> component appears to be a function component that returns a class instance. " + .. "Change %s to a class that extends React.Component instead. ", + -- ROBLOX deviation: Don't print JS-specific remediation advice + -- "If you can't use a class try assigning the prototype on the function as a workaround. " .. + -- "`%s.prototype = React.Component.prototype`. Don't use an arrow function since it " .. + -- "cannot be called with `new` by React.", + componentName, + componentName + -- componentName + ) + DidWarn.didWarnAboutModulePatternComponent[componentName] = true + end + end + end + + if + -- Run these checks in production only if the flag is off. + -- Eventually we'll delete this branch altogether. + not disableModulePatternComponents + and value ~= nil + and typeofValue == "table" + and type(value.render) == "function" + and value["$$typeof"] == nil + then + if __DEV__ then + local componentName = getComponentName(Component) or "Unknown" + if not DidWarn.didWarnAboutModulePatternComponent[componentName] then + console.error( + "The <%s /> component appears to be a function component that returns a class instance. " + .. "Change %s to a class that extends React.Component instead. " + -- ROBLOX deviation: Don't print JS-specific remediation advice + -- "If you can't use a class try assigning the prototype on the function as a workaround. " .. + -- "`%s.prototype = React.Component.prototype`. Don't use an arrow function since it " .. + -- "cannot be called with `new` by React.", + .. componentName, + componentName + -- componentName + ) + DidWarn.didWarnAboutModulePatternComponent[componentName] = true + end + end + + -- Proceed under the assumption that this is a class instance + workInProgress.tag = ClassComponent + + -- Throw out any hooks that were used. + workInProgress.memoizedState = nil + workInProgress.updateQueue = nil + + -- Push context providers early to prevent context stack mismatches. + -- During mounting we don't know the child context yet as the instance doesn't exist. + -- We will invalidate the child context in finishClassComponent() right after rendering. + local hasContext = false + if isLegacyContextProvider(Component) then + hasContext = true + pushLegacyContextProvider(workInProgress) + else + hasContext = false + end + + -- deviation: Lua doesn't need to coerce `T | null | undefined` to `T | null` + workInProgress.memoizedState = value.state + + initializeUpdateQueue(workInProgress) + + -- ROBLOX deviation: don't access field on function + local getDerivedStateFromProps + if type(Component) ~= "function" then + getDerivedStateFromProps = (Component :: React_Component).getDerivedStateFromProps + end + if + getDerivedStateFromProps ~= nil + and type(getDerivedStateFromProps) == "function" + then + applyDerivedStateFromProps( + workInProgress, + Component, + getDerivedStateFromProps, + props + ) + end + + adoptClassInstance(workInProgress, value) + mountClassInstance(workInProgress, Component, props, renderLanes) + return finishClassComponent( + nil, + workInProgress, + Component, + true, + hasContext, + renderLanes + ) + else + -- Proceed under the assumption that this is a function component + workInProgress.tag = FunctionComponent + if __DEV__ then + if disableLegacyContext and Component.contextTypes then + console.error( + "%s uses the legacy contextTypes API which is no longer supported. " + .. "Use React.createContext() with React.useContext() instead.", + getComponentName(Component) or "Unknown" + ) + end + + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + local ok, result = xpcall( + renderWithHooks, + describeError, + nil, + workInProgress, + Component, + props, + context, + renderLanes + ) + -- finally + reenableLogs() + if ok then + value = result + else + error(result) + end + end + end + reconcileChildren(nil, workInProgress, value, renderLanes) + if __DEV__ then + validateFunctionComponentInDev(workInProgress, Component) + end + return workInProgress.child + end end function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) - if _G.__DEV__ then - -- deviation: Lua doesn't allow fields on functions, so this never happens - -- if Component then - -- if Component.childContextTypes then - -- console.error( - -- '%s(...): childContextTypes cannot be defined on a function component.', - -- Component.displayName or Component.name or 'Component' - -- ) - -- end - -- end - if workInProgress.ref ~= nil then - local info = '' - local ownerName = getCurrentFiberOwnerNameInDevOrNull() - if ownerName then - info ..= '\n\nCheck the render method of `' .. ownerName .. '`.' - end - - local warningKey = ownerName or workInProgress._debugID or '' - local debugSource = workInProgress._debugSource - if debugSource then - warningKey = debugSource.fileName .. ':' .. debugSource.lineNumber - end - if not DidWarn.didWarnAboutFunctionRefs[warningKey] then - DidWarn.didWarnAboutFunctionRefs[warningKey] = true - console.error( - 'Function components cannot be given refs. ' .. - 'Attempts to access this ref will fail. ' .. - 'Did you mean to use React.forwardRef()?%s', - info - ) - end - end - - if - warnAboutDefaultPropsOnFunctionComponents and - -- ROBLOX deviation: functions can't have fields in Lua - typeof(Component) ~= 'function' and - Component.defaultProps ~= nil - then - local componentName = getComponentName(Component) or 'Unknown' - - if not DidWarn.didWarnAboutDefaultPropsOnFunctionComponent[componentName] then - console.error( - '%s: Support for defaultProps will be removed from function components ' .. - 'in a future major release. Use JavaScript default parameters instead.', - componentName - ) - DidWarn.didWarnAboutDefaultPropsOnFunctionComponent[componentName] = true - end - end - - -- ROBLOX deviation: Lua functions can't have fields - if typeof(Component) ~= 'function' and typeof(Component.getDerivedStateFromProps) == 'function' then - local componentName = getComponentName(Component) or 'Unknown' - - if not DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent[componentName] then - console.error( - '%s: Function components do not support getDerivedStateFromProps.', - componentName - ) - DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent[componentName] = true - end - end - - -- ROBLOX deviation: Lua functions can't have fields - if typeof(Component) ~= 'function' and - typeof(Component.contextType) == 'table' and - Component.contextType ~= nil - then - local componentName = getComponentName(Component) or 'Unknown' - - if not DidWarn.didWarnAboutContextTypeOnFunctionComponent[componentName] then - console.error( - '%s: Function components do not support contextType.', - componentName - ) - DidWarn.didWarnAboutContextTypeOnFunctionComponent[componentName] = true - end - end - end + if __DEV__ then + -- ROBLOX deviation: Lua doesn't allow fields on functions, so this never happens + -- if Component then + -- if Component.childContextTypes then + -- console.error( + -- '%s(...): childContextTypes cannot be defined on a function component.', + -- Component.displayName or Component.name or 'Component' + -- ) + -- end + -- end + if workInProgress.ref ~= nil then + local info = "" + local ownerName = getCurrentFiberOwnerNameInDevOrNull() + if ownerName then + info ..= "\n\nCheck the render method of `" .. ownerName .. "`." + end + + local warningKey = ownerName or workInProgress._debugID or "" + local debugSource = workInProgress._debugSource + if debugSource then + warningKey = debugSource.fileName .. ":" .. debugSource.lineNumber + end + if not DidWarn.didWarnAboutFunctionRefs[warningKey] then + DidWarn.didWarnAboutFunctionRefs[warningKey] = true + console.error( + "Function components cannot be given refs. " + .. "Attempts to access this ref will fail. " + .. "Did you mean to use React.forwardRef()?%s", + info + ) + end + end + + if + warnAboutDefaultPropsOnFunctionComponents + -- ROBLOX deviation: functions can't have fields in Lua + and type(Component) ~= "function" + and Component.defaultProps ~= nil + then + local componentName = getComponentName(Component) or "Unknown" + + if not DidWarn.didWarnAboutDefaultPropsOnFunctionComponent[componentName] then + console.error( + "%s: Support for defaultProps will be removed from function components " + -- ROBLOX deviation: Don't print JS-specific remediation advice + .. "in a future major release.", -- Use JavaScript default parameters instead.', + componentName + ) + DidWarn.didWarnAboutDefaultPropsOnFunctionComponent[componentName] = true + end + end + + -- ROBLOX deviation: Lua functions can't have fields + if + type(Component) ~= "function" + and Component.getDerivedStateFromProps ~= nil + and type(Component.getDerivedStateFromProps) == "function" + then + local componentName = getComponentName(Component) or "Unknown" + + if + not DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent[componentName] + then + console.error( + "%s: Function components do not support getDerivedStateFromProps.", + componentName + ) + DidWarn.didWarnAboutGetDerivedStateOnFunctionComponent[componentName] = + true + end + end + + -- ROBLOX deviation: Lua functions can't have fields + if + type(Component) ~= "function" + and Component.contextType ~= nil + and type(Component.contextType) == "table" + then + local componentName = getComponentName(Component) or "Unknown" + + if not DidWarn.didWarnAboutContextTypeOnFunctionComponent[componentName] then + console.error( + "%s: Function components do not support contextType.", + componentName + ) + DidWarn.didWarnAboutContextTypeOnFunctionComponent[componentName] = true + end + end + end end local SUSPENDED_MARKER: SuspenseState = { - dehydrated = nil, - retryLane = ReactFiberLane.NoLane + dehydrated = nil, + retryLane = ReactFiberLane.NoLane, } local function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState - return { - baseLanes = renderLanes - } + return { + baseLanes = renderLanes, + } end local function updateSuspenseOffscreenState( - prevOffscreenState: OffscreenState, - renderLanes: Lanes + prevOffscreenState: OffscreenState, + renderLanes: Lanes ): OffscreenState - return { - baseLanes = ReactFiberLane.mergeLanes(prevOffscreenState.baseLanes, renderLanes), - } + return { + baseLanes = ReactFiberLane.mergeLanes(prevOffscreenState.baseLanes, renderLanes), + } end -- TODO: Probably should inline this back local function shouldRemainOnFallback( - suspenseContext: SuspenseContext, - current: nil | Fiber, - workInProgress: Fiber, - renderLanes: Lanes + suspenseContext: SuspenseContext, + current: nil | Fiber, + workInProgress: Fiber, + renderLanes: Lanes ) - -- If we're already showing a fallback, there are cases where we need to - -- remain on that fallback regardless of whether the content has resolved. - -- For example, SuspenseList coordinates when nested content appears. - if current ~= nil then - local suspenseState: SuspenseState = (current :: Fiber).memoizedState - if suspenseState == nil then - -- Currently showing content. Don't hide it, even if ForceSuspenseFallack - -- is true. More precise name might be "ForceRemainSuspenseFallback". - -- Note: This is a factoring smell. Can't remain on a fallback if there's - -- no fallback to remain on. - return false - end - end - - -- Not currently showing content. Consult the Suspense context. - return hasSuspenseContext( - suspenseContext, - ForceSuspenseFallback - ) + -- If we're already showing a fallback, there are cases where we need to + -- remain on that fallback regardless of whether the content has resolved. + -- For example, SuspenseList coordinates when nested content appears. + if current ~= nil then + local suspenseState: SuspenseState = (current :: Fiber).memoizedState + if suspenseState == nil then + -- Currently showing content. Don't hide it, even if ForceSuspenseFallack + -- is true. More precise name might be "ForceRemainSuspenseFallback". + -- Note: This is a factoring smell. Can't remain on a fallback if there's + -- no fallback to remain on. + return false + end + end + + -- Not currently showing content. Consult the Suspense context. + return hasSuspenseContext(suspenseContext, ForceSuspenseFallback) end local function getRemainingWorkInPrimaryTree(current: Fiber, renderLanes) - -- TODO: Should not remove render lanes that were pinged during this render - return ReactFiberLane.removeLanes(current.childLanes, renderLanes) + -- TODO: Should not remove render lanes that were pinged during this render + return ReactFiberLane.removeLanes(current.childLanes, renderLanes) end -- ROBLOX deviation: predeclare these methods to resolve method declaration ordering @@ -1842,756 +1880,706 @@ local updateSuspenseFallbackChildren local updateDehydratedSuspenseComponent local function updateSuspenseComponent(current, workInProgress, renderLanes) - local nextProps = workInProgress.pendingProps - - -- This is used by DevTools to force a boundary to suspend. - if _G.__DEV__ then - if shouldSuspend(workInProgress) then - workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) - end - end - - local suspenseContext: SuspenseContext = suspenseStackCursor.current - - local showFallback = false - local didSuspend = bit32.band(workInProgress.flags, DidCapture) ~= NoFlags - - if - didSuspend or - shouldRemainOnFallback( - suspenseContext, - current, - workInProgress, - renderLanes - ) - then - -- Something in this boundary's subtree already suspended. Switch to - -- rendering the fallback children. - showFallback = true - workInProgress.flags = bit32.band(workInProgress.flags, bit32.bnot(DidCapture)) - else - -- Attempting the main content - if - current == nil or - current.memoizedState ~= nil - then - -- This is a new mount or this boundary is already showing a fallback state. - -- Mark this subtree context as having at least one invisible parent that could - -- handle the fallback state. - -- Boundaries without fallbacks or should be avoided are not considered since - -- they cannot handle preferred fallback states. - if - nextProps.fallback ~= nil and - nextProps.unstable_avoidThisFallback ~= true - then - suspenseContext = addSubtreeSuspenseContext( - suspenseContext, - InvisibleParentSuspenseContext - ) - end - end - end - - suspenseContext = setDefaultShallowSuspenseContext(suspenseContext) - - pushSuspenseContext(workInProgress, suspenseContext) - - -- OK, the next part is confusing. We're about to reconcile the Suspense - -- boundary's children. This involves some custom reconcilation logic. Two - -- main reasons this is so complicated. - -- - -- First, Legacy Mode has different semantics for backwards compatibility. The - -- primary tree will commit in an inconsistent state, so when we do the - -- second pass to render the fallback, we do some exceedingly, uh, clever - -- hacks to make that not totally break. Like transferring effects and - -- deletions from hidden tree. In Concurrent Mode, it's much simpler, - -- because we bailout on the primary tree completely and leave it in its old - -- state, no effects. Same as what we do for Offscreen (except that - -- Offscreen doesn't have the first render pass). - -- - -- Second is hydration. During hydration, the Suspense fiber has a slightly - -- different layout, where the child points to a dehydrated fragment, which - -- contains the DOM rendered by the server. - -- - -- Third, even if you set all that aside, Suspense is like error boundaries in - -- that we first we try to render one tree, and if that fails, we render again - -- and switch to a different tree. Like a try/catch block. So we have to track - -- which branch we're currently rendering. Ideally we would model this using - -- a stack. - if current == nil then - -- Initial mount - -- If we're currently hydrating, try to hydrate this boundary. - -- But only if this has a fallback. - if nextProps.fallback ~= nil then - tryToClaimNextHydratableInstance(workInProgress) - -- This could've been a dehydrated suspense component. - if enableSuspenseServerRenderer then - local suspenseState: nil | SuspenseState = - workInProgress.memoizedState - if suspenseState ~= nil then - local dehydrated = (suspenseState :: SuspenseState).dehydrated - if dehydrated ~= nil then - return mountDehydratedSuspenseComponent( - workInProgress, - dehydrated, - renderLanes - ) - end - end - end - end - - local nextPrimaryChildren = nextProps.children - local nextFallbackChildren = nextProps.fallback - if showFallback then - local fallbackFragment = mountSuspenseFallbackChildren( - workInProgress, - nextPrimaryChildren, - nextFallbackChildren, - renderLanes - ) - local primaryChildFragment: Fiber = workInProgress.child - primaryChildFragment.memoizedState = mountSuspenseOffscreenState( - renderLanes - ) - workInProgress.memoizedState = SUSPENDED_MARKER - return fallbackFragment - elseif typeof(nextProps.unstable_expectedLoadTime) == 'number' then - -- This is a CPU-bound tree. Skip this tree and show a placeholder to - -- unblock the surrounding content. Then immediately retry after the - -- initial commit. - local fallbackFragment = mountSuspenseFallbackChildren( - workInProgress, - nextPrimaryChildren, - nextFallbackChildren, - renderLanes - ) - local primaryChildFragment: Fiber = workInProgress.child - primaryChildFragment.memoizedState = mountSuspenseOffscreenState( - renderLanes - ) - workInProgress.memoizedState = SUSPENDED_MARKER - - -- Since nothing actually suspended, there will nothing to ping this to - -- get it started back up to attempt the next item. While in terms of - -- priority this work has the same priority as this current render, it's - -- not part of the same transition once the transition has committed. If - -- it's sync, we still want to yield so that it can be painted. - -- Conceptually, this is really the same as pinging. We can use any - -- RetryLane even if it's the one currently rendering since we're leaving - -- it behind on this node. - workInProgress.lanes = ReactFiberLane.SomeRetryLane - if enableSchedulerTracing then - markSpawnedWork(ReactFiberLane.SomeRetryLane) - end - return fallbackFragment - else - return mountSuspensePrimaryChildren( - workInProgress, - nextPrimaryChildren, - renderLanes - ) - end - else - -- This is an update. - - -- If the current fiber has a SuspenseState, that means it's already showing - -- a fallback. - local prevState: nil | SuspenseState = current.memoizedState - if prevState ~= nil then - -- The current tree is already showing a fallback - - -- Special path for hydration - if enableSuspenseServerRenderer then - -- ROBLOX FIXME: remove :: when Luau understands ~= nil - local dehydrated = (prevState :: SuspenseState).dehydrated - - if dehydrated ~= nil then - if not didSuspend then - return updateDehydratedSuspenseComponent( - current, - workInProgress, - dehydrated, - prevState, - renderLanes - ) - elseif - workInProgress.memoizedState ~= nil - then - -- Something suspended and we should still be in dehydrated mode. - -- Leave the existing child in place. - workInProgress.child = current.child - -- The dehydrated completion pass expects this flag to be there - -- but the normal suspense pass doesn't. - workInProgress.flags = bit32.bor(workInProgress, DidCapture) - return nil - else - -- Suspended but we should no longer be in dehydrated mode. - -- Therefore we now have to render the fallback. - local nextPrimaryChildren = nextProps.children - local nextFallbackChildren = nextProps.fallback - local fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating( - current, - workInProgress, - nextPrimaryChildren, - nextFallbackChildren, - renderLanes - ) - local primaryChildFragment: Fiber = workInProgress.child - primaryChildFragment.memoizedState = mountSuspenseOffscreenState( - renderLanes - ) - workInProgress.memoizedState = SUSPENDED_MARKER - return fallbackChildFragment - end - end - end - - if showFallback then - local nextFallbackChildren = nextProps.fallback - local nextPrimaryChildren = nextProps.children - local fallbackChildFragment = updateSuspenseFallbackChildren( - current, - workInProgress, - nextPrimaryChildren, - nextFallbackChildren, - renderLanes - ) - local primaryChildFragment: Fiber = workInProgress.child - local prevOffscreenState: OffscreenState | nil = current.child - .memoizedState - - -- ROBLOX deviation: if/else in place of ternary - if prevOffscreenState == nil then - primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes) - else - -- ROBLOX FIXME: remove :: when Luau understands ~= nil - primaryChildFragment.memoizedState = updateSuspenseOffscreenState((prevOffscreenState :: OffscreenState), renderLanes) - end - - primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree( - current, - renderLanes - ) - workInProgress.memoizedState = SUSPENDED_MARKER - return fallbackChildFragment - else - local nextPrimaryChildren = nextProps.children - local primaryChildFragment = updateSuspensePrimaryChildren( - current, - workInProgress, - nextPrimaryChildren, - renderLanes - ) - workInProgress.memoizedState = nil - return primaryChildFragment - end - else - -- The current tree is not already showing a fallback. - if showFallback then - -- Timed out. - local nextFallbackChildren = nextProps.fallback - local nextPrimaryChildren = nextProps.children - local fallbackChildFragment = updateSuspenseFallbackChildren( - current, - workInProgress, - nextPrimaryChildren, - nextFallbackChildren, - renderLanes - ) - local primaryChildFragment: Fiber = workInProgress.child - local prevOffscreenState: OffscreenState | nil = current.child - .memoizedState - - -- ROBLOX deviation: if/else in place of ternary - if prevOffscreenState == nil then - primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes) - else - -- ROBLOX FIXME: remove :: once Luau understands nil check - primaryChildFragment.memoizedState = updateSuspenseOffscreenState((prevOffscreenState :: OffscreenState), renderLanes) - end - - primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree( - current, - renderLanes - ) - -- Skip the primary children, and continue working on the - -- fallback children. - workInProgress.memoizedState = SUSPENDED_MARKER - return fallbackChildFragment - else - -- Still haven't timed out. Continue rendering the children, like we - -- normally do. - local nextPrimaryChildren = nextProps.children - local primaryChildFragment = updateSuspensePrimaryChildren( - current, - workInProgress, - nextPrimaryChildren, - renderLanes - ) - workInProgress.memoizedState = nil - return primaryChildFragment - end - end - end + local nextProps = workInProgress.pendingProps + + -- This is used by DevTools to force a boundary to suspend. + if __DEV__ then + if shouldSuspend(workInProgress) then + workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) + end + end + + local suspenseContext: SuspenseContext = suspenseStackCursor.current + + local showFallback = false + local didSuspend = bit32.band(workInProgress.flags, DidCapture) ~= NoFlags + + if + didSuspend + or shouldRemainOnFallback(suspenseContext, current, workInProgress, renderLanes) + then + -- Something in this boundary's subtree already suspended. Switch to + -- rendering the fallback children. + showFallback = true + workInProgress.flags = bit32.band(workInProgress.flags, bit32.bnot(DidCapture)) + else + -- Attempting the main content + if current == nil or current.memoizedState ~= nil then + -- This is a new mount or this boundary is already showing a fallback state. + -- Mark this subtree context as having at least one invisible parent that could + -- handle the fallback state. + -- Boundaries without fallbacks or should be avoided are not considered since + -- they cannot handle preferred fallback states. + if + nextProps.fallback ~= nil + and nextProps.unstable_avoidThisFallback ~= true + then + suspenseContext = addSubtreeSuspenseContext( + suspenseContext, + InvisibleParentSuspenseContext + ) + end + end + end + + suspenseContext = setDefaultShallowSuspenseContext(suspenseContext) + + pushSuspenseContext(workInProgress, suspenseContext) + + -- OK, the next part is confusing. We're about to reconcile the Suspense + -- boundary's children. This involves some custom reconcilation logic. Two + -- main reasons this is so complicated. + -- + -- First, Legacy Mode has different semantics for backwards compatibility. The + -- primary tree will commit in an inconsistent state, so when we do the + -- second pass to render the fallback, we do some exceedingly, uh, clever + -- hacks to make that not totally break. Like transferring effects and + -- deletions from hidden tree. In Concurrent Mode, it's much simpler, + -- because we bailout on the primary tree completely and leave it in its old + -- state, no effects. Same as what we do for Offscreen (except that + -- Offscreen doesn't have the first render pass). + -- + -- Second is hydration. During hydration, the Suspense fiber has a slightly + -- different layout, where the child points to a dehydrated fragment, which + -- contains the DOM rendered by the server. + -- + -- Third, even if you set all that aside, Suspense is like error boundaries in + -- that we first we try to render one tree, and if that fails, we render again + -- and switch to a different tree. Like a try/catch block. So we have to track + -- which branch we're currently rendering. Ideally we would model this using + -- a stack. + if current == nil then + -- Initial mount + -- If we're currently hydrating, try to hydrate this boundary. + -- But only if this has a fallback. + if nextProps.fallback ~= nil then + tryToClaimNextHydratableInstance(workInProgress) + -- This could've been a dehydrated suspense component. + if enableSuspenseServerRenderer then + local suspenseState: nil | SuspenseState = workInProgress.memoizedState + if suspenseState ~= nil then + local dehydrated = (suspenseState :: SuspenseState).dehydrated + if dehydrated ~= nil then + return mountDehydratedSuspenseComponent( + workInProgress, + dehydrated, + renderLanes + ) + end + end + end + end + + local nextPrimaryChildren = nextProps.children + local nextFallbackChildren = nextProps.fallback + if showFallback then + local fallbackFragment = mountSuspenseFallbackChildren( + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes + ) + local primaryChildFragment: Fiber = workInProgress.child :: any + primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes) + workInProgress.memoizedState = SUSPENDED_MARKER + return fallbackFragment + elseif + nextProps.unstable_expectedLoadTime ~= nil + and type(nextProps.unstable_expectedLoadTime) == "number" + then + -- This is a CPU-bound tree. Skip this tree and show a placeholder to + -- unblock the surrounding content. Then immediately retry after the + -- initial commit. + local fallbackFragment = mountSuspenseFallbackChildren( + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes + ) + local primaryChildFragment: Fiber = workInProgress.child :: any + primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes) + workInProgress.memoizedState = SUSPENDED_MARKER + + -- Since nothing actually suspended, there will nothing to ping this to + -- get it started back up to attempt the next item. While in terms of + -- priority this work has the same priority as this current render, it's + -- not part of the same transition once the transition has committed. If + -- it's sync, we still want to yield so that it can be painted. + -- Conceptually, this is really the same as pinging. We can use any + -- RetryLane even if it's the one currently rendering since we're leaving + -- it behind on this node. + workInProgress.lanes = ReactFiberLane.SomeRetryLane + if enableSchedulerTracing then + markSpawnedWork(ReactFiberLane.SomeRetryLane) + end + return fallbackFragment + else + return mountSuspensePrimaryChildren( + workInProgress, + nextPrimaryChildren, + renderLanes + ) + end + else + -- This is an update. + + -- If the current fiber has a SuspenseState, that means it's already showing + -- a fallback. + local prevState: nil | SuspenseState = current.memoizedState + if prevState ~= nil then + -- The current tree is already showing a fallback + + -- Special path for hydration + if enableSuspenseServerRenderer then + -- ROBLOX FIXME: remove :: when Luau understands ~= nil + local dehydrated = (prevState :: SuspenseState).dehydrated + + if dehydrated ~= nil then + if not didSuspend then + return updateDehydratedSuspenseComponent( + current, + workInProgress, + dehydrated, + prevState, + renderLanes + ) + elseif workInProgress.memoizedState ~= nil then + -- Something suspended and we should still be in dehydrated mode. + -- Leave the existing child in place. + workInProgress.child = current.child + -- The dehydrated completion pass expects this flag to be there + -- but the normal suspense pass doesn't. + workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) + return nil + else + -- Suspended but we should no longer be in dehydrated mode. + -- Therefore we now have to render the fallback. + local nextPrimaryChildren = nextProps.children + local nextFallbackChildren = nextProps.fallback + local fallbackChildFragment = + mountSuspenseFallbackAfterRetryWithoutHydrating( + current, + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes + ) + local primaryChildFragment: Fiber = workInProgress.child :: any + primaryChildFragment.memoizedState = + mountSuspenseOffscreenState(renderLanes) + workInProgress.memoizedState = SUSPENDED_MARKER + return fallbackChildFragment + end + end + end + + if showFallback then + local nextFallbackChildren = nextProps.fallback + local nextPrimaryChildren = nextProps.children + local fallbackChildFragment = updateSuspenseFallbackChildren( + current, + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes + ) + local primaryChildFragment: Fiber = workInProgress.child :: any + local prevOffscreenState: OffscreenState | nil = (current.child :: any).memoizedState + + -- ROBLOX deviation: if/else in place of ternary + if prevOffscreenState == nil then + primaryChildFragment.memoizedState = + mountSuspenseOffscreenState(renderLanes) + else + -- ROBLOX FIXME: remove :: when Luau understands ~= nil + primaryChildFragment.memoizedState = updateSuspenseOffscreenState( + prevOffscreenState :: OffscreenState, + renderLanes + ) + end + + primaryChildFragment.childLanes = + getRemainingWorkInPrimaryTree(current, renderLanes) + workInProgress.memoizedState = SUSPENDED_MARKER + return fallbackChildFragment + else + local nextPrimaryChildren = nextProps.children + local primaryChildFragment = updateSuspensePrimaryChildren( + current, + workInProgress, + nextPrimaryChildren, + renderLanes + ) + workInProgress.memoizedState = nil + return primaryChildFragment + end + else + -- The current tree is not already showing a fallback. + if showFallback then + -- Timed out. + local nextFallbackChildren = nextProps.fallback + local nextPrimaryChildren = nextProps.children + local fallbackChildFragment = updateSuspenseFallbackChildren( + current, + workInProgress, + nextPrimaryChildren, + nextFallbackChildren, + renderLanes + ) + local primaryChildFragment: Fiber = workInProgress.child :: any + local prevOffscreenState: OffscreenState | nil = (current.child :: any).memoizedState + + -- ROBLOX deviation: if/else in place of ternary + if prevOffscreenState == nil then + primaryChildFragment.memoizedState = + mountSuspenseOffscreenState(renderLanes) + else + -- ROBLOX FIXME: remove :: once Luau understands nil check + primaryChildFragment.memoizedState = updateSuspenseOffscreenState( + prevOffscreenState :: OffscreenState, + renderLanes + ) + end + + primaryChildFragment.childLanes = + getRemainingWorkInPrimaryTree(current, renderLanes) + -- Skip the primary children, and continue working on the + -- fallback children. + workInProgress.memoizedState = SUSPENDED_MARKER + return fallbackChildFragment + else + -- Still haven't timed out. Continue rendering the children, like we + -- normally do. + local nextPrimaryChildren = nextProps.children + local primaryChildFragment = updateSuspensePrimaryChildren( + current, + workInProgress, + nextPrimaryChildren, + renderLanes + ) + workInProgress.memoizedState = nil + return primaryChildFragment + end + end + end end -function mountSuspensePrimaryChildren( - workInProgress, - primaryChildren, - renderLanes -) - local mode = workInProgress.mode - local primaryChildProps: OffscreenProps = { - mode = 'visible', - children = primaryChildren - } - local primaryChildFragment = createFiberFromOffscreen( - primaryChildProps, - mode, - renderLanes, - nil - ) - primaryChildFragment.return_ = workInProgress - workInProgress.child = primaryChildFragment - return primaryChildFragment +function mountSuspensePrimaryChildren(workInProgress, primaryChildren, renderLanes) + local mode = workInProgress.mode + local primaryChildProps: OffscreenProps = { + mode = "visible", + children = primaryChildren, + } + local primaryChildFragment = + createFiberFromOffscreen(primaryChildProps, mode, renderLanes, nil) + primaryChildFragment.return_ = workInProgress + workInProgress.child = primaryChildFragment + return primaryChildFragment end function mountSuspenseFallbackChildren( - workInProgress, - primaryChildren, - fallbackChildren, - renderLanes + workInProgress, + primaryChildren, + fallbackChildren, + renderLanes ) - local mode = workInProgress.mode - local progressedPrimaryFragment: Fiber | nil = workInProgress.child - - local primaryChildProps: OffscreenProps = { - mode = 'hidden', - children = primaryChildren - } - - local primaryChildFragment - local fallbackChildFragment - if bit32.band(mode, BlockingMode) == NoMode and progressedPrimaryFragment ~= nil then - -- In legacy mode, we commit the primary tree as if it successfully - -- completed, even though it's in an inconsistent state. - primaryChildFragment = progressedPrimaryFragment - primaryChildFragment.childLanes = ReactFiberLane.NoLanes - primaryChildFragment.pendingProps = primaryChildProps - - if enableProfilerTimer and bit32.band(workInProgress.mode, ProfileMode) ~= 0 then - -- Reset the durations from the first pass so they aren't included in the - -- final amounts. This seems counterintuitive, since we're intentionally - -- not measuring part of the render phase, but this makes it match what we - -- do in Concurrent Mode. - primaryChildFragment.actualDuration = 0 - primaryChildFragment.actualStartTime = -1 - primaryChildFragment.selfBaseDuration = 0 - primaryChildFragment.treeBaseDuration = 0 - end - - fallbackChildFragment = createFiberFromFragment( - fallbackChildren, - mode, - renderLanes, - nil - ) - else - primaryChildFragment = createFiberFromOffscreen( - primaryChildProps, - mode, - ReactFiberLane.NoLanes, - nil - ) - fallbackChildFragment = createFiberFromFragment( - fallbackChildren, - mode, - renderLanes, - nil - ) - end - - primaryChildFragment.return_ = workInProgress - fallbackChildFragment.return_ = workInProgress - primaryChildFragment.sibling = fallbackChildFragment - workInProgress.child = primaryChildFragment - return fallbackChildFragment + local mode = workInProgress.mode + local progressedPrimaryFragment: Fiber | nil = workInProgress.child + + local primaryChildProps: OffscreenProps = { + mode = "hidden", + children = primaryChildren, + } + + local primaryChildFragment + local fallbackChildFragment + if bit32.band(mode, BlockingMode) == NoMode and progressedPrimaryFragment ~= nil then + -- In legacy mode, we commit the primary tree as if it successfully + -- completed, even though it's in an inconsistent state. + primaryChildFragment = progressedPrimaryFragment + primaryChildFragment.childLanes = ReactFiberLane.NoLanes + primaryChildFragment.pendingProps = primaryChildProps + + if enableProfilerTimer and bit32.band(workInProgress.mode, ProfileMode) ~= 0 then + -- Reset the durations from the first pass so they aren't included in the + -- final amounts. This seems counterintuitive, since we're intentionally + -- not measuring part of the render phase, but this makes it match what we + -- do in Concurrent Mode. + primaryChildFragment.actualDuration = 0 + primaryChildFragment.actualStartTime = -1 + primaryChildFragment.selfBaseDuration = 0 + primaryChildFragment.treeBaseDuration = 0 + end + + fallbackChildFragment = + createFiberFromFragment(fallbackChildren, mode, renderLanes, nil) + else + primaryChildFragment = + createFiberFromOffscreen(primaryChildProps, mode, ReactFiberLane.NoLanes, nil) + fallbackChildFragment = + createFiberFromFragment(fallbackChildren, mode, renderLanes, nil) + end + + primaryChildFragment.return_ = workInProgress + fallbackChildFragment.return_ = workInProgress + primaryChildFragment.sibling = fallbackChildFragment + workInProgress.child = primaryChildFragment + return fallbackChildFragment end local function createWorkInProgressOffscreenFiber( - current: Fiber, - offscreenProps: OffscreenProps + current: Fiber, + offscreenProps: OffscreenProps ) - -- The props argument to `createWorkInProgress` is `any` typed, so we use this - -- wrapper function to constrain it. - return createWorkInProgress(current, offscreenProps) + -- The props argument to `createWorkInProgress` is `any` typed, so we use this + -- wrapper function to constrain it. + return createWorkInProgress(current, offscreenProps) end function updateSuspensePrimaryChildren( - current, - workInProgress, - primaryChildren, - renderLanes + current, + workInProgress, + primaryChildren, + renderLanes ) - local currentPrimaryChildFragment: Fiber = current.child - local currentFallbackChildFragment: Fiber | nil = - currentPrimaryChildFragment.sibling - - local primaryChildFragment = createWorkInProgressOffscreenFiber( - currentPrimaryChildFragment, - { - mode = 'visible', - children = primaryChildren - } - ) - if bit32.band(workInProgress.mode, BlockingMode) == NoMode then - primaryChildFragment.lanes = renderLanes - end - primaryChildFragment.return_ = workInProgress - primaryChildFragment.sibling = nil - if currentFallbackChildFragment ~= nil then - -- Delete the fallback child fragment - local deletions = workInProgress.deletions - if deletions == nil then - workInProgress.deletions = {currentFallbackChildFragment} - -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) - workInProgress.flags = bit32.bor(workInProgress.flags, Deletion) - else - table.insert(deletions, currentFallbackChildFragment) - end - end - - workInProgress.child = primaryChildFragment - return primaryChildFragment + local currentPrimaryChildFragment: Fiber = current.child :: any + local currentFallbackChildFragment: Fiber | nil = currentPrimaryChildFragment.sibling + + local primaryChildFragment = + createWorkInProgressOffscreenFiber(currentPrimaryChildFragment, { + mode = "visible", + children = primaryChildren, + }) + if bit32.band(workInProgress.mode, BlockingMode) == NoMode then + primaryChildFragment.lanes = renderLanes + end + primaryChildFragment.return_ = workInProgress + primaryChildFragment.sibling = nil + if currentFallbackChildFragment ~= nil then + -- Delete the fallback child fragment + local deletions = workInProgress.deletions + if deletions == nil then + workInProgress.deletions = { currentFallbackChildFragment } + -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) + workInProgress.flags = bit32.bor(workInProgress.flags, Deletion) + else + table.insert(deletions, currentFallbackChildFragment) + end + end + + workInProgress.child = primaryChildFragment + return primaryChildFragment end function updateSuspenseFallbackChildren( - current, - workInProgress, - primaryChildren, - fallbackChildren, - renderLanes + current, + workInProgress, + primaryChildren, + fallbackChildren, + renderLanes ) - local mode = workInProgress.mode - local currentPrimaryChildFragment: Fiber = current.child - local currentFallbackChildFragment: Fiber | nil = currentPrimaryChildFragment.sibling - - local primaryChildProps: OffscreenProps = { - mode = 'hidden', - children = primaryChildren - } - - local primaryChildFragment - if - -- In legacy mode, we commit the primary tree as if it successfully - -- completed, even though it's in an inconsistent state. - bit32.band(mode, BlockingMode) == NoMode and - -- Make sure we're on the second pass, i.e. the primary child fragment was - -- already cloned. In legacy mode, the only case where this isn't true is - -- when DevTools forces us to display a fallback; we skip the first render - -- pass entirely and go straight to rendering the fallback. (In Concurrent - -- Mode, SuspenseList can also trigger this scenario, but this is a legacy- - -- only codepath.) - workInProgress.child ~= currentPrimaryChildFragment - then - local progressedPrimaryFragment: Fiber = workInProgress.child - primaryChildFragment = progressedPrimaryFragment - primaryChildFragment.childLanes = ReactFiberLane.NoLanes - primaryChildFragment.pendingProps = primaryChildProps - - if enableProfilerTimer and bit32.band(workInProgress.mode, ProfileMode) ~= 0 then - -- Reset the durations from the first pass so they aren't included in the - -- final amounts. This seems counterintuitive, since we're intentionally - -- not measuring part of the render phase, but this makes it match what we - -- do in Concurrent Mode. - primaryChildFragment.actualDuration = 0 - primaryChildFragment.actualStartTime = -1 - primaryChildFragment.selfBaseDuration = - currentPrimaryChildFragment.selfBaseDuration - primaryChildFragment.treeBaseDuration = - currentPrimaryChildFragment.treeBaseDuration - end - - -- The fallback fiber was added as a deletion effect during the first pass. - -- However, since we're going to remain on the fallback, we no longer want - -- to delete it. - workInProgress.deletions = nil - else - primaryChildFragment = createWorkInProgressOffscreenFiber( - currentPrimaryChildFragment, - primaryChildProps - ) - - -- Since we're reusing a current tree, we need to reuse the flags, too. - -- (We don't do this in legacy mode, because in legacy mode we don't re-use - -- the current tree; see previous branch.) - primaryChildFragment.subtreeFlags = - bit32.band(currentPrimaryChildFragment.subtreeFlags, StaticMask) - end - local fallbackChildFragment - if currentFallbackChildFragment ~= nil then - fallbackChildFragment = createWorkInProgress( - currentFallbackChildFragment, - fallbackChildren - ) - else - fallbackChildFragment = createFiberFromFragment( - fallbackChildren, - mode, - renderLanes, - nil - ) - -- Needs a placement effect because the parent (the Suspense boundary) already - -- mounted but this is a new fiber. - fallbackChildFragment.flags = bit32.bor(fallbackChildFragment.flags, Placement) - end - - fallbackChildFragment.return_ = workInProgress - primaryChildFragment.return_ = workInProgress - primaryChildFragment.sibling = fallbackChildFragment - workInProgress.child = primaryChildFragment - - return fallbackChildFragment + local mode = workInProgress.mode + local currentPrimaryChildFragment: Fiber = current.child :: any + local currentFallbackChildFragment: Fiber | nil = currentPrimaryChildFragment.sibling + + local primaryChildProps: OffscreenProps = { + mode = "hidden", + children = primaryChildren, + } + + local primaryChildFragment + if + -- In legacy mode, we commit the primary tree as if it successfully + -- completed, even though it's in an inconsistent state. + bit32.band(mode, BlockingMode) == NoMode + -- Make sure we're on the second pass, i.e. the primary child fragment was + -- already cloned. In legacy mode, the only case where this isn't true is + -- when DevTools forces us to display a fallback; we skip the first render + -- pass entirely and go straight to rendering the fallback. (In Concurrent + -- Mode, SuspenseList can also trigger this scenario, but this is a legacy- + -- only codepath.) + and workInProgress.child ~= currentPrimaryChildFragment + then + local progressedPrimaryFragment: Fiber = workInProgress.child + primaryChildFragment = progressedPrimaryFragment + primaryChildFragment.childLanes = ReactFiberLane.NoLanes + primaryChildFragment.pendingProps = primaryChildProps + + if enableProfilerTimer and bit32.band(workInProgress.mode, ProfileMode) ~= 0 then + -- Reset the durations from the first pass so they aren't included in the + -- final amounts. This seems counterintuitive, since we're intentionally + -- not measuring part of the render phase, but this makes it match what we + -- do in Concurrent Mode. + primaryChildFragment.actualDuration = 0 + primaryChildFragment.actualStartTime = -1 + primaryChildFragment.selfBaseDuration = + currentPrimaryChildFragment.selfBaseDuration + primaryChildFragment.treeBaseDuration = + currentPrimaryChildFragment.treeBaseDuration + end + + -- The fallback fiber was added as a deletion effect during the first pass. + -- However, since we're going to remain on the fallback, we no longer want + -- to delete it. + workInProgress.deletions = nil + else + primaryChildFragment = createWorkInProgressOffscreenFiber( + currentPrimaryChildFragment, + primaryChildProps + ) + + -- Since we're reusing a current tree, we need to reuse the flags, too. + -- (We don't do this in legacy mode, because in legacy mode we don't re-use + -- the current tree; see previous branch.) + primaryChildFragment.subtreeFlags = + bit32.band(currentPrimaryChildFragment.subtreeFlags, StaticMask) + end + local fallbackChildFragment + if currentFallbackChildFragment ~= nil then + fallbackChildFragment = + createWorkInProgress(currentFallbackChildFragment, fallbackChildren) + else + fallbackChildFragment = + createFiberFromFragment(fallbackChildren, mode, renderLanes, nil) + -- Needs a placement effect because the parent (the Suspense boundary) already + -- mounted but this is a new fiber. + fallbackChildFragment.flags = bit32.bor(fallbackChildFragment.flags, Placement) + end + + fallbackChildFragment.return_ = workInProgress + primaryChildFragment.return_ = workInProgress + primaryChildFragment.sibling = fallbackChildFragment + workInProgress.child = primaryChildFragment + + return fallbackChildFragment end local function retrySuspenseComponentWithoutHydrating( - current: Fiber, - workInProgress: Fiber, - renderLanes: Lanes + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes ) - -- This will add the old fiber to the deletion list - reconcileChildFibers(workInProgress, current.child, nil, renderLanes) - - -- We're now not suspended nor dehydrated. - local nextProps = workInProgress.pendingProps - local primaryChildren = nextProps.children - local primaryChildFragment = mountSuspensePrimaryChildren( - workInProgress, - primaryChildren, - renderLanes - ) - -- Needs a placement effect because the parent (the Suspense boundary) already - -- mounted but this is a new fiber. - primaryChildFragment.flags = bit32.bor(primaryChildFragment.flags, Placement) - workInProgress.memoizedState = nil - - return primaryChildFragment + -- This will add the old fiber to the deletion list + reconcileChildFibers(workInProgress, current.child, nil, renderLanes) + + -- We're now not suspended nor dehydrated. + local nextProps = workInProgress.pendingProps + local primaryChildren = nextProps.children + local primaryChildFragment = + mountSuspensePrimaryChildren(workInProgress, primaryChildren, renderLanes) + -- Needs a placement effect because the parent (the Suspense boundary) already + -- mounted but this is a new fiber. + primaryChildFragment.flags = bit32.bor(primaryChildFragment.flags, Placement) + workInProgress.memoizedState = nil + + return primaryChildFragment end function mountSuspenseFallbackAfterRetryWithoutHydrating( - current, - workInProgress, - primaryChildren, - fallbackChildren, - renderLanes + current, + workInProgress, + primaryChildren, + fallbackChildren, + renderLanes ) - local mode = workInProgress.mode - local primaryChildFragment = createFiberFromOffscreen( - primaryChildren, - mode, - ReactFiberLane.NoLanes, - nil - ) - local fallbackChildFragment = createFiberFromFragment( - fallbackChildren, - mode, - renderLanes, - nil - ) - -- Needs a placement effect because the parent (the Suspense - -- boundary) already mounted but this is a new fiber. - fallbackChildFragment.flags = bit32.bor(fallbackChildFragment.flags, Placement) - - primaryChildFragment.return_ = workInProgress - fallbackChildFragment.return_ = workInProgress - primaryChildFragment.sibling = fallbackChildFragment - workInProgress.child = primaryChildFragment - - if bit32.band(workInProgress.mode, BlockingMode) ~= NoMode then - -- We will have dropped the effect list which contains the - -- deletion. We need to reconcile to delete the current child. - reconcileChildFibers(workInProgress, current.child, nil, renderLanes) - end - - return fallbackChildFragment + local mode = workInProgress.mode + local primaryChildFragment = + createFiberFromOffscreen(primaryChildren, mode, ReactFiberLane.NoLanes, nil) + local fallbackChildFragment = + createFiberFromFragment(fallbackChildren, mode, renderLanes, nil) + -- Needs a placement effect because the parent (the Suspense + -- boundary) already mounted but this is a new fiber. + fallbackChildFragment.flags = bit32.bor(fallbackChildFragment.flags, Placement) + + primaryChildFragment.return_ = workInProgress + fallbackChildFragment.return_ = workInProgress + primaryChildFragment.sibling = fallbackChildFragment + workInProgress.child = primaryChildFragment + + if bit32.band(workInProgress.mode, BlockingMode) ~= NoMode then + -- We will have dropped the effect list which contains the + -- deletion. We need to reconcile to delete the current child. + reconcileChildFibers(workInProgress, current.child, nil, renderLanes) + end + + return fallbackChildFragment end function mountDehydratedSuspenseComponent( - workInProgress: Fiber, - suspenseInstance: SuspenseInstance, - renderLanes: Lanes + workInProgress: Fiber, + suspenseInstance: SuspenseInstance, + renderLanes: Lanes ): nil | Fiber - -- During the first pass, we'll bail out and not drill into the children. - -- Instead, we'll leave the content in place and try to hydrate it later. - if bit32.band(workInProgress.mode, BlockingMode) == NoMode then - if _G.__DEV__ then - console.error( - 'Cannot hydrate Suspense in legacy mode. Switch from' .. - 'ReactDOM.hydrate(element, container) to ' .. - 'ReactDOM.createBlockingRoot(container, { hydrate: true })' .. - '.render(element) or remove the Suspense components' .. - 'the server rendered components.' - ) - end - workInProgress.lanes = ReactFiberLane.laneToLanes(ReactFiberLane.SyncLane) - elseif isSuspenseInstanceFallback(suspenseInstance) then - -- This is a client-only boundary. Since we won't get any content from the server - -- for this, we need to schedule that at a higher priority based on when it would - -- have timed out. In theory we could render it in this pass but it would have the - -- wrong priority associated with it and will prevent hydration of parent path. - -- Instead, we'll leave work left on it to render it in a separate commit. - - -- TODO This time should be the time at which the server rendered response that is - -- a parent to this boundary was displayed. However, since we currently don't have - -- a protocol to transfer that time, we'll just estimate it by using the current - -- time. This will mean that Suspense timeouts are slightly shifted to later than - -- they should be. - -- Schedule a normal pri update to render this content. - if enableSchedulerTracing then - markSpawnedWork(ReactFiberLane.DefaultHydrationLane) - end - workInProgress.lanes = ReactFiberLane.laneToLanes(ReactFiberLane.DefaultHydrationLane) - else - -- We'll continue hydrating the rest at offscreen priority since we'll already - -- be showing the right content coming from the server, it is no rush. - workInProgress.lanes = ReactFiberLane.laneToLanes(ReactFiberLane.OffscreenLane) - if enableSchedulerTracing then - markSpawnedWork(ReactFiberLane.OffscreenLane) - end - end - return nil + -- During the first pass, we'll bail out and not drill into the children. + -- Instead, we'll leave the content in place and try to hydrate it later. + if bit32.band(workInProgress.mode, BlockingMode) == NoMode then + if __DEV__ then + console.error( + "Cannot hydrate Suspense in legacy mode. Switch from" + .. "ReactDOM.hydrate(element, container) to " + .. "ReactDOM.createBlockingRoot(container, { hydrate: true })" + .. ".render(element) or remove the Suspense components" + .. "the server rendered components." + ) + end + workInProgress.lanes = ReactFiberLane.laneToLanes(ReactFiberLane.SyncLane) + elseif isSuspenseInstanceFallback(suspenseInstance) then + -- This is a client-only boundary. Since we won't get any content from the server + -- for this, we need to schedule that at a higher priority based on when it would + -- have timed out. In theory we could render it in this pass but it would have the + -- wrong priority associated with it and will prevent hydration of parent path. + -- Instead, we'll leave work left on it to render it in a separate commit. + + -- TODO This time should be the time at which the server rendered response that is + -- a parent to this boundary was displayed. However, since we currently don't have + -- a protocol to transfer that time, we'll just estimate it by using the current + -- time. This will mean that Suspense timeouts are slightly shifted to later than + -- they should be. + -- Schedule a normal pri update to render this content. + if enableSchedulerTracing then + markSpawnedWork(ReactFiberLane.DefaultHydrationLane) + end + workInProgress.lanes = + ReactFiberLane.laneToLanes(ReactFiberLane.DefaultHydrationLane) + else + -- We'll continue hydrating the rest at offscreen priority since we'll already + -- be showing the right content coming from the server, it is no rush. + workInProgress.lanes = ReactFiberLane.laneToLanes(ReactFiberLane.OffscreenLane) + if enableSchedulerTracing then + markSpawnedWork(ReactFiberLane.OffscreenLane) + end + end + return nil end function updateDehydratedSuspenseComponent( - current: Fiber, - workInProgress: Fiber, - suspenseInstance: SuspenseInstance, - suspenseState: SuspenseState, - renderLanes: Lanes + current: Fiber, + workInProgress: Fiber, + suspenseInstance: SuspenseInstance, + suspenseState: SuspenseState, + renderLanes: Lanes ): nil | Fiber - -- We should never be hydrating at this point because it is the first pass, - -- but after we've already committed once. - warnIfHydrating() - - if bit32.band(getExecutionContext(), RetryAfterError) ~= NoContext then - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes - ) - end - - if bit32.band(workInProgress.mode, BlockingMode) == NoMode then - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes - ) - end - - if isSuspenseInstanceFallback(suspenseInstance) then - -- This boundary is in a permanent fallback state. In this case, we'll never - -- get an update and we'll never be able to hydrate the final content. Let's just try the - -- client side render instead. - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes - ) - end - -- We use lanes to indicate that a child might depend on context, so if - -- any context has changed, we need to treat is as if the input might have changed. - local hasContextChanged = ReactFiberLane.includesSomeLane(renderLanes, current.childLanes) - if didReceiveUpdate or hasContextChanged then - -- This boundary has changed since the first render. This means that we are now unable to - -- hydrate it. We might still be able to hydrate it using a higher priority lane. - local root = getWorkInProgressRoot() - if root ~= nil then - local attemptHydrationAtLane = ReactFiberLane.getBumpedLaneForHydration( - root, - renderLanes - ) - if - attemptHydrationAtLane ~= ReactFiberLane.NoLane and - attemptHydrationAtLane ~= suspenseState.retryLane - then - -- Intentionally mutating since this render will get interrupted. This - -- is one of the very rare times where we mutate the current tree - -- during the render phase. - suspenseState.retryLane = attemptHydrationAtLane - -- TODO: Ideally this would inherit the event time of the current render - local eventTime = ReactFiberLane.NoTimestamp - scheduleUpdateOnFiber(current, attemptHydrationAtLane, eventTime) - else - -- We have already tried to ping at a higher priority than we're rendering with - -- so if we got here, we must have failed to hydrate at those levels. We must - -- now give up. Instead, we're going to delete the whole subtree and instead inject - -- a new real Suspense boundary to take its place, which may render content - -- or fallback. This might suspend for a while and if it does we might still have - -- an opportunity to hydrate before this pass commits. - end - end - - -- If we have scheduled higher pri work above, this will probably just abort the render - -- since we now have higher priority work, but in case it doesn't, we need to prepare to - -- render something, if we time out. Even if that requires us to delete everything and - -- skip hydration. - -- Delay having to do this as long as the suspense timeout allows us. - renderDidSuspendDelayIfPossible() - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes - ) - elseif isSuspenseInstancePending(suspenseInstance) then - -- This component is still pending more data from the server, so we can't hydrate its - -- content. We treat it as if this component suspended itself. It might seem as if - -- we could just try to render it client-side instead. However, this will perform a - -- lot of unnecessary work and is unlikely to complete since it often will suspend - -- on missing data anyway. Additionally, the server might be able to render more - -- than we can on the client yet. In that case we'd end up with more fallback states - -- on the client than if we just leave it alone. If the server times out or errors - -- these should update this boundary to the permanent Fallback state instead. - -- Mark it as having captured (i.e. suspended). - workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) - -- Leave the child in place. I.e. the dehydrated fragment. - workInProgress.child = current.child - - -- Register a callback to retry this boundary once the server has sent the result. - -- ROBLOX deviation: wrapper function in place of bind - local retry = function() - return retryDehydratedSuspenseBoundary(current) - end - - if enableSchedulerTracing then - retry = Schedule_tracing_wrap(retry) - end - - registerSuspenseInstanceRetry(suspenseInstance, retry) - return nil - else - -- This is the first attempt. - reenterHydrationStateFromDehydratedSuspenseInstance( - workInProgress, - suspenseInstance - ) - local nextProps = workInProgress.pendingProps - local primaryChildren = nextProps.children - local primaryChildFragment = mountSuspensePrimaryChildren( - workInProgress, - primaryChildren, - renderLanes - ) - -- Mark the children as hydrating. This is a fast path to know whether this - -- tree is part of a hydrating tree. This is used to determine if a child - -- node has fully mounted yet, and for scheduling event replaying. - -- Conceptually this is similar to Placement in that a new subtree is - -- inserted into the React tree here. It just happens to not need DOM - -- mutations because it already exists. - primaryChildFragment.flags = bit32.bor(primaryChildFragment.flags, Hydrating) - return primaryChildFragment - end + -- We should never be hydrating at this point because it is the first pass, + -- but after we've already committed once. + warnIfHydrating() + + if bit32.band(getExecutionContext(), RetryAfterError) ~= NoContext then + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ) + end + + if bit32.band(workInProgress.mode, BlockingMode) == NoMode then + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ) + end + + if isSuspenseInstanceFallback(suspenseInstance) then + -- This boundary is in a permanent fallback state. In this case, we'll never + -- get an update and we'll never be able to hydrate the final content. Let's just try the + -- client side render instead. + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ) + end + -- We use lanes to indicate that a child might depend on context, so if + -- any context has changed, we need to treat is as if the input might have changed. + local hasContextChanged = + ReactFiberLane.includesSomeLane(renderLanes, current.childLanes) + if didReceiveUpdate or hasContextChanged then + -- This boundary has changed since the first render. This means that we are now unable to + -- hydrate it. We might still be able to hydrate it using a higher priority lane. + local root = getWorkInProgressRoot() + if root ~= nil then + local attemptHydrationAtLane = + ReactFiberLane.getBumpedLaneForHydration(root, renderLanes) + if + attemptHydrationAtLane ~= ReactFiberLane.NoLane + and attemptHydrationAtLane ~= suspenseState.retryLane + then + -- Intentionally mutating since this render will get interrupted. This + -- is one of the very rare times where we mutate the current tree + -- during the render phase. + suspenseState.retryLane = attemptHydrationAtLane + -- TODO: Ideally this would inherit the event time of the current render + local eventTime = ReactFiberLane.NoTimestamp + scheduleUpdateOnFiber(current, attemptHydrationAtLane, eventTime) + else + -- We have already tried to ping at a higher priority than we're rendering with + -- so if we got here, we must have failed to hydrate at those levels. We must + -- now give up. Instead, we're going to delete the whole subtree and instead inject + -- a new real Suspense boundary to take its place, which may render content + -- or fallback. This might suspend for a while and if it does we might still have + -- an opportunity to hydrate before this pass commits. + end + end + + -- If we have scheduled higher pri work above, this will probably just abort the render + -- since we now have higher priority work, but in case it doesn't, we need to prepare to + -- render something, if we time out. Even if that requires us to delete everything and + -- skip hydration. + -- Delay having to do this as long as the suspense timeout allows us. + renderDidSuspendDelayIfPossible() + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ) + elseif isSuspenseInstancePending(suspenseInstance) then + -- This component is still pending more data from the server, so we can't hydrate its + -- content. We treat it as if this component suspended itself. It might seem as if + -- we could just try to render it client-side instead. However, this will perform a + -- lot of unnecessary work and is unlikely to complete since it often will suspend + -- on missing data anyway. Additionally, the server might be able to render more + -- than we can on the client yet. In that case we'd end up with more fallback states + -- on the client than if we just leave it alone. If the server times out or errors + -- these should update this boundary to the permanent Fallback state instead. + -- Mark it as having captured (i.e. suspended). + workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) + -- Leave the child in place. I.e. the dehydrated fragment. + workInProgress.child = current.child + + -- Register a callback to retry this boundary once the server has sent the result. + -- ROBLOX deviation: wrapper function in place of bind + local retry = function() + return retryDehydratedSuspenseBoundary(current) + 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 + + registerSuspenseInstanceRetry(suspenseInstance, retry) + return nil + else + -- This is the first attempt. + reenterHydrationStateFromDehydratedSuspenseInstance( + workInProgress, + suspenseInstance + ) + local nextProps = workInProgress.pendingProps + local primaryChildren = nextProps.children + local primaryChildFragment = + mountSuspensePrimaryChildren(workInProgress, primaryChildren, renderLanes) + -- Mark the children as hydrating. This is a fast path to know whether this + -- tree is part of a hydrating tree. This is used to determine if a child + -- node has fully mounted yet, and for scheduling event replaying. + -- Conceptually this is similar to Placement in that a new subtree is + -- inserted into the React tree here. It just happens to not need DOM + -- mutations because it already exists. + primaryChildFragment.flags = bit32.bor(primaryChildFragment.flags, Hydrating) + return primaryChildFragment + end end -- function scheduleWorkOnFiber(fiber: Fiber, renderLanes: Lanes) @@ -2668,7 +2656,7 @@ end -- type SuspenseListRevealOrder = 'forwards' | 'backwards' | 'together' | void -- function validateRevealOrder(revealOrder: SuspenseListRevealOrder) --- if _G.__DEV__ then +-- if __DEV__ then -- if -- revealOrder ~= undefined and -- revealOrder ~= 'forwards' and @@ -2723,7 +2711,7 @@ end -- tailMode: SuspenseListTailMode, -- revealOrder: SuspenseListRevealOrder, -- ) --- if _G.__DEV__ then +-- if __DEV__ then -- if tailMode ~= undefined and !didWarnAboutTailOptions[tailMode]) -- if tailMode ~= 'collapsed' and tailMode ~= 'hidden') -- didWarnAboutTailOptions[tailMode] = true @@ -2746,7 +2734,7 @@ end -- end -- function validateSuspenseListNestedChild(childSlot: mixed, index: number) --- if _G.__DEV__ then +-- if __DEV__ then -- local isArray = Array.isArray(childSlot) -- local isIterable = -- !isArray and typeof getIteratorFn(childSlot) == 'function' @@ -2772,7 +2760,7 @@ end -- children: mixed, -- revealOrder: SuspenseListRevealOrder, -- ) --- if _G.__DEV__ then +-- if __DEV__ then -- if -- (revealOrder == 'forwards' or revealOrder == 'backwards') and -- children ~= undefined and @@ -2974,664 +2962,677 @@ end -- end function updatePortalComponent( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes ) - pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo) - local nextChildren = workInProgress.pendingProps - if current == nil then - -- Portals are special because we don't append the children during mount - -- but at commit. Therefore we need to track insertions which the normal - -- flow doesn't do during mount. This doesn't happen at the root because - -- the root always starts with a "current" with a nil child. - -- TODO: Consider unifying this with how the root works. - workInProgress.child = reconcileChildFibers( - workInProgress, - nil, - nextChildren, - renderLanes - ) - else - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - end - return workInProgress.child + pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo) + local nextChildren = workInProgress.pendingProps + if current == nil then + -- Portals are special because we don't append the children during mount + -- but at commit. Therefore we need to track insertions which the normal + -- flow doesn't do during mount. This doesn't happen at the root because + -- the root always starts with a "current" with a nil child. + -- TODO: Consider unifying this with how the root works. + workInProgress.child = + reconcileChildFibers(workInProgress, nil, nextChildren, renderLanes) + else + reconcileChildren(current, workInProgress, nextChildren, renderLanes) + end + return workInProgress.child end local hasWarnedAboutUsingNoValuePropOnContextProvider = false local function updateContextProvider( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes ) - local providerType: ReactProviderType = workInProgress.type - local context: ReactContext = providerType._context - - local newProps = workInProgress.pendingProps - local oldProps = workInProgress.memoizedProps - - local newValue = newProps.value - - if _G.__DEV__ then - if Array.indexOf(Object.keys(newProps), "value") < 1 then - if not hasWarnedAboutUsingNoValuePropOnContextProvider then - hasWarnedAboutUsingNoValuePropOnContextProvider = true - console.error( - "The `value` prop is required for the ``. Did you misspell it or forget to pass it?" - ) - end - end - local providerPropTypes = workInProgress.type.propTypes - - if providerPropTypes then - checkPropTypes(providerPropTypes, newProps, 'prop', 'Context.Provider') - end - end - - pushProvider(workInProgress, newValue) - - if oldProps ~= nil then - local oldValue = oldProps.value - local changedBits = calculateChangedBits(context, newValue, oldValue) - if changedBits == 0 then - -- No change. Bailout early if children are the same. - if - oldProps.children == newProps.children and - not hasLegacyContextChanged() - then - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderLanes - ) - end - else - -- The context value changed. Search for matching consumers and schedule - -- them to update. - propagateContextChange(workInProgress, context, changedBits, renderLanes) - end - end - - local newChildren = newProps.children - reconcileChildren(current, workInProgress, newChildren, renderLanes) - return workInProgress.child + local providerType: ReactProviderType = workInProgress.type + local context: ReactContext = providerType._context + + local newProps = workInProgress.pendingProps + local oldProps = workInProgress.memoizedProps + + local newValue = newProps.value + + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + if Array.indexOf(Object.keys(newProps), "value") < 1 then + if not hasWarnedAboutUsingNoValuePropOnContextProvider then + hasWarnedAboutUsingNoValuePropOnContextProvider = true + console.error( + "The `value` prop is required for the ``. Did you misspell it or forget to pass it?" + ) + end + end + -- ROBLOX deviation: adds support for legacy Roact's validateProps() + local providerPropTypes = workInProgress.type.propTypes + local validateProps = workInProgress.type.validateProps + + if providerPropTypes or validateProps then + checkPropTypes( + providerPropTypes, + validateProps, + newProps, + "prop", + "Context.Provider" + ) + end + end + + pushProvider(workInProgress, newValue) + + if oldProps ~= nil then + local oldValue = oldProps.value + local changedBits = calculateChangedBits(context, newValue, oldValue) + if changedBits == 0 then + -- No change. Bailout early if children are the same. + if + oldProps.children == newProps.children + and not hasLegacyContextChanged() + then + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + end + else + -- The context value changed. Search for matching consumers and schedule + -- them to update. + propagateContextChange(workInProgress, context, changedBits, renderLanes) + end + end + + local newChildren = newProps.children + reconcileChildren(current, workInProgress, newChildren, renderLanes) + return workInProgress.child end -local hasWarnedAboutUsingContextAsConsumer = false +-- ROBLOX deviation: combine two warning flags to dodge the registers limit +local hasWarnedAbout = { + usingContextAsConsumer = false, + usingLegacyConsumer = false, +} function updateContextConsumer( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes ) - local context: ReactContext = workInProgress.type - -- The logic below for Context differs depending on PROD or DEV mode. In - -- DEV mode, we create a separate object for Context.Consumer that acts - -- like a proxy to Context. This proxy object adds unnecessary code in PROD - -- so we use the old behaviour (Context.Consumer references Context) to - -- reduce size and overhead. The separate object references context via - -- a property called "_context", which also gives us the ability to check - -- in DEV mode if this property exists or not and warn if it does not. - if _G.__DEV__ then - if context._context == nil then - -- This may be because it's a Context (rather than a Consumer). - -- Or it may be because it's older React where they're the same thing. - -- We only want to warn if we're sure it's a new React. - if context ~= context.Consumer then - if not hasWarnedAboutUsingContextAsConsumer then - hasWarnedAboutUsingContextAsConsumer = true - console.error( - 'Rendering directly is not supported and will be removed in ' .. - 'a future major release. Did you mean to render instead?' - ) - end - end - else - context = context._context - end - end - local newProps = workInProgress.pendingProps - - -- ROBLOX deviation: compatibility for old Roact's context consumer API - local render - if newProps.render then - if _G.__DEV__ then - console.warn("Your Context.Consumer component is using legacy Roact syntax, which won't be supported in future versions of Roact. \n" .. - "Please provide no props and supply the 'render' function as a child (the 3rd argument of createElement). For example: \n" .. - " createElement(ContextConsumer, {render = function(...) end})\n" .. - "becomes:\n" .. - " createElement(ContextConsumer, nil, function(...) end)\n" .. - "For more info, reference the React documentation here: \n" .. - "https://reactjs.org/docs/context.html#contextconsumer") - end - render = newProps.render - else - render = newProps.children - end - - if _G.__DEV__ then - if typeof(render) ~= 'function' then - console.error( - 'A context consumer was rendered with multiple children, or a child ' .. - "that isn't a function. A context consumer expects a single child " .. - 'that is a function. If you did pass a function, make sure there ' .. - 'is no trailing or leading whitespace around it.' - ) - end - end - - prepareToReadContext(workInProgress, renderLanes, exports.markWorkInProgressReceivedUpdate) - local newValue = readContext(context, newProps.unstable_observedBits) - local newChildren - if _G.__DEV__ then - ReactCurrentOwner.current = workInProgress - setIsRendering(true) - newChildren = render(newValue) - setIsRendering(false) - else - newChildren = render(newValue) - end - - -- React DevTools reads this flag. - workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) - reconcileChildren(current, workInProgress, newChildren, renderLanes) - return workInProgress.child + local context: ReactContext = workInProgress.type + -- The logic below for Context differs depending on PROD or DEV mode. In + -- DEV mode, we create a separate object for Context.Consumer that acts + -- like a proxy to Context. This proxy object adds unnecessary code in PROD + -- so we use the old behaviour (Context.Consumer references Context) to + -- reduce size and overhead. The separate object references context via + -- a property called "_context", which also gives us the ability to check + -- in DEV mode if this property exists or not and warn if it does not. + if __DEV__ then + if (context :: any)._context == nil then + -- This may be because it's a Context (rather than a Consumer). + -- Or it may be because it's older React where they're the same thing. + -- We only want to warn if we're sure it's a new React. + if context ~= context.Consumer then + if not hasWarnedAbout.usingContextAsConsumer then + hasWarnedAbout.usingContextAsConsumer = true + console.error( + "Rendering directly is not supported and will be removed in " + .. "a future major release. Did you mean to render instead?" + ) + end + end + else + context = (context :: any)._context + end + end + local newProps = workInProgress.pendingProps + + -- ROBLOX deviation: compatibility for old Roact's context consumer API + local render + if newProps.render then + if __DEV__ and __COMPAT_WARNINGS__ then + if not hasWarnedAbout.usingLegacyConsumer then + hasWarnedAbout.usingLegacyConsumer = true + console.warn( + "Your Context.Consumer component is using legacy Roact syntax, which won't be supported in future versions of Roact. \n" + .. "Please provide no props and supply the 'render' function as a child (the 3rd argument of createElement). For example: \n" + .. " createElement(ContextConsumer, {render = function(...) end})\n" + .. "becomes:\n" + .. " createElement(ContextConsumer, nil, function(...) end)\n" + .. "For more info, reference the React documentation here: \n" + .. "https://reactjs.org/docs/context.html#contextconsumer" + ) + end + end + render = newProps.render + else + render = newProps.children + end + + if __DEV__ then + if type(render) ~= "function" then + console.error( + "A context consumer was rendered with multiple children, or a child " + .. "that isn't a function. A context consumer expects a single child " + .. "that is a function. If you did pass a function, make sure there " + .. "is no trailing or leading whitespace around it." + ) + end + end + + prepareToReadContext( + workInProgress, + renderLanes, + exports.markWorkInProgressReceivedUpdate + ) + local newValue = readContext(context, newProps.unstable_observedBits) + local newChildren + if __DEV__ then + ReactCurrentOwner.current = workInProgress + setIsRendering(true) + newChildren = render(newValue) + setIsRendering(false) + else + newChildren = render(newValue) + end + + -- React DevTools reads this flag. + workInProgress.flags = bit32.bor(workInProgress.flags, PerformedWork) + reconcileChildren(current, workInProgress, newChildren, renderLanes) + return workInProgress.child end -function updateFundamentalComponent(current, workInProgress, renderLanes) - local fundamentalImpl = workInProgress.type.impl - if fundamentalImpl.reconcileChildren == false then - return nil - end - local nextProps = workInProgress.pendingProps - local nextChildren = nextProps.children +-- ROBLOX TODO: fundamental component is removed in React 18, clean up all traces when we upgrade +-- function updateFundamentalComponent(current, workInProgress, renderLanes) +-- local fundamentalImpl = workInProgress.type.impl +-- if fundamentalImpl.reconcileChildren == false then +-- return nil +-- end +-- local nextProps = workInProgress.pendingProps +-- local nextChildren = nextProps.children - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child -end +-- reconcileChildren(current, workInProgress, nextChildren, renderLanes) +-- return workInProgress.child +-- end -function updateScopeComponent(current, workInProgress, renderLanes) - local nextProps = workInProgress.pendingProps - local nextChildren = nextProps.children +-- ROBLOX TODO: scope component is disabled in our FeatureFlags, uncomment when we enable it +-- function updateScopeComponent(current, workInProgress, renderLanes) +-- local nextProps = workInProgress.pendingProps +-- local nextChildren = nextProps.children - reconcileChildren(current, workInProgress, nextChildren, renderLanes) - return workInProgress.child -end +-- reconcileChildren(current, workInProgress, nextChildren, renderLanes) +-- return workInProgress.child +-- end exports.markWorkInProgressReceivedUpdate = function() - didReceiveUpdate = true + didReceiveUpdate = true end -bailoutOnAlreadyFinishedWork = function( - current: Fiber | nil, - workInProgress: Fiber, - renderLanes: Lanes +function bailoutOnAlreadyFinishedWork( + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes ): Fiber | nil - if current then - -- Reuse previous dependencies - workInProgress.dependencies = current.dependencies - end - - if enableProfilerTimer then - -- Don't update "base" render times for bailouts. - stopProfilerTimerIfRunning(workInProgress) - end - - markSkippedUpdateLanes(workInProgress.lanes) - - -- Check if the children have any pending work. - if not ReactFiberLane.includesSomeLane(renderLanes, workInProgress.childLanes) then - -- The children don't have any work either. We can skip them. - -- TODO: Once we add back resuming, we should check if the children are - -- a work-in-progress set. If so, we need to transfer their effects. - return nil - else - -- This fiber doesn't have work, but its subtree does. Clone the child - -- fibers and continue. - cloneChildFibers(current, workInProgress) - return workInProgress.child - end + if current then + -- Reuse previous dependencies + workInProgress.dependencies = current.dependencies + end + + if enableProfilerTimer then + -- Don't update "base" render times for bailouts. + stopProfilerTimerIfRunning(workInProgress) + end + + markSkippedUpdateLanes(workInProgress.lanes) + + -- Check if the children have any pending work. + if not ReactFiberLane.includesSomeLane(renderLanes, workInProgress.childLanes) then + -- The children don't have any work either. We can skip them. + -- TODO: Once we add back resuming, we should check if the children are + -- a work-in-progress set. If so, we need to transfer their effects. + return nil + else + -- This fiber doesn't have work, but its subtree does. Clone the child + -- fibers and continue. + cloneChildFibers(current, workInProgress) + return workInProgress.child + end end function remountFiber( - current: Fiber, - oldWorkInProgress: Fiber, - newWorkInProgress: Fiber + current: Fiber, + oldWorkInProgress: Fiber, + newWorkInProgress: Fiber ): Fiber | nil - if _G.__DEV__ then - local returnFiber = oldWorkInProgress.return_ - if returnFiber == nil then - error('Cannot swap the root fiber.') - end - - -- Disconnect from the old current. - -- It will get deleted. - current.alternate = nil - oldWorkInProgress.alternate = nil - - -- Connect to the new tree. - newWorkInProgress.index = oldWorkInProgress.index - newWorkInProgress.sibling = oldWorkInProgress.sibling - newWorkInProgress.return_ = oldWorkInProgress.return_ - newWorkInProgress.ref = oldWorkInProgress.ref - - -- Replace the child/sibling pointers above it. - if oldWorkInProgress == returnFiber.child then - returnFiber.child = newWorkInProgress - else - local prevSibling = returnFiber.child - if prevSibling == nil then - error('Expected parent to have a child.') - end - while prevSibling.sibling ~= oldWorkInProgress do - prevSibling = prevSibling.sibling - if prevSibling == nil then - error('Expected to find the previous sibling.') - end - end - prevSibling.sibling = newWorkInProgress - end - - -- Delete the old fiber and place the new one. - -- Since the old fiber is disconnected, we have to schedule it manually. - local deletions = returnFiber.deletions - if deletions == nil then - returnFiber.deletions = {current} - -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) - returnFiber.flags = bit32.bor(returnFiber.flags, Deletion) - else - table.insert(deletions, current) - end - - newWorkInProgress.flags = bit32.bor(newWorkInProgress.flags, Placement) - - -- Restart work from the new fiber. - return newWorkInProgress - else - error( - 'Did not expect this call in production. ' .. - 'This is a bug in React. Please file an issue.' - ) - end + if __DEV__ then + local returnFiber = oldWorkInProgress.return_ + if returnFiber == nil then + error("Cannot swap the root fiber.") + end + -- ROBLOX FIXME Luau: remove this assert when Luau type states understands the above guard + assert(returnFiber ~= nil, "returnFiber was nil in remountFiber") + + -- Disconnect from the old current. + -- It will get deleted. + current.alternate = nil + oldWorkInProgress.alternate = nil + + -- Connect to the new tree. + newWorkInProgress.index = oldWorkInProgress.index + newWorkInProgress.sibling = oldWorkInProgress.sibling + newWorkInProgress.return_ = oldWorkInProgress.return_ + newWorkInProgress.ref = oldWorkInProgress.ref + + -- Replace the child/sibling pointers above it. + if oldWorkInProgress == returnFiber.child then + returnFiber.child = newWorkInProgress + else + local prevSibling = returnFiber.child + if prevSibling == nil then + error("Expected parent to have a child.") + end + -- ROBLOX FIXME Luau: remove this assert when Luau type states understands the above guard + assert(prevSibling ~= nil, "prevSibling was nil in remountFiber") + + while prevSibling.sibling ~= oldWorkInProgress do + prevSibling = prevSibling.sibling + if prevSibling == nil then + error("Expected to find the previous sibling.") + end + end + prevSibling.sibling = newWorkInProgress + end + + -- Delete the old fiber and place the new one. + -- Since the old fiber is disconnected, we have to schedule it manually. + local deletions = returnFiber.deletions + if deletions == nil then + returnFiber.deletions = { current } + -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) + returnFiber.flags = bit32.bor(returnFiber.flags, Deletion) + else + table.insert(deletions, current) + end + + newWorkInProgress.flags = bit32.bor(newWorkInProgress.flags, Placement) + + -- Restart work from the new fiber. + return newWorkInProgress + else + error( + "Did not expect this call in production. " + .. "This is a bug in React. Please file an issue." + ) + end end -- FIXME (roblox): restore types when refinement is better: -- current: Fiber | nil, -exports.beginWork = function( - current: any, - workInProgress: Fiber, - renderLanes: Lanes -): Fiber? - local updateLanes = workInProgress.lanes - - if _G.__DEV__ then - if workInProgress._debugNeedsRemount and current ~= nil then - -- This will restart the begin phase with a new fiber. - return remountFiber( - current, - workInProgress, - createFiberFromTypeAndProps( - workInProgress.type, - workInProgress.key, - workInProgress.pendingProps, - workInProgress._debugOwner or nil, - workInProgress.mode, - workInProgress.lanes - ) - ) - end - end - - if current ~= nil then - local oldProps = current.memoizedProps - local newProps = workInProgress.pendingProps - if - oldProps ~= newProps or - hasLegacyContextChanged() or - -- Force a re-render if the implementation changed due to hot reload: - (function() - if _G.__DEV__ then - return workInProgress.type ~= current.type - end - return false - end)() - then - -- If props or context changed, mark the fiber as having performed work. - -- This may be unset if the props are determined to be equal later (memo). - didReceiveUpdate = true - elseif not ReactFiberLane.includesSomeLane(renderLanes, updateLanes) then - didReceiveUpdate = false - -- This fiber does not have any pending work. Bailout without entering - -- the begin phase. There's still some bookkeeping we that needs to be done - -- in this optimized path, mostly pushing stuff onto the stack. - if workInProgress.tag == HostRoot then - pushHostRootContext(workInProgress) - resetHydrationState() - elseif workInProgress.tag == HostComponent then - pushHostContext(workInProgress) - elseif workInProgress.tag == ClassComponent then - local Component = workInProgress.type - if isLegacyContextProvider(Component) then - pushLegacyContextProvider(workInProgress) - end - elseif workInProgress.tag == HostPortal then - pushHostContainer( - workInProgress, - workInProgress.stateNode.containerInfo - ) - elseif workInProgress.tag == ContextProvider then - local newValue = workInProgress.memoizedProps.value - pushProvider(workInProgress, newValue) - elseif workInProgress.tag == Profiler then - if enableProfilerTimer then - -- Reset effect durations for the next eventual effect phase. - -- These are reset during render to allow the DevTools commit hook a chance to read them, - local stateNode = workInProgress.stateNode - stateNode.effectDuration = 0 - stateNode.passiveEffectDuration = 0 - end - elseif workInProgress.tag == SuspenseComponent then - local state: SuspenseState | nil = workInProgress.memoizedState - if state ~= nil then - if enableSuspenseServerRenderer then - if (state :: SuspenseState).dehydrated ~= nil then - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current) - ) - -- We know that this component will suspend again because if it has - -- been unsuspended it has committed as a resolved Suspense component. - -- If it needs to be retried, it should have work scheduled on it. - workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) - -- We should never render the children of a dehydrated boundary until we - -- upgrade it. We return nil instead of bailoutOnAlreadyFinishedWork. - return nil - end - end - - -- If this boundary is currently timed out, we need to decide - -- whether to retry the primary children, or to skip over it and - -- go straight to the fallback. Check the priority of the primary - -- child fragment. - local primaryChildFragment: Fiber = (workInProgress.child :: Fiber) - local primaryChildLanes = primaryChildFragment.childLanes - if ReactFiberLane.includesSomeLane(renderLanes, primaryChildLanes) then - -- The primary children have pending work. Use the normal path - -- to attempt to render the primary children again. - return updateSuspenseComponent( - current, - workInProgress, - renderLanes - ) - else - -- The primary child fragment does not have pending work marked - -- on it - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current) - ) - -- The primary children do not have pending work with sufficient - -- priority. Bailout. - local child = bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderLanes - ) - if child ~= nil then - -- The fallback children have pending work. Skip over the - -- primary children and work on the fallback. - return child.sibling - else - return nil - end - end - else - pushSuspenseContext( - workInProgress, - setDefaultShallowSuspenseContext(suspenseStackCursor.current) - ) - end - elseif workInProgress.tag == SuspenseListComponent then - unimplemented("beginWork: SuspenseListComponent") - -- local didSuspendBefore = bit32.band(current.flags, DidCapture) ~= NoFlags - - -- local hasChildWork = ReactFiberLane.includesSomeLane( - -- renderLanes, - -- workInProgress.childLanes - -- ) - - -- if didSuspendBefore then - -- if hasChildWork then - -- -- If something was in fallback state last time, and we have all the - -- -- same children then we're still in progressive loading state. - -- -- Something might get unblocked by state updates or retries in the - -- -- tree which will affect the tail. So we need to use the normal - -- -- path to compute the correct tail. - -- return updateSuspenseListComponent( - -- current, - -- workInProgress, - -- renderLanes - -- ) - -- end - -- -- If none of the children had any work, that means that none of - -- -- them got retried so they'll still be blocked in the same way - -- -- as before. We can fast bail out. - -- workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) - -- end - - -- -- If nothing suspended before and we're rendering the same children, - -- -- then the tail doesn't matter. Anything new that suspends will work - -- -- in the "together" mode, so we can continue from the state we had. - -- local renderState = workInProgress.memoizedState - -- if renderState ~= nil then - -- -- Reset to the "together" mode in case we've started a different - -- -- update in the past but didn't complete it. - -- renderState.rendering = nil - -- renderState.tail = nil - -- end - -- pushSuspenseContext(workInProgress, suspenseStackCursor.current) - - -- if not hasChildWork then - -- -- If none of the children had any work, that means that none of - -- -- them got retried so they'll still be blocked in the same way - -- -- as before. We can fast bail out. - -- return nil - -- end - elseif - workInProgress.tag == OffscreenComponent or - workInProgress.tag == LegacyHiddenComponent - then - -- Need to check if the tree still needs to be deferred. This is - -- almost identical to the logic used in the normal update path, - -- so we'll just enter that. The only difference is we'll bail out - -- at the next level instead of this one, because the child props - -- have not changed. Which is fine. - -- TODO: Probably should refactor `beginWork` to split the bailout - -- path from the normal path. I'm tempted to do a labeled break here - -- but I won't :) - workInProgress.lanes = ReactFiberLane.NoLanes - return updateOffscreenComponent(current, workInProgress, renderLanes) - end - return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) - else - if bit32.band(current.flags, ForceUpdateForLegacySuspense) ~= NoFlags then - -- This is a special case that only exists for legacy mode. - -- See https://github.com/facebook/react/pull/19216. - didReceiveUpdate = true - else - -- An update was scheduled on this fiber, but there are no new props - -- nor legacy context. Set this to false. If an update queue or context - -- consumer produces a changed value, it will set this to true. Otherwise, - -- the component will assume the children have not changed and bail out. - didReceiveUpdate = false - end - end - else - didReceiveUpdate = false - end - - -- Before entering the begin phase, clear pending update priority. - -- TODO: This assumes that we're about to evaluate the component and process - -- the update queue. However, there's an exception: SimpleMemoComponent - -- sometimes bails out later in the begin phase. This indicates that we should - -- move this assignment out of the common path and into each branch. - workInProgress.lanes = ReactFiberLane.NoLanes - - if workInProgress.tag == IndeterminateComponent then - return mountIndeterminateComponent( - current, - workInProgress, - workInProgress.type, - renderLanes - ) - elseif workInProgress.tag == LazyComponent then - local elementType = workInProgress.elementType - return mountLazyComponent( - current, - workInProgress, - elementType, - updateLanes, - renderLanes - ) - elseif workInProgress.tag == FunctionComponent then - local Component = workInProgress.type - local unresolvedProps = workInProgress.pendingProps - local resolvedProps - if workInProgress.elementType == Component then - resolvedProps = unresolvedProps - else - resolvedProps = resolveDefaultProps(Component, unresolvedProps) - end - return updateFunctionComponent( - current, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - elseif workInProgress.tag == ClassComponent then - local Component = workInProgress.type - local unresolvedProps = workInProgress.pendingProps - local resolvedProps = - workInProgress.elementType == Component - and unresolvedProps - or resolveDefaultProps(Component, unresolvedProps) - return updateClassComponent( - current, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - elseif workInProgress.tag == HostRoot then - return updateHostRoot(current, workInProgress, renderLanes) - elseif workInProgress.tag == HostComponent then - return updateHostComponent(current, workInProgress, renderLanes) - elseif workInProgress.tag == HostText then - return updateHostText(current, workInProgress) - elseif workInProgress.tag == SuspenseComponent then - return updateSuspenseComponent(current, workInProgress, renderLanes) - elseif workInProgress.tag == HostPortal then - return updatePortalComponent(current, workInProgress, renderLanes) - elseif workInProgress.tag == ForwardRef then - local type = workInProgress.type - local unresolvedProps = workInProgress.pendingProps - local resolvedProps = unresolvedProps - if workInProgress.elementType ~= type then - resolvedProps = resolveDefaultProps(type, unresolvedProps) - end - return updateForwardRef( - current, - workInProgress, - type, - resolvedProps, - renderLanes - ) - elseif workInProgress.tag == Fragment then - return updateFragment(current, workInProgress, renderLanes) - elseif workInProgress.tag == Mode then - return updateMode(current, workInProgress, renderLanes) - elseif workInProgress.tag == Profiler then - return updateProfiler(current, workInProgress, renderLanes) - elseif workInProgress.tag == ContextProvider then - return updateContextProvider(current, workInProgress, renderLanes) - elseif workInProgress.tag == ContextConsumer then - return updateContextConsumer(current, workInProgress, renderLanes) - elseif workInProgress.tag == MemoComponent then - local type = workInProgress.type - local unresolvedProps = workInProgress.pendingProps - -- Resolve outer props first, then resolve inner props. - local resolvedProps = resolveDefaultProps(type, unresolvedProps) - if _G.__DEV__ then - if workInProgress.type ~= workInProgress.elementType then - -- 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 - checkPropTypes( - outerPropTypes, - resolvedProps, -- Resolved for outer only - "prop", - getComponentName(type) - ) - end - end - end - resolvedProps = resolveDefaultProps(type.type, resolvedProps) - return updateMemoComponent( - current, - workInProgress, - type, - resolvedProps, - updateLanes, - renderLanes - ) - elseif workInProgress.tag == SimpleMemoComponent then - return updateSimpleMemoComponent( - current, - workInProgress, - workInProgress.type, - workInProgress.pendingProps, - updateLanes, - renderLanes - ) - elseif workInProgress.tag == IncompleteClassComponent then - local Component = workInProgress.type - local unresolvedProps = workInProgress.pendingProps - local resolvedProps = - workInProgress.elementType == Component - and unresolvedProps - or resolveDefaultProps(Component, unresolvedProps) - return mountIncompleteClassComponent( - current, - workInProgress, - Component, - resolvedProps, - renderLanes - ) - elseif workInProgress.tag == SuspenseListComponent then - unimplemented("beginWork: SuspenseListComponent") - -- return updateSuspenseListComponent(current, workInProgress, renderLanes) - elseif workInProgress.tag == FundamentalComponent then - if enableFundamentalAPI then - return updateFundamentalComponent(current, workInProgress, renderLanes) - end - elseif workInProgress.tag == ScopeComponent then - if enableScopeAPI then - return updateScopeComponent(current, workInProgress, renderLanes) - end - elseif workInProgress.tag == OffscreenComponent then - return updateOffscreenComponent(current, workInProgress, renderLanes) - elseif workInProgress.tag == LegacyHiddenComponent then - return updateLegacyHiddenComponent(current, workInProgress, renderLanes) - end - invariant( - false, - "Unknown unit of work tag (%s). This error is likely caused by a bug in " .. - "React. Please file an issue.", - workInProgress.tag - ) - return nil +local function beginWork(current: any, workInProgress: Fiber, renderLanes: Lanes): Fiber? + local updateLanes = workInProgress.lanes + + if __DEV__ then + if workInProgress._debugNeedsRemount and current ~= nil then + -- This will restart the begin phase with a new fiber. + return remountFiber( + current, + workInProgress, + createFiberFromTypeAndProps( + workInProgress.type, + -- ROBLOX FIXME: we widen this to be number|string for Roact compatibility + workInProgress.key :: string?, + workInProgress.pendingProps, + workInProgress._debugOwner or nil, + workInProgress.mode, + workInProgress.lanes + ) + ) + end + end + + if current ~= nil then + local oldProps = current.memoizedProps + local newProps = workInProgress.pendingProps + + if + oldProps ~= newProps + or hasLegacyContextChanged() + -- Force a re-render if the implementation changed due to hot reload: + or if __DEV__ then workInProgress.type ~= current.type else false + then + -- If props or context changed, mark the fiber as having performed work. + -- This may be unset if the props are determined to be equal later (memo). + didReceiveUpdate = true + elseif not ReactFiberLane.includesSomeLane(renderLanes, updateLanes) then + didReceiveUpdate = false + -- This fiber does not have any pending work. Bailout without entering + -- the begin phase. There's still some bookkeeping we that needs to be done + -- in this optimized path, mostly pushing stuff onto the stack. + if workInProgress.tag == HostRoot then + pushHostRootContext(workInProgress) + resetHydrationState() + elseif workInProgress.tag == HostComponent then + pushHostContext(workInProgress) + elseif workInProgress.tag == ClassComponent then + local Component = workInProgress.type + if isLegacyContextProvider(Component) then + pushLegacyContextProvider(workInProgress) + end + elseif workInProgress.tag == HostPortal then + pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo) + elseif workInProgress.tag == ContextProvider then + local newValue = workInProgress.memoizedProps.value + pushProvider(workInProgress, newValue) + elseif workInProgress.tag == Profiler then + if enableProfilerTimer then + -- Reset effect durations for the next eventual effect phase. + -- These are reset during render to allow the DevTools commit hook a chance to read them, + local stateNode = workInProgress.stateNode + stateNode.effectDuration = 0 + stateNode.passiveEffectDuration = 0 + end + elseif workInProgress.tag == SuspenseComponent then + local state: SuspenseState | nil = workInProgress.memoizedState + if state ~= nil then + if enableSuspenseServerRenderer then + if (state :: SuspenseState).dehydrated ~= nil then + pushSuspenseContext( + workInProgress, + setDefaultShallowSuspenseContext( + suspenseStackCursor.current + ) + ) + -- We know that this component will suspend again because if it has + -- been unsuspended it has committed as a resolved Suspense component. + -- If it needs to be retried, it should have work scheduled on it. + workInProgress.flags = + bit32.bor(workInProgress.flags, DidCapture) + -- We should never render the children of a dehydrated boundary until we + -- upgrade it. We return nil instead of bailoutOnAlreadyFinishedWork. + return nil + end + end + + -- If this boundary is currently timed out, we need to decide + -- whether to retry the primary children, or to skip over it and + -- go straight to the fallback. Check the priority of the primary + -- child fragment. + local primaryChildFragment: Fiber = workInProgress.child :: any + local primaryChildLanes = primaryChildFragment.childLanes + if + ReactFiberLane.includesSomeLane(renderLanes, primaryChildLanes) + then + -- The primary children have pending work. Use the normal path + -- to attempt to render the primary children again. + return updateSuspenseComponent( + current, + workInProgress, + renderLanes + ) + else + -- The primary child fragment does not have pending work marked + -- on it + pushSuspenseContext( + workInProgress, + setDefaultShallowSuspenseContext(suspenseStackCursor.current) + ) + -- The primary children do not have pending work with sufficient + -- priority. Bailout. + local child = bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes + ) + if child ~= nil then + -- The fallback children have pending work. Skip over the + -- primary children and work on the fallback. + return child.sibling + else + return nil + end + end + else + pushSuspenseContext( + workInProgress, + setDefaultShallowSuspenseContext(suspenseStackCursor.current) + ) + end + elseif workInProgress.tag == SuspenseListComponent then + unimplemented("beginWork: SuspenseListComponent") + -- local didSuspendBefore = bit32.band(current.flags, DidCapture) ~= NoFlags + + -- local hasChildWork = ReactFiberLane.includesSomeLane( + -- renderLanes, + -- workInProgress.childLanes + -- ) + + -- if didSuspendBefore then + -- if hasChildWork then + -- -- If something was in fallback state last time, and we have all the + -- -- same children then we're still in progressive loading state. + -- -- Something might get unblocked by state updates or retries in the + -- -- tree which will affect the tail. So we need to use the normal + -- -- path to compute the correct tail. + -- return updateSuspenseListComponent( + -- current, + -- workInProgress, + -- renderLanes + -- ) + -- end + -- -- If none of the children had any work, that means that none of + -- -- them got retried so they'll still be blocked in the same way + -- -- as before. We can fast bail out. + -- workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) + -- end + + -- -- If nothing suspended before and we're rendering the same children, + -- -- then the tail doesn't matter. Anything new that suspends will work + -- -- in the "together" mode, so we can continue from the state we had. + -- local renderState = workInProgress.memoizedState + -- if renderState ~= nil then + -- -- Reset to the "together" mode in case we've started a different + -- -- update in the past but didn't complete it. + -- renderState.rendering = nil + -- renderState.tail = nil + -- end + -- pushSuspenseContext(workInProgress, suspenseStackCursor.current) + + -- if not hasChildWork then + -- -- If none of the children had any work, that means that none of + -- -- them got retried so they'll still be blocked in the same way + -- -- as before. We can fast bail out. + -- return nil + -- end + elseif + workInProgress.tag == OffscreenComponent + or workInProgress.tag == LegacyHiddenComponent + then + -- Need to check if the tree still needs to be deferred. This is + -- almost identical to the logic used in the normal update path, + -- so we'll just enter that. The only difference is we'll bail out + -- at the next level instead of this one, because the child props + -- have not changed. Which is fine. + -- TODO: Probably should refactor `beginWork` to split the bailout + -- path from the normal path. I'm tempted to do a labeled break here + -- but I won't :) + workInProgress.lanes = ReactFiberLane.NoLanes + return updateOffscreenComponent(current, workInProgress, renderLanes) + end + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + else + if bit32.band(current.flags, ForceUpdateForLegacySuspense) ~= NoFlags then + -- This is a special case that only exists for legacy mode. + -- See https://github.com/facebook/react/pull/19216. + didReceiveUpdate = true + else + -- An update was scheduled on this fiber, but there are no new props + -- nor legacy context. Set this to false. If an update queue or context + -- consumer produces a changed value, it will set this to true. Otherwise, + -- the component will assume the children have not changed and bail out. + didReceiveUpdate = false + end + end + else + didReceiveUpdate = false + end + + -- Before entering the begin phase, clear pending update priority. + -- TODO: This assumes that we're about to evaluate the component and process + -- the update queue. However, there's an exception: SimpleMemoComponent + -- sometimes bails out later in the begin phase. This indicates that we should + -- move this assignment out of the common path and into each branch. + workInProgress.lanes = ReactFiberLane.NoLanes + + if workInProgress.tag == ReactWorkTags.IndeterminateComponent then + return mountIndeterminateComponent( + current, + workInProgress, + workInProgress.type, + renderLanes + ) + elseif workInProgress.tag == LazyComponent then + local elementType = workInProgress.elementType + return mountLazyComponent( + current, + workInProgress, + elementType, + updateLanes, + renderLanes + ) + elseif workInProgress.tag == FunctionComponent then + local Component = workInProgress.type + local unresolvedProps = workInProgress.pendingProps + local resolvedProps + if workInProgress.elementType == Component then + resolvedProps = unresolvedProps + else + resolvedProps = resolveDefaultProps(Component, unresolvedProps) + end + return updateFunctionComponent( + current, + workInProgress, + Component, + resolvedProps, + renderLanes + ) + elseif workInProgress.tag == ClassComponent then + local Component = workInProgress.type + local unresolvedProps = workInProgress.pendingProps + local resolvedProps = workInProgress.elementType == Component and unresolvedProps + or resolveDefaultProps(Component, unresolvedProps) + return updateClassComponent( + current, + workInProgress, + Component, + resolvedProps, + renderLanes + ) + elseif workInProgress.tag == HostRoot then + return updateHostRoot(current, workInProgress, renderLanes) + elseif workInProgress.tag == HostComponent then + return updateHostComponent(current, workInProgress, renderLanes) + elseif workInProgress.tag == HostText then + return updateHostText(current, workInProgress) + elseif workInProgress.tag == SuspenseComponent then + return updateSuspenseComponent(current, workInProgress, renderLanes) + elseif workInProgress.tag == HostPortal then + return updatePortalComponent(current, workInProgress, renderLanes) + elseif workInProgress.tag == ForwardRef then + local type = workInProgress.type + local unresolvedProps = workInProgress.pendingProps + local resolvedProps = unresolvedProps + if workInProgress.elementType ~= type then + resolvedProps = resolveDefaultProps(type, unresolvedProps) + end + return updateForwardRef(current, workInProgress, type, resolvedProps, renderLanes) + elseif workInProgress.tag == Fragment then + return updateFragment(current, workInProgress, renderLanes) + elseif workInProgress.tag == Mode then + return updateMode(current, workInProgress, renderLanes) + elseif workInProgress.tag == Profiler then + return updateProfiler(current, workInProgress, renderLanes) + elseif workInProgress.tag == ContextProvider then + return updateContextProvider(current, workInProgress, renderLanes) + elseif workInProgress.tag == ContextConsumer then + return updateContextConsumer(current, workInProgress, renderLanes) + elseif workInProgress.tag == MemoComponent then + local type_ = workInProgress.type + local unresolvedProps = workInProgress.pendingProps + -- Resolve outer props first, then resolve inner props. + local resolvedProps = resolveDefaultProps(type_, unresolvedProps) + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ 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 + if type(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_) + ) + end + end + end + resolvedProps = resolveDefaultProps(type_.type, resolvedProps) + return updateMemoComponent( + current, + workInProgress, + type_, + resolvedProps, + updateLanes, + renderLanes + ) + elseif workInProgress.tag == SimpleMemoComponent then + return updateSimpleMemoComponent( + current, + workInProgress, + workInProgress.type, + workInProgress.pendingProps, + updateLanes, + renderLanes + ) + elseif workInProgress.tag == IncompleteClassComponent then + local Component = workInProgress.type + local unresolvedProps = workInProgress.pendingProps + local resolvedProps = workInProgress.elementType == Component and unresolvedProps + or resolveDefaultProps(Component, unresolvedProps) + return mountIncompleteClassComponent( + current, + workInProgress, + Component, + resolvedProps, + renderLanes + ) + -- elseif workInProgress.tag == SuspenseListComponent then + -- unimplemented("beginWork: SuspenseListComponent") + -- return updateSuspenseListComponent(current, workInProgress, renderLanes) + -- elseif workInProgress.tag == FundamentalComponent then + -- if enableFundamentalAPI then + -- return updateFundamentalComponent(current, workInProgress, renderLanes) + -- end + -- elseif workInProgress.tag == ScopeComponent then + -- if enableScopeAPI then + -- return updateScopeComponent(current, workInProgress, renderLanes) + -- end + elseif workInProgress.tag == OffscreenComponent then + return updateOffscreenComponent(current, workInProgress, renderLanes) + elseif workInProgress.tag == LegacyHiddenComponent then + return updateLegacyHiddenComponent(current, workInProgress, renderLanes) + end + invariant( + false, + "Unknown unit of work tag (%s). This error is likely caused by a bug in " + .. "React. Please file an issue.", + tostring(workInProgress.tag) + ) + return nil end +exports.beginWork = beginWork return exports diff --git a/modules/react-reconciler/src/ReactFiberClassComponent.new.lua b/modules/react-reconciler/src/ReactFiberClassComponent.new.lua index 1834ece5..a60529f4 100644 --- a/modules/react-reconciler/src/ReactFiberClassComponent.new.lua +++ b/modules/react-reconciler/src/ReactFiberClassComponent.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/c63741fb3daef6c1e8746cbe7d7b07ecb281a9fd/packages/react-reconciler/src/ReactFiberClassComponent.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/c63741fb3daef6c1e8746cbe7d7b07ecb281a9fd/packages/react-reconciler/src/ReactFiberClassComponent.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,23 +8,14 @@ * * @flow ]] ---!nocheck --- ROBLOX FIXME: remove above nocheck once Luau circular dependencies are resolved - --- 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 Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Object = LuauPolyfill.Object +local __DEV__ = ReactGlobals.__DEV__ :: boolean + -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local ReactInternalTypes = require(script.Parent.ReactInternalTypes) @@ -31,7 +23,10 @@ type Fiber = ReactInternalTypes.Fiber local ReactFiberLane = require(script.Parent.ReactFiberLane) type Lanes = ReactFiberLane.Lanes local ReactUpdateQueue = require(script.Parent["ReactUpdateQueue.new"]) -type UpdateQueue = ReactUpdateQueue.UpdateQueue +type UpdateQueue = ReactInternalTypes.UpdateQueue + +local ReactTypes = require(Packages.Shared) +type React_Component = ReactTypes.React_Component local React = require(Packages.React) @@ -41,7 +36,8 @@ local Snapshot = ReactFiberFlags.Snapshot local MountLayoutDev = ReactFiberFlags.MountLayoutDev local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -local debugRenderPhaseSideEffectsForStrictMode = ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode +local debugRenderPhaseSideEffectsForStrictMode = + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode local disableLegacyContext = ReactFeatureFlags.disableLegacyContext local enableDebugTracing = ReactFeatureFlags.enableDebugTracing local enableSchedulingProfiler = ReactFeatureFlags.enableSchedulingProfiler @@ -55,20 +51,25 @@ local getInstance = ReactInstanceMap.get local setInstance = ReactInstanceMap.set local shallowEqual = require(Packages.Shared).shallowEqual local getComponentName = require(Packages.Shared).getComponentName +local UninitializedState = require(Packages.Shared).UninitializedState +local describeError = require(Packages.Shared).describeError -- local invariant = require(Packages.Shared).invariant local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_CONTEXT_TYPE = ReactSymbols.REACT_CONTEXT_TYPE local REACT_PROVIDER_TYPE = ReactSymbols.REACT_PROVIDER_TYPE -local resolveDefaultProps = require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps +local resolveDefaultProps = + require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps local ReactTypeOfMode = require(script.Parent.ReactTypeOfMode) local DebugTracingMode = ReactTypeOfMode.DebugTracingMode local StrictMode = ReactTypeOfMode.StrictMode local enqueueUpdate = ReactUpdateQueue.enqueueUpdate local processUpdateQueue = ReactUpdateQueue.processUpdateQueue -local checkHasForceUpdateAfterProcessing = ReactUpdateQueue.checkHasForceUpdateAfterProcessing -local resetHasForceUpdateBeforeProcessing = ReactUpdateQueue.resetHasForceUpdateBeforeProcessing +local checkHasForceUpdateAfterProcessing = + ReactUpdateQueue.checkHasForceUpdateAfterProcessing +local resetHasForceUpdateBeforeProcessing = + ReactUpdateQueue.resetHasForceUpdateBeforeProcessing local createUpdate = ReactUpdateQueue.createUpdate local ReplaceState = ReactUpdateQueue.ReplaceState local ForceUpdate = ReactUpdateQueue.ForceUpdate @@ -90,7 +91,9 @@ local readContext = ReactFiberNewContext.readContext -- local requestEventTime = ReactFiberWorkLoop.requestEventTime -- local requestUpdateLane = ReactFiberWorkLoop.requestUpdateLane -- local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber --- local {logForceUpdateScheduled, logStateUpdateScheduled} = require(script.Parent.DebugTracing) +local DebugTracing = require(script.Parent.DebugTracing) +local logForceUpdateScheduled = DebugTracing.logForceUpdateScheduled +local logStateUpdateScheduled = DebugTracing.logStateUpdateScheduled local ConsolePatchingDev = require(Packages.Shared).ConsolePatchingDev local disableLogs = ConsolePatchingDev.disableLogs @@ -107,7 +110,9 @@ local fakeInternalInstance = {} -- React.Component uses a shared frozen object by default. -- We'll use it to determine whether we need to initialize legacy refs. -local emptyRefsObject = React.Component:extend("").refs +-- ROBLOX deviation: Uses __refs instead of refs to avoid conflicts +-- local emptyRefsObject = React.Component:extend("").refs +local emptyRefsObject = React.Component:extend("").__refs local didWarnAboutStateAssignmentForComponent local didWarnAboutUninitializedState @@ -120,1258 +125,1309 @@ local didWarnAboutDirectlyAssigningPropsToState local didWarnAboutContextTypeAndContextTypes local didWarnAboutInvalidateContextType -if _G.__DEV__ then - didWarnAboutStateAssignmentForComponent = {} - didWarnAboutUninitializedState = {} - didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate = {} - didWarnAboutLegacyLifecyclesAndDerivedState = {} - didWarnAboutDirectlyAssigningPropsToState = {} - _didWarnAboutUndefinedDerivedState = {} - didWarnAboutContextTypeAndContextTypes = {} - didWarnAboutInvalidateContextType = {} - - local didWarnOnInvalidCallback = {} - - warnOnInvalidCallback = function(callback: any, callerName: string) - if callback == nil or typeof(callback) == "function" then - return - end - local key = callerName .. '_' .. tostring(callback) - if not didWarnOnInvalidCallback[key] then - didWarnOnInvalidCallback[key] = true - console.error( - "%s(...): Expected the last optional `callback` argument to be a " .. - "function. Instead received: %s.", - callerName, - callback - ) - end - end - - warnOnUndefinedDerivedState = function(type_, partialState) - -- deviation: `nil` is a valid return for getDerivedStateFromProps, but - -- `undefined` is not possible for us to return; we could try to detect - -- returning zero values, but that's likely not possible without tracking it - -- differently at the original callsite (e.g. the value we save to - -- `partialState` would still be nil) - - -- if partialState == nil then - -- local componentName = getComponentName(type_) or "Component" - -- if not didWarnAboutUndefinedDerivedState[componentName] then - -- didWarnAboutUndefinedDerivedState[componentName] = true - -- console.error( - -- "%s.getDerivedStateFromProps(): A valid state object (or nil) must be returned. " .. - -- "You have returned undefined.", - -- componentName - -- ) - -- end - -- end - end - --- -- ROBLOX FIXME: I'm not sure this applies, need to revisit it --- -- -- This is so gross but it's at least non-critical and can be removed if --- -- -- it causes problems. This is meant to give a nicer error message for --- -- -- ReactDOM15.unstable_renderSubtreeIntoContainer(reactDOM16Component, --- -- -- ...)) which otherwise throws a "_processChildContext is not a function" --- -- -- exception. --- -- Object.defineProperty(fakeInternalInstance, '_processChildContext', { --- -- enumerable: false, --- -- value: function() --- -- invariant( --- -- false, --- -- '_processChildContext is not available in React 16+. This likely ' + --- -- 'means you have multiple copies of React and are attempting to nest ' + --- -- 'a React 15 tree inside a React 16 tree using ' + --- -- "unstable_renderSubtreeIntoContainer, which isn't supported. Try " + --- -- 'to make sure you have only one copy of React (and ideally, switch ' + --- -- 'to ReactDOM.createPortal).', --- -- ) --- -- }, --- -- }) --- Object.freeze(fakeInternalInstance) +if __DEV__ then + didWarnAboutStateAssignmentForComponent = {} + didWarnAboutUninitializedState = {} + didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate = {} + didWarnAboutLegacyLifecyclesAndDerivedState = {} + didWarnAboutDirectlyAssigningPropsToState = {} + _didWarnAboutUndefinedDerivedState = {} + didWarnAboutContextTypeAndContextTypes = {} + didWarnAboutInvalidateContextType = {} + + local didWarnOnInvalidCallback = {} + + function warnOnInvalidCallback(callback: any, callerName: string) + if callback == nil or type(callback) == "function" then + return + end + local key = callerName .. "_" .. tostring(callback) + if not didWarnOnInvalidCallback[key] then + didWarnOnInvalidCallback[key] = true + console.error( + "%s(...): Expected the last optional `callback` argument to be a " + .. "function. Instead received: %s.", + callerName, + tostring(callback) + ) + end + end + + function warnOnUndefinedDerivedState(type_, partialState) + -- ROBLOX deviation: `nil` is a valid return for getDerivedStateFromProps, but + -- `undefined` is not possible for us to return; we could try to detect + -- returning zero values, but that's likely not possible without tracking it + -- differently at the original callsite (e.g. the value we save to + -- `partialState` would still be nil) + + -- if partialState == nil then + -- local componentName = getComponentName(type_) or "Component" + -- if not didWarnAboutUndefinedDerivedState[componentName] then + -- didWarnAboutUndefinedDerivedState[componentName] = true + -- console.error( + -- "%s.getDerivedStateFromProps(): A valid state object (or nil) must be returned. " .. + -- "You have returned undefined.", + -- componentName + -- ) + -- end + -- end + end + + -- -- ROBLOX FIXME: I'm not sure this applies, need to revisit it + -- -- -- This is so gross but it's at least non-critical and can be removed if + -- -- -- it causes problems. This is meant to give a nicer error message for + -- -- -- ReactDOM15.unstable_renderSubtreeIntoContainer(reactDOM16Component, + -- -- -- ...)) which otherwise throws a "_processChildContext is not a function" + -- -- -- exception. + -- -- Object.defineProperty(fakeInternalInstance, '_processChildContext', { + -- -- enumerable: false, + -- -- value: function() + -- -- invariant( + -- -- false, + -- -- '_processChildContext is not available in React 16+. This likely ' + + -- -- 'means you have multiple copies of React and are attempting to nest ' + + -- -- 'a React 15 tree inside a React 16 tree using ' + + -- -- "unstable_renderSubtreeIntoContainer, which isn't supported. Try " + + -- -- 'to make sure you have only one copy of React (and ideally, switch ' + + -- -- 'to ReactDOM.createPortal).', + -- -- ) + -- -- }, + -- -- }) + -- Object.freeze(fakeInternalInstance) end -local function applyDerivedStateFromProps( - workInProgress: Fiber, - ctor: any, - getDerivedStateFromProps: (any, any) -> any, - nextProps: any +local function applyDerivedStateFromProps( + workInProgress: Fiber, + ctor: React_Component, + getDerivedStateFromProps: (Props, State) -> State?, + nextProps: Props ) - local prevState = workInProgress.memoizedState - - if _G.__DEV__ then - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - -- Invoke the function an extra time to help detect side-effects. - getDerivedStateFromProps(nextProps, prevState) - end) - - reenableLogs() - - if not ok then - error(result) - end - end - end - - local partialState = getDerivedStateFromProps(nextProps, prevState) - - if _G.__DEV__ then - warnOnUndefinedDerivedState(ctor, partialState) - end - -- Merge the partial state and the previous state. - local memoizedState = partialState == nil - and prevState - or Object.assign({}, prevState, partialState) - workInProgress.memoizedState = memoizedState - - -- Once the update queue is empty, persist the derived state onto the - -- base state. - if workInProgress.lanes == NoLanes then - -- Queue is always non-null for classes - -- ROBLOX FIXME: type coercion - -- local updateQueue: UpdateQueue = (workInProgress.updateQueue: any) - local updateQueue: UpdateQueue = workInProgress.updateQueue - updateQueue.baseState = memoizedState - end + local prevState = workInProgress.memoizedState + + if __DEV__ then + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + -- Invoke the function an extra time to help detect side-effects. + local ok, result = + xpcall(getDerivedStateFromProps, describeError, nextProps, prevState) + + reenableLogs() + + if not ok then + error(result) + end + end + end + + local partialState = getDerivedStateFromProps(nextProps, prevState) + + if __DEV__ then + warnOnUndefinedDerivedState(ctor, partialState) + end + -- Merge the partial state and the previous state. + local memoizedState = if partialState == nil + then prevState + else Object.assign({}, prevState, partialState) + workInProgress.memoizedState = memoizedState + + -- Once the update queue is empty, persist the derived state onto the + -- base state. + if workInProgress.lanes == NoLanes then + -- Queue is always non-null for classes + local updateQueue: UpdateQueue = workInProgress.updateQueue + updateQueue.baseState = memoizedState + end end -- deviation: lazy initialize this to avoid cycles local classComponentUpdater = nil local function initializeClassComponentUpdater() - local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) - local requestEventTime = ReactFiberWorkLoop.requestEventTime - local requestUpdateLane = ReactFiberWorkLoop.requestUpdateLane - local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber - - classComponentUpdater = { - isMounted = isMounted, - enqueueSetState = function(inst, payload, callback) - local fiber = getInstance(inst) - local eventTime = requestEventTime() - local lane = requestUpdateLane(fiber) - - local update = createUpdate(eventTime, lane) - update.payload = payload - if callback ~= nil then - if _G.__DEV__ then - warnOnInvalidCallback(callback, "setState") - end - update.callback = callback - end - - enqueueUpdate(fiber, update) - scheduleUpdateOnFiber(fiber, lane, eventTime) - - if _G.__DEV__ then - if enableDebugTracing then - if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then - local _name = getComponentName(fiber.type) or "Unknown" - unimplemented("logStateUpdateScheduled") - -- logStateUpdateScheduled(name, lane, payload) - end - end - end - - if enableSchedulingProfiler then - markStateUpdateScheduled(fiber, lane) - end - end, - enqueueReplaceState = function(inst, payload, callback) - local fiber = getInstance(inst) - local eventTime = requestEventTime() - local lane = requestUpdateLane(fiber) - - local update = createUpdate(eventTime, lane) - update.tag = ReplaceState - update.payload = payload - - if callback ~= nil then - if _G.__DEV__ then - warnOnInvalidCallback(callback, "replaceState") - end - update.callback = callback - end - - enqueueUpdate(fiber, update) - scheduleUpdateOnFiber(fiber, lane, eventTime) - - if _G.__DEV__ then - if enableDebugTracing then - if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then - local _name = getComponentName(fiber.type) or "Unknown" - warn("Skip unimplemented: logStateUpdateScheduled") - -- logStateUpdateScheduled(name, lane, payload) - end - end - end - - if enableSchedulingProfiler then - markStateUpdateScheduled(fiber, lane) - end - end, - enqueueForceUpdate = function(inst, callback) - local fiber = getInstance(inst) - local eventTime = requestEventTime() - local lane = requestUpdateLane(fiber) - - local update = createUpdate(eventTime, lane) - update.tag = ForceUpdate - - if callback ~= nil then - if _G.__DEV__ then - warnOnInvalidCallback(callback, "forceUpdate") - end - update.callback = callback - end - - enqueueUpdate(fiber, update) - scheduleUpdateOnFiber(fiber, lane, eventTime) - - if _G.__DEV__ then - if enableDebugTracing then - if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then - local _name = getComponentName(fiber.type) or "Unknown" - warn("Skip unimplemented: logStateUpdateScheduled") - -- logForceUpdateScheduled(name, lane) - end - end - end - - if enableSchedulingProfiler then - markForceUpdateScheduled(fiber, lane) - end - end, - } + local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) + local requestEventTime = ReactFiberWorkLoop.requestEventTime + local requestUpdateLane = ReactFiberWorkLoop.requestUpdateLane + local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber + + classComponentUpdater = { + isMounted = isMounted, + enqueueSetState = function(inst, payload, callback: (() -> ...any)?) + local fiber = getInstance(inst) + local eventTime = requestEventTime() + local lane = requestUpdateLane(fiber) + + local update = createUpdate(eventTime, lane, payload, callback) + -- update.payload = payload + if callback ~= nil then + if __DEV__ then + warnOnInvalidCallback(callback, "setState") + end + -- update.callback = callback + end + + enqueueUpdate(fiber, update) + scheduleUpdateOnFiber(fiber, lane, eventTime) + + if __DEV__ then + if enableDebugTracing then + if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then + local name = getComponentName(fiber.type) or "Unknown" + logStateUpdateScheduled(name, lane, payload) + end + end + end + + if enableSchedulingProfiler then + markStateUpdateScheduled(fiber, lane) + end + end, + enqueueReplaceState = function(inst, payload, callback) + local fiber = getInstance(inst) + local eventTime = requestEventTime() + local lane = requestUpdateLane(fiber) + + local update = createUpdate(eventTime, lane, payload, callback) + update.tag = ReplaceState + -- update.payload = payload + + if callback ~= nil then + if __DEV__ then + warnOnInvalidCallback(callback, "replaceState") + end + -- update.callback = callback + end + + enqueueUpdate(fiber, update) + scheduleUpdateOnFiber(fiber, lane, eventTime) + + if __DEV__ then + if enableDebugTracing then + if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then + local name = getComponentName(fiber.type) or "Unknown" + logStateUpdateScheduled(name, lane, payload) + end + end + end + + if enableSchedulingProfiler then + markStateUpdateScheduled(fiber, lane) + end + end, + enqueueForceUpdate = function(inst, callback) + local fiber = getInstance(inst) + local eventTime = requestEventTime() + local lane = requestUpdateLane(fiber) + + local update = createUpdate(eventTime, lane, nil, callback) + update.tag = ForceUpdate + + if callback ~= nil then + if __DEV__ then + warnOnInvalidCallback(callback, "forceUpdate") + end + -- update.callback = callback + end + + enqueueUpdate(fiber, update) + scheduleUpdateOnFiber(fiber, lane, eventTime) + + if __DEV__ then + if enableDebugTracing then + if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then + local name = getComponentName(fiber.type) or "Unknown" + logForceUpdateScheduled(name, lane) + end + end + end + + if enableSchedulingProfiler then + markForceUpdateScheduled(fiber, lane) + end + end, + } end local function getClassComponentUpdater() - if classComponentUpdater == nil then - initializeClassComponentUpdater() - end - return classComponentUpdater + if classComponentUpdater == nil then + initializeClassComponentUpdater() + end + return classComponentUpdater end function checkShouldComponentUpdate( - workInProgress, - ctor, - oldProps, - newProps, - oldState, - newState, - nextContext + workInProgress, + ctor, + oldProps, + newProps, + oldState, + newState, + nextContext ) - local instance = workInProgress.stateNode - if typeof(instance.shouldComponentUpdate) == "function" then - if _G.__DEV__ then - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - -- deviation: Call with ":" so that the method receives self - -- Invoke the function an extra time to help detect side-effects. - instance:shouldComponentUpdate(newProps, newState, nextContext) - end) - -- finally - reenableLogs() - if not ok then - error(result) - end - end - end - -- deviation: Call with ":" so that the method receives self - local shouldUpdate = instance:shouldComponentUpdate( - newProps, - newState, - nextContext - ) - - if _G.__DEV__ then - if shouldUpdate == nil then - console.error( - "%s.shouldComponentUpdate(): Returned nil instead of a " .. - "boolean value. Make sure to return true or false.", - getComponentName(ctor) or "Component" - ) - end - end - - return shouldUpdate - end - - -- deviation: for us, the isPureReactComponent flag will be visible as a - -- direct member of the 'ctor', which in reality is the component definition - if ctor.isPureReactComponent then - return ( - not shallowEqual(oldProps, newProps) or not shallowEqual(oldState, newState) - ) - end - - return true + local instance = workInProgress.stateNode + if + instance.shouldComponentUpdate ~= nil + and type(instance.shouldComponentUpdate) == "function" + then + if __DEV__ then + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + -- deviation: Pass instance so that the method receives self + -- Invoke the function an extra time to help detect side-effects. + local ok, result = xpcall( + instance.shouldComponentUpdate, + describeError, + instance, + newProps, + newState, + nextContext + ) + -- finally + reenableLogs() + if not ok then + error(result) + end + end + end + -- deviation: Call with ":" so that the method receives self + local shouldUpdate = + instance:shouldComponentUpdate(newProps, newState, nextContext) + + if __DEV__ then + if shouldUpdate == nil then + console.error( + "%s.shouldComponentUpdate(): Returned nil instead of a " + .. "boolean value. Make sure to return true or false.", + getComponentName(ctor) or "Component" + ) + end + end + + return shouldUpdate + end + + -- ROBLOX deviation: for us, the isPureReactComponent flag will be visible as a + -- direct member of the 'ctor', which in reality is the component definition + if type(ctor) == "table" and ctor.isPureReactComponent then + return ( + not shallowEqual(oldProps, newProps) or not shallowEqual(oldState, newState) + ) + end + + return true end local function checkClassInstance(workInProgress: Fiber, ctor: any, newProps: any) - local instance = workInProgress.stateNode - if _G.__DEV__ then - local name = getComponentName(ctor) or "Component" - local renderPresent = instance.render - - if not renderPresent then - -- ROBLOX deviation: for us, the render function will be visible as a direct - -- member of the 'ctor', which in reality is the component definition - if typeof(ctor.render) == "function" then - console.error( - "%s(...): No `render` method found on the returned component " .. - "instance: did you accidentally return an object from the constructor?", - name - ) - else - console.error( - "%s(...): No `render` method found on the returned component " .. - "instance: you may have forgotten to define `render`.", - name - ) - end - end - - if - instance.getInitialState and - not instance.getInitialState.isReactClassApproved and - not instance.state - then - console.error( - "getInitialState was defined on %s, a plain JavaScript class. " .. - "This is only supported for classes created using React.createClass. " .. - "Did you mean to define a state property instead?", - name - ) - end - if - instance.getDefaultProps and - not instance.getDefaultProps.isReactClassApproved - then - console.error( - "getDefaultProps was defined on %s, a plain JavaScript class. " .. - "This is only supported for classes created using React.createClass. " .. - "Use a static property to define defaultProps instead.", - name - ) - end - -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention - if instance.propTypes and not ctor.propTypes then - console.error( - "propTypes was defined as an instance property on %s. Use a static " .. - "property to define propTypes instead.", - name - ) - end - -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention - if instance.contextType and not ctor.contextType then - console.error( - "contextType was defined as an instance property on %s. Use a static " .. - "property to define contextType instead.", - name - ) - end - - if disableLegacyContext then - if ctor.childContextTypes then - console.error( - "%s uses the legacy childContextTypes API which is no longer supported. " .. - "Use React.createContext() instead.", - name - ) - end - if ctor.contextTypes then - console.error( - "%s uses the legacy contextTypes API which is no longer supported. " .. - "Use React.createContext() with static contextType instead.", - name - ) - end - else - -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention - if instance.contextTypes and not ctor.contextTypes then - console.error( - "contextTypes was defined as an instance property on %s. Use a static " .. - "property to define contextTypes instead.", - name - ) - end - - -- ROBLOX deviation: don't access fields on a function - if - typeof(ctor) ~= "function" and - ctor.contextType and - ctor.contextTypes and - not didWarnAboutContextTypeAndContextTypes[ctor] - then - didWarnAboutContextTypeAndContextTypes[ctor] = true - console.error( - "%s declares both contextTypes and contextType static properties. " .. - "The legacy contextTypes property will be ignored.", - name - ) - end - end - - if typeof(instance.componentShouldUpdate) == "function" then - console.error( - "%s has a method called " .. - "componentShouldUpdate(). Did you mean shouldComponentUpdate()? " .. - "The name is phrased as a question because the function is " .. - "expected to return a value.", - name - ) - end - -- ROBLOX deviation: don't access fields on a function - if - typeof(ctor) ~= "function" and - ctor.isPureReactComponent and - instance.shouldComponentUpdate ~= nil - then - console.error( - "%s has a method called shouldComponentUpdate(). " .. - "shouldComponentUpdate should not be used when extending React.PureComponent. " .. - "Please extend React.Component if shouldComponentUpdate is used.", - getComponentName(ctor) or "A pure component" - ) - end - if typeof(instance.componentDidUnmount) == "function" then - console.error( - "%s has a method called " .. - "componentDidUnmount(). But there is no such lifecycle method. " .. - "Did you mean componentWillUnmount()?", - name - ) - end - if typeof(instance.componentDidReceiveProps) == "function" then - console.error( - "%s has a method called " .. - "componentDidReceiveProps(). But there is no such lifecycle method. " .. - "If you meant to update the state in response to changing props, " .. - "use componentWillReceiveProps(). If you meant to fetch data or " .. - "run side-effects or mutations after React has updated the UI, use componentDidUpdate().", - name - ) - end - if typeof(instance.componentWillRecieveProps) == "function" then - console.error( - "%s has a method called " .. - "componentWillRecieveProps(). Did you mean componentWillReceiveProps()?", - name - ) - end - if typeof(instance.UNSAFE_componentWillRecieveProps) == "function" then - console.error( - "%s has a method called " .. - "UNSAFE_componentWillRecieveProps(). Did you mean UNSAFE_componentWillReceiveProps()?", - name - ) - end - local hasMutatedProps = instance.props ~= newProps - if instance.props ~= nil and hasMutatedProps then - console.error( - "%s(...): When calling super() in `%s`, make sure to pass " .. - "up the same props that your component's constructor was passed.", - name, - name - ) - end - if rawget(instance, "defaultProps") then - console.error( - "Setting defaultProps as an instance property on %s is not supported and will be ignored." .. - " Instead, define defaultProps as a static property on %s.", - name, - name - ) - end - - if - typeof(instance.getSnapshotBeforeUpdate) == "function" and - typeof(instance.componentDidUpdate) ~= "function" and - not didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate[ctor] - then - didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate[ctor] = true - console.error( - "%s: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " .. - "This component defines getSnapshotBeforeUpdate() only.", - getComponentName(ctor) - ) - end - - -- deviation: We don't actually have a way to detect this in Lua!! - -- if typeof(instance.getDerivedStateFromProps) == "function" then - -- console.error( - -- "%s: getDerivedStateFromProps() is defined as an instance method " .. - -- "and will be ignored. Instead, declare it as a static method.", - -- name - -- ) - -- end - -- deviation: We don't actually have a way to detect this in Lua!! - -- if typeof(instance.getDerivedStateFromError) == "function" then - -- console.error( - -- "%s: getDerivedStateFromError() is defined as an instance method " .. - -- "and will be ignored. Instead, declare it as a static method.", - -- name - -- ) - -- end - -- deviation: We don't actually have a way to detect this in Lua!! - -- if typeof(ctor.getSnapshotBeforeUpdate) == "function" then - -- console.error( - -- "%s: getSnapshotBeforeUpdate() is defined as a static method " .. - -- "and will be ignored. Instead, declare it as an instance method.", - -- name - -- ) - -- end - local state = instance.state - -- deviation: It's not useful for us to try to distinguish an array from an - -- object in this case - -- if state and (typeof(state) ~= "table" or Array.isArray(state)) then - if state ~= nil and typeof(state) ~= "table" then - console.error("%s.state: must be set to an object or nil", name) - end - -- ROBLOX deviation: don't access fields on a function - if - typeof(ctor) ~= "function" and - typeof(instance.getChildContext) == "function" and - typeof(ctor.childContextTypes) ~= "table" - then - console.error( - "%s.getChildContext(): childContextTypes must be defined in order to " .. - "use getChildContext().", - name - ) - end - end + local instance = workInProgress.stateNode + if __DEV__ then + local name = getComponentName(ctor) or "Component" + local renderPresent = instance.render + + if not renderPresent then + -- ROBLOX deviation: for us, the render function will be visible as a direct + -- member of the 'ctor', which in reality is the component definition + if type(ctor.render) == "function" then + console.error( + "%s(...): No `render` method found on the returned component " + .. "instance: did you accidentally return an object from the constructor?", + name + ) + else + console.error( + "%s(...): No `render` method found on the returned component " + .. "instance: you may have forgotten to define `render`.", + name + ) + end + end + + if + instance.getInitialState + and not instance.getInitialState.isReactClassApproved + and not instance.state + then + console.error( + "getInitialState was defined on %s, a plain JavaScript class. " + .. "This is only supported for classes created using React.createClass. " + .. "Did you mean to define a state property instead?", + name + ) + end + if + instance.getDefaultProps + and not instance.getDefaultProps.isReactClassApproved + then + console.error( + "getDefaultProps was defined on %s, a plain JavaScript class. " + .. "This is only supported for classes created using React.createClass. " + .. "Use a static property to define defaultProps instead.", + name + ) + end + -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention + if instance.propTypes and not ctor.propTypes then + console.error( + "propTypes was defined as an instance property on %s. Use a static " + .. "property to define propTypes instead.", + name + ) + end + -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention + if instance.contextType and not ctor.contextType then + console.error( + "contextType was defined as an instance property on %s. Use a static " + .. "property to define contextType instead.", + name + ) + end + + if disableLegacyContext then + if ctor.childContextTypes then + console.error( + "%s uses the legacy childContextTypes API which is no longer supported. " + .. "Use React.createContext() instead.", + name + ) + end + if ctor.contextTypes then + console.error( + "%s uses the legacy contextTypes API which is no longer supported. " + .. "Use React.createContext() with static contextType instead.", + name + ) + end + else + -- ROBLOX TODO? the original check causes false positives, this adjustment should live up to the intention + if instance.contextTypes and not ctor.contextTypes then + console.error( + "contextTypes was defined as an instance property on %s. Use a static " + .. "property to define contextTypes instead.", + name + ) + end + + -- ROBLOX deviation: don't access fields on a function + if + type(ctor) == "table" + and ctor.contextType + and ctor.contextTypes + and not didWarnAboutContextTypeAndContextTypes[ctor] + then + didWarnAboutContextTypeAndContextTypes[ctor] = true + console.error( + "%s declares both contextTypes and contextType static properties. " + .. "The legacy contextTypes property will be ignored.", + name + ) + end + end + + if type(instance.componentShouldUpdate) == "function" then + console.error( + "%s has a method called " + .. "componentShouldUpdate(). Did you mean shouldComponentUpdate()? " + .. "The name is phrased as a question because the function is " + .. "expected to return a value.", + name + ) + end + -- ROBLOX deviation: don't access fields on a function + if + type(ctor) == "table" + and ctor.isPureReactComponent + and instance.shouldComponentUpdate ~= nil + then + console.error( + "%s has a method called shouldComponentUpdate(). " + .. "shouldComponentUpdate should not be used when extending React.PureComponent. " + .. "Please extend React.Component if shouldComponentUpdate is used.", + getComponentName(ctor) or "A pure component" + ) + end + if type(instance.componentDidUnmount) == "function" then + console.error( + "%s has a method called " + .. "componentDidUnmount(). But there is no such lifecycle method. " + .. "Did you mean componentWillUnmount()?", + name + ) + end + if type(instance.componentDidReceiveProps) == "function" then + console.error( + "%s has a method called " + .. "componentDidReceiveProps(). But there is no such lifecycle method. " + .. "If you meant to update the state in response to changing props, " + .. "use componentWillReceiveProps(). If you meant to fetch data or " + .. "run side-effects or mutations after React has updated the UI, use componentDidUpdate().", + name + ) + end + if type(instance.componentWillRecieveProps) == "function" then + console.error( + "%s has a method called " + .. "componentWillRecieveProps(). Did you mean componentWillReceiveProps()?", + name + ) + end + if type(instance.UNSAFE_componentWillRecieveProps) == "function" then + console.error( + "%s has a method called " + .. "UNSAFE_componentWillRecieveProps(). Did you mean UNSAFE_componentWillReceiveProps()?", + name + ) + end + local hasMutatedProps = instance.props ~= newProps + if instance.props ~= nil and hasMutatedProps then + console.error( + "%s(...): When calling super() in `%s`, make sure to pass " + .. "up the same props that your component's constructor was passed.", + name, + name + ) + end + if rawget(instance, "defaultProps") then + console.error( + "Setting defaultProps as an instance property on %s is not supported and will be ignored." + .. " Instead, define defaultProps as a static property on %s.", + name, + name + ) + end + + if + type(instance.getSnapshotBeforeUpdate) == "function" + and type(instance.componentDidUpdate) ~= "function" + and not didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate[ctor] + then + didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate[ctor] = true + console.error( + "%s: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " + .. "This component defines getSnapshotBeforeUpdate() only.", + getComponentName(ctor) + ) + end + + -- ROBLOX TODO: get function arity to see if it takes >0 arguments. if it takes 1, assume it's self, and warn + -- if type(instance.getDerivedStateFromProps) == "function" then + -- console.error( + -- "%s: getDerivedStateFromProps() is defined as an instance method " .. + -- "and will be ignored. Instead, declare it as a static method.", + -- name + -- ) + -- end + -- if type(instance.getDerivedStateFromError) == "function" then + -- console.error( + -- "%s: getDerivedStateFromError() is defined as an instance method " .. + -- "and will be ignored. Instead, declare it as a static method.", + -- name + -- ) + -- end + -- if type(ctor.getSnapshotBeforeUpdate) == "function" then + -- console.error( + -- "%s: getSnapshotBeforeUpdate() is defined as a static method " .. + -- "and will be ignored. Instead, declare it as an instance method.", + -- name + -- ) + -- end + local state = instance.state + -- deviation: It's not useful for us to try to distinguish an array from an + -- object in this case + -- if state and (type(state) ~= "table" or Array.isArray(state)) then + if state ~= nil and type(state) ~= "table" then + console.error("%s.state: must be set to an object or nil", name) + end + -- ROBLOX deviation: don't access fields on a function + if + type(ctor) == "table" + and type(instance.getChildContext) == "function" + and type(ctor.childContextTypes) ~= "table" + then + console.error( + "%s.getChildContext(): childContextTypes must be defined in order to " + .. "use getChildContext().", + name + ) + end + end end local function adoptClassInstance(workInProgress: Fiber, instance: any) - instance.updater = getClassComponentUpdater() - workInProgress.stateNode = instance - -- The instance needs access to the fiber so that it can schedule updates - setInstance(instance, workInProgress) - if _G.__DEV__ then - instance._reactInternalInstance = fakeInternalInstance - end + -- ROBLOX performance? it looks like this lazy init is a perf problem in tab switching hot path + instance.__updater = getClassComponentUpdater() + workInProgress.stateNode = instance + -- The instance needs access to the fiber so that it can schedule updates + setInstance(instance, workInProgress) + if __DEV__ then + instance._reactInternalInstance = fakeInternalInstance + end end -local function constructClassInstance( - workInProgress: Fiber, - ctor: any, - props: any -): any - local isLegacyContextConsumer = false - local unmaskedContext = emptyContextObject - local context = emptyContextObject - local contextType = ctor.contextType - - if _G.__DEV__ then - -- deviation: `ctor` is actually a table, in our case; use normal indexing - if ctor["contextType"] ~= nil then - -- ROBLOX TODO: Double-check this boolean for accuracy - local isValid = - -- Allow nil for conditional declaration - contextType == nil or - (contextType["$$typeof"] == REACT_CONTEXT_TYPE and - contextType._context == nil) -- Not a - - if not isValid and not didWarnAboutInvalidateContextType[ctor] then - didWarnAboutInvalidateContextType[ctor] = true - - local addendum = "" - if contextType == nil then - addendum = - " However, it is set to undefined. " .. - "This can be caused by a typo or by mixing up named and default imports. " .. - "This can also happen due to a circular dependency, so " .. - "try moving the createContext() call to a separate file." - elseif typeof(contextType) ~= "table" then - addendum = " However, it is set to a " .. typeof(contextType) .. "." - elseif contextType["$$typeof"] == REACT_PROVIDER_TYPE then - addendum = " Did you accidentally pass the Context.Provider instead?" - elseif contextType._context ~= nil then - -- - addendum = " Did you accidentally pass the Context.Consumer instead?" - else - addendum ..= " However, it is set to an object with keys {" - for key, _ in pairs(contextType) do - addendum ..= key .. ", " - end - addendum ..= "}." - end - console.error( - "%s defines an invalid contextType. " .. - "contextType should point to the Context object returned by React.createContext().%s", - getComponentName(ctor) or "Component", - addendum - ) - end - end - end - - if typeof(contextType) == "table" and contextType ~= nil then - context = readContext(contextType) - elseif not disableLegacyContext then - unmaskedContext = getUnmaskedContext(workInProgress, ctor, true) - local contextTypes = ctor.contextTypes - isLegacyContextConsumer = contextTypes ~= nil - context = isLegacyContextConsumer - and getMaskedContext(workInProgress, unmaskedContext) - or emptyContextObject - end - - -- Instantiate twice to help detect side-effects. - if _G.__DEV__ then - if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 - then - disableLogs() - local ok, result = pcall(function() - -- deviation: ctor will actually refer to a class component, we use the - -- `__ctor` function that it exposes - ctor.__ctor(props, context) -- eslint-disable-line no-new - end) - -- finally - reenableLogs() - - if not ok then - error(result) - end - end - end - - -- deviation: ctor will actually refer to a class component, we use the - -- `__ctor` function that it exposes - local instance = ctor.__ctor(props, context) - -- deviation: no need to worry about undefined - -- local state = (workInProgress.memoizedState = - -- instance.state ~= nil and instance.state ~= undefined - -- ? instance.state - -- : nil) - workInProgress.memoizedState = instance.state - local state = workInProgress.memoizedState - adoptClassInstance(workInProgress, instance) - - if _G.__DEV__ then - if typeof(ctor.getDerivedStateFromProps) == "function" and state == nil then - local componentName = getComponentName(ctor) or "Component" - if not didWarnAboutUninitializedState[componentName] then - didWarnAboutUninitializedState[componentName] = true - -- deviation: message adjusted for accuracy with Lua "class" components - console.error( - "`%s` uses `getDerivedStateFromProps` but its initial state is " .. - "%s. This is not recommended. Instead, define the initial state by " .. - "assigning an object to `self.state` in the `init` method of `%s`. " .. - "This ensures that `getDerivedStateFromProps` arguments have a consistent shape.", - componentName, - -- deviation: no need to worry about undefined - -- instance.state == nil and 'nil' or 'undefined', - "nil", - componentName - ) - end - end - - -- If new component APIs are defined, "unsafe" lifecycles won't be called. - -- Warn about these lifecycles if they are present. - -- Don't warn about react-lifecycles-compat polyfilled methods though. - if - typeof(ctor.getDerivedStateFromProps) == "function" or - typeof(instance.getSnapshotBeforeUpdate) == "function" - then - local foundWillMountName = nil - local foundWillReceivePropsName = nil - local foundWillUpdateName = nil - if - -- ROBLOX FIXME: This won't work! Lua functions can't have properties - typeof(instance.componentWillMount) == "function" -- and - -- instance.componentWillMount.__suppressDeprecationWarning ~= true - then - foundWillMountName = "componentWillMount" - elseif typeof(instance.UNSAFE_componentWillMount) == "function" then - foundWillMountName = "UNSAFE_componentWillMount" - end - if - -- ROBLOX FIXME: This won't work! Lua functions can't have properties - typeof(instance.componentWillReceiveProps) == "function" -- and - -- instance.componentWillReceiveProps.__suppressDeprecationWarning ~= true - then - foundWillReceivePropsName = "componentWillReceiveProps" - elseif - typeof(instance.UNSAFE_componentWillReceiveProps) == "function" - then - foundWillReceivePropsName = "UNSAFE_componentWillReceiveProps" - end - if - -- ROBLOX FIXME: This won't work! Lua functions can't have properties - typeof(instance.componentWillUpdate) == "function" -- and - -- instance.componentWillUpdate.__suppressDeprecationWarning ~= true - then - foundWillUpdateName = "componentWillUpdate" - elseif typeof(instance.UNSAFE_componentWillUpdate) == "function" then - foundWillUpdateName = "UNSAFE_componentWillUpdate" - end - if - foundWillMountName ~= nil or - foundWillReceivePropsName ~= nil or - foundWillUpdateName ~= nil - then - local componentName = getComponentName(ctor) or "Component" - local newApiName - if typeof(ctor.getDerivedStateFromProps) == "function" then - newApiName = "getDerivedStateFromProps()" - else - newApiName = "getSnapshotBeforeUpdate()" - end - - local willMountName - if foundWillMountName ~= nil then - willMountName = ("\n " .. tostring(foundWillMountName)) - else - willMountName = "" - end - - local willReceievePropsName - if foundWillReceivePropsName ~= nil then - willReceievePropsName = ("\n " .. tostring(foundWillReceivePropsName)) - else - willReceievePropsName = "" - end - - local willUpdateName - if foundWillUpdateName ~= nil then - willUpdateName = "\n " .. tostring(foundWillUpdateName) - else - willUpdateName = "" - end - - if not didWarnAboutLegacyLifecyclesAndDerivedState[componentName] then - didWarnAboutLegacyLifecyclesAndDerivedState[componentName] = true - console.error( - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "%s uses %s but also contains the following legacy lifecycles:%s%s%s\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - componentName, - newApiName, - willMountName, - willReceievePropsName, - willUpdateName - ) - end - end - end - end - - -- Cache unmasked context so we can avoid recreating masked context unless necessary. - -- ReactFiberContext usually updates this cache but can't for newly-created instances. - if isLegacyContextConsumer then - cacheContext(workInProgress, unmaskedContext, context) - end - - return instance +local function constructClassInstance(workInProgress: Fiber, ctor: any, props: any): any + local isLegacyContextConsumer = false + local unmaskedContext = emptyContextObject + local context = emptyContextObject + local contextType = ctor.contextType + + if __DEV__ then + -- deviation: `ctor` is actually a table, in our case; use normal indexing + if ctor["contextType"] ~= nil then + -- ROBLOX TODO: Double-check this boolean for accuracy + local isValid = + -- Allow nil for conditional declaration + contextType == nil or (contextType["$$typeof"] == REACT_CONTEXT_TYPE and contextType._context == nil) -- Not a + + if not isValid and not didWarnAboutInvalidateContextType[ctor] then + didWarnAboutInvalidateContextType[ctor] = true + + local addendum = "" + if contextType == nil then + addendum = + -- ROBLOX deviation: s/undefined/nil + " However, it is set to nil. " .. "This can be caused by a typo or by mixing up named and default imports. " .. "This can also happen due to a circular dependency, so " .. "try moving the createContext() call to a separate file." + elseif type(contextType) ~= "table" then + addendum = " However, it is set to a " .. type(contextType) .. "." + elseif contextType["$$typeof"] == REACT_PROVIDER_TYPE then + addendum = " Did you accidentally pass the Context.Provider instead?" + elseif contextType._context ~= nil then + -- + addendum = " Did you accidentally pass the Context.Consumer instead?" + else + addendum ..= " However, it is set to an object with keys {" + for key, _ in contextType do + addendum ..= key .. ", " + end + addendum ..= "}." + end + console.error( + "%s defines an invalid contextType. " + .. "contextType should point to the Context object returned by React.createContext().%s", + getComponentName(ctor) or "Component", + addendum + ) + end + end + end + + -- ROBLOX performance: check for nil first to avoid typeof when possible + if contextType ~= nil and type(contextType) == "table" then + context = readContext(contextType) + elseif not disableLegacyContext then + unmaskedContext = getUnmaskedContext(workInProgress, ctor, true) + local contextTypes = ctor.contextTypes + isLegacyContextConsumer = contextTypes ~= nil + context = isLegacyContextConsumer + and getMaskedContext(workInProgress, unmaskedContext) + or emptyContextObject + end + + -- Instantiate twice to help detect side-effects. + if __DEV__ then + if + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 + then + disableLogs() + -- deviation: ctor will actually refer to a class component, we use the + -- `__ctor` function that it exposes + local ok, result = xpcall(ctor.__ctor, describeError, props, context) -- eslint-disable-line no-new + -- finally + reenableLogs() + + if not ok then + error(result) + end + end + end + + -- deviation: ctor will actually refer to a class component, we use the + -- `__ctor` function that it exposes + local instance = ctor.__ctor(props, context) + -- deviation: no need to worry about undefined + -- local state = (workInProgress.memoizedState = + -- instance.state ~= nil and instance.state ~= undefined + -- ? instance.state + -- : nil) + workInProgress.memoizedState = instance.state + local state = workInProgress.memoizedState + adoptClassInstance(workInProgress, instance) + + if __DEV__ then + -- ROBLOX deviation: Instead of checking if state is nil, we check if it is our + -- UninitializedState singleton. + if + type(ctor.getDerivedStateFromProps) == "function" + and state == UninitializedState + then + local componentName = getComponentName(ctor) or "Component" + if not didWarnAboutUninitializedState[componentName] then + didWarnAboutUninitializedState[componentName] = true + -- ROBLOX deviation: message adjusted for accuracy with Lua "class" components + console.error( + "`%s` uses `getDerivedStateFromProps` but its initial state has not been initialized. " + .. "This is not recommended. Instead, define the initial state by " + .. "passing an object to `self:setState` in the `init` method of `%s`. " + .. "This ensures that `getDerivedStateFromProps` arguments have a consistent shape.", + componentName, + -- deviation: no need to worry about undefined + -- instance.state == nil and 'nil' or 'undefined', + componentName + ) + end + end + + -- If new component APIs are defined, "unsafe" lifecycles won't be called. + -- Warn about these lifecycles if they are present. + -- Don't warn about react-lifecycles-compat polyfilled methods though. + if + type(ctor.getDerivedStateFromProps) == "function" + or type(instance.getSnapshotBeforeUpdate) == "function" + then + local foundWillMountName = nil + local foundWillReceivePropsName = nil + local foundWillUpdateName = nil + if + -- ROBLOX FIXME: This won't work! Lua functions can't have properties + type(instance.componentWillMount) == "function" -- and + -- instance.componentWillMount.__suppressDeprecationWarning ~= true + then + foundWillMountName = "componentWillMount" + elseif type(instance.UNSAFE_componentWillMount) == "function" then + foundWillMountName = "UNSAFE_componentWillMount" + end + if + -- ROBLOX FIXME: This won't work! Lua functions can't have properties + type(instance.componentWillReceiveProps) == "function" -- and + -- instance.componentWillReceiveProps.__suppressDeprecationWarning ~= true + then + foundWillReceivePropsName = "componentWillReceiveProps" + elseif type(instance.UNSAFE_componentWillReceiveProps) == "function" then + foundWillReceivePropsName = "UNSAFE_componentWillReceiveProps" + end + if + -- ROBLOX FIXME: This won't work! Lua functions can't have properties + type(instance.componentWillUpdate) == "function" -- and + -- instance.componentWillUpdate.__suppressDeprecationWarning ~= true + then + foundWillUpdateName = "componentWillUpdate" + elseif type(instance.UNSAFE_componentWillUpdate) == "function" then + foundWillUpdateName = "UNSAFE_componentWillUpdate" + end + if + foundWillMountName ~= nil + or foundWillReceivePropsName ~= nil + or foundWillUpdateName ~= nil + then + local componentName = getComponentName(ctor) or "Component" + local newApiName + if type(ctor.getDerivedStateFromProps) == "function" then + newApiName = "getDerivedStateFromProps()" + else + newApiName = "getSnapshotBeforeUpdate()" + end + + local willMountName + if foundWillMountName ~= nil then + willMountName = ("\n " .. tostring(foundWillMountName)) + else + willMountName = "" + end + + local willReceievePropsName + if foundWillReceivePropsName ~= nil then + willReceievePropsName = ( + "\n " .. tostring(foundWillReceivePropsName) + ) + else + willReceievePropsName = "" + end + + local willUpdateName + if foundWillUpdateName ~= nil then + willUpdateName = "\n " .. tostring(foundWillUpdateName) + else + willUpdateName = "" + end + + if not didWarnAboutLegacyLifecyclesAndDerivedState[componentName] then + didWarnAboutLegacyLifecyclesAndDerivedState[componentName] = true + console.error( + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "%s uses %s but also contains the following legacy lifecycles:%s%s%s\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + componentName, + newApiName, + willMountName, + willReceievePropsName, + willUpdateName + ) + end + end + end + end + + -- Cache unmasked context so we can avoid recreating masked context unless necessary. + -- ReactFiberContext usually updates this cache but can't for newly-created instances. + if isLegacyContextConsumer then + cacheContext(workInProgress, unmaskedContext, context) + end + + return instance end local function callComponentWillMount(workInProgress, instance) - local oldState = instance.state - - if typeof(instance.componentWillMount) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:componentWillMount() - end - -- ROBLOX TODO: Should we really run both of these? - if typeof(instance.UNSAFE_componentWillMount) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:UNSAFE_componentWillMount() - end - - if oldState ~= instance.state then - if _G.__DEV__ then - console.error( - "%s.componentWillMount(): Assigning directly to this.state is " .. - "deprecated (except inside a component's " .. - "constructor). Use setState instead.", - getComponentName(workInProgress.type) or "Component" - ) - end - getClassComponentUpdater().enqueueReplaceState(instance, instance.state, nil) - end + local oldState = instance.state + + if + instance.componentWillMount ~= nil + and type(instance.componentWillMount) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:componentWillMount() + end + -- ROBLOX TODO: Should we really run both of these? + if + instance.UNSAFE_componentWillMount ~= nil + and type(instance.UNSAFE_componentWillMount) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:UNSAFE_componentWillMount() + end + + if oldState ~= instance.state then + if __DEV__ then + console.error( + "%s.componentWillMount(): Assigning directly to this.state is " + .. "deprecated (except inside a component's " + .. "constructor). Use setState instead.", + getComponentName(workInProgress.type) or "Component" + ) + end + getClassComponentUpdater().enqueueReplaceState(instance, instance.state) + end end -function callComponentWillReceiveProps( - workInProgress, - instance, - newProps, - nextContext -) - local oldState = instance.state - if typeof(instance.componentWillReceiveProps) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:componentWillReceiveProps(newProps, nextContext) - end - if typeof(instance.UNSAFE_componentWillReceiveProps) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:UNSAFE_componentWillReceiveProps(newProps, nextContext) - end - - if instance.state ~= oldState then - if _G.__DEV__ then - local componentName = - getComponentName(workInProgress.type) or "Component" - if not didWarnAboutStateAssignmentForComponent[componentName] then - didWarnAboutStateAssignmentForComponent[componentName] = true - console.error( - "%s.componentWillReceiveProps(): Assigning directly to " .. - "this.state is deprecated (except inside a component's " .. - "constructor). Use setState instead.", - componentName - ) - end - end - getClassComponentUpdater().enqueueReplaceState(instance, instance.state, nil) - end +function callComponentWillReceiveProps(workInProgress, instance, newProps, nextContext) + local oldState = instance.state + if + instance.componentWillReceiveProps ~= nil + and type(instance.componentWillReceiveProps) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:componentWillReceiveProps(newProps, nextContext) + end + if + instance.UNSAFE_componentWillReceiveProps ~= nil + and type(instance.UNSAFE_componentWillReceiveProps) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:UNSAFE_componentWillReceiveProps(newProps, nextContext) + end + + if instance.state ~= oldState then + if __DEV__ then + local componentName = getComponentName(workInProgress.type) or "Component" + if not didWarnAboutStateAssignmentForComponent[componentName] then + didWarnAboutStateAssignmentForComponent[componentName] = true + console.error( + "%s.componentWillReceiveProps(): Assigning directly to " + .. "this.state is deprecated (except inside a component's " + .. "constructor). Use setState instead.", + componentName + ) + end + end + getClassComponentUpdater().enqueueReplaceState(instance, instance.state) + end end -- Invokes the mount life-cycles on a previously never rendered instance. local function mountClassInstance( - workInProgress: Fiber, - ctor: any, - newProps: any, - renderLanes: Lanes + workInProgress: Fiber, + ctor: any, + newProps: any, + renderLanes: Lanes ) - if _G.__DEV__ then - checkClassInstance(workInProgress, ctor, newProps) - end - - local instance = workInProgress.stateNode - instance.props = newProps - instance.state = workInProgress.memoizedState - instance.refs = emptyRefsObject - - initializeUpdateQueue(workInProgress) - - -- RObLOX deviation: don't access field on a function - local contextType - if typeof(ctor) ~= "function" then - contextType = ctor.contextType - end - if typeof(contextType) == "table" and contextType ~= nil then - instance.context = readContext(contextType) - elseif disableLegacyContext then - instance.context = emptyContextObject - else - local unmaskedContext = getUnmaskedContext(workInProgress, ctor, true) - instance.context = getMaskedContext(workInProgress, unmaskedContext) - end - - if _G.__DEV__ then - if instance.state == newProps then - local componentName = getComponentName(ctor) or "Component" - if not didWarnAboutDirectlyAssigningPropsToState[componentName] then - didWarnAboutDirectlyAssigningPropsToState[componentName] = true - console.error( - "%s: It is not recommended to assign props directly to state " .. - "because updates to props won't be reflected in state. " .. - "In most cases, it is better to use props directly.", - componentName - ) - end - end - - if bit32.band(workInProgress.mode, StrictMode) ~= 0 then - ReactStrictModeWarnings.recordLegacyContextWarning( - workInProgress, - instance - ) - end - - if warnAboutDeprecatedLifecycles then - ReactStrictModeWarnings.recordUnsafeLifecycleWarnings( - workInProgress, - instance - ) - end - end - - processUpdateQueue(workInProgress, newProps, instance, renderLanes) - instance.state = workInProgress.memoizedState - - -- ROBLOX deviation: don't access field on a function - local getDerivedStateFromProps - if typeof(ctor) ~= "function" then - getDerivedStateFromProps = ctor.getDerivedStateFromProps - end - if typeof(getDerivedStateFromProps) == "function" then - applyDerivedStateFromProps( - workInProgress, - ctor, - getDerivedStateFromProps, - newProps - ) - instance.state = workInProgress.memoizedState - end - - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - -- ROBLOX deviation: don't access fields on a function - if - typeof(ctor) ~= "function" and - typeof(ctor.getDerivedStateFromProps) ~= "function" and - typeof(instance.getSnapshotBeforeUpdate) ~= "function" and - (typeof(instance.UNSAFE_componentWillMount) == "function" or - typeof(instance.componentWillMount) == "function") - then - callComponentWillMount(workInProgress, instance) - -- If we had additional state updates during this life-cycle, let's - -- process them now. - processUpdateQueue(workInProgress, newProps, instance, renderLanes) - instance.state = workInProgress.memoizedState - end - - if typeof(instance.componentDidMount) == "function" then - if _G.__DEV__ and enableDoubleInvokingEffects then - workInProgress.flags = bit32.bor(workInProgress.flags, bit32.bor(MountLayoutDev, Update)) - else - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end + if __DEV__ then + checkClassInstance(workInProgress, ctor, newProps) + end + + local instance = workInProgress.stateNode + instance.props = newProps + instance.state = workInProgress.memoizedState + -- ROBLOX deviation: Uses __refs instead of refs to avoid conflicts + -- instance.refs = emptyRefsObject + instance.__refs = emptyRefsObject + + initializeUpdateQueue(workInProgress) + + -- ROBLOX deviation: don't access field on a function + local contextType + if type(ctor) == "table" then + contextType = ctor.contextType + end + -- ROBLOX deviation: nil check first so we don't call typeof() unnecessarily + if contextType ~= nil and type(contextType) == "table" then + instance.context = readContext(contextType) + elseif disableLegacyContext then + instance.context = emptyContextObject + else + local unmaskedContext = getUnmaskedContext(workInProgress, ctor, true) + instance.context = getMaskedContext(workInProgress, unmaskedContext) + end + + if __DEV__ then + if instance.state == newProps then + local componentName = getComponentName(ctor) or "Component" + if not didWarnAboutDirectlyAssigningPropsToState[componentName] then + didWarnAboutDirectlyAssigningPropsToState[componentName] = true + console.error( + "%s: It is not recommended to assign props directly to state " + .. "because updates to props won't be reflected in state. " + .. "In most cases, it is better to use props directly.", + componentName + ) + end + end + + if bit32.band(workInProgress.mode, StrictMode) ~= 0 then + ReactStrictModeWarnings.recordLegacyContextWarning(workInProgress, instance) + end + + if warnAboutDeprecatedLifecycles then + ReactStrictModeWarnings.recordUnsafeLifecycleWarnings( + workInProgress, + instance + ) + end + end + + processUpdateQueue(workInProgress, newProps, instance, renderLanes) + instance.state = workInProgress.memoizedState + + -- ROBLOX deviation START: don't access field on a function, cache typeofCtor + local typeofCtor = type(ctor) + local getDerivedStateFromProps + if type(ctor) == "table" then + getDerivedStateFromProps = ctor.getDerivedStateFromProps + end + if + getDerivedStateFromProps ~= nil + and type(getDerivedStateFromProps) == "function" + then + applyDerivedStateFromProps( + workInProgress, + ctor, + getDerivedStateFromProps, + newProps + ) + instance.state = workInProgress.memoizedState + end + + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + -- ROBLOX deviation: don't access fields on a function + if + typeofCtor == "table" + and type(ctor.getDerivedStateFromProps) ~= "function" + and type(instance.getSnapshotBeforeUpdate) ~= "function" + and ( + type(instance.UNSAFE_componentWillMount) == "function" + or type(instance.componentWillMount) == "function" + ) + then + callComponentWillMount(workInProgress, instance) + -- If we had additional state updates during this life-cycle, let's + -- process them now. + processUpdateQueue(workInProgress, newProps, instance, renderLanes) + instance.state = workInProgress.memoizedState + end + + if type(instance.componentDidMount) == "function" then + if __DEV__ and enableDoubleInvokingEffects then + workInProgress.flags = + bit32.bor(workInProgress.flags, bit32.bor(MountLayoutDev, Update)) + else + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end end function resumeMountClassInstance( - workInProgress: Fiber, - ctor: any, - newProps: any, - renderLanes: Lanes + workInProgress: Fiber, + ctor: any, + newProps: any, + renderLanes: Lanes ): boolean - local instance = workInProgress.stateNode - - local oldProps = workInProgress.memoizedProps - instance.props = oldProps - - local oldContext = instance.context - local contextType = ctor.contextType - local nextContext = emptyContextObject - if typeof(contextType) == 'table' and contextType ~= nil then - nextContext = readContext(contextType) - elseif not disableLegacyContext then - local nextLegacyUnmaskedContext = getUnmaskedContext( - workInProgress, - ctor, - true - ) - nextContext = getMaskedContext(workInProgress, nextLegacyUnmaskedContext) - end - - local getDerivedStateFromProps = ctor.getDerivedStateFromProps - local hasNewLifecycles = - typeof(getDerivedStateFromProps) == 'function' or - typeof(instance.getSnapshotBeforeUpdate) == 'function' - - -- Note: During these life-cycles, instance.props/instance.state are what - -- ever the previously attempted to render - not the "current". However, - -- during componentDidUpdate we pass the "current" props. - - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - if - not hasNewLifecycles and - (typeof(instance.UNSAFE_componentWillReceiveProps) == 'function' or - typeof(instance.componentWillReceiveProps) == 'function') then - if oldProps ~= newProps or oldContext ~= nextContext then - callComponentWillReceiveProps( - workInProgress, - instance, - newProps, - nextContext - ) - end - end - - resetHasForceUpdateBeforeProcessing() - - local oldState = workInProgress.memoizedState - instance.state = oldState - local newState = oldState - processUpdateQueue(workInProgress, newProps, instance, renderLanes) - newState = workInProgress.memoizedState - if - oldProps == newProps and - oldState == newState and - not hasContextChanged() and - not checkHasForceUpdateAfterProcessing() - then - -- If an update was already in progress, we should schedule an Update - -- effect even though we're bailing out, so that cWU/cDU are called. - if typeof(instance.componentDidMount) == 'function' then - if _G.__DEV__ and enableDoubleInvokingEffects then - workInProgress.flags = bit32.bor(workInProgress.flags, MountLayoutDev, Update) - else - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - return false - end - - if typeof(getDerivedStateFromProps) == 'function' then - applyDerivedStateFromProps( - workInProgress, - ctor, - getDerivedStateFromProps, - newProps - ) - newState = workInProgress.memoizedState - end - - local shouldUpdate = - checkHasForceUpdateAfterProcessing() or - checkShouldComponentUpdate( - workInProgress, - ctor, - oldProps, - newProps, - oldState, - newState, - nextContext - ) - - 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 - not hasNewLifecycles and - (typeof(instance.UNSAFE_componentWillMount) == 'function' or - typeof(instance.componentWillMount) == 'function') - then - if typeof(instance.componentWillMount) == 'function' then - instance:componentWillMount() - end - if typeof(instance.UNSAFE_componentWillMount) == 'function' then - instance:UNSAFE_componentWillMount() - end - end - if typeof(instance.componentDidMount) == 'function' then - if _G.__DEV__ and enableDoubleInvokingEffects then - workInProgress.flags = bit32.bor(workInProgress.flags, MountLayoutDev, Update) - else - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - else - -- If an update was already in progress, we should schedule an Update - -- effect even though we're bailing out, so that cWU/cDU are called. - if typeof(instance.componentDidMount) == 'function' then - if _G.__DEV__ and enableDoubleInvokingEffects then - workInProgress.flags = bit32.bor(workInProgress.flags, MountLayoutDev, Update) - else - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - - -- If shouldComponentUpdate returned false, we should still update the - -- memoized state to indicate that this work can be reused. - workInProgress.memoizedProps = newProps - workInProgress.memoizedState = newState - end - - -- Update the existing instance's state, props, and context pointers even - -- if shouldComponentUpdate returns false. - instance.props = newProps - instance.state = newState - instance.context = nextContext - - return shouldUpdate + local instance = workInProgress.stateNode + + local oldProps = workInProgress.memoizedProps + instance.props = oldProps + + local oldContext = instance.context + local contextType = ctor.contextType + local nextContext = emptyContextObject + + -- ROBLOX performance: check for nil first to avoid typeof when possible + if contextType ~= nil and type(contextType) == "table" then + nextContext = readContext(contextType) + elseif not disableLegacyContext then + local nextLegacyUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true) + nextContext = getMaskedContext(workInProgress, nextLegacyUnmaskedContext) + end + + local getDerivedStateFromProps = ctor.getDerivedStateFromProps + local hasNewLifecycles = type(getDerivedStateFromProps) == "function" + or type(instance.getSnapshotBeforeUpdate) == "function" + + -- Note: During these life-cycles, instance.props/instance.state are what + -- ever the previously attempted to render - not the "current". However, + -- during componentDidUpdate we pass the "current" props. + + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + if + not hasNewLifecycles + and ( + type(instance.UNSAFE_componentWillReceiveProps) == "function" + or type(instance.componentWillReceiveProps) == "function" + ) + then + if oldProps ~= newProps or oldContext ~= nextContext then + callComponentWillReceiveProps(workInProgress, instance, newProps, nextContext) + end + end + + resetHasForceUpdateBeforeProcessing() + + local oldState = workInProgress.memoizedState + instance.state = oldState + local newState = oldState + processUpdateQueue(workInProgress, newProps, instance, renderLanes) + newState = workInProgress.memoizedState + if + oldProps == newProps + and oldState == newState + and not hasContextChanged() + and not checkHasForceUpdateAfterProcessing() + then + -- If an update was already in progress, we should schedule an Update + -- effect even though we're bailing out, so that cWU/cDU are called. + if type(instance.componentDidMount) == "function" then + if __DEV__ and enableDoubleInvokingEffects then + workInProgress.flags = + bit32.bor(workInProgress.flags, MountLayoutDev, Update) + else + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + return false + end + + if + getDerivedStateFromProps ~= nil + and type(getDerivedStateFromProps) == "function" + then + applyDerivedStateFromProps( + workInProgress, + ctor, + getDerivedStateFromProps, + newProps + ) + newState = workInProgress.memoizedState + end + + local shouldUpdate = checkHasForceUpdateAfterProcessing() + or checkShouldComponentUpdate( + workInProgress, + ctor, + oldProps, + newProps, + oldState, + newState, + nextContext + ) + + 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 + not hasNewLifecycles + and ( + type(instance.UNSAFE_componentWillMount) == "function" + or type(instance.componentWillMount) == "function" + ) + then + if type(instance.componentWillMount) == "function" then + instance:componentWillMount() + end + if type(instance.UNSAFE_componentWillMount) == "function" then + instance:UNSAFE_componentWillMount() + end + end + if type(instance.componentDidMount) == "function" then + if __DEV__ and enableDoubleInvokingEffects then + workInProgress.flags = + bit32.bor(workInProgress.flags, MountLayoutDev, Update) + else + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + else + -- If an update was already in progress, we should schedule an Update + -- effect even though we're bailing out, so that cWU/cDU are called. + if type(instance.componentDidMount) == "function" then + if __DEV__ and enableDoubleInvokingEffects then + workInProgress.flags = + bit32.bor(workInProgress.flags, MountLayoutDev, Update) + else + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + + -- If shouldComponentUpdate returned false, we should still update the + -- memoized state to indicate that this work can be reused. + workInProgress.memoizedProps = newProps + workInProgress.memoizedState = newState + end + + -- Update the existing instance's state, props, and context pointers even + -- if shouldComponentUpdate returns false. + instance.props = newProps + instance.state = newState + instance.context = nextContext + + return shouldUpdate end -- Invokes the update life-cycles and returns false if it shouldn't rerender. local function updateClassInstance( - current: Fiber, - workInProgress: Fiber, - ctor: any, - newProps: any, - renderLanes: Lanes + current: Fiber, + workInProgress: Fiber, + ctor: any, + newProps: any, + renderLanes: Lanes ): boolean - local instance = workInProgress.stateNode - - cloneUpdateQueue(current, workInProgress) - - local unresolvedOldProps = workInProgress.memoizedProps - local oldProps = - workInProgress.type == workInProgress.elementType - and unresolvedOldProps - or resolveDefaultProps(workInProgress.type, unresolvedOldProps) - instance.props = oldProps - local unresolvedNewProps = workInProgress.pendingProps - - local oldContext = instance.context - local contextType = ctor.contextType - local nextContext = emptyContextObject - if typeof(contextType) == "table" then - nextContext = readContext(contextType) - elseif not disableLegacyContext then - local nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true) - nextContext = getMaskedContext(workInProgress, nextUnmaskedContext) - end - - local getDerivedStateFromProps = ctor.getDerivedStateFromProps - local hasNewLifecycles = - typeof(getDerivedStateFromProps) == "function" or - typeof(instance.getSnapshotBeforeUpdate) == "function" - - -- Note: During these life-cycles, instance.props/instance.state are what - -- ever the previously attempted to render - not the "current". However, - -- during componentDidUpdate we pass the "current" props. - - -- In order to support react-lifecycles-compat polyfilled components, - -- Unsafe lifecycles should not be invoked for components using the new APIs. - if - not hasNewLifecycles and - (typeof(instance.UNSAFE_componentWillReceiveProps) == "function" or - typeof(instance.componentWillReceiveProps) == "function") - then - if - unresolvedOldProps ~= unresolvedNewProps or - oldContext ~= nextContext - then - callComponentWillReceiveProps( - workInProgress, - instance, - newProps, - nextContext - ) - end - end - - resetHasForceUpdateBeforeProcessing() - - local oldState = workInProgress.memoizedState - instance.state = oldState - local newState = instance.state - processUpdateQueue(workInProgress, newProps, instance, renderLanes) - newState = workInProgress.memoizedState - - if - unresolvedOldProps == unresolvedNewProps and - oldState == newState and - not hasContextChanged() and - not checkHasForceUpdateAfterProcessing() - then - -- If an update was already in progress, we should schedule an Update - -- effect even though we're bailing out, so that cWU/cDU are called. - if typeof(instance.componentDidUpdate) == "function" then - if - unresolvedOldProps ~= current.memoizedProps or - oldState ~= current.memoizedState - then - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - if typeof(instance.getSnapshotBeforeUpdate) == "function" then - if - unresolvedOldProps ~= current.memoizedProps or - oldState ~= current.memoizedState - then - workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) - end - end - return false - end - - if typeof(getDerivedStateFromProps) == "function" then - applyDerivedStateFromProps( - workInProgress, - ctor, - getDerivedStateFromProps, - newProps - ) - newState = workInProgress.memoizedState - end - - local shouldUpdate = - checkHasForceUpdateAfterProcessing() or - checkShouldComponentUpdate( - workInProgress, - ctor, - oldProps, - newProps, - oldState, - newState, - nextContext - ) - - 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 - not hasNewLifecycles and - (typeof(instance.UNSAFE_componentWillUpdate) == "function" or - typeof(instance.componentWillUpdate) == "function") - then - if typeof(instance.componentWillUpdate) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:componentWillUpdate(newProps, newState, nextContext) - end - if typeof(instance.UNSAFE_componentWillUpdate) == "function" then - -- deviation: Call with ":" so that the method receives self - instance:UNSAFE_componentWillUpdate(newProps, newState, nextContext) - end - end - if typeof(instance.componentDidUpdate) == "function" then - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - if typeof(instance.getSnapshotBeforeUpdate) == "function" then - workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) - end - else - -- If an update was already in progress, we should schedule an Update - -- effect even though we're bailing out, so that cWU/cDU are called. - if typeof(instance.componentDidUpdate) == "function" then - if - unresolvedOldProps ~= current.memoizedProps or - oldState ~= current.memoizedState - then - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - if typeof(instance.getSnapshotBeforeUpdate) == "function" then - if - unresolvedOldProps ~= current.memoizedProps or - oldState ~= current.memoizedState - then - workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) - end - end - - -- If shouldComponentUpdate returned false, we should still update the - -- memoized props/state to indicate that this work can be reused. - workInProgress.memoizedProps = newProps - workInProgress.memoizedState = newState - end - - -- Update the existing instance's state, props, and context pointers even - -- if shouldComponentUpdate returns false. - instance.props = newProps - instance.state = newState - instance.context = nextContext - - return shouldUpdate + local instance = workInProgress.stateNode + + cloneUpdateQueue(current, workInProgress) + + local unresolvedOldProps = workInProgress.memoizedProps + local oldProps = if workInProgress.type == workInProgress.elementType + then unresolvedOldProps + else resolveDefaultProps(workInProgress.type, unresolvedOldProps) + instance.props = oldProps + local unresolvedNewProps = workInProgress.pendingProps + + local oldContext = instance.context + local contextType + local getDerivedStateFromProps + -- ROBLOX deviation: don't access fields on a function + if type(ctor) == "table" then + contextType = ctor.contextType + getDerivedStateFromProps = ctor.getDerivedStateFromProps + end + local nextContext = emptyContextObject + if type(contextType) == "table" then + nextContext = readContext(contextType) + elseif not disableLegacyContext then + local nextUnmaskedContext = getUnmaskedContext(workInProgress, ctor, true) + nextContext = getMaskedContext(workInProgress, nextUnmaskedContext) + end + + local hasNewLifecycles = ( + getDerivedStateFromProps ~= nil + and type(getDerivedStateFromProps) == "function" + ) + or ( + instance.getSnapshotBeforeUpdate ~= nil + and type(instance.getSnapshotBeforeUpdate) == "function" + ) + + -- Note: During these life-cycles, instance.props/instance.state are what + -- ever the previously attempted to render - not the "current". However, + -- during componentDidUpdate we pass the "current" props. + + -- In order to support react-lifecycles-compat polyfilled components, + -- Unsafe lifecycles should not be invoked for components using the new APIs. + if + not hasNewLifecycles + and ( + ( + instance.UNSAFE_componentWillReceiveProps ~= nil + and type(instance.UNSAFE_componentWillReceiveProps) == "function" + ) + or ( + instance.componentWillReceiveProps ~= nil + and type(instance.componentWillReceiveProps) == "function" + ) + ) + then + if unresolvedOldProps ~= unresolvedNewProps or oldContext ~= nextContext then + callComponentWillReceiveProps(workInProgress, instance, newProps, nextContext) + end + end + + resetHasForceUpdateBeforeProcessing() + + local oldState = workInProgress.memoizedState + instance.state = oldState + local newState = instance.state + processUpdateQueue(workInProgress, newProps, instance, renderLanes) + newState = workInProgress.memoizedState + + if + unresolvedOldProps == unresolvedNewProps + and oldState == newState + and not hasContextChanged() + and not checkHasForceUpdateAfterProcessing() + then + -- If an update was already in progress, we should schedule an Update + -- effect even though we're bailing out, so that cWU/cDU are called. + if + instance.componentDidUpdate ~= nil + and type(instance.componentDidUpdate) == "function" + then + if + unresolvedOldProps ~= current.memoizedProps + or oldState ~= current.memoizedState + then + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + if + instance.getSnapshotBeforeUpdate ~= nil + and type(instance.getSnapshotBeforeUpdate) == "function" + then + if + unresolvedOldProps ~= current.memoizedProps + or oldState ~= current.memoizedState + then + workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) + end + end + return false + end + + if + getDerivedStateFromProps ~= nil + and type(getDerivedStateFromProps) == "function" + then + applyDerivedStateFromProps( + workInProgress, + ctor, + getDerivedStateFromProps, + newProps + ) + newState = workInProgress.memoizedState + end + + local shouldUpdate = checkHasForceUpdateAfterProcessing() + or checkShouldComponentUpdate( + workInProgress, + ctor, + oldProps, + newProps, + oldState, + newState, + nextContext + ) + + 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 + not hasNewLifecycles + and ( + ( + instance.UNSAFE_componentWillUpdate ~= nil + and type(instance.UNSAFE_componentWillUpdate) == "function" + ) + or ( + instance.componentWillUpdate ~= nil + and type(instance.componentWillUpdate) == "function" + ) + ) + then + if + instance.componentWillUpdate ~= nil + and type(instance.componentWillUpdate) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:componentWillUpdate(newProps, newState, nextContext) + end + if + instance.UNSAFE_componentWillUpdate ~= nil + and type(instance.UNSAFE_componentWillUpdate) == "function" + then + -- deviation: Call with ":" so that the method receives self + instance:UNSAFE_componentWillUpdate(newProps, newState, nextContext) + end + end + if + instance.componentDidUpdate ~= nil + and type(instance.componentDidUpdate) == "function" + then + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + if + instance.getSnapshotBeforeUpdate ~= nil + and type(instance.getSnapshotBeforeUpdate) == "function" + then + workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) + end + else + -- If an update was already in progress, we should schedule an Update + -- effect even though we're bailing out, so that cWU/cDU are called. + if + instance.componentDidUpdate ~= nil + and type(instance.componentDidUpdate) == "function" + then + if + unresolvedOldProps ~= current.memoizedProps + or oldState ~= current.memoizedState + then + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + if + instance.getSnapshotBeforeUpdate ~= nil + and type(instance.getSnapshotBeforeUpdate) == "function" + then + if + unresolvedOldProps ~= current.memoizedProps + or oldState ~= current.memoizedState + then + workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) + end + end + + -- If shouldComponentUpdate returned false, we should still update the + -- memoized props/state to indicate that this work can be reused. + workInProgress.memoizedProps = newProps + workInProgress.memoizedState = newState + end + + -- Update the existing instance's state, props, and context pointers even + -- if shouldComponentUpdate returns false. + instance.props = newProps + instance.state = newState + instance.context = nextContext + + return shouldUpdate end return { - adoptClassInstance = adoptClassInstance, - constructClassInstance = constructClassInstance, - mountClassInstance = mountClassInstance, - resumeMountClassInstance = resumeMountClassInstance, - updateClassInstance = updateClassInstance, - - applyDerivedStateFromProps = applyDerivedStateFromProps, - -- deviation: this should be safe to export, since it gets assigned only once - emptyRefsObject = emptyRefsObject, + adoptClassInstance = adoptClassInstance, + constructClassInstance = constructClassInstance, + mountClassInstance = mountClassInstance, + resumeMountClassInstance = resumeMountClassInstance, + updateClassInstance = updateClassInstance, + + applyDerivedStateFromProps = applyDerivedStateFromProps, + -- deviation: this should be safe to export, since it gets assigned only once + emptyRefsObject = emptyRefsObject, } diff --git a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua index 2fadf938..c40222be 100644 --- a/modules/react-reconciler/src/ReactFiberCommitWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberCommitWork.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/7f08e908b10a58cda902611378ec053003d371ed/packages/react-reconciler/src/ReactFiberCommitWork.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/7f08e908b10a58cda902611378ec053003d371ed/packages/react-reconciler/src/ReactFiberCommitWork.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,68 +7,83 @@ * * @flow ]] ---!nolint LocalShadowPedantic -- 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) +local function unimplemented(message: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("UNIMPLEMENTED ERROR: " .. tostring(message)) + error("FIXME (roblox): " .. message .. " is unimplemented", 2) end +-- ROBLOX DEVIATION: keep track of the pcall run depth and stop wrapping pcalls after we hit MAX_RUN_DEPTH. +-- ROBLOX note: if this number is raised to 195, the test in RoactRecursiveLayoutPcallDepth will fail +local runDepth = 0 +local MAX_RUN_DEPTH = 20 + local function isCallable(value) - if typeof(value) == "function" then - return true - end - if typeof(value) == "table" then - local mt = getmetatable(value) - if mt and rawget(mt, "__call") then - return true - end - if value._isMockFunction then - return true - end - end - return false + if typeof(value) == "function" then + return true + end + if typeof(value) == "table" then + local mt = getmetatable(value) + if mt and rawget(mt, "__call") then + return true + end + if value._isMockFunction then + return true + end + end + return false end local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error +local Set = LuauPolyfill.Set type Array = { [number]: T } +local __DEV__ = ReactGlobals.__DEV__ :: boolean +local __YOLO__ = ReactGlobals.__YOLO__ :: boolean + local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -type Instance = ReactFiberHostConfig.Instance; -type Container = ReactFiberHostConfig.Container; +type Instance = ReactFiberHostConfig.Instance +type Container = ReactFiberHostConfig.Container type TextInstance = ReactFiberHostConfig.TextInstance +-- ROBLOX deviation START: we have to inline, because type imports don't work across dynamic requires like HostConfig -- local type { -- SuspenseInstance, -- ChildSet, -- UpdatePayload, +type UpdatePayload = Array -- } = require(script.Parent.ReactFiberHostConfig) +-- ROBLOX deviation END local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber -type FiberRoot = ReactInternalTypes.FiberRoot; -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +type FiberRoot = ReactInternalTypes.FiberRoot +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState local ReactUpdateQueueModule = require(script.Parent["ReactUpdateQueue.new"]) -type UpdateQueue = ReactUpdateQueueModule.UpdateQueue +type UpdateQueue = ReactInternalTypes.UpdateQueue -- local ReactFiberHooks = require(script.Parent["ReactFiberHooks.new"]) -- type FunctionComponentUpdateQueue = ReactFiberHooks.FunctionComponentUpdateQueue -- ROBLOX deviation: inline the typedef here to avoid circular dependency type Effect = { - tag: HookFlags, - create: () -> (() -> ())?, - destroy: (() -> ())?, - deps: Array?, - next: Effect, + tag: HookFlags, + create: () -> (() -> ())?, + destroy: (() -> ())?, + deps: Array?, + next: Effect, } type FunctionComponentUpdateQueue = { - lastEffect: Effect? + lastEffect: Effect?, } local ReactTypes = require(Packages.Shared) @@ -78,7 +93,7 @@ type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel local ReactFiberOffscreenComponent = require(script.Parent.ReactFiberOffscreenComponent) type OffscreenState = ReactFiberOffscreenComponent.OffscreenState local ReactHookEffectTags = require(script.Parent.ReactHookEffectTags) -type HookFlags = ReactHookEffectTags.HookFlags; +type HookFlags = ReactHookEffectTags.HookFlags -- ROBLOX deviation: import tracing as a top-level export to avoid direct file access local Schedule_tracing_wrap = require(Packages.Scheduler).tracing.unstable_wrap @@ -86,10 +101,10 @@ local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags local enableSchedulerTracing = ReactFeatureFlags.enableSchedulerTracing local enableProfilerTimer = ReactFeatureFlags.enableProfilerTimer local enableProfilerCommitHooks = ReactFeatureFlags.enableProfilerCommitHooks -local enableSuspenseServerRenderer = ReactFeatureFlags.enableSuspenseServerRenderer -local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI +-- local enableSuspenseServerRenderer = ReactFeatureFlags.enableSuspenseServerRenderer +-- local enableFundamentalAPI = ReactFeatureFlags.enableFundamentalAPI local enableSuspenseCallback = ReactFeatureFlags.enableSuspenseCallback -local enableScopeAPI = ReactFeatureFlags.enableScopeAPI +-- local enableScopeAPI = ReactFeatureFlags.enableScopeAPI local enableDoubleInvokingEffects = ReactFeatureFlags.enableDoubleInvokingEffects local ReactWorkTags = require(script.Parent.ReactWorkTags) local FunctionComponent = ReactWorkTags.FunctionComponent @@ -127,13 +142,16 @@ local PassiveMask = ReactFiberFlags.PassiveMask local Ref = ReactFiberFlags.Ref local getComponentName = require(Packages.Shared).getComponentName local invariant = require(Packages.Shared).invariant +local describeError = require(Packages.Shared).describeError local ReactCurrentFiber = require(script.Parent.ReactCurrentFiber) -- ROBLOX deviation: this property would be captured as values instead of bound local currentDebugFiberInDEV = ReactCurrentFiber.current local resetCurrentDebugFiberInDEV = ReactCurrentFiber.resetCurrentFiber local setCurrentDebugFiberInDEV = ReactCurrentFiber.setCurrentFiber -local onCommitUnmount = require(script.Parent["ReactFiberDevToolsHook.new"]).onCommitUnmount -local resolveDefaultProps = require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps +local onCommitUnmount = + require(script.Parent["ReactFiberDevToolsHook.new"]).onCommitUnmount +local resolveDefaultProps = + require(script.Parent["ReactFiberLazyComponent.new"]).resolveDefaultProps local ReactProfilerTimer = require(script.Parent["ReactProfilerTimer.new"]) local startLayoutEffectTimer = ReactProfilerTimer.startLayoutEffectTimer local recordPassiveEffectDuration = ReactProfilerTimer.recordPassiveEffectDuration @@ -171,43 +189,41 @@ local commitHydratedSuspenseInstance = ReactFiberHostConfig.commitHydratedSuspen local clearContainer = ReactFiberHostConfig.clearContainer -- local prepareScopeUpdate = ReactFiberHostConfig.prepareScopeUpdate - -- ROBLOX deviation: Lazy init to avoid circular dependencies local ReactFiberWorkLoop -local resolveRetryWakeable = function(...) - if not ReactFiberWorkLoop then - ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any - end - return ReactFiberWorkLoop.resolveRetryWakeable(...) +local function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable): () + if not ReactFiberWorkLoop then + ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any + end + ReactFiberWorkLoop.resolveRetryWakeable(boundaryFiber, wakeable) end - -local markCommitTimeOfFallback = function(...) - if not ReactFiberWorkLoop then - ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any - end - return ReactFiberWorkLoop.markCommitTimeOfFallback(...) +local function markCommitTimeOfFallback(): () + if not ReactFiberWorkLoop then + ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any + end + ReactFiberWorkLoop.markCommitTimeOfFallback() 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 -schedulePassiveEffectCallback = function(current, parent, error_) - console.warn("ReactFiberCommitWork: schedulePassiveEffectCallback causes a dependency cycle\n" .. debug.traceback()) - error(error_) +local function schedulePassiveEffectCallback(): () + console.warn( + "ReactFiberCommitWork: schedulePassiveEffectCallback causes a dependency cycle\n" + .. debug.traceback() + ) end -- deviation: stub to allow dependency injection that breaks circular dependency -local captureCommitPhaseError -captureCommitPhaseError = function(current, parent, error_) - console.warn("ReactFiberCommitWork: captureCommitPhaseError causes a dependency cycle") - error(error_) +local function captureCommitPhaseError( + rootFiber: Fiber, + sourceFiber: Fiber | nil, + error_: any? +): () + console.warn( + "ReactFiberCommitWork: captureCommitPhaseError causes a dependency cycle" + ) + error(error_) end local NoHookEffect = ReactHookEffectTags.NoFlags @@ -218,1159 +234,1108 @@ local HookPassive = ReactHookEffectTags.Passive -- ROBLOX deviation: lazy init to break cyclic dependency local didWarnAboutReassigningPropsRef local didWarnAboutReassigningProps = function() - if not didWarnAboutReassigningPropsRef then - didWarnAboutReassigningPropsRef = require(script.Parent["ReactFiberBeginWork.new"]).didWarnAboutReassigningProps - end - return didWarnAboutReassigningPropsRef + if not didWarnAboutReassigningPropsRef then + didWarnAboutReassigningPropsRef = + require(script.Parent["ReactFiberBeginWork.new"]).didWarnAboutReassigningProps + end + return didWarnAboutReassigningPropsRef end -- deviation: Common types -type Set = { [T]: boolean }; +type Set = { [T]: boolean } -- deviation: pre-declare functions when necessary -local isHostParent, getHostSibling, insertOrAppendPlacementNode, - insertOrAppendPlacementNodeIntoContainer, commitLayoutEffectsForHostRoot, - commitLayoutEffectsForHostComponent, commitLayoutEffectsForClassComponent, - unmountHostComponents, commitNestedUnmounts, commitUnmount +local isHostParent, getHostSibling, insertOrAppendPlacementNode, insertOrAppendPlacementNodeIntoContainer, commitLayoutEffectsForHostRoot, commitLayoutEffectsForHostComponent, commitLayoutEffectsForClassComponent, unmountHostComponents, commitNestedUnmounts, commitUnmount -- Used to avoid traversing the return path to find the nearest Profiler ancestor during commit. local nearestProfilerOnStack: Fiber | nil = nil -- deviation: Not possible to return `undefined` in lua -- local didWarnAboutUndefinedSnapshotBeforeUpdate: Set? = nil --- if _G.__DEV__ then +-- if __DEV__ then -- didWarnAboutUndefinedSnapshotBeforeUpdate = {} -- end -- local PossiblyWeakSet = typeof WeakSet == 'function' ? WeakSet : Set -local callComponentWillUnmountWithTimer = function(current, instance) - instance.props = current.memoizedProps - instance.state = current.memoizedState - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(current.mode, ProfileMode) ~= 0 - then - local ok, exception = pcall(function() - startLayoutEffectTimer() - -- ROBLOX deviation: Call with ":" so that the method receives self - instance:componentWillUnmount() - end) - - recordLayoutEffectDuration(current) - - if not ok then - error(exception) - end - else - -- ROBLOX deviation: Call with ":" so that the method receives self - instance:componentWillUnmount() - end +local function callComponentWillUnmountWithTimer(current, instance) + instance.props = current.memoizedProps + instance.state = current.memoizedState + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(current.mode, ProfileMode) ~= 0 + then + -- ROBLOX performance? we could hoist start...Timer() out and eliminate the anon function, but then the timer would incldue the pcall overhead + local ok, exception = xpcall(function() + startLayoutEffectTimer() + -- ROBLOX deviation: Call with ":" so that the method receives self + instance:componentWillUnmount() + end, describeError) + + recordLayoutEffectDuration(current) + + if not ok then + error(exception) + end + else + -- ROBLOX deviation: Call with ":" so that the method receives self + instance:componentWillUnmount() + end end -- Capture errors so they don't interrupt unmounting. function safelyCallComponentWillUnmount( - current: Fiber, - instance: any, - nearestMountedAncestor -) - if _G.__DEV__ then - invokeGuardedCallback( - nil, - callComponentWillUnmountWithTimer, - nil, - current, - instance - ) - if hasCaughtError() then - local unmountError = clearCaughtError() - captureCommitPhaseError(current, nearestMountedAncestor, unmountError) - end - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, unmountError - if not _G.__YOLO__ then - ok, unmountError = pcall(function() - callComponentWillUnmountWithTimer(current, instance) - end) - else - ok = true - callComponentWillUnmountWithTimer(current, instance) - end - - if not ok then - captureCommitPhaseError(current, nearestMountedAncestor, unmountError) - end - end + current: Fiber, + instance: any, + nearestMountedAncestor +): () + -- ROBLOX performance: eliminate the __DEV__ and invokeGuardedCallback, like React 18 has done + local ok, error_ = + xpcall(callComponentWillUnmountWithTimer, describeError, current, instance) + + if not ok then + captureCommitPhaseError(current, nearestMountedAncestor, error_) + end end -local function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber) - local ref = current.ref - if ref ~= nil then - if typeof(ref) == "function" then - if _G.__DEV__ then - invokeGuardedCallback(nil, ref, nil, nil) - if hasCaughtError() then - local refError = clearCaughtError() - captureCommitPhaseError(current, nearestMountedAncestor, refError) - end - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, refError - if not _G.__YOLO__ then - ok, refError = pcall(function() - ref(nil) - end) - else - ok = true - ref(nil) - end - if not ok then - captureCommitPhaseError(current, nearestMountedAncestor, refError) - end - end - else - ref.current = nil - end - end +local function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber): () + local ref = current.ref + if ref ~= nil then + if typeof(ref) == "function" then + -- ROBLOX performance: eliminate the __DEV__ and invokeGuardedCallback, like React 18 has done + local ok, error_ = xpcall(ref, describeError) + if not ok then + captureCommitPhaseError(current, nearestMountedAncestor, error_) + end + else + -- ROBLOX FIXME Luau: next line gets Expected type table, got 'RefObject | {| [string]: any, _stringRef: string? |}' instead + ref.current = nil + end + end end local function safelyCallDestroy( - current: Fiber, - nearestMountedAncestor: Fiber?, - destroy: () -> () -) - if _G.__DEV__ then - invokeGuardedCallback(nil, destroy, nil) - if hasCaughtError() then - local error_ = clearCaughtError() - captureCommitPhaseError(current, nearestMountedAncestor, error_) - end - else - local ok, error_ = pcall(function() - destroy() - end) - if not ok then - captureCommitPhaseError(current, nearestMountedAncestor, error_) - end - end + current: Fiber, + nearestMountedAncestor: Fiber | nil, + destroy: () -> () +): () + -- ROBLOX performance: eliminate the __DEV__ and invokeGuardedCallback, like React 18 has done + local ok, error_ = xpcall(destroy, describeError) + if not ok then + captureCommitPhaseError(current, nearestMountedAncestor, error_) + end end --- FIXME: Type refinement --- local function commitBeforeMutationLifeCycles( --- current: Fiber | nil, --- finishedWork: Fiber --- ) local function commitBeforeMutationLifeCycles( - current: any, - finishedWork: Fiber -) - if - finishedWork.tag == FunctionComponent or - finishedWork.tag == ForwardRef or - finishedWork.tag == SimpleMemoComponent or - finishedWork.tag == Block - then - return - elseif finishedWork.tag == ClassComponent then - if bit32.band(finishedWork.flags, Snapshot) ~= 0 then - if current ~= nil then - local prevProps = current.memoizedProps - local prevState = current.memoizedState - local instance = finishedWork.stateNode - -- We could update instance props and state here, - -- but instead we rely on them being set during last render. - -- TODO: revisit this when we implement resuming. - if _G.__DEV__ then - if - finishedWork.type == finishedWork.elementType and - not didWarnAboutReassigningProps - then - if instance.props ~= finishedWork.memoizedProps then - console.error( - "Expected %s props to match memoized props before " .. - "getSnapshotBeforeUpdate. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.props`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - if instance.state ~= finishedWork.memoizedState then - console.error( - "Expected %s state to match memoized state before " .. - "getSnapshotBeforeUpdate. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.state`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - end - end - -- deviation: Call with ':' instead of '.' so that self is available - local snapshot = instance:getSnapshotBeforeUpdate( - finishedWork.elementType == finishedWork.type - and prevProps - or resolveDefaultProps(finishedWork.type, prevProps), - prevState - ) - if _G.__DEV__ then - -- deviation: not possible to return `undefined` in Lua - -- local didWarnSet = ((didWarnAboutUndefinedSnapshotBeforeUpdate: any): Set) - -- if snapshot == nil and not didWarnSet[finishedWork.type] then - -- didWarnSet[finishedWork.type] = true - -- console.error( - -- "%s.getSnapshotBeforeUpdate(): A snapshot value (or nil) " .. - -- "must be returned. You have returned undefined.", - -- getComponentName(finishedWork.type) - -- ) - -- end - end - instance.__reactInternalSnapshotBeforeUpdate = snapshot - end - end - return - elseif finishedWork.tag == HostRoot then - if supportsMutation then - if bit32.band(finishedWork.flags, Snapshot) ~= 0 then - local root = finishedWork.stateNode - clearContainer(root.containerInfo) - end - end - return - elseif - finishedWork.tag == HostComponent or - finishedWork.tag == HostText or - finishedWork.tag == HostPortal or - finishedWork.tag == IncompleteClassComponent - then - -- Nothing to do for these component types - return - end - invariant( - false, - "This unit of work tag should not have side-effects. This error is " .. - "likely caused by a bug in React. Please file an issue." - ) + current: Fiber | nil, + finishedWork: Fiber +): () + if + finishedWork.tag == FunctionComponent + or finishedWork.tag == ForwardRef + or finishedWork.tag == SimpleMemoComponent + or finishedWork.tag == Block + then + return + elseif finishedWork.tag == ClassComponent then + if bit32.band(finishedWork.flags, Snapshot) ~= 0 then + if current ~= nil then + local prevProps = (current :: Fiber).memoizedProps + local prevState = (current :: Fiber).memoizedState + local instance = finishedWork.stateNode + -- We could update instance props and state here, + -- but instead we rely on them being set during last render. + -- TODO: revisit this when we implement resuming. + if __DEV__ then + if + finishedWork.type == finishedWork.elementType + and not didWarnAboutReassigningProps + then + if instance.props ~= finishedWork.memoizedProps then + console.error( + "Expected %s props to match memoized props before " + .. "getSnapshotBeforeUpdate. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.props`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + if instance.state ~= finishedWork.memoizedState then + console.error( + "Expected %s state to match memoized state before " + .. "getSnapshotBeforeUpdate. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.state`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + end + end + -- deviation: Call with ':' instead of '.' so that self is available + local snapshot = instance:getSnapshotBeforeUpdate( + finishedWork.elementType == finishedWork.type and prevProps + or resolveDefaultProps(finishedWork.type, prevProps), + prevState + ) + if __DEV__ then + -- ROBLOX deviation: not possible to return `undefined` in Lua + -- local didWarnSet = ((didWarnAboutUndefinedSnapshotBeforeUpdate: any): Set) + -- if snapshot == nil and not didWarnSet[finishedWork.type] then + -- didWarnSet[finishedWork.type] = true + -- console.error( + -- "%s.getSnapshotBeforeUpdate(): A snapshot value (or nil) " .. + -- "must be returned. You have returned undefined.", + -- getComponentName(finishedWork.type) + -- ) + -- end + end + instance.__reactInternalSnapshotBeforeUpdate = snapshot + end + end + return + elseif finishedWork.tag == HostRoot then + if supportsMutation then + if bit32.band(finishedWork.flags, Snapshot) ~= 0 then + local root = finishedWork.stateNode + clearContainer(root.containerInfo) + end + end + return + elseif + finishedWork.tag == HostComponent + or finishedWork.tag == HostText + or finishedWork.tag == HostPortal + or finishedWork.tag == IncompleteClassComponent + then + -- Nothing to do for these component types + return + end + invariant( + false, + "This unit of work tag should not have side-effects. This error is " + .. "likely caused by a bug in React. Please file an issue." + ) end local function commitHookEffectListUnmount( - flags: HookFlags, - finishedWork: Fiber, - nearestMountedAncestor: Fiber? + flags: HookFlags, + finishedWork: Fiber, + nearestMountedAncestor: Fiber? ) - local updateQueue: FunctionComponentUpdateQueue | nil = finishedWork.updateQueue - local lastEffect - if updateQueue ~= nil then - lastEffect = (updateQueue :: FunctionComponentUpdateQueue).lastEffect - end - - if lastEffect ~= nil then - local firstEffect = lastEffect.next - local effect = firstEffect - repeat - if bit32.band(effect.tag, flags) == flags then - -- Unmount - local destroy = effect.destroy - effect.destroy = nil - if destroy ~= nil then - safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy) - end - end - effect = effect.next - until effect == firstEffect - end + local updateQueue: FunctionComponentUpdateQueue | nil = finishedWork.updateQueue + local lastEffect + if updateQueue ~= nil then + lastEffect = (updateQueue :: FunctionComponentUpdateQueue).lastEffect + end + + if lastEffect ~= nil then + local firstEffect = lastEffect.next + local effect = firstEffect + repeat + if bit32.band(effect.tag, flags) == flags then + -- Unmount + local destroy = effect.destroy + effect.destroy = nil + if destroy ~= nil then + safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy) + end + end + effect = effect.next + until effect == firstEffect + end end local function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) - local updateQueue: FunctionComponentUpdateQueue | nil = finishedWork.updateQueue - local lastEffect - if updateQueue ~= nil then - lastEffect = (updateQueue :: FunctionComponentUpdateQueue).lastEffect - end - if lastEffect ~= nil then - local firstEffect = lastEffect.next - local effect = firstEffect - repeat - if bit32.band(effect.tag, flags) == flags then - -- Mount - local create = effect.create - effect.destroy = create() - - if _G.__DEV__ then - local destroy = effect.destroy - if destroy ~= nil and typeof(destroy) ~= 'function' then - local addendum - if destroy == nil then - addendum = - ' You returned nil. If your effect does not require clean ' .. - 'up, return undefined (or nothing).' - elseif typeof(destroy.andThen) == 'function' then - addendum = - '\n\nIt looks like you wrote useEffect(async () => ...) or returned a Promise. ' .. - 'Instead, write the async function inside your effect ' .. - 'and call it immediately:\n\n' .. - 'useEffect(() => {\n' .. - ' async function fetchData() {\n' .. - ' -- You can await here\n' .. - ' local response = await MyAPI.getData(someId);\n' .. - ' -- ...\n' .. - ' }\n' .. - ' fetchData();\n' .. - "}, [someId]); -- Or [] if effect doesn't need props or state\n\n" .. - 'Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching' - else - addendum = ' You returned: ' .. destroy - end - console.error( - 'An effect function must not return anything besides a function, ' .. - 'which is used for clean-up.%s', - addendum - ) - end - end - end - effect = effect.next - until effect == firstEffect - end + local updateQueue: FunctionComponentUpdateQueue | nil = + finishedWork.updateQueue :: any + local lastEffect = if updateQueue ~= nil then updateQueue.lastEffect else nil + if lastEffect ~= nil then + local firstEffect = lastEffect.next + local effect = firstEffect + repeat + if bit32.band(effect.tag, flags) == flags then + -- Mount + local create = effect.create + effect.destroy = create() + + if __DEV__ then + local destroy = effect.destroy + if destroy ~= nil and typeof(destroy) ~= "function" then + local addendum + if destroy == nil then + addendum = " You returned nil. If your effect does not require clean " + .. "up, return nil (or nothing)." + elseif typeof(destroy.andThen) == "function" then + addendum = + -- ROBLOX FIXME: write a real program that does the equivalent and update this example, LUAFDN-754 + "\n\nIt looks like you wrote useEffect(Promise.new(function() --[[...]] end) or returned a Promise. " .. "Instead, write the async function inside your effect " .. "and call it immediately:\n\n" .. "useEffect(function()\n" .. " function fetchData()\n" .. " -- You can await here\n" .. " local response = MyAPI.getData(someId):await()\n" .. " -- ...\n" .. " end\n" .. " fetchData()\n" .. "end, {someId}) -- Or {} if effect doesn't need props or state\n\n" .. "Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching" + else + addendum = " You returned: " .. destroy + end + console.error( + "An effect function must not return anything besides a function, " + .. "which is used for clean-up.%s", + addendum + ) + end + end + end + effect = effect.next + until effect == firstEffect + end end -function commitProfilerPassiveEffect( - finishedRoot: FiberRoot, - finishedWork: Fiber -): () - if enableProfilerTimer and enableProfilerCommitHooks then - if finishedWork.tag == Profiler then - local passiveEffectDuration = finishedWork.stateNode.passiveEffectDuration - local id, onPostCommit = finishedWork.memoizedProps.id, finishedWork.memoizedProps.onPostCommit - - -- This value will still reflect the previous commit phase. - -- It does not get reset until the start of the next commit phase. - local commitTime = getCommitTime() - - if typeof(onPostCommit) == 'function' then - if enableSchedulerTracing then - onPostCommit( - id, - (function() - if finishedWork.alternate == nil then - return 'mount' - end - return 'update' - end)(), - passiveEffectDuration, - commitTime, - finishedRoot.memoizedInteractions - ) - else - onPostCommit( - id, - (function() - if finishedWork.alternate == nil then - return 'mount' - end - return 'update' - end)(), - passiveEffectDuration, - commitTime - ) - end - end - end - end +function commitProfilerPassiveEffect(finishedRoot: FiberRoot, finishedWork: Fiber): () + if enableProfilerTimer and enableProfilerCommitHooks then + if finishedWork.tag == Profiler then + local passiveEffectDuration = finishedWork.stateNode.passiveEffectDuration + local id, onPostCommit = + finishedWork.memoizedProps.id, finishedWork.memoizedProps.onPostCommit + + -- This value will still reflect the previous commit phase. + -- It does not get reset until the start of the next commit phase. + local commitTime = getCommitTime() + + if typeof(onPostCommit) == "function" then + if enableSchedulerTracing then + onPostCommit( + id, + if finishedWork.alternate == nil then "mount" else "update", + passiveEffectDuration, + commitTime, + finishedRoot.memoizedInteractions + ) + else + onPostCommit( + id, + if finishedWork.alternate == nil then "mount" else "update", + passiveEffectDuration, + commitTime + ) + end + end + end + end end local function recursivelyCommitLayoutEffects( - finishedWork: Fiber, - finishedRoot: FiberRoot, - -- ROBLOX deviation: pass in these functions to avoid dependency cycle - _captureCommitPhaseError: (any, Fiber, any) -> (), - _schedulePassiveEffectCallback: () -> () + finishedWork: Fiber, + finishedRoot: FiberRoot, + -- ROBLOX deviation: pass in these functions to avoid dependency cycle + _captureCommitPhaseError: ( + sourceFiber: Fiber, + nearestMountedAncestor: Fiber?, + error: any + ) -> (), + _schedulePassiveEffectCallback: () -> () ) - if _captureCommitPhaseError ~= nil then - captureCommitPhaseError = _captureCommitPhaseError - end - if _schedulePassiveEffectCallback ~= nil then - schedulePassiveEffectCallback = _schedulePassiveEffectCallback - end - local flags = finishedWork.flags - local tag = finishedWork.tag - if tag == Profiler then - local prevProfilerOnStack = nil - if enableProfilerTimer and enableProfilerCommitHooks then - prevProfilerOnStack = nearestProfilerOnStack - nearestProfilerOnStack = finishedWork - end - - local child = finishedWork.child - while child ~= nil do - local primarySubtreeFlags = bit32.band(finishedWork.subtreeFlags, LayoutMask) - if primarySubtreeFlags ~= NoFlags then - if _G.__DEV__ then - local prevCurrentFiberInDEV = currentDebugFiberInDEV - setCurrentDebugFiberInDEV(child) - invokeGuardedCallback( - nil, - recursivelyCommitLayoutEffects, - nil, - child, - finishedRoot, - -- ROBLOX deviation: pass in these functions to avoid dependency cycle - captureCommitPhaseError, - schedulePassiveEffectCallback - ) - if hasCaughtError() then - local error_ = clearCaughtError() - captureCommitPhaseError(child, finishedWork, error_) - end - if prevCurrentFiberInDEV ~= nil then - setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) - else - resetCurrentDebugFiberInDEV() - end - else - local ok, error_ = pcall(function() - -- ROBLOX deviation: pass in captureCommitPhaseError function to avoid dependency cycle - recursivelyCommitLayoutEffects( - child, - finishedRoot, - captureCommitPhaseError, - schedulePassiveEffectCallback - ) - end) - if not ok then - captureCommitPhaseError(child, finishedWork, error_) - end - end - end - child = child.sibling - end - - local primaryFlags = bit32.band(flags, bit32.bor(Update, Callback)) - if primaryFlags ~= NoFlags then - if enableProfilerTimer then - if _G.__DEV__ then - local prevCurrentFiberInDEV = currentDebugFiberInDEV - setCurrentDebugFiberInDEV(finishedWork) - invokeGuardedCallback( - nil, - commitLayoutEffectsForProfiler, - nil, - finishedWork, - finishedRoot - ) - if hasCaughtError() then - local error_ = clearCaughtError() - captureCommitPhaseError(finishedWork, finishedWork.return_, error_) - end - if prevCurrentFiberInDEV ~= nil then - setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) - else - resetCurrentDebugFiberInDEV() - end - else - local ok, error_ = pcall(function() - -- ROBLOX TODO? pass in captureCommitPhaseError? - commitLayoutEffectsForProfiler(finishedWork, finishedRoot) - end) - if not ok then - captureCommitPhaseError(finishedWork, finishedWork.return_, error_) - end - end - end - end - - if enableProfilerTimer and enableProfilerCommitHooks then - -- Propagate layout effect durations to the next nearest Profiler ancestor. - -- Do not reset these values until the next render so DevTools has a chance to read them first. - if prevProfilerOnStack ~= nil then - prevProfilerOnStack.stateNode.effectDuration += - finishedWork.stateNode.effectDuration - end - - nearestProfilerOnStack = prevProfilerOnStack - end - -- elseif tag == Offscreen then - -- TODO: Fast path to invoke all nested layout effects when Offscren goes from hidden to visible. - else - local child = finishedWork.child - while child ~= nil do - local primarySubtreeFlags = bit32.band(finishedWork.subtreeFlags, LayoutMask) - if primarySubtreeFlags ~= NoFlags then - if _G.__DEV__ then - local prevCurrentFiberInDEV = ReactCurrentFiber.current - setCurrentDebugFiberInDEV(child) - invokeGuardedCallback( - nil, - recursivelyCommitLayoutEffects, - nil, - child, - finishedRoot, - -- ROBLOX deviation: pass in this function to avoid dependency cycle - captureCommitPhaseError, - schedulePassiveEffectCallback - ) - if hasCaughtError() then - local error_ = clearCaughtError() - captureCommitPhaseError(child, finishedWork, error_) - end - if prevCurrentFiberInDEV ~= nil then - setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) - else - resetCurrentDebugFiberInDEV() - end - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, error_ - if not _G.__YOLO__ then - ok, error_ = pcall(function() - -- ROBLOX deviation: pass in this function to avoid dependency cycle - recursivelyCommitLayoutEffects(child, finishedRoot, captureCommitPhaseError, schedulePassiveEffectCallback) - end) - else - ok = true - recursivelyCommitLayoutEffects(child, finishedRoot, captureCommitPhaseError, schedulePassiveEffectCallback) - end - - if not ok then - captureCommitPhaseError(child, finishedWork, error_) - end - end - end - child = child.sibling - end - - local primaryFlags = bit32.band(flags, bit32.bor(Update, Callback)) - if primaryFlags ~= NoFlags then - if - tag == FunctionComponent or - tag == ForwardRef or - tag == SimpleMemoComponent or - tag == Block - then - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - -- ROBLOX try - local ok, error_ = pcall(function() - startLayoutEffectTimer() - commitHookEffectListMount( - bit32.bor(HookLayout, HookHasEffect), - finishedWork - ) - end) - -- ROBLOX finally - recordLayoutEffectDuration(finishedWork) - if not ok then - error(error_) - end - else - commitHookEffectListMount( - bit32.bor(HookLayout, HookHasEffect), - finishedWork - ) - end - - if bit32.band(finishedWork.subtreeFlags, PassiveMask) ~= NoFlags then - schedulePassiveEffectCallback() - end - elseif tag == ClassComponent then - -- NOTE: Layout effect durations are measured within this function. - commitLayoutEffectsForClassComponent(finishedWork) - elseif tag == HostRoot then - commitLayoutEffectsForHostRoot(finishedWork) - elseif tag == HostComponent then - commitLayoutEffectsForHostComponent(finishedWork) - elseif tag == SuspenseComponent then - commitSuspenseHydrationCallbacks(finishedRoot, finishedWork) - elseif - tag == FundamentalComponent or - tag == HostPortal or - tag == HostText or - tag == IncompleteClassComponent or - tag == LegacyHiddenComponent or - tag == OffscreenComponent or - tag == ScopeComponent or - tag == SuspenseListComponent - then - -- break - else - invariant( - false, - "This unit of work tag should not have side-effects. This error is " .. - "likely caused by a bug in React. Please file an issue." - ) - end - end - - if enableScopeAPI then - -- TODO: This is a temporary solution that allowed us to transition away from React Flare on www. - if bit32.band(flags, Ref) ~= 0 and tag ~= ScopeComponent then - commitAttachRef(finishedWork) - end - else - if bit32.band(flags, Ref) ~= 0 then - commitAttachRef(finishedWork) - end - end - end + if _captureCommitPhaseError ~= nil then + captureCommitPhaseError = _captureCommitPhaseError + end + if _schedulePassiveEffectCallback ~= nil then + schedulePassiveEffectCallback = _schedulePassiveEffectCallback + end + local flags = finishedWork.flags + local tag = finishedWork.tag + if tag == Profiler then + local prevProfilerOnStack = nil + if enableProfilerTimer and enableProfilerCommitHooks then + prevProfilerOnStack = nearestProfilerOnStack + nearestProfilerOnStack = finishedWork + end + + local child = finishedWork.child + while child ~= nil do + local primarySubtreeFlags = bit32.band(finishedWork.subtreeFlags, LayoutMask) + if primarySubtreeFlags ~= NoFlags then + if __DEV__ then + local prevCurrentFiberInDEV = currentDebugFiberInDEV + setCurrentDebugFiberInDEV(child) + invokeGuardedCallback( + nil, + recursivelyCommitLayoutEffects, + nil, + child, + finishedRoot, + -- ROBLOX deviation: pass in these functions to avoid dependency cycle + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + if hasCaughtError() then + local error_ = clearCaughtError() + captureCommitPhaseError(child, finishedWork, error_) + end + if prevCurrentFiberInDEV ~= nil then + setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) + else + resetCurrentDebugFiberInDEV() + end + else + local ok, error_ = xpcall( + -- ROBLOX deviation: pass in captureCommitPhaseError function to avoid dependency cycle + recursivelyCommitLayoutEffects, + describeError, + child, + finishedRoot, + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + if not ok then + captureCommitPhaseError(child, finishedWork, error_) + end + end + end + child = child.sibling + end + + local primaryFlags = bit32.band(flags, bit32.bor(Update, Callback)) + if primaryFlags ~= NoFlags then + if enableProfilerTimer then + if __DEV__ then + local prevCurrentFiberInDEV = currentDebugFiberInDEV + setCurrentDebugFiberInDEV(finishedWork) + invokeGuardedCallback( + nil, + commitLayoutEffectsForProfiler, + nil, + finishedWork, + finishedRoot + ) + if hasCaughtError() then + local error_ = clearCaughtError() + captureCommitPhaseError( + finishedWork, + finishedWork.return_, + error_ + ) + end + if prevCurrentFiberInDEV ~= nil then + setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) + else + resetCurrentDebugFiberInDEV() + end + else + -- ROBLOX TODO? pass in captureCommitPhaseError? + local ok, error_ = xpcall( + commitLayoutEffectsForProfiler, + describeError, + finishedWork, + finishedRoot + ) + if not ok then + captureCommitPhaseError( + finishedWork, + finishedWork.return_, + error_ + ) + end + end + end + end + + if enableProfilerTimer and enableProfilerCommitHooks then + -- Propagate layout effect durations to the next nearest Profiler ancestor. + -- Do not reset these values until the next render so DevTools has a chance to read them first. + if prevProfilerOnStack ~= nil then + prevProfilerOnStack.stateNode.effectDuration += finishedWork.stateNode.effectDuration + end + + nearestProfilerOnStack = prevProfilerOnStack + end + -- elseif tag == Offscreen then + -- TODO: Fast path to invoke all nested layout effects when Offscren goes from hidden to visible. + else + local child = finishedWork.child + while child ~= nil do + local primarySubtreeFlags = bit32.band(finishedWork.subtreeFlags, LayoutMask) + if primarySubtreeFlags ~= NoFlags then + if __DEV__ then + local prevCurrentFiberInDEV = ReactCurrentFiber.current + setCurrentDebugFiberInDEV(child) + --[[ + ROBLOX DEVIATION: In DEV, After MAX_RUN_DEPTH pcalls, do not wrap recursive calls in pcall. Otherwise, + we hit the stack limit and get a stack overflow error. + ]] + if runDepth < MAX_RUN_DEPTH then + runDepth += 1 + invokeGuardedCallback( + nil, + recursivelyCommitLayoutEffects, + nil, + child, + finishedRoot, + -- ROBLOX deviation: pass in this function to avoid dependency cycle + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + runDepth -= 1 + + if hasCaughtError() then + local error_ = clearCaughtError() + captureCommitPhaseError(child, finishedWork, error_) + end + else + recursivelyCommitLayoutEffects( + child, + finishedRoot, + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + end + if prevCurrentFiberInDEV ~= nil then + setCurrentDebugFiberInDEV(prevCurrentFiberInDEV) + else + resetCurrentDebugFiberInDEV() + end + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, error_ + if not __YOLO__ and runDepth < MAX_RUN_DEPTH then + --[[ + ROBLOX DEVIATION: After MAX_RUN_DEPTH pcalls, do not wrap recursive calls in pcall. Otherwise, we hit the + stack limit and get a stack overflow error. + ]] + runDepth += 1 + + ok, error_ = xpcall( + -- ROBLOX deviation: pass in this function to avoid dependency cycle + recursivelyCommitLayoutEffects, + describeError, + child, + finishedRoot, + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + + runDepth -= 1 + else + ok = true + recursivelyCommitLayoutEffects( + child, + finishedRoot, + captureCommitPhaseError, + schedulePassiveEffectCallback + ) + end + + if not ok then + captureCommitPhaseError(child, finishedWork, error_) + end + end + end + child = child.sibling + end + + local primaryFlags = bit32.band(flags, bit32.bor(Update, Callback)) + if primaryFlags ~= NoFlags then + if + tag == FunctionComponent + or tag == ForwardRef + or tag == SimpleMemoComponent + or tag == Block + then + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + -- ROBLOX try + local ok, error_ = xpcall(function() + startLayoutEffectTimer() + commitHookEffectListMount( + bit32.bor(HookLayout, HookHasEffect), + finishedWork + ) + end, describeError) + -- ROBLOX finally + recordLayoutEffectDuration(finishedWork) + if not ok then + error(error_) + end + else + commitHookEffectListMount( + bit32.bor(HookLayout, HookHasEffect), + finishedWork + ) + end + + if bit32.band(finishedWork.subtreeFlags, PassiveMask) ~= NoFlags then + schedulePassiveEffectCallback() + end + elseif tag == ClassComponent then + -- NOTE: Layout effect durations are measured within this function. + commitLayoutEffectsForClassComponent(finishedWork) + elseif tag == HostRoot then + commitLayoutEffectsForHostRoot(finishedWork) + elseif tag == HostComponent then + commitLayoutEffectsForHostComponent(finishedWork) + elseif tag == SuspenseComponent then + commitSuspenseHydrationCallbacks(finishedRoot, finishedWork) + elseif + tag == FundamentalComponent + or tag == HostPortal + or tag == HostText + or tag == IncompleteClassComponent + or tag == LegacyHiddenComponent + or tag == OffscreenComponent + or tag == ScopeComponent + or tag == SuspenseListComponent + then + -- break + else + invariant( + false, + "This unit of work tag should not have side-effects. This error is " + .. "likely caused by a bug in React. Please file an issue." + ) + end + end + + -- ROBLOX performance: avoid cmp on always-false value + -- if enableScopeAPI then + -- -- TODO: This is a temporary solution that allowed us to transition away from React Flare on www. + -- if bit32.band(flags, Ref) ~= 0 and tag ~= ScopeComponent then + -- commitAttachRef(finishedWork) + -- end + -- else + if bit32.band(flags, Ref) ~= 0 then + commitAttachRef(finishedWork) + end + -- end + end end -function commitLayoutEffectsForProfiler( - finishedWork: Fiber, - finishedRoot: FiberRoot -) - if enableProfilerTimer then - local flags = finishedWork.flags - local current = finishedWork.alternate - - local onCommit, onRender = finishedWork.memoizedProps.onCommit, finishedWork.memoizedProps.onRender - local effectDuration = finishedWork.stateNode.effectDuration - - local commitTime = getCommitTime() - - local OnRenderFlag = Update - local OnCommitFlag = Callback - - if bit32.band(flags, OnRenderFlag) ~= NoFlags - -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields - and isCallable(onRender) - then - if enableSchedulerTracing then - onRender( - finishedWork.memoizedProps.id, - (function() - if current == nil then - return 'mount' - end - return 'update' - end)(), - finishedWork.actualDuration, - finishedWork.treeBaseDuration, - finishedWork.actualStartTime, - commitTime, - finishedRoot.memoizedInteractions - ) - else - onRender( - finishedWork.memoizedProps.id, - (function() - if current == nil then - return 'mount' - end - return 'update' - end)(), - finishedWork.actualDuration, - finishedWork.treeBaseDuration, - finishedWork.actualStartTime, - commitTime - ) - end - end - - if enableProfilerCommitHooks then - if - bit32.band(flags, OnCommitFlag) ~= NoFlags - -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields - and isCallable(onCommit) - then - if enableSchedulerTracing then - onCommit( - finishedWork.memoizedProps.id, - (function() - if current == nil then - return 'mount' - end - return 'update' - end)(), - effectDuration, - commitTime, - finishedRoot.memoizedInteractions - ) - else - onCommit( - finishedWork.memoizedProps.id, - (function() - if current == nil then - return 'mount' - end - return 'update' - end)(), - effectDuration, - commitTime - ) - end - end - end - end +function commitLayoutEffectsForProfiler(finishedWork: Fiber, finishedRoot: FiberRoot) + if enableProfilerTimer then + local flags = finishedWork.flags + local current = finishedWork.alternate + + local onCommit, onRender = + finishedWork.memoizedProps.onCommit, finishedWork.memoizedProps.onRender + local effectDuration = finishedWork.stateNode.effectDuration + + local commitTime = getCommitTime() + + local OnRenderFlag = Update + local OnCommitFlag = Callback + + if + bit32.band(flags, OnRenderFlag) ~= NoFlags + -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields + and isCallable(onRender) + then + if enableSchedulerTracing then + onRender( + finishedWork.memoizedProps.id, + if current == nil then "mount" else "update", + finishedWork.actualDuration, + finishedWork.treeBaseDuration, + finishedWork.actualStartTime, + commitTime, + finishedRoot.memoizedInteractions + ) + else + onRender( + finishedWork.memoizedProps.id, + if current == nil then "mount" else "update", + finishedWork.actualDuration, + finishedWork.treeBaseDuration, + finishedWork.actualStartTime, + commitTime + ) + end + end + + if enableProfilerCommitHooks then + if + bit32.band(flags, OnCommitFlag) ~= NoFlags + -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields + and isCallable(onCommit) + then + if enableSchedulerTracing then + onCommit( + finishedWork.memoizedProps.id, + if current == nil then "mount" else "update", + effectDuration, + commitTime, + finishedRoot.memoizedInteractions + ) + else + onCommit( + finishedWork.memoizedProps.id, + if current == nil then "mount" else "update", + effectDuration, + commitTime + ) + end + end + end + end end -commitLayoutEffectsForClassComponent = function(finishedWork: Fiber) - local instance = finishedWork.stateNode - local current = finishedWork.alternate - if bit32.band(finishedWork.flags, Update) ~= 0 then - if current == nil then - -- We could update instance props and state here, - -- but instead we rely on them being set during last render. - -- TODO: revisit this when we implement resuming. - if _G.__DEV__ then - if - finishedWork.type == finishedWork.elementType and - not didWarnAboutReassigningProps - then - if instance.props ~= finishedWork.memoizedProps then - console.error( - "Expected %s props to match memoized props before " .. - "componentDidMount. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.props`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - if instance.state ~= finishedWork.memoizedState then - console.error( - "Expected %s state to match memoized state before " .. - "componentDidMount. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.state`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - end - end - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - local ok, result = pcall(function() - startLayoutEffectTimer() - -- ROBLOX deviation: Call with ":" so that the method receives self - instance:componentDidMount() - end) - -- finally - recordLayoutEffectDuration(finishedWork) - if not ok then - error(result) - end - else - -- ROBLOX deviation: Call with ":" so that the method receives self - instance:componentDidMount() - end - else - local prevProps = - finishedWork.elementType == finishedWork.type - and current.memoizedProps - or resolveDefaultProps(finishedWork.type, current.memoizedProps) - local prevState = current.memoizedState - -- We could update instance props and state here, - -- but instead we rely on them being set during last render. - -- TODO: revisit this when we implement resuming. - if _G.__DEV__ then - if - finishedWork.type == finishedWork.elementType and - not didWarnAboutReassigningProps - then - if instance.props ~= finishedWork.memoizedProps then - console.error( - "Expected %s props to match memoized props before " .. - "componentDidUpdate. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.props`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - if instance.state ~= finishedWork.memoizedState then - console.error( - "Expected %s state to match memoized state before " .. - "componentDidUpdate. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.state`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - end - end - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - local ok, result = pcall(function() - startLayoutEffectTimer() - -- deviation: Call with ":" so that the method receives self - instance:componentDidUpdate( - prevProps, - prevState, - instance.__reactInternalSnapshotBeforeUpdate - ) - end) - -- finally - recordLayoutEffectDuration(finishedWork) - if not ok then - error(result) - end - else - -- deviation: Call with ":" so that the method receives self - instance:componentDidUpdate( - prevProps, - prevState, - instance.__reactInternalSnapshotBeforeUpdate - ) - end - end - end - - -- TODO: I think this is now always non-null by the time it reaches the - -- commit phase. Consider removing the type check. - local updateQueue: UpdateQueue | nil = finishedWork.updateQueue - if updateQueue ~= nil then - if _G.__DEV__ then - if - finishedWork.type == finishedWork.elementType and - not didWarnAboutReassigningProps - then - if instance.props ~= finishedWork.memoizedProps then - console.error( - "Expected %s props to match memoized props before " .. - "processing the update queue. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.props`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - if instance.state ~= finishedWork.memoizedState then - console.error( - "Expected %s state to match memoized state before " .. - "processing the update queue. " .. - "This might either be because of a bug in React, or because " .. - "a component reassigns its own `this.state`. " .. - "Please file an issue.", - getComponentName(finishedWork.type) or "instance" - ) - end - end - end - -- We could update instance props and state here, - -- but instead we rely on them being set during last render. - -- TODO: revisit this when we implement resuming. - commitUpdateQueue(finishedWork, updateQueue, instance) - end +function commitLayoutEffectsForClassComponent(finishedWork: Fiber) + local instance = finishedWork.stateNode + local current = finishedWork.alternate + if bit32.band(finishedWork.flags, Update) ~= 0 then + if current == nil then + -- We could update instance props and state here, + -- but instead we rely on them being set during last render. + -- TODO: revisit this when we implement resuming. + if __DEV__ then + if + finishedWork.type == finishedWork.elementType + and not didWarnAboutReassigningProps + then + if instance.props ~= finishedWork.memoizedProps then + console.error( + "Expected %s props to match memoized props before " + .. "componentDidMount. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.props`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + if instance.state ~= finishedWork.memoizedState then + console.error( + "Expected %s state to match memoized state before " + .. "componentDidMount. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.state`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + end + end + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + local ok, result = xpcall(function() + startLayoutEffectTimer() + -- ROBLOX deviation: Call with ":" so that the method receives self + instance:componentDidMount() + end, describeError) + -- finally + recordLayoutEffectDuration(finishedWork) + if not ok then + error(result) + end + else + -- ROBLOX deviation: Call with ":" so that the method receives self + instance:componentDidMount() + end + else + local prevProps = finishedWork.elementType == finishedWork.type + and current.memoizedProps + or resolveDefaultProps(finishedWork.type, current.memoizedProps) + local prevState = current.memoizedState + -- We could update instance props and state here, + -- but instead we rely on them being set during last render. + -- TODO: revisit this when we implement resuming. + if __DEV__ then + if + finishedWork.type == finishedWork.elementType + and not didWarnAboutReassigningProps + then + if instance.props ~= finishedWork.memoizedProps then + console.error( + "Expected %s props to match memoized props before " + .. "componentDidUpdate. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.props`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + if instance.state ~= finishedWork.memoizedState then + console.error( + "Expected %s state to match memoized state before " + .. "componentDidUpdate. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.state`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + end + end + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + local ok, result = xpcall(function() + startLayoutEffectTimer() + -- deviation: Call with ":" so that the method receives self + instance:componentDidUpdate( + prevProps, + prevState, + instance.__reactInternalSnapshotBeforeUpdate + ) + end, describeError) + -- finally + recordLayoutEffectDuration(finishedWork) + if not ok then + error(result) + end + else + -- deviation: Call with ":" so that the method receives self + instance:componentDidUpdate( + prevProps, + prevState, + instance.__reactInternalSnapshotBeforeUpdate + ) + end + end + end + + -- TODO: I think this is now always non-null by the time it reaches the + -- commit phase. Consider removing the type check. + local updateQueue: UpdateQueue | nil = finishedWork.updateQueue + if updateQueue ~= nil then + if __DEV__ then + if + finishedWork.type == finishedWork.elementType + and not didWarnAboutReassigningProps + then + if instance.props ~= finishedWork.memoizedProps then + console.error( + "Expected %s props to match memoized props before " + .. "processing the update queue. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.props`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + if instance.state ~= finishedWork.memoizedState then + console.error( + "Expected %s state to match memoized state before " + .. "processing the update queue. " + .. "This might either be because of a bug in React, or because " + .. "a component reassigns its own `this.state`. " + .. "Please file an issue.", + getComponentName(finishedWork.type) or "instance" + ) + end + end + end + -- We could update instance props and state here, + -- but instead we rely on them being set during last render. + -- TODO: revisit this when we implement resuming. + commitUpdateQueue(finishedWork, updateQueue, instance) + end end -commitLayoutEffectsForHostRoot = function(finishedWork: Fiber) - -- TODO: I think this is now always non-null by the time it reaches the - -- commit phase. Consider removing the type check. - local updateQueue: UpdateQueue | nil = finishedWork.updateQueue - if updateQueue ~= nil then - local instance = nil - if finishedWork.child ~= nil then - -- ROBLOX TODO: localize child, workaround Luau type refinement shortcomings - local child = finishedWork.child - if child.tag == HostComponent then - instance = getPublicInstance(child.stateNode) - elseif child.tag == ClassComponent then - instance = child.stateNode - end - end - commitUpdateQueue(finishedWork, updateQueue, instance) - end +function commitLayoutEffectsForHostRoot(finishedWork: Fiber) + -- TODO: I think this is now always non-null by the time it reaches the + -- commit phase. Consider removing the type check. + local updateQueue: UpdateQueue | nil = finishedWork.updateQueue + if updateQueue ~= nil then + local instance = nil + if finishedWork.child ~= nil then + -- ROBLOX TODO: localize child, workaround Luau type refinement shortcomings + local child = finishedWork.child + if child.tag == HostComponent then + instance = getPublicInstance(child.stateNode) + elseif child.tag == ClassComponent then + instance = child.stateNode + end + end + commitUpdateQueue(finishedWork, updateQueue, instance) + end end -commitLayoutEffectsForHostComponent = function(finishedWork: Fiber) - local instance: Instance = finishedWork.stateNode - local current = finishedWork.alternate - - -- Renderers may schedule work to be done after host components are mounted - -- (eg DOM renderer may schedule auto-focus for inputs and form controls). - -- These effects should only be committed when components are first mounted, - -- aka when there is no current/alternate. - if current == nil and bit32.band(finishedWork.flags, Update) ~= 0 then - local type = finishedWork.type - local props = finishedWork.memoizedProps - commitMount(instance, type, props, finishedWork) - end +function commitLayoutEffectsForHostComponent(finishedWork: Fiber) + local instance: Instance = finishedWork.stateNode + local current = finishedWork.alternate + + -- Renderers may schedule work to be done after host components are mounted + -- (eg DOM renderer may schedule auto-focus for inputs and form controls). + -- These effects should only be committed when components are first mounted, + -- aka when there is no current/alternate. + if current == nil and bit32.band(finishedWork.flags, Update) ~= 0 then + local type = finishedWork.type + local props = finishedWork.memoizedProps + commitMount(instance, type, props, finishedWork) + end end local function hideOrUnhideAllChildren(finishedWork, isHidden) - if supportsMutation then - -- We only have the top Fiber that was inserted but we need to recurse down its - -- children to find all the terminal nodes. - local node: Fiber = finishedWork - while true do - if node.tag == HostComponent then - local instance = node.stateNode - if isHidden then - hideInstance(instance) - else - unhideInstance(node.stateNode, node.memoizedProps) - end - elseif node.tag == HostText then - local instance = node.stateNode - if isHidden then - hideTextInstance(instance) - else - unhideTextInstance(instance, node.memoizedProps) - end - elseif - (node.tag == OffscreenComponent or - node.tag == LegacyHiddenComponent) and - (node.memoizedState :: OffscreenState) ~= nil and - node ~= finishedWork - then - -- Found a nested Offscreen component that is hidden. Don't search - -- any deeper. This tree should remain hidden. - elseif node.child ~= nil then - -- ROBLOX FIXME: type casts to silence analyze, Luau doesn't understand nil check - (node.child :: Fiber).return_ = node - node = (node.child :: Fiber) - continue - end - if node == finishedWork then - return - end - while node.sibling == nil do - if node.return_ == nil or node.return_ == finishedWork then - return - end - -- ROBLOX FIXME: type cast to silence analyze, Luau doesn't understand nil check - node = (node.return_ :: Fiber) - end - -- ROBLOX FIXME: cast to any to silence analyze - (node.sibling :: Fiber).return_ = node.return_ - -- ROBLOX FIXME: recast to silence analyze while Luau doesn't understand nil check - node = (node.sibling :: Fiber) - end - end + if supportsMutation then + -- We only have the top Fiber that was inserted but we need to recurse down its + -- children to find all the terminal nodes. + local node: Fiber = finishedWork + while true do + if node.tag == HostComponent then + local instance = node.stateNode + if isHidden then + hideInstance(instance) + else + unhideInstance(node.stateNode, node.memoizedProps) + end + elseif node.tag == HostText then + local instance = node.stateNode + if isHidden then + hideTextInstance(instance) + else + unhideTextInstance(instance, node.memoizedProps) + end + elseif + (node.tag == OffscreenComponent or node.tag == LegacyHiddenComponent) + and (node.memoizedState :: OffscreenState) ~= nil + and node ~= finishedWork + then + -- Found a nested Offscreen component that is hidden. Don't search + -- any deeper. This tree should remain hidden. + elseif node.child ~= nil then + -- ROBLOX FIXME: type casts to silence analyze, Luau doesn't understand nil check + (node.child :: Fiber).return_ = node + node = node.child :: Fiber + continue + end + if node == finishedWork then + return + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == finishedWork then + return + end + node = node.return_ :: Fiber -- ROBLOX TODO: Luau narrowing doesn't understand this loop until nil pattern + end + -- ROBLOX FIXME: cast to any to silence analyze + (node.sibling :: Fiber).return_ = node.return_ + -- ROBLOX FIXME: recast to silence analyze while Luau doesn't understand nil check + node = node.sibling :: Fiber + end + end end function commitAttachRef(finishedWork: Fiber) - local ref = finishedWork.ref - if ref ~= nil then - local instance = finishedWork.stateNode - local instanceToUse - if finishedWork.tag == HostComponent then - instanceToUse = getPublicInstance(instance) - else - instanceToUse = instance - end - -- Moved outside to ensure DCE works with this flag - if enableScopeAPI and finishedWork.tag == ScopeComponent then - instanceToUse = instance - end - if typeof(ref) == 'function' then - ref(instanceToUse) - else - if _G.__DEV__ then - -- ROBLOX FIXME: We won't be able to recognize a ref object by checking - -- for the existence of the `current` key, since it won't be initialized - -- at this point. We might consider using a symbol to uniquely identify - -- ref objects, or relying more heavily on Luau types - -- if not ref.current then - if typeof(ref) ~= "table" then - console.error( - 'Unexpected ref object provided for %s. ' .. - 'Use either a ref-setter function or React.createRef().', - getComponentName(finishedWork.type) - ) - end - end - - ref.current = instanceToUse - end - end + local ref = finishedWork.ref + if ref ~= nil then + local instance = finishedWork.stateNode + local instanceToUse + if finishedWork.tag == HostComponent then + instanceToUse = getPublicInstance(instance) + else + instanceToUse = instance + end + -- Moved outside to ensure DCE works with this flag + -- ROBLOX performance: avoid cmp on always-false value + -- if enableScopeAPI and finishedWork.tag == ScopeComponent then + -- instanceToUse = instance + -- end + if typeof(ref) == "function" then + ref(instanceToUse) + else + if __DEV__ then + -- ROBLOX FIXME: We won't be able to recognize a ref object by checking + -- for the existence of the `current` key, since it won't be initialized + -- at this point. We might consider using a symbol to uniquely identify + -- ref objects, or relying more heavily on Luau types + -- if not ref.current then + if typeof(ref) ~= "table" then + console.error( + "Unexpected ref object provided for %s. " + .. "Use either a ref-setter function or React.createRef().", + getComponentName(finishedWork.type) or "instance" + ) + return + end + end + + ref.current = instanceToUse + end + end end function commitDetachRef(current: Fiber) - local currentRef = current.ref - if currentRef ~= nil then - if typeof(currentRef) == 'function' then - currentRef(nil) - else - currentRef.current = nil - end - end + local currentRef = current.ref + if currentRef ~= nil then + if typeof(currentRef) == "function" then + currentRef(nil) + else + currentRef.current = nil + end + end end -- User-originating errors (lifecycles and refs) should not interrupt -- deletion, so don't local them throw. Host-originating errors should -- interrupt deletion, so it's okay -commitUnmount = function( - finishedRoot: FiberRoot, - current: Fiber, - nearestMountedAncestor: Fiber, - renderPriorityLevel: ReactPriorityLevel +function commitUnmount( + finishedRoot: FiberRoot, + current: Fiber, + nearestMountedAncestor: Fiber, + renderPriorityLevel: ReactPriorityLevel ): () - onCommitUnmount(current) - - if - current.tag == FunctionComponent or - current.tag == ForwardRef or - current.tag == MemoComponent or - current.tag == SimpleMemoComponent or - current.tag == Block - then - local updateQueue: FunctionComponentUpdateQueue | nil = current.updateQueue - if updateQueue ~= nil then - local lastEffect = (updateQueue :: FunctionComponentUpdateQueue).lastEffect - if lastEffect ~= nil then - local firstEffect = lastEffect.next - - local effect = firstEffect - repeat - if effect.destroy ~= nil then - if bit32.band(effect.tag, HookLayout) ~= NoHookEffect then - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(current.mode, ProfileMode) ~= 0 - then - startLayoutEffectTimer() - safelyCallDestroy(current, nearestMountedAncestor, effect.destroy) - recordLayoutEffectDuration(current) - else - safelyCallDestroy(current, nearestMountedAncestor, effect.destroy) - end - end - end - effect = effect.next - until effect == firstEffect - end - end - return - elseif current.tag == ClassComponent then - safelyDetachRef(current, nearestMountedAncestor) - local instance = current.stateNode - if typeof(instance.componentWillUnmount) == 'function' then - safelyCallComponentWillUnmount( - current, - instance, - nearestMountedAncestor - ) - end - return - elseif current.tag == HostComponent then - safelyDetachRef(current, nearestMountedAncestor) - return - elseif current.tag == HostPortal then - -- TODO: this is recursive. - -- We are also not using this parent because - -- the portal will get pushed immediately. - if supportsMutation then - unmountHostComponents( - finishedRoot, - current, - nearestMountedAncestor, - renderPriorityLevel - ) - elseif supportsPersistence then - unimplemented("emptyPortalContainer") - -- emptyPortalContainer(current) - end - return - elseif current.tag == FundamentalComponent then - unimplemented("commitUnmount - FundamentalComponent") - -- if enableFundamentalAPI then - -- local fundamentalInstance = current.stateNode - -- if fundamentalInstance ~= nil then - -- unmountFundamentalComponent(fundamentalInstance) - -- current.stateNode = nil - -- end - -- end - -- return - elseif current.tag == DehydratedFragment then - unimplemented("commitUnmount - DehydratedFragment") - -- if enableSuspenseCallback then - -- local hydrationCallbacks = finishedRoot.hydrationCallbacks - -- if hydrationCallbacks ~= nil then - -- local onDeleted = hydrationCallbacks.onDeleted - -- if onDeleted then - -- onDeleted((current.stateNode: SuspenseInstance)) - -- end - -- end - -- end - -- return - elseif current.tag == ScopeComponent then - if enableScopeAPI then - safelyDetachRef(current, nearestMountedAncestor) - end - return - end + onCommitUnmount(current) + + if + current.tag == FunctionComponent + or current.tag == ForwardRef + or current.tag == MemoComponent + or current.tag == SimpleMemoComponent + or current.tag == Block + then + local updateQueue: FunctionComponentUpdateQueue | nil = current.updateQueue + if updateQueue ~= nil then + local lastEffect = (updateQueue :: FunctionComponentUpdateQueue).lastEffect + if lastEffect ~= nil then + local firstEffect = lastEffect.next + + local effect = firstEffect + repeat + if effect.destroy ~= nil then + if bit32.band(effect.tag, HookLayout) ~= NoHookEffect then + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(current.mode, ProfileMode) ~= 0 + then + startLayoutEffectTimer() + safelyCallDestroy( + current, + nearestMountedAncestor, + effect.destroy + ) + recordLayoutEffectDuration(current) + else + safelyCallDestroy( + current, + nearestMountedAncestor, + effect.destroy + ) + end + end + end + effect = effect.next + until effect == firstEffect + end + end + return + elseif current.tag == ClassComponent then + safelyDetachRef(current, nearestMountedAncestor) + local instance = current.stateNode + if typeof(instance.componentWillUnmount) == "function" then + safelyCallComponentWillUnmount(current, instance, nearestMountedAncestor) + end + return + elseif current.tag == HostComponent then + safelyDetachRef(current, nearestMountedAncestor) + return + elseif current.tag == HostPortal then + -- TODO: this is recursive. + -- We are also not using this parent because + -- the portal will get pushed immediately. + if supportsMutation then + unmountHostComponents( + finishedRoot, + current, + nearestMountedAncestor, + renderPriorityLevel + ) + elseif supportsPersistence then + unimplemented("emptyPortalContainer") + -- emptyPortalContainer(current) + end + return + -- elseif current.tag == FundamentalComponent then + -- unimplemented("commitUnmount - FundamentalComponent") + -- if enableFundamentalAPI then + -- local fundamentalInstance = current.stateNode + -- if fundamentalInstance ~= nil then + -- unmountFundamentalComponent(fundamentalInstance) + -- current.stateNode = nil + -- end + -- end + -- return + -- elseif current.tag == DehydratedFragment then + -- unimplemented("commitUnmount - DehydratedFragment") + -- if enableSuspenseCallback then + -- local hydrationCallbacks = finishedRoot.hydrationCallbacks + -- if hydrationCallbacks ~= nil then + -- local onDeleted = hydrationCallbacks.onDeleted + -- if onDeleted then + -- onDeleted((current.stateNode: SuspenseInstance)) + -- end + -- end + -- end + -- return + -- elseif current.tag == ScopeComponent then + -- if enableScopeAPI then + -- safelyDetachRef(current, nearestMountedAncestor) + -- end + -- return + end end -commitNestedUnmounts = function( - finishedRoot: FiberRoot, - root: Fiber, - nearestMountedAncestor: Fiber, - renderPriorityLevel: ReactPriorityLevel +function commitNestedUnmounts( + finishedRoot: FiberRoot, + root: Fiber, + nearestMountedAncestor: Fiber, + renderPriorityLevel: ReactPriorityLevel ) - -- While we're inside a removed host node we don't want to call - -- removeChild on the inner nodes because they're removed by the top - -- call anyway. We also want to call componentWillUnmount on all - -- composites before this host node is removed from the tree. Therefore - -- we do an inner loop while we're still inside the host node. - local node: Fiber = root - while true do - commitUnmount( - finishedRoot, - node, - nearestMountedAncestor, - renderPriorityLevel - ) - -- Visit children because they may contain more composite or host nodes. - -- Skip portals because commitUnmount() currently visits them recursively. - if - node.child ~= nil and - -- If we use mutation we drill down into portals using commitUnmount above. - -- If we don't use mutation we drill down into portals here instead. - (not supportsMutation or node.tag ~= HostPortal) - then - (node.child :: Fiber).return_ = node - node = node.child :: Fiber - continue - end - if node == root then - return - end - while node.sibling == nil do - if node.return_ == nil or node.return_ == root then - return - end - node = node.return_ :: Fiber - end - (node.sibling :: Fiber).return_ = node.return_ - node = node.sibling :: Fiber - end + -- While we're inside a removed host node we don't want to call + -- removeChild on the inner nodes because they're removed by the top + -- call anyway. We also want to call componentWillUnmount on all + -- composites before this host node is removed from the tree. Therefore + -- we do an inner loop while we're still inside the host node. + local node: Fiber = root + while true do + commitUnmount(finishedRoot, node, nearestMountedAncestor, renderPriorityLevel) + -- Visit children because they may contain more composite or host nodes. + -- Skip portals because commitUnmount() currently visits them recursively. + if + node.child ~= nil + -- If we use mutation we drill down into portals using commitUnmount above. + -- If we don't use mutation we drill down into portals here instead. + and (not supportsMutation or node.tag ~= HostPortal) + then + (node.child :: Fiber).return_ = node + node = node.child :: Fiber + continue + end + if node == root then + return + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == root then + return + end + node = node.return_ :: Fiber -- ROBLOX TODO: Luau narrowing doesn't understand this loop until nil pattern + end + (node.sibling :: Fiber).return_ = node.return_ + node = node.sibling :: Fiber + end end local function detachFiberMutation(fiber: Fiber) - -- Cut off the return pointer to disconnect it from the tree. - -- This enables us to detect and warn against state updates on an unmounted component. - -- It also prevents events from bubbling from within disconnected components. - -- - -- Ideally, we should also clear the child pointer of the parent alternate to local this - -- get GC:ed but we don't know which for sure which parent is the current - -- one so we'll settle for GC:ing the subtree of this child. - -- This child itself will be GC:ed when the parent updates the next time. - -- - -- Note that we can't clear child or sibling pointers yet. - -- They're needed for passive effects and for findDOMNode. - -- We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects). - local alternate = fiber.alternate - if alternate ~= nil then - alternate.return_ = nil - fiber.alternate = nil - end - fiber.return_ = nil + -- Cut off the return pointer to disconnect it from the tree. + -- This enables us to detect and warn against state updates on an unmounted component. + -- It also prevents events from bubbling from within disconnected components. + -- + -- Ideally, we should also clear the child pointer of the parent alternate to local this + -- get GC:ed but we don't know which for sure which parent is the current + -- one so we'll settle for GC:ing the subtree of this child. + -- This child itself will be GC:ed when the parent updates the next time. + -- + -- Note that we can't clear child or sibling pointers yet. + -- They're needed for passive effects and for findDOMNode. + -- We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects). + local alternate = fiber.alternate + if alternate ~= nil then + alternate.return_ = nil + fiber.alternate = nil + end + fiber.return_ = nil end -- function emptyPortalContainer(current: Fiber) @@ -1420,970 +1385,956 @@ end -- end local function getHostParentFiber(fiber: Fiber): Fiber - local parent = fiber.return_ - while parent ~= nil do - if isHostParent(parent) then - return parent - end - parent = parent.return_ - end - invariant( - false, - "Expected to find a host parent. This error is likely caused by a bug " .. - "in React. Please file an issue." - ) - -- FIXME (roblox): roblox-cli doesn't understand that `invariant` throws, so - -- we need an explicit return to satisfy it; the actual value doesn't matter - return parent + local parent = fiber.return_ + while parent ~= nil do + if isHostParent(parent) then + return parent + end + parent = parent.return_ + end + -- ROBLOX deviation START: use React 18 approach, which Luau understands better than invariant + error( + Error.new( + "Expected to find a host parent. This error is likely caused by a bug " + .. "in React. Please file an issue." + ) + ) + -- ROBLOX deviation END end -isHostParent = function(fiber: Fiber): boolean - return - fiber.tag == HostComponent or - fiber.tag == HostRoot or - fiber.tag == HostPortal +function isHostParent(fiber: Fiber): boolean + return fiber.tag == HostComponent or fiber.tag == HostRoot or fiber.tag == HostPortal end -getHostSibling = function(fiber: Fiber): Instance? - -- We're going to search forward into the tree until we find a sibling host - -- node. Unfortunately, if multiple insertions are done in a row we have to - -- search past them. This leads to exponential search for the next sibling. - -- TODO: Find a more efficient way to do this. - local node: Fiber = fiber - while true do - -- ROBLOX deviation: we can't `continue` with labels in luau, so some variable - -- juggling is used instead - local continueOuter = false - -- If we didn't find anything, let's try the next sibling. - while node.sibling == nil do - if node.return_ == nil or isHostParent(node.return_) then - -- If we pop out of the root or hit the parent the fiber we are the - -- last sibling. - return nil - end - node = node.return_ :: Fiber - end - (node.sibling :: Fiber).return_ = node.return_ :: Fiber - node = node.sibling :: Fiber - while - node.tag ~= HostComponent and - node.tag ~= HostText and - node.tag ~= DehydratedFragment - do - -- If it is not host node and, we might have a host node inside it. - -- Try to search down until we find one. - if bit32.band(node.flags, Placement) ~= 0 then - -- If we don't have a child, try the siblings instead. - continueOuter = true - break - end - -- If we don't have a child, try the siblings instead. - -- We also skip portals because they are not part of this host tree. - if node.child == nil or node.tag == HostPortal then - continueOuter = true - break - else - (node.child :: Fiber).return_ = node - node = node.child :: Fiber - end - end - if continueOuter then - continue - end - -- Check if this host node is stable or about to be placed. - if bit32.band(node.flags, Placement) == 0 then - -- Found it! - return node.stateNode - end - end +function getHostSibling(fiber: Fiber): Instance? + -- We're going to search forward into the tree until we find a sibling host + -- node. Unfortunately, if multiple insertions are done in a row we have to + -- search past them. This leads to exponential search for the next sibling. + -- TODO: Find a more efficient way to do this. + local node: Fiber = fiber + while true do + -- ROBLOX deviation: we can't `continue` with labels in luau, so some variable + -- juggling is used instead + local continueOuter = false + -- If we didn't find anything, let's try the next sibling. + while node.sibling == nil do + if node.return_ == nil or isHostParent(node.return_) then + -- If we pop out of the root or hit the parent the fiber we are the + -- last sibling. + return nil + end + node = node.return_ :: Fiber -- ROBLOX TODO: Luau narrowing doesn't understand this loop until nil pattern + end + (node.sibling :: Fiber).return_ = node.return_ :: Fiber + node = node.sibling :: Fiber + while + node.tag ~= HostComponent + and node.tag ~= HostText + and node.tag ~= DehydratedFragment + do + -- If it is not host node and, we might have a host node inside it. + -- Try to search down until we find one. + if bit32.band(node.flags, Placement) ~= 0 then + -- If we don't have a child, try the siblings instead. + continueOuter = true + break + end + -- If we don't have a child, try the siblings instead. + -- We also skip portals because they are not part of this host tree. + if node.child == nil or node.tag == HostPortal then + continueOuter = true + break + else + (node.child :: Fiber).return_ = node + node = node.child :: Fiber + end + end + if continueOuter then + continue + end + -- Check if this host node is stable or about to be placed. + if bit32.band(node.flags, Placement) == 0 then + -- Found it! + return node.stateNode + end + end end local function commitPlacement(finishedWork: Fiber) - if not supportsMutation then - return - end - - -- Recursively insert all host nodes into the parent. - local parentFiber = getHostParentFiber(finishedWork) - - -- Note: these two variables *must* always be updated together. - local parent - local isContainer - local parentStateNode = parentFiber.stateNode - if parentFiber.tag == HostComponent then - parent = parentStateNode - isContainer = false - elseif parentFiber.tag == HostRoot then - parent = parentStateNode.containerInfo - isContainer = true - elseif parentFiber.tag == HostPortal then - parent = parentStateNode.containerInfo - isContainer = true - elseif parentFiber.tag == FundamentalComponent then - if enableFundamentalAPI then - parent = parentStateNode.instance - isContainer = false - end - else - -- eslint-disable-next-line-no-fallthrough - invariant( - false, - "Invalid host parent fiber. This error is likely caused by a bug " .. - "in React. Please file an issue." - ) - end - if bit32.band(parentFiber.flags, ContentReset) ~= 0 then - -- Reset the text content of the parent before doing any insertions - resetTextContent(parent) - -- Clear ContentReset from the effect tag - parentFiber.flags = bit32.band(parentFiber.flags, bit32.bnot(ContentReset)) - end - - local before = getHostSibling(finishedWork) - -- We only have the top Fiber that was inserted but we need to recurse down its - -- children to find all the terminal nodes. - if isContainer then - insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent) - else - insertOrAppendPlacementNode(finishedWork, before, parent) - end + if not supportsMutation then + return + end + + -- Recursively insert all host nodes into the parent. + local parentFiber = getHostParentFiber(finishedWork) + + -- Note: these two variables *must* always be updated together. + local parent + local isContainer + local parentStateNode = parentFiber.stateNode + if parentFiber.tag == HostComponent then + parent = parentStateNode + isContainer = false + elseif parentFiber.tag == HostRoot then + parent = parentStateNode.containerInfo + isContainer = true + elseif parentFiber.tag == HostPortal then + parent = parentStateNode.containerInfo + isContainer = true + -- elseif parentFiber.tag == FundamentalComponent then + -- if enableFundamentalAPI then + -- parent = parentStateNode.instance + -- isContainer = false + -- end + else + -- eslint-disable-next-line-no-fallthrough + invariant( + false, + "Invalid host parent fiber. This error is likely caused by a bug " + .. "in React. Please file an issue." + ) + end + if bit32.band(parentFiber.flags, ContentReset) ~= 0 then + -- Reset the text content of the parent before doing any insertions + resetTextContent(parent) + -- Clear ContentReset from the effect tag + parentFiber.flags = bit32.band(parentFiber.flags, bit32.bnot(ContentReset)) + end + + local before = getHostSibling(finishedWork) + -- We only have the top Fiber that was inserted but we need to recurse down its + -- children to find all the terminal nodes. + if isContainer then + insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent) + else + insertOrAppendPlacementNode(finishedWork, before, parent) + end end -insertOrAppendPlacementNodeIntoContainer = function( - node: Fiber, - before: Instance?, - parent: Container +function insertOrAppendPlacementNodeIntoContainer( + node: Fiber, + before: Instance?, + parent: Container ) - local tag = node.tag - local isHost = tag == HostComponent or tag == HostText - if isHost or (enableFundamentalAPI and tag == FundamentalComponent) then - local stateNode - if isHost then - stateNode = node.stateNode - else - stateNode = node.stateNode.instance - end - if before then - insertInContainerBefore(parent, stateNode, before) - else - appendChildToContainer(parent, stateNode) - end - elseif tag == HostPortal then - -- If the insertion itself is a portal, then we don't want to traverse - -- down its children. Instead, we'll get insertions from each child in - -- the portal directly. - else - local child = node.child - if child ~= nil then - insertOrAppendPlacementNodeIntoContainer(child, before, parent) - local sibling = child.sibling - while sibling ~= nil do - insertOrAppendPlacementNodeIntoContainer(sibling, before, parent) - sibling = sibling.sibling - end - end - end + local tag = node.tag + local isHost = tag == HostComponent or tag == HostText + -- ROBLOX performance: avoid always-false compare for Roblox renderer in hot path + if isHost then -- or (enableFundamentalAPI and tag == FundamentalComponent) then + local stateNode = node.stateNode + if before then + insertInContainerBefore(parent, stateNode, before) + else + appendChildToContainer(parent, stateNode) + end + elseif tag == HostPortal then + -- If the insertion itself is a portal, then we don't want to traverse + -- down its children. Instead, we'll get insertions from each child in + -- the portal directly. + else + local child = node.child + if child ~= nil then + insertOrAppendPlacementNodeIntoContainer(child, before, parent) + local sibling = child.sibling + while sibling ~= nil do + insertOrAppendPlacementNodeIntoContainer(sibling, before, parent) + sibling = sibling.sibling + end + end + end end -insertOrAppendPlacementNode = function( - node: Fiber, - before: Instance?, - parent: Instance -) - local tag = node.tag - local isHost = tag == HostComponent or tag == HostText - if isHost or (enableFundamentalAPI and tag == FundamentalComponent) then - local stateNode - if isHost then - stateNode = node.stateNode - else - stateNode = node.stateNode.instance - end - if before then - insertBefore(parent, stateNode, before) - else - appendChild(parent, stateNode) - end - elseif tag == HostPortal then - -- If the insertion itself is a portal, then we don't want to traverse - -- down its children. Instead, we'll get insertions from each child in - -- the portal directly. - else - local child = node.child - if child ~= nil then - insertOrAppendPlacementNode(child, before, parent) - local sibling = child.sibling - while sibling ~= nil do - insertOrAppendPlacementNode(sibling, before, parent) - sibling = sibling.sibling - end - end - end +function insertOrAppendPlacementNode(node: Fiber, before: Instance?, parent: Instance): () + local tag = node.tag + local isHost = tag == HostComponent or tag == HostText + -- ROBLOX performance: avoid always-false compare for Roblox renderer in hot path + if isHost then -- or (enableFundamentalAPI and tag == FundamentalComponent) then + local stateNode = node.stateNode + if before then + insertBefore(parent, stateNode, before) + else + appendChild(parent, stateNode) + end + elseif tag == HostPortal then + -- If the insertion itself is a portal, then we don't want to traverse + -- down its children. Instead, we'll get insertions from each child in + -- the portal directly. + else + local child = node.child + if child ~= nil then + insertOrAppendPlacementNode(child, before, parent) + local sibling = child.sibling + while sibling ~= nil do + insertOrAppendPlacementNode(sibling, before, parent) + sibling = sibling.sibling + end + end + end end -unmountHostComponents = function( - finishedRoot: FiberRoot, - current: Fiber, - nearestMountedAncestor: Fiber, - renderPriorityLevel: ReactPriorityLevel -) - -- We only have the top Fiber that was deleted but we need to recurse down its - -- children to find all the terminal nodes. - -- ROBLOX TODO: type refinement - -- local node: Fiber = current - local node = current - - -- Each iteration, currentParent is populated with node's host parent if not - -- currentParentIsValid. - local currentParentIsValid = false - - -- Note: these two variables *must* always be updated together. - local currentParent - local currentParentIsContainer - - while true do - if not currentParentIsValid then - local parent = node.return_ - while true do - invariant( - parent ~= nil, - "Expected to find a host parent. This error is likely caused by " .. - "a bug in React. Please file an issue." - ) - local parentStateNode = parent.stateNode - if parent.tag == HostComponent then - currentParent = parentStateNode - currentParentIsContainer = false - break - elseif parent.tag == HostRoot then - currentParent = parentStateNode.containerInfo - currentParentIsContainer = true - break - elseif parent.tag == HostPortal then - currentParent = parentStateNode.containerInfo - currentParentIsContainer = true - break - elseif parent.tag == FundamentalComponent then - if enableFundamentalAPI then - currentParent = parentStateNode.instance - currentParentIsContainer = false - end - end - parent = parent.return_ - end - currentParentIsValid = true - end - - if node.tag == HostComponent or node.tag == HostText then - commitNestedUnmounts( - finishedRoot, - node, - nearestMountedAncestor, - renderPriorityLevel - ) - -- After all the children have unmounted, it is now safe to remove the - -- node from the tree. - if currentParentIsContainer then - -- removeChildFromContainer( - -- ((currentParent: any): Container), - -- (fundamentalNode: Instance), - -- ) - -- ROBLOX FIXME: type coercion - removeChildFromContainer(currentParent, node.stateNode) - else - -- removeChild( - -- ((currentParent: any): Instance), - -- (fundamentalNode: Instance), - -- ) - -- ROBLOX FIXME: type coercion - removeChild(currentParent, node.stateNode) - end - -- Don't visit children because we already visited them. - elseif enableFundamentalAPI and node.tag == FundamentalComponent then - local fundamentalNode = node.stateNode.instance - commitNestedUnmounts( - finishedRoot, - node, - nearestMountedAncestor, - renderPriorityLevel - ) - -- After all the children have unmounted, it is now safe to remove the - -- node from the tree. - if currentParentIsContainer then - -- removeChildFromContainer( - -- ((currentParent: any): Container), - -- (fundamentalNode: Instance), - -- ) - -- ROBLOX FIXME: type coercion - removeChildFromContainer(currentParent, fundamentalNode) - else - -- removeChild( - -- ((currentParent: any): Instance), - -- (fundamentalNode: Instance), - -- ) - -- ROBLOX FIXME: type coercion - removeChild(currentParent, fundamentalNode) - end - elseif - enableSuspenseServerRenderer and - node.tag == DehydratedFragment - then - unimplemented("clearSuspenseBoundary") - -- if enableSuspenseCallback then - -- local hydrationCallbacks = finishedRoot.hydrationCallbacks - -- if hydrationCallbacks ~= nil) - -- local onDeleted = hydrationCallbacks.onDeleted - -- if onDeleted) - -- onDeleted((node.stateNode: SuspenseInstance)) - -- end - -- end - -- end - - -- -- Delete the dehydrated suspense boundary and all of its content. - -- if currentParentIsContainer) - -- clearSuspenseBoundaryFromContainer( - -- ((currentParent: any): Container), - -- (node.stateNode: SuspenseInstance), - -- ) - -- } else { - -- clearSuspenseBoundary( - -- ((currentParent: any): Instance), - -- (node.stateNode: SuspenseInstance), - -- ) - -- end - elseif node.tag == HostPortal then - if node.child ~= nil then - -- When we go into a portal, it becomes the parent to remove from. - -- We will reassign it back when we pop the portal on the way up. - currentParent = node.stateNode.containerInfo - currentParentIsContainer = true - -- Visit children because portals might contain host components. - node.child.return_ = node - node = node.child - continue - end - else - commitUnmount( - finishedRoot, - node, - nearestMountedAncestor, - renderPriorityLevel - ) - -- Visit children because we may find more host components below. - if node.child ~= nil then - node.child.return_ = node - node = node.child - continue - end - end - if node == current then - return - end - while node.sibling == nil do - if node.return_ == nil or node.return_ == current then - return - end - node = node.return_ - if node.tag == HostPortal then - -- When we go out of the portal, we need to restore the parent. - -- Since we don't keep a stack of them, we will search for it. - currentParentIsValid = false - end - end - node.sibling.return_ = node.return_ - node = node.sibling - end +function unmountHostComponents( + finishedRoot: FiberRoot, + current: Fiber, + nearestMountedAncestor: Fiber, + renderPriorityLevel: ReactPriorityLevel +): () + -- We only have the top Fiber that was deleted but we need to recurse down its + -- children to find all the terminal nodes. + local node: Fiber = current + + -- Each iteration, currentParent is populated with node's host parent if not + -- currentParentIsValid. + local currentParentIsValid = false + + -- Note: these two variables *must* always be updated together. + local currentParent + local currentParentIsContainer + + while true do + if not currentParentIsValid then + -- ROBLOX FIXME Luau: Luau doesn't understand the nil guard at the top of the loop + local parent = node.return_ :: Fiber + while true do + -- ROBLOX deviation START: use React 18 approach so Luau understands control flow better + if parent == nil then + error( + Error.new( + "Expected to find a host parent. This error is likely caused by " + .. "a bug in React. Please file an issue." + ) + ) + end + -- ROBLOX deviation END + local parentStateNode = parent.stateNode + if parent.tag == HostComponent then + currentParent = parentStateNode + currentParentIsContainer = false + break + elseif parent.tag == HostRoot then + currentParent = parentStateNode.containerInfo + currentParentIsContainer = true + break + elseif parent.tag == HostPortal then + currentParent = parentStateNode.containerInfo + currentParentIsContainer = true + break + -- ROBLOX performance: eliminate always-false compare for Roblox in hot path + -- elseif parent.tag == FundamentalComponent then + -- if enableFundamentalAPI then + -- currentParent = parentStateNode.instance + -- currentParentIsContainer = false + -- end + end + -- ROBLOX FIXME Luau: Luau doesn't understand the nil guard at the top of the loop + parent = parent.return_ :: Fiber + end + currentParentIsValid = true + end + + if node.tag == HostComponent or node.tag == HostText then + commitNestedUnmounts( + finishedRoot, + node, + nearestMountedAncestor, + renderPriorityLevel + ) + -- After all the children have unmounted, it is now safe to remove the + -- node from the tree. + if currentParentIsContainer then + -- removeChildFromContainer( + -- ((currentParent: any): Container), + -- (fundamentalNode: Instance), + -- ) + -- ROBLOX FIXME: type coercion + removeChildFromContainer(currentParent, node.stateNode) + else + -- removeChild( + -- ((currentParent: any): Instance), + -- (fundamentalNode: Instance), + -- ) + -- ROBLOX FIXME: type coercion + removeChild(currentParent, node.stateNode) + end + -- Don't visit children because we already visited them. + -- ROBLOX performance? fundamentalAPI and suspenseServerRender are always false for Roblox. avoid unnecessary cmp in hot path + -- elseif enableFundamentalAPI and node.tag == FundamentalComponent then + -- local fundamentalNode = node.stateNode.instance + -- commitNestedUnmounts( + -- finishedRoot, + -- node, + -- nearestMountedAncestor, + -- renderPriorityLevel + -- ) + -- -- After all the children have unmounted, it is now safe to remove the + -- -- node from the tree. + -- if currentParentIsContainer then + -- -- removeChildFromContainer( + -- -- ((currentParent: any): Container), + -- -- (fundamentalNode: Instance), + -- -- ) + -- -- ROBLOX FIXME: type coercion + -- removeChildFromContainer(currentParent, fundamentalNode) + -- else + -- -- removeChild( + -- -- ((currentParent: any): Instance), + -- -- (fundamentalNode: Instance), + -- -- ) + -- -- ROBLOX FIXME: type coercion + -- removeChild(currentParent, fundamentalNode) + -- end + -- elseif + -- enableSuspenseServerRenderer and + -- node.tag == DehydratedFragment + -- then + -- unimplemented("clearSuspenseBoundary") + -- -- if enableSuspenseCallback then + -- -- local hydrationCallbacks = finishedRoot.hydrationCallbacks + -- -- if hydrationCallbacks ~= nil) + -- -- local onDeleted = hydrationCallbacks.onDeleted + -- -- if onDeleted) + -- -- onDeleted((node.stateNode: SuspenseInstance)) + -- -- end + -- -- end + -- -- end + + -- -- -- Delete the dehydrated suspense boundary and all of its content. + -- -- if currentParentIsContainer) + -- -- clearSuspenseBoundaryFromContainer( + -- -- ((currentParent: any): Container), + -- -- (node.stateNode: SuspenseInstance), + -- -- ) + -- -- } else { + -- -- clearSuspenseBoundary( + -- -- ((currentParent: any): Instance), + -- -- (node.stateNode: SuspenseInstance), + -- -- ) + -- -- end + elseif node.tag == HostPortal then + if node.child ~= nil then + -- When we go into a portal, it becomes the parent to remove from. + -- We will reassign it back when we pop the portal on the way up. + currentParent = node.stateNode.containerInfo + currentParentIsContainer = true + -- Visit children because portals might contain host components. + node.child.return_ = node + node = node.child + continue + end + else + commitUnmount(finishedRoot, node, nearestMountedAncestor, renderPriorityLevel) + -- Visit children because we may find more host components below. + if node.child ~= nil then + node.child.return_ = node + node = node.child + continue + end + end + if node == current then + return + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == current then + return + end + -- ROBLOX FIXME Luau: Luau doesn't understand narrowing by guard above + node = node.return_ :: Fiber + if node.tag == HostPortal then + -- When we go out of the portal, we need to restore the parent. + -- Since we don't keep a stack of them, we will search for it. + currentParentIsValid = false + end + end + -- ROBLOX TODO: flowtype makes an impossible leap here, contribute this annotation upstream + (node.sibling :: Fiber).return_ = node.return_ + node = node.sibling :: Fiber + end end local function commitDeletion( - finishedRoot: FiberRoot, - current: Fiber, - nearestMountedAncestor: Fiber, - renderPriorityLevel: ReactPriorityLevel -) - if supportsMutation then - -- Recursively delete all host nodes from the parent. - -- Detach refs and call componentWillUnmount() on the whole subtree. - unmountHostComponents( - finishedRoot, - current, - nearestMountedAncestor, - renderPriorityLevel - ) - else - -- Detach refs and call componentWillUnmount() on the whole subtree. - commitNestedUnmounts( - finishedRoot, - current, - nearestMountedAncestor, - renderPriorityLevel - ) - end - local alternate = current.alternate - detachFiberMutation(current) - if alternate ~= nil then - detachFiberMutation(alternate) - end + finishedRoot: FiberRoot, + current: Fiber, + nearestMountedAncestor: Fiber, + renderPriorityLevel: ReactPriorityLevel +): () + -- ROBLOX performance? supportsMutation always true, eliminate cmp on hot path + -- if supportsMutation then + -- Recursively delete all host nodes from the parent. + -- Detach refs and call componentWillUnmount() on the whole subtree. + unmountHostComponents( + finishedRoot, + current, + nearestMountedAncestor, + renderPriorityLevel + ) + -- else + -- -- Detach refs and call componentWillUnmount() on the whole subtree. + -- commitNestedUnmounts( + -- finishedRoot, + -- current, + -- nearestMountedAncestor, + -- renderPriorityLevel + -- ) + -- end + local alternate = current.alternate + detachFiberMutation(current) + if alternate ~= nil then + detachFiberMutation(alternate) + end end - local function commitWork(current: Fiber | nil, finishedWork: Fiber) - if not supportsMutation then - unimplemented("commitWork: non-mutation branch") - -- switch (finishedWork.tag) - -- case FunctionComponent: - -- case ForwardRef: - -- case MemoComponent: - -- case SimpleMemoComponent: - -- case Block: { - -- -- Layout effects are destroyed during the mutation phase so that all - -- -- destroy functions for all fibers are called before any create functions. - -- -- This prevents sibling component effects from interfering with each other, - -- -- e.g. a destroy function in one component should never override a ref set - -- -- by a create function in another component during the same commit. - -- if - -- enableProfilerTimer and - -- enableProfilerCommitHooks and - -- finishedWork.mode & ProfileMode - -- ) - -- try { - -- startLayoutEffectTimer() - -- commitHookEffectListUnmount( - -- HookLayout | HookHasEffect, - -- finishedWork, - -- finishedWork.return_, - -- ) - -- } finally { - -- recordLayoutEffectDuration(finishedWork) - -- end - -- } else { - -- commitHookEffectListUnmount( - -- HookLayout | HookHasEffect, - -- finishedWork, - -- finishedWork.return_, - -- ) - -- end - -- return - -- end - -- case Profiler: { - -- return - -- end - -- case SuspenseComponent: { - -- commitSuspenseComponent(finishedWork) - -- attachSuspenseRetryListeners(finishedWork) - -- return - -- end - -- case SuspenseListComponent: { - -- attachSuspenseRetryListeners(finishedWork) - -- return - -- end - -- case HostRoot: { - -- if supportsHydration) - -- local root: FiberRoot = finishedWork.stateNode - -- if root.hydrate) - -- -- We've just hydrated. No need to hydrate again. - -- root.hydrate = false - -- commitHydratedContainer(root.containerInfo) - -- end - -- end - -- break - -- end - -- case OffscreenComponent: - -- case LegacyHiddenComponent: { - -- return - -- end - -- end - - -- commitContainer(finishedWork) - -- return - end - - if - finishedWork.tag == FunctionComponent or - finishedWork.tag == ForwardRef or - finishedWork.tag == MemoComponent or - finishedWork.tag == SimpleMemoComponent or - finishedWork.tag == Block - then - -- Layout effects are destroyed during the mutation phase so that all - -- destroy functions for all fibers are called before any create functions. - -- This prevents sibling component effects from interfering with each other, - -- e.g. a destroy function in one component should never override a ref set - -- by a create function in another component during the same commit. - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - -- ROBLOX try - local ok, result = pcall(function() - startLayoutEffectTimer() - commitHookEffectListUnmount( - bit32.bor(HookLayout, HookHasEffect), - finishedWork, - finishedWork.return_ - ) - end) - -- ROBLOX finally - recordLayoutEffectDuration(finishedWork) - if not ok then - error(result) - end - else - commitHookEffectListUnmount( - bit32.bor(HookLayout, HookHasEffect), - finishedWork, - finishedWork.return_ - ) - end - return - elseif finishedWork.tag == ClassComponent then - return - elseif finishedWork.tag == HostComponent then - local instance: Instance = finishedWork.stateNode - if instance ~= nil then - -- Commit the work prepared earlier. - local newProps = finishedWork.memoizedProps - -- For hydration we reuse the update path but we treat the oldProps - -- as the newProps. The updatePayload will contain the real change in - -- this case. - local oldProps - if current then - oldProps = current.memoizedProps - else - oldProps = newProps - end - local type = finishedWork.type - -- TODO: Type the updateQueue to be specific to host components. - -- FIXME (roblox): type coercion - -- local updatePayload: nil | UpdatePayload = (finishedWork.updateQueue: any) - local updatePayload = finishedWork.updateQueue - finishedWork.updateQueue = nil - if updatePayload ~= nil then - commitUpdate( - instance, - updatePayload, - type, - oldProps, - newProps, - finishedWork - ) - end - end - return - elseif finishedWork.tag == HostText then - invariant( - finishedWork.stateNode ~= nil, - 'This should have a text node initialized. This error is likely ' .. - 'caused by a bug in React. Please file an issue.' - ) - local textInstance: TextInstance = finishedWork.stateNode - local newText: string = finishedWork.memoizedProps - -- For hydration we reuse the update path but we treat the oldProps - -- as the newProps. The updatePayload will contain the real change in - -- this case. - local oldText: string - if current ~= nil then - oldText = (current :: Fiber).memoizedProps - oldText = newText - end - commitTextUpdate(textInstance, oldText, newText) - return - elseif finishedWork.tag == HostRoot then - if supportsHydration then - local root: FiberRoot = finishedWork.stateNode - if root.hydrate then - -- We've just hydrated. No need to hydrate again. - root.hydrate = false - unimplemented("commitWork: HostRoot: commitHydratedContainer") - -- commitHydratedContainer(root.containerInfo) - end - end - return - elseif finishedWork.tag == Profiler then - return - elseif finishedWork.tag == SuspenseComponent then - commitSuspenseComponent(finishedWork) - attachSuspenseRetryListeners(finishedWork) - return - elseif finishedWork.tag == SuspenseListComponent then - unimplemented("commitWork: SuspenseListComponent") - -- attachSuspenseRetryListeners(finishedWork) - -- return - elseif finishedWork.tag == IncompleteClassComponent then - return - elseif finishedWork.tag == FundamentalComponent then - unimplemented("commitWork: FundamentalComponent") - -- if enableFundamentalAPI) - -- local fundamentalInstance = finishedWork.stateNode - -- updateFundamentalComponent(fundamentalInstance) - -- return - -- end - -- break - elseif finishedWork.tag == ScopeComponent then - unimplemented("commitWork: ScopeComponent") - -- if enableScopeAPI) - -- local scopeInstance = finishedWork.stateNode - -- prepareScopeUpdate(scopeInstance, finishedWork) - -- return - -- end - -- break - elseif - finishedWork.tag == OffscreenComponent or - finishedWork.tag == LegacyHiddenComponent - then - local newState: OffscreenState | nil = finishedWork.memoizedState - local isHidden = newState ~= nil - hideOrUnhideAllChildren(finishedWork, isHidden) - return - end - invariant( - false, - "This unit of work tag should not have side-effects. This error is " .. - "likely caused by a bug in React. Please file an issue." - ) +local function commitWork(current: Fiber | nil, finishedWork: Fiber) + -- if not supportsMutation then + -- unimplemented("commitWork: non-mutation branch") + -- switch (finishedWork.tag) + -- case FunctionComponent: + -- case ForwardRef: + -- case MemoComponent: + -- case SimpleMemoComponent: + -- case Block: { + -- -- Layout effects are destroyed during the mutation phase so that all + -- -- destroy functions for all fibers are called before any create functions. + -- -- This prevents sibling component effects from interfering with each other, + -- -- e.g. a destroy function in one component should never override a ref set + -- -- by a create function in another component during the same commit. + -- if + -- enableProfilerTimer and + -- enableProfilerCommitHooks and + -- finishedWork.mode & ProfileMode + -- ) + -- try { + -- startLayoutEffectTimer() + -- commitHookEffectListUnmount( + -- HookLayout | HookHasEffect, + -- finishedWork, + -- finishedWork.return_, + -- ) + -- } finally { + -- recordLayoutEffectDuration(finishedWork) + -- end + -- } else { + -- commitHookEffectListUnmount( + -- HookLayout | HookHasEffect, + -- finishedWork, + -- finishedWork.return_, + -- ) + -- end + -- return + -- end + -- case Profiler: { + -- return + -- end + -- case SuspenseComponent: { + -- commitSuspenseComponent(finishedWork) + -- attachSuspenseRetryListeners(finishedWork) + -- return + -- end + -- case SuspenseListComponent: { + -- attachSuspenseRetryListeners(finishedWork) + -- return + -- end + -- case HostRoot: { + -- if supportsHydration) + -- local root: FiberRoot = finishedWork.stateNode + -- if root.hydrate) + -- -- We've just hydrated. No need to hydrate again. + -- root.hydrate = false + -- commitHydratedContainer(root.containerInfo) + -- end + -- end + -- break + -- end + -- case OffscreenComponent: + -- case LegacyHiddenComponent: { + -- return + -- end + -- end + + -- commitContainer(finishedWork) + -- return + -- end + + if + finishedWork.tag == FunctionComponent + or finishedWork.tag == ForwardRef + or finishedWork.tag == MemoComponent + or finishedWork.tag == SimpleMemoComponent + or finishedWork.tag == Block + then + -- Layout effects are destroyed during the mutation phase so that all + -- destroy functions for all fibers are called before any create functions. + -- This prevents sibling component effects from interfering with each other, + -- e.g. a destroy function in one component should never override a ref set + -- by a create function in another component during the same commit. + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + -- ROBLOX try + local ok, result = xpcall(function() + startLayoutEffectTimer() + commitHookEffectListUnmount( + bit32.bor(HookLayout, HookHasEffect), + finishedWork, + finishedWork.return_ + ) + end, describeError) + -- ROBLOX finally + recordLayoutEffectDuration(finishedWork) + if not ok then + error(result) + end + else + commitHookEffectListUnmount( + bit32.bor(HookLayout, HookHasEffect), + finishedWork, + finishedWork.return_ + ) + end + return + elseif finishedWork.tag == ClassComponent then + return + elseif finishedWork.tag == HostComponent then + local instance: Instance = finishedWork.stateNode + if instance ~= nil then + -- Commit the work prepared earlier. + local newProps = finishedWork.memoizedProps + -- For hydration we reuse the update path but we treat the oldProps + -- as the newProps. The updatePayload will contain the real change in + -- this case. + local oldProps + if current then + oldProps = current.memoizedProps + else + oldProps = newProps + end + local type = finishedWork.type + -- TODO: Type the updateQueue to be specific to host components. + local updatePayload: nil | UpdatePayload = finishedWork.updateQueue :: any + finishedWork.updateQueue = nil + if updatePayload ~= nil then + commitUpdate( + instance, + updatePayload, + type, + oldProps, + newProps, + finishedWork + ) + end + end + return + elseif finishedWork.tag == HostText then + invariant( + finishedWork.stateNode ~= nil, + "This should have a text node initialized. This error is likely " + .. "caused by a bug in React. Please file an issue." + ) + local textInstance: TextInstance = finishedWork.stateNode + local newText: string = finishedWork.memoizedProps + -- For hydration we reuse the update path but we treat the oldProps + -- as the newProps. The updatePayload will contain the real change in + -- this case. + local oldText: string + if current ~= nil then + oldText = (current :: Fiber).memoizedProps + oldText = newText + end + commitTextUpdate(textInstance, oldText, newText) + return + elseif finishedWork.tag == HostRoot then + if supportsHydration then + local root: FiberRoot = finishedWork.stateNode + if root.hydrate then + -- We've just hydrated. No need to hydrate again. + root.hydrate = false + unimplemented("commitWork: HostRoot: commitHydratedContainer") + -- commitHydratedContainer(root.containerInfo) + end + end + return + elseif finishedWork.tag == Profiler then + return + elseif finishedWork.tag == SuspenseComponent then + commitSuspenseComponent(finishedWork) + attachSuspenseRetryListeners(finishedWork) + return + elseif finishedWork.tag == SuspenseListComponent then + unimplemented("commitWork: SuspenseListComponent") + -- attachSuspenseRetryListeners(finishedWork) + -- return + elseif finishedWork.tag == IncompleteClassComponent then + return + -- elseif finishedWork.tag == FundamentalComponent then + -- unimplemented("commitWork: FundamentalComponent") + -- if enableFundamentalAPI) + -- local fundamentalInstance = finishedWork.stateNode + -- updateFundamentalComponent(fundamentalInstance) + -- return + -- end + -- break + -- elseif finishedWork.tag == ScopeComponent then + -- unimplemented("commitWork: ScopeComponent") + -- if enableScopeAPI) + -- local scopeInstance = finishedWork.stateNode + -- prepareScopeUpdate(scopeInstance, finishedWork) + -- return + -- end + -- break + elseif + finishedWork.tag == OffscreenComponent + or finishedWork.tag == LegacyHiddenComponent + then + local newState: OffscreenState | nil = finishedWork.memoizedState + local isHidden = newState ~= nil + hideOrUnhideAllChildren(finishedWork, isHidden) + return + end + invariant( + false, + "This unit of work tag should not have side-effects. This error is " + .. "likely caused by a bug in React. Please file an issue." + ) end function commitSuspenseComponent(finishedWork: Fiber) - local newState: SuspenseState | nil = finishedWork.memoizedState - - if newState ~= nil then - markCommitTimeOfFallback() - - if supportsMutation then - -- Hide the Offscreen component that contains the primary children. TODO: - -- Ideally, this effect would have been scheduled on the Offscreen fiber - -- itself. That's how unhiding works: the Offscreen component schedules an - -- effect on itself. However, in this case, the component didn't complete, - -- so the fiber was never added to the effect list in the normal path. We - -- could have appended it to the effect list in the Suspense component's - -- second pass, but doing it this way is less complicated. This would be - -- simpler if we got rid of the effect list and traversed the tree, like - -- we're planning to do. - local primaryChildParent: Fiber = (finishedWork.child :: any) - hideOrUnhideAllChildren(primaryChildParent, true) - end - end - - if enableSuspenseCallback and newState ~= nil then - local suspenseCallback = finishedWork.memoizedProps.suspenseCallback - if typeof(suspenseCallback) == 'function' then - local wakeables: Set | nil = (finishedWork.updateQueue :: any) - if wakeables ~= nil then - suspenseCallback(Object.assign({}, wakeables)) - end - elseif _G.__DEV__ then - if suspenseCallback ~= nil then - console.error('Unexpected type for suspenseCallback.') - end - end - end + local newState: SuspenseState | nil = finishedWork.memoizedState + + if newState ~= nil then + markCommitTimeOfFallback() + + if supportsMutation then + -- Hide the Offscreen component that contains the primary children. TODO: + -- Ideally, this effect would have been scheduled on the Offscreen fiber + -- itself. That's how unhiding works: the Offscreen component schedules an + -- effect on itself. However, in this case, the component didn't complete, + -- so the fiber was never added to the effect list in the normal path. We + -- could have appended it to the effect list in the Suspense component's + -- second pass, but doing it this way is less complicated. This would be + -- simpler if we got rid of the effect list and traversed the tree, like + -- we're planning to do. + local primaryChildParent: Fiber = finishedWork.child :: any + hideOrUnhideAllChildren(primaryChildParent, true) + end + end + + if enableSuspenseCallback and newState ~= nil then + local suspenseCallback = finishedWork.memoizedProps.suspenseCallback + if typeof(suspenseCallback) == "function" then + local wakeables: Set | nil = finishedWork.updateQueue :: any + if wakeables ~= nil then + suspenseCallback(table.clone(wakeables)) + end + elseif __DEV__ then + if suspenseCallback ~= nil then + console.error( + "Unexpected type for suspenseCallback: %s", + tostring(suspenseCallback) + ) + end + end + end end -function commitSuspenseHydrationCallbacks( - finishedRoot: FiberRoot, - finishedWork: Fiber -) - if not supportsHydration then - return - end - local newState: SuspenseState | nil = finishedWork.memoizedState - if newState == nil then - local current = finishedWork.alternate - if current ~= nil then - local prevState: SuspenseState | nil = current.memoizedState - if prevState ~= nil then - -- ROBLOX FIXME: recast to silence analyze - local suspenseInstance = (prevState :: SuspenseState).dehydrated - if suspenseInstance ~= nil then - commitHydratedSuspenseInstance(suspenseInstance) - if enableSuspenseCallback then - local hydrationCallbacks = finishedRoot.hydrationCallbacks - if hydrationCallbacks ~= nil then - local onHydrated = hydrationCallbacks.onHydrated - if onHydrated then - onHydrated(suspenseInstance) - end - end - end - end - end - end - end +function commitSuspenseHydrationCallbacks(finishedRoot: FiberRoot, finishedWork: Fiber) + if not supportsHydration then + return + end + local newState: SuspenseState | nil = finishedWork.memoizedState + if newState == nil then + local current = finishedWork.alternate + if current ~= nil then + local prevState: SuspenseState | nil = current.memoizedState + if prevState ~= nil then + local suspenseInstance = prevState.dehydrated + if suspenseInstance ~= nil then + commitHydratedSuspenseInstance(suspenseInstance) + if enableSuspenseCallback then + local hydrationCallbacks = finishedRoot.hydrationCallbacks + if hydrationCallbacks ~= nil then + local onHydrated = hydrationCallbacks.onHydrated + if onHydrated then + onHydrated(suspenseInstance) + end + end + end + end + end + end + end end function attachSuspenseRetryListeners(finishedWork: Fiber) - -- If this boundary just timed out, then it will have a set of wakeables. - -- For each wakeable, attach a listener so that when it resolves, React - -- attempts to re-render the boundary in the primary (pre-timeout) state. - local wakeables: Set | nil = (finishedWork.updateQueue :: any) - if wakeables ~= nil then - finishedWork.updateQueue = nil - local retryCache = finishedWork.stateNode - if retryCache == nil then - finishedWork.stateNode = {} - retryCache = finishedWork.stateNode - end - for wakeable, _ in pairs((wakeables :: Set)) do - -- Memoize using the boundary fiber to prevent redundant listeners. - local retry = function() - return resolveRetryWakeable(finishedWork, wakeable) - end - - if not retryCache[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) - end - end - end + -- If this boundary just timed out, then it will have a set of wakeables. + -- For each wakeable, attach a listener so that when it resolves, React + -- attempts to re-render the boundary in the primary (pre-timeout) state. + local wakeables: Set | nil = finishedWork.updateQueue :: any + if wakeables ~= nil then + finishedWork.updateQueue = nil + local retryCache = finishedWork.stateNode + if retryCache == nil then + finishedWork.stateNode = Set.new() + retryCache = finishedWork.stateNode + end + for wakeable, _ in wakeables :: Set do + -- Memoize using the boundary fiber to prevent redundant listeners. + local retry = function() + return resolveRetryWakeable(finishedWork, wakeable) + end + + if not retryCache:has(wakeable) then + if enableSchedulerTracing then + if wakeable.__reactDoNotTraceInteractions ~= true then + retry = Schedule_tracing_wrap(retry) + end + end + retryCache:add(wakeable) + wakeable:andThen(function() + return retry() + end, function() + return retry() + end) + end + end + end end -- This function detects when a Suspense boundary goes from visible to hidden. -- It returns false if the boundary is already hidden. -- TODO: Use an effect tag. -function isSuspenseBoundaryBeingHidden( - current: Fiber | nil, - finishedWork: Fiber -): boolean - if current ~= nil then - -- ROBLOX TODO: remove typechecks when narrowing works better - local oldState: SuspenseState | nil = (current :: Fiber).memoizedState - if oldState == nil or (oldState :: SuspenseState).dehydrated ~= nil then - local newState: SuspenseState | nil = finishedWork.memoizedState - return newState ~= nil and (newState :: SuspenseState).dehydrated == nil - end - end - return false +function isSuspenseBoundaryBeingHidden(current: Fiber | nil, finishedWork: Fiber): boolean + if current ~= nil then + -- ROBLOX TODO: remove typechecks when narrowing works better + local oldState: SuspenseState | nil = (current :: Fiber).memoizedState + if oldState == nil or (oldState :: SuspenseState).dehydrated ~= nil then + local newState: SuspenseState | nil = finishedWork.memoizedState + return newState ~= nil and (newState :: SuspenseState).dehydrated == nil + end + end + return false end function commitResetTextContent(current: Fiber): () - if not supportsMutation then - return - end - resetTextContent(current.stateNode) + if not supportsMutation then + return + end + resetTextContent(current.stateNode) end local function commitPassiveUnmount(finishedWork: Fiber): () - if - finishedWork.tag == FunctionComponent or - finishedWork.tag == ForwardRef or - finishedWork.tag == SimpleMemoComponent or - finishedWork.tag == Block - then - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - startPassiveEffectTimer() - commitHookEffectListUnmount( - bit32.bor(HookPassive, HookHasEffect), - finishedWork, - finishedWork.return_ - ) - recordPassiveEffectDuration(finishedWork) - else - commitHookEffectListUnmount( - bit32.bor(HookPassive, HookHasEffect), - finishedWork, - finishedWork.return_ - ) - end - end + if + finishedWork.tag == FunctionComponent + or finishedWork.tag == ForwardRef + or finishedWork.tag == SimpleMemoComponent + or finishedWork.tag == Block + then + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + startPassiveEffectTimer() + commitHookEffectListUnmount( + bit32.bor(HookPassive, HookHasEffect), + finishedWork, + finishedWork.return_ + ) + recordPassiveEffectDuration(finishedWork) + else + commitHookEffectListUnmount( + bit32.bor(HookPassive, HookHasEffect), + finishedWork, + finishedWork.return_ + ) + end + end end local function commitPassiveUnmountInsideDeletedTree( - current: Fiber, - nearestMountedAncestor: Fiber | nil + current: Fiber, + nearestMountedAncestor: Fiber | nil ): () - if - current.tag == FunctionComponent or - current.tag == ForwardRef or - current.tag == SimpleMemoComponent or - current.tag == Block - then - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(current.mode, ProfileMode) ~= 0 - then - startPassiveEffectTimer() - commitHookEffectListUnmount( - HookPassive, - current, - nearestMountedAncestor - ) - recordPassiveEffectDuration(current) - else - commitHookEffectListUnmount( - HookPassive, - current, - nearestMountedAncestor - ) - end - end + if + current.tag == FunctionComponent + or current.tag == ForwardRef + or current.tag == SimpleMemoComponent + or current.tag == Block + then + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(current.mode, ProfileMode) ~= 0 + then + startPassiveEffectTimer() + commitHookEffectListUnmount(HookPassive, current, nearestMountedAncestor) + recordPassiveEffectDuration(current) + else + commitHookEffectListUnmount(HookPassive, current, nearestMountedAncestor) + end + end end -local function commitPassiveMount( - finishedRoot: FiberRoot, - finishedWork: Fiber -): () - if - finishedWork.tag == FunctionComponent or - finishedWork.tag == ForwardRef or - finishedWork.tag == SimpleMemoComponent or - finishedWork.tag == Block - then - if - enableProfilerTimer and - enableProfilerCommitHooks and - bit32.band(finishedWork.mode, ProfileMode) ~= 0 - then - startPassiveEffectTimer() - -- ROBLOX try - local ok, error_ = pcall(function() - commitHookEffectListMount(bit32.bor(HookPassive, HookHasEffect), finishedWork) - end) - -- ROBLOX finally - recordPassiveEffectDuration(finishedWork) - if not ok then - error(error_) - end - else - commitHookEffectListMount(bit32.bor(HookPassive, HookHasEffect), finishedWork) - end - elseif finishedWork.tag == Profiler then - commitProfilerPassiveEffect(finishedRoot, finishedWork) - end +local function commitPassiveMount(finishedRoot: FiberRoot, finishedWork: Fiber): () + if + finishedWork.tag == FunctionComponent + or finishedWork.tag == ForwardRef + or finishedWork.tag == SimpleMemoComponent + or finishedWork.tag == Block + then + if + enableProfilerTimer + and enableProfilerCommitHooks + and bit32.band(finishedWork.mode, ProfileMode) ~= 0 + then + startPassiveEffectTimer() + -- ROBLOX try + local ok, error_ = xpcall( + commitHookEffectListMount, + describeError, + bit32.bor(HookPassive, HookHasEffect), + finishedWork + ) + -- ROBLOX finally + recordPassiveEffectDuration(finishedWork) + if not ok then + error(error_) + end + else + commitHookEffectListMount(bit32.bor(HookPassive, HookHasEffect), finishedWork) + end + elseif finishedWork.tag == Profiler then + commitProfilerPassiveEffect(finishedRoot, finishedWork) + end end function invokeLayoutEffectMountInDEV(fiber: Fiber): () - if _G.__DEV__ and enableDoubleInvokingEffects then - if fiber.tag == FunctionComponent or - fiber.tag == ForwardRef or - fiber.tag == SimpleMemoComponent or - fiber.tag == Block then - invokeGuardedCallback( - nil, - commitHookEffectListMount, - nil, - bit32.bor(HookLayout, HookHasEffect), - fiber - ) - if hasCaughtError() then - local mountError = clearCaughtError() - captureCommitPhaseError(fiber, fiber.return_, mountError) - end - return - end - elseif fiber.tag == ClassComponent then - local instance = fiber.stateNode - invokeGuardedCallback(nil, instance.componentDidMount, instance) - if hasCaughtError() then - local mountError = clearCaughtError() - captureCommitPhaseError(fiber, fiber.return_, mountError) - end - return - end + if __DEV__ and enableDoubleInvokingEffects then + if + fiber.tag == FunctionComponent + or fiber.tag == ForwardRef + or fiber.tag == SimpleMemoComponent + or fiber.tag == Block + then + invokeGuardedCallback( + nil, + commitHookEffectListMount, + nil, + bit32.bor(HookLayout, HookHasEffect), + fiber + ) + if hasCaughtError() then + local mountError = clearCaughtError() + captureCommitPhaseError(fiber, fiber.return_, mountError) + end + return + end + elseif fiber.tag == ClassComponent then + local instance = fiber.stateNode + invokeGuardedCallback(nil, instance.componentDidMount, instance) + if hasCaughtError() then + local mountError = clearCaughtError() + captureCommitPhaseError(fiber, fiber.return_, mountError) + end + return + end end - function invokePassiveEffectMountInDEV(fiber: Fiber): () - if _G.__DEV__ and enableDoubleInvokingEffects then - if fiber.tag == FunctionComponent or - fiber.tag == ForwardRef or - fiber.tag == SimpleMemoComponent or - fiber.tag == Block then - invokeGuardedCallback( - nil, - commitHookEffectListMount, - nil, - bit32.bor(HookPassive, HookHasEffect), - fiber - ) - if hasCaughtError() then - local mountError = clearCaughtError() - captureCommitPhaseError(fiber, fiber.return_, mountError) - end - return - end - end + if __DEV__ and enableDoubleInvokingEffects then + if + fiber.tag == FunctionComponent + or fiber.tag == ForwardRef + or fiber.tag == SimpleMemoComponent + or fiber.tag == Block + then + invokeGuardedCallback( + nil, + commitHookEffectListMount, + nil, + bit32.bor(HookPassive, HookHasEffect), + fiber + ) + if hasCaughtError() then + local mountError = clearCaughtError() + captureCommitPhaseError(fiber, fiber.return_, mountError) + end + return + end + end end - function invokeLayoutEffectUnmountInDEV(fiber: Fiber): () - if _G.__DEV__ and enableDoubleInvokingEffects then - if fiber.tag == FunctionComponent or - fiber.tag == ForwardRef or - fiber.tag == SimpleMemoComponent or - fiber.tag == Block then - invokeGuardedCallback( - nil, - commitHookEffectListUnmount, - nil, - bit32.bor(HookLayout, HookHasEffect), - fiber, - fiber.return_ - ) - if hasCaughtError() then - local unmountError = clearCaughtError() - captureCommitPhaseError(fiber, fiber.return_, unmountError) - end - return - end - elseif fiber.tag == ClassComponent then - local instance = fiber.stateNode - if typeof(instance.componentWillUnmount) == 'function' then - safelyCallComponentWillUnmount(fiber, instance, fiber.return_) - end - return - end + if __DEV__ and enableDoubleInvokingEffects then + if + fiber.tag == FunctionComponent + or fiber.tag == ForwardRef + or fiber.tag == SimpleMemoComponent + or fiber.tag == Block + then + invokeGuardedCallback( + nil, + commitHookEffectListUnmount, + nil, + bit32.bor(HookLayout, HookHasEffect), + fiber, + fiber.return_ + ) + if hasCaughtError() then + local unmountError = clearCaughtError() + captureCommitPhaseError(fiber, fiber.return_, unmountError) + end + return + end + elseif fiber.tag == ClassComponent then + local instance = fiber.stateNode + if typeof(instance.componentWillUnmount) == "function" then + safelyCallComponentWillUnmount(fiber, instance, fiber.return_) + end + return + end end function invokePassiveEffectUnmountInDEV(fiber: Fiber): () - if _G.__DEV__ and enableDoubleInvokingEffects then - if fiber.tag == FunctionComponent or - fiber.tag == ForwardRef or - fiber.tag == SimpleMemoComponent or - fiber.tag == Block then - invokeGuardedCallback( - nil, - commitHookEffectListUnmount, - nil, - bit32.bor(HookPassive, HookHasEffect), - fiber, - fiber.return_ - ) - if hasCaughtError() then - local unmountError = clearCaughtError() - captureCommitPhaseError(fiber, fiber.return_, unmountError) - end - return - end - end + if __DEV__ and enableDoubleInvokingEffects then + if + fiber.tag == FunctionComponent + or fiber.tag == ForwardRef + or fiber.tag == SimpleMemoComponent + or fiber.tag == Block + then + invokeGuardedCallback( + nil, + commitHookEffectListUnmount, + nil, + bit32.bor(HookPassive, HookHasEffect), + fiber, + fiber.return_ + ) + if hasCaughtError() then + local unmountError = clearCaughtError() + captureCommitPhaseError(fiber, fiber.return_, unmountError) + end + return + end + end end return { - safelyCallDestroy = safelyCallDestroy, - - commitBeforeMutationLifeCycles = commitBeforeMutationLifeCycles, - commitResetTextContent = commitResetTextContent, - commitPlacement = commitPlacement, - commitDeletion = commitDeletion, - commitWork = commitWork, - commitAttachRef = commitAttachRef, - commitDetachRef = commitDetachRef, - commitPassiveUnmount = commitPassiveUnmount, - commitPassiveUnmountInsideDeletedTree = commitPassiveUnmountInsideDeletedTree, - commitPassiveMount = commitPassiveMount, - invokeLayoutEffectMountInDEV = invokeLayoutEffectMountInDEV, - invokeLayoutEffectUnmountInDEV = invokeLayoutEffectUnmountInDEV, - invokePassiveEffectMountInDEV = invokePassiveEffectMountInDEV, - invokePassiveEffectUnmountInDEV = invokePassiveEffectUnmountInDEV, - isSuspenseBoundaryBeingHidden = isSuspenseBoundaryBeingHidden, - recursivelyCommitLayoutEffects = recursivelyCommitLayoutEffects, + safelyCallDestroy = safelyCallDestroy, + + commitBeforeMutationLifeCycles = commitBeforeMutationLifeCycles, + commitResetTextContent = commitResetTextContent, + commitPlacement = commitPlacement, + commitDeletion = commitDeletion, + commitWork = commitWork, + commitAttachRef = commitAttachRef, + commitDetachRef = commitDetachRef, + commitPassiveUnmount = commitPassiveUnmount, + commitPassiveUnmountInsideDeletedTree = commitPassiveUnmountInsideDeletedTree, + commitPassiveMount = commitPassiveMount, + invokeLayoutEffectMountInDEV = invokeLayoutEffectMountInDEV, + invokeLayoutEffectUnmountInDEV = invokeLayoutEffectUnmountInDEV, + invokePassiveEffectMountInDEV = invokePassiveEffectMountInDEV, + invokePassiveEffectUnmountInDEV = invokePassiveEffectUnmountInDEV, + isSuspenseBoundaryBeingHidden = isSuspenseBoundaryBeingHidden, + recursivelyCommitLayoutEffects = recursivelyCommitLayoutEffects, } diff --git a/modules/react-reconciler/src/ReactFiberCompleteWork.new.lua b/modules/react-reconciler/src/ReactFiberCompleteWork.new.lua index 3b599274..6beb41d6 100644 --- a/modules/react-reconciler/src/ReactFiberCompleteWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberCompleteWork.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/87c023b1c1b00d6776b7031f6e105913ead355da/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/87c023b1c1b00d6776b7031f6e105913ead355da/packages/react-reconciler/src/ReactFiberCompleteWork.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,21 +7,18 @@ * * @flow ]] ---!nolint LocalShadowPedantic -- 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) +local function unimplemented(message: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("UNIMPLEMENTED ERROR: " .. tostring(message)) + error("FIXME (roblox): " .. message .. " is unimplemented", 2) end local Packages = script.Parent.Parent local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber local ReactFiberLane = require(script.Parent.ReactFiberLane) type Lanes = ReactFiberLane.Lanes type Lane = ReactFiberLane.Lane @@ -32,11 +29,11 @@ local OffscreenLane = ReactFiberLane.OffscreenLane -- } = require(Packages.Shared).ReactTypes -- local type {FiberRoot} = require(script.Parent.ReactInternalTypes) local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -type Instance = ReactFiberHostConfig.Instance; -type Type = ReactFiberHostConfig.Type; -type Props = ReactFiberHostConfig.Props; -type Container = ReactFiberHostConfig.Container; -type ChildSet = ReactFiberHostConfig.ChildSet; +type Instance = ReactFiberHostConfig.Instance +type Type = ReactFiberHostConfig.Type +type Props = ReactFiberHostConfig.Props +type Container = ReactFiberHostConfig.Container +type ChildSet = ReactFiberHostConfig.ChildSet -- local type { -- SuspenseState, -- SuspenseListRenderState, @@ -45,7 +42,8 @@ local ReactFiberOffscreenComponent = require(script.Parent.ReactFiberOffscreenCo type OffscreenState = ReactFiberOffscreenComponent.OffscreenState local ReactMutableSource = require(script.Parent["ReactMutableSource.new"]) -local resetMutableSourceWorkInProgressVersions = ReactMutableSource.resetWorkInProgressVersions +local resetMutableSourceWorkInProgressVersions = + ReactMutableSource.resetWorkInProgressVersions -- local {now} = require(script.Parent.SchedulerWithReactIntegration.new) @@ -74,7 +72,8 @@ local ScopeComponent = ReactWorkTags.ScopeComponent local Block = ReactWorkTags.Block local OffscreenComponent = ReactWorkTags.OffscreenComponent local LegacyHiddenComponent = ReactWorkTags.LegacyHiddenComponent -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState type SuspenseListRenderState = ReactFiberSuspenseComponent.SuspenseState @@ -129,7 +128,8 @@ local popHostContainer = ReactFiberHostContext.popHostContainer local ReactFiberSuspenseContext = require(script.Parent["ReactFiberSuspenseContext.new"]) local popSuspenseContext = ReactFiberSuspenseContext.popSuspenseContext local suspenseStackCursor = ReactFiberSuspenseContext.suspenseStackCursor -local InvisibleParentSuspenseContext = ReactFiberSuspenseContext.InvisibleParentSuspenseContext +local InvisibleParentSuspenseContext = + ReactFiberSuspenseContext.InvisibleParentSuspenseContext local hasSuspenseContext = ReactFiberSuspenseContext.hasSuspenseContext type SuspenseContext = ReactFiberSuspenseContext.SuspenseContext -- local pushSuspenseContext = ReactFiberSuspenseContext.pushSuspenseContext @@ -144,13 +144,17 @@ local popLegacyContext = ReactFiberContext.popContext local popTopLevelLegacyContextObject = ReactFiberContext.popTopLevelContextObject local popProvider = require(script.Parent["ReactFiberNewContext.new"]).popProvider -local ReactFiberHydrationContext = require(script.Parent["ReactFiberHydrationContext.new"]) -local prepareToHydrateHostSuspenseInstance = ReactFiberHydrationContext.prepareToHydrateHostSuspenseInstance +local ReactFiberHydrationContext = + require(script.Parent["ReactFiberHydrationContext.new"]) +local prepareToHydrateHostSuspenseInstance = + ReactFiberHydrationContext.prepareToHydrateHostSuspenseInstance local popHydrationState = ReactFiberHydrationContext.popHydrationState local resetHydrationState = ReactFiberHydrationContext.resetHydrationState -- local getIsHydrating = ReactFiberHydrationContext.getIsHydrating -local prepareToHydrateHostInstance = ReactFiberHydrationContext.prepareToHydrateHostInstance -local prepareToHydrateHostTextInstance = ReactFiberHydrationContext.prepareToHydrateHostTextInstance +local prepareToHydrateHostInstance = + ReactFiberHydrationContext.prepareToHydrateHostInstance +local prepareToHydrateHostTextInstance = + ReactFiberHydrationContext.prepareToHydrateHostTextInstance local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags local enableSchedulerTracing = ReactFeatureFlags.enableSchedulerTracing local enableSuspenseCallback = ReactFeatureFlags.enableSuspenseCallback @@ -183,34 +187,34 @@ local ReactProfilerTimer = require(script.Parent["ReactProfilerTimer.new"]) local transferActualDuration = ReactProfilerTimer.transferActualDuration local function markUpdate(workInProgress: Fiber) - -- Tag the fiber with an update effect. This turns a Placement into - -- a PlacementAndUpdate. - workInProgress.flags = bit32.bor(workInProgress.flags, Update) + -- Tag the fiber with an update effect. This turns a Placement into + -- a PlacementAndUpdate. + workInProgress.flags = bit32.bor(workInProgress.flags, Update) end local function markRef(workInProgress: Fiber) - workInProgress.flags = bit32.bor(workInProgress.flags, Ref) + workInProgress.flags = bit32.bor(workInProgress.flags, Ref) end -- ROBLOX FIXME: type refinement -- local function hadNoMutationsEffects(current: nil | Fiber, completedWork: Fiber) local function hadNoMutationsEffects(current, completedWork: Fiber) - local didBailout = current ~= nil and current.child == completedWork.child - if didBailout then - return true - end - - local child = completedWork.child - while child ~= nil do - if bit32.band(child.flags, MutationMask) ~= NoFlags then - return false - end - if bit32.band(child.subtreeFlags, MutationMask) ~= NoFlags then - return false - end - child = child.sibling - end - return true + local didBailout = current ~= nil and current.child == completedWork.child + if didBailout then + return true + end + + local child = completedWork.child + while child ~= nil do + if bit32.band(child.flags, MutationMask) ~= NoFlags then + return false + end + if bit32.band(child.subtreeFlags, MutationMask) ~= NoFlags then + return false + end + child = child.sibling + end + return true end local appendAllChildren @@ -218,312 +222,314 @@ local updateHostContainer local updateHostComponent local updateHostText if supportsMutation then - -- Mutation mode - - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean - ) - -- We only have the top Fiber that was created but we need recurse down its - -- children to find all the terminal nodes. - local node = workInProgress.child - while node ~= nil do - if node.tag == HostComponent or node.tag == HostText then - appendInitialChild(parent, node.stateNode) - elseif enableFundamentalAPI and node.tag == FundamentalComponent then - appendInitialChild(parent, node.stateNode.instance) - elseif node.tag == HostPortal then - -- If we have a portal child, then we don't want to traverse - -- down its children. Instead, we'll get insertions from each child in - -- the portal directly. - elseif node.child ~= nil then - node.child.return_ = node - node = node.child - continue - end - if node == workInProgress then - return - end - while node.sibling == nil do - if node.return_ == nil or node.return_ == workInProgress then - return - end - node = node.return_ - end - node.sibling.return_ = node.return_ - node = node.sibling - end - end - - updateHostContainer = function(current: nil | Fiber, workInProgress: Fiber) - -- Noop - end - updateHostComponent = function( - current: Fiber, - workInProgress: Fiber, - type: Type, - newProps: Props, - rootContainerInstance: Container - ) - -- If we have an alternate, that means this is an update and we need to - -- schedule a side-effect to do the updates. - local oldProps = current.memoizedProps - if oldProps == newProps then - -- In mutation mode, this is sufficient for a bailout because - -- we won't touch this node even if children changed. - return - end - - -- If we get updated because one of our children updated, we don't - -- have newProps so we'll have to reuse them. - -- TODO: Split the update API as separate for the props vs. children. - -- Even better would be if children weren't special cased at all tho. - local instance: Instance = workInProgress.stateNode - local currentHostContext = getHostContext() - -- TODO: Experiencing an error where oldProps is nil. Suggests a host - -- component is hitting the resume path. Figure out why. Possibly - -- related to `hidden`. - local updatePayload = prepareUpdate( - instance, - type, - oldProps, - newProps, - rootContainerInstance, - currentHostContext - ) - -- TODO: Type this specific to this type of component. - workInProgress.updateQueue = updatePayload - -- If the update payload indicates that there is a change or if there - -- is a new ref we mark this as an update. All the work is done in commitWork. - if updatePayload then - markUpdate(workInProgress) - end - end - updateHostText = function( - current: Fiber, - workInProgress: Fiber, - oldText: string, - newText: string - ) - -- If the text differs, mark it as an update. All the work in done in commitWork. - if oldText ~= newText then - markUpdate(workInProgress) - end - end + -- Mutation mode + + appendAllChildren = function( + parent: Instance, + workInProgress: Fiber, + needsVisibilityToggle: boolean, + isHidden: boolean + ) + -- We only have the top Fiber that was created but we need recurse down its + -- children to find all the terminal nodes. + local node = workInProgress.child + while node ~= nil do + if node.tag == HostComponent or node.tag == HostText then + appendInitialChild(parent, node.stateNode) + elseif enableFundamentalAPI and node.tag == FundamentalComponent then + appendInitialChild(parent, node.stateNode.instance) + elseif node.tag == HostPortal then + -- If we have a portal child, then we don't want to traverse + -- down its children. Instead, we'll get insertions from each child in + -- the portal directly. + elseif node.child ~= nil then + node.child.return_ = node + node = node.child + continue + end + if node == workInProgress then + return + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == workInProgress then + return + end + node = node.return_ + end + -- ROBLOX FIXME Luau: Luau doesn't understand loop predicates above results in node.sibling ~= nil + (node.sibling :: Fiber).return_ = node.return_ + node = node.sibling + end + end + + updateHostContainer = function(current: nil | Fiber, workInProgress: Fiber) + -- Noop + end + function updateHostComponent( + current: Fiber, + workInProgress: Fiber, + type: Type, + newProps: Props, + rootContainerInstance: Container + ) + -- If we have an alternate, that means this is an update and we need to + -- schedule a side-effect to do the updates. + local oldProps = current.memoizedProps + if oldProps == newProps then + -- In mutation mode, this is sufficient for a bailout because + -- we won't touch this node even if children changed. + return + end + + -- If we get updated because one of our children updated, we don't + -- have newProps so we'll have to reuse them. + -- TODO: Split the update API as separate for the props vs. children. + -- Even better would be if children weren't special cased at all tho. + local instance: Instance = workInProgress.stateNode + local currentHostContext = getHostContext() + -- TODO: Experiencing an error where oldProps is nil. Suggests a host + -- component is hitting the resume path. Figure out why. Possibly + -- related to `hidden`. + local updatePayload = prepareUpdate( + instance, + type, + oldProps, + newProps, + rootContainerInstance, + currentHostContext + ) + -- TODO: Type this specific to this type of component. + workInProgress.updateQueue = updatePayload + -- If the update payload indicates that there is a change or if there + -- is a new ref we mark this as an update. All the work is done in commitWork. + if updatePayload then + markUpdate(workInProgress) + end + end + function updateHostText( + current: Fiber, + workInProgress: Fiber, + oldText: string, + newText: string + ) + -- If the text differs, mark it as an update. All the work in done in commitWork. + if oldText ~= newText then + markUpdate(workInProgress) + end + end elseif supportsPersistence then - -- Persistent host tree mode - appendAllChildren = function( - parent: Instance, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean - ) - unimplemented("appendAllChildren") --- -- We only have the top Fiber that was created but we need recurse down its --- -- children to find all the terminal nodes. --- local node = workInProgress.child --- while (node ~= nil) --- -- eslint-disable-next-line no-labels --- branches: if node.tag == HostComponent) --- local instance = node.stateNode --- if needsVisibilityToggle and isHidden) --- -- This child is inside a timed out tree. Hide it. --- local props = node.memoizedProps --- local type = node.type --- instance = cloneHiddenInstance(instance, type, props, node) --- end --- appendInitialChild(parent, instance) --- } else if node.tag == HostText) --- local instance = node.stateNode --- if needsVisibilityToggle and isHidden) --- -- This child is inside a timed out tree. Hide it. --- local text = node.memoizedProps --- instance = cloneHiddenTextInstance(instance, text, node) --- end --- appendInitialChild(parent, instance) --- } else if enableFundamentalAPI and node.tag == FundamentalComponent) --- local instance = node.stateNode.instance --- if needsVisibilityToggle and isHidden) --- -- This child is inside a timed out tree. Hide it. --- local props = node.memoizedProps --- local type = node.type --- instance = cloneHiddenInstance(instance, type, props, node) --- end --- appendInitialChild(parent, instance) --- } else if node.tag == HostPortal) --- -- If we have a portal child, then we don't want to traverse --- -- down its children. Instead, we'll get insertions from each child in --- -- the portal directly. --- } else if node.tag == SuspenseComponent) --- if (node.flags & Update) ~= NoFlags) --- -- Need to toggle the visibility of the primary children. --- local newIsHidden = node.memoizedState ~= nil --- if newIsHidden) --- local primaryChildParent = node.child --- if primaryChildParent ~= nil) --- if primaryChildParent.child ~= nil) --- primaryChildParent.child.return = primaryChildParent --- appendAllChildren( --- parent, --- primaryChildParent, --- true, --- newIsHidden, --- ) --- end --- local fallbackChildParent = primaryChildParent.sibling --- if fallbackChildParent ~= nil) --- fallbackChildParent.return = node --- node = fallbackChildParent --- continue --- end --- end --- end --- end --- if node.child ~= nil) --- -- Continue traversing like normal --- node.child.return = node --- node = node.child --- continue --- end --- } else if node.child ~= nil) --- node.child.return = node --- node = node.child --- continue --- end --- -- $FlowFixMe This is correct but Flow is confused by the labeled break. --- node = (node: Fiber) --- if node == workInProgress) --- return --- end --- while (node.sibling == nil) --- if node.return == nil or node.return == workInProgress) --- return --- end --- node = node.return --- end --- node.sibling.return = node.return --- node = node.sibling --- end - end - - -- An unfortunate fork of appendAllChildren because we have two different parent types. - local function appendAllChildrenToContainer( - containerChildSet: ChildSet, - workInProgress: Fiber, - needsVisibilityToggle: boolean, - isHidden: boolean - ) - unimplemented("appendAllChildrenToContainer") - -- -- We only have the top Fiber that was created but we need recurse down its - -- -- children to find all the terminal nodes. - -- local node = workInProgress.child - -- while node ~= nil do - -- -- eslint-disable-next-line no-labels - -- if node.tag == HostComponent then - -- local instance = node.stateNode - -- if needsVisibilityToggle and isHidden then - -- -- This child is inside a timed out tree. Hide it. - -- local props = node.memoizedProps - -- local type = node.type - -- instance = cloneHiddenInstance(instance, type, props, node) - -- end - -- appendChildToContainerChildSet(containerChildSet, instance) - -- elseif node.tag == HostText then - -- local instance = node.stateNode - -- if needsVisibilityToggle and isHidden then - -- -- This child is inside a timed out tree. Hide it. - -- local text = node.memoizedProps - -- instance = cloneHiddenTextInstance(instance, text, node) - -- end - -- appendChildToContainerChildSet(containerChildSet, instance) - -- elseif enableFundamentalAPI and node.tag == FundamentalComponent then - -- local instance = node.stateNode.instance - -- if needsVisibilityToggle and isHidden then - -- -- This child is inside a timed out tree. Hide it. - -- local props = node.memoizedProps - -- local type = node.type - -- instance = cloneHiddenInstance(instance, type, props, node) - -- end - -- appendChildToContainerChildSet(containerChildSet, instance) - -- elseif node.tag == HostPortal then - -- -- If we have a portal child, then we don't want to traverse - -- -- down its children. Instead, we'll get insertions from each child in - -- -- the portal directly. - -- elseif node.tag == SuspenseComponent then - -- if bit32.band(node.flags, Update) ~= NoFlags then - -- -- Need to toggle the visibility of the primary children. - -- local newIsHidden = node.memoizedState ~= nil - -- if newIsHidden then - -- local primaryChildParent = node.child - -- if primaryChildParent ~= nil then - -- if primaryChildParent.child ~= nil then - -- primaryChildParent.child.return_ = primaryChildParent - -- appendAllChildrenToContainer( - -- containerChildSet, - -- primaryChildParent, - -- true, - -- newIsHidden - -- ) - -- end - -- local fallbackChildParent = primaryChildParent.sibling - -- if fallbackChildParent ~= nil then - -- fallbackChildParent.return_ = node - -- node = fallbackChildParent - -- continue - -- end - -- end - -- end - -- end - -- if node.child ~= nil then - -- -- Continue traversing like normal - -- node.child.return_ = node - -- node = node.child - -- continue - -- end - -- elseif node.child ~= nil then - -- node.child.return_ = node - -- node = node.child - -- continue - -- end - -- -- $FlowFixMe This is correct but Flow is confused by the labeled break. - -- -- node = (node: Fiber) - -- if node == workInProgress then - -- return - -- end - -- while node.sibling == nil do - -- if node.return_ == nil or node.return_ == workInProgress then - -- return - -- end - -- node = node.return_ - -- end - -- node.sibling.return_ = node.return_ - -- node = node.sibling - -- end - end - - updateHostContainer = function(current: nil | Fiber, workInProgress: Fiber) - local portalOrRoot: { - containerInfo: Container, - pendingChildren: ChildSet, - -- ... - [any]: any, - } = workInProgress.stateNode - local childrenUnchanged = hadNoMutationsEffects(current, workInProgress) - if childrenUnchanged then - -- No changes, just reuse the existing instance. - else - local container = portalOrRoot.containerInfo - local newChildSet = createContainerChildSet(container) - -- If children might have changed, we have to add them all to the set. - appendAllChildrenToContainer(newChildSet, workInProgress, false, false) - portalOrRoot.pendingChildren = newChildSet - -- Schedule an update on the container to swap out the container. - markUpdate(workInProgress) - finalizeContainerChildren(container, newChildSet) - end - end + -- Persistent host tree mode + appendAllChildren = function( + parent: Instance, + workInProgress: Fiber, + needsVisibilityToggle: boolean, + isHidden: boolean + ) + unimplemented("appendAllChildren") + -- -- We only have the top Fiber that was created but we need recurse down its + -- -- children to find all the terminal nodes. + -- local node = workInProgress.child + -- while (node ~= nil) + -- -- eslint-disable-next-line no-labels + -- branches: if node.tag == HostComponent) + -- local instance = node.stateNode + -- if needsVisibilityToggle and isHidden) + -- -- This child is inside a timed out tree. Hide it. + -- local props = node.memoizedProps + -- local type = node.type + -- instance = cloneHiddenInstance(instance, type, props, node) + -- end + -- appendInitialChild(parent, instance) + -- } else if node.tag == HostText) + -- local instance = node.stateNode + -- if needsVisibilityToggle and isHidden) + -- -- This child is inside a timed out tree. Hide it. + -- local text = node.memoizedProps + -- instance = cloneHiddenTextInstance(instance, text, node) + -- end + -- appendInitialChild(parent, instance) + -- } else if enableFundamentalAPI and node.tag == FundamentalComponent) + -- local instance = node.stateNode.instance + -- if needsVisibilityToggle and isHidden) + -- -- This child is inside a timed out tree. Hide it. + -- local props = node.memoizedProps + -- local type = node.type + -- instance = cloneHiddenInstance(instance, type, props, node) + -- end + -- appendInitialChild(parent, instance) + -- } else if node.tag == HostPortal) + -- -- If we have a portal child, then we don't want to traverse + -- -- down its children. Instead, we'll get insertions from each child in + -- -- the portal directly. + -- } else if node.tag == SuspenseComponent) + -- if (node.flags & Update) ~= NoFlags) + -- -- Need to toggle the visibility of the primary children. + -- local newIsHidden = node.memoizedState ~= nil + -- if newIsHidden) + -- local primaryChildParent = node.child + -- if primaryChildParent ~= nil) + -- if primaryChildParent.child ~= nil) + -- primaryChildParent.child.return = primaryChildParent + -- appendAllChildren( + -- parent, + -- primaryChildParent, + -- true, + -- newIsHidden, + -- ) + -- end + -- local fallbackChildParent = primaryChildParent.sibling + -- if fallbackChildParent ~= nil) + -- fallbackChildParent.return = node + -- node = fallbackChildParent + -- continue + -- end + -- end + -- end + -- end + -- if node.child ~= nil) + -- -- Continue traversing like normal + -- node.child.return = node + -- node = node.child + -- continue + -- end + -- } else if node.child ~= nil) + -- node.child.return = node + -- node = node.child + -- continue + -- end + -- -- $FlowFixMe This is correct but Flow is confused by the labeled break. + -- node = (node: Fiber) + -- if node == workInProgress) + -- return + -- end + -- while (node.sibling == nil) + -- if node.return == nil or node.return == workInProgress) + -- return + -- end + -- node = node.return + -- end + -- node.sibling.return = node.return + -- node = node.sibling + -- end + end + + -- An unfortunate fork of appendAllChildren because we have two different parent types. + local function appendAllChildrenToContainer( + containerChildSet: ChildSet, + workInProgress: Fiber, + needsVisibilityToggle: boolean, + isHidden: boolean + ) + unimplemented("appendAllChildrenToContainer") + -- -- We only have the top Fiber that was created but we need recurse down its + -- -- children to find all the terminal nodes. + -- local node = workInProgress.child + -- while node ~= nil do + -- -- eslint-disable-next-line no-labels + -- if node.tag == HostComponent then + -- local instance = node.stateNode + -- if needsVisibilityToggle and isHidden then + -- -- This child is inside a timed out tree. Hide it. + -- local props = node.memoizedProps + -- local type = node.type + -- instance = cloneHiddenInstance(instance, type, props, node) + -- end + -- appendChildToContainerChildSet(containerChildSet, instance) + -- elseif node.tag == HostText then + -- local instance = node.stateNode + -- if needsVisibilityToggle and isHidden then + -- -- This child is inside a timed out tree. Hide it. + -- local text = node.memoizedProps + -- instance = cloneHiddenTextInstance(instance, text, node) + -- end + -- appendChildToContainerChildSet(containerChildSet, instance) + -- elseif enableFundamentalAPI and node.tag == FundamentalComponent then + -- local instance = node.stateNode.instance + -- if needsVisibilityToggle and isHidden then + -- -- This child is inside a timed out tree. Hide it. + -- local props = node.memoizedProps + -- local type = node.type + -- instance = cloneHiddenInstance(instance, type, props, node) + -- end + -- appendChildToContainerChildSet(containerChildSet, instance) + -- elseif node.tag == HostPortal then + -- -- If we have a portal child, then we don't want to traverse + -- -- down its children. Instead, we'll get insertions from each child in + -- -- the portal directly. + -- elseif node.tag == SuspenseComponent then + -- if bit32.band(node.flags, Update) ~= NoFlags then + -- -- Need to toggle the visibility of the primary children. + -- local newIsHidden = node.memoizedState ~= nil + -- if newIsHidden then + -- local primaryChildParent = node.child + -- if primaryChildParent ~= nil then + -- if primaryChildParent.child ~= nil then + -- primaryChildParent.child.return_ = primaryChildParent + -- appendAllChildrenToContainer( + -- containerChildSet, + -- primaryChildParent, + -- true, + -- newIsHidden + -- ) + -- end + -- local fallbackChildParent = primaryChildParent.sibling + -- if fallbackChildParent ~= nil then + -- fallbackChildParent.return_ = node + -- node = fallbackChildParent + -- continue + -- end + -- end + -- end + -- end + -- if node.child ~= nil then + -- -- Continue traversing like normal + -- node.child.return_ = node + -- node = node.child + -- continue + -- end + -- elseif node.child ~= nil then + -- node.child.return_ = node + -- node = node.child + -- continue + -- end + -- -- $FlowFixMe This is correct but Flow is confused by the labeled break. + -- -- node = (node: Fiber) + -- if node == workInProgress then + -- return + -- end + -- while node.sibling == nil do + -- if node.return_ == nil or node.return_ == workInProgress then + -- return + -- end + -- node = node.return_ + -- end + -- node.sibling.return_ = node.return_ + -- node = node.sibling + -- end + end + + function updateHostContainer(current: nil | Fiber, workInProgress: Fiber) + local portalOrRoot: { + containerInfo: Container, + pendingChildren: ChildSet, + -- ... + [any]: any, + } = + workInProgress.stateNode + local childrenUnchanged = hadNoMutationsEffects(current, workInProgress) + if childrenUnchanged then + -- No changes, just reuse the existing instance. + else + local container = portalOrRoot.containerInfo + local newChildSet = createContainerChildSet(container) + -- If children might have changed, we have to add them all to the set. + appendAllChildrenToContainer(newChildSet, workInProgress, false, false) + portalOrRoot.pendingChildren = newChildSet + -- Schedule an update on the container to swap out the container. + markUpdate(workInProgress) + finalizeContainerChildren(container, newChildSet) + end + end -- updateHostComponent = function( -- current: Fiber, -- workInProgress: Fiber, @@ -617,208 +623,231 @@ elseif supportsPersistence then -- end -- end else - -- No host operations - updateHostContainer = function(current: nil | Fiber, workInProgress: Fiber) - -- Noop - end --- updateHostComponent = function( --- current: Fiber, --- workInProgress: Fiber, --- type: Type, --- newProps: Props, --- rootContainerInstance: Container, --- ) --- -- Noop --- end --- updateHostText = function( --- current: Fiber, --- workInProgress: Fiber, --- oldText: string, --- newText: string, --- ) --- -- Noop --- end --- end - --- function cutOffTailIfNeeded( --- renderState: SuspenseListRenderState, --- hasRenderedATailFallback: boolean, --- ) --- if getIsHydrating()) --- -- If we're hydrating, we should consume as many items as we can --- -- so we don't leave any behind. --- return --- end --- switch (renderState.tailMode) --- case 'hidden': { --- -- Any insertions at the end of the tail list after this point --- -- should be invisible. If there are already mounted boundaries --- -- anything before them are not considered for collapsing. --- -- Therefore we need to go through the whole tail to find if --- -- there are any. --- local tailNode = renderState.tail --- local lastTailNode = nil --- while (tailNode ~= nil) --- if tailNode.alternate ~= nil) --- lastTailNode = tailNode --- end --- tailNode = tailNode.sibling --- end --- -- Next we're simply going to delete all insertions after the --- -- last rendered item. --- if lastTailNode == nil) --- -- All remaining items in the tail are insertions. --- renderState.tail = nil --- else --- -- Detach the insertion after the last node that was already --- -- inserted. --- lastTailNode.sibling = nil --- end --- break --- end --- case 'collapsed': { --- -- Any insertions at the end of the tail list after this point --- -- should be invisible. If there are already mounted boundaries --- -- anything before them are not considered for collapsing. --- -- Therefore we need to go through the whole tail to find if --- -- there are any. --- local tailNode = renderState.tail --- local lastTailNode = nil --- while (tailNode ~= nil) --- if tailNode.alternate ~= nil) --- lastTailNode = tailNode --- end --- tailNode = tailNode.sibling --- end --- -- Next we're simply going to delete all insertions after the --- -- last rendered item. --- if lastTailNode == nil) --- -- All remaining items in the tail are insertions. --- if !hasRenderedATailFallback and renderState.tail ~= nil) --- -- We suspended during the head. We want to show at least one --- -- row at the tail. So we'll keep on and cut off the rest. --- renderState.tail.sibling = nil --- else --- renderState.tail = nil --- end --- else --- -- Detach the insertion after the last node that was already --- -- inserted. --- lastTailNode.sibling = nil --- end --- break --- end --- end + -- No host operations + updateHostContainer = function(current: nil | Fiber, workInProgress: Fiber) + -- Noop + end + -- updateHostComponent = function( + -- current: Fiber, + -- workInProgress: Fiber, + -- type: Type, + -- newProps: Props, + -- rootContainerInstance: Container, + -- ) + -- -- Noop + -- end + -- updateHostText = function( + -- current: Fiber, + -- workInProgress: Fiber, + -- oldText: string, + -- newText: string, + -- ) + -- -- Noop + -- end + -- end + + -- function cutOffTailIfNeeded( + -- renderState: SuspenseListRenderState, + -- hasRenderedATailFallback: boolean, + -- ) + -- if getIsHydrating()) + -- -- If we're hydrating, we should consume as many items as we can + -- -- so we don't leave any behind. + -- return + -- end + -- switch (renderState.tailMode) + -- case 'hidden': { + -- -- Any insertions at the end of the tail list after this point + -- -- should be invisible. If there are already mounted boundaries + -- -- anything before them are not considered for collapsing. + -- -- Therefore we need to go through the whole tail to find if + -- -- there are any. + -- local tailNode = renderState.tail + -- local lastTailNode = nil + -- while (tailNode ~= nil) + -- if tailNode.alternate ~= nil) + -- lastTailNode = tailNode + -- end + -- tailNode = tailNode.sibling + -- end + -- -- Next we're simply going to delete all insertions after the + -- -- last rendered item. + -- if lastTailNode == nil) + -- -- All remaining items in the tail are insertions. + -- renderState.tail = nil + -- else + -- -- Detach the insertion after the last node that was already + -- -- inserted. + -- lastTailNode.sibling = nil + -- end + -- break + -- end + -- case 'collapsed': { + -- -- Any insertions at the end of the tail list after this point + -- -- should be invisible. If there are already mounted boundaries + -- -- anything before them are not considered for collapsing. + -- -- Therefore we need to go through the whole tail to find if + -- -- there are any. + -- local tailNode = renderState.tail + -- local lastTailNode = nil + -- while (tailNode ~= nil) + -- if tailNode.alternate ~= nil) + -- lastTailNode = tailNode + -- end + -- tailNode = tailNode.sibling + -- end + -- -- Next we're simply going to delete all insertions after the + -- -- last rendered item. + -- if lastTailNode == nil) + -- -- All remaining items in the tail are insertions. + -- if !hasRenderedATailFallback and renderState.tail ~= nil) + -- -- We suspended during the head. We want to show at least one + -- -- row at the tail. So we'll keep on and cut off the rest. + -- renderState.tail.sibling = nil + -- else + -- renderState.tail = nil + -- end + -- else + -- -- Detach the insertion after the last node that was already + -- -- inserted. + -- lastTailNode.sibling = nil + -- end + -- break + -- end + -- end end local function bubbleProperties(completedWork: Fiber) - local didBailout = - completedWork.alternate ~= nil and - (completedWork.alternate :: Fiber).child == completedWork.child - - local newChildLanes = NoLanes - local subtreeFlags = NoFlags - - if not didBailout then - -- Bubble up the earliest expiration time. - if enableProfilerTimer and bit32.band(completedWork.mode, ProfileMode) ~= NoMode then - -- In profiling mode, resetChildExpirationTime is also used to reset - -- profiler durations. - local actualDuration = completedWork.actualDuration - local treeBaseDuration = completedWork.selfBaseDuration - - local child = completedWork.child - while child ~= nil do - newChildLanes = mergeLanes( - newChildLanes, - mergeLanes(child.lanes, child.childLanes) - ) - - subtreeFlags = bit32.bor(subtreeFlags, child.subtreeFlags) - subtreeFlags = bit32.bor(subtreeFlags, child.flags) - - -- When a fiber is cloned, its actualDuration is reset to 0. This value will - -- only be updated if work is done on the fiber (i.e. it doesn't bailout). - -- When work is done, it should bubble to the parent's actualDuration. If - -- the fiber has not been cloned though, (meaning no work was done), then - -- this value will reflect the amount of time spent working on a previous - -- render. In that case it should not bubble. We determine whether it was - -- cloned by comparing the child pointer. - actualDuration += child.actualDuration - - treeBaseDuration += child.treeBaseDuration - child = child.sibling - end - - completedWork.actualDuration = actualDuration - completedWork.treeBaseDuration = treeBaseDuration - else - local child = completedWork.child - while child ~= nil do - newChildLanes = mergeLanes( - newChildLanes, - mergeLanes(child.lanes, child.childLanes) - ) - - subtreeFlags = bit32.bor(subtreeFlags, child.subtreeFlags) - subtreeFlags = bit32.bor(subtreeFlags, child.flags) - - child = child.sibling - end - end - - completedWork.subtreeFlags = bit32.bor(completedWork.subtreeFlags, subtreeFlags) - else - -- Bubble up the earliest expiration time. - if enableProfilerTimer and bit32.band(completedWork.mode, ProfileMode) ~= NoMode then - -- In profiling mode, resetChildExpirationTime is also used to reset - -- profiler durations. - local treeBaseDuration = completedWork.selfBaseDuration - - local child = completedWork.child - while child ~= nil do - newChildLanes = mergeLanes( - newChildLanes, - mergeLanes(child.lanes, child.childLanes) - ) - - -- "Static" flags share the lifetime of the fiber/hook they belong to, - -- so we should bubble those up even during a bailout. All the other - -- flags have a lifetime only of a single render + commit, so we should - -- ignore them. - subtreeFlags = bit32.bor(subtreeFlags, bit32.band(child.subtreeFlags, StaticMask)) - subtreeFlags = bit32.bor(subtreeFlags, bit32.band(child.flags, StaticMask)) - - treeBaseDuration += child.treeBaseDuration - child = child.sibling - end - - completedWork.treeBaseDuration = treeBaseDuration - else - local child = completedWork.child - while child ~= nil do - newChildLanes = mergeLanes( - newChildLanes, - mergeLanes(child.lanes, child.childLanes) - ) - - -- "Static" flags share the lifetime of the fiber/hook they belong to, - -- so we should bubble those up even during a bailout. All the other - -- flags have a lifetime only of a single render + commit, so we should - -- ignore them. - subtreeFlags = bit32.bor(subtreeFlags, bit32.band(child.subtreeFlags, StaticMask)) - subtreeFlags = bit32.bor(subtreeFlags, bit32.band(child.flags, StaticMask)) - - child = child.sibling - end - end - - completedWork.subtreeFlags = bit32.bor(completedWork.subtreeFlags, subtreeFlags) - end - - completedWork.childLanes = newChildLanes - - return didBailout + local didBailout = completedWork.alternate ~= nil + and (completedWork.alternate :: Fiber).child == completedWork.child + + local newChildLanes = NoLanes + local subtreeFlags = NoFlags + + if not didBailout then + -- Bubble up the earliest expiration time. + if + enableProfilerTimer + and bit32.band(completedWork.mode, ProfileMode) ~= NoMode + then + -- In profiling mode, resetChildExpirationTime is also used to reset + -- profiler durations. + local actualDuration = completedWork.actualDuration + local treeBaseDuration = completedWork.selfBaseDuration + + local child = completedWork.child + while child ~= nil do + newChildLanes = + mergeLanes(newChildLanes, mergeLanes(child.lanes, child.childLanes)) + + subtreeFlags = bit32.bor(subtreeFlags, child.subtreeFlags) + subtreeFlags = bit32.bor(subtreeFlags, child.flags) + + -- When a fiber is cloned, its actualDuration is reset to 0. This value will + -- only be updated if work is done on the fiber (i.e. it doesn't bailout). + -- When work is done, it should bubble to the parent's actualDuration. If + -- the fiber has not been cloned though, (meaning no work was done), then + -- this value will reflect the amount of time spent working on a previous + -- render. In that case it should not bubble. We determine whether it was + -- cloned by comparing the child pointer. + actualDuration += child.actualDuration + + treeBaseDuration += child.treeBaseDuration + child = child.sibling + end + + completedWork.actualDuration = actualDuration + completedWork.treeBaseDuration = treeBaseDuration + else + local child = completedWork.child + while child ~= nil do + -- ROBLOX performance: inline mergeLanes + -- newChildLanes = mergeLanes( + -- newChildLanes, + -- mergeLanes(child.lanes, child.childLanes) + -- ) + newChildLanes = + bit32.bor(newChildLanes, bit32.bor(child.lanes, child.childLanes)) + + subtreeFlags = bit32.bor(subtreeFlags, child.subtreeFlags) + subtreeFlags = bit32.bor(subtreeFlags, child.flags) + + -- ROBLOX note: this was missed in the "new" version of the file in React 17, but is fixed in React 18 + -- Update the return pointer so the tree is consistent. This is a code + -- smell because it assumes the commit phase is never concurrent with + -- the render phase. Will address during refactor to alternate model. + child.return_ = completedWork + + child = child.sibling + end + end + + completedWork.subtreeFlags = bit32.bor(completedWork.subtreeFlags, subtreeFlags) + else + -- Bubble up the earliest expiration time. + if + enableProfilerTimer + and bit32.band(completedWork.mode, ProfileMode) ~= NoMode + then + -- In profiling mode, resetChildExpirationTime is also used to reset + -- profiler durations. + local treeBaseDuration = completedWork.selfBaseDuration + + local child = completedWork.child + while child ~= nil do + newChildLanes = + mergeLanes(newChildLanes, mergeLanes(child.lanes, child.childLanes)) + + -- "Static" flags share the lifetime of the fiber/hook they belong to, + -- so we should bubble those up even during a bailout. All the other + -- flags have a lifetime only of a single render + commit, so we should + -- ignore them. + subtreeFlags = + bit32.bor(subtreeFlags, bit32.band(child.subtreeFlags, StaticMask)) + subtreeFlags = + bit32.bor(subtreeFlags, bit32.band(child.flags, StaticMask)) + + treeBaseDuration += child.treeBaseDuration + child = child.sibling + end + + completedWork.treeBaseDuration = treeBaseDuration + else + local child = completedWork.child + while child ~= nil do + -- ROBLOX performance: inline mergeLanes + -- newChildLanes = mergeLanes( + -- newChildLanes, + -- mergeLanes(child.lanes, child.childLanes) + -- ) + newChildLanes = + bit32.bor(newChildLanes, bit32.bor(child.lanes, child.childLanes)) + + -- "Static" flags share the lifetime of the fiber/hook they belong to, + -- so we should bubble those up even during a bailout. All the other + -- flags have a lifetime only of a single render + commit, so we should + -- ignore them. + subtreeFlags = + bit32.bor(subtreeFlags, bit32.band(child.subtreeFlags, StaticMask)) + subtreeFlags = + bit32.bor(subtreeFlags, bit32.band(child.flags, StaticMask)) + + -- ROBLOX note: this was missed in the "new" version of the file in React 17, but is fixed in React 18 + -- Update the return pointer so the tree is consistent. This is a code + -- smell because it assumes the commit phase is never concurrent with + -- the render phase. Will address during refactor to alternate model. + child.return_ = completedWork + + child = child.sibling + end + end + + completedWork.subtreeFlags = bit32.bor(completedWork.subtreeFlags, subtreeFlags) + end + + completedWork.childLanes = newChildLanes + + return didBailout end -- FIXME (roblox): type refinement @@ -828,756 +857,761 @@ end -- renderLanes: Lanes -- ): Fiber | nil local function completeWork( - current, - workInProgress: Fiber, - renderLanes: Lanes + current, + workInProgress: Fiber, + renderLanes: Lanes ): Fiber | nil - local newProps = workInProgress.pendingProps - - if - workInProgress.tag == IndeterminateComponent or - workInProgress.tag == LazyComponent or - workInProgress.tag == SimpleMemoComponent or - workInProgress.tag == FunctionComponent or - workInProgress.tag == ForwardRef or - workInProgress.tag == Fragment or - workInProgress.tag == Mode or - workInProgress.tag == ContextConsumer or - workInProgress.tag == MemoComponent - then - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == ClassComponent then - local Component = workInProgress.type - if isLegacyContextProvider(Component) then - popLegacyContext(workInProgress) - end - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == HostRoot then - popHostContainer(workInProgress) - popTopLevelLegacyContextObject(workInProgress) - resetMutableSourceWorkInProgressVersions() - -- ROBLOX FIXME: type coercion - -- local fiberRoot = (workInProgress.stateNode: FiberRoot) - local fiberRoot = workInProgress.stateNode - if fiberRoot.pendingContext then - fiberRoot.context = fiberRoot.pendingContext - fiberRoot.pendingContext = nil - end - if current == nil or current.child == nil then - -- If we hydrated, pop so that we can delete any remaining children - -- that weren't hydrated. - local wasHydrated = popHydrationState(workInProgress) - if wasHydrated then - -- If we hydrated, then we'll need to schedule an update for - -- the commit side-effects on the root. - markUpdate(workInProgress) - elseif not fiberRoot.hydrate then - -- Schedule an effect to clear this container at the start of the next commit. - -- This handles the case of React rendering into a container with previous children. - -- It's also safe to do for updates too, because current.child would only be nil - -- if the previous render was nil (so the the container would already be empty). - workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) - end - end - updateHostContainer(current, workInProgress) - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == HostComponent then - popHostContext(workInProgress) - local rootContainerInstance = getRootHostContainer() - local type = workInProgress.type - if current ~= nil and workInProgress.stateNode ~= nil then - updateHostComponent( - current, - workInProgress, - type, - newProps, - rootContainerInstance - ) - - if current.ref ~= workInProgress.ref then - markRef(workInProgress) - end - else - if not newProps then - invariant( - workInProgress.stateNode ~= nil, - "We must have new props for new mounts. This error is likely " .. - "caused by a bug in React. Please file an issue." - ) - -- This can happen when we abort work. - bubbleProperties(workInProgress) - return nil - end - - local currentHostContext = getHostContext() - -- TODO: Move createInstance to beginWork and keep it on a context - -- "stack" as the parent. Then append children as we go in beginWork - -- or completeWork depending on whether we want to add them top->down or - -- bottom->up. Top->down is faster in IE11. - local wasHydrated = popHydrationState(workInProgress) - if wasHydrated then - -- TODO: Move this and createInstance step into the beginPhase - -- to consolidate. - if - prepareToHydrateHostInstance( - workInProgress, - rootContainerInstance, - currentHostContext - ) - then - -- If changes to the hydrated node need to be applied at the - -- commit-phase we mark this as such. - markUpdate(workInProgress) - end - else - - local instance = createInstance( - type, - newProps, - rootContainerInstance, - currentHostContext, - workInProgress - ) - - appendAllChildren(instance, workInProgress, false, false) - - workInProgress.stateNode = instance - - -- Certain renderers require commit-time effects for initial mount. - -- (eg DOM renderer supports auto-focus for certain elements). - -- Make sure such renderers get scheduled for later work. - if - finalizeInitialChildren( - instance, - type, - newProps, - rootContainerInstance, - currentHostContext - ) - then - markUpdate(workInProgress) - end - end - - if workInProgress.ref ~= nil then - -- If there is a ref on a host node we need to schedule a callback - markRef(workInProgress) - end - end - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == HostText then - local newText = newProps - if current and workInProgress.stateNode ~= nil then - local oldText = current.memoizedProps - -- If we have an alternate, that means this is an update and we need - -- to schedule a side-effect to do the updates. - updateHostText(current, workInProgress, oldText, newText) - else - if typeof(newText) ~= 'string' then - invariant( - workInProgress.stateNode ~= nil, - 'We must have new props for new mounts. This error is likely ' .. - 'caused by a bug in React. Please file an issue.' - ) - -- This can happen when we abort work. - end - local rootContainerInstance = getRootHostContainer() - local currentHostContext = getHostContext() - local wasHydrated = popHydrationState(workInProgress) - if wasHydrated then - if prepareToHydrateHostTextInstance(workInProgress) then - markUpdate(workInProgress) - end - else - workInProgress.stateNode = createTextInstance( - newText, - rootContainerInstance, - currentHostContext, - workInProgress - ) - end - end - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == Profiler then - local didBailout = bubbleProperties(workInProgress) - if not didBailout then - -- Use subtreeFlags to determine which commit callbacks should fire. - -- TODO: Move this logic to the commit phase, since we already check if - -- a fiber's subtree contains effects. Refactor the commit phase's - -- depth-first traversal so that we can put work tag-specific logic - -- before or after committing a subtree's effects. - local OnRenderFlag = Update - local OnCommitFlag = Callback - local OnPostCommitFlag = Passive - local subtreeFlags = workInProgress.subtreeFlags - local flags = workInProgress.flags - local newFlags = flags - - -- Call onRender any time this fiber or its subtree are worked on. - if - bit32.band(flags, PerformedWork) ~= NoFlags or - bit32.band(subtreeFlags, PerformedWork) ~= NoFlags - then - newFlags = bit32.bor(newFlags, OnRenderFlag) - end - - -- Call onCommit only if the subtree contains layout work, or if it - -- contains deletions, since those might result in unmount work, which - -- we include in the same measure. - -- TODO: Can optimize by using a static flag to track whether a tree - -- contains layout effects, like we do for passive effects. - if - bit32.band(flags, bit32.bor(LayoutMask, Deletion)) ~= NoFlags or - bit32.band(subtreeFlags, bit32.bor(LayoutMask, Deletion)) ~= NoFlags - then - newFlags = bit32.bor(newFlags, OnCommitFlag) - end - - -- Call onPostCommit only if the subtree contains passive work. - -- Don't have to check for deletions, because Deletion is already - -- a passive flag. - if - bit32.band(flags, PassiveMask) ~= NoFlags or - bit32.band(subtreeFlags, PassiveMask) ~= NoFlags - then - newFlags = bit32.bor(newFlags, OnPostCommitFlag) - end - workInProgress.flags = newFlags - else - -- This fiber and its subtree bailed out, so don't fire any callbacks. - end - - return nil - elseif workInProgress.tag == SuspenseComponent then - popSuspenseContext(workInProgress) - local nextState: nil | SuspenseState = workInProgress.memoizedState - - if enableSuspenseServerRenderer then - -- ROBLOX FIXME: remove :: recast once Luau understands if statement nil check - if nextState ~= nil and (nextState :: SuspenseState).dehydrated ~= nil then - if current == nil then - local wasHydrated = popHydrationState(workInProgress) - invariant( - wasHydrated, - 'A dehydrated suspense component was completed without a hydrated node. ' .. - 'This is probably a bug in React.' - ) - prepareToHydrateHostSuspenseInstance(workInProgress) - if enableSchedulerTracing then - markSpawnedWork(OffscreenLane) - end - bubbleProperties(workInProgress) - if enableProfilerTimer then - if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then - local isTimedOutSuspense = nextState ~= nil - if isTimedOutSuspense then - -- Don't count time spent in a timed out Suspense subtree as part of the base duration. - local primaryChildFragment = workInProgress.child - if primaryChildFragment ~= nil then - -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator - workInProgress.treeBaseDuration = ((primaryChildFragment.treeBaseDuration :: any) :: number) - end - end - end - end - return nil - else - -- We should never have been in a hydration state if we didn't have a current. - -- However, in some of those paths, we might have reentered a hydration state - -- and then we might be inside a hydration state. In that case, we'll need to exit out of it. - resetHydrationState() - if bit32.band(workInProgress.flags, DidCapture) == NoFlags then - -- This boundary did not suspend so it's now hydrated and unsuspended. - workInProgress.memoizedState = nil - end - -- If nothing suspended, we need to schedule an effect to mark this boundary - -- as having hydrated so events know that they're free to be invoked. - -- It's also a signal to replay events and the suspense callback. - -- If something suspended, schedule an effect to attach retry listeners. - -- So we might as well always mark this. - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - bubbleProperties(workInProgress) - if enableProfilerTimer then - if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then - local isTimedOutSuspense = nextState ~= nil - if isTimedOutSuspense then - -- Don't count time spent in a timed out Suspense subtree as part of the base duration. - local primaryChildFragment = workInProgress.child - if primaryChildFragment ~= nil then - -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator - -- ROBLOX deviation: remove recast to silence analyze - workInProgress.treeBaseDuration -= primaryChildFragment.treeBaseDuration - end - end - end - end - return nil - end - end - end - - if bit32.band(workInProgress.flags, DidCapture) ~= NoFlags then - -- Something suspended. Re-render with the fallback children. - workInProgress.lanes = renderLanes - -- Do not reset the effect list. - if - enableProfilerTimer and - bit32.band(workInProgress.mode, ProfileMode) ~= NoMode - then - transferActualDuration(workInProgress) - end - -- Don't bubble properties in this case. - return workInProgress - end - - local nextDidTimeout = nextState ~= nil - local prevDidTimeout = false - if current == nil then - if workInProgress.memoizedProps.fallback ~= nil then - popHydrationState(workInProgress) - end - else - local prevState: nil | SuspenseState = current.memoizedState - prevDidTimeout = prevState ~= nil - end - - if nextDidTimeout and not prevDidTimeout then - -- If this subtreee is running in blocking mode we can suspend, - -- otherwise we won't suspend. - -- TODO: This will still suspend a synchronous tree if anything - -- in the concurrent tree already suspended during this render. - -- This is a known bug. - if bit32.band(workInProgress.mode, BlockingMode) ~= NoMode then - -- TODO: Move this back to throwException because this is too late - -- if this is a large tree which is common for initial loads. We - -- don't know if we should restart a render or not until we get - -- this marker, and this is too late. - -- If this render already had a ping or lower pri updates, - -- and this is the first time we know we're going to suspend we - -- should be able to immediately restart from within throwException. - local hasInvisibleChildContext = - current == nil and - workInProgress.memoizedProps.unstable_avoidThisFallback ~= true - if - hasInvisibleChildContext or - hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext :: SuspenseContext) - ) - then - -- If this was in an invisible tree or a new render, then showing - -- this boundary is ok. - renderDidSuspend() - else - -- Otherwise, we're going to have to hide content so we should - -- suspend for longer if possible. - renderDidSuspendDelayIfPossible() - end - end - end - - if supportsPersistence then - -- TODO: Only schedule updates if not prevDidTimeout. - if nextDidTimeout then - -- If this boundary just timed out, schedule an effect to attach a - -- retry listener to the promise. This flag is also used to hide the - -- primary children. - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - if supportsMutation then - -- TODO: Only schedule updates if these values are non equal, i.e. it changed. - if nextDidTimeout or prevDidTimeout then - -- If this boundary just timed out, schedule an effect to attach a - -- retry listener to the promise. This flag is also used to hide the - -- primary children. In mutation mode, we also need the flag to - -- *unhide* children that were previously hidden, so check if this - -- is currently timed out, too. - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - if - enableSuspenseCallback and - workInProgress.updateQueue ~= nil and - workInProgress.memoizedProps.suspenseCallback ~= nil - then - -- Always notify the callback - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - bubbleProperties(workInProgress) - if enableProfilerTimer then - if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then - if nextDidTimeout then - -- Don't count time spent in a timed out Suspense subtree as part of the base duration. - local primaryChildFragment = workInProgress.child - if primaryChildFragment ~= nil then - -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator - -- ROBLOX deviation: remove recast to silence analyze - workInProgress.treeBaseDuration -= primaryChildFragment.treeBaseDuration - end - end - end - end - return nil - elseif workInProgress.tag == HostPortal then - popHostContainer(workInProgress) - updateHostContainer(current, workInProgress) - if current == nil then - preparePortalMount(workInProgress.stateNode.containerInfo) - end - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == ContextProvider then - -- Pop provider fiber - popProvider(workInProgress) - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == IncompleteClassComponent then - -- Same as class component case. I put it down here so that the tags are - -- sequential to ensure this switch is compiled to a jump table. - local Component = workInProgress.type - if isLegacyContextProvider(Component) then - popLegacyContext(workInProgress) - end - bubbleProperties(workInProgress) - return nil - elseif workInProgress.tag == SuspenseListComponent then - unimplemented("SuspenseListComponent") - -- popSuspenseContext(workInProgress) - - -- local renderState: nil | SuspenseListRenderState = - -- workInProgress.memoizedState - - -- if renderState == nil) - -- -- We're running in the default, "independent" mode. - -- -- We don't do anything in this mode. - -- bubbleProperties(workInProgress) - -- return nil - -- end - - -- local didSuspendAlready = (workInProgress.flags & DidCapture) ~= NoFlags - - -- local renderedTail = renderState.rendering - -- if renderedTail == nil) - -- -- We just rendered the head. - -- if !didSuspendAlready) - -- -- This is the first pass. We need to figure out if anything is still - -- -- suspended in the rendered set. - - -- -- If new content unsuspended, but there's still some content that - -- -- didn't. Then we need to do a second pass that forces everything - -- -- to keep showing their fallbacks. - - -- -- We might be suspended if something in this render pass suspended, or - -- -- something in the previous committed pass suspended. Otherwise, - -- -- there's no chance so we can skip the expensive call to - -- -- findFirstSuspended. - -- local cannotBeSuspended = - -- renderHasNotSuspendedYet() and - -- (current == nil or (current.flags & DidCapture) == NoFlags) - -- if !cannotBeSuspended) - -- local row = workInProgress.child - -- while (row ~= nil) - -- local suspended = findFirstSuspended(row) - -- if suspended ~= nil) - -- didSuspendAlready = true - -- workInProgress.flags |= DidCapture - -- cutOffTailIfNeeded(renderState, false) - - -- -- If this is a newly suspended tree, it might not get committed as - -- -- part of the second pass. In that case nothing will subscribe to - -- -- its thennables. Instead, we'll transfer its thennables to the - -- -- SuspenseList so that it can retry if they resolve. - -- -- There might be multiple of these in the list but since we're - -- -- going to wait for all of them anyway, it doesn't really matter - -- -- which ones gets to ping. In theory we could get clever and keep - -- -- track of how many dependencies remain but it gets tricky because - -- -- in the meantime, we can add/remove/change items and dependencies. - -- -- We might bail out of the loop before finding any but that - -- -- doesn't matter since that means that the other boundaries that - -- -- we did find already has their listeners attached. - -- local newThennables = suspended.updateQueue - -- if newThennables ~= nil) - -- workInProgress.updateQueue = newThennables - -- workInProgress.flags |= Update - -- end - - -- -- Rerender the whole list, but this time, we'll force fallbacks - -- -- to stay in place. - -- -- Reset the child fibers to their original state. - -- workInProgress.subtreeFlags = NoFlags - -- resetChildFibers(workInProgress, renderLanes) - - -- -- Set up the Suspense Context to force suspense and immediately - -- -- rerender the children. - -- pushSuspenseContext( - -- workInProgress, - -- setShallowSuspenseContext( - -- suspenseStackCursor.current, - -- ForceSuspenseFallback, - -- ), - -- ) - -- -- Don't bubble properties in this case. - -- return workInProgress.child - -- end - -- row = row.sibling - -- end - -- end - - -- if renderState.tail ~= nil and now() > getRenderTargetTime()) - -- -- We have already passed our CPU deadline but we still have rows - -- -- left in the tail. We'll just give up further attempts to render - -- -- the main content and only render fallbacks. - -- workInProgress.flags |= DidCapture - -- didSuspendAlready = true - - -- cutOffTailIfNeeded(renderState, false) - - -- -- Since nothing actually suspended, there will nothing to ping this - -- -- to get it started back up to attempt the next item. While in terms - -- -- of priority this work has the same priority as this current render, - -- -- it's not part of the same transition once the transition has - -- -- committed. If it's sync, we still want to yield so that it can be - -- -- painted. Conceptually, this is really the same as pinging. - -- -- We can use any RetryLane even if it's the one currently rendering - -- -- since we're leaving it behind on this node. - -- workInProgress.lanes = SomeRetryLane - -- if enableSchedulerTracing) - -- markSpawnedWork(SomeRetryLane) - -- end - -- end - -- else - -- cutOffTailIfNeeded(renderState, false) - -- end - -- -- Next we're going to render the tail. - -- else - -- -- Append the rendered row to the child list. - -- if !didSuspendAlready) - -- local suspended = findFirstSuspended(renderedTail) - -- if suspended ~= nil) - -- workInProgress.flags |= DidCapture - -- didSuspendAlready = true - - -- -- Ensure we transfer the update queue to the parent so that it doesn't - -- -- get lost if this row ends up dropped during a second pass. - -- local newThennables = suspended.updateQueue - -- if newThennables ~= nil) - -- workInProgress.updateQueue = newThennables - -- workInProgress.flags |= Update - -- end - - -- cutOffTailIfNeeded(renderState, true) - -- -- This might have been modified. - -- if - -- renderState.tail == nil and - -- renderState.tailMode == 'hidden' and - -- !renderedTail.alternate and - -- !getIsHydrating() -- We don't cut it if we're hydrating. - -- ) - -- -- We're done. - -- bubbleProperties(workInProgress) - -- return nil - -- end - -- } else if - -- -- The time it took to render last row is greater than the remaining - -- -- time we have to render. So rendering one more row would likely - -- -- exceed it. - -- now() * 2 - renderState.renderingStartTime > - -- getRenderTargetTime() and - -- renderLanes ~= OffscreenLane - -- ) - -- -- We have now passed our CPU deadline and we'll just give up further - -- -- attempts to render the main content and only render fallbacks. - -- -- The assumption is that this is usually faster. - -- workInProgress.flags |= DidCapture - -- didSuspendAlready = true - - -- cutOffTailIfNeeded(renderState, false) - - -- -- Since nothing actually suspended, there will nothing to ping this - -- -- to get it started back up to attempt the next item. If we can show - -- -- them, then they really have the same priority as this render. - -- -- So we'll pick it back up the very next render pass once we've had - -- -- an opportunity to yield for paint. - -- workInProgress.lanes = SomeRetryLane - -- if enableSchedulerTracing) - -- markSpawnedWork(SomeRetryLane) - -- end - -- end - -- end - -- if renderState.isBackwards) - -- -- The effect list of the backwards tail will have been added - -- -- to the end. This breaks the guarantee that life-cycles fire in - -- -- sibling order but that isn't a strong guarantee promised by React. - -- -- Especially since these might also just pop in during future commits. - -- -- Append to the beginning of the list. - -- renderedTail.sibling = workInProgress.child - -- workInProgress.child = renderedTail - -- else - -- local previousSibling = renderState.last - -- if previousSibling ~= nil) - -- previousSibling.sibling = renderedTail - -- else - -- workInProgress.child = renderedTail - -- end - -- renderState.last = renderedTail - -- end - -- end - - -- if renderState.tail ~= nil) - -- -- We still have tail rows to render. - -- -- Pop a row. - -- local next = renderState.tail - -- renderState.rendering = next - -- renderState.tail = next.sibling - -- renderState.renderingStartTime = now() - -- next.sibling = nil - - -- -- Restore the context. - -- -- TODO: We can probably just avoid popping it instead and only - -- -- setting it the first time we go from not suspended to suspended. - -- local suspenseContext = suspenseStackCursor.current - -- if didSuspendAlready) - -- suspenseContext = setShallowSuspenseContext( - -- suspenseContext, - -- ForceSuspenseFallback, - -- ) - -- else - -- suspenseContext = setDefaultShallowSuspenseContext(suspenseContext) - -- end - -- pushSuspenseContext(workInProgress, suspenseContext) - -- -- Do a pass over the next row. - -- -- Don't bubble properties in this case. - -- return next - -- end - -- bubbleProperties(workInProgress) - -- return nil - -- end - elseif workInProgress.tag == FundamentalComponent then - unimplemented("FundamentalComponent") - -- if enableFundamentalAPI) - -- local fundamentalImpl = workInProgress.type.impl - -- local fundamentalInstance: ReactFundamentalComponentInstance< - -- any, - -- any, - -- > | nil = workInProgress.stateNode - - -- if fundamentalInstance == nil) - -- local getInitialState = fundamentalImpl.getInitialState - -- local fundamentalState - -- if getInitialState ~= undefined) - -- fundamentalState = getInitialState(newProps) - -- end - -- fundamentalInstance = workInProgress.stateNode = createFundamentalStateInstance( - -- workInProgress, - -- newProps, - -- fundamentalImpl, - -- fundamentalState or {}, - -- ) - -- local instance = ((getFundamentalComponentInstance( - -- fundamentalInstance, - -- ): any): Instance) - -- fundamentalInstance.instance = instance - -- if fundamentalImpl.reconcileChildren == false) - -- bubbleProperties(workInProgress) - -- return nil - -- end - -- appendAllChildren(instance, workInProgress, false, false) - -- mountFundamentalComponent(fundamentalInstance) - -- else - -- -- We fire update in commit phase - -- local prevProps = fundamentalInstance.props - -- fundamentalInstance.prevProps = prevProps - -- fundamentalInstance.props = newProps - -- fundamentalInstance.currentFiber = workInProgress - -- if supportsPersistence) - -- local instance = cloneFundamentalInstance(fundamentalInstance) - -- fundamentalInstance.instance = instance - -- appendAllChildren(instance, workInProgress, false, false) - -- end - -- local shouldUpdate = shouldUpdateFundamentalComponent( - -- fundamentalInstance, - -- ) - -- if shouldUpdate) - -- markUpdate(workInProgress) - -- end - -- end - -- bubbleProperties(workInProgress) - -- return nil - -- end - elseif workInProgress.tag == ScopeComponent then - unimplemented("ScopeComponent") - -- if enableScopeAPI) - -- if current == nil) - -- local scopeInstance: ReactScopeInstance = createScopeInstance() - -- workInProgress.stateNode = scopeInstance - -- prepareScopeUpdate(scopeInstance, workInProgress) - -- if workInProgress.ref ~= nil) - -- markRef(workInProgress) - -- markUpdate(workInProgress) - -- end - -- else - -- if workInProgress.ref ~= nil) - -- markUpdate(workInProgress) - -- end - -- if current.ref ~= workInProgress.ref) - -- markRef(workInProgress) - -- end - -- end - -- bubbleProperties(workInProgress) - -- return nil - -- end - elseif workInProgress.tag == Block then - unimplemented("Block") - -- if enableBlocksAPI) - -- bubbleProperties(workInProgress) - -- return nil - -- end - elseif - workInProgress.tag == OffscreenComponent or - workInProgress.tag == LegacyHiddenComponent - then - popRenderLanes(workInProgress) - local nextState: OffscreenState | nil = workInProgress.memoizedState - local nextIsHidden = nextState ~= nil - - if current ~= nil then - local prevState: OffscreenState | nil = current.memoizedState - - local prevIsHidden = prevState ~= nil - if - prevIsHidden ~= nextIsHidden and - newProps.mode ~= 'unstable-defer-without-hiding' - then - workInProgress.flags = bit32.bor(workInProgress.flags, Update) - end - end - - -- Don't bubble properties for hidden children. - if - not nextIsHidden or - includesSomeLane(ReactFiberWorkLoop.subtreeRenderLanes, (OffscreenLane :: Lane)) or - bit32.band(workInProgress.mode, ConcurrentMode) == NoMode - then - bubbleProperties(workInProgress) - end - - return nil - end - invariant( - false, - "Unknown unit of work tag (%s). This error is likely caused by a bug in " .. - "React. Please file an issue.", - workInProgress.tag - ) - return nil + local newProps = workInProgress.pendingProps + + if + workInProgress.tag == IndeterminateComponent + or workInProgress.tag == LazyComponent + or workInProgress.tag == SimpleMemoComponent + or workInProgress.tag == FunctionComponent + or workInProgress.tag == ForwardRef + or workInProgress.tag == Fragment + or workInProgress.tag == Mode + or workInProgress.tag == ContextConsumer + or workInProgress.tag == MemoComponent + then + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == ClassComponent then + local Component = workInProgress.type + if isLegacyContextProvider(Component) then + popLegacyContext(workInProgress) + end + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == HostRoot then + popHostContainer(workInProgress) + popTopLevelLegacyContextObject(workInProgress) + resetMutableSourceWorkInProgressVersions() + -- ROBLOX FIXME: type coercion + -- local fiberRoot = (workInProgress.stateNode: FiberRoot) + local fiberRoot = workInProgress.stateNode + if fiberRoot.pendingContext then + fiberRoot.context = fiberRoot.pendingContext + fiberRoot.pendingContext = nil + end + if current == nil or current.child == nil then + -- If we hydrated, pop so that we can delete any remaining children + -- that weren't hydrated. + local wasHydrated = popHydrationState(workInProgress) + if wasHydrated then + -- If we hydrated, then we'll need to schedule an update for + -- the commit side-effects on the root. + markUpdate(workInProgress) + elseif not fiberRoot.hydrate then + -- Schedule an effect to clear this container at the start of the next commit. + -- This handles the case of React rendering into a container with previous children. + -- It's also safe to do for updates too, because current.child would only be nil + -- if the previous render was nil (so the the container would already be empty). + workInProgress.flags = bit32.bor(workInProgress.flags, Snapshot) + end + end + updateHostContainer(current, workInProgress) + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == HostComponent then + popHostContext(workInProgress) + local rootContainerInstance = getRootHostContainer() + local type = workInProgress.type + if current ~= nil and workInProgress.stateNode ~= nil then + updateHostComponent( + current, + workInProgress, + type, + newProps, + rootContainerInstance + ) + + if current.ref ~= workInProgress.ref then + markRef(workInProgress) + end + else + if not newProps then + invariant( + workInProgress.stateNode ~= nil, + "We must have new props for new mounts. This error is likely " + .. "caused by a bug in React. Please file an issue." + ) + -- This can happen when we abort work. + bubbleProperties(workInProgress) + return nil + end + + local currentHostContext = getHostContext() + -- TODO: Move createInstance to beginWork and keep it on a context + -- "stack" as the parent. Then append children as we go in beginWork + -- or completeWork depending on whether we want to add them top->down or + -- bottom->up. Top->down is faster in IE11. + local wasHydrated = popHydrationState(workInProgress) + if wasHydrated then + -- TODO: Move this and createInstance step into the beginPhase + -- to consolidate. + if + prepareToHydrateHostInstance( + workInProgress, + rootContainerInstance, + currentHostContext + ) + then + -- If changes to the hydrated node need to be applied at the + -- commit-phase we mark this as such. + markUpdate(workInProgress) + end + else + local instance = createInstance( + type, + newProps, + rootContainerInstance, + currentHostContext, + workInProgress + ) + + appendAllChildren(instance, workInProgress, false, false) + + workInProgress.stateNode = instance + + -- Certain renderers require commit-time effects for initial mount. + -- (eg DOM renderer supports auto-focus for certain elements). + -- Make sure such renderers get scheduled for later work. + if + finalizeInitialChildren( + instance, + type, + newProps, + rootContainerInstance, + currentHostContext + ) + then + markUpdate(workInProgress) + end + end + + if workInProgress.ref ~= nil then + -- If there is a ref on a host node we need to schedule a callback + markRef(workInProgress) + end + end + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == HostText then + local newText = newProps + if current and workInProgress.stateNode ~= nil then + local oldText = current.memoizedProps + -- If we have an alternate, that means this is an update and we need + -- to schedule a side-effect to do the updates. + updateHostText(current, workInProgress, oldText, newText) + else + if typeof(newText) ~= "string" then + invariant( + workInProgress.stateNode ~= nil, + "We must have new props for new mounts. This error is likely " + .. "caused by a bug in React. Please file an issue." + ) + -- This can happen when we abort work. + end + local rootContainerInstance = getRootHostContainer() + local currentHostContext = getHostContext() + local wasHydrated = popHydrationState(workInProgress) + if wasHydrated then + if prepareToHydrateHostTextInstance(workInProgress) then + markUpdate(workInProgress) + end + else + workInProgress.stateNode = createTextInstance( + newText, + rootContainerInstance, + currentHostContext, + workInProgress + ) + end + end + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == Profiler then + local didBailout = bubbleProperties(workInProgress) + if not didBailout then + -- Use subtreeFlags to determine which commit callbacks should fire. + -- TODO: Move this logic to the commit phase, since we already check if + -- a fiber's subtree contains effects. Refactor the commit phase's + -- depth-first traversal so that we can put work tag-specific logic + -- before or after committing a subtree's effects. + local OnRenderFlag = Update + local OnCommitFlag = Callback + local OnPostCommitFlag = Passive + local subtreeFlags = workInProgress.subtreeFlags + local flags = workInProgress.flags + local newFlags = flags + + -- Call onRender any time this fiber or its subtree are worked on. + if + bit32.band(flags, PerformedWork) ~= NoFlags + or bit32.band(subtreeFlags, PerformedWork) ~= NoFlags + then + newFlags = bit32.bor(newFlags, OnRenderFlag) + end + + -- Call onCommit only if the subtree contains layout work, or if it + -- contains deletions, since those might result in unmount work, which + -- we include in the same measure. + -- TODO: Can optimize by using a static flag to track whether a tree + -- contains layout effects, like we do for passive effects. + if + bit32.band(flags, bit32.bor(LayoutMask, Deletion)) ~= NoFlags + or bit32.band(subtreeFlags, bit32.bor(LayoutMask, Deletion)) + ~= NoFlags + then + newFlags = bit32.bor(newFlags, OnCommitFlag) + end + + -- Call onPostCommit only if the subtree contains passive work. + -- Don't have to check for deletions, because Deletion is already + -- a passive flag. + if + bit32.band(flags, PassiveMask) ~= NoFlags + or bit32.band(subtreeFlags, PassiveMask) ~= NoFlags + then + newFlags = bit32.bor(newFlags, OnPostCommitFlag) + end + workInProgress.flags = newFlags + else + -- This fiber and its subtree bailed out, so don't fire any callbacks. + end + + return nil + elseif workInProgress.tag == SuspenseComponent then + popSuspenseContext(workInProgress) + local nextState: nil | SuspenseState = workInProgress.memoizedState + + if enableSuspenseServerRenderer then + -- ROBLOX FIXME: remove :: recast once Luau understands if statement nil check + if nextState ~= nil and (nextState :: SuspenseState).dehydrated ~= nil then + if current == nil then + local wasHydrated = popHydrationState(workInProgress) + invariant( + wasHydrated, + "A dehydrated suspense component was completed without a hydrated node. " + .. "This is probably a bug in React." + ) + prepareToHydrateHostSuspenseInstance(workInProgress) + if enableSchedulerTracing then + markSpawnedWork(OffscreenLane) + end + bubbleProperties(workInProgress) + if enableProfilerTimer then + if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then + local isTimedOutSuspense = nextState ~= nil + if isTimedOutSuspense then + -- Don't count time spent in a timed out Suspense subtree as part of the base duration. + local primaryChildFragment = workInProgress.child + if primaryChildFragment ~= nil then + -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator + workInProgress.treeBaseDuration = ( + primaryChildFragment.treeBaseDuration :: any + ) :: number + end + end + end + end + return nil + else + -- We should never have been in a hydration state if we didn't have a current. + -- However, in some of those paths, we might have reentered a hydration state + -- and then we might be inside a hydration state. In that case, we'll need to exit out of it. + resetHydrationState() + if bit32.band(workInProgress.flags, DidCapture) == NoFlags then + -- This boundary did not suspend so it's now hydrated and unsuspended. + workInProgress.memoizedState = nil + end + -- If nothing suspended, we need to schedule an effect to mark this boundary + -- as having hydrated so events know that they're free to be invoked. + -- It's also a signal to replay events and the suspense callback. + -- If something suspended, schedule an effect to attach retry listeners. + -- So we might as well always mark this. + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + bubbleProperties(workInProgress) + if enableProfilerTimer then + if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then + local isTimedOutSuspense = nextState ~= nil + if isTimedOutSuspense then + -- Don't count time spent in a timed out Suspense subtree as part of the base duration. + local primaryChildFragment = workInProgress.child + if primaryChildFragment ~= nil then + -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator + -- ROBLOX deviation: remove recast to silence analyze + workInProgress.treeBaseDuration -= primaryChildFragment.treeBaseDuration + end + end + end + end + return nil + end + end + end + + if bit32.band(workInProgress.flags, DidCapture) ~= NoFlags then + -- Something suspended. Re-render with the fallback children. + workInProgress.lanes = renderLanes + -- Do not reset the effect list. + if + enableProfilerTimer + and bit32.band(workInProgress.mode, ProfileMode) ~= NoMode + then + transferActualDuration(workInProgress) + end + -- Don't bubble properties in this case. + return workInProgress + end + + local nextDidTimeout = nextState ~= nil + local prevDidTimeout = false + if current == nil then + if workInProgress.memoizedProps.fallback ~= nil then + popHydrationState(workInProgress) + end + else + local prevState: nil | SuspenseState = current.memoizedState + prevDidTimeout = prevState ~= nil + end + + if nextDidTimeout and not prevDidTimeout then + -- If this subtreee is running in blocking mode we can suspend, + -- otherwise we won't suspend. + -- TODO: This will still suspend a synchronous tree if anything + -- in the concurrent tree already suspended during this render. + -- This is a known bug. + if bit32.band(workInProgress.mode, BlockingMode) ~= NoMode then + -- TODO: Move this back to throwException because this is too late + -- if this is a large tree which is common for initial loads. We + -- don't know if we should restart a render or not until we get + -- this marker, and this is too late. + -- If this render already had a ping or lower pri updates, + -- and this is the first time we know we're going to suspend we + -- should be able to immediately restart from within throwException. + local hasInvisibleChildContext = current == nil + and workInProgress.memoizedProps.unstable_avoidThisFallback + ~= true + if + hasInvisibleChildContext + or hasSuspenseContext( + suspenseStackCursor.current, + InvisibleParentSuspenseContext :: SuspenseContext + ) + then + -- If this was in an invisible tree or a new render, then showing + -- this boundary is ok. + renderDidSuspend() + else + -- Otherwise, we're going to have to hide content so we should + -- suspend for longer if possible. + renderDidSuspendDelayIfPossible() + end + end + end + + if supportsPersistence then + -- TODO: Only schedule updates if not prevDidTimeout. + if nextDidTimeout then + -- If this boundary just timed out, schedule an effect to attach a + -- retry listener to the promise. This flag is also used to hide the + -- primary children. + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + if supportsMutation then + -- TODO: Only schedule updates if these values are non equal, i.e. it changed. + if nextDidTimeout or prevDidTimeout then + -- If this boundary just timed out, schedule an effect to attach a + -- retry listener to the promise. This flag is also used to hide the + -- primary children. In mutation mode, we also need the flag to + -- *unhide* children that were previously hidden, so check if this + -- is currently timed out, too. + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + if + enableSuspenseCallback + and workInProgress.updateQueue ~= nil + and workInProgress.memoizedProps.suspenseCallback ~= nil + then + -- Always notify the callback + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + bubbleProperties(workInProgress) + if enableProfilerTimer then + if bit32.band(workInProgress.mode, ProfileMode) ~= NoMode then + if nextDidTimeout then + -- Don't count time spent in a timed out Suspense subtree as part of the base duration. + local primaryChildFragment = workInProgress.child + if primaryChildFragment ~= nil then + -- $FlowFixMe Flow doens't support type casting in combiation with the -= operator + -- ROBLOX deviation: remove recast to silence analyze + workInProgress.treeBaseDuration -= primaryChildFragment.treeBaseDuration + end + end + end + end + return nil + elseif workInProgress.tag == HostPortal then + popHostContainer(workInProgress) + updateHostContainer(current, workInProgress) + if current == nil then + preparePortalMount(workInProgress.stateNode.containerInfo) + end + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == ContextProvider then + -- Pop provider fiber + popProvider(workInProgress) + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == IncompleteClassComponent then + -- Same as class component case. I put it down here so that the tags are + -- sequential to ensure this switch is compiled to a jump table. + local Component = workInProgress.type + if isLegacyContextProvider(Component) then + popLegacyContext(workInProgress) + end + bubbleProperties(workInProgress) + return nil + elseif workInProgress.tag == SuspenseListComponent then + unimplemented("SuspenseListComponent") + -- popSuspenseContext(workInProgress) + + -- local renderState: nil | SuspenseListRenderState = + -- workInProgress.memoizedState + + -- if renderState == nil) + -- -- We're running in the default, "independent" mode. + -- -- We don't do anything in this mode. + -- bubbleProperties(workInProgress) + -- return nil + -- end + + -- local didSuspendAlready = (workInProgress.flags & DidCapture) ~= NoFlags + + -- local renderedTail = renderState.rendering + -- if renderedTail == nil) + -- -- We just rendered the head. + -- if !didSuspendAlready) + -- -- This is the first pass. We need to figure out if anything is still + -- -- suspended in the rendered set. + + -- -- If new content unsuspended, but there's still some content that + -- -- didn't. Then we need to do a second pass that forces everything + -- -- to keep showing their fallbacks. + + -- -- We might be suspended if something in this render pass suspended, or + -- -- something in the previous committed pass suspended. Otherwise, + -- -- there's no chance so we can skip the expensive call to + -- -- findFirstSuspended. + -- local cannotBeSuspended = + -- renderHasNotSuspendedYet() and + -- (current == nil or (current.flags & DidCapture) == NoFlags) + -- if !cannotBeSuspended) + -- local row = workInProgress.child + -- while (row ~= nil) + -- local suspended = findFirstSuspended(row) + -- if suspended ~= nil) + -- didSuspendAlready = true + -- workInProgress.flags |= DidCapture + -- cutOffTailIfNeeded(renderState, false) + + -- -- If this is a newly suspended tree, it might not get committed as + -- -- part of the second pass. In that case nothing will subscribe to + -- -- its thennables. Instead, we'll transfer its thennables to the + -- -- SuspenseList so that it can retry if they resolve. + -- -- There might be multiple of these in the list but since we're + -- -- going to wait for all of them anyway, it doesn't really matter + -- -- which ones gets to ping. In theory we could get clever and keep + -- -- track of how many dependencies remain but it gets tricky because + -- -- in the meantime, we can add/remove/change items and dependencies. + -- -- We might bail out of the loop before finding any but that + -- -- doesn't matter since that means that the other boundaries that + -- -- we did find already has their listeners attached. + -- local newThennables = suspended.updateQueue + -- if newThennables ~= nil) + -- workInProgress.updateQueue = newThennables + -- workInProgress.flags |= Update + -- end + + -- -- Rerender the whole list, but this time, we'll force fallbacks + -- -- to stay in place. + -- -- Reset the child fibers to their original state. + -- workInProgress.subtreeFlags = NoFlags + -- resetChildFibers(workInProgress, renderLanes) + + -- -- Set up the Suspense Context to force suspense and immediately + -- -- rerender the children. + -- pushSuspenseContext( + -- workInProgress, + -- setShallowSuspenseContext( + -- suspenseStackCursor.current, + -- ForceSuspenseFallback, + -- ), + -- ) + -- -- Don't bubble properties in this case. + -- return workInProgress.child + -- end + -- row = row.sibling + -- end + -- end + + -- if renderState.tail ~= nil and now() > getRenderTargetTime()) + -- -- We have already passed our CPU deadline but we still have rows + -- -- left in the tail. We'll just give up further attempts to render + -- -- the main content and only render fallbacks. + -- workInProgress.flags |= DidCapture + -- didSuspendAlready = true + + -- cutOffTailIfNeeded(renderState, false) + + -- -- Since nothing actually suspended, there will nothing to ping this + -- -- to get it started back up to attempt the next item. While in terms + -- -- of priority this work has the same priority as this current render, + -- -- it's not part of the same transition once the transition has + -- -- committed. If it's sync, we still want to yield so that it can be + -- -- painted. Conceptually, this is really the same as pinging. + -- -- We can use any RetryLane even if it's the one currently rendering + -- -- since we're leaving it behind on this node. + -- workInProgress.lanes = SomeRetryLane + -- if enableSchedulerTracing) + -- markSpawnedWork(SomeRetryLane) + -- end + -- end + -- else + -- cutOffTailIfNeeded(renderState, false) + -- end + -- -- Next we're going to render the tail. + -- else + -- -- Append the rendered row to the child list. + -- if !didSuspendAlready) + -- local suspended = findFirstSuspended(renderedTail) + -- if suspended ~= nil) + -- workInProgress.flags |= DidCapture + -- didSuspendAlready = true + + -- -- Ensure we transfer the update queue to the parent so that it doesn't + -- -- get lost if this row ends up dropped during a second pass. + -- local newThennables = suspended.updateQueue + -- if newThennables ~= nil) + -- workInProgress.updateQueue = newThennables + -- workInProgress.flags |= Update + -- end + + -- cutOffTailIfNeeded(renderState, true) + -- -- This might have been modified. + -- if + -- renderState.tail == nil and + -- renderState.tailMode == 'hidden' and + -- !renderedTail.alternate and + -- !getIsHydrating() -- We don't cut it if we're hydrating. + -- ) + -- -- We're done. + -- bubbleProperties(workInProgress) + -- return nil + -- end + -- } else if + -- -- The time it took to render last row is greater than the remaining + -- -- time we have to render. So rendering one more row would likely + -- -- exceed it. + -- now() * 2 - renderState.renderingStartTime > + -- getRenderTargetTime() and + -- renderLanes ~= OffscreenLane + -- ) + -- -- We have now passed our CPU deadline and we'll just give up further + -- -- attempts to render the main content and only render fallbacks. + -- -- The assumption is that this is usually faster. + -- workInProgress.flags |= DidCapture + -- didSuspendAlready = true + + -- cutOffTailIfNeeded(renderState, false) + + -- -- Since nothing actually suspended, there will nothing to ping this + -- -- to get it started back up to attempt the next item. If we can show + -- -- them, then they really have the same priority as this render. + -- -- So we'll pick it back up the very next render pass once we've had + -- -- an opportunity to yield for paint. + -- workInProgress.lanes = SomeRetryLane + -- if enableSchedulerTracing) + -- markSpawnedWork(SomeRetryLane) + -- end + -- end + -- end + -- if renderState.isBackwards) + -- -- The effect list of the backwards tail will have been added + -- -- to the end. This breaks the guarantee that life-cycles fire in + -- -- sibling order but that isn't a strong guarantee promised by React. + -- -- Especially since these might also just pop in during future commits. + -- -- Append to the beginning of the list. + -- renderedTail.sibling = workInProgress.child + -- workInProgress.child = renderedTail + -- else + -- local previousSibling = renderState.last + -- if previousSibling ~= nil) + -- previousSibling.sibling = renderedTail + -- else + -- workInProgress.child = renderedTail + -- end + -- renderState.last = renderedTail + -- end + -- end + + -- if renderState.tail ~= nil) + -- -- We still have tail rows to render. + -- -- Pop a row. + -- local next = renderState.tail + -- renderState.rendering = next + -- renderState.tail = next.sibling + -- renderState.renderingStartTime = now() + -- next.sibling = nil + + -- -- Restore the context. + -- -- TODO: We can probably just avoid popping it instead and only + -- -- setting it the first time we go from not suspended to suspended. + -- local suspenseContext = suspenseStackCursor.current + -- if didSuspendAlready) + -- suspenseContext = setShallowSuspenseContext( + -- suspenseContext, + -- ForceSuspenseFallback, + -- ) + -- else + -- suspenseContext = setDefaultShallowSuspenseContext(suspenseContext) + -- end + -- pushSuspenseContext(workInProgress, suspenseContext) + -- -- Do a pass over the next row. + -- -- Don't bubble properties in this case. + -- return next + -- end + -- bubbleProperties(workInProgress) + -- return nil + -- end + elseif workInProgress.tag == FundamentalComponent then + unimplemented("FundamentalComponent") + -- if enableFundamentalAPI) + -- local fundamentalImpl = workInProgress.type.impl + -- local fundamentalInstance: ReactFundamentalComponentInstance< + -- any, + -- any, + -- > | nil = workInProgress.stateNode + + -- if fundamentalInstance == nil) + -- local getInitialState = fundamentalImpl.getInitialState + -- local fundamentalState + -- if getInitialState ~= undefined) + -- fundamentalState = getInitialState(newProps) + -- end + -- fundamentalInstance = workInProgress.stateNode = createFundamentalStateInstance( + -- workInProgress, + -- newProps, + -- fundamentalImpl, + -- fundamentalState or {}, + -- ) + -- local instance = ((getFundamentalComponentInstance( + -- fundamentalInstance, + -- ): any): Instance) + -- fundamentalInstance.instance = instance + -- if fundamentalImpl.reconcileChildren == false) + -- bubbleProperties(workInProgress) + -- return nil + -- end + -- appendAllChildren(instance, workInProgress, false, false) + -- mountFundamentalComponent(fundamentalInstance) + -- else + -- -- We fire update in commit phase + -- local prevProps = fundamentalInstance.props + -- fundamentalInstance.prevProps = prevProps + -- fundamentalInstance.props = newProps + -- fundamentalInstance.currentFiber = workInProgress + -- if supportsPersistence) + -- local instance = cloneFundamentalInstance(fundamentalInstance) + -- fundamentalInstance.instance = instance + -- appendAllChildren(instance, workInProgress, false, false) + -- end + -- local shouldUpdate = shouldUpdateFundamentalComponent( + -- fundamentalInstance, + -- ) + -- if shouldUpdate) + -- markUpdate(workInProgress) + -- end + -- end + -- bubbleProperties(workInProgress) + -- return nil + -- end + elseif workInProgress.tag == ScopeComponent then + unimplemented("ScopeComponent") + -- if enableScopeAPI) + -- if current == nil) + -- local scopeInstance: ReactScopeInstance = createScopeInstance() + -- workInProgress.stateNode = scopeInstance + -- prepareScopeUpdate(scopeInstance, workInProgress) + -- if workInProgress.ref ~= nil) + -- markRef(workInProgress) + -- markUpdate(workInProgress) + -- end + -- else + -- if workInProgress.ref ~= nil) + -- markUpdate(workInProgress) + -- end + -- if current.ref ~= workInProgress.ref) + -- markRef(workInProgress) + -- end + -- end + -- bubbleProperties(workInProgress) + -- return nil + -- end + elseif workInProgress.tag == Block then + unimplemented("Block") + -- if enableBlocksAPI) + -- bubbleProperties(workInProgress) + -- return nil + -- end + elseif + workInProgress.tag == OffscreenComponent + or workInProgress.tag == LegacyHiddenComponent + then + popRenderLanes(workInProgress) + local nextState: OffscreenState | nil = workInProgress.memoizedState + local nextIsHidden = nextState ~= nil + + if current ~= nil then + local prevState: OffscreenState | nil = current.memoizedState + + local prevIsHidden = prevState ~= nil + if + prevIsHidden ~= nextIsHidden + and newProps.mode ~= "unstable-defer-without-hiding" + then + workInProgress.flags = bit32.bor(workInProgress.flags, Update) + end + end + + -- Don't bubble properties for hidden children. + if + not nextIsHidden + or includesSomeLane( + ReactFiberWorkLoop.subtreeRenderLanes, + OffscreenLane :: Lane + ) + or bit32.band(workInProgress.mode, ConcurrentMode) == NoMode + then + bubbleProperties(workInProgress) + end + + return nil + end + invariant( + false, + "Unknown unit of work tag (%s). This error is likely caused by a bug in " + .. "React. Please file an issue.", + tostring(workInProgress.tag) + ) + return nil end return { - completeWork = completeWork, + completeWork = completeWork, } diff --git a/modules/react-reconciler/src/ReactFiberComponentStack.lua b/modules/react-reconciler/src/ReactFiberComponentStack.lua index a2cc7c40..3ac4386e 100644 --- a/modules/react-reconciler/src/ReactFiberComponentStack.lua +++ b/modules/react-reconciler/src/ReactFiberComponentStack.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/72d00ab623502983ebd7ac0756cf2787df109811/packages/react-reconciler/src/ReactFiberComponentStack.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/72d00ab623502983ebd7ac0756cf2787df109811/packages/react-reconciler/src/ReactFiberComponentStack.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,6 +10,10 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +type Function = (...any) -> ...any +type Error = LuauPolyfill.Error local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber @@ -33,8 +38,8 @@ local describeClassComponentFrame = ReactComponentStackFrame.describeClassCompon local function describeFiber(fiber: Fiber): string -- deviation: untangling some nested ternaries to use more idiomatic if/else - local owner: (() -> ())? = nil - if _G.__DEV__ then + local owner: nil | Function = nil + if ReactGlobals.__DEV__ then -- FIXME (roblox): Luau's limited narrowing necessitates this local local debugOwner = fiber._debugOwner if debugOwner then @@ -42,7 +47,7 @@ local function describeFiber(fiber: Fiber): string end end local source = nil - if _G.__DEV__ then + if ReactGlobals.__DEV__ then source = fiber._debugSource end if fiber.tag == HostComponent then @@ -69,21 +74,32 @@ local function describeFiber(fiber: Fiber): string end return { - getStackByFiberInDevAndProd = function(workInProgress: Fiber): string - local ok, result = pcall(function() + getStackByFiberInDevAndProd = function(workInProgress: Fiber?): string + local ok: boolean, result: Error | string = pcall(function() local info = "" local node = workInProgress repeat - info ..= describeFiber(node) - node = node.return_ + info ..= describeFiber(node :: Fiber) + node = (node :: Fiber).return_ until node == nil return info end) + if not ok then - -- FIXME: result.stack is probably not present here with the current - -- shape of our `Error` object - return "\nError generating stack: " .. result.message .. "\n" .. result.stack + local message = "\nError generating stack: " + if + typeof(result) == "table" + and (result :: Error).message + and (result :: Error).stack + then + return message + .. (result :: Error).message + .. "\n" + .. tostring((result :: Error).stack) + end + return message .. tostring(result) end - return result + + return result :: string end, } diff --git a/modules/react-reconciler/src/ReactFiberContext.new.lua b/modules/react-reconciler/src/ReactFiberContext.new.lua index e69c4fa2..209c0b2b 100644 --- a/modules/react-reconciler/src/ReactFiberContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberContext.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberContext.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/a724a3b578dce77d427bef313102a4d0e978d9b4/packages/react-reconciler/src/ReactFiberContext.new.js +--!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,29 +8,32 @@ * * @flow ]] ---!nolint LocalShadowPedantic local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Object = LuauPolyfill.Object +local Error = LuauPolyfill.Error -local Cryo = require(Packages.Cryo) +local __DEV__ = ReactGlobals.__DEV__ :: boolean +local __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ = + ReactGlobals.__DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ :: boolean -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber local ReactFiberStack = require(script.Parent["ReactFiberStack.new"]) -type StackCursor = ReactFiberStack.StackCursor; +type StackCursor = ReactFiberStack.StackCursor local isFiberMounted = require(script.Parent.ReactFiberTreeReflection).isFiberMounted -local disableLegacyContext = require(Packages.Shared).ReactFeatureFlags.disableLegacyContext +local disableLegacyContext = + require(Packages.Shared).ReactFeatureFlags.disableLegacyContext local ReactWorkTags = require(script.Parent.ReactWorkTags) local ClassComponent = ReactWorkTags.ClassComponent local HostRoot = ReactWorkTags.HostRoot local getComponentName = require(Packages.Shared).getComponentName -local invariant = require(Packages.Shared).invariant local checkPropTypes = require(Packages.Shared).checkPropTypes local createCursor = ReactFiberStack.createCursor @@ -38,22 +42,20 @@ local pop = ReactFiberStack.pop local warnedAboutMissingGetChildContext -if _G.__DEV__ then +if __DEV__ then warnedAboutMissingGetChildContext = {} end local emptyContextObject = {} -if _G.__DEV__ then +if __DEV__ then Object.freeze(emptyContextObject) end -- deviation: Common types -type Object = { [any]: any }; +type Object = { [any]: any } -- A cursor to the current merged context object on the stack. -local contextStackCursor: StackCursor = createCursor( - emptyContextObject -) +local contextStackCursor: StackCursor = createCursor(emptyContextObject) -- A cursor to a boolean indicating whether the context has changed. local didPerformWorkStackCursor: StackCursor = createCursor(false) -- Keep track of the previous context object that was on the stack. @@ -70,82 +72,83 @@ local function getUnmaskedContext( Component: any, didPushOwnContextIfProvider: boolean ): Object - if disableLegacyContext then - return emptyContextObject - else - if didPushOwnContextIfProvider and isContextProvider(Component) then - -- If the fiber is a context provider itself, when we read its context - -- we may have already pushed its own child context on the stack. A context - -- provider should not "see" its own child context. Therefore we read the - -- previous (parent) context instead for a context provider. - return previousContext - end - return contextStackCursor.current + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return emptyContextObject + -- else + if didPushOwnContextIfProvider and isContextProvider(Component) then + -- If the fiber is a context provider itself, when we read its context + -- we may have already pushed its own child context on the stack. A context + -- provider should not "see" its own child context. Therefore we read the + -- previous (parent) context instead for a context provider. + return previousContext end + return contextStackCursor.current + -- end end local function cacheContext( workInProgress: Fiber, unmaskedContext: Object, maskedContext: Object -) - if disableLegacyContext then - return - else - local instance = workInProgress.stateNode - instance.__reactInternalMemoizedUnmaskedChildContext = unmaskedContext - instance.__reactInternalMemoizedMaskedChildContext = maskedContext - end +): () + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return + -- else + local instance = workInProgress.stateNode + instance.__reactInternalMemoizedUnmaskedChildContext = unmaskedContext + instance.__reactInternalMemoizedMaskedChildContext = maskedContext + -- end end -local function getMaskedContext( - workInProgress: Fiber, - unmaskedContext: Object -): Object - if disableLegacyContext then - return emptyContextObject - else - local type = workInProgress.type - -- deviation: For function components, we can't support `contextTypes`; - -- instead, just return unmaskedContext - if typeof(type) == "function" then - return unmaskedContext - end - - local contextTypes = type.contextTypes - if not contextTypes then - return emptyContextObject - end +local function getMaskedContext(workInProgress: Fiber, unmaskedContext: Object): Object + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return emptyContextObject + -- else + local type_ = workInProgress.type + -- deviation: For function components, we can't support `contextTypes`; + -- instead, just return unmaskedContext + if type(type_) == "function" then + return unmaskedContext + end - -- Avoid recreating masked context unless unmasked context has changed. - -- Failing to do this will result in unnecessary calls to componentWillReceiveProps. - -- This may trigger infinite loops if componentWillReceiveProps calls setState. - local instance = workInProgress.stateNode - if - instance and - instance.__reactInternalMemoizedUnmaskedChildContext == unmaskedContext - then - return instance.__reactInternalMemoizedMaskedChildContext - end + local contextTypes = type_.contextTypes + if not contextTypes then + return emptyContextObject + end - local context = {} - for key, _ in pairs(contextTypes) do - context[key] = unmaskedContext[key] - end + -- Avoid recreating masked context unless unmasked context has changed. + -- Failing to do this will result in unnecessary calls to componentWillReceiveProps. + -- This may trigger infinite loops if componentWillReceiveProps calls setState. + local instance = workInProgress.stateNode + if + instance + and instance.__reactInternalMemoizedUnmaskedChildContext == unmaskedContext + then + return instance.__reactInternalMemoizedMaskedChildContext + end - if _G.__DEV__ then - local name = getComponentName(type) or "Unknown" - checkPropTypes(contextTypes, context, "context", name) - end + local context = {} + for key, _ in contextTypes do + context[key] = unmaskedContext[key] + end - -- Cache unmasked context so we can avoid recreating masked context unless necessary. - -- Context is created before the class component is instantiated so check for instance. - if instance then - cacheContext(workInProgress, unmaskedContext, context) - end + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + local name = getComponentName(type_) or "Unknown" + -- ROBLOX deviation: nil as second argument for validateProps compatibility + checkPropTypes(contextTypes, nil, context, "context", name) + end - return context + -- Cache unmasked context so we can avoid recreating masked context unless necessary. + -- Context is created before the class component is instantiated so check for instance. + if instance then + cacheContext(workInProgress, unmaskedContext, context) end + + return context + -- end end local function hasContextChanged(): boolean @@ -157,205 +160,227 @@ local function hasContextChanged(): boolean end -- deviation: `type: Function` - lock down component type def -isContextProvider = function(type): boolean - if disableLegacyContext then +function isContextProvider(type_): boolean + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return false + -- else + -- ROBLOX deviation: context types only valid for class components + -- ROBLOX performance: type is already guarded as being a ClassComponent and/or typeof == 'table' in all call sites + if type(type_) == "function" then return false - else - -- deviation: context types only valid for class components - if typeof(type) == "function" then - return false - end - local childContextTypes = type.childContextTypes - return childContextTypes ~= nil end + local childContextTypes = type_.childContextTypes + return childContextTypes ~= nil + -- end end -local function popContext(fiber: Fiber) - if disableLegacyContext then - return - else - pop(didPerformWorkStackCursor, fiber) - pop(contextStackCursor, fiber) - end +local function popContext(fiber: Fiber): () + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return + -- else + pop(didPerformWorkStackCursor, fiber) + pop(contextStackCursor, fiber) + -- end end -local function popTopLevelContextObject(fiber: Fiber) - if disableLegacyContext then - return - else - pop(didPerformWorkStackCursor, fiber) - pop(contextStackCursor, fiber) - end +local function popTopLevelContextObject(fiber: Fiber): () + -- ROBLOX performance: skip always-false compare in hot path + -- if disableLegacyContext then + -- return + -- else + pop(didPerformWorkStackCursor, fiber) + pop(contextStackCursor, fiber) + -- end end local function pushTopLevelContextObject( fiber: Fiber, context: Object, didChange: boolean -) - if disableLegacyContext then - return - else - invariant( - contextStackCursor.current == emptyContextObject, - "Unexpected context found on stack. " .. - "This error is likely caused by a bug in React. Please file an issue." +): () + -- ROBLOX performance: disable always-false cmp in hot path + -- if disableLegacyContext then + -- return + -- else + if contextStackCursor.current ~= emptyContextObject then + error( + Error.new( + "Unexpected context found on stack. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) ) - - push(contextStackCursor, context, fiber) - push(didPerformWorkStackCursor, didChange, fiber) end + + push(contextStackCursor, context, fiber) + push(didPerformWorkStackCursor, didChange, fiber) + -- end end local function processChildContext( fiber: Fiber, - type: any, + type_: any, parentContext: Object ): Object - if disableLegacyContext then - return parentContext - else - local instance = fiber.stateNode - local childContextTypes = type.childContextTypes - - -- TODO (bvaughn) Replace this behavior with an invariant() in the future. - -- It has only been added in Fiber to match the (unintentional) behavior in Stack. - if typeof(instance.getChildContext) ~= "function" then - if _G.__DEV__ then - local componentName = getComponentName(type) or "Unknown" - - if not warnedAboutMissingGetChildContext[componentName] then - warnedAboutMissingGetChildContext[componentName] = true - console.error( - "%s.childContextTypes is specified but there is no getChildContext() method " .. - "on the instance. You can either define getChildContext() on %s or remove " .. - "childContextTypes from it.", - componentName, - componentName - ) - end + -- ROBLOX performance: eliminate always-false compare in hot path + -- if disableLegacyContext then + -- return parentContext + -- else + local instance = fiber.stateNode + local childContextTypes = type_.childContextTypes + + -- TODO (bvaughn) Replace this behavior with an invariant() in the future. + -- It has only been added in Fiber to match the (unintentional) behavior in Stack. + if + instance.getChildContext == nil + or type(instance.getChildContext) ~= "function" + then + if __DEV__ then + local componentName = getComponentName(type_) or "Unknown" + + if not warnedAboutMissingGetChildContext[componentName] then + warnedAboutMissingGetChildContext[componentName] = true + console.error( + "%s.childContextTypes is specified but there is no getChildContext() method " + .. "on the instance. You can either define getChildContext() on %s or remove " + .. "childContextTypes from it.", + componentName, + componentName + ) end - return parentContext end + return parentContext + end - local childContext = instance:getChildContext() - for contextKey, _ in pairs(childContext) do - invariant( - childContextTypes[contextKey] ~= nil, - "%s.getChildContext(): key \"%s\" is not defined in childContextTypes.", - getComponentName(type) or "Unknown", - contextKey + local childContext = instance:getChildContext() + for contextKey, _ in childContext do + if childContextTypes[contextKey] == nil then + local name = getComponentName(type_) or "Unknown" + error( + Error.new( + string.format( + '%s.getChildContext(): key "%s" is not defined in childContextTypes.', + name, + contextKey + ) + ) ) end - if _G.__DEV__ then - local name = getComponentName(type) or "Unknown" - checkPropTypes(childContextTypes, childContext, "child context", name) - end - - return Cryo.Dictionary.join(parentContext, childContext) end + if __DEV__ or __DISABLE_ALL_WARNINGS_EXCEPT_PROP_VALIDATION__ then + local name = getComponentName(type_) or "Unknown" + -- ROBLOX deviation: nil as second argument for validateProps compatibility + checkPropTypes(childContextTypes, nil, childContext, "child context", name) + end + + return Object.assign({}, parentContext, childContext) + -- end end local function pushContextProvider(workInProgress: Fiber): boolean - if disableLegacyContext then - return false - else - local instance = workInProgress.stateNode - -- We push the context as early as possible to ensure stack integrity. - -- If the instance does not exist yet, we will push nil at first, - -- and replace it on the stack later when invalidating the context. - local memoizedMergedChildContext = - (instance and instance.__reactInternalMemoizedMergedChildContext) or - emptyContextObject - - -- Remember the parent context so we can merge with it later. - -- Inherit the parent's did-perform-work value to avoid inadvertently blocking updates. - previousContext = contextStackCursor.current - push(contextStackCursor, memoizedMergedChildContext, workInProgress) - push( - didPerformWorkStackCursor, - didPerformWorkStackCursor.current, - workInProgress - ) - - return true - end + -- ROBLOX performance: eliminate always-false compare in hot path + -- if disableLegacyContext then + -- return false + -- else + local instance = workInProgress.stateNode + -- We push the context as early as possible to ensure stack integrity. + -- If the instance does not exist yet, we will push nil at first, + -- and replace it on the stack later when invalidating the context. + local memoizedMergedChildContext = ( + instance and instance.__reactInternalMemoizedMergedChildContext + ) or emptyContextObject + + -- Remember the parent context so we can merge with it later. + -- Inherit the parent's did-perform-work value to avoid inadvertently blocking updates. + previousContext = contextStackCursor.current + push(contextStackCursor, memoizedMergedChildContext, workInProgress) + push(didPerformWorkStackCursor, didPerformWorkStackCursor.current, workInProgress) + + return true + -- end end local function invalidateContextProvider( workInProgress: Fiber, - type: any, + type_: any, didChange: boolean -) - if disableLegacyContext then - return - else - local instance = workInProgress.stateNode - invariant( - instance, - "Expected to have an instance by this point. " .. - "This error is likely caused by a bug in React. Please file an issue." +): () + -- ROBLOX performance: eliminate always-false compare in hot path + -- if disableLegacyContext then + -- return + -- else + local instance = workInProgress.stateNode + + if not instance then + error( + Error.new( + "Expected to have an instance by this point. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) ) + end - if didChange then - -- Merge parent and own context. - -- Skip this if we're not updating due to sCU. - -- This avoids unnecessarily recomputing memoized values. - local mergedContext = processChildContext( - workInProgress, - type, - previousContext - ) - instance.__reactInternalMemoizedMergedChildContext = mergedContext - - -- Replace the old (or empty) context with the new one. - -- It is important to unwind the context in the reverse order. - pop(didPerformWorkStackCursor, workInProgress) - pop(contextStackCursor, workInProgress) - -- Now push the new context and mark that it has changed. - push(contextStackCursor, mergedContext, workInProgress) - push(didPerformWorkStackCursor, didChange, workInProgress) - else - pop(didPerformWorkStackCursor, workInProgress) - push(didPerformWorkStackCursor, didChange, workInProgress) - end + if didChange then + -- Merge parent and own context. + -- Skip this if we're not updating due to sCU. + -- This avoids unnecessarily recomputing memoized values. + local mergedContext = processChildContext(workInProgress, type_, previousContext) + instance.__reactInternalMemoizedMergedChildContext = mergedContext + + -- Replace the old (or empty) context with the new one. + -- It is important to unwind the context in the reverse order. + pop(didPerformWorkStackCursor, workInProgress) + pop(contextStackCursor, workInProgress) + -- Now push the new context and mark that it has changed. + push(contextStackCursor, mergedContext, workInProgress) + push(didPerformWorkStackCursor, didChange, workInProgress) + else + pop(didPerformWorkStackCursor, workInProgress) + push(didPerformWorkStackCursor, didChange, workInProgress) end + -- end end local function findCurrentUnmaskedContext(fiber: Fiber): Object - if disableLegacyContext then - return emptyContextObject - else - -- Currently this is only used with renderSubtreeIntoContainer; not sure if it - -- makes sense elsewhere - invariant( - isFiberMounted(fiber) and fiber.tag == ClassComponent, - "Expected subtree parent to be a mounted class component. " .. - "This error is likely caused by a bug in React. Please file an issue." + -- ROBLOX performance: eliminate always-false in hot path + -- if disableLegacyContext then + -- return emptyContextObject + -- else + -- Currently this is only used with renderSubtreeIntoContainer; not sure if it + -- makes sense elsewhere + if fiber.tag ~= ClassComponent or not isFiberMounted(fiber) then + error( + Error.new( + "Expected subtree parent to be a mounted class component. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) ) + end - local node = fiber - repeat - if node.tag == HostRoot then - return node.stateNode.context - elseif node.tag == ClassComponent then - local Component = node.type - if isContextProvider(Component) then - return node.stateNode.__reactInternalMemoizedMergedChildContext - end + -- ROBLOX TODO: Luau analyze can't accommodate the 'repeat until (not) nil' pattern + local node: any = fiber + repeat + if node.tag == HostRoot then + return node.stateNode.context + elseif node.tag == ClassComponent then + local Component = node.type + -- ROBLOX deviation: inline specialized check for isContextProvider since we know it's a class + if Component.childContextTypes ~= nil then + return node.stateNode.__reactInternalMemoizedMergedChildContext end + end - node = node.return_ - until node == nil - invariant( - false, - "Found unexpected detached subtree parent. " .. - "This error is likely caused by a bug in React. Please file an issue." - ) - end + node = node.return_ + until node == nil - -- deviation: invariant not recognized as error, so we return something here - return {} + error( + Error.new( + "Found unexpected detached subtree parent. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + ) + -- end end return { diff --git a/modules/react-reconciler/src/ReactFiberDevToolsHook.new.lua b/modules/react-reconciler/src/ReactFiberDevToolsHook.new.lua index 98a39776..21a7cabb 100644 --- a/modules/react-reconciler/src/ReactFiberDevToolsHook.new.lua +++ b/modules/react-reconciler/src/ReactFiberDevToolsHook.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberDevToolsHook.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberDevToolsHook.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,27 +9,39 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +local LuauPolyfill = require(Packages.LuauPolyfill) +type Object = LuauPolyfill.Object local exports = {} +-- ROBLOX deviation: subset copied here from devtools-shared/backend/types, to have stronger enforcement than 'Object' without circular dep +type DevToolsHook = { + -- ROBLOX TODO: ideally, ReactRenderer type would be importable from this file so we could use it here + inject: (Object) -> number | nil, + supportsFiber: boolean, + isDisabled: boolean, + -- ... +} + +-- ROBLOX deviation: we use callable tables instead of functions sometimes, so typeof() == "function" isn't enough local function isCallable(value) - if typeof(value) == "function" then - return true - end - if typeof(value) == "table" then - local mt = getmetatable(value) - if mt and rawget(mt, "__call") then - return true - end - if value._isMockFunction then - return true - end - end - return false + if typeof(value) == "function" then + return true + end + if typeof(value) == "table" then + local mt = getmetatable(value) + if mt and rawget(mt, "__call") then + return true + end + if value._isMockFunction then + return true + end + end + return false end - local enableProfilerTimer = require(Packages.Shared).ReactFeatureFlags.enableProfilerTimer local ReactInternalTypes = require(script.Parent.ReactInternalTypes) @@ -48,119 +60,111 @@ local rendererID = nil local injectedHook = nil local hasLoggedError = false +-- ROBLOX deviation: We use a function to handle the hook being changed at runtime +exports.isDevToolsPresent = function() + return ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ ~= nil +end -exports.isDevToolsPresent = - typeof(_G.__REACT_DEVTOOLS_GLOBAL_HOOK__) ~= 'nil' - -exports.injectInternals = function(internals): boolean - if typeof(_G.__REACT_DEVTOOLS_GLOBAL_HOOK__) == 'nil' then - -- No DevTools - return false - end - local hook = _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ - if hook.isDisabled then - -- This isn't a real property on the hook, but it can be set to opt out - -- of DevTools integration and associated warnings and logs. - -- http://github.com/facebook/react/issues/3877 - return true - end - if not hook.supportsFiber then - if _G.__DEV__ then - console.error( - 'The installed version of React DevTools is too old and will not work ' .. - 'with the current version of React. Please update React DevTools. ' .. - 'https://reactjs.org/link/react-devtools' - ) - end - -- DevTools exists, even though it doesn't support Fiber. - return true - end - local ok, err = pcall(function() - rendererID = hook.inject(internals) - -- We have successfully injected, so now it is safe to set up hooks. - injectedHook = hook - end) - - if not ok then - -- Catch all errors because it is unsafe to throw during initialization. - if _G.__DEV__ then - console.error('React instrumentation encountered an error: %s.', err) - end - end - -- DevTools exists - return true +exports.injectInternals = function(internals: Object): boolean + if ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ == nil then + -- No DevTools + return false + end + local hook: DevToolsHook = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if hook.isDisabled then + -- This isn't a real property on the hook, but it can be set to opt out + -- of DevTools integration and associated warnings and logs. + -- http://github.com/facebook/react/issues/3877 + return true + end + if not hook.supportsFiber then + if ReactGlobals.__DEV__ then + console.error( + "The installed version of React DevTools is too old and will not work " + .. "with the current version of React. Please update React DevTools. " + .. "https://reactjs.org/link/react-devtools" + ) + end + -- DevTools exists, even though it doesn't support Fiber. + return true + end + local ok, err = pcall(function() + rendererID = hook.inject(internals) + -- We have successfully injected, so now it is safe to set up hooks. + injectedHook = hook + end) + + if not ok then + -- Catch all errors because it is unsafe to throw during initialization. + if ReactGlobals.__DEV__ then + console.error("React instrumentation encountered an error: %s.", err) + end + end + -- DevTools exists + return true end exports.onScheduleRoot = function(root: FiberRoot, children: ReactNodeList) - if _G.__DEV__ then - if - injectedHook - -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields - and isCallable(injectedHook.onScheduleFiberRoot) - then - local ok, err = pcall(function() - injectedHook.onScheduleFiberRoot(rendererID, root, children) - end) - - if not ok then - if _G.__DEV__ and not hasLoggedError then - hasLoggedError = true - console.error('React instrumentation encountered an error: %s', err) - end - end - end - end + if ReactGlobals.__DEV__ then + if + injectedHook + -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields + and isCallable(injectedHook.onScheduleFiberRoot) + then + local ok, err = + pcall(injectedHook.onScheduleFiberRoot, rendererID, root, children) + + if not ok then + if ReactGlobals.__DEV__ and not hasLoggedError then + hasLoggedError = true + console.error("React instrumentation encountered an error: %s", err) + end + end + end + end end -exports.onCommitRoot = function( - root: FiberRoot, - priorityLevel: ReactPriorityLevel -) - if injectedHook - -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields - and isCallable(injectedHook.onCommitFiberRoot) - then - local ok, err = pcall(function() - local didError = bit32.band(root.current.flags, DidCapture) == DidCapture - if enableProfilerTimer then - injectedHook.onCommitFiberRoot( - rendererID, - root, - priorityLevel, - didError - ) - else - injectedHook.onCommitFiberRoot(rendererID, root, nil, didError) - end - end) - if not ok then - if _G.__DEV__ then - if not hasLoggedError then - hasLoggedError = true - console.error('React instrumentation encountered an error: %s', err) - end - end - end - end +exports.onCommitRoot = function(root: FiberRoot, priorityLevel: ReactPriorityLevel) + if + injectedHook + -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields + and isCallable(injectedHook.onCommitFiberRoot) + then + local ok, err = pcall(function() + local didError = bit32.band(root.current.flags, DidCapture) == DidCapture + if enableProfilerTimer then + injectedHook.onCommitFiberRoot(rendererID, root, priorityLevel, didError) + else + injectedHook.onCommitFiberRoot(rendererID, root, nil, didError) + end + end) + if not ok then + if ReactGlobals.__DEV__ then + if not hasLoggedError then + hasLoggedError = true + console.error("React instrumentation encountered an error: %s", err) + end + end + end + end end exports.onCommitUnmount = function(fiber: Fiber) - if injectedHook - -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields - and isCallable(injectedHook.onCommitFiberUnmount) - then - local ok, err = pcall(function() - injectedHook.onCommitFiberUnmount(rendererID, fiber) - end) - if not ok then - if _G.__DEV__ then - if not hasLoggedError then - hasLoggedError = true - console.error('React instrumentation encountered an error: %s', err) - end - end - end - end + if + injectedHook + -- ROBLOX deviation: our mocked functions are tables with __call, since they have fields + and isCallable(injectedHook.onCommitFiberUnmount) + then + local ok, err = pcall(injectedHook.onCommitFiberUnmount, rendererID, fiber) + if not ok then + if ReactGlobals.__DEV__ then + if not hasLoggedError then + hasLoggedError = true + console.error("React instrumentation encountered an error: %s", err) + end + end + end + end end return exports diff --git a/modules/react-reconciler/src/ReactFiberErrorDialog.lua b/modules/react-reconciler/src/ReactFiberErrorDialog.lua index fe81c4be..5636e466 100644 --- a/modules/react-reconciler/src/ReactFiberErrorDialog.lua +++ b/modules/react-reconciler/src/ReactFiberErrorDialog.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiberErrorDialog.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiberErrorDialog.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -13,12 +14,9 @@ -- Forks can return `false` if this isn't desirable. local exports = {} -exports.showErrorDialog = function( - boundary, - errorInfo -): boolean - -- ROBLOX TODO: we may replace this with something that sends telemetry LUAFDN-222 - return true +exports.showErrorDialog = function(boundary, errorInfo): boolean + -- ROBLOX TODO: we may replace this with something that sends telemetry LUAFDN-222 + return true end return exports diff --git a/modules/react-reconciler/src/ReactFiberErrorLogger.lua b/modules/react-reconciler/src/ReactFiberErrorLogger.lua index 9418faab..773d1b6f 100644 --- a/modules/react-reconciler/src/ReactFiberErrorLogger.lua +++ b/modules/react-reconciler/src/ReactFiberErrorLogger.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/702fad4b1b48ac8f626ed3f35e8f86f5ea728084/packages/react-reconciler/src/ReactFiberErrorLogger.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/702fad4b1b48ac8f626ed3f35e8f86f5ea728084/packages/react-reconciler/src/ReactFiberErrorLogger.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,11 +10,15 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) +type Error = LuauPolyfill.Error +local inspect = LuauPolyfill.util.inspect local setTimeout = LuauPolyfill.setTimeout --- ROBLOX: use patched console from shared -local console = require(Packages.Shared).console +local Shared = require(Packages.Shared) +local console = Shared.console +local errorToString = Shared.errorToString local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber @@ -26,96 +31,103 @@ local getComponentName = require(Packages.Shared).getComponentName local exports = {} -exports.logCapturedError = function( - boundary: Fiber, - errorInfo: CapturedValue -) - local ok, e = pcall(function() - local logError = showErrorDialog(boundary, errorInfo) +exports.logCapturedError = function(boundary: Fiber, errorInfo: CapturedValue) + local ok, e = pcall(function() + local logError = showErrorDialog(boundary, errorInfo) - -- Allow injected showErrorDialog() to prevent default console.error logging. - -- This enables renderers like ReactNative to better manage redbox behavior. - if logError == false then - return - end + -- Allow injected showErrorDialog() to prevent default console.error logging. + -- This enables renderers like ReactNative to better manage redbox behavior. + if logError == false then + -- ROBLOX Luau FIXME: needs void return Luau bugfix + return nil + end - local error_ = errorInfo.value - if _G.__DEV__ then - local source = errorInfo.source - local stack = errorInfo.stack - local componentStack = stack or '' - -- Browsers support silencing uncaught errors by calling - -- `preventDefault()` in window `error` handler. - -- We record this information as an expando on the error. - if error_ ~= nil and error_._suppressLogging then - if boundary.tag == ClassComponent then - -- The error is recoverable and was silenced. - -- Ignore it and don't print the stack addendum. - -- This is handy for testing error boundaries without noise. - return - end - -- The error is fatal. Since the silencing might have - -- been accidental, we'll surface it anyway. - -- However, the browser would have silenced the original error - -- so we'll print it first, and then print the stack addendum. - console['error'](error_) -- Don't transform to our wrapper - -- For a more detailed description of this block, see: - -- https://github.com/facebook/react/pull/13384 - end + local error_ = errorInfo.value + if ReactGlobals.__DEV__ then + local source = errorInfo.source + local stack = errorInfo.stack + local componentStack = stack or "" + -- Browsers support silencing uncaught errors by calling + -- `preventDefault()` in window `error` handler. + -- We record this information as an expando on the error. + if error_ ~= nil and error_._suppressLogging then + if boundary.tag == ClassComponent then + -- The error is recoverable and was silenced. + -- Ignore it and don't print the stack addendum. + -- This is handy for testing error boundaries without noise. + return + end + -- The error is fatal. Since the silencing might have + -- been accidental, we'll surface it anyway. + -- However, the browser would have silenced the original error + -- so we'll print it first, and then print the stack addendum. + console["error"](error_) -- Don't transform to our wrapper + -- For a more detailed description of this block, see: + -- https://github.com/facebook/react/pull/13384 + end - local componentName - if source ~= nil then - componentName = getComponentName(source.type) - else - componentName = nil - end + local componentName + if source ~= nil then + componentName = getComponentName(source.type) + else + componentName = nil + end - local componentNameMessage - if componentName then - componentNameMessage = 'The above error occurred in the <' .. tostring(componentName) .. '> component:' - else - componentNameMessage = 'The above error occurred in one of your React components:' - end + local componentNameMessage + if componentName then + componentNameMessage = "The above error occurred in the <" + .. tostring(componentName) + .. "> component:" + else + componentNameMessage = + "The above error occurred in one of your React components:" + end - local errorBoundaryMessage - local errorBoundaryName = getComponentName(boundary.type) - if errorBoundaryName then - errorBoundaryMessage = - 'React will try to recreate this component tree from scratch ' .. - 'using the error boundary you provided, ' .. errorBoundaryName .. '.' - else - errorBoundaryMessage = - 'Consider adding an error boundary to your tree to customize error handling behavior.\n' .. - 'Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.' - end - local combinedMessage = - componentNameMessage .. '\n' .. componentStack .. '\n\n' .. - errorBoundaryMessage + local errorBoundaryMessage + local errorBoundaryName = getComponentName(boundary.type) + if errorBoundaryName then + errorBoundaryMessage = "React will try to recreate this component tree from scratch " + .. "using the error boundary you provided, " + .. errorBoundaryName + .. "." + else + errorBoundaryMessage = "Consider adding an error boundary to your tree to customize error handling behavior.\n" + .. "Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries." + end + local combinedMessage = componentNameMessage + .. "\n" + .. componentStack + .. "\n\n" + .. errorBoundaryMessage - -- In development, we provide our own message with just the component stack. - -- We don't include the original error message and JS stack because the browser - -- has already printed it. Even if the application swallows the error, it is still - -- displayed by the browser thanks to the DEV-only fake event trick in ReactErrorUtils. - console['error'](combinedMessage) -- Don't transform to our wrapper - else - -- In production, we print the error directly. - -- This will include the message, the JS stack, and anything the browser wants to show. - -- We pass the error object instead of custom message so that the browser displays the error natively. - console['error'](error_) -- Don't transform to our wrapper - end - end) + -- In development, we provide our own message with just the component stack. + -- We don't include the original error message and JS stack because the browser + -- has already printed it. Even if the application swallows the error, it is still + -- displayed by the browser thanks to the DEV-only fake event trick in ReactErrorUtils. + console["error"](combinedMessage) -- Don't transform to our wrapper + else + -- In production, we print the error directly. + -- This will include the message, the JS stack, and anything the browser wants to show. + -- We pass the error object instead of custom message so that the browser displays the error natively. + console["error"](inspect(error_)) -- Don't transform to our wrapper + end - if not ok then - warn("failed to error with error") - -- ROBLOX TODO: we may need to think about this more deeply and do something different - -- This method must not throw, or React internal state will get messed up. - -- If console.error is overridden, or logCapturedError() shows a dialog that throws, - -- we want to report this error outside of the normal stack as a last resort. - -- https://github.com/facebook/react/issues/13188 - setTimeout(function() - error(e) - end) - end + -- ROBLOX Luau FIXME: needs void return Luau bugfix + return nil + end) + + if not ok then + warn("failed to error with error: " .. inspect(e)) + -- ROBLOX TODO: we may need to think about this more deeply and do something different + -- This method must not throw, or React internal state will get messed up. + -- If console.error is overridden, or logCapturedError() shows a dialog that throws, + -- we want to report this error outside of the normal stack as a last resort. + -- https://github.com/facebook/react/issues/13188 + setTimeout(function() + -- ROBLOX FIXME: the top-level Luau VM handler doesn't deal with non-string errors, so massage it until VM support lands + error(errorToString(e :: any)) + end) + end end return exports diff --git a/modules/react-reconciler/src/ReactFiberFlags.lua b/modules/react-reconciler/src/ReactFiberFlags.lua index c35a00be..e51c43aa 100644 --- a/modules/react-reconciler/src/ReactFiberFlags.lua +++ b/modules/react-reconciler/src/ReactFiberFlags.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/37cb732c59863297e48f69ac1f6e2ba1aa1886f0/packages/react-reconciler/src/ReactFiberFlags.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/37cb732c59863297e48f69ac1f6e2ba1aa1886f0/packages/react-reconciler/src/ReactFiberFlags.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -13,58 +14,86 @@ export type Flags = number local exports = {} -- Don't change these two values. They're used by React Dev Tools. -exports.NoFlags = --[[ ]] 0b000000000000000000 -exports.PerformedWork = --[[ ]] 0b000000000000000001 +exports.NoFlags = --[[ ]] + 0b000000000000000000 +exports.PerformedWork = --[[ ]] + 0b000000000000000001 -- You can change the rest (and add more). -exports.Placement = --[[ ]] 0b000000000000000010 -exports.Update = --[[ ]] 0b000000000000000100 -exports.PlacementAndUpdate = --[[ ]] 0b000000000000000110 -exports.Deletion = --[[ ]] 0b000000000000001000 -exports.ContentReset = --[[ ]] 0b000000000000010000 -exports.Callback = --[[ ]] 0b000000000000100000 -exports.DidCapture = --[[ ]] 0b000000000001000000 -exports.Ref = --[[ ]] 0b000000000010000000 -exports.Snapshot = --[[ ]] 0b000000000100000000 -exports.Passive = --[[ ]] 0b000000001000000000 +exports.Placement = --[[ ]] + 0b000000000000000010 +exports.Update = --[[ ]] + 0b000000000000000100 +exports.PlacementAndUpdate = --[[ ]] + 0b000000000000000110 +exports.Deletion = --[[ ]] + 0b000000000000001000 +exports.ContentReset = --[[ ]] + 0b000000000000010000 +exports.Callback = --[[ ]] + 0b000000000000100000 +exports.DidCapture = --[[ ]] + 0b000000000001000000 +exports.Ref = --[[ ]] + 0b000000000010000000 +exports.Snapshot = --[[ ]] + 0b000000000100000000 +exports.Passive = --[[ ]] + 0b000000001000000000 -- TODO (effects) Remove this bit once the new reconciler is synced to the old. -exports.PassiveUnmountPendingDev = --[[ ]] 0b000010000000000000 -exports.Hydrating = --[[ ]] 0b000000010000000000 -exports.HydratingAndUpdate = --[[ ]] 0b000000010000000100 +exports.PassiveUnmountPendingDev = --[[ ]] + 0b000010000000000000 +exports.Hydrating = --[[ ]] + 0b000000010000000000 +exports.HydratingAndUpdate = --[[ ]] + 0b000000010000000100 -- Passive & Update & Callback & Ref & Snapshot -exports.LifecycleEffectMask = --[[ ]] 0b000000001110100100 +exports.LifecycleEffectMask = --[[ ]] + 0b000000001110100100 -- Union of all host effects -exports.HostEffectMask = --[[ ]] 0b000000011111111111 +exports.HostEffectMask = --[[ ]] + 0b000000011111111111 -- These are not really side effects, but we still reuse this field. -exports.Incomplete = --[[ ]] 0b000000100000000000 -exports.ShouldCapture = --[[ ]] 0b000001000000000000 -exports.ForceUpdateForLegacySuspense = --[[ ]] 0b000100000000000000 +exports.Incomplete = --[[ ]] + 0b000000100000000000 +exports.ShouldCapture = --[[ ]] + 0b000001000000000000 +exports.ForceUpdateForLegacySuspense = --[[ ]] + 0b000100000000000000 -- Static tags describe aspects of a fiber that are not specific to a render, -- e.g. a fiber uses a passive effect (even if there are no updates on this particular render). -- This enables us to defer more work in the unmount case, -- since we can defer traversing the tree during layout to look for Passive effects, -- and instead rely on the static flag as a signal that there may be cleanup work. -exports.PassiveStatic = --[[ ]] 0b001000000000000000 +exports.PassiveStatic = --[[ ]] + 0b001000000000000000 -- Union of side effect groupings as pertains to subtreeFlags -exports.BeforeMutationMask = --[[ ]] 0b000000001100001010 -exports.MutationMask = --[[ ]] 0b000000010010011110 -exports.LayoutMask = --[[ ]] 0b000000000010100100 -exports.PassiveMask = --[[ ]] 0b000000001000001000 +exports.BeforeMutationMask = --[[ ]] + 0b000000001100001010 +exports.MutationMask = --[[ ]] + 0b000000010010011110 +exports.LayoutMask = --[[ ]] + 0b000000000010100100 +exports.PassiveMask = --[[ ]] + 0b000000001000001000 -- Union of tags that don't get reset on clones. -- This allows certain concepts to persist without recalculting them, -- e.g. whether a subtree contains passive effects or portals. -exports.StaticMask = --[[ ]] 0b001000000000000000 +exports.StaticMask = --[[ ]] + 0b001000000000000000 -- These flags allow us to traverse to fibers that have effects on mount -- without traversing the entire tree after every commit for -- double invoking -exports.MountLayoutDev = --[[ ]] 0b010000000000000000 -exports.MountPassiveDev = --[[ ]] 0b100000000000000000 +exports.MountLayoutDev = --[[ ]] + 0b010000000000000000 +exports.MountPassiveDev = --[[ ]] + 0b100000000000000000 return exports diff --git a/modules/react-reconciler/src/ReactFiberHooks.new.lua b/modules/react-reconciler/src/ReactFiberHooks.new.lua index 6ad0aa3c..0e70f2c8 100644 --- a/modules/react-reconciler/src/ReactFiberHooks.new.lua +++ b/modules/react-reconciler/src/ReactFiberHooks.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/43363e2795393a00fd77312a16d6b80e626c29de/packages/react-reconciler/src/ReactFiberHooks.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/43363e2795393a00fd77312a16d6b80e626c29de/packages/react-reconciler/src/ReactFiberHooks.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -8,34 +9,45 @@ * @flow ]] -- 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") +local function unimplemented(message: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("UNIMPLEMENTED ERROR: " .. message) + error("FIXME (roblox): " .. message .. " is unimplemented") end - local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array -local Cryo = require(Packages.Cryo) +local Error = LuauPolyfill.Error +local Object = LuauPolyfill.Object + +local __DEV__ = ReactGlobals.__DEV__ :: boolean + +-- ROBLOX: use Bindings to implement useRef +local createRef = require(Packages.React).createRef +local createBinding = require(Packages.React).createBinding -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local ReactTypes = require(Packages.Shared) type ReactContext = ReactTypes.ReactContext +type ReactBinding = ReactTypes.ReactBinding +type ReactBindingUpdater = ReactTypes.ReactBindingUpdater type MutableSource = ReactTypes.MutableSource -type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn -type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn +type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn< + Source, + Snapshot +> +type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn< + Source, + Snapshot +> local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber --- type Dispatcher = ReactInternalTypes.Dispatcher --- FIXME (roblox): Dispatcher definition -type Dispatcher = any +type Dispatcher = ReactInternalTypes.Dispatcher type HookType = ReactInternalTypes.HookType type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel local ReactFiberLane = require(script.Parent.ReactFiberLane) @@ -44,13 +56,15 @@ type Lane = ReactFiberLane.Lane local ReactHookEffectTags = require(script.Parent.ReactHookEffectTags) type HookFlags = ReactHookEffectTags.HookFlags type FiberRoot = ReactInternalTypes.FiberRoot +-- ROBLOX TODO: figure out how to expose types through dynamic exports -- local type {OpaqueIDType} = require(script.Parent.ReactFiberHostConfig) +type OpaqueIDType = any local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -local enableDebugTracing = ReactFeatureFlags.enableDebugTracing -local enableSchedulingProfiler = ReactFeatureFlags.enableSchedulingProfiler -local enableNewReconciler = ReactFeatureFlags.enableNewReconciler +local enableDebugTracing: boolean? = ReactFeatureFlags.enableDebugTracing +local enableSchedulingProfiler: boolean? = ReactFeatureFlags.enableSchedulingProfiler +local enableNewReconciler: boolean? = ReactFeatureFlags.enableNewReconciler -- local decoupleUpdatePriorityFromScheduler = ReactFeatureFlags.decoupleUpdatePriorityFromScheduler local enableDoubleInvokingEffects = ReactFeatureFlags.enableDoubleInvokingEffects @@ -79,15 +93,17 @@ local MountPassiveDevEffect = ReactFiberFlags.MountPassiveDev local HookHasEffect = ReactHookEffectTags.HasEffect local HookLayout = ReactHookEffectTags.Layout local HookPassive = ReactHookEffectTags.Passive -local ReactFiberWorkLoop = require(script.Parent['ReactFiberWorkLoop.new']) :: any -local warnIfNotCurrentlyActingUpdatesInDEV = ReactFiberWorkLoop.warnIfNotCurrentlyActingUpdatesInDEV +local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any +local warnIfNotCurrentlyActingUpdatesInDEV = + ReactFiberWorkLoop.warnIfNotCurrentlyActingUpdatesInDEV local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber local warnIfNotScopedWithMatchingAct = ReactFiberWorkLoop.warnIfNotScopedWithMatchingAct local requestEventTime = ReactFiberWorkLoop.requestEventTime local requestUpdateLane = ReactFiberWorkLoop.requestUpdateLane local markSkippedUpdateLanes = ReactFiberWorkLoop.markSkippedUpdateLanes local getWorkInProgressRoot = ReactFiberWorkLoop.getWorkInProgressRoot -local warnIfNotCurrentlyActingEffectsInDEV = ReactFiberWorkLoop.warnIfNotCurrentlyActingEffectsInDEV +local warnIfNotCurrentlyActingEffectsInDEV = + ReactFiberWorkLoop.warnIfNotCurrentlyActingEffectsInDEV -- local { -- getWorkInProgressRoot, -- requestUpdateLane, @@ -97,15 +113,20 @@ local warnIfNotCurrentlyActingEffectsInDEV = ReactFiberWorkLoop.warnIfNotCurrent local invariant = require(Packages.Shared).invariant local getComponentName = require(Packages.Shared).getComponentName -local is = require(Packages.Shared).objectIs -local markWorkInProgressReceivedUpdate = require(script.Parent['ReactFiberBeginWork.new']).markWorkInProgressReceivedUpdate :: any +-- local is = require(Packages.Shared).objectIs +local function is(x: any, y: any) + return x == y and (x ~= 0 or 1 / x == 1 / y) or x ~= x and y ~= y -- eslint-disable-line no-self-compare +end +local markWorkInProgressReceivedUpdate = + require(script.Parent["ReactFiberBeginWork.new"]).markWorkInProgressReceivedUpdate :: any -- local { -- UserBlockingPriority, -- NormalPriority, -- runWithPriority, -- getCurrentPriorityLevel, -- } = require(script.Parent.SchedulerWithReactIntegration.new) -local getIsHydrating = require(script.Parent["ReactFiberHydrationContext.new"]).getIsHydrating +local getIsHydrating = + require(script.Parent["ReactFiberHydrationContext.new"]).getIsHydrating -- local { -- makeClientId, -- makeClientIdInDEV, @@ -122,56 +143,62 @@ local setWorkInProgressVersion = ReactMutableSource.setWorkInProgressVersion local markSourceAsDirty = ReactMutableSource.markSourceAsDirty -- local getIsRendering = require(script.Parent.ReactCurrentFiber).getIsRendering --- local logStateUpdateScheduled = require(script.Parent.DebugTracing) -local markStateUpdateScheduled = require(script.Parent.SchedulingProfiler).markStateUpdateScheduled +local logStateUpdateScheduled = + require(script.Parent.DebugTracing).logStateUpdateScheduled +local markStateUpdateScheduled = + require(script.Parent.SchedulingProfiler).markStateUpdateScheduled local ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher -- local ReactCurrentBatchConfig = ReactSharedInternals.ReactCurrentBatchConfig +local FFlagReactCleanQueueOnUpdateBailout = + require(Packages.SafeFlags).createGetFFlag("ReactCleanQueueOnUpdateBailout")() + -- deviation: common types type Array = { [number]: T } type Update = { - lane: Lane, - action: A, - eagerReducer: ((S, A) -> S) | nil, - eagerState: S | nil, - next: Update, - priority: ReactPriorityLevel?, + lane: Lane, + action: A, + eagerReducer: ((S, A) -> S) | nil, + eagerState: S | nil, + next: Update, + priority: ReactPriorityLevel?, } type UpdateQueue = { - pending: Update | nil, - dispatch: ((A) -> any) | nil, - lastRenderedReducer: ((S, A) -> S) | nil, - lastRenderedState: S | nil, + pending: Update | nil, + dispatch: ((A) -> ...any) | nil, + lastRenderedReducer: ((S, A) -> S) | nil, + lastRenderedState: S | nil, } local didWarnAboutMismatchedHooksForComponent local _didWarnAboutUseOpaqueIdentifier -if _G.__DEV__ then - _didWarnAboutUseOpaqueIdentifier = {} - didWarnAboutMismatchedHooksForComponent = {} +if __DEV__ then + _didWarnAboutUseOpaqueIdentifier = {} + didWarnAboutMismatchedHooksForComponent = {} end export type Hook = { - memoizedState: any, - baseState: any, - baseQueue: Update?, - queue: UpdateQueue?, - next: Hook?, + memoizedState: any, + baseState: any, + baseQueue: Update | nil, + queue: UpdateQueue | nil, + next: Hook?, } export type Effect = { - tag: HookFlags, - create: () -> (() -> ())?, - destroy: (() -> ())?, - deps: Array?, - next: Effect, + tag: HookFlags, + -- ROBLOX TODO: this needs Luau type pack support to express accurately + create: (() -> () -> ()) | () -> (), + destroy: (() -> ())?, + deps: Array | nil, + next: Effect, } export type FunctionComponentUpdateQueue = { - lastEffect: Effect? + lastEffect: Effect?, } type BasicStateAction = ((S) -> S) | S @@ -184,8 +211,7 @@ local exports: any = {} local renderLanes: Lanes = NoLanes -- The work-in-progress fiber. I've named it differently to distinguish it from -- the work-in-progress hook. --- FIXME (roblox): No luau support for `local x: Fiber = (nil: any)` -local currentlyRenderingFiber: any = nil +local currentlyRenderingFiber: Fiber = nil :: any -- Hooks are stored as a linked list on the fiber's memoizedState field. The -- current hook list is the list that belongs to the current fiber. The @@ -217,14 +243,13 @@ local currentHookNameInDev: HookType? = nil -- In DEV, this list ensures that hooks are called in the same order between renders. -- The list stores the order of hooks used during the initial render (mount). -- Subsequent renders (updates) reference this list. --- FIXME (roblox): type refinement --- local hookTypesDev: Array | nil = nil -local hookTypesDev: any = nil +local hookTypesDev: Array | nil = nil local hookTypesUpdateIndexDev: number = 0 -- In DEV, this tracks whether currently rendering component needs to ignore -- the dependencies for Hooks that need them (e.g. useEffect or useMemo). -- When true, such Hooks will always be "remounted". Only used during hot reload. +-- ROBLOX performance: eliminate unuseful cmp in hot path, we don't currently support hot reloading local ignorePreviousDependencies: boolean = false -- Deviation: move to top so below function can reference @@ -236,1328 +261,1307 @@ local InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher | nil = nil local InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher | nil = nil local InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher | nil = nil +-- ROBLOX deviation: Used to better compare dependency arrays with gaps +local function getHighestIndex(array: Array) + local highestIndex = 0 + for k, v in array do + highestIndex = if k > highestIndex then k else highestIndex + end + return highestIndex +end + +-- ROBLOX deviation: Used to better detect dependency arrays with gaps, to be +-- used in place of Array.isArray +local function isArrayOrSparseArray(deps: any) + if type(deps) ~= "table" then + return false + end + for k, _v in deps do + if type(k) ~= "number" then + return false + end + end + return true +end + local function mountHookTypesDev() - if _G.__DEV__ then - -- FIXME (roblox): type coercion - -- local hookName = ((currentHookNameInDev: any): HookType) - local hookName = currentHookNameInDev - - if hookTypesDev == nil then - hookTypesDev = { hookName } - else - table.insert(hookTypesDev, hookName) - end - end + if __DEV__ then + local hookName = (currentHookNameInDev :: any) :: HookType + + if hookTypesDev == nil then + -- ROBLOX FIXME Luau: needs normalization (I think) + hookTypesDev = ({ hookName } :: any) :: Array + else + table.insert(hookTypesDev, hookName) + end + end end function updateHookTypesDev() - if _G.__DEV__ then - local hookName = currentHookNameInDev - - if hookTypesDev ~= nil then - hookTypesUpdateIndexDev += 1 - if hookTypesDev[hookTypesUpdateIndexDev] ~= hookName then - warnOnHookMismatchInDev(hookName) - end - end - end + if __DEV__ then + -- ROBLOX FIXME Luau: needs normalization (I think) to avoid duplicate type declaration + local hookName: HookType = (currentHookNameInDev :: any) :: HookType + + if hookTypesDev ~= nil then + hookTypesUpdateIndexDev += 1 + if hookTypesDev[hookTypesUpdateIndexDev] ~= hookName then + warnOnHookMismatchInDev(hookName) + end + end + end end local function checkDepsAreArrayDev(deps: any) - if _G.__DEV__ then - if deps ~= nil and not Array.isArray(deps) then - -- Verify deps, but only on mount to avoid extra checks. - -- It's unlikely their type would change as usually you define them inline. - console.error( - "%s received a final argument that is not an array (instead, received `%s`). When " .. - "specified, the final argument must be an array.", - currentHookNameInDev, - typeof(deps) - ) - end - end + if __DEV__ then + if deps ~= nil and not isArrayOrSparseArray(deps) then + -- Verify deps, but only on mount to avoid extra checks. + -- It's unlikely their type would change as usually you define them inline. + console.error( + "%s received a final argument that is not an array (instead, received `%s`). When " + .. "specified, the final argument must be an array.", + currentHookNameInDev, + type(deps) + ) + end + end end function warnOnHookMismatchInDev(currentHookName: HookType) - if _G.__DEV__ then - -- ROBLOX deviation: getComponentName will return nil in most Hook cases, use same fallback as elsewhere - local componentName = getComponentName(currentlyRenderingFiber.type) or "Component" - if not didWarnAboutMismatchedHooksForComponent[componentName] then - didWarnAboutMismatchedHooksForComponent[componentName] = true - - if hookTypesDev ~= nil then - local table_ = '' - - local secondColumnStart = 30 - - for i = 1, hookTypesUpdateIndexDev do - local oldHookName = hookTypesDev[i] - local newHookName - if i == hookTypesUpdateIndexDev then - newHookName = currentHookName - else - newHookName = oldHookName - end - - local row = tostring(i) .. ". " .. oldHookName - - -- Extra space so second column lines up - -- lol @ IE not supporting String#repeat - while row.length < secondColumnStart do - row ..= ' ' - end - - row ..= newHookName + '\n' - - table_ ..= row - end - - console.error( - 'React has detected a change in the order of Hooks called by %s. ' .. - 'This will lead to bugs and errors if not fixed. ' .. - 'For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n' .. - ' Previous render Next render\n' .. - ' ------------------------------------------------------\n' .. - '%s' .. - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n', - componentName, - table_ - ) - end - end - end + if __DEV__ then + -- ROBLOX deviation: getComponentName will return nil in most Hook cases, use same fallback as elsewhere + local componentName = getComponentName(currentlyRenderingFiber.type) + or "Component" + if not didWarnAboutMismatchedHooksForComponent[componentName] then + didWarnAboutMismatchedHooksForComponent[componentName] = true + + if hookTypesDev ~= nil then + local table_ = "" + + local secondColumnStart = 30 + + for i = 1, hookTypesUpdateIndexDev do + local oldHookName = (hookTypesDev :: any)[i] + local newHookName + if i == hookTypesUpdateIndexDev then + newHookName = currentHookName + else + newHookName = oldHookName + end + + -- ROBLOX note: upstream lets this be void and string concat coerces it to 'undefined' + local row = tostring(i) .. ". " .. (oldHookName or "undefined") + + -- Extra space so second column lines up + -- lol @ IE not supporting String#repeat + while string.len(row) < secondColumnStart do + row ..= " " + end + + row ..= newHookName .. "\n" + + table_ ..= row + end + + console.error( + "React has detected a change in the order of Hooks called by %s. " + .. "This will lead to bugs and errors if not fixed. " + .. "For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. "%s" + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n", + componentName, + table_ + ) + end + end + end end -local function throwInvalidHookError() - invariant( - false, - "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:\n" .. - "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" .. - "2. You might be breaking the Rules of Hooks\n" .. - "3. You might have more than one copy of React in the same app\n" .. - "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." - ) +local function throwInvalidHookError(): () + error( + Error.new( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + ) end -- FIXME (roblox): type refinement -- prevDeps: Array? -local function areHookInputsEqual( - nextDeps: Array, - prevDeps: Array -) - if _G.__DEV__ then - if ignorePreviousDependencies then - -- Only true when this component is being hot reloaded. - return false - end - end - - if prevDeps == nil then - if _G.__DEV__ then - console.error( - "%s received a final argument during this 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 - console.error( - "The final argument passed to %s changed size between renders. The " .. - "order and size of this array must remain constant.\n\n" .. - "Previous: %s\n" .. - "Incoming: %s", - currentHookNameInDev, - table.concat(prevDeps, ", "), - table.concat(nextDeps, ", ") - ) - end - end - for i = 1, math.min(#prevDeps, #nextDeps) do - if is(nextDeps[i], prevDeps[i]) then - continue - end - return false - end - return true +local function areHookInputsEqual(nextDeps: Array, prevDeps: Array) + if __DEV__ then + if ignorePreviousDependencies then + -- Only true when this component is being hot reloaded. + return false + end + end + + if prevDeps == nil then + if __DEV__ then + -- ROBLOX TODO: no unit tests in upstream for this, we should add some + console.error( + "%s received a final argument during this 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 + + -- ROBLOX deviation START: calculate lengths with iteration instead of # to + -- accommodate nil values and disable warning for differing lengths + local nextDepsLength = getHighestIndex(nextDeps) + local prevDepsLength = getHighestIndex(prevDeps) + + -- ROBLOX note: In upstream, lengths aren't even compared unless dev mode is + -- enabled because they _always_ indicate a misuse of dependency arrays. In + -- luau, since trailing `nil`s effectively change the length of the array, + -- it's possible to trigger this scenario with a valid use of the dependencies + -- array (e.g. `{1, 2, 3}` -> `{1, 2, nil}`) + if nextDepsLength ~= prevDepsLength then + -- ROBLOX TODO: linting like upstream does would make this warning less + -- necessary, and would help justify our exclusion of the warning. + + -- https://jira.rbx.com/browse/LUAFDN-1175 + -- if __DEV__ then + -- console.error( + -- "The final argument passed to %s changed size between renders. The " .. + -- "order and size of this array must remain constant.\n\n" .. + -- "Previous: %s\n" .. + -- "Incoming: %s", + -- currentHookNameInDev, + -- inspect(prevDeps), + -- inspect(nextDeps) + -- ) + -- end + + -- Short-circuit here since we know that different lengths means a change in + -- values, even if it's due to trailing nil values + return false + end + -- ROBLOX deviation END + + local minDependencyCount = math.min(prevDepsLength, nextDepsLength) + for i = 1, minDependencyCount do + if is(nextDeps[i], prevDeps[i]) then + continue + end + return false + end + return true end -exports.bailoutHooks = function( - current: Fiber, - workInProgress: Fiber, - lanes: Lanes -) - workInProgress.updateQueue = current.updateQueue - if _G.__DEV__ and enableDoubleInvokingEffects then - workInProgress.flags = bit32.band( - workInProgress.flags, - bit32.bnot(bit32.bor( - MountPassiveDevEffect, - PassiveEffect, - MountLayoutDevEffect, - UpdateEffect - )) - ) - else - workInProgress.flags = bit32.band( - workInProgress.flags, - bit32.bnot(bit32.bor(PassiveEffect, UpdateEffect)) - ) - end - current.lanes = removeLanes(current.lanes, lanes) +exports.bailoutHooks = function(current: Fiber, workInProgress: Fiber, lanes: Lanes) + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + workInProgress.updateQueue = current.updateQueue + if __DEV__ and enableDoubleInvokingEffects then + workInProgress.flags = bit32.band( + workInProgress.flags, + bit32.bnot( + bit32.bor( + MountPassiveDevEffect, + PassiveEffect, + MountLayoutDevEffect, + UpdateEffect + ) + ) + ) + else + workInProgress.flags = bit32.band( + workInProgress.flags, + bit32.bnot(bit32.bor(PassiveEffect, UpdateEffect)) + ) + end + current.lanes = removeLanes(current.lanes, lanes) end local _isUpdatingOpaqueValueInRenderPhase = false -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 - - if didScheduleRenderPhaseUpdate then - -- There were render phase updates. These are only valid for this render - -- phase, which we are now aborting. Remove the updates from the queues so - -- they do not persist to the next render. Do not remove updates from hooks - -- that weren't processed. - -- - -- Only reset the updates from the queue if it has a clone. If it does - -- not have a clone, that means it wasn't processed, and the updates were - -- scheduled before we entered the render phase. - -- FIXME (roblox): Better type refinement - -- local hook: Hook | nil = currentlyRenderingFiber.memoizedState - local hook: any = currentlyRenderingFiber.memoizedState - while hook ~= nil do - local queue = hook.queue - if queue ~= nil then - queue.pending = nil - end - hook = hook.next - end - didScheduleRenderPhaseUpdate = false - end - - renderLanes = NoLanes - currentlyRenderingFiber = nil - - currentHook = nil - workInProgressHook = nil - - if _G.__DEV__ then - hookTypesDev = nil - hookTypesUpdateIndexDev = 0 - - currentHookNameInDev = nil - - _isUpdatingOpaqueValueInRenderPhase = false - end - - didScheduleRenderPhaseUpdateDuringThisPass = false +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 + + if didScheduleRenderPhaseUpdate then + -- There were render phase updates. These are only valid for this render + -- phase, which we are now aborting. Remove the updates from the queues so + -- they do not persist to the next render. Do not remove updates from hooks + -- that weren't processed. + -- + -- Only reset the updates from the queue if it has a clone. If it does + -- not have a clone, that means it wasn't processed, and the updates were + -- scheduled before we entered the render phase. + -- FIXME (roblox): Better type refinement + -- local hook: Hook | nil = currentlyRenderingFiber.memoizedState + local hook: any = currentlyRenderingFiber.memoizedState + while hook ~= nil do + local queue = hook.queue + if queue ~= nil then + queue.pending = nil + end + hook = hook.next + end + didScheduleRenderPhaseUpdate = false + end + + renderLanes = NoLanes + currentlyRenderingFiber = nil :: any + + currentHook = nil + workInProgressHook = nil + + if __DEV__ then + hookTypesDev = nil + hookTypesUpdateIndexDev = 0 + + currentHookNameInDev = nil + + _isUpdatingOpaqueValueInRenderPhase = false + end + + didScheduleRenderPhaseUpdateDuringThisPass = false end local function mountWorkInProgressHook(): Hook - local hook: Hook = { - memoizedState = nil, - - baseState = nil, - baseQueue = nil, - queue = nil, - - next = nil, - } - - if workInProgressHook == nil then - -- This is the first hook in the list - currentlyRenderingFiber.memoizedState = hook - workInProgressHook = hook - else - -- Append to the end of the list - workInProgressHook.next = hook - workInProgressHook = hook - end - return workInProgressHook + local hook: Hook = { + memoizedState = nil, + + baseState = nil, + baseQueue = nil, + queue = nil, + + next = nil, + } + + if workInProgressHook == nil then + -- This is the first hook in the list + currentlyRenderingFiber.memoizedState = hook + workInProgressHook = hook + else + -- Append to the end of the list + workInProgressHook.next = hook + workInProgressHook = hook + end + return workInProgressHook end local function updateWorkInProgressHook(): Hook - -- This function is used both for updates and for re-renders triggered by a - -- render phase update. It assumes there is either a current hook we can - -- clone, or a work-in-progress hook from a previous render pass that we can - -- use as a base. When we reach the end of the base list, we must switch to - -- the dispatcher used for mounts. - -- FIXME (roblox): type refinement - -- local nextCurrentHook: Hook? - local nextCurrentHook - if currentHook == nil then - local current = currentlyRenderingFiber.alternate - if current ~= nil then - nextCurrentHook = current.memoizedState - else - nextCurrentHook = nil - end - else - nextCurrentHook = currentHook.next - end - - -- FIXME (roblox): type refinement - -- local nextWorkInProgressHook: Hook? - local nextWorkInProgressHook - if workInProgressHook == nil then - nextWorkInProgressHook = currentlyRenderingFiber.memoizedState - else - nextWorkInProgressHook = workInProgressHook.next - end - - if nextWorkInProgressHook ~= nil then - -- There's already a work-in-progress. Reuse it. - workInProgressHook = nextWorkInProgressHook - nextWorkInProgressHook = workInProgressHook.next - - currentHook = nextCurrentHook - else - -- Clone from the current hook. - - invariant( - nextCurrentHook ~= nil, - "Rendered more hooks than during the previous render." - ) - currentHook = nextCurrentHook - - local newHook: Hook = { - memoizedState = currentHook.memoizedState, - - baseState = currentHook.baseState, - baseQueue = currentHook.baseQueue, - queue = currentHook.queue, - - next = nil, - } - - if workInProgressHook == nil then - -- This is the first hook in the list. - workInProgressHook = newHook - currentlyRenderingFiber.memoizedState = newHook - else - -- Append to the end of the list. - workInProgressHook.next = newHook - workInProgressHook = newHook - end - end - return workInProgressHook + -- This function is used both for updates and for re-renders triggered by a + -- render phase update. It assumes there is either a current hook we can + -- clone, or a work-in-progress hook from a previous render pass that we can + -- use as a base. When we reach the end of the base list, we must switch to + -- the dispatcher used for mounts. + -- FIXME (roblox): type refinement + -- local nextCurrentHook: Hook? + local nextCurrentHook + if currentHook == nil then + local current = currentlyRenderingFiber.alternate + if current ~= nil then + nextCurrentHook = current.memoizedState + else + nextCurrentHook = nil + end + else + nextCurrentHook = currentHook.next + end + + -- FIXME (roblox): type refinement + -- local nextWorkInProgressHook: Hook? + local nextWorkInProgressHook: Hook + if workInProgressHook == nil then + nextWorkInProgressHook = currentlyRenderingFiber.memoizedState + else + nextWorkInProgressHook = workInProgressHook.next + end + + if nextWorkInProgressHook ~= nil then + -- There's already a work-in-progress. Reuse it. + workInProgressHook = nextWorkInProgressHook + nextWorkInProgressHook = workInProgressHook.next + + currentHook = nextCurrentHook + else + -- Clone from the current hook. + + -- ROBLOX performance: use React 18 check to avoid function call overhead + if nextCurrentHook == nil then + error(Error.new("Rendered more hooks than during the previous render.")) + end + + currentHook = nextCurrentHook + + local newHook: Hook = { + memoizedState = currentHook.memoizedState, + + baseState = currentHook.baseState, + baseQueue = currentHook.baseQueue, + queue = currentHook.queue, + + next = nil, + } + + if workInProgressHook == nil then + -- This is the first hook in the list. + workInProgressHook = newHook + currentlyRenderingFiber.memoizedState = newHook + else + -- Append to the end of the list. + workInProgressHook.next = newHook + workInProgressHook = newHook + end + end + return workInProgressHook end -local function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue - return { - lastEffect = nil, - } -end +-- ROBLOX performance: inlined in hot path +-- local function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue +-- return { +-- lastEffect = nil, +-- } +-- end --- function basicStateReducer(state: S, action: BasicStateAction): S { -function basicStateReducer(state, action) - -- $FlowFixMe: Flow doesn't like mixed types - if typeof(action) == 'function' then - return action(state) - else - return action - end +function basicStateReducer(state: S, action: BasicStateAction): S + -- $FlowFixMe: Flow doesn't like mixed types + if type(action) == "function" then + return action(state) + else + return action + end end --- ROBLOX TODO: function generics --- function mountReducer( --- reducer: (S, A) => S, --- initialArg: I, --- init?: I => S, --- ): [S, Dispatch] { -function mountReducer( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? -): (any, Dispatch) - local hook = mountWorkInProgressHook() - local initialState - if init ~= nil then - -- ROBLOX FIXME: remove :: once Luau understnads nil check - initialState = (init :: (any) -> any)(initialArg) - else - -- ROBLOX TODO: recast initialArg to ((initialArg: any): S) - initialState = initialArg - end - -- deviation: multiple assignment converted to two assignments - hook.baseState = initialState - hook.memoizedState = hook.baseState - - -- deviation: multiple assignment converted to two assignments - hook.queue = { - pending = nil, - dispatch = nil, - lastRenderedReducer = reducer, - -- ROBLOX TODO: recast(initialState: any) - lastRenderedState = initialState - } - - local queue = hook.queue - -- deviation: set currentlyRenderingFiber to a local varible so it doesn't change - -- by call time - local cRF = currentlyRenderingFiber - queue.dispatch = function(...) - return dispatchAction(cRF, queue, ...) - end - local dispatch: Dispatch = queue.dispatch - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return hook.memoizedState, dispatch +function mountReducer( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? +): (S, Dispatch) + local hook = mountWorkInProgressHook() + local initialState + if init ~= nil then + initialState = init(initialArg) + else + initialState = (initialArg :: any) :: S + end + hook.baseState = initialState + hook.memoizedState = hook.baseState + + local queue: UpdateQueue = { + pending = nil, + dispatch = nil, + lastRenderedReducer = reducer, + lastRenderedState = initialState :: any, + } + hook.queue = queue + + -- deviation: set currentlyRenderingFiber to a local varible so it doesn't change + -- by call time + local cRF = currentlyRenderingFiber + -- ROBLOX FIXME? we pass in action here, but is that what really happens upstream? + local dispatch: Dispatch = function(action, ...) + -- ROBLOX Luau FIXME: relies on normalization + dispatchAction(cRF, queue :: UpdateQueue, action, ...) + end :: any + queue.dispatch = dispatch :: any + -- ROBLOX deviation START: Lua version of useState and useReducer return two items, not list like upstream + return hook.memoizedState, dispatch + -- ROBLOX deviation END: Lua version of useState and useReducer return two items, not list like upstream end --- ROBLOX FIXME: function generics, return type --- useReducer( --- reducer: (S, A) => S, --- initialArg: I, --- init?: I => S, --- ): [S, Dispatch] -function updateReducer( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? -): (any, Dispatch) - local hook = updateWorkInProgressHook() - local queue = hook.queue - invariant( - queue ~= nil, - 'Should have a queue. This is likely a bug in React. Please file an issue.' - ) - - queue.lastRenderedReducer = reducer - - local current: Hook = currentHook - - -- The last rebase update that is NOT part of the base state. - local baseQueue = current.baseQueue - - -- The last pending update that hasn't been processed yet. - local pendingQueue = queue.pending - if pendingQueue ~= nil then - -- We have new updates that haven't been processed yet. - -- We'll add them to the base queue. - if baseQueue ~= nil then - -- Merge the pending queue and the base queue. - local baseFirst = baseQueue.next - local pendingFirst = pendingQueue.next - baseQueue.next = pendingFirst - pendingQueue.next = baseFirst - end - if _G.__DEV__ then - if current.baseQueue ~= baseQueue then - -- Internal invariant that should never happen, but feasibly could in - -- the future if we implement resuming, or some form of that. - console.error( - 'Internal error: Expected work-in-progress queue to be a clone. ' .. - 'This is a bug in React.' - ) - end - end - baseQueue = pendingQueue - current.baseQueue = baseQueue - queue.pending = nil - end - - if baseQueue ~= nil then - -- We have a queue to process. - local first = baseQueue.next - local newState = current.baseState - - local newBaseState = nil - local newBaseQueueFirst = nil - local newBaseQueueLast = nil - local update = first - repeat - local updateLane = update.lane - if not isSubsetOfLanes(renderLanes, updateLane) then - -- Priority is insufficient. Skip this update. If this is the first - -- skipped update, the previous update/state is the new base - -- update/state. - local clone: Update = { - lane= updateLane, - action= update.action, - eagerReducer= update.eagerReducer, - eagerState= update.eagerState, - next= nil, - } - if newBaseQueueLast == nil then - newBaseQueueLast = clone - newBaseQueueFirst = newBaseQueueLast - newBaseState = newState - else - newBaseQueueLast.next = clone - newBaseQueueLast = newBaseQueueLast.next - end - -- Update the remaining priority in the queue. - -- TODO: Don't need to accumulate this. Instead, we can remove - -- renderLanes from the original lanes. - currentlyRenderingFiber.lanes = mergeLanes( - currentlyRenderingFiber.lanes, - updateLane - ) - markSkippedUpdateLanes(updateLane) - else - -- This update does have sufficient priority. - - if newBaseQueueLast ~= nil then - local clone: Update = { - -- This update is going to be committed so we never want uncommit - -- it. Using NoLane works because 0 is a subset of all bitmasks, so - -- this will never be skipped by the check above. - lane = NoLane, - action= update.action, - eagerReducer= update.eagerReducer, - eagerState= update.eagerState, - next= nil - } - newBaseQueueLast.next = clone - newBaseQueueLast = newBaseQueueLast.next - end - - -- Process this update. - if update.eagerReducer == reducer then - -- If this update was processed eagerly, and its reducer matches the - -- current reducer, we can use the eagerly computed state. - newState = update.eagerState - else - local action = update.action - newState = reducer(newState, action) - end - end - update = update.next - until update == nil or update == first - - if newBaseQueueLast == nil then - newBaseState = newState - else - newBaseQueueLast.next = newBaseQueueFirst - end - - -- Mark that the fiber performed work, but only if the new state is - -- different from the current state. - if not is(newState, hook.memoizedState) then - markWorkInProgressReceivedUpdate() - end - - hook.memoizedState = newState - hook.baseState = newBaseState - hook.baseQueue = newBaseQueueLast - - queue.lastRenderedState = newState - end - - -- ROBLOX TODO: dispatch type Dispatch - local dispatch = queue.dispatch - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return hook.memoizedState, dispatch +function updateReducer( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? +): (S, Dispatch) + local hook = updateWorkInProgressHook() + local queue = hook.queue + -- ROBLOX deviation: change from invariant to avoid funtion call in hot path + assert( + queue ~= nil, + "Should have a queue. This is likely a bug in React. Please file an issue." + ) + + queue.lastRenderedReducer = reducer + + local current: Hook = currentHook + + -- The last rebase update that is NOT part of the base state. + local baseQueue = current.baseQueue + + -- The last pending update that hasn't been processed yet. + local pendingQueue = queue.pending + if pendingQueue ~= nil then + -- We have new updates that haven't been processed yet. + -- We'll add them to the base queue. + if baseQueue ~= nil then + -- Merge the pending queue and the base queue. + local baseFirst = baseQueue.next + local pendingFirst = pendingQueue.next + baseQueue.next = pendingFirst + pendingQueue.next = baseFirst + end + -- ROBLOX performance: elimiante cmp in hot path + -- if __DEV__ then + -- if current.baseQueue ~= baseQueue then + -- -- Internal invariant that should never happen, but feasibly could in + -- -- the future if we implement resuming, or some form of that. + -- console.error( + -- 'Internal error: Expected work-in-progress queue to be a clone. ' .. + -- 'This is a bug in React.' + -- ) + -- end + -- end + baseQueue = pendingQueue + current.baseQueue = baseQueue + queue.pending = nil + end + + if baseQueue ~= nil then + -- We have a queue to process. + local first = baseQueue.next + local newState = current.baseState + + local newBaseState = nil + local newBaseQueueFirst = nil + local newBaseQueueLast = nil + local update = first + repeat + local updateLane = update.lane + -- ROBLOX performance: inline isSubsetOfLanes for hot path + -- if not isSubsetOfLanes(renderLanes, updateLane) then + if bit32.band(renderLanes, updateLane) ~= updateLane then + -- Priority is insufficient. Skip this update. If this is the first + -- skipped update, the previous update/state is the new base + -- update/state. + local clone: Update = { + lane = updateLane, + action = update.action, + eagerReducer = update.eagerReducer, + eagerState = update.eagerState, + next = nil :: any, + } + if newBaseQueueLast == nil then + newBaseQueueLast = clone + newBaseQueueFirst = newBaseQueueLast + newBaseState = newState + else + newBaseQueueLast.next = clone + newBaseQueueLast = newBaseQueueLast.next + end + -- Update the remaining priority in the queue. + -- TODO: Don't need to accumulate this. Instead, we can remove + -- renderLanes from the original lanes. + currentlyRenderingFiber.lanes = + mergeLanes(currentlyRenderingFiber.lanes, updateLane) + markSkippedUpdateLanes(updateLane) + else + -- This update does have sufficient priority. + + if newBaseQueueLast ~= nil then + local clone: Update = { + -- This update is going to be committed so we never want uncommit + -- it. Using NoLane works because 0 is a subset of all bitmasks, so + -- this will never be skipped by the check above. + lane = NoLane, + action = update.action, + eagerReducer = update.eagerReducer, + eagerState = update.eagerState, + next = nil :: any, + } + newBaseQueueLast.next = clone + newBaseQueueLast = newBaseQueueLast.next + end + + -- Process this update. + if update.eagerReducer == reducer then + -- If this update was processed eagerly, and its reducer matches the + -- current reducer, we can use the eagerly computed state. + newState = update.eagerState + else + local action = update.action + newState = reducer(newState, action) + end + end + update = update.next + until update == nil or update == first + + if newBaseQueueLast == nil then + newBaseState = newState + else + newBaseQueueLast.next = newBaseQueueFirst + end + + -- Mark that the fiber performed work, but only if the new state is + -- different from the current state. + if not is(newState, hook.memoizedState) then + markWorkInProgressReceivedUpdate() + end + + hook.memoizedState = newState + hook.baseState = newBaseState + hook.baseQueue = newBaseQueueLast + + queue.lastRenderedState = newState + end + + local dispatch: Dispatch = queue.dispatch :: any + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return hook.memoizedState, dispatch end --- function rerenderReducer( --- reducer: (S, A) => S, --- initialArg: I, --- init?: I => S, --- ): [S, Dispatch] { -function rerenderReducer( - reducer: (any, any) -> any, - initialArg, - init: ((any) -> any)? -): (any, Dispatch) - local hook = updateWorkInProgressHook() - local queue = hook.queue - invariant( - queue ~= nil, - 'Should have a queue. This is likely a bug in React. Please file an issue.' - ) - - queue.lastRenderedReducer = reducer - - -- This is a re-render. Apply the new render phase updates to the previous - -- work-in-progress hook. - local dispatch: Dispatch = queue.dispatch - local lastRenderPhaseUpdate = queue.pending - local newState = hook.memoizedState - if lastRenderPhaseUpdate ~= nil then - -- The queue doesn't persist past this render pass. - queue.pending = nil - - local firstRenderPhaseUpdate = lastRenderPhaseUpdate.next - local update = firstRenderPhaseUpdate - repeat - -- Process this render phase update. We don't have to check the - -- priority because it will always be the same as the current - -- render's. - local action = update.action - newState = reducer(newState, action) - update = update.next - until update == firstRenderPhaseUpdate - - -- Mark that the fiber performed work, but only if the new state is - -- different from the current state. - if not is(newState, hook.memoizedState) then - markWorkInProgressReceivedUpdate() - end - - hook.memoizedState = newState - -- Don't persist the state accumulated from the render phase updates to - -- the base state unless the queue is empty. - -- TODO: Not sure if this is the desired semantics, but it's what we - -- do for gDSFP. I can't remember why. - if hook.baseQueue == nil then - hook.baseState = newState - end - - queue.lastRenderedState = newState - end - return newState, dispatch +function rerenderReducer( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? +): (S, Dispatch) + local hook = updateWorkInProgressHook() + local queue = hook.queue + -- ROBLOX performance: changed from invariant to avoid function call in hot path + assert( + queue ~= nil, + "Should have a queue. This is likely a bug in React. Please file an issue." + ) + + queue.lastRenderedReducer = reducer + + -- This is a re-render. Apply the new render phase updates to the previous + -- work-in-progress hook. + local dispatch: Dispatch = queue.dispatch :: Dispatch + local lastRenderPhaseUpdate = queue.pending + local newState = hook.memoizedState + if lastRenderPhaseUpdate ~= nil then + -- The queue doesn't persist past this render pass. + queue.pending = nil + + local firstRenderPhaseUpdate = lastRenderPhaseUpdate.next + local update = firstRenderPhaseUpdate + repeat + -- Process this render phase update. We don't have to check the + -- priority because it will always be the same as the current + -- render's. + local action = update.action + newState = reducer(newState, action) + update = update.next + until update == firstRenderPhaseUpdate + + -- Mark that the fiber performed work, but only if the new state is + -- different from the current state. + if not is(newState, hook.memoizedState) then + markWorkInProgressReceivedUpdate() + end + + hook.memoizedState = newState + -- Don't persist the state accumulated from the render phase updates to + -- the base state unless the queue is empty. + -- TODO: Not sure if this is the desired semantics, but it's what we + -- do for gDSFP. I can't remember why. + if hook.baseQueue == nil then + hook.baseState = newState + end + + queue.lastRenderedState = newState + end + -- ROBLOX deviation: Lua version returns two values instead of an array + return newState, dispatch end type MutableSourceMemoizedState = { - refs: { - getSnapshot: MutableSourceGetSnapshotFn, - setSnapshot: (Snapshot) -> () - }, - source: MutableSource, - subscribe: MutableSourceSubscribeFn, + refs: { + getSnapshot: MutableSourceGetSnapshotFn, + setSnapshot: (Snapshot) -> (), + }, + source: MutableSource, + subscribe: MutableSourceSubscribeFn, } --- ROBLOX TODO: Luau generics --- function readFromUnsubcribedMutableSource( --- root: FiberRoot, --- source: MutableSource, --- getSnapshot: MutableSourceGetSnapshotFn, --- ): Snapshot { -function readFromUnsubcribedMutableSource( - root: FiberRoot, - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn -): any - if _G.__DEV__ then - warnAboutMultipleRenderersDEV(source) - end - - local getVersion = source._getVersion - local version = getVersion(source._source) - - -- Is it safe for this component to read from this source during the current render? - local isSafeToReadFromSource = false - - -- Check the version first. - -- If this render has already been started with a specific version, - -- we can use it alone to determine if we can safely read from the source. - local currentRenderVersion = getWorkInProgressVersion(source) - if currentRenderVersion ~= nil then - -- It's safe to read if the store hasn't been mutated since the last time - -- we read something. - isSafeToReadFromSource = currentRenderVersion == version - else - -- If there's no version, then this is the first time we've read from the - -- source during the current render pass, so we need to do a bit more work. - -- What we need to determine is if there are any hooks that already - -- subscribed to the source, and if so, whether there are any pending - -- mutations that haven't been synchronized yet. - -- - -- If there are no pending mutations, then `root.mutableReadLanes` will be - -- empty, and we know we can safely read. - -- - -- If there *are* pending mutations, we may still be able to safely read - -- if the currently rendering lanes are inclusive of the pending mutation - -- lanes, since that guarantees that the value we're about to read from - -- the source is consistent with the values that we read during the most - -- recent mutation. - isSafeToReadFromSource = isSubsetOfLanes( - renderLanes, - root.mutableReadLanes - ) - - if isSafeToReadFromSource then - -- If it's safe to read from this source during the current render, - -- store the version in case other components read from it. - -- A changed version number will local those components know to throw and restart the render. - setWorkInProgressVersion(source, version) - end - end - - if isSafeToReadFromSource then - local snapshot = getSnapshot(source._source) - if _G.__DEV__ then - if typeof(snapshot) == 'function' then - console.error( - 'Mutable source should not return a function as the snapshot value. ' .. - 'Functions may close over mutable values and cause tearing.' - ) - end - end - return snapshot - else - -- This handles the special case of a mutable source being shared between renderers. - -- In that case, if the source is mutated between the first and second renderer, - -- The second renderer don't know that it needs to reset the WIP version during unwind, - -- (because the hook only marks sources as dirty if it's written to their WIP version). - -- That would cause this tear check to throw again and eventually be visible to the user. - -- We can avoid this infinite loop by explicitly marking the source as dirty. - -- - -- This can lead to tearing in the first renderer when it resumes, - -- but there's nothing we can do about that (short of throwing here and refusing to continue the render). - markSourceAsDirty(source) - - invariant( - false, - 'Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue.' - ) - -- ROBLOX deviation: explicit return to silence - return - end +function readFromUnsubcribedMutableSource( + root: FiberRoot, + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + > +): Snapshot + if __DEV__ then + warnAboutMultipleRenderersDEV(source) + end + + local getVersion = source._getVersion + local version_ = getVersion(source._source) + + -- Is it safe for this component to read from this source during the current render? + local isSafeToReadFromSource = false + + -- Check the version first. + -- If this render has already been started with a specific version, + -- we can use it alone to determine if we can safely read from the source. + local currentRenderVersion = getWorkInProgressVersion(source) + if currentRenderVersion ~= nil then + -- It's safe to read if the store hasn't been mutated since the last time + -- we read something. + isSafeToReadFromSource = currentRenderVersion == version_ + else + -- If there's no version, then this is the first time we've read from the + -- source during the current render pass, so we need to do a bit more work. + -- What we need to determine is if there are any hooks that already + -- subscribed to the source, and if so, whether there are any pending + -- mutations that haven't been synchronized yet. + -- + -- If there are no pending mutations, then `root.mutableReadLanes` will be + -- empty, and we know we can safely read. + -- + -- If there *are* pending mutations, we may still be able to safely read + -- if the currently rendering lanes are inclusive of the pending mutation + -- lanes, since that guarantees that the value we're about to read from + -- the source is consistent with the values that we read during the most + -- recent mutation. + isSafeToReadFromSource = isSubsetOfLanes(renderLanes, root.mutableReadLanes) + + if isSafeToReadFromSource then + -- If it's safe to read from this source during the current render, + -- store the version in case other components read from it. + -- A changed version number will local those components know to throw and restart the render. + setWorkInProgressVersion(source, version_) + end + end + + if isSafeToReadFromSource then + local snapshot = getSnapshot(source._source) + if __DEV__ then + -- ROBLOX deviation: the Snapshot generic isn't constrained upstream, but it as to be for this typeof() to work + if type(snapshot :: any) == "function" then + console.error( + "Mutable source should not return a function as the snapshot value. " + .. "Functions may close over mutable values and cause tearing." + ) + end + end + return snapshot + else + -- This handles the special case of a mutable source being shared between renderers. + -- In that case, if the source is mutated between the first and second renderer, + -- The second renderer don't know that it needs to reset the WIP version during unwind, + -- (because the hook only marks sources as dirty if it's written to their WIP version). + -- That would cause this tear check to throw again and eventually be visible to the user. + -- We can avoid this infinite loop by explicitly marking the source as dirty. + -- + -- This can lead to tearing in the first renderer when it resumes, + -- but there's nothing we can do about that (short of throwing here and refusing to continue the render). + markSourceAsDirty(source) + + error( + Error.new( + "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue." + ) + ) + end end --- ROBLOX TODO: needs Luau function generics --- function useMutableSource( --- hook: Hook, --- source: MutableSource, --- getSnapshot: MutableSourceGetSnapshotFn, --- subscribe: MutableSourceSubscribeFn, --- ): Snapshot { -function useMutableSource( - hook: Hook, - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn -): any - local root: FiberRoot = getWorkInProgressRoot() - invariant( - root ~= nil, - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.' - ) - - local getVersion = source._getVersion - local version = getVersion(source._source) - - local dispatcher = ReactCurrentDispatcher.current - - -- eslint-disable-next-line prefer-const - local currentSnapshot, setSnapshot = dispatcher.useState(function() - return readFromUnsubcribedMutableSource(root, source, getSnapshot) - end) - local snapshot = currentSnapshot - - -- Grab a handle to the state hook as well. - -- We use it to clear the pending update queue if we have a new source. - - -- ROBLOX TODO: recast local stateHook = ((workInProgressHook: any): Hook) - local stateHook = workInProgressHook - - local memoizedState: MutableSourceMemoizedState = hook.memoizedState - if memoizedState.refs == nil then - error(tostring(debug.traceback())) - end - local refs = memoizedState.refs - local prevGetSnapshot = refs.getSnapshot - local prevSource = memoizedState.source - local prevSubscribe = memoizedState.subscribe - - local fiber = currentlyRenderingFiber - - hook.memoizedState = { - refs = refs, - source = source, - subscribe = subscribe, - } - - -- Sync the values needed by our subscription handler after each commit. - dispatcher.useEffect(function() - refs.getSnapshot = getSnapshot - - -- Normally the dispatch function for a state hook never changes, - -- but this hook recreates the queue in certain cases to avoid updates from stale sources. - -- handleChange() below needs to reference the dispatch function without re-subscribing, - -- so we use a ref to ensure that it always has the latest version. - refs.setSnapshot = setSnapshot - - -- Check for a possible change between when we last rendered now. - local maybeNewVersion = getVersion(source._source) - if not is(version, maybeNewVersion) then - local maybeNewSnapshot = getSnapshot(source._source) - if _G.__DEV__ then - if typeof(maybeNewSnapshot) == 'function' then - console.error( - 'Mutable source should not return a function as the snapshot value. ' .. - 'Functions may close over mutable values and cause tearing.' - ) - end - end - - if not is(snapshot, maybeNewSnapshot) then - setSnapshot(maybeNewSnapshot) - - local lane = requestUpdateLane(fiber) - markRootMutableRead(root, lane) - end - -- If the source mutated between render and now, - -- there may be state updates already scheduled from the old source. - -- Entangle the updates so that they render in the same batch. - markRootEntangled(root, root.mutableReadLanes) - end - end, {getSnapshot, source, subscribe}) - - -- If we got a new source or subscribe function, re-subscribe in a passive effect. - dispatcher.useEffect(function() - local handleChange = function() - local latestGetSnapshot = refs.getSnapshot - local latestSetSnapshot = refs.setSnapshot - - local ok, result = pcall(function() - latestSetSnapshot(latestGetSnapshot(source._source)) - - -- Record a pending mutable source update with the same expiration time. - local lane = requestUpdateLane(fiber) - - markRootMutableRead(root, lane) - end) - - if not ok then - -- A selector might throw after a source mutation. - -- e.g. it might try to read from a part of the store that no longer exists. - -- In this case we should still schedule an update with React. - -- Worst case the selector will throw again and then an error boundary will handle it. - latestSetSnapshot( - function() - error(result) - end) - end - end - - local unsubscribe = subscribe(source._source, handleChange) - if _G.__DEV__ then - if typeof(unsubscribe) ~= 'function' then - console.error( - 'Mutable source subscribe function must return an unsubscribe function.' - ) - end - end - - return unsubscribe - end, {source, subscribe}) - - -- If any of the inputs to useMutableSource change, reading is potentially unsafe. - -- - -- If either the source or the subscription have changed we can't can't trust the update queue. - -- Maybe the source changed in a way that the old subscription ignored but the new one depends on. - -- - -- If the getSnapshot function changed, we also shouldn't rely on the update queue. - -- It's possible that the underlying source was mutated between the when the last "change" event fired, - -- and when the current render (with the new getSnapshot function) is processed. - -- - -- In both cases, we need to throw away pending updates (since they are no longer relevant) - -- and treat reading from the source as we do in the mount case. - if - not is(prevGetSnapshot, getSnapshot) or - not is(prevSource, source) or - not is(prevSubscribe, subscribe) - then - -- Create a new queue and setState method, - -- So if there are interleaved updates, they get pushed to the older queue. - -- When this becomes current, the previous queue and dispatch method will be discarded, - -- including any interleaving updates that occur. - local newQueue = { - pending = nil, - dispatch = nil, - lastRenderedReducer = basicStateReducer, - lastRenderedState = snapshot - } - - -- deviation: keep local pointer so if global changes we maintain correct reference. - local cRF = currentlyRenderingFiber - setSnapshot = function(...) - return dispatchAction(cRF, newQueue, ...) - end - - newQueue.dispatch = setSnapshot - stateHook.queue = newQueue - stateHook.baseQueue = nil - snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot) - stateHook.baseState = snapshot - stateHook.memoizedState = stateHook.baseState - end - - return snapshot +function useMutableSource( + hook: Hook, + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > +): Snapshot + local root: FiberRoot = getWorkInProgressRoot() + invariant( + root ~= nil, + "Expected a work-in-progress root. This is a bug in React. Please file an issue." + ) + + local getVersion = source._getVersion + local version_ = getVersion(source._source) + + local dispatcher = ReactCurrentDispatcher.current + -- ROBLOX deviation: upstream doesn't assert non-nil, but we have to for type soundness + assert(dispatcher ~= nil, "dispatcher was nil, this is a bug in React") + -- eslint-disable-next-line prefer-const + local currentSnapshot, setSnapshot = dispatcher.useState(function() + return readFromUnsubcribedMutableSource(root, source, getSnapshot) + end) + local snapshot = currentSnapshot + + -- Grab a handle to the state hook as well. + -- We use it to clear the pending update queue if we have a new source. + + -- ROBLOX TODO: recast local stateHook = ((workInProgressHook: any): Hook) + local stateHook = workInProgressHook + + local memoizedState: MutableSourceMemoizedState = hook.memoizedState + if memoizedState.refs == nil then + error(tostring(debug.traceback())) + end + local refs = memoizedState.refs + local prevGetSnapshot = refs.getSnapshot + local prevSource = memoizedState.source + local prevSubscribe = memoizedState.subscribe + + local fiber = currentlyRenderingFiber + + hook.memoizedState = { + refs = refs, + source = source, + subscribe = subscribe, + } + + -- Sync the values needed by our subscription handler after each commit. + dispatcher.useEffect(function() + refs.getSnapshot = getSnapshot + + -- Normally the dispatch function for a state hook never changes, + -- but this hook recreates the queue in certain cases to avoid updates from stale sources. + -- handleChange() below needs to reference the dispatch function without re-subscribing, + -- so we use a ref to ensure that it always has the latest version. + refs.setSnapshot = setSnapshot + + -- Check for a possible change between when we last rendered now. + local maybeNewVersion = getVersion(source._source) + if not is(version_, maybeNewVersion) then + local maybeNewSnapshot = getSnapshot(source._source) + if __DEV__ then + -- ROBLOX deviation: the Snapshot generic isn't constrained upstream, but it as to be for this typeof() to work + if type(maybeNewSnapshot :: any) == "function" then + console.error( + "Mutable source should not return a function as the snapshot value. " + .. "Functions may close over mutable values and cause tearing." + ) + end + end + + if not is(snapshot, maybeNewSnapshot) then + setSnapshot(maybeNewSnapshot) + + local lane = requestUpdateLane(fiber) + markRootMutableRead(root, lane) + end + -- If the source mutated between render and now, + -- there may be state updates already scheduled from the old source. + -- Entangle the updates so that they render in the same batch. + markRootEntangled(root, root.mutableReadLanes) + end + -- ROBLOX Luau FIXME: Luau doesn't support mixed arrays + end, { getSnapshot, source, subscribe } :: Array) + + -- If we got a new source or subscribe function, re-subscribe in a passive effect. + dispatcher.useEffect(function() + local handleChange = function() + local latestGetSnapshot = refs.getSnapshot + local latestSetSnapshot = refs.setSnapshot + + -- ROBLOX performance? only latestGet..() is throwable. hoist the rest out to eliminate anon func overhead? + local ok, result = pcall(function() + latestSetSnapshot(latestGetSnapshot(source._source)) + + -- Record a pending mutable source update with the same expiration time. + local lane = requestUpdateLane(fiber) + + markRootMutableRead(root, lane) + end) + + if not ok then + -- A selector might throw after a source mutation. + -- e.g. it might try to read from a part of the store that no longer exists. + -- In this case we should still schedule an update with React. + -- Worst case the selector will throw again and then an error boundary will handle it. + latestSetSnapshot(function() + error(result) + end) + end + end + + local unsubscribe = subscribe(source._source, handleChange) + if __DEV__ then + if type(unsubscribe) ~= "function" then + console.error( + "Mutable source subscribe function must return an unsubscribe function." + ) + end + end + + return unsubscribe + -- ROBLOX Luau FIXME: Luau doesn't support mixed arrays + end, { source, subscribe } :: Array) + + -- If any of the inputs to useMutableSource change, reading is potentially unsafe. + -- + -- If either the source or the subscription have changed we can't can't trust the update queue. + -- Maybe the source changed in a way that the old subscription ignored but the new one depends on. + -- + -- If the getSnapshot function changed, we also shouldn't rely on the update queue. + -- It's possible that the underlying source was mutated between the when the last "change" event fired, + -- and when the current render (with the new getSnapshot function) is processed. + -- + -- In both cases, we need to throw away pending updates (since they are no longer relevant) + -- and treat reading from the source as we do in the mount case. + if + not is(prevGetSnapshot, getSnapshot) + or not is(prevSource, source) + or not is(prevSubscribe, subscribe) + then + -- Create a new queue and setState method, + -- So if there are interleaved updates, they get pushed to the older queue. + -- When this becomes current, the previous queue and dispatch method will be discarded, + -- including any interleaving updates that occur. + local newQueue = { + pending = nil, + dispatch = nil, + lastRenderedReducer = basicStateReducer, + lastRenderedState = snapshot, + } + + -- ROBLOX deviation: keep local pointer so if global changes we maintain correct reference. + local cRF = currentlyRenderingFiber + + setSnapshot = function(...) + dispatchAction(cRF, newQueue, ...) + end :: any + newQueue.dispatch = setSnapshot :: any + stateHook.queue = newQueue + stateHook.baseQueue = nil + snapshot = readFromUnsubcribedMutableSource(root, source, getSnapshot) + stateHook.baseState = snapshot + stateHook.memoizedState = stateHook.baseState + end + + return snapshot end --- ROBLOX TODO: function generics --- function mountMutableSource( --- source: MutableSource, --- getSnapshot: MutableSourceGetSnapshotFn, --- subscribe: MutableSourceSubscribeFn, --- ): Snapshot { -function mountMutableSource( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - local hook = mountWorkInProgressHook() - hook.memoizedState = { - refs = { - getSnapshot = getSnapshot, - setSnapshot = nil, - }, - source = source, - subscribe = subscribe, - } - return useMutableSource(hook, source, getSnapshot, subscribe) +function mountMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > +): Snapshot + local hook = mountWorkInProgressHook() + hook.memoizedState = { + refs = { + getSnapshot = getSnapshot, + setSnapshot = nil, + }, + source = source, + subscribe = subscribe, + } + return useMutableSource(hook, source, getSnapshot, subscribe) end --- ROBLOX TODO: function generics --- function updateMutableSource( --- source: MutableSource, --- getSnapshot: MutableSourceGetSnapshotFn, --- subscribe: MutableSourceSubscribeFn, --- ): Snapshot { -function updateMutableSource( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - local hook = updateWorkInProgressHook() - return useMutableSource(hook, source, getSnapshot, subscribe) +function updateMutableSource( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > +): Snapshot + local hook = updateWorkInProgressHook() + return useMutableSource(hook, source, getSnapshot, subscribe) end --- ROBLOX FIXME: Luau function generics and return type --- function mountState( --- initialState: (() => S) | S, --- ): [S, Dispatch>] -function mountState( - initialState: (() -> any) | any -): (any, Dispatch>) - local hook = mountWorkInProgressHook() - if typeof(initialState) == 'function' then - -- $FlowFixMe: Flow doesn't like mixed types - -- deviation: workaround to silence cli analyze not understanding that we've already verified initialState is a function - local initialStateAsFunction: () -> any = initialState - initialState = initialStateAsFunction() - end - hook.baseState = initialState - hook.memoizedState = hook.baseState - hook.queue = { - pending = nil, - dispatch = nil, - lastRenderedReducer = basicStateReducer, - lastRenderedState = initialState - } - local queue = hook.queue - - -- deviation: set currentlyRenderingFiber to a local varible so it doesn't change - -- by call time - local cRF = currentlyRenderingFiber - queue.dispatch = function(...) - return dispatchAction(cRF, queue, (...)) - end - local dispatch = queue.dispatch - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return hook.memoizedState, dispatch +function mountState(initialState: (() -> S) | S): (S, Dispatch>) + local hook = mountWorkInProgressHook() + if type(initialState) == "function" then + -- $FlowFixMe: Flow doesn't like mixed types + -- deviation: workaround to silence cli analyze not understanding that we've already verified initialState is a function + local initialStateAsFunction: () -> S = initialState + initialState = initialStateAsFunction() + end + hook.baseState = initialState + hook.memoizedState = hook.baseState + local queue: UpdateQueue> = { + pending = nil, + dispatch = nil, + lastRenderedReducer = nil, --basicStateReducer, + lastRenderedState = initialState :: any, + } + -- ROBLOX Luau FIXME: work around a toposorting issue in Luau: CLI-48752 + queue.lastRenderedReducer = basicStateReducer + hook.queue = queue + + -- ROBLOX deviation: set currentlyRenderingFiber to a local varible so it doesn't change by call time + local cRF = currentlyRenderingFiber + local dispatch: Dispatch> = function(action, ...) + -- ROBLOX FIXME? we pass in action here, but is that what really happens upstream? + dispatchAction(cRF, queue :: UpdateQueue, action, ...) + end :: any + queue.dispatch = dispatch + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return hook.memoizedState, dispatch end --- ROBLOX FIXME: Luau function generics and return type --- function updateState( --- initialState: (() => S) | S, --- ): [S, Dispatch>] -function updateState( - initialState: (() -> any) | any -): (any, Dispatch>) - return updateReducer(basicStateReducer, initialState) +function updateState(initialState: (() -> S) | S): (S, Dispatch>) + return updateReducer(basicStateReducer, initialState) end --- ROBLOX FIXME: Luau function generics and return type --- function rerenderState( --- initialState: (() => S) | S, --- ): [S, Dispatch>] -function rerenderState( - initialState: (() -> any) | any -): (any, Dispatch>) - return rerenderReducer(basicStateReducer, initialState) +function rerenderState(initialState: (() -> S) | S): (S, Dispatch>) + return rerenderReducer(basicStateReducer, initialState) end local function pushEffect(tag, create, destroy, deps) - local effect: Effect = { - tag = tag, - create = create, - destroy = destroy, - deps = deps, - -- Circular - next = nil, - } - -- FIXME (roblox): type coercion - -- local componentUpdateQueue: FunctionComponentUpdateQueue? = (currentlyRenderingFiber.updateQueue: any) - local componentUpdateQueue = currentlyRenderingFiber.updateQueue - if componentUpdateQueue == nil then - componentUpdateQueue = createFunctionComponentUpdateQueue() - currentlyRenderingFiber.updateQueue = componentUpdateQueue - effect.next = effect - componentUpdateQueue.lastEffect = effect - else - local lastEffect = componentUpdateQueue.lastEffect - if lastEffect == nil then - componentUpdateQueue.lastEffect = effect - effect.next = effect - else - local firstEffect = lastEffect.next - lastEffect.next = effect - effect.next = firstEffect - componentUpdateQueue.lastEffect = effect - end - end - return effect + local effect: Effect = { + tag = tag, + create = create, + destroy = destroy, + deps = deps, + -- Circular + next = nil :: any, + } + local componentUpdateQueue: FunctionComponentUpdateQueue = + currentlyRenderingFiber.updateQueue :: any + if componentUpdateQueue == nil then + -- ROBLOX performance: inline simple function in hot path + -- componentUpdateQueue = createFunctionComponentUpdateQueue() + componentUpdateQueue = { + lastEffect = nil, + } + currentlyRenderingFiber.updateQueue = componentUpdateQueue + effect.next = effect + componentUpdateQueue.lastEffect = effect + else + local lastEffect = componentUpdateQueue.lastEffect + if lastEffect == nil then + componentUpdateQueue.lastEffect = effect + effect.next = effect + else + local firstEffect = lastEffect.next + lastEffect.next = effect + effect.next = firstEffect + componentUpdateQueue.lastEffect = effect + end + end + return effect +end + +-- ROBLOX deviation: Bindings are a feature unique to Roact +function mountBinding(initialValue: T): (ReactBinding, ReactBindingUpdater) + local hook = mountWorkInProgressHook() + local value, updateValue = createBinding(initialValue) + + -- ROBLOX Luau FIXME: Luau doesn't allow mixed arrays, forcing us to use any here + hook.memoizedState = { value :: any, updateValue :: any } + return value, updateValue end --- function mountRef(initialValue: T): {|current: T|} { -function mountRef(initialValue): {current: any} - local hook = mountWorkInProgressHook() - local ref = {current = initialValue} - -- if (__DEV__) then - -- Object.seal(ref) - -- end - hook.memoizedState = ref - return ref +function updateBinding(initialValue: T): (ReactBinding, ReactBindingUpdater) + local hook = updateWorkInProgressHook() + return unpack(hook.memoizedState) end --- function updateRef(initialValue: T): {|current: T|} { -function updateRef(initialValue): {current: any} - local hook = updateWorkInProgressHook() - return hook.memoizedState +function mountRef(initialValue: T): { current: T } + local hook = mountWorkInProgressHook() + -- ROBLOX DEVIATION: Implement useRef with bindings + local ref: any = createRef() + ref.current = initialValue + -- if (__DEV__) then + -- Object.seal(ref) + -- end + hook.memoizedState = ref + return ref end -local function mountEffectImpl(fiberFlags, hookFlags, create, deps) - local hook = mountWorkInProgressHook() - -- deviation: no need to account for undefined - -- local nextDeps = deps == undefined ? nil : deps - local nextDeps = deps - currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, fiberFlags) - - hook.memoizedState = pushEffect( - bit32.bor(HookHasEffect, hookFlags), - create, - nil, - nextDeps - ) +function updateRef(initialValue: T): { current: T } + local hook = updateWorkInProgressHook() + return hook.memoizedState +end + +local function mountEffectImpl(fiberFlags, hookFlags, create, deps): () + local hook = mountWorkInProgressHook() + -- deviation: no need to account for undefined + -- local nextDeps = deps == undefined ? nil : deps + local nextDeps = deps + currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, fiberFlags) + + hook.memoizedState = + pushEffect(bit32.bor(HookHasEffect, hookFlags), create, nil, nextDeps) end -function updateEffectImpl(fiberFlags, hookFlags, create, deps) - local hook = updateWorkInProgressHook() - -- deviation: no need to account for undefined - -- local nextDeps = deps == undefined ? nil : deps - local nextDeps = deps - local destroy - - if currentHook ~= nil then - local prevEffect = currentHook.memoizedState - destroy = prevEffect.destroy - if nextDeps ~= nil then - local prevDeps = prevEffect.deps - if areHookInputsEqual(nextDeps, prevDeps) then - pushEffect(hookFlags, create, destroy, nextDeps) - return - end - end - end - - currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, fiberFlags) - - hook.memoizedState = pushEffect( - bit32.bor(HookHasEffect, hookFlags), - create, - destroy, - nextDeps - ) +-- ROBLOX deviation START: must explicitly mark deps argument as optional/nil-able +function updateEffectImpl(fiberFlags, hookFlags, create, deps: Array?): () + -- ROBLOX deviation END + local hook = updateWorkInProgressHook() + -- ROBLOX deviation: no need to account for undefined + -- local nextDeps = deps == undefined ? nil : deps + local nextDeps = deps + local destroy + + if currentHook ~= nil then + local prevEffect = currentHook.memoizedState + destroy = prevEffect.destroy + if nextDeps ~= nil then + local prevDeps = prevEffect.deps + if areHookInputsEqual(nextDeps, prevDeps) then + hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps) + return + end + end + end + + currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, fiberFlags) + + hook.memoizedState = + pushEffect(bit32.bor(HookHasEffect, hookFlags), create, destroy, nextDeps) end local function mountEffect( - create: () -> (() -> ())?, - deps: Array? -) - if _G.__DEV__ then - -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ as well as jest - -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests - if typeof(_G.jest) ~= "nil" or _G.__TESTEZ_RUNNING_TEST__ then - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber) - end - end - - if _G.__DEV__ and enableDoubleInvokingEffects then - return mountEffectImpl( - bit32.bor(MountPassiveDevEffect, PassiveEffect, PassiveStaticEffect), - HookPassive, - create, - deps - ) - else - return mountEffectImpl( - bit32.bor(PassiveEffect, PassiveStaticEffect), - HookPassive, - create, - deps - ) - end + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + if __DEV__ then + -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ as well as jest + -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests + if type(_G.jest) ~= "nil" or ReactGlobals.__TESTEZ_RUNNING_TEST__ then + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber) + end + end + + if __DEV__ and enableDoubleInvokingEffects then + mountEffectImpl( + bit32.bor(MountPassiveDevEffect, PassiveEffect, PassiveStaticEffect), + HookPassive, + create, + deps + ) + else + mountEffectImpl( + bit32.bor(PassiveEffect, PassiveStaticEffect), + HookPassive, + create, + deps + ) + end end local function updateEffect( - create: () -> (() -> ())?, - deps: Array? -) - if _G.__DEV__ then - -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ in addition to jest - -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests - if typeof(_G.jest) ~= "nil" or _G.__TESTEZ_RUNNING_TEST__ then - warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber) - end - end - return updateEffectImpl( - PassiveEffect, - HookPassive, - create, - deps - ) + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + if __DEV__ then + -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ in addition to jest + -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests + if type(_G.jest) ~= "nil" or ReactGlobals.__TESTEZ_RUNNING_TEST__ then + warnIfNotCurrentlyActingEffectsInDEV(currentlyRenderingFiber) + end + end + updateEffectImpl(PassiveEffect, HookPassive, create, deps) end local function mountLayoutEffect( - create: () -> (() -> ())?, - deps: Array? -) - if _G.__DEV__ and enableDoubleInvokingEffects then - return mountEffectImpl( - bit32.bor(MountLayoutDevEffect, UpdateEffect), - HookLayout, - create, - deps - ) - else - return mountEffectImpl(UpdateEffect, HookLayout, create, deps) - end + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + if __DEV__ and enableDoubleInvokingEffects then + mountEffectImpl( + bit32.bor(MountLayoutDevEffect, UpdateEffect), + HookLayout, + create, + deps + ) + else + mountEffectImpl(UpdateEffect, HookLayout, create, deps) + end end local function updateLayoutEffect( - create: () -> (() -> ())?, - deps: Array? -) - return updateEffectImpl(UpdateEffect, HookLayout, create, deps) + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? +): () + updateEffectImpl(UpdateEffect, HookLayout, create, deps) end --- ROBLOX FIXME: Luau function generics imperativeHandleEffect --- function imperativeHandleEffect( --- create: () => T, --- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, --- ) -function imperativeHandleEffect( - create: () -> any, - ref: {current: any | nil} | (any | nil) -> (any) | nil -) - if typeof(ref) == 'function' then - local refCallback = ref - local inst = create() - refCallback(inst) - return function() - return refCallback(nil) - end - elseif ref ~= nil then - local refObject = ref - -- ROBLOX deviation: can't check for key presence because nil is a legitimate value. - -- if _G.__DEV__ then - -- -- ROBLOX FIXME: upstream uses hasOwnProperty, is this an OK translation? - -- if rawget(refObject, 'current') == nil then - -- local keyset = {} - -- local n = 0 - -- for k, _ in pairs(refObject) do - -- n = n + 1 - -- keyset[n]=k - -- end - -- console.error( - -- 'Expected useImperativeHandle() first argument to either be a ' .. - -- 'ref callback or React.createRef() object. Instead received: %s.', - -- 'an object with keys {' .. table.concat(keyset, ", ") .. '}' - -- ) - -- end - -- end - local inst = create() - refObject.current = inst - return function() - refObject.current = nil - end - -- deviation: explicit return to silence analyze - else - return - end +function imperativeHandleEffect( + create: () -> T, + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil + -- ROBLOX deviation: explicit type annotation needed due to mixed return +): nil | () -> ...any + if ref ~= nil and type(ref) == "function" then + local refCallback = ref + local inst = create() + refCallback(inst) + return function() + return refCallback(nil) + end + elseif ref ~= nil then + local refObject = ref :: any + -- ROBLOX deviation: can't check for key presence because nil is a legitimate value. + if __DEV__ then + -- ROBLOX FIXME: This is a clumsy approximation, since we don't have any + -- explicit way to know that something is a ref object; instead, we check + -- that it's an empty object with a metatable, which is what Roact refs + -- look like since they indirect to bindings via their metatable + local isRefObject = getmetatable(refObject) ~= nil + and #Object.keys(refObject) == 0 + if not isRefObject then + console.error( + "Expected useImperativeHandle() first argument to either be a " + .. "ref callback or React.createRef() object. Instead received: %s.", + "an object with keys {" + .. Array.join(Object.keys(refObject), ", ") + .. "}" + ) + end + end + local inst = create() + refObject.current = inst + return function() + refObject.current = nil + end + -- deviation: explicit return to silence analyze + else + return nil + end end --- ROBLOX FIXME: function generics mountImperativeHandle --- function mountImperativeHandle( --- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, --- create: () => T, --- deps: Array | void | null, --- ): void -function mountImperativeHandle( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: {any?}? -) - if _G.__DEV__ then - if typeof(create) ~= 'function' then - local errorArg = 'nil' - if create then - errorArg = typeof(create) - end - console.error( - 'Expected useImperativeHandle() second argument to be a function ' .. - 'that creates a handle. Instead received: %s.', - errorArg - ) - end - end - -- TODO: If deps are provided, should we skip comparing the ref itself? - -- deviation: ternary turned to explicit if/else - local effectDeps - if deps ~= nil then - effectDeps = Cryo.List.join(deps, {ref}) - end - - if _G.__DEV__ and enableDoubleInvokingEffects then - return mountEffectImpl( - bit32.bor(MountLayoutDevEffect, UpdateEffect), - HookLayout, - function() - return imperativeHandleEffect(create, ref) - end, - effectDeps - ) - else - return mountEffectImpl( - UpdateEffect, - HookLayout, - function() - return imperativeHandleEffect(create, ref) - end, - effectDeps - ) - end +function mountImperativeHandle( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil +): () + if __DEV__ then + if type(create) ~= "function" then + console.error( + "Expected useImperativeHandle() second argument to be a function " + .. "that creates a handle. Instead received: %s.", + -- ROBLOX deviation START: nil instead of null + if create ~= nil then type(create) else "nil" + -- ROBLOX deviation END + ) + end + end + -- TODO: If deps are provided, should we skip comparing the ref itself? + local effectDeps = if deps ~= nil then Array.concat(deps, { ref }) else nil + + if __DEV__ and enableDoubleInvokingEffects then + return mountEffectImpl( + bit32.bor(MountLayoutDevEffect, UpdateEffect), + HookLayout, + function() + return imperativeHandleEffect(create, ref) + end, + effectDeps + ) + else + return mountEffectImpl(UpdateEffect, HookLayout, function() + return imperativeHandleEffect(create, ref) + end, effectDeps) + end end --- ROBLOX FIXME: function generics updateImperativeHandle --- function updateImperativeHandle( --- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, --- create: () => T, --- deps: Array | void | null, --- ): void -function updateImperativeHandle( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array | nil -) - if _G.__DEV__ then - if typeof(create) ~= 'function' then - local errorArg = 'nil' - if create then - errorArg = typeof(create) - end - console.error( - 'Expected useImperativeHandle() second argument to be a function ' .. - 'that creates a handle. Instead received: %s.', - errorArg - ) - end - end - - -- TODO: If deps are provided, should we skip comparing the ref itself? - -- ROBLOX deviation: ternary turned to explicit if/else - local effectDeps - if deps ~= nil then - effectDeps = Cryo.List.join(deps, {ref}) - end - - return updateEffectImpl( - UpdateEffect, - HookLayout, - function() - return imperativeHandleEffect(create, ref) - end, - effectDeps - ) +function updateImperativeHandle( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil +): () + if __DEV__ then + if type(create) ~= "function" then + local errorArg = "nil" + if create then + errorArg = type(create) + end + console.error( + "Expected useImperativeHandle() second argument to be a function " + .. "that creates a handle. Instead received: %s.", + errorArg + ) + end + end + + -- TODO: If deps are provided, should we skip comparing the ref itself? + -- ROBLOX deviation: ternary turned to explicit if/else + local effectDeps + if deps ~= nil then + effectDeps = table.clone(deps) + table.insert(effectDeps, ref) + end + + return updateEffectImpl(UpdateEffect, HookLayout, function() + return imperativeHandleEffect(create, ref) + end, effectDeps) end -function mountDebugValue(value, formatterFn: nil | (any) -> any) - -- This hook is normally a no-op. - -- The react-debug-hooks package injects its own implementation - -- so that e.g. DevTools can display custom hook values. +function mountDebugValue(value: T, formatterFn: nil | (T) -> any): () + -- This hook is normally a no-op. + -- The react-debug-hooks package injects its own implementation + -- so that e.g. DevTools can display custom hook values. end local updateDebugValue = mountDebugValue -function mountCallback(callback, deps: Array | nil): any - local hook = mountWorkInProgressHook() - local nextDeps = deps - hook.memoizedState = {callback, nextDeps} - return callback +function mountCallback(callback: T, deps: Array | nil): T + local hook = mountWorkInProgressHook() + local nextDeps = deps + -- ROBLOX Luau FIXME: Luau doesn't allow mixed arrays, forcing us to use any here + hook.memoizedState = { callback :: any, nextDeps :: any } + return callback end -function updateCallback(callback, deps: Array | nil) - local hook = updateWorkInProgressHook() - local nextDeps = deps - local prevState = hook.memoizedState - if prevState ~= nil then - if nextDeps ~= nil then - -- ROBLOX TODO: Luau false positive when this is `Array?` (E001) Type 'Array?' could not be converted into 'Array' - local prevDeps: Array = prevState[2] - if areHookInputsEqual(nextDeps, prevDeps) then - return prevState[1] - end - end - end - hook.memoizedState = {callback, nextDeps} - return callback +function updateCallback(callback: T, deps: Array | nil): T + local hook = updateWorkInProgressHook() + local nextDeps = deps + local prevState = hook.memoizedState + if prevState ~= nil then + if nextDeps ~= nil then + -- ROBLOX TODO: Luau false positive when this is `Array?` (E001) Type 'Array?' could not be converted into 'Array' + local prevDeps: Array = prevState[2] + if areHookInputsEqual(nextDeps, prevDeps) then + return prevState[1] + end + end + end + -- ROBLOX Luau FIXME: Luau doesn't allow mixed arrays, forcing us to use any here + hook.memoizedState = { callback :: any, nextDeps :: any } + return callback end --- ROBLOX TODO: function generics --- function mountMemo( --- nextCreate: () => T, --- deps: Array | nil, --- ): T { -function mountMemo( - nextCreate: () -> any, - deps: Array | nil -): any - local hook = mountWorkInProgressHook() +-- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 +function mountMemo(nextCreate: () -> T..., deps: Array | nil): ...any + local hook = mountWorkInProgressHook() - -- deviation: equivilant to upstream ternary logic - local nextDeps = deps - local nextValue = nextCreate() - hook.memoizedState = {nextValue, nextDeps} - return nextValue + -- deviation: equivilant to upstream ternary logic + local nextDeps = deps + -- ROBLOX DEVIATION: Wrap memoized values in a table and unpack to allow for multiple return values + local nextValue = { nextCreate() } + hook.memoizedState = { nextValue :: any, nextDeps } + return unpack(nextValue) end --- ROBLOX TODO: function generics --- function updateMemo( --- nextCreate: () => T, --- deps: Array | nil, --- ): T { -function updateMemo( - nextCreate: () -> any, - deps: Array | nil -): any - local hook = updateWorkInProgressHook() - -- deviation: equivilant to upstream ternary logic - local nextDeps = deps - local prevState = hook.memoizedState - if prevState ~= nil then - -- Assume these are defined. If they're not, areHookInputsEqual will warn. - if nextDeps ~= nil then - local prevDeps: Array = prevState[2] - if areHookInputsEqual(nextDeps, prevDeps) then - return prevState[1] - end - end - end - local nextValue = nextCreate() - hook.memoizedState = {nextValue, nextDeps} - return nextValue +-- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 +function updateMemo(nextCreate: () -> T..., deps: Array | nil): ...any + local hook = updateWorkInProgressHook() + -- deviation: equivilant to upstream ternary logic + local nextDeps = deps + local prevState = hook.memoizedState + if prevState ~= nil then + -- Assume these are defined. If they're not, areHookInputsEqual will warn. + if nextDeps ~= nil then + local prevDeps: Array = prevState[2] + if areHookInputsEqual(nextDeps, prevDeps) then + return unpack(prevState[1]) + end + end + end + -- ROBLOX DEVIATION: Wrap memoized values in a table and unpack to allow for multiple return values + local nextValue = { nextCreate() } + hook.memoizedState = { nextValue :: any, nextDeps } + return unpack(nextValue) end -- function mountDeferredValue(value: T): T { @@ -1691,14 +1695,14 @@ end local isUpdatingOpaqueValueInRenderPhase = false exports.getIsUpdatingOpaqueValueInRenderPhaseInDEV = function(): boolean? - if _G.__DEV__ then - return isUpdatingOpaqueValueInRenderPhase - end - return nil + if __DEV__ then + return isUpdatingOpaqueValueInRenderPhase + end + return nil end -- function warnOnOpaqueIdentifierAccessInDEV(fiber) --- if _G.__DEV__ then +-- if __DEV__ then -- -- TODO: Should warn in effects and callbacks, too -- local name = getComponentName(fiber.type) or 'Unknown' -- if getIsRendering() and not didWarnAboutUseOpaqueIdentifier[name] then @@ -1713,1711 +1717,1572 @@ end -- end function mountOpaqueIdentifier() - local makeId - if _G.__DEV__ then - unimplemented("mountOpauqeIdentigier") - -- makeId = makeClientIdInDEV.bind( - -- nil, - -- warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber), - -- ) - else - makeId = makeClientId - end - - if getIsHydrating() then - unimplemented("ReactFiberHooks: getIsHydrating() true") - return nil - -- local didUpgrade = false - -- local fiber = currentlyRenderingFiber - -- local readValue = function() - -- if not didUpgrade then - -- -- Only upgrade once. This works even inside the render phase because - -- -- the update is added to a shared queue, which outlasts the - -- -- in-progress render. - -- didUpgrade = true - -- if _G.__DEV__ then - -- isUpdatingOpaqueValueInRenderPhase = true - -- setId(makeId()) - -- isUpdatingOpaqueValueInRenderPhase = false - -- warnOnOpaqueIdentifierAccessInDEV(fiber) - -- else - -- setId(makeId()) - -- end - -- end - -- invariant( - -- false, - -- 'The object passed back from useOpaqueIdentifier is meant to be ' .. - -- 'passed through to attributes only. Do not read the value directly.' - -- ) - -- end - -- local id = makeOpaqueHydratingObject(readValue) - - -- local setId = mountState(id)[1] - - -- if bit32.band(currentlyRenderingFiber.mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then - -- if _G.__DEV__ and enableDoubleInvokingEffects then - -- currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, - -- MountPassiveDevEffect, PassiveEffect, PassiveStaticEffect) - -- else - -- currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, - -- PassiveEffect, PassiveStaticEffect) - -- end - -- pushEffect( - -- bit32.bor(HookHasEffect, HookPassive), - -- function() - -- setId(makeId()) - -- end, - -- nil, - -- nil - -- ) - -- end - -- return id - else - local id = makeId() - mountState(id) - return id - end + local makeId + if __DEV__ then + console.warn("!!! unimplemented: warnOnOpaqueIdentifierAccessInDEV") + -- makeId = makeClientIdInDEV.bind( + -- nil, + -- warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber), + -- ) + else + makeId = makeClientId + end + + if getIsHydrating() then + unimplemented("ReactFiberHooks: getIsHydrating() true") + return nil + -- local didUpgrade = false + -- local fiber = currentlyRenderingFiber + -- local readValue = function() + -- if not didUpgrade then + -- -- Only upgrade once. This works even inside the render phase because + -- -- the update is added to a shared queue, which outlasts the + -- -- in-progress render. + -- didUpgrade = true + -- if __DEV__ then + -- isUpdatingOpaqueValueInRenderPhase = true + -- setId(makeId()) + -- isUpdatingOpaqueValueInRenderPhase = false + -- warnOnOpaqueIdentifierAccessInDEV(fiber) + -- else + -- setId(makeId()) + -- end + -- end + -- invariant( + -- false, + -- 'The object passed back from useOpaqueIdentifier is meant to be ' .. + -- 'passed through to attributes only. Do not read the value directly.' + -- ) + -- end + -- local id = makeOpaqueHydratingObject(readValue) + + -- local setId = mountState(id)[1] + + -- if bit32.band(currentlyRenderingFiber.mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then + -- if __DEV__ and enableDoubleInvokingEffects then + -- currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, + -- MountPassiveDevEffect, PassiveEffect, PassiveStaticEffect) + -- else + -- currentlyRenderingFiber.flags = bit32.bor(currentlyRenderingFiber.flags, + -- PassiveEffect, PassiveStaticEffect) + -- end + -- pushEffect( + -- bit32.bor(HookHasEffect, HookPassive), + -- function() + -- setId(makeId()) + -- end, + -- nil, + -- nil + -- ) + -- end + -- return id + else + local id = makeId() + mountState(id) + return id + end end --- function updateOpaqueIdentifier(): OpaqueIDType { --- local id = updateState(undefined)[0] --- return id --- end +function updateOpaqueIdentifier(): OpaqueIDType + local id, _ = updateState(nil) + return id +end --- function rerenderOpaqueIdentifier(): OpaqueIDType { --- local id = rerenderState(undefined)[0] --- return id --- end +function rerenderOpaqueIdentifier(): OpaqueIDType + local id, _ = rerenderState(nil) + return id +end --- ROBLOX FIXME: Luau function generics --- function dispatchAction( --- fiber: Fiber, --- queue: UpdateQueue, --- action: A, --- ) -function dispatchAction( - fiber: Fiber, - queue: UpdateQueue, - action: any, - extraArg: any? -) - -- deviation: use extraArg to catch if call was given an extra vs counting total args as upstream does - if _G.__DEV__ then - if typeof(extraArg) == 'function' then - console.error( - "State updates from the useState() and useReducer() Hooks don't support the " .. - 'second callback argument. To execute a side effect after ' .. - 'rendering, declare it in the component body with useEffect().' - ) - end - end - - local eventTime = requestEventTime() - local lane = requestUpdateLane(fiber) - - local update: Update = { - lane = lane, - action = action, - eagerReducer = nil, - eagerState = nil, - next = nil - } - - -- Append the update to the end of the list. - local pending = queue.pending - if pending == nil then - -- This is the first update. Create a circular list. - update.next = update - else - update.next = pending.next - pending.next = update - end - queue.pending = update - - local alternate = fiber.alternate - if - fiber == currentlyRenderingFiber or - (alternate ~= nil and alternate == currentlyRenderingFiber) - then - -- This is a render phase update. Stash it in a lazily-created map of - -- queue -> linked list of updates. After this render pass, we'll restart - -- and apply the stashed updates on top of the work-in-progress hook. - didScheduleRenderPhaseUpdate = true - didScheduleRenderPhaseUpdateDuringThisPass = true - else - if - fiber.lanes == NoLanes and - (alternate == nil or alternate.lanes == NoLanes) - then - -- The queue is currently empty, which means we can eagerly compute the - -- next state before entering the render phase. If the new state is the - -- same as the current state, we may be able to bail out entirely. - local lastRenderedReducer = queue.lastRenderedReducer - if lastRenderedReducer ~= nil then - local prevDispatcher - if _G.__DEV__ then - prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - end - -- ROBLOX try - local ok, result = pcall(function() - local currentState = queue.lastRenderedState - local eagerState = lastRenderedReducer(currentState, action) - -- Stash the eagerly computed state, and the reducer used to compute - -- it, on the update object. If the reducer hasn't changed by the - -- time we enter the render phase, then the eager state can be used - -- without calling the reducer again. - update.eagerReducer = lastRenderedReducer - update.eagerState = eagerState - if is(eagerState, currentState) then - -- Fast path. We can bail out without scheduling React to re-render. - -- It's still possible that we'll need to rebase this update later, - -- if the component re-renders for a different reason and by that - -- time the reducer has changed. - - -- deviation: workaround for pcall() format - return "Bail" - -- deviation: explicit return to silence analyze - end - return - end) - -- ROBLOX finally - if _G.__DEV__ then - ReactCurrentDispatcher.current = prevDispatcher - end - if ok and result == "Bail" then - return - elseif ok then - -- Left pcall without error, don't return - else - -- ROBLOX catch - -- Suppress the error. It will throw again in the render phase. - end - end - end - if _G.__DEV__ then - -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests - -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ as well as jest - if typeof(_G.jest) ~= "nil" or _G.__TESTEZ_RUNNING_TEST__ then - warnIfNotScopedWithMatchingAct(fiber) - warnIfNotCurrentlyActingUpdatesInDEV(fiber) - end - end - scheduleUpdateOnFiber(fiber, lane, eventTime) - end - - if _G.__DEV__ then - if enableDebugTracing then - if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then - local _name = getComponentName(fiber.type) or 'Unknown' - warn("Skip unimplemented: logStateUpdateScheduled") - -- logStateUpdateScheduled(name, lane, action) - end - end - end - - if enableSchedulingProfiler then - markStateUpdateScheduled(fiber, lane) - end - - return +function dispatchAction(fiber: Fiber, queue: UpdateQueue, action: A, ...): () + if __DEV__ then + local childrenLength = select("#", ...) + local extraArg + if childrenLength == 1 then + extraArg = select(1, ...) + end + if type(extraArg) == "function" then + console.error( + "State updates from the useState() and useReducer() Hooks don't support the " + .. "second callback argument. To execute a side effect after " + .. "rendering, declare it in the component body with useEffect()." + ) + end + end + + local eventTime = requestEventTime() + local lane = requestUpdateLane(fiber) + + local update: Update = { + lane = lane, + action = action, + eagerReducer = nil, + eagerState = nil, + next = nil :: any, + } + + -- Append the update to the end of the list. + local pending = queue.pending + if pending == nil then + -- This is the first update. Create a circular list. + update.next = update + else + update.next = pending.next + pending.next = update + end + queue.pending = update + + local alternate = fiber.alternate + if + fiber == currentlyRenderingFiber + or (alternate ~= nil and alternate == currentlyRenderingFiber) + then + -- This is a render phase update. Stash it in a lazily-created map of + -- queue -> linked list of updates. After this render pass, we'll restart + -- and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true + didScheduleRenderPhaseUpdateDuringThisPass = true + else + if + fiber.lanes == NoLanes and (alternate == nil or alternate.lanes == NoLanes) + then + -- The queue is currently empty, which means we can eagerly compute the + -- next state before entering the render phase. If the new state is the + -- same as the current state, we may be able to bail out entirely. + local lastRenderedReducer = queue.lastRenderedReducer + if lastRenderedReducer ~= nil then + local prevDispatcher + if __DEV__ then + prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = + InvalidNestedHooksDispatcherOnUpdateInDEV + end + -- ROBLOX try + local currentState: S = queue.lastRenderedState :: any + -- ROBLOX performance: only wrap the thing that can throw in a pcall to elimiante anon function creation overhead + local ok, eagerState = pcall(lastRenderedReducer, currentState, action) + -- Stash the eagerly computed state, and the reducer used to compute + -- it, on the update object. If the reducer hasn't changed by the + -- time we enter the render phase, then the eager state can be used + -- without calling the reducer again. + if ok then + update.eagerReducer = lastRenderedReducer + update.eagerState = eagerState + end + + -- ROBLOX finally + if __DEV__ then + ReactCurrentDispatcher.current = prevDispatcher + end + + if is(eagerState, currentState) then + -- Fast path. We can bail out without scheduling React to re-render. + -- It's still possible that we'll need to rebase this update later, + -- if the component re-renders for a different reason and by that + -- time the reducer has changed. + + -- First remove the update just added to prevent unbounded queue growth. + if FFlagReactCleanQueueOnUpdateBailout then + if pending == nil then + queue.pending = nil + else + pending.next = update.next + queue.pending = pending + end + end + + return + end + -- ROBLOX catch + if not ok then + -- Suppress the error. It will throw again in the render phase. + end + end + end + if __DEV__ then + -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests + -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ as well as jest + if type(_G.jest) ~= "nil" or ReactGlobals.__TESTEZ_RUNNING_TEST__ then + warnIfNotScopedWithMatchingAct(fiber) + warnIfNotCurrentlyActingUpdatesInDEV(fiber) + end + end + scheduleUpdateOnFiber(fiber, lane, eventTime) + end + + if __DEV__ then + if enableDebugTracing then + if bit32.band(fiber.mode, DebugTracingMode) ~= 0 then + local name = getComponentName(fiber.type) or "Unknown" + logStateUpdateScheduled(name, lane, action) + end + end + end + + if enableSchedulingProfiler then + markStateUpdateScheduled(fiber, lane) + end + + return end -- deviation: Move these to the top so they're in scope for above functions local ContextOnlyDispatcher: Dispatcher = { - readContext = readContext, - - useCallback = throwInvalidHookError, - useContext = throwInvalidHookError, - useEffect = throwInvalidHookError, - useImperativeHandle = throwInvalidHookError, - useLayoutEffect = throwInvalidHookError, - useMemo = throwInvalidHookError, - useReducer = throwInvalidHookError, - useRef = throwInvalidHookError, - useState = throwInvalidHookError, - useDebugValue = throwInvalidHookError, - useDeferredValue = throwInvalidHookError, - useTransition = throwInvalidHookError, - useMutableSource = throwInvalidHookError, - useOpaqueIdentifier = throwInvalidHookError, - - unstable_isNewReconciler = enableNewReconciler, + readContext = readContext, + + useCallback = throwInvalidHookError :: any, + useContext = throwInvalidHookError :: any, + useEffect = throwInvalidHookError :: any, + useImperativeHandle = throwInvalidHookError :: any, + useLayoutEffect = throwInvalidHookError :: any, + useMemo = throwInvalidHookError :: any, + useReducer = throwInvalidHookError :: any, + useRef = throwInvalidHookError :: any, + useBinding = throwInvalidHookError :: any, + useState = throwInvalidHookError :: any, + useDebugValue = throwInvalidHookError :: any, + -- useDeferredValue = throwInvalidHookError, + -- useTransition = throwInvalidHookError, + useMutableSource = throwInvalidHookError :: any, + useOpaqueIdentifier = throwInvalidHookError :: any, + + unstable_isNewReconciler = enableNewReconciler, } exports.ContextOnlyDispatcher = ContextOnlyDispatcher local HooksDispatcherOnMount: Dispatcher = { - readContext = readContext, - - useCallback = mountCallback, - useContext = readContext, - useEffect = mountEffect, - useImperativeHandle = mountImperativeHandle, - useLayoutEffect = mountLayoutEffect, - useMemo = mountMemo, - useReducer = mountReducer, - useRef = mountRef, - useState = mountState, - -- useDebugValue = mountDebugValue, - -- useDeferredValue = mountDeferredValue, - -- useTransition = mountTransition, - useMutableSource = mountMutableSource, - useOpaqueIdentifier = mountOpaqueIdentifier, - - unstable_isNewReconciler = enableNewReconciler, + readContext = readContext, + + useCallback = mountCallback, + useContext = readContext, + useEffect = mountEffect, + useImperativeHandle = mountImperativeHandle, + useLayoutEffect = mountLayoutEffect, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = mountMemo :: any, + useReducer = mountReducer, + useRef = mountRef, + useBinding = mountBinding, + useState = mountState, + useDebugValue = mountDebugValue, + -- useDeferredValue = mountDeferredValue, + -- useTransition = mountTransition, + useMutableSource = mountMutableSource, + useOpaqueIdentifier = mountOpaqueIdentifier, + + unstable_isNewReconciler = enableNewReconciler, } local HooksDispatcherOnUpdate: Dispatcher = { - readContext = readContext, - - useCallback = updateCallback, - useContext = readContext, - useEffect = updateEffect, - useImperativeHandle = updateImperativeHandle, - useLayoutEffect = updateLayoutEffect, - useMemo = updateMemo, - useReducer = updateReducer, - useRef = updateRef, - useState = updateState, - useDebugValue = updateDebugValue, - -- useDeferredValue = updateDeferredValue, - -- useTransition = updateTransition, - useMutableSource = updateMutableSource, - -- useOpaqueIdentifier = updateOpaqueIdentifier, - - unstable_isNewReconciler = enableNewReconciler, + readContext = readContext, + + useCallback = updateCallback, + useContext = readContext, + useEffect = updateEffect, + useImperativeHandle = updateImperativeHandle, + useLayoutEffect = updateLayoutEffect, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = updateMemo :: any, + useReducer = updateReducer, + useRef = updateRef, + useBinding = updateBinding, + useState = updateState, + useDebugValue = updateDebugValue, + -- useDeferredValue = updateDeferredValue, + -- useTransition = updateTransition, + useMutableSource = updateMutableSource, + useOpaqueIdentifier = updateOpaqueIdentifier, + + unstable_isNewReconciler = enableNewReconciler, } local HooksDispatcherOnRerender: Dispatcher = { - readContext = readContext, - - useCallback = updateCallback, - useContext = readContext, - useEffect = updateEffect, - useImperativeHandle = updateImperativeHandle, - useLayoutEffect = updateLayoutEffect, - useMemo = updateMemo, - useReducer = rerenderReducer, - useRef = updateRef, - useState = rerenderState, - useDebugValue = updateDebugValue, - -- useDeferredValue = rerenderDeferredValue, - -- useTransition = rerenderTransition, - useMutableSource = updateMutableSource, - -- useOpaqueIdentifier = rerenderOpaqueIdentifier, - - unstable_isNewReconciler = enableNewReconciler, + readContext = readContext, + + useCallback = updateCallback, + useContext = readContext, + useEffect = updateEffect, + useImperativeHandle = updateImperativeHandle, + useLayoutEffect = updateLayoutEffect, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = updateMemo :: any, + useReducer = rerenderReducer, + useRef = updateRef, + useBinding = updateBinding, + useState = rerenderState, + useDebugValue = updateDebugValue, + -- useDeferredValue = rerenderDeferredValue, + -- useTransition = rerenderTransition, + useMutableSource = updateMutableSource, + useOpaqueIdentifier = rerenderOpaqueIdentifier, + + unstable_isNewReconciler = enableNewReconciler, } -if _G.__DEV__ then - local warnInvalidContextAccess = function() - console.error( - 'Context can only be read while React is rendering. ' .. - 'In classes, you can read it in the render method or getDerivedStateFromProps. ' .. - 'In function components, you can read it directly in the function body, but not ' .. - 'inside Hooks like useReducer() or useMemo().' - ) - end - - local warnInvalidHookAccess = function() - console.error( - 'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. ' .. - 'You can only call Hooks at the top level of your React function. ' .. - 'For more information, see ' .. - 'https://reactjs.org/link/rules-of-hooks' - ) - end - - HooksDispatcherOnMountInDEV = { - readContext = function( - context: ReactContext, - observedBits: number | boolean - ) - return readContext(context, observedBits) - end, - useCallback = function(callback, deps: Array | nil): any - currentHookNameInDev = 'useCallback' - mountHookTypesDev() - checkDepsAreArrayDev(deps) - return mountCallback(callback, deps) - end, - useContext = function( - context: ReactContext, - observedBits: number | boolean - ): any - currentHookNameInDev = 'useContext' - mountHookTypesDev() - return readContext(context, observedBits) - end, - useEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = "useEffect" - mountHookTypesDev() - checkDepsAreArrayDev(deps) - return mountEffect(create, deps) - end, - -- ROBLOX FIXME: function generics - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array | nil - ) - currentHookNameInDev = 'useImperativeHandle' - mountHookTypesDev() - checkDepsAreArrayDev(deps) - return mountImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - mountHookTypesDev() - checkDepsAreArrayDev(deps) - return mountLayoutEffect(create, deps) - end, - -- ROBLOX TODO: function generics - -- useMemo(create: () => T, deps: Array | nil): T { - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - mountHookTypesDev() - checkDepsAreArrayDev(deps) - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result = pcall(function() - return mountMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - mountHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result, setResult = pcall(function() - return mountReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, --- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - mountHookTypesDev() - return mountRef(initialValue) - end, - -- FIXME ROBLOX: Luau function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: (() -> any?) | any? - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - mountHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - -- deviation: Lua version of mountState return two items, not list like upstream. - local ok, result, setResult = pcall(function() - return mountState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- 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 { --- currentHookNameInDev = 'useDebugValue' --- mountHookTypesDev() --- return mountDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- mountHookTypesDev() --- return mountDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- mountHookTypesDev() --- return mountTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - mountHookTypesDev() - return mountMutableSource(source, getSnapshot, subscribe) - end, - useOpaqueIdentifier = function() - currentHookNameInDev = 'useOpaqueIdentifier' - mountHookTypesDev() - return mountOpaqueIdentifier() - end, - - unstable_isNewReconciler = enableNewReconciler, - } - - HooksDispatcherOnMountWithHookTypesInDEV = { - -- ROBLOX FIXME: Luau function generics and return - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean - ) - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil): any - currentHookNameInDev = 'useCallback' - updateHookTypesDev() - checkDepsAreArrayDev(deps) - return mountCallback(callback, deps) - end, - -- ROBLOX FIXME: Luau function generics and return type - -- useContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - useContext = function( - context: ReactContext, - observedBits: number | boolean - ) - currentHookNameInDev = 'useContext' - updateHookTypesDev() - return readContext(context, observedBits) - end, - useEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = "useEffect" - updateHookTypesDev() - return mountEffect(create, deps) - end, - -- ROBLOX FIXME: Luau function generics - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array? - ) - currentHookNameInDev = 'useImperativeHandle' - updateHookTypesDev() - return mountImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - updateHookTypesDev() - return mountLayoutEffect(create, deps) - end, - -- ROBLOX TODO: function generics - -- useMemo(create: () => T, deps: Array | nil): T { - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result = pcall(function() - return mountMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result, setResult = pcall(function() - return mountReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, - -- FIXME ROBLOX: function generics and return type - -- useRef(initialValue: T): {|current: T|} - useRef = function(initialValue: any?): {current: any} - currentHookNameInDev = 'useRef' - updateHookTypesDev() - return mountRef(initialValue) - end, - -- FIXME ROBLOX: function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - -- deviation: Lua version of mountState return two items, not list like upstream - local ok, result, setResult = pcall(function() - return mountState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of mountState return two items, not list like upstream - return result, setResult - end, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { --- currentHookNameInDev = 'useDebugValue' --- updateHookTypesDev() --- return mountDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- updateHookTypesDev() --- return mountDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- updateHookTypesDev() --- return mountTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - updateHookTypesDev() - return mountMutableSource(source, getSnapshot, subscribe) - end, - useOpaqueIdentifier = function() - currentHookNameInDev = 'useOpaqueIdentifier' - updateHookTypesDev() - return mountOpaqueIdentifier() - end, - --- unstable_isNewReconciler: enableNewReconciler, - } - - HooksDispatcherOnUpdateInDEV = { - -- ROBLOX FIXME: Luau function generics and return type - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean - ) - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil): any - currentHookNameInDev = 'useCallback' - updateHookTypesDev() - return mountCallback(callback, deps) - end, - -- ROBLOX FIXME: Luau function generics and return type - -- useContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - useContext = function( - context: ReactContext, - observedBits: number | boolean | nil - ) - currentHookNameInDev = 'useContext' - updateHookTypesDev() - return readContext(context, observedBits) - end, - useEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = "useEffect" - updateHookTypesDev() - return updateEffect(create, deps) - end, - -- ROBLOX FIXME: function generics - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array | nil - ) - currentHookNameInDev = 'useImperativeHandle' - updateHookTypesDev() - return updateImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - updateHookTypesDev() - return updateLayoutEffect(create, deps) - end, - -- ROBLOX TODO: function generics - -- useMemo(create: () => T, deps: Array | nil): T { - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result = pcall(function() - return updateMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result, setResult = pcall(function() - return updateReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, --- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - updateHookTypesDev() - return updateRef(initialValue) - end, - -- FIXME (roblox): function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - -- deviation: Lua version of updateState returns two items, not list like upstream - local ok, result, setResult = pcall(function() - return updateState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState returns two items, not list like upstream - return result, setResult - end, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { --- currentHookNameInDev = 'useDebugValue' --- updateHookTypesDev() --- return updateDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- updateHookTypesDev() --- return updateDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- updateHookTypesDev() --- return updateTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - updateHookTypesDev() - return updateMutableSource(source, getSnapshot, subscribe) - end, --- useOpaqueIdentifier(): OpaqueIDType { --- currentHookNameInDev = 'useOpaqueIdentifier' --- updateHookTypesDev() --- return updateOpaqueIdentifier() --- }, - --- unstable_isNewReconciler: enableNewReconciler, - } - - HooksDispatcherOnRerenderInDEV = { - -- ROBLOX FIXME: function generics and return type - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean | nil - ) - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil): any - currentHookNameInDev = 'useCallback' - updateHookTypesDev() - return mountCallback(callback, deps) - end, - -- ROBLOX FIXME: function generics and return type - -- useContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - useContext = function( - context: ReactContext, - observedBits: number | boolean | nil - ) - currentHookNameInDev = 'useContext' - updateHookTypesDev() - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: function generics and return type - -- useEffect( - -- create: () => (() => void), - -- deps: Array | nil, - -- ): void { - useEffect = function( - create: () -> (() -> ()), - deps: Array | nil - ): () - currentHookNameInDev = 'useEffect' - updateHookTypesDev() - return updateEffect(create, deps) - end, - -- ROBLOX FIXME: function generics and return type useImperativeHandle() - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array? - ) - currentHookNameInDev = 'useImperativeHandle' - updateHookTypesDev() - return updateImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ())?, - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - updateHookTypesDev() - return updateLayoutEffect(create, deps) - end, - -- ROBLOX TODO: function generics - - -- useMemo(create: () => T, deps: Array | nil): T { - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV - local ok, result = pcall(function() - return updateMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV - local ok, result, setResult = pcall(function() - return rerenderReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, - -- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - updateHookTypesDev() - return updateRef(initialValue) - end, - -- ROBLOX FIXME: luau function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV - -- deviation: Lua version of useState returns two items, not list like upstream - local ok, result, setResult = pcall(function() - return rerenderState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState returns two items, not list like upstream - return result, setResult - end, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { --- currentHookNameInDev = 'useDebugValue' --- updateHookTypesDev() --- return updateDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- updateHookTypesDev() --- return rerenderDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- updateHookTypesDev() --- return rerenderTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - updateHookTypesDev() - return updateMutableSource(source, getSnapshot, subscribe) - end, --- useOpaqueIdentifier(): OpaqueIDType { --- currentHookNameInDev = 'useOpaqueIdentifier' --- updateHookTypesDev() --- return rerenderOpaqueIdentifier() --- }, - --- unstable_isNewReconciler: enableNewReconciler, - } - - InvalidNestedHooksDispatcherOnMountInDEV = { - -- ROBLOX FIXME generics - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean - ) - warnInvalidContextAccess() - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil) - currentHookNameInDev = 'useCallback' - warnInvalidHookAccess() - mountHookTypesDev() - return mountCallback(callback, deps) - end, - useContext = function( - context: ReactContext, - observedBits: number | boolean - ) - currentHookNameInDev = 'useContext' - warnInvalidHookAccess() - mountHookTypesDev() - return readContext(context, observedBits) - end, - useEffect = function( - create: () -> (() -> ()), - deps: Array | nil - ) - currentHookNameInDev = 'useEffect' - warnInvalidHookAccess() - mountHookTypesDev() - return mountEffect(create, deps) - end, - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array | nil - ) - currentHookNameInDev = 'useImperativeHandle' - warnInvalidHookAccess() - mountHookTypesDev() - return mountImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ()), - deps: Array | nil - ) - currentHookNameInDev = 'useLayoutEffect' - warnInvalidHookAccess() - mountHookTypesDev() - return mountLayoutEffect(create, deps) - end, - useMemo = function(create: () -> any, deps: Array | nil) - currentHookNameInDev = 'useMemo' - warnInvalidHookAccess() - mountHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result = pcall(function() - return mountMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - useReducer = function( - reducer: (any, any) -> any, - initialArg, - init: (any) -> any | nil - ): Array - currentHookNameInDev = 'useReducer' - warnInvalidHookAccess() - mountHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - local ok, result, setResult = pcall(function() - return mountReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, - -- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - warnInvalidHookAccess() - mountHookTypesDev() - return mountRef(initialValue) - end, - -- ROBLOX FIXME: luau function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - warnInvalidHookAccess() - mountHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV - -- deviation: Lua version of useState returns two items, not list like upstream - local ok, result, setResult = pcall(function() - return mountState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState returns two items, not list like upstream - return result, setResult - end, - -- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { - -- currentHookNameInDev = 'useDebugValue' - -- warnInvalidHookAccess() - -- mountHookTypesDev() - -- return mountDebugValue(value, formatterFn) - -- }, - -- useDeferredValue(value: T): T { - -- currentHookNameInDev = 'useDeferredValue' - -- warnInvalidHookAccess() - -- mountHookTypesDev() - -- return mountDeferredValue(value) - -- }, - -- useTransition(): [(() => void) => void, boolean] { - -- currentHookNameInDev = 'useTransition' - -- warnInvalidHookAccess() - -- mountHookTypesDev() - -- return mountTransition() - -- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - warnInvalidHookAccess() - mountHookTypesDev() - return mountMutableSource(source, getSnapshot, subscribe) - end, - -- useOpaqueIdentifier(): OpaqueIDType { - -- currentHookNameInDev = 'useOpaqueIdentifier' - -- warnInvalidHookAccess() - -- mountHookTypesDev() - -- return mountOpaqueIdentifier() - -- }, - - -- unstable_isNewReconciler: enableNewReconciler, - } - - InvalidNestedHooksDispatcherOnUpdateInDEV = { - -- ROBLOX FIXME: luau function generics and return type - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean | nil - ) - warnInvalidContextAccess() - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil) - currentHookNameInDev = 'useCallback' - warnInvalidHookAccess() - updateHookTypesDev() - return mountCallback(callback, deps) - end, - - useContext = function( - context: ReactContext, - observedBits: number | boolean - ): any - currentHookNameInDev = 'useContext' - warnInvalidHookAccess() - updateHookTypesDev() - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useEffect( - -- create: () => (() => void), - -- deps: Array | nil, - -- ): void { - useEffect = function( - create: () -> (() -> ()), - deps: Array | nil - ) - currentHookNameInDev = 'useEffect' - warnInvalidHookAccess() - updateHookTypesDev() - return updateEffect(create, deps) - end, - -- ROBLOX FIXME: luau function generics - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array? - ) - currentHookNameInDev = 'useImperativeHandle' - warnInvalidHookAccess() - updateHookTypesDev() - return updateImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> nil), - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - warnInvalidHookAccess() - updateHookTypesDev() - return updateLayoutEffect(create, deps) - end, - -- ROBLOX TODO: function generics - -- useMemo(create: () => T, deps: Array | nil): T { - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result = pcall(function() - return updateMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result, setResult = pcall(function() - return updateReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, --- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - warnInvalidHookAccess() - updateHookTypesDev() - return updateRef(initialValue) - end, - -- ROBLOX FIXME: Luau generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - -- deviation: Lua version of useState returns two items, not list like upstream - local ok, result, setResult = pcall(function() - return updateState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState returns two items, not list like upstream - return result, setResult - end, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { --- currentHookNameInDev = 'useDebugValue' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return updateDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return updateDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return updateTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - warnInvalidHookAccess() - updateHookTypesDev() - return updateMutableSource(source, getSnapshot, subscribe) - end, --- useOpaqueIdentifier(): OpaqueIDType { --- currentHookNameInDev = 'useOpaqueIdentifier' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return updateOpaqueIdentifier() --- }, - --- unstable_isNewReconciler: enableNewReconciler, - } - - InvalidNestedHooksDispatcherOnRerenderInDEV = { - -- ROBLOX FIXME: function generics - -- readContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - readContext = function( - context: ReactContext, - observedBits: number | boolean - ): any - warnInvalidContextAccess() - return readContext(context, observedBits) - end, - - -- ROBLOX FIXME: Luau function generics and return - -- useCallback(callback: T, deps: Array | void | null): T { - useCallback = function(callback, deps: Array | nil): any - currentHookNameInDev = 'useCallback' - warnInvalidHookAccess() - updateHookTypesDev() - return updateCallback(callback, deps) - end, - -- ROBLOX FIXME: function generics - -- useContext( - -- context: ReactContext, - -- observedBits: void | number | boolean, - -- ): T - useContext = function( - context: ReactContext, - observedBits: number | boolean - ): any - currentHookNameInDev = 'useContext' - warnInvalidHookAccess() - updateHookTypesDev() - return readContext(context, observedBits) - end, - -- ROBLOX FIXME: Luau function generics and return - -- useEffect( - -- create: () => (() => void), - -- deps: Array | nil, - -- ): void { - useEffect = function( - create: () -> (() -> ()), - deps: Array | nil - ) - currentHookNameInDev = 'useEffect' - warnInvalidHookAccess() - updateHookTypesDev() - return updateEffect(create, deps) - end, - -- ROBLOX FIXME: function generics - -- useImperativeHandle( - -- ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, - -- create: () => T, - -- deps: Array | void | null, - -- ): void - useImperativeHandle = function( - ref: {current: any | nil} | (any | nil) -> (any) | nil, - create: () -> any, - deps: Array? - ) - currentHookNameInDev = 'useImperativeHandle' - warnInvalidHookAccess() - updateHookTypesDev() - return updateImperativeHandle(ref, create, deps) - end, - useLayoutEffect = function( - create: () -> (() -> ()), - deps: Array? - ) - currentHookNameInDev = 'useLayoutEffect' - warnInvalidHookAccess() - updateHookTypesDev() - return updateLayoutEffect(create, deps) - end, - -- ROBLOX FIXME: function generics - -- useMemo(create: () => T, deps: Array | nil): T - useMemo = function(create: () -> any, deps: Array | nil): any - currentHookNameInDev = 'useMemo' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result = pcall(function() - return updateMemo(create, deps) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - return result - end, - -- ROBLOX TODO: function generics - -- useReducer( - -- reducer: (S, A) => S, - -- initialArg: I, - -- init?: I => S, - -- ): [S, Dispatch] { - useReducer = function( - reducer: (any, any) -> any, - initialArg: any, - init: ((any) -> any)? - ): (any, Dispatch) - currentHookNameInDev = 'useReducer' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - local ok, result, setResult = pcall(function() - return rerenderReducer(reducer, initialArg, init) - end) - -- ROBLOX finally - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState and useReducer return two items, not list like upstream - return result, setResult - end, --- useRef(initialValue: T): {|current: T|} { - useRef = function(initialValue): {current: any} - currentHookNameInDev = 'useRef' - warnInvalidHookAccess() - updateHookTypesDev() - return updateRef(initialValue) - end, - -- ROBLOX FIXME: function generics and return type - -- useState( - -- initialState: (() => S) | S, - -- ): [S, Dispatch>] - useState = function( - initialState: () -> any | any - ): (any, Dispatch>) - currentHookNameInDev = 'useState' - warnInvalidHookAccess() - updateHookTypesDev() - local prevDispatcher = ReactCurrentDispatcher.current - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV - -- deviation: Lua version of useState returns two items, not list like upstream - local ok, result, setResult = pcall(function() - return rerenderState(initialState) - end) - ReactCurrentDispatcher.current = prevDispatcher - if not ok then - error(result) - end - -- deviation: Lua version of useState returns two items, not list like upstream - return result, setResult - end, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { --- currentHookNameInDev = 'useDebugValue' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return updateDebugValue(value, formatterFn) --- }, --- useDeferredValue(value: T): T { --- currentHookNameInDev = 'useDeferredValue' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return rerenderDeferredValue(value) --- }, --- useTransition(): [(() => void) => void, boolean] { --- currentHookNameInDev = 'useTransition' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return rerenderTransition() --- }, - -- ROBLOX TODO: function generics - -- useMutableSource( - -- source: MutableSource, - -- getSnapshot: MutableSourceGetSnapshotFn, - -- subscribe: MutableSourceSubscribeFn, - -- ): Snapshot { - useMutableSource = function( - source: MutableSource, - getSnapshot: MutableSourceGetSnapshotFn, - subscribe: MutableSourceSubscribeFn - ): any - currentHookNameInDev = 'useMutableSource' - warnInvalidHookAccess() - updateHookTypesDev() - return updateMutableSource(source, getSnapshot, subscribe) - end, --- useOpaqueIdentifier(): OpaqueIDType { --- currentHookNameInDev = 'useOpaqueIdentifier' --- warnInvalidHookAccess() --- updateHookTypesDev() --- return rerenderOpaqueIdentifier() --- }, - --- unstable_isNewReconciler: enableNewReconciler, - } +if __DEV__ then + local warnInvalidContextAccess = function() + console.error( + "Context can only be read while React is rendering. " + .. "In classes, you can read it in the render method or getDerivedStateFromProps. " + .. "In function components, you can read it directly in the function body, but not " + .. "inside Hooks like useReducer() or useMemo()." + ) + end + + local warnInvalidHookAccess = function() + console.error( + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. " + .. "You can only call Hooks at the top level of your React function. " + .. "For more information, see " + .. "https://reactjs.org/link/rules-of-hooks" + ) + end + + HooksDispatcherOnMountInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + return mountCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + mountHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useEffect" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + return mountEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + return mountImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + return mountLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + mountHookTypesDev() + checkDepsAreArrayDev(deps) + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(mountMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + -- ROBLOX FIXME Luau: TypeError: Type 'boolean' could not be converted into 'T' + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + mountHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + local ok, result, setResult = pcall(mountReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + mountHookTypesDev() + return mountRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + mountHookTypesDev() + return mountBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + mountHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + -- deviation: Lua version of mountState return two items, not list like upstream. + local ok, result, setResult = pcall(mountState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- ROBLOX deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + mountHookTypesDev() + return mountDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- mountHookTypesDev() + -- return mountDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- mountHookTypesDev() + -- return mountTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + mountHookTypesDev() + return mountMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function() + currentHookNameInDev = "useOpaqueIdentifier" + mountHookTypesDev() + return mountOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + HooksDispatcherOnMountWithHookTypesInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + updateHookTypesDev() + checkDepsAreArrayDev(deps) + return mountCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + updateHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useEffect" + updateHookTypesDev() + return mountEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + updateHookTypesDev() + return mountImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + updateHookTypesDev() + return mountLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(mountMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + local ok, result, setResult = pcall(mountReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + updateHookTypesDev() + return mountRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + updateHookTypesDev() + return mountBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + -- deviation: Lua version of mountState return two items, not list like upstream + local ok, result, setResult = pcall(mountState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of mountState return two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + updateHookTypesDev() + return mountDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- updateHookTypesDev() + -- return mountDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- updateHookTypesDev() + -- return mountTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + updateHookTypesDev() + return mountMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function() + currentHookNameInDev = "useOpaqueIdentifier" + updateHookTypesDev() + return mountOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + HooksDispatcherOnUpdateInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + updateHookTypesDev() + return updateCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + updateHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useEffect" + updateHookTypesDev() + return updateEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + updateHookTypesDev() + return updateImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + updateHookTypesDev() + return updateLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(updateMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + local ok, result, setResult = pcall(updateReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + updateHookTypesDev() + return updateRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + updateHookTypesDev() + return updateBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + -- deviation: Lua version of updateState returns two items, not list like upstream + local ok, result, setResult = pcall(updateState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState returns two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + updateHookTypesDev() + return updateDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- updateHookTypesDev() + -- return updateDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- updateHookTypesDev() + -- return updateTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + updateHookTypesDev() + return updateMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function(): OpaqueIDType + currentHookNameInDev = "useOpaqueIdentifier" + updateHookTypesDev() + return updateOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + HooksDispatcherOnRerenderInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + updateHookTypesDev() + return mountCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + updateHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ): () + currentHookNameInDev = "useEffect" + updateHookTypesDev() + return updateEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + updateHookTypesDev() + return updateImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + updateHookTypesDev() + return updateLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(updateMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV + local ok, result, setResult = + pcall(rerenderReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- ROBLOX deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + updateHookTypesDev() + return updateRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + updateHookTypesDev() + return updateBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnRerenderInDEV + -- deviation: Lua version of useState returns two items, not list like upstream + local ok, result, setResult = pcall(rerenderState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState returns two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + updateHookTypesDev() + return updateDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- updateHookTypesDev() + -- return rerenderDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- updateHookTypesDev() + -- return rerenderTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + updateHookTypesDev() + return updateMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function(): OpaqueIDType + currentHookNameInDev = "useOpaqueIdentifier" + updateHookTypesDev() + return rerenderOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + InvalidNestedHooksDispatcherOnMountInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + warnInvalidContextAccess() + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + warnInvalidHookAccess() + mountHookTypesDev() + return mountCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + warnInvalidHookAccess() + mountHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ): () + currentHookNameInDev = "useEffect" + warnInvalidHookAccess() + mountHookTypesDev() + return mountEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + warnInvalidHookAccess() + mountHookTypesDev() + return mountImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ): () + currentHookNameInDev = "useLayoutEffect" + warnInvalidHookAccess() + mountHookTypesDev() + return mountLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + warnInvalidHookAccess() + mountHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(mountMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + warnInvalidHookAccess() + mountHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + local ok, result, setResult = pcall(mountReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + warnInvalidHookAccess() + mountHookTypesDev() + return mountRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + warnInvalidHookAccess() + mountHookTypesDev() + return mountBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + warnInvalidHookAccess() + mountHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV + -- deviation: Lua version of useState returns two items, not list like upstream + local ok, result, setResult = pcall(mountState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState returns two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + warnInvalidHookAccess() + mountHookTypesDev() + return mountDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- warnInvalidHookAccess() + -- mountHookTypesDev() + -- return mountDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- warnInvalidHookAccess() + -- mountHookTypesDev() + -- return mountTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + warnInvalidHookAccess() + mountHookTypesDev() + return mountMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function(): OpaqueIDType + currentHookNameInDev = "useOpaqueIdentifier" + warnInvalidHookAccess() + mountHookTypesDev() + return mountOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + InvalidNestedHooksDispatcherOnUpdateInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + warnInvalidContextAccess() + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + warnInvalidHookAccess() + updateHookTypesDev() + return mountCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + warnInvalidHookAccess() + updateHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ): () + currentHookNameInDev = "useEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + warnInvalidHookAccess() + updateHookTypesDev() + return updateImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(updateMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + local ok, result, setResult = pcall(updateReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + warnInvalidHookAccess() + updateHookTypesDev() + return updateRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + warnInvalidHookAccess() + updateHookTypesDev() + return updateBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + -- deviation: Lua version of useState returns two items, not list like upstream + local ok, result, setResult = pcall(updateState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState returns two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + warnInvalidHookAccess() + updateHookTypesDev() + return updateDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- warnInvalidHookAccess() + -- updateHookTypesDev() + -- return updateDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- warnInvalidHookAccess() + -- updateHookTypesDev() + -- return updateTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + warnInvalidHookAccess() + updateHookTypesDev() + return updateMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function(): OpaqueIDType + currentHookNameInDev = "useOpaqueIdentifier" + warnInvalidHookAccess() + updateHookTypesDev() + return updateOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } + + InvalidNestedHooksDispatcherOnRerenderInDEV = { + readContext = function( + context: ReactContext, + observedBits: number | boolean | nil + ): T + warnInvalidContextAccess() + return readContext(context, observedBits) + end, + useCallback = function(callback: T, deps: Array | nil): T + currentHookNameInDev = "useCallback" + warnInvalidHookAccess() + updateHookTypesDev() + return updateCallback(callback, deps) + end, + useContext = function( + context: ReactContext, + observedBits: nil | number | boolean + ): T + currentHookNameInDev = "useContext" + warnInvalidHookAccess() + updateHookTypesDev() + return readContext(context, observedBits) + end, + useEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array | nil + ): () + currentHookNameInDev = "useEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateEffect(create, deps) + end, + useImperativeHandle = function( + ref: { current: T | nil } | ((inst: T | nil) -> ...any) | nil, + create: () -> T, + deps: Array | nil + ): () + currentHookNameInDev = "useImperativeHandle" + warnInvalidHookAccess() + updateHookTypesDev() + return updateImperativeHandle(ref, create, deps) + end, + useLayoutEffect = function( + -- ROBLOX TODO: Luau needs union type packs for this type to translate idiomatically + create: (() -> ()) | (() -> () -> ()), + deps: Array? + ): () + currentHookNameInDev = "useLayoutEffect" + warnInvalidHookAccess() + updateHookTypesDev() + return updateLayoutEffect(create, deps) + end, + -- ROBLOX FIXME Luau: work around 'Failed to unify type packs' error: CLI-51338 + useMemo = function(create: () -> T..., deps: Array | nil): ...any + currentHookNameInDev = "useMemo" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + --[[ + ROBLOX DEVIATION: `results` captures all pcall return value: either + { false, errorObject } or { true, ...returnValues } + ]] + local results = { pcall(updateMemo, create, deps) } + ReactCurrentDispatcher.current = prevDispatcher + if not results[1] then + error(results[2]) + end + return unpack(results, 2) + end :: any, + useReducer = function( + reducer: (S, A) -> S, + initialArg: I, + init: ((I) -> S)? + ): (S, Dispatch) + currentHookNameInDev = "useReducer" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + local ok, result, setResult = + pcall(rerenderReducer, reducer, initialArg, init) + -- ROBLOX finally + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState and useReducer return two items, not list like upstream + return result, setResult + end, + useRef = function(initialValue: T): { current: T } + currentHookNameInDev = "useRef" + warnInvalidHookAccess() + updateHookTypesDev() + return updateRef(initialValue) + end, + -- ROBLOX deviation: Bindings are a feature unique to Roact + useBinding = function(initialValue: T): (ReactBinding, ReactBindingUpdater) + currentHookNameInDev = "useBinding" + warnInvalidHookAccess() + updateHookTypesDev() + return updateBinding(initialValue) + end, + useState = function( + initialState: (() -> S) | S + ): (S, Dispatch>) + currentHookNameInDev = "useState" + warnInvalidHookAccess() + updateHookTypesDev() + local prevDispatcher = ReactCurrentDispatcher.current + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV + -- deviation: Lua version of useState returns two items, not list like upstream + local ok, result, setResult = pcall(rerenderState, initialState) + ReactCurrentDispatcher.current = prevDispatcher + if not ok then + error(result) + end + -- deviation: Lua version of useState returns two items, not list like upstream + return result, setResult + end, + useDebugValue = function(value: T, formatterFn: ((value: T) -> any)?): () + currentHookNameInDev = "useDebugValue" + warnInvalidHookAccess() + updateHookTypesDev() + return updateDebugValue(value, formatterFn) + end, + -- useDeferredValue(value: T): T { + -- currentHookNameInDev = 'useDeferredValue' + -- warnInvalidHookAccess() + -- updateHookTypesDev() + -- return rerenderDeferredValue(value) + -- }, + -- useTransition(): [(() => void) => void, boolean] { + -- currentHookNameInDev = 'useTransition' + -- warnInvalidHookAccess() + -- updateHookTypesDev() + -- return rerenderTransition() + -- }, + useMutableSource = function( + source: MutableSource, + getSnapshot: MutableSourceGetSnapshotFn< + Source, + Snapshot + >, + subscribe: MutableSourceSubscribeFn< + Source, + Snapshot + > + ): Snapshot + currentHookNameInDev = "useMutableSource" + warnInvalidHookAccess() + updateHookTypesDev() + return updateMutableSource(source, getSnapshot, subscribe) + end, + useOpaqueIdentifier = function(): OpaqueIDType + currentHookNameInDev = "useOpaqueIdentifier" + warnInvalidHookAccess() + updateHookTypesDev() + return rerenderOpaqueIdentifier() + end, + + unstable_isNewReconciler = enableNewReconciler, + } end --- deviation: Moved to bottom so that its dependencies are defined ahead of it --- FIXME (roblox): function generics --- exports.renderWithHooks = function( --- current: Fiber | nil, --- workInProgress: Fiber, --- Component: (p: Props, arg: SecondArg) => any, --- props: Props, --- secondArg: SecondArg, --- nextRenderLanes: Lanes --- ): any { -exports.renderWithHooks = function( - current, - workInProgress: Fiber, - Component: (any, any) -> any, - props: any, - secondArg: any, - nextRenderLanes: Lanes +local function renderWithHooks( + current: Fiber | nil, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) -> any, + props: Props, + secondArg: SecondArg, + nextRenderLanes: Lanes ): any - renderLanes = nextRenderLanes - currentlyRenderingFiber = workInProgress - - if _G.__DEV__ then - -- FIXME (roblox): type coercion - -- hookTypesDev = - -- current ~= nil - -- ? ((current._debugHookTypes: any): Array) - -- : nil - if current ~= nil then - hookTypesDev = current._debugHookTypes - else - hookTypesDev = nil - end - hookTypesUpdateIndexDev = 0 - -- Used for hot reloading: - ignorePreviousDependencies = - current ~= nil and current.type ~= workInProgress.type - end - - workInProgress.memoizedState = nil - workInProgress.updateQueue = nil - workInProgress.lanes = NoLanes - - -- The following should have already been reset - -- currentHook = nil - -- workInProgressHook = nil - - -- didScheduleRenderPhaseUpdate = false - - -- TODO Warn if no hooks are used at all during mount, then some are used during update. - -- Currently we will identify the update render as a mount because memoizedState == nil. - -- This is tricky because it's valid for certain types of components (e.g. React.lazy) - - -- Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used. - -- Non-stateful hooks (e.g. context) don't get added to memoizedState, - -- so memoizedState would be nil during updates and mounts. - if _G.__DEV__ then - if current ~= nil and current.memoizedState ~= nil then - ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV - elseif hookTypesDev ~= nil then - -- This dispatcher handles an edge case where a component is updating, - -- but no stateful hooks have been used. - -- We want to match the production code behavior (which will use HooksDispatcherOnMount), - -- but with the extra DEV validation to ensure hooks ordering hasn't changed. - -- This dispatcher does that. - ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV - else - ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV - end - else - ReactCurrentDispatcher.current = - (current == nil or current.memoizedState == nil) - and HooksDispatcherOnMount - or HooksDispatcherOnUpdate - end - - local children = Component(props, secondArg) - - -- Check if there was a render phase update - if didScheduleRenderPhaseUpdateDuringThisPass then - -- Keep rendering in a loop for as long as render phase updates continue to - -- be scheduled. Use a counter to prevent infinite loops. - local numberOfReRenders: number = 0 - repeat - didScheduleRenderPhaseUpdateDuringThisPass = false - invariant( - numberOfReRenders < RE_RENDER_LIMIT, - "Too many re-renders. React limits the number of renders to prevent " .. - "an infinite loop." - ) - - numberOfReRenders += 1 - if _G.__DEV__ then - -- Even when hot reloading, allow dependencies to stabilize - -- after first render to prevent infinite render phase updates. - ignorePreviousDependencies = false - end - - -- Start over from the beginning of the list - currentHook = nil - workInProgressHook = nil - - workInProgress.updateQueue = nil - - if _G.__DEV__ then - -- Also validate hook order for cascading updates. - hookTypesUpdateIndexDev = 0 - end - - ReactCurrentDispatcher.current = _G.__DEV__ - and HooksDispatcherOnRerenderInDEV - or HooksDispatcherOnRerender - - children = Component(props, secondArg) - until not didScheduleRenderPhaseUpdateDuringThisPass - end - - -- 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 = ContextOnlyDispatcher - - if _G.__DEV__ then - workInProgress._debugHookTypes = hookTypesDev - end - - -- This check uses currentHook so that it works the same in DEV and prod bundles. - -- hookTypesDev could catch more cases (e.g. context) but only in DEV bundles. - local didRenderTooFewHooks = - currentHook ~= nil and currentHook.next ~= nil - - renderLanes = NoLanes - currentlyRenderingFiber = nil - - currentHook = nil - workInProgressHook = nil - - if _G.__DEV__ then - currentHookNameInDev = nil - hookTypesDev = nil - hookTypesUpdateIndexDev =0 - end - - didScheduleRenderPhaseUpdate = false - - invariant( - not didRenderTooFewHooks, - "Rendered fewer hooks than expected. This may be caused by an accidental " .. - "early return statement." - ) - - return children + renderLanes = nextRenderLanes + currentlyRenderingFiber = workInProgress + + if __DEV__ then + hookTypesDev = if current ~= nil + then (current._debugHookTypes :: any) :: Array + else nil + -- ROBLOX deviation START: index variable offset by one for Lua + hookTypesUpdateIndexDev = 0 + -- ROBLOX deviation END + -- Used for hot reloading: + -- ROBLOX performance: eliminate unuseful cmp in hot path, we don't currently support hot reloading + -- ignorePreviousDependencies = + -- current ~= nil and current.type ~= workInProgress.type + end + + workInProgress.memoizedState = nil + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + workInProgress.updateQueue = nil + workInProgress.lanes = NoLanes + + -- The following should have already been reset + -- currentHook = nil + -- workInProgressHook = nil + + -- didScheduleRenderPhaseUpdate = false + + -- TODO Warn if no hooks are used at all during mount, then some are used during update. + -- Currently we will identify the update render as a mount because memoizedState == nil. + -- This is tricky because it's valid for certain types of components (e.g. React.lazy) + + -- Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used. + -- Non-stateful hooks (e.g. context) don't get added to memoizedState, + -- so memoizedState would be nil during updates and mounts. + if __DEV__ then + if current ~= nil and current.memoizedState ~= nil then + ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV + elseif hookTypesDev ~= nil then + -- This dispatcher handles an edge case where a component is updating, + -- but no stateful hooks have been used. + -- We want to match the production code behavior (which will use HooksDispatcherOnMount), + -- but with the extra DEV validation to ensure hooks ordering hasn't changed. + -- This dispatcher does that. + ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV + else + ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV + end + else + ReactCurrentDispatcher.current = (current == nil or current.memoizedState == nil) + and HooksDispatcherOnMount + or HooksDispatcherOnUpdate + end + + local children = Component(props, secondArg) + + -- Check if there was a render phase update + if didScheduleRenderPhaseUpdateDuringThisPass then + -- Keep rendering in a loop for as long as render phase updates continue to + -- be scheduled. Use a counter to prevent infinite loops. + local numberOfReRenders: number = 0 + repeat + didScheduleRenderPhaseUpdateDuringThisPass = false + -- ROBLOX performance: use React 18 approach to avoid invariant in hot path + if numberOfReRenders >= RE_RENDER_LIMIT then + error( + Error.new( + "Too many re-renders. React limits the number of renders to prevent " + .. "an infinite loop." + ) + ) + end + + numberOfReRenders += 1 + -- ROBLOX performance: eliminate unuseful cmp in hot path, we don't currently support hot reloading + -- if __DEV__ then + -- Even when hot reloading, allow dependencies to stabilize + -- after first render to prevent infinite render phase updates. + -- ignorePreviousDependencies = false + -- end + + -- Start over from the beginning of the list + currentHook = nil + workInProgressHook = nil + + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + workInProgress.updateQueue = nil + + if __DEV__ then + -- Also validate hook order for cascading updates. + hookTypesUpdateIndexDev = 0 + end + + ReactCurrentDispatcher.current = __DEV__ and HooksDispatcherOnRerenderInDEV + or HooksDispatcherOnRerender + + children = Component(props, secondArg) + until not didScheduleRenderPhaseUpdateDuringThisPass + end + + -- 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 = ContextOnlyDispatcher + + if __DEV__ then + workInProgress._debugHookTypes = hookTypesDev + end + + -- This check uses currentHook so that it works the same in DEV and prod bundles. + -- hookTypesDev could catch more cases (e.g. context) but only in DEV bundles. + local didRenderTooFewHooks = currentHook ~= nil and currentHook.next ~= nil + + renderLanes = NoLanes + currentlyRenderingFiber = nil :: any + + currentHook = nil + workInProgressHook = nil + + if __DEV__ then + currentHookNameInDev = nil + hookTypesDev = nil + hookTypesUpdateIndexDev = 0 + end + + didScheduleRenderPhaseUpdate = false + + -- ROBLOX performance: use React 18 approach that avoid invariant in hot paths + if didRenderTooFewHooks then + local childrenName = "unknown" + if children and children.type then + childrenName = getComponentName(children.type) or childrenName + end + error( + Error.new( + "Rendered fewer hooks than expected. This may be caused by an accidental " + .. "early return statement. Inside '" + .. childrenName + .. "'" + ) + ) + end + + return children end +exports.renderWithHooks = renderWithHooks return exports diff --git a/modules/react-reconciler/src/ReactFiberHostConfig.lua b/modules/react-reconciler/src/ReactFiberHostConfig.lua index 99017d18..4b163658 100644 --- a/modules/react-reconciler/src/ReactFiberHostConfig.lua +++ b/modules/react-reconciler/src/ReactFiberHostConfig.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/9ac42dd074c42b66ecc0334b75200b1d2989f892/packages/react-reconciler/src/ReactFiberHostConfig.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/9ac42dd074c42b66ecc0334b75200b1d2989f892/packages/react-reconciler/src/ReactFiberHostConfig.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -10,43 +11,29 @@ --[[ eslint-disable react-internal/invariant-args ]] -if _G.__NO_LOADMODULE__ then - -- When __NO_LOADMODULE__ is enabled, this module's contents will not be - -- overwritten; we need to return before we hit the invariant below - return {} -end - -- ROBLOX FIXME: Cannot carry types over via the module overriding that's in use -- here; this is a particularly tricky case of cross-dependency type definitions - --- export type Instance = ReactRobloxHostTypes.HostInstance; --- -- ROBLOX FIXME: Figure out what we're doing about text instances --- export type TextInstance = any; --- export type Container = ReactRobloxHostTypes.Container; --- export type HostContext = ReactRobloxHostTypes.HostContext; --- export type HydratableInstance = ReactRobloxHostTypes.HydratableInstance; --- export type SuspenseInstance = ReactRobloxHostTypes.SuspenseInstance; --- export type PublicInstance = ReactRobloxHostTypes.PublicInstance; --- export type Type = ReactRobloxHostTypes.Type; --- export type Props = ReactRobloxHostTypes.Props; --- -- ROBLOX FIXME: Can't create type equal to void --- export type ChildSet = any; --- -- ROBLOX FIXME: Can't create equivalent type: $ReadOnly<{or}> --- export type RendererInspectionConfig = any; -export type Instance = any; -export type TextInstance = any; -export type Container = any; -export type HostContext = any; -export type HydratableInstance = any; -export type SuspenseInstance = any; -export type PublicInstance = any; -export type Type = any; -export type Props = any; -export type ChildSet = any; -export type RendererInspectionConfig = any; - -local module: { [string]: any } = {} -return module +-- Use a common set of typedefs across ReactTestHostConfig and ReactRobloxHostTypes +type Object = { [string]: any } + +export type Instance = Object +export type HostInstance = Instance +export type TextInstance = Instance +export type Container = Object +export type HostContext = Object +export type HydratableInstance = Instance | SuspenseInstance +export type SuspenseInstance = Object +export type PublicInstance = HostInstance + +export type Type = string +export type Props = Object +export type ChildSet = {} -- void, unused +export type RendererInspectionConfig = Object + +-- if _G.__NO_LOADMODULE__ then +local exports: { [string]: any } = {} +return exports +-- end -- -- We expect that our Rollup, Jest, and Flow configurations -- -- always shim this module with the corresponding host config @@ -58,20 +45,4 @@ return module -- -- deviation: FIXME (roblox): is there a way to configure luau to account for this module -- -- being shimmed? --- export type Instance = any; --- export type TextInstance = any; --- export type HydratableInstance = any; --- export type Container = any; --- export type PublicInstance = any; --- export type RendererInspectionConfig = any; --- export type SuspenseInstance = any; --- export type HostContext = any; --- export type Type = any; --- export type Props = any; --- export type ChildSet = any; - --- -- deviation: roblox-cli doesn't have any way of understanding that this file is --- -- expected to be mocked/replaced, and we don't yet have "build" scripts that --- -- simulate react's handling of this stub --- local exports: {[any]: any} = {} --- return exports +-- error('This module must be shimmed by a specific renderer.') diff --git a/modules/react-reconciler/src/ReactFiberHostContext.new.lua b/modules/react-reconciler/src/ReactFiberHostContext.new.lua index 3ae2c9ec..c9df0451 100644 --- a/modules/react-reconciler/src/ReactFiberHostContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberHostContext.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberHostContext.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberHostContext.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -8,7 +9,7 @@ * @flow ]] -local Packages = script.Parent.Parent +-- local Packages = script.Parent.Parent local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber @@ -18,7 +19,7 @@ local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) type Container = ReactFiberHostConfig.Container type HostContext = ReactFiberHostConfig.HostContext -local invariant = require(Packages.Shared).invariant +-- local invariant = require(Packages.Shared).invariant local getChildHostContext = ReactFiberHostConfig.getChildHostContext local getRootHostContext = ReactFiberHostConfig.getRootHostContext @@ -30,100 +31,96 @@ local pop = ReactFiberStack.pop -- is always this exact object, but I think ours will match against any empty -- table -- declare class NoContextT {} -type NoContextT = {}; +type NoContextT = {} local NO_CONTEXT: NoContextT = {} -local contextStackCursor: StackCursor = createCursor( - NO_CONTEXT -) -local contextFiberStackCursor: StackCursor = createCursor( - NO_CONTEXT -) -local rootInstanceStackCursor: StackCursor< - Container | NoContextT -> = createCursor(NO_CONTEXT) - --- FIXME (roblox): function generics --- function requiredContext(c: Value | NoContextT): Value { -function requiredContext(c: any | NoContextT): any - invariant( - c ~= NO_CONTEXT, - "Expected host context to exist. This error is likely caused by a bug " .. - "in React. Please file an issue." - ) - return c +local contextStackCursor: StackCursor = createCursor(NO_CONTEXT) +local contextFiberStackCursor: StackCursor = createCursor(NO_CONTEXT) +local rootInstanceStackCursor: StackCursor = + createCursor(NO_CONTEXT) + +function requiredContext(c: Value | NoContextT): Value + -- ROBLOX performance: eliminate expensive optional cmp in hot path + -- invariant( + -- c ~= NO_CONTEXT, + -- "Expected host context to exist. This error is likely caused by a bug " .. + -- "in React. Please file an issue." + -- ) + return c :: any end function getRootHostContainer(): Container - local rootInstance = requiredContext(rootInstanceStackCursor.current) - return rootInstance + -- ROBLOX performance: inline requiredContext impl for hot path + -- local rootInstance = requiredContext(rootInstanceStackCursor.current) + -- return rootInstance + return rootInstanceStackCursor.current end function pushHostContainer(fiber: Fiber, nextRootInstance: Container) - -- Push current root instance onto the stack - -- This allows us to reset root when portals are popped. - push(rootInstanceStackCursor, nextRootInstance, fiber) - -- Track the context and the Fiber that provided it. - -- This enables us to pop only Fibers that provide unique contexts. - push(contextFiberStackCursor, fiber, fiber) - - -- Finally, we need to push the host context to the stack. - -- However, we can't just call getRootHostContext() and push it because - -- we'd have a different number of entries on the stack depending on - -- whether getRootHostContext() throws somewhere in renderer code or not. - -- So we push an empty value first. This lets us safely unwind on errors. - push(contextStackCursor, NO_CONTEXT, fiber) - local nextRootContext = getRootHostContext(nextRootInstance) - -- Now that we know this function doesn't throw, replace it. - pop(contextStackCursor, fiber) - push(contextStackCursor, nextRootContext, fiber) + -- Push current root instance onto the stack + -- This allows us to reset root when portals are popped. + push(rootInstanceStackCursor, nextRootInstance, fiber) + -- Track the context and the Fiber that provided it. + -- This enables us to pop only Fibers that provide unique contexts. + push(contextFiberStackCursor, fiber, fiber) + + -- Finally, we need to push the host context to the stack. + -- However, we can't just call getRootHostContext() and push it because + -- we'd have a different number of entries on the stack depending on + -- whether getRootHostContext() throws somewhere in renderer code or not. + -- So we push an empty value first. This lets us safely unwind on errors. + push(contextStackCursor, NO_CONTEXT, fiber) + local nextRootContext = getRootHostContext(nextRootInstance) + -- Now that we know this function doesn't throw, replace it. + pop(contextStackCursor, fiber) + push(contextStackCursor, nextRootContext, fiber) end function popHostContainer(fiber: Fiber) - pop(contextStackCursor, fiber) - pop(contextFiberStackCursor, fiber) - pop(rootInstanceStackCursor, fiber) + pop(contextStackCursor, fiber) + pop(contextFiberStackCursor, fiber) + pop(rootInstanceStackCursor, fiber) end function getHostContext(): HostContext - local context = requiredContext(contextStackCursor.current) - return context + -- ROBLOX performance: inline requiredContext impl for hot path + -- local context = requiredContext(contextStackCursor.current) + -- return context + return contextStackCursor.current end function pushHostContext(fiber: Fiber) - local rootInstance: Container = requiredContext( - rootInstanceStackCursor.current - ) - local context: HostContext = requiredContext(contextStackCursor.current) - local nextContext = getChildHostContext(context, fiber.type, rootInstance) - - -- Don't push this Fiber's context unless it's unique. - if context == nextContext then - return - end - - -- Track the context and the Fiber that provided it. - -- This enables us to pop only Fibers that provide unique contexts. - push(contextFiberStackCursor, fiber, fiber) - push(contextStackCursor, nextContext, fiber) + local rootInstance: Container = requiredContext(rootInstanceStackCursor.current) + local context: HostContext = requiredContext(contextStackCursor.current) + local nextContext = getChildHostContext(context, fiber.type, rootInstance) + + -- Don't push this Fiber's context unless it's unique. + if context == nextContext then + return + end + + -- Track the context and the Fiber that provided it. + -- This enables us to pop only Fibers that provide unique contexts. + push(contextFiberStackCursor, fiber, fiber) + push(contextStackCursor, nextContext, fiber) end function popHostContext(fiber: Fiber) - -- Do not pop unless this Fiber provided the current context. - -- pushHostContext() only pushes Fibers that provide unique contexts. - if contextFiberStackCursor.current ~= fiber then - return - end - - pop(contextStackCursor, fiber) - pop(contextFiberStackCursor, fiber) + -- Do not pop unless this Fiber provided the current context. + -- pushHostContext() only pushes Fibers that provide unique contexts. + if contextFiberStackCursor.current ~= fiber then + return + end + + pop(contextStackCursor, fiber) + pop(contextFiberStackCursor, fiber) end return { - getHostContext = getHostContext, - getRootHostContainer = getRootHostContainer, - popHostContainer = popHostContainer, - popHostContext = popHostContext, - pushHostContainer = pushHostContainer, - pushHostContext = pushHostContext, + getHostContext = getHostContext, + getRootHostContainer = getRootHostContainer, + popHostContainer = popHostContainer, + popHostContext = popHostContext, + pushHostContainer = pushHostContainer, + pushHostContext = pushHostContext, } diff --git a/modules/react-reconciler/src/ReactFiberHotReloading.new.lua b/modules/react-reconciler/src/ReactFiberHotReloading.new.lua index ccce6d38..ed8fff2b 100644 --- a/modules/react-reconciler/src/ReactFiberHotReloading.new.lua +++ b/modules/react-reconciler/src/ReactFiberHotReloading.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/6edaf6f764f23043f0cd1c2da355b42f641afd8b/packages/react-reconciler/src/ReactFiberHotReloading.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/6edaf6f764f23043f0cd1c2da355b42f641afd8b/packages/react-reconciler/src/ReactFiberHotReloading.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,82 +8,96 @@ * * @flow ]] ---!nolint LocalShadowPedantic local Packages = script.Parent.Parent -local ReactTypes = require(Packages.Shared) +local ReactGlobals = require(Packages.ReactGlobals) +local LuauPolyfill = require(Packages.LuauPolyfill) +local Array = LuauPolyfill.Array +local Error = LuauPolyfill.Error +local Set = LuauPolyfill.Set +-- ROBLOX deviation: use Set until a WeakSet polyfill is added +local WeakSet = Set +type Array = LuauPolyfill.Array +type Set = LuauPolyfill.Set +type WeakSet = Set + +local ReactElementType = require(Packages.Shared) -- ROBLOX deviation: ReactElement is defined at the top level of Shared along -- with the rest of the ReactTypes -type ReactElement = ReactTypes.ReactElement - +type ReactElement = ReactElementType.ReactElement local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; --- local type {FiberRoot} = require(script.Parent.ReactInternalTypes) --- local type {Instance} = require(script.Parent.ReactFiberHostConfig) --- local type {ReactNodeList} = require(Packages.Shared).ReactTypes - --- local { --- flushSync, --- scheduleUpdateOnFiber, --- flushPassiveEffects, --- } = require(script.Parent.ReactFiberWorkLoop.new) --- local {updateContainer} = require(script.Parent.ReactFiberReconciler.new) --- local {emptyContextObject} = require(script.Parent.ReactFiberContext.new) --- local {SyncLane, NoTimestamp} = require(script.Parent.ReactFiberLane) --- local { --- ClassComponent, --- FunctionComponent, --- ForwardRef, --- HostComponent, --- HostPortal, --- HostRoot, --- MemoComponent, --- SimpleMemoComponent, --- } = require(script.Parent.ReactWorkTags) +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot +local ReactFiberHostConfigModule = require(script.Parent.ReactFiberHostConfig) +type Instance = ReactFiberHostConfigModule.Instance +type ReactNodeList = ReactElementType.ReactNodeList + +-- ROBLOX deviation: inline these requires to accommodate cyclic imports +-- local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) +-- local flushSync = ReactFiberWorkLoop.flushSync +-- local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber +-- local flushPassiveEffects = ReactFiberWorkLoop.flushPassiveEffects +-- local updateContainer = require(script.Parent["ReactFiberReconciler.new"]).updateContainer +-- local emptyContextObject = require(script.Parent["ReactFiberContext.new"]).emptyContextObject +local ReactFiberLaneModule = require(script.Parent.ReactFiberLane) +local SyncLane = ReactFiberLaneModule.SyncLane +local NoTimestamp = ReactFiberLaneModule.NoTimestamp +local ReactWorkTagsModule = require(script.Parent.ReactWorkTags) +local ClassComponent = ReactWorkTagsModule.ClassComponent +local FunctionComponent = ReactWorkTagsModule.FunctionComponent +local ForwardRef = ReactWorkTagsModule.ForwardRef +local HostComponent = ReactWorkTagsModule.HostComponent +local HostPortal = ReactWorkTagsModule.HostPortal +local HostRoot = ReactWorkTagsModule.HostRoot +local MemoComponent = ReactWorkTagsModule.MemoComponent +local SimpleMemoComponent = ReactWorkTagsModule.SimpleMemoComponent local ReactSymbols = require(Packages.Shared).ReactSymbols local REACT_FORWARD_REF_TYPE = ReactSymbols.REACT_FORWARD_REF_TYPE --- REACT_MEMO_TYPE, --- REACT_LAZY_TYPE, +local REACT_MEMO_TYPE = ReactSymbols.REACT_MEMO_TYPE +local REACT_LAZY_TYPE = ReactSymbols.REACT_LAZY_TYPE + +local __DEV__ = ReactGlobals.__DEV__ export type Family = { current: any, -}; +} --- export type RefreshUpdate = {| --- staleFamilies: Set, --- updatedFamilies: Set, --- |} +export type RefreshUpdate = { + staleFamilies: Set, + updatedFamilies: Set, +} -- Resolves type to a family. -type RefreshHandler = (any) -> (Family?); - --- -- Used by React Refresh runtime through DevTools Global Hook. --- export type SetRefreshHandler = (handler: RefreshHandler | nil) => void --- export type ScheduleRefresh = (root: FiberRoot, update: RefreshUpdate) => void --- export type ScheduleRoot = (root: FiberRoot, element: ReactNodeList) => void --- export type FindHostInstancesForRefresh = ( --- root: FiberRoot, --- families: Array, --- ) => Set - --- FIXME (roblox): restore type 'resolveFamily: RefreshHandler?' when type --- refinement in Luau works better -local resolveFamily = nil +type RefreshHandler = (any) -> Family? + +-- Used by React Refresh runtime through DevTools Global Hook. +export type SetRefreshHandler = (handler: RefreshHandler | nil) -> () +export type ScheduleRefresh = (root: FiberRoot, update: RefreshUpdate) -> () +export type ScheduleRoot = (root: FiberRoot, element: ReactNodeList) -> () +export type FindHostInstancesForRefresh = ( + root: FiberRoot, + families: Array +) -> Set + +local resolveFamily: RefreshHandler? = nil -- $FlowFixMe Flow gets confused by a WeakSet feature check below. --- ROBLOX deviation: Using table instead of WeakSet -local failedBoundaries: { [number]: Fiber } | nil = nil +local failedBoundaries: WeakSet? = nil local exports = {} --- export local setRefreshHandler = (handler: RefreshHandler | nil): void => { --- if _G.__DEV__) --- resolveFamily = handler --- end --- end +-- ROBLOX deviation: pre-delcare shared functions +local findChildHostInstancesForFiberShallowly, findHostInstancesForFiberShallowly, findHostInstancesForMatchingFibersRecursively, scheduleFibersWithFamiliesRecursively + +local function setRefreshHandler(handler: RefreshHandler | nil): () + if __DEV__ then + resolveFamily = handler + end +end +exports.setRefreshHandler = setRefreshHandler local function resolveFunctionForHotReloading(type: any): any - if _G.__DEV__ then + if __DEV__ then if resolveFamily == nil then -- Hot reloading is disabled. return type @@ -106,7 +121,7 @@ end exports.resolveClassForHotReloading = resolveClassForHotReloading local function resolveForwardRefForHotReloading(type: any): any - if _G.__DEV__ then + if __DEV__ then if resolveFamily == nil then -- Hot reloading is disabled. return type @@ -114,10 +129,7 @@ local function resolveForwardRefForHotReloading(type: any): any local family = resolveFamily(type) if family == nil then -- Check if we're dealing with a real forwardRef. Don't want to crash early. - if - type ~= nil and - typeof(type.render) == 'function' - then + if type ~= nil and typeof(type.render) == "function" then -- ForwardRef is special because its resolved .type is an object, -- but it's possible that we only have its inner render function in the map. -- If that inner render function is different, we'll build a new forwardRef type. @@ -126,6 +138,8 @@ local function resolveForwardRefForHotReloading(type: any): any local syntheticType = { ["$$typeof"] = REACT_FORWARD_REF_TYPE, render = currentRender, + -- ROBLOX deviation: Luau needs table initializers to be complete + displayName = nil, } if type.displayName ~= nil then syntheticType.displayName = type.displayName @@ -143,361 +157,360 @@ local function resolveForwardRefForHotReloading(type: any): any end exports.resolveForwardRefForHotReloading = resolveForwardRefForHotReloading -exports.isCompatibleFamilyForHotReloading = function( +local function isCompatibleFamilyForHotReloading( fiber: Fiber, element: ReactElement ): boolean - warn("isCompatibleFamilyForHotReloading is stubbed (returns false)") - return false - -- if _G.__DEV__ then - -- if resolveFamily == nil then - -- -- Hot reloading is disabled. - -- return false - -- end - - -- local prevType = fiber.elementType - -- local nextType = element.type + if __DEV__ then + if resolveFamily == nil then + -- Hot reloading is disabled. + return false + end - -- -- If we got here, we know types aren't == equal. - -- local needsCompareFamilies = false + local prevType = fiber.elementType + local nextType = element.type - -- local $$typeofNextType = - -- typeof nextType == 'table’' and nextType ~= nil - -- ? nextType.$$typeof - -- : nil + -- If we got here, we know types aren't === equal. + local needsCompareFamilies = false - -- switch (fiber.tag) - -- case ClassComponent: { - -- if typeof nextType == 'function') - -- needsCompareFamilies = true - -- end - -- break - -- end - -- case FunctionComponent: { - -- if typeof nextType == 'function') - -- needsCompareFamilies = true - -- } else if $$typeofNextType == REACT_LAZY_TYPE) - -- -- We don't know the inner type yet. - -- -- We're going to assume that the lazy inner type is stable, - -- -- and so it is sufficient to avoid reconciling it away. - -- -- We're not going to unwrap or actually use the new lazy type. - -- needsCompareFamilies = true - -- end - -- break - -- end - -- case ForwardRef: { - -- if $$typeofNextType == REACT_FORWARD_REF_TYPE) - -- needsCompareFamilies = true - -- } else if $$typeofNextType == REACT_LAZY_TYPE) - -- needsCompareFamilies = true - -- end - -- break - -- end - -- case MemoComponent: - -- case SimpleMemoComponent: { - -- if $$typeofNextType == REACT_MEMO_TYPE) - -- -- TODO: if it was but can no longer be simple, - -- -- we shouldn't set this. - -- needsCompareFamilies = true - -- } else if $$typeofNextType == REACT_LAZY_TYPE) - -- needsCompareFamilies = true - -- end - -- break - -- end - -- default: - -- return false - -- end + local __typeofNextType = if typeof(nextType) == "table" + and nextType ~= nil + then nextType["$$typeof"] + else nil - -- -- Check if both types have a family and it's the same one. - -- if needsCompareFamilies) - -- -- Note: memo() and forwardRef() we'll compare outer rather than inner type. - -- -- This means both of them need to be registered to preserve state. - -- -- If we unwrapped and compared the inner types for wrappers instead, - -- -- then we would risk falsely saying two separate memo(Foo) - -- -- calls are equivalent because they wrap the same Foo function. - -- local prevFamily = resolveFamily(prevType) - -- if prevFamily ~= undefined and prevFamily == resolveFamily(nextType)) - -- return true - -- end - -- end - -- return false - -- } else { - -- return false - -- end + repeat --[[ ROBLOX comment: switch statement conversion ]] + local condition_ = fiber.tag + if condition_ == ClassComponent then + do + if typeof(nextType) == "function" then + needsCompareFamilies = true + end + break + end + elseif condition_ == FunctionComponent then + do + if typeof(nextType) == "function" then + needsCompareFamilies = true + elseif __typeofNextType == REACT_LAZY_TYPE then + -- We don't know the inner type yet. + -- We're going to assume that the lazy inner type is stable, + -- and so it is sufficient to avoid reconciling it away. + -- We're not going to unwrap or actually use the new lazy type. + needsCompareFamilies = true + end + break + end + elseif condition_ == ForwardRef then + do + if __typeofNextType == REACT_FORWARD_REF_TYPE then + needsCompareFamilies = true + elseif __typeofNextType == REACT_LAZY_TYPE then + needsCompareFamilies = true + end + break + end + elseif condition_ == MemoComponent or condition_ == SimpleMemoComponent then + do + if __typeofNextType == REACT_MEMO_TYPE then + -- TODO: if it was but can no longer be simple, + -- we shouldn't set this. + needsCompareFamilies = true + elseif __typeofNextType == REACT_LAZY_TYPE then + needsCompareFamilies = true + end + break + end + else + return false + end + until true + + -- Check if both types have a family and it's the same one. + if needsCompareFamilies then + -- Note: memo() and forwardRef() we'll compare outer rather than inner type. + -- This means both of them need to be registered to preserve state. + -- If we unwrapped and compared the inner types for wrappers instead, + -- then we would risk falsely saying two separate memo(Foo) + -- calls are equivalent because they wrap the same Foo function. + local prevFamily = resolveFamily(prevType) + if prevFamily ~= nil and prevFamily == resolveFamily(nextType) then + return true + end + end + return false + else + return false + end end +exports.isCompatibleFamilyForHotReloading = isCompatibleFamilyForHotReloading -exports.markFailedErrorBoundaryForHotReloading = function(fiber: Fiber) - if _G.__DEV__ then +local function markFailedErrorBoundaryForHotReloading(fiber: Fiber) + if __DEV__ then if resolveFamily == nil then -- Hot reloading is disabled. return end - -- if typeof(WeakSet) ~= 'function' then + -- ROBLOX deviation: WeakSet is always available + -- if typeof(WeakSet) ~= "function" then -- return -- end - -- ROBLOX deviation: {} in place of WeakSet if failedBoundaries == nil then - failedBoundaries = {} + failedBoundaries = WeakSet.new() end - -- ROBLOX FIXME: remove :: once Luau understands nil check - table.insert((failedBoundaries :: { [number]: Fiber }), fiber) + -- ROBLOX Luau FIXME: control flow analysis + (failedBoundaries :: WeakSet):add(fiber) end end +exports.markFailedErrorBoundaryForHotReloading = markFailedErrorBoundaryForHotReloading --- export local scheduleRefresh: ScheduleRefresh = ( --- root: FiberRoot, --- update: RefreshUpdate, --- ): void => { --- if _G.__DEV__) --- if resolveFamily == nil) --- -- Hot reloading is disabled. --- return --- end --- local {staleFamilies, updatedFamilies} = update --- flushPassiveEffects() --- flushSync(() => { --- scheduleFibersWithFamiliesRecursively( --- root.current, --- updatedFamilies, --- staleFamilies, --- ) --- }) --- end --- end - --- export local scheduleRoot: ScheduleRoot = ( --- root: FiberRoot, --- element: ReactNodeList, --- ): void => { --- if _G.__DEV__) --- if root.context ~= emptyContextObject) --- -- Super edge case: root has a legacy _renderSubtree context --- -- but we don't know the parentComponent so we can't pass it. --- -- Just ignore. We'll delete this with _renderSubtree code path later. --- return --- end --- flushPassiveEffects() --- flushSync(() => { --- updateContainer(element, root, nil, nil) --- }) --- end --- end - --- function scheduleFibersWithFamiliesRecursively( --- fiber: Fiber, --- updatedFamilies: Set, --- staleFamilies: Set, --- ) --- if _G.__DEV__) --- local {alternate, child, sibling, tag, type} = fiber - --- local candidateType = nil --- switch (tag) --- case FunctionComponent: --- case SimpleMemoComponent: --- case ClassComponent: --- candidateType = type --- break --- case ForwardRef: --- candidateType = type.render --- break --- default: --- break --- end - --- if resolveFamily == nil) --- throw new Error('Expected resolveFamily to be set during hot reload.') --- end - --- local needsRender = false --- local needsRemount = false --- if candidateType ~= nil) --- local family = resolveFamily(candidateType) --- if family ~= undefined) --- if staleFamilies.has(family)) --- needsRemount = true --- } else if updatedFamilies.has(family)) --- if tag == ClassComponent) --- needsRemount = true --- } else { --- needsRender = true --- end --- end --- end --- end --- if failedBoundaries ~= nil) --- if --- failedBoundaries.has(fiber) or --- (alternate ~= nil and failedBoundaries.has(alternate)) --- ) --- needsRemount = true --- end --- end - --- if needsRemount) --- fiber._debugNeedsRemount = true --- end --- if needsRemount or needsRender) --- scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) --- end --- if child ~= nil and !needsRemount) --- scheduleFibersWithFamiliesRecursively( --- child, --- updatedFamilies, --- staleFamilies, --- ) --- end --- if sibling ~= nil) --- scheduleFibersWithFamiliesRecursively( --- sibling, --- updatedFamilies, --- staleFamilies, --- ) --- end --- end --- end - --- export local findHostInstancesForRefresh: FindHostInstancesForRefresh = ( --- root: FiberRoot, --- families: Array, --- ): Set => { --- if _G.__DEV__) --- local hostInstances = new Set() --- local types = new Set(families.map(family => family.current)) --- findHostInstancesForMatchingFibersRecursively( --- root.current, --- types, --- hostInstances, --- ) --- return hostInstances --- } else { --- throw new Error( --- 'Did not expect findHostInstancesForRefresh to be called in production.', --- ) --- end --- end - --- function findHostInstancesForMatchingFibersRecursively( --- fiber: Fiber, --- types: Set, --- hostInstances: Set, --- ) --- if _G.__DEV__) --- local {child, sibling, tag, type} = fiber - --- local candidateType = nil --- switch (tag) --- case FunctionComponent: --- case SimpleMemoComponent: --- case ClassComponent: --- candidateType = type --- break --- case ForwardRef: --- candidateType = type.render --- break --- default: --- break --- end +local scheduleRefresh: ScheduleRefresh +function scheduleRefresh(root: FiberRoot, update: RefreshUpdate): () + if __DEV__ then + if resolveFamily == nil then + -- Hot reloading is disabled. + return + end + local staleFamilies, updatedFamilies = + update.staleFamilies, update.updatedFamilies + local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any + local flushPassiveEffects = ReactFiberWorkLoop.flushPassiveEffects + local flushSync = ReactFiberWorkLoop.flushSync + flushPassiveEffects() + flushSync(function() + scheduleFibersWithFamiliesRecursively( + root.current, + updatedFamilies, + staleFamilies + ) + end) + end +end +exports.scheduleRefresh = scheduleRefresh + +local scheduleRoot: ScheduleRoot +function scheduleRoot(root: FiberRoot, element: ReactNodeList): () + if __DEV__ then + local emptyContextObject = ( + require(script.Parent["ReactFiberContext.new"]) :: any + ).emptyContextObject + if root.context ~= emptyContextObject then + -- Super edge case: root has a legacy _renderSubtree context + -- but we don't know the parentComponent so we can't pass it. + -- Just ignore. We'll delete this with _renderSubtree code path later. + return + end + local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) :: any + local flushPassiveEffects = ReactFiberWorkLoop.flushPassiveEffects + local flushSync = ReactFiberWorkLoop.flushSync + flushPassiveEffects() + flushSync(function() + local updateContainer = ( + require(script.Parent["ReactFiberReconciler.new"]) :: any + ).updateContainer + updateContainer(element, root, nil, nil) + end) + end +end +exports.scheduleRoot = scheduleRoot --- local didMatch = false --- if candidateType ~= nil) --- if types.has(candidateType)) --- didMatch = true --- end --- end +function scheduleFibersWithFamiliesRecursively( + fiber: Fiber, + updatedFamilies: Set, + staleFamilies: Set +) + if __DEV__ then + local alternate, child, sibling, tag, type_ = + fiber.alternate, fiber.child, fiber.sibling, fiber.tag, fiber.type + local candidateType = nil + local condition_ = tag + if + condition_ == FunctionComponent + or condition_ == SimpleMemoComponent + or condition_ == ClassComponent + then + candidateType = type_ + elseif condition_ == ForwardRef then + candidateType = type_.render + -- ROBLOX deviation: no default case + -- else + end + if resolveFamily == nil then + error(Error.new("Expected resolveFamily to be set during hot reload.")) + end + local needsRender = false + local needsRemount = false + if candidateType ~= nil then + local family = resolveFamily(candidateType) + if family ~= nil then + if staleFamilies:has(family) then + needsRemount = true + elseif updatedFamilies:has(family) then + if tag == ClassComponent then + needsRemount = true + else + needsRender = true + end + end + end + end + if failedBoundaries ~= nil then + if + failedBoundaries:has(fiber) + or (alternate ~= nil and failedBoundaries:has(alternate)) + then + needsRemount = true + end + end + if needsRemount then + fiber._debugNeedsRemount = true + end + if needsRemount or needsRender then + local ReactFiberWorkLoop = + require(script.Parent["ReactFiberWorkLoop.new"]) :: any + local scheduleUpdateOnFiber = ReactFiberWorkLoop.scheduleUpdateOnFiber + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) + end + if child ~= nil and not needsRemount then + scheduleFibersWithFamiliesRecursively(child, updatedFamilies, staleFamilies) + end + if sibling ~= nil then + scheduleFibersWithFamiliesRecursively(sibling, updatedFamilies, staleFamilies) + end + end +end --- if didMatch) --- -- We have a match. This only drills down to the closest host components. --- -- There's no need to search deeper because for the purpose of giving --- -- visual feedback, "flashing" outermost parent rectangles is sufficient. --- findHostInstancesForFiberShallowly(fiber, hostInstances) --- } else { --- -- If there's no match, maybe there will be one further down in the child tree. --- if child ~= nil) --- findHostInstancesForMatchingFibersRecursively( --- child, --- types, --- hostInstances, --- ) --- end --- end +local findHostInstancesForRefresh: FindHostInstancesForRefresh +function findHostInstancesForRefresh( + root: FiberRoot, + families: Array +): Set + if __DEV__ then + local hostInstances = Set.new() + local types = Set.new(Array.map(families, function(family) + return family.current + end)) + findHostInstancesForMatchingFibersRecursively(root.current, types, hostInstances) + return hostInstances + else + error( + Error.new( + "Did not expect findHostInstancesForRefresh to be called in production." + ) + ) + end +end +exports.findHostInstancesForRefresh = findHostInstancesForRefresh --- if sibling ~= nil) --- findHostInstancesForMatchingFibersRecursively( --- sibling, --- types, --- hostInstances, --- ) --- end --- end --- end +function findHostInstancesForMatchingFibersRecursively( + fiber: Fiber, + types: Set, + hostInstances: Set +) + if __DEV__ then + local child, sibling, tag, type_ = + fiber.child, fiber.sibling, fiber.tag, fiber.type + local candidateType = nil + local condition_ = tag + if + condition_ == FunctionComponent + or condition_ == SimpleMemoComponent + or condition_ == ClassComponent + then + candidateType = type_ + elseif condition_ == ForwardRef then + candidateType = type_.render + -- ROBLOX deviation: no default case + -- else + end + local didMatch = false + if candidateType ~= nil then + if types:has(candidateType) then + didMatch = true + end + end + if didMatch then + -- We have a match. This only drills down to the closest host components. + -- There's no need to search deeper because for the purpose of giving + -- visual feedback, "flashing" outermost parent rectangles is sufficient. + findHostInstancesForFiberShallowly(fiber, hostInstances) + else + -- If there's no match, maybe there will be one further down in the child tree. + if child ~= nil then + findHostInstancesForMatchingFibersRecursively(child, types, hostInstances) + end + end + if sibling ~= nil then + findHostInstancesForMatchingFibersRecursively(sibling, types, hostInstances) + end + end +end --- function findHostInstancesForFiberShallowly( --- fiber: Fiber, --- hostInstances: Set, --- ): void { --- if _G.__DEV__) --- local foundHostInstances = findChildHostInstancesForFiberShallowly( --- fiber, --- hostInstances, --- ) --- if foundHostInstances) --- return --- end --- -- If we didn't find any host children, fallback to closest host parent. --- local node = fiber --- while (true) --- switch (node.tag) --- case HostComponent: --- hostInstances.add(node.stateNode) --- return --- case HostPortal: --- hostInstances.add(node.stateNode.containerInfo) --- return --- case HostRoot: --- hostInstances.add(node.stateNode.containerInfo) --- return --- end --- if node.return == nil) --- throw new Error('Expected to reach root first.') --- end --- node = node.return --- end --- end --- end +function findHostInstancesForFiberShallowly( + fiber: Fiber, + hostInstances: Set +): () + if __DEV__ then + local foundHostInstances = + findChildHostInstancesForFiberShallowly(fiber, hostInstances) + if foundHostInstances then + return + end + -- If we didn't find any host children, fallback to closest host parent. + local node = fiber + while true do + local condition_ = node.tag + if condition_ == HostComponent then + hostInstances:add(node.stateNode) + return + elseif condition_ == HostPortal then + hostInstances:add(node.stateNode.containerInfo) + return + elseif condition_ == HostRoot then + hostInstances:add(node.stateNode.containerInfo) + return + end + if node.return_ == nil then + error(Error.new("Expected to reach root first.")) + end + node = node.return_ + end + end +end --- function findChildHostInstancesForFiberShallowly( --- fiber: Fiber, --- hostInstances: Set, --- ): boolean { --- if _G.__DEV__) --- local node: Fiber = fiber --- local foundHostInstances = false --- while (true) --- if node.tag == HostComponent) --- -- We got a match. --- foundHostInstances = true --- hostInstances.add(node.stateNode) --- -- There may still be more, so keep searching. --- } else if node.child ~= nil) --- node.child.return = node --- node = node.child --- continue --- end --- if node == fiber) --- return foundHostInstances --- end --- while (node.sibling == nil) --- if node.return == nil or node.return == fiber) --- return foundHostInstances --- end --- node = node.return --- end --- node.sibling.return = node.return --- node = node.sibling --- end --- end --- return false --- end +function findChildHostInstancesForFiberShallowly( + fiber: Fiber, + hostInstances: Set +): boolean + if __DEV__ then + local node: Fiber = fiber + local foundHostInstances = false + while true do + if node.tag == HostComponent then + -- We got a match. + foundHostInstances = true + hostInstances:add(node.stateNode) + -- There may still be more, so keep searching. + elseif node.child ~= nil then + node.child.return_ = node + node = node.child + continue + end + if node == fiber then + return foundHostInstances + end + while node.sibling == nil do + if node.return_ == nil or node.return_ == fiber then + return foundHostInstances + end + node = node.return_ + end + -- ROBLOX Luau FIXME: control flow analysis + assert(node.sibling ~= nil, "should be non-nil") + node.sibling.return_ = node.return_ + node = node.sibling + end + end + return false +end return exports diff --git a/modules/react-reconciler/src/ReactFiberHydrationContext.new.lua b/modules/react-reconciler/src/ReactFiberHydrationContext.new.lua index d1f076a4..c368f3c5 100644 --- a/modules/react-reconciler/src/ReactFiberHydrationContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberHydrationContext.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberHydrationContext.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,17 +10,16 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console -- 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) +local function unimplemented(message: string) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + print("UNIMPLEMENTED ERROR: " .. message) + error("FIXME (roblox): " .. message .. " is unimplemented", 2) end local ReactInternalTypes = require(script.Parent.ReactInternalTypes) @@ -32,7 +32,8 @@ type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance type Container = ReactFiberHostConfig.Container type HostContext = ReactFiberHostConfig.HostContext -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState local ReactWorkTags = require(script.Parent.ReactWorkTags) @@ -60,9 +61,12 @@ local canHydrateSuspenseInstance = ReactFiberHostConfig.canHydrateSuspenseInstan local hydrateInstance = ReactFiberHostConfig.hydrateInstance local hydrateTextInstance = ReactFiberHostConfig.hydrateTextInstance local hydrateSuspenseInstance = ReactFiberHostConfig.hydrateSuspenseInstance -local getNextHydratableInstanceAfterSuspenseInstance = ReactFiberHostConfig.getNextHydratableInstanceAfterSuspenseInstance -local didNotMatchHydratedContainerTextInstance = ReactFiberHostConfig.didNotMatchHydratedContainerTextInstance -local didNotMatchHydratedTextInstance = ReactFiberHostConfig.didNotMatchHydratedTextInstance +local getNextHydratableInstanceAfterSuspenseInstance = + ReactFiberHostConfig.getNextHydratableInstanceAfterSuspenseInstance +local didNotMatchHydratedContainerTextInstance = + ReactFiberHostConfig.didNotMatchHydratedContainerTextInstance +local didNotMatchHydratedTextInstance = + ReactFiberHostConfig.didNotMatchHydratedTextInstance local shouldSetTextContent = ReactFiberHostConfig.shouldSetTextContent -- local { @@ -75,7 +79,8 @@ local shouldSetTextContent = ReactFiberHostConfig.shouldSetTextContent -- didNotFindHydratableTextInstance, -- didNotFindHydratableSuspenseInstance, -- } = require(Packages../ReactFiberHostConfig' -local enableSuspenseServerRenderer = require(Packages.Shared).ReactFeatureFlags.enableSuspenseServerRenderer +local enableSuspenseServerRenderer = + require(Packages.Shared).ReactFeatureFlags.enableSuspenseServerRenderer local OffscreenLane = require(script.Parent.ReactFiberLane).OffscreenLane -- The deepest Fiber on the stack involved in a hydration context. @@ -85,440 +90,433 @@ local nextHydratableInstance: nil | HydratableInstance = nil local isHydrating: boolean = false function warnIfHydrating() - if _G.__DEV__ then - if isHydrating then - console.error( - 'We should not be hydrating here. This is a bug in React. Please file a bug.' - ) - end - end + if ReactGlobals.__DEV__ then + if isHydrating then + console.error( + "We should not be hydrating here. This is a bug in React. Please file a bug." + ) + end + end end function enterHydrationState(fiber: Fiber): boolean - if not supportsHydration then - return false - end - - local parentInstance = fiber.stateNode.containerInfo - nextHydratableInstance = getFirstHydratableChild(parentInstance) - hydrationParentFiber = fiber - isHydrating = true - return true + if not supportsHydration then + return false + end + + local parentInstance = fiber.stateNode.containerInfo + nextHydratableInstance = getFirstHydratableChild(parentInstance) + hydrationParentFiber = fiber + isHydrating = true + return true end function reenterHydrationStateFromDehydratedSuspenseInstance( - fiber: Fiber, - suspenseInstance: SuspenseInstance + fiber: Fiber, + suspenseInstance: SuspenseInstance ): boolean - if not supportsHydration then - return false - end - - nextHydratableInstance = getNextHydratableSibling(suspenseInstance) - popToNextHostParent(fiber) - isHydrating = true - return true + if not supportsHydration then + return false + end + + nextHydratableInstance = getNextHydratableSibling(suspenseInstance) + popToNextHostParent(fiber) + isHydrating = true + return true end -function deleteHydratableInstance( - returnFiber: Fiber?, - instance: HydratableInstance -) - unimplemented("deleteHydratableInstance") - -- if _G.__DEV__ then - -- switch (returnFiber.tag) - -- case HostRoot: - -- didNotHydrateContainerInstance( - -- returnFiber.stateNode.containerInfo, - -- instance, - -- ) - -- break - -- case HostComponent: - -- didNotHydrateInstance( - -- returnFiber.type, - -- returnFiber.memoizedProps, - -- returnFiber.stateNode, - -- instance, - -- ) - -- break - -- end - -- end - - -- local childToDelete = createFiberFromHostInstanceForDeletion() - -- childToDelete.stateNode = instance - -- childToDelete.return = returnFiber - - -- local deletions = returnFiber.deletions - -- if deletions == nil) - -- returnFiber.deletions = [childToDelete] - -- -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) - -- returnFiber.flags |= Deletion - -- } else { - -- deletions.push(childToDelete) - -- } +function deleteHydratableInstance(returnFiber: Fiber?, instance: HydratableInstance) + unimplemented("deleteHydratableInstance") + -- if ReactGlobals.__DEV__ then + -- switch (returnFiber.tag) + -- case HostRoot: + -- didNotHydrateContainerInstance( + -- returnFiber.stateNode.containerInfo, + -- instance, + -- ) + -- break + -- case HostComponent: + -- didNotHydrateInstance( + -- returnFiber.type, + -- returnFiber.memoizedProps, + -- returnFiber.stateNode, + -- instance, + -- ) + -- break + -- end + -- end + + -- local childToDelete = createFiberFromHostInstanceForDeletion() + -- childToDelete.stateNode = instance + -- childToDelete.return = returnFiber + + -- local deletions = returnFiber.deletions + -- if deletions == nil) + -- returnFiber.deletions = [childToDelete] + -- -- TODO (effects) Rename this to better reflect its new usage (e.g. ChildDeletions) + -- returnFiber.flags |= Deletion + -- } else { + -- deletions.push(childToDelete) + -- } end function insertNonHydratedInstance(returnFiber: Fiber?, fiber: Fiber) - unimplemented("insertNonHydratedInstance") - fiber.flags = bit32.bor(bit32.band(fiber.flags, bit32.bnot(Hydrating)), Placement) - if _G.__DEV__ then - -- switch (returnFiber.tag) - -- case HostRoot: { - -- local parentContainer = returnFiber.stateNode.containerInfo - -- switch (fiber.tag) - -- case HostComponent: - -- local type = fiber.type - -- local props = fiber.pendingProps - -- didNotFindHydratableContainerInstance(parentContainer, type, props) - -- break - -- case HostText: - -- local text = fiber.pendingProps - -- didNotFindHydratableContainerTextInstance(parentContainer, text) - -- break - -- case SuspenseComponent: - -- didNotFindHydratableContainerSuspenseInstance(parentContainer) - -- break - -- } - -- break - -- } - -- case HostComponent: { - -- local parentType = returnFiber.type - -- local parentProps = returnFiber.memoizedProps - -- local parentInstance = returnFiber.stateNode - -- switch (fiber.tag) - -- case HostComponent: - -- local type = fiber.type - -- local props = fiber.pendingProps - -- didNotFindHydratableInstance( - -- parentType, - -- parentProps, - -- parentInstance, - -- type, - -- props, - -- ) - -- break - -- case HostText: - -- local text = fiber.pendingProps - -- didNotFindHydratableTextInstance( - -- parentType, - -- parentProps, - -- parentInstance, - -- text, - -- ) - -- break - -- case SuspenseComponent: - -- didNotFindHydratableSuspenseInstance( - -- parentType, - -- parentProps, - -- parentInstance, - -- ) - -- break - -- } - -- break - -- } - -- default: - -- return - -- end - end + unimplemented("insertNonHydratedInstance") + fiber.flags = bit32.bor(bit32.band(fiber.flags, bit32.bnot(Hydrating)), Placement) + if ReactGlobals.__DEV__ then + -- switch (returnFiber.tag) + -- case HostRoot: { + -- local parentContainer = returnFiber.stateNode.containerInfo + -- switch (fiber.tag) + -- case HostComponent: + -- local type = fiber.type + -- local props = fiber.pendingProps + -- didNotFindHydratableContainerInstance(parentContainer, type, props) + -- break + -- case HostText: + -- local text = fiber.pendingProps + -- didNotFindHydratableContainerTextInstance(parentContainer, text) + -- break + -- case SuspenseComponent: + -- didNotFindHydratableContainerSuspenseInstance(parentContainer) + -- break + -- } + -- break + -- } + -- case HostComponent: { + -- local parentType = returnFiber.type + -- local parentProps = returnFiber.memoizedProps + -- local parentInstance = returnFiber.stateNode + -- switch (fiber.tag) + -- case HostComponent: + -- local type = fiber.type + -- local props = fiber.pendingProps + -- didNotFindHydratableInstance( + -- parentType, + -- parentProps, + -- parentInstance, + -- type, + -- props, + -- ) + -- break + -- case HostText: + -- local text = fiber.pendingProps + -- didNotFindHydratableTextInstance( + -- parentType, + -- parentProps, + -- parentInstance, + -- text, + -- ) + -- break + -- case SuspenseComponent: + -- didNotFindHydratableSuspenseInstance( + -- parentType, + -- parentProps, + -- parentInstance, + -- ) + -- break + -- } + -- break + -- } + -- default: + -- return + -- end + end end function tryHydrate(fiber, nextInstance) - if fiber.tag == HostComponent then - local type_ = fiber.type - local props = fiber.pendingProps - local instance = canHydrateInstance(nextInstance, type_, props) - if instance ~= nil then - fiber.stateNode = instance - return true - end - return false - elseif fiber.tag == HostText then - local text = fiber.pendingProps - local textInstance = canHydrateTextInstance(nextInstance, text) - if textInstance ~= nil then - fiber.stateNode = textInstance - return true - end - return false - elseif fiber.tag == SuspenseComponent then - if enableSuspenseServerRenderer then - local suspenseInstance: nil | SuspenseInstance = canHydrateSuspenseInstance( - nextInstance - ) - if suspenseInstance ~= nil then - local suspenseState: SuspenseState = { - dehydrated = suspenseInstance, - retryLane = OffscreenLane, - } - fiber.memoizedState = suspenseState - -- Store the dehydrated fragment as a child fiber. - -- This simplifies the code for getHostSibling and deleting nodes, - -- since it doesn't have to consider all Suspense boundaries and - -- check if they're dehydrated ones or not. - local dehydratedFragment = createFiberFromDehydratedFragment( - suspenseInstance - ) - dehydratedFragment.return_ = fiber - fiber.child = dehydratedFragment - return true - end - end - return false - else - return false - end + if fiber.tag == HostComponent then + local type_ = fiber.type + local props = fiber.pendingProps + local instance = canHydrateInstance(nextInstance, type_, props) + if instance ~= nil then + fiber.stateNode = instance + return true + end + return false + elseif fiber.tag == HostText then + local text = fiber.pendingProps + local textInstance = canHydrateTextInstance(nextInstance, text) + if textInstance ~= nil then + fiber.stateNode = textInstance + return true + end + return false + elseif fiber.tag == SuspenseComponent then + if enableSuspenseServerRenderer then + local suspenseInstance: nil | SuspenseInstance = + canHydrateSuspenseInstance(nextInstance) + if suspenseInstance ~= nil then + local suspenseState: SuspenseState = { + dehydrated = suspenseInstance, + retryLane = OffscreenLane, + } + fiber.memoizedState = suspenseState + -- Store the dehydrated fragment as a child fiber. + -- This simplifies the code for getHostSibling and deleting nodes, + -- since it doesn't have to consider all Suspense boundaries and + -- check if they're dehydrated ones or not. + local dehydratedFragment = + createFiberFromDehydratedFragment(suspenseInstance) + dehydratedFragment.return_ = fiber + fiber.child = dehydratedFragment + return true + end + end + return false + else + return false + end end function tryToClaimNextHydratableInstance(fiber: Fiber) - if not isHydrating then - return - end - local nextInstance = nextHydratableInstance - if not nextInstance then - -- Nothing to hydrate. Make it an insertion. - insertNonHydratedInstance(hydrationParentFiber, fiber) - isHydrating = false - hydrationParentFiber = fiber - return - end - local firstAttemptedInstance = nextInstance - if not tryHydrate(fiber, nextInstance) then - -- If we can't hydrate this instance let's try the next one. - -- We use this as a heuristic. It's based on intuition and not data so it - -- might be flawed or unnecessary. - nextInstance = getNextHydratableSibling(firstAttemptedInstance) - if not nextInstance or not tryHydrate(fiber, nextInstance) then - -- Nothing to hydrate. Make it an insertion. - insertNonHydratedInstance(hydrationParentFiber, fiber) - isHydrating = false - hydrationParentFiber = fiber - return - end - -- We matched the next one, we'll now assume that the first one was - -- superfluous and we'll delete it. Since we can't eagerly delete it - -- we'll have to schedule a deletion. To do that, this node needs a dummy - -- fiber associated with it. - deleteHydratableInstance( - hydrationParentFiber, - firstAttemptedInstance - ) - end - hydrationParentFiber = fiber - nextHydratableInstance = getFirstHydratableChild(nextInstance) + if not isHydrating then + return + end + local nextInstance = nextHydratableInstance + if not nextInstance then + -- Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance(hydrationParentFiber, fiber) + isHydrating = false + hydrationParentFiber = fiber + return + end + -- ROBLOX FIXME Luau: Luau doesn't narrow based on the guard above + local firstAttemptedInstance = nextInstance :: HydratableInstance + if not tryHydrate(fiber, nextInstance) then + -- If we can't hydrate this instance let's try the next one. + -- We use this as a heuristic. It's based on intuition and not data so it + -- might be flawed or unnecessary. + nextInstance = getNextHydratableSibling(firstAttemptedInstance) + if not nextInstance or not tryHydrate(fiber, nextInstance) then + -- Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance(hydrationParentFiber, fiber) + isHydrating = false + hydrationParentFiber = fiber + return + end + -- We matched the next one, we'll now assume that the first one was + -- superfluous and we'll delete it. Since we can't eagerly delete it + -- we'll have to schedule a deletion. To do that, this node needs a dummy + -- fiber associated with it. + deleteHydratableInstance(hydrationParentFiber, firstAttemptedInstance) + end + hydrationParentFiber = fiber + nextHydratableInstance = getFirstHydratableChild(nextInstance) end function prepareToHydrateHostInstance( - fiber: Fiber, - rootContainerInstance: Container, - hostContext: HostContext + fiber: Fiber, + rootContainerInstance: Container, + hostContext: HostContext ): boolean - if not supportsHydration then - invariant( - false, - 'Expected prepareToHydrateHostInstance() to never be called. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - end - - local instance: Instance = fiber.stateNode - local updatePayload = hydrateInstance( - instance, - fiber.type, - fiber.memoizedProps, - rootContainerInstance, - hostContext, - fiber - ) - -- TODO: Type this specific to this type of component. - fiber.updateQueue = updatePayload - -- If the update payload indicates that there is a change or if there - -- is a new ref we mark this as an update. - if updatePayload ~= nil then - return true - end - return false + if not supportsHydration then + invariant( + false, + "Expected prepareToHydrateHostInstance() to never be called. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + + local instance: Instance = fiber.stateNode + local updatePayload = hydrateInstance( + instance, + fiber.type, + fiber.memoizedProps, + rootContainerInstance, + hostContext, + fiber + ) + -- TODO: Type this specific to this type of component. + fiber.updateQueue = updatePayload + -- If the update payload indicates that there is a change or if there + -- is a new ref we mark this as an update. + if updatePayload ~= nil then + return true + end + return false end function prepareToHydrateHostTextInstance(fiber: Fiber): boolean - if not supportsHydration then - invariant( - false, - 'Expected prepareToHydrateHostTextInstance() to never be called. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - end - - local textInstance: TextInstance = fiber.stateNode - local textContent: string = fiber.memoizedProps - local shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber) - if _G.__DEV__ then - if shouldUpdate then - -- We assume that prepareToHydrateHostTextInstance is called in a context where the - -- hydration parent is the parent host component of this host text. - local returnFiber = hydrationParentFiber - if returnFiber ~= nil then - if returnFiber.tag == HostRoot then - local parentContainer = returnFiber.stateNode.containerInfo - didNotMatchHydratedContainerTextInstance( - parentContainer, - textInstance, - textContent - ) - elseif returnFiber.tag == HostComponent then - local parentType = returnFiber.type - local parentProps = returnFiber.memoizedProps - local parentInstance = returnFiber.stateNode - didNotMatchHydratedTextInstance( - parentType, - parentProps, - parentInstance, - textInstance, - textContent - ) - end - end - end - end - return shouldUpdate + if not supportsHydration then + invariant( + false, + "Expected prepareToHydrateHostTextInstance() to never be called. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + + local textInstance: TextInstance = fiber.stateNode + local textContent: string = fiber.memoizedProps + local shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber) + if ReactGlobals.__DEV__ then + if shouldUpdate then + -- We assume that prepareToHydrateHostTextInstance is called in a context where the + -- hydration parent is the parent host component of this host text. + local returnFiber = hydrationParentFiber + if returnFiber ~= nil then + if returnFiber.tag == HostRoot then + local parentContainer = returnFiber.stateNode.containerInfo + didNotMatchHydratedContainerTextInstance( + parentContainer, + textInstance, + textContent + ) + elseif returnFiber.tag == HostComponent then + local parentType = returnFiber.type + local parentProps = returnFiber.memoizedProps + local parentInstance = returnFiber.stateNode + didNotMatchHydratedTextInstance( + parentType, + parentProps, + parentInstance, + textInstance, + textContent + ) + end + end + end + end + return shouldUpdate end function prepareToHydrateHostSuspenseInstance(fiber: Fiber) - if not supportsHydration then - invariant( - false, - 'Expected prepareToHydrateHostSuspenseInstance() to never be called. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - end - - local suspenseState: SuspenseState = fiber.memoizedState - local suspenseInstance: nil | SuspenseInstance - if suspenseState ~= nil then - suspenseInstance = suspenseState.dehydrated - else - suspenseInstance = nil - end - - invariant( - suspenseInstance, - 'Expected to have a hydrated suspense instance. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - hydrateSuspenseInstance(suspenseInstance, fiber) + if not supportsHydration then + invariant( + false, + "Expected prepareToHydrateHostSuspenseInstance() to never be called. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + + local suspenseState: SuspenseState = fiber.memoizedState + local suspenseInstance: nil | SuspenseInstance + if suspenseState ~= nil then + suspenseInstance = suspenseState.dehydrated + else + suspenseInstance = nil + end + + invariant( + suspenseInstance, + "Expected to have a hydrated suspense instance. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + hydrateSuspenseInstance(suspenseInstance, fiber) end -function skipPastDehydratedSuspenseInstance( - fiber: Fiber -): nil | HydratableInstance - if not supportsHydration then - invariant( - false, - 'Expected skipPastDehydratedSuspenseInstance() to never be called. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - end - local suspenseState: SuspenseState = fiber.memoizedState - local suspenseInstance: nil | SuspenseInstance - if suspenseState ~= nil then - suspenseInstance = suspenseState.dehydrated - else - suspenseInstance = nil - end - invariant( - suspenseInstance, - 'Expected to have a hydrated suspense instance. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - return getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance) +function skipPastDehydratedSuspenseInstance(fiber: Fiber): nil | HydratableInstance + if not supportsHydration then + invariant( + false, + "Expected skipPastDehydratedSuspenseInstance() to never be called. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + end + local suspenseState: SuspenseState = fiber.memoizedState + local suspenseInstance: nil | SuspenseInstance + if suspenseState ~= nil then + suspenseInstance = suspenseState.dehydrated + else + suspenseInstance = nil + end + invariant( + suspenseInstance, + "Expected to have a hydrated suspense instance. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + return getNextHydratableInstanceAfterSuspenseInstance(suspenseInstance) end function popToNextHostParent(fiber: Fiber) - local parent = fiber.return_ - while - parent ~= nil and - parent.tag ~= HostComponent and - parent.tag ~= HostRoot and - parent.tag ~= SuspenseComponent - do - parent = parent.return_ - end - hydrationParentFiber = parent + local parent = fiber.return_ + while + parent ~= nil + and parent.tag ~= HostComponent + and parent.tag ~= HostRoot + and parent.tag ~= SuspenseComponent + do + parent = parent.return_ + end + hydrationParentFiber = parent end function popHydrationState(fiber: Fiber): boolean - if not supportsHydration then - return false - end - if fiber ~= hydrationParentFiber then - -- We're deeper than the current hydration context, inside an inserted - -- tree. - return false - end - if not isHydrating then - -- If we're not currently hydrating but we're in a hydration context, then - -- we were an insertion and now need to pop up reenter hydration of our - -- siblings. - popToNextHostParent(fiber) - isHydrating = true - return false - end - - local type_ = fiber.type - - -- If we have any remaining hydratable nodes, we need to delete them now. - -- We only do this deeper than head and body since they tend to have random - -- other nodes in them. We also ignore components with pure text content in - -- side of them. - -- TODO: Better heuristic. - if - fiber.tag ~= HostComponent or - (type_ ~= 'head' and - type_ ~= 'body' and - not shouldSetTextContent(type_, fiber.memoizedProps)) - then - local nextInstance = nextHydratableInstance - while nextInstance do - deleteHydratableInstance(fiber, nextInstance) - nextInstance = getNextHydratableSibling(nextInstance) - end - end - - popToNextHostParent(fiber) - if fiber.tag == SuspenseComponent then - nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber) - else - if hydrationParentFiber then - nextHydratableInstance = getNextHydratableSibling(fiber.stateNode) - else - nextHydratableInstance = nil - end - end - return true + if not supportsHydration then + return false + end + if fiber ~= hydrationParentFiber then + -- We're deeper than the current hydration context, inside an inserted + -- tree. + return false + end + if not isHydrating then + -- If we're not currently hydrating but we're in a hydration context, then + -- we were an insertion and now need to pop up reenter hydration of our + -- siblings. + popToNextHostParent(fiber) + isHydrating = true + return false + end + + local type_ = fiber.type + + -- If we have any remaining hydratable nodes, we need to delete them now. + -- We only do this deeper than head and body since they tend to have random + -- other nodes in them. We also ignore components with pure text content in + -- side of them. + -- TODO: Better heuristic. + if + fiber.tag ~= HostComponent + or ( + type_ ~= "head" + and type_ ~= "body" + and not shouldSetTextContent(type_, fiber.memoizedProps) + ) + then + local nextInstance = nextHydratableInstance + while nextInstance do + deleteHydratableInstance(fiber, nextInstance) + nextInstance = getNextHydratableSibling(nextInstance) + end + end + + popToNextHostParent(fiber) + if fiber.tag == SuspenseComponent then + nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber) + else + if hydrationParentFiber then + nextHydratableInstance = getNextHydratableSibling(fiber.stateNode) + else + nextHydratableInstance = nil + end + end + return true end function resetHydrationState() - if not supportsHydration then - return - end + if not supportsHydration then + return + end - hydrationParentFiber = nil - nextHydratableInstance = nil - isHydrating = false + hydrationParentFiber = nil + nextHydratableInstance = nil + isHydrating = false end function getIsHydrating(): boolean - return isHydrating + return isHydrating end return { - warnIfHydrating = warnIfHydrating, - enterHydrationState = enterHydrationState, - getIsHydrating = getIsHydrating, - reenterHydrationStateFromDehydratedSuspenseInstance = reenterHydrationStateFromDehydratedSuspenseInstance, - resetHydrationState = resetHydrationState, - tryToClaimNextHydratableInstance = tryToClaimNextHydratableInstance, - prepareToHydrateHostInstance = prepareToHydrateHostInstance, - prepareToHydrateHostTextInstance = prepareToHydrateHostTextInstance, - prepareToHydrateHostSuspenseInstance = prepareToHydrateHostSuspenseInstance, - popHydrationState = popHydrationState + warnIfHydrating = warnIfHydrating, + enterHydrationState = enterHydrationState, + getIsHydrating = getIsHydrating, + reenterHydrationStateFromDehydratedSuspenseInstance = reenterHydrationStateFromDehydratedSuspenseInstance, + resetHydrationState = resetHydrationState, + tryToClaimNextHydratableInstance = tryToClaimNextHydratableInstance, + prepareToHydrateHostInstance = prepareToHydrateHostInstance, + prepareToHydrateHostTextInstance = prepareToHydrateHostTextInstance, + prepareToHydrateHostSuspenseInstance = prepareToHydrateHostSuspenseInstance, + popHydrationState = popHydrationState, } diff --git a/modules/react-reconciler/src/ReactFiberLane.lua b/modules/react-reconciler/src/ReactFiberLane.lua index dfe699b2..074f60cb 100644 --- a/modules/react-reconciler/src/ReactFiberLane.lua +++ b/modules/react-reconciler/src/ReactFiberLane.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/6f62abb58ae46d9c88525635f1790487285666e6/packages/react-reconciler/src/ReactFiberLane.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/6f62abb58ae46d9c88525635f1790487285666e6/packages/react-reconciler/src/ReactFiberLane.js --!strict --[[ * Copyright (c) Facebook, Inc. and its affiliates. @@ -9,24 +9,26 @@ * @flow ]] local Packages = script.Parent.Parent -local LuauPolyfill = require(Packages.LuauPolyfill) - +local ReactGlobals = require(Packages.ReactGlobals) local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type FiberRoot = ReactInternalTypes.FiberRoot; -type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel; -local clz32 = LuauPolyfill.Math.clz32 - --- ROBLOX: use patched console from shared +type FiberRoot = ReactInternalTypes.FiberRoot +type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel local console = require(Packages.Shared).console -local ReactFiberSchedulerPriorities = require(script.Parent["ReactFiberSchedulerPriorities.roblox"]) + +local ReactFiberSchedulerPriorities = + require(script.Parent["ReactFiberSchedulerPriorities.roblox"]) + +local SafeFlags = require(Packages.SafeFlags) +local GetFFlagReactInlineMergeLanes = SafeFlags.createGetFFlag("ReactInlineMergeLanes") +local FFlagReactInlineMergeLanes = GetFFlagReactInlineMergeLanes() -- deviation: Instead of defining these here, and and re-exporting in -- `ReactInternalTypes`, we depend on and re-export them here to avoid cyclical -- require issues -export type LanePriority = ReactInternalTypes.LanePriority; -export type Lane = ReactInternalTypes.Lane; -export type Lanes = ReactInternalTypes.Lanes; -export type LaneMap = ReactInternalTypes.LaneMap; +export type LanePriority = ReactInternalTypes.LanePriority +export type Lane = ReactInternalTypes.Lane +export type Lanes = ReactInternalTypes.Lanes +export type LaneMap = ReactInternalTypes.LaneMap local invariant = require(Packages.Shared).invariant @@ -72,48 +74,69 @@ local OffscreenLanePriority: LanePriority = 1 local NoLanePriority: LanePriority = 0 exports.NoLanePriority = NoLanePriority -local TotalLanes = 31 +-- ROBLOX performance: only use was for loop that was manually unrolled +-- local TotalLanes = 31 -local NoLanes: Lanes = --[[ ]] 0b0000000000000000000000000000000 +local NoLanes: Lanes = --[[ ]] + 0b0000000000000000000000000000000 exports.NoLanes = NoLanes -local NoLane: Lane = --[[ ]] 0b0000000000000000000000000000000 +local NoLane: Lane = --[[ ]] + 0b0000000000000000000000000000000 exports.NoLane = NoLane -local SyncLane: Lane = --[[ ]] 0b0000000000000000000000000000001 +local SyncLane: Lane = --[[ ]] + 0b0000000000000000000000000000001 exports.SyncLane = SyncLane -local SyncBatchedLane: Lanes = --[[ ]] 0b0000000000000000000000000000010 +local SyncBatchedLane: Lanes = --[[ ]] + 0b0000000000000000000000000000010 exports.SyncBatchedLane = SyncBatchedLane -local InputDiscreteHydrationLane: Lane = --[[ ]] 0b0000000000000000000000000000100 +local InputDiscreteHydrationLane: Lane = --[[ ]] + 0b0000000000000000000000000000100 exports.InputDiscreteHydrationLane = InputDiscreteHydrationLane -local InputDiscreteLanes: Lanes = --[[ ]] 0b0000000000000000000000000011000 +local InputDiscreteLanes: Lanes = --[[ ]] + 0b0000000000000000000000000011000 -local InputContinuousHydrationLane: Lane = --[[ ]] 0b0000000000000000000000000100000 -local InputContinuousLanes: Lanes = --[[ ]] 0b0000000000000000000000011000000 +local InputContinuousHydrationLane: Lane = --[[ ]] + 0b0000000000000000000000000100000 +local InputContinuousLanes: Lanes = --[[ ]] + 0b0000000000000000000000011000000 -local DefaultHydrationLane: Lane = --[[ ]] 0b0000000000000000000000100000000 +local DefaultHydrationLane: Lane = --[[ ]] + 0b0000000000000000000000100000000 exports.DefaultHydrationLane = DefaultHydrationLane -local DefaultLanes: Lanes = --[[ ]] 0b0000000000000000000111000000000 +local DefaultLanes: Lanes = --[[ ]] + 0b0000000000000000000111000000000 exports.DefaultLanes = DefaultLanes -local TransitionHydrationLane: Lane = --[[ ]] 0b0000000000000000001000000000000 -local TransitionLanes: Lanes = --[[ ]] 0b0000000001111111110000000000000 - -local RetryLanes: Lanes = --[[ ]] 0b0000011110000000000000000000000 - -local SomeRetryLane: Lanes = --[[ ]] 0b0000010000000000000000000000000 +local TransitionHydrationLane: Lane = --[[ ]] + 0b0000000000000000001000000000000 +local TransitionLanes: Lanes = --[[ ]] + 0b0000000001111111110000000000000 + +local RetryLanes: Lanes = --[[ ]] + 0b0000011110000000000000000000000 +-- ROBLOX deviation: we expose this symbol from the module for ReactUpdateQueue.roblox tests, we don't re-export outside the package +exports.RetryLanes = RetryLanes +local SomeRetryLane: Lanes = --[[ ]] + 0b0000010000000000000000000000000 exports.SomeRetryLane = SomeRetryLane -local SelectiveHydrationLane: Lane = --[[ ]] 0b0000100000000000000000000000000 +local SelectiveHydrationLane: Lane = --[[ ]] + 0b0000100000000000000000000000000 exports.SelectiveHydrationLane = SelectiveHydrationLane -local NonIdleLanes = --[[ ]] 0b0000111111111111111111111111111 +local NonIdleLanes = --[[ ]] + 0b0000111111111111111111111111111 -local IdleHydrationLane: Lane = --[[ ]] 0b0001000000000000000000000000000 +local IdleHydrationLane: Lane = --[[ ]] + 0b0001000000000000000000000000000 exports.IdleHydrationLane = IdleHydrationLane -local IdleLanes: Lanes = --[[ ]] 0b0110000000000000000000000000000 +local IdleLanes: Lanes = --[[ ]] + 0b0110000000000000000000000000000 -local OffscreenLane: Lane = --[[ ]] 0b1000000000000000000000000000000 +local OffscreenLane: Lane = --[[ ]] + 0b1000000000000000000000000000000 exports.OffscreenLane = OffscreenLane local NoTimestamp = -1 @@ -133,7 +156,7 @@ end -- // Used by getHighestPriorityLanes and getNextLanes: local return_highestLanePriority: LanePriority = exports.DefaultLanePriority -local function getHighestPriorityLanes(lanes: Lanes | Lane) : Lanes +local function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes if bit32.band(SyncLane, lanes) ~= NoLanes then return_highestLanePriority = SyncLanePriority return SyncLane @@ -200,8 +223,8 @@ local function getHighestPriorityLanes(lanes: Lanes | Lane) : Lanes return_highestLanePriority = OffscreenLanePriority return OffscreenLane end - if _G.__DEV__ then - console.error('Should have found matching lanes. This is a bug in React.') + if ReactGlobals.__DEV__ then + console.error("Should have found matching lanes. This is a bug in React.") end -- // This shouldn't be reachable, but as a fallback, return the entire bitmask. return_highestLanePriority = DefaultLanePriority @@ -215,7 +238,8 @@ local function schedulerPriorityToLanePriority( return SyncLanePriority elseif schedulerPriorityLevel == UserBlockingSchedulerPriority then return InputContinuousLanePriority - elseif schedulerPriorityLevel == NormalSchedulerPriority + elseif + schedulerPriorityLevel == NormalSchedulerPriority or schedulerPriorityLevel == LowSchedulerPriority then -- // TODO: Handle LowSchedulerPriority, somehow. Maybe the same lane as hydration. @@ -231,27 +255,28 @@ exports.schedulerPriorityToLanePriority = schedulerPriorityToLanePriority local function lanePriorityToSchedulerPriority( lanePriority: LanePriority ): ReactPriorityLevel - if lanePriority == SyncLanePriority or - lanePriority == SyncBatchedLanePriority - then + if lanePriority == SyncLanePriority or lanePriority == SyncBatchedLanePriority then return ImmediateSchedulerPriority - elseif lanePriority == InputDiscreteHydrationLanePriority or - lanePriority == InputDiscreteLanePriority or - lanePriority == InputContinuousHydrationLanePriority or - lanePriority == InputContinuousLanePriority + elseif + lanePriority == InputDiscreteHydrationLanePriority + or lanePriority == InputDiscreteLanePriority + or lanePriority == InputContinuousHydrationLanePriority + or lanePriority == InputContinuousLanePriority then return UserBlockingSchedulerPriority - elseif lanePriority == DefaultHydrationLanePriority or - lanePriority == DefaultLanePriority or - lanePriority == TransitionHydrationPriority or - lanePriority == TransitionPriority or - lanePriority == SelectiveHydrationLanePriority or - lanePriority == RetryLanePriority + elseif + lanePriority == DefaultHydrationLanePriority + or lanePriority == DefaultLanePriority + or lanePriority == TransitionHydrationPriority + or lanePriority == TransitionPriority + or lanePriority == SelectiveHydrationLanePriority + or lanePriority == RetryLanePriority then return NormalSchedulerPriority - elseif lanePriority == IdleHydrationLanePriority or - lanePriority == IdleLanePriority or - lanePriority == OffscreenLanePriority + elseif + lanePriority == IdleHydrationLanePriority + or lanePriority == IdleLanePriority + or lanePriority == OffscreenLanePriority then return IdleSchedulerPriority elseif lanePriority == NoLanePriority then @@ -259,18 +284,17 @@ local function lanePriorityToSchedulerPriority( else invariant( false, - 'Invalid update priority: %s. This is a bug in React.', + "Invalid update priority: %s. This is a bug in React.", lanePriority ) -- deviation: luau doesn't know that invariant throws, so we error - error('unreachable') + error("unreachable") end end exports.lanePriorityToSchedulerPriority = lanePriorityToSchedulerPriority -- deviation: pre-declare pickArbitraryLaneIndex to keep ordering -local pickArbitraryLaneIndex -local getEqualOrHigherPriorityLanes +local pickArbitraryLaneIndex, getLowestPriorityLane, getEqualOrHigherPriorityLanes local function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes -- // Early bailout if there's no pending work left. @@ -297,7 +321,8 @@ local function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes -- // even if the work is suspended. local nonIdlePendingLanes = bit32.band(pendingLanes, NonIdleLanes) if nonIdlePendingLanes ~= NoLanes then - local nonIdleUnblockedLanes = bit32.band(nonIdlePendingLanes, bit32.bnot(suspendedLanes)) + local nonIdleUnblockedLanes = + bit32.band(nonIdlePendingLanes, bit32.bnot(suspendedLanes)) if nonIdleUnblockedLanes ~= NoLanes then nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes) nextLanePriority = return_highestLanePriority @@ -331,16 +356,20 @@ local function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes -- // If there are higher priority lanes, we'll include them even if they -- // are suspended. - nextLanes = bit32.band(pendingLanes, getEqualOrHigherPriorityLanes(nextLanes)) + -- ROBLOX performance: inline getEqualOrHigherPriorityLanes to avoid function call overhead in hot path + -- nextLanes = bit32.band(pendingLanes, getEqualOrHigherPriorityLanes(nextLanes)) + nextLanes = + bit32.band(pendingLanes, bit32.lshift(getLowestPriorityLane(nextLanes), 1) - 1) -- // If we're already in the middle of a render, switching lanes will interrupt -- // it and we'll lose our progress. We should only do this if the new lanes are -- // higher priority. - if wipLanes ~= NoLanes and - wipLanes ~= nextLanes and + if + wipLanes ~= NoLanes + and wipLanes ~= nextLanes -- // If we already suspended with a delay, then interrupting is fine. Don't -- // bother waiting until the root is complocale. - bit32.band(wipLanes, suspendedLanes) == NoLanes + and bit32.band(wipLanes, suspendedLanes) == NoLanes then getHighestPriorityLanes(wipLanes) local wipLanePriority = return_highestLanePriority @@ -459,8 +488,9 @@ local function markStarvedLanesAsExpired(root: FiberRoot, currentTime: number) -- // Found a pending lane with no expiration time. If it's not suspended, or -- // if it's pinged, assume it's CPU-bound. Compute a new expiration time -- // using the current time. - if bit32.band(lane, suspendedLanes) == NoLanes or - bit32.band(lane, pingedLanes) ~= NoLanes + if + bit32.band(lane, suspendedLanes) == NoLanes + or bit32.band(lane, pingedLanes) ~= NoLanes then -- // Assumes timestamps are monotonically increasing. expirationTimes[index] = computeExpirationTime(lane, currentTime) @@ -483,7 +513,8 @@ end exports.getHighestPriorityPendingLanes = getHighestPriorityPendingLanes local function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes - local everythingButOffscreen = bit32.band(root.pendingLanes, bit32.bnot(OffscreenLane)) + local everythingButOffscreen = + bit32.band(root.pendingLanes, bit32.bnot(OffscreenLane)) if everythingButOffscreen ~= NoLanes then return everythingButOffscreen end @@ -519,10 +550,7 @@ local pickArbitraryLane -- // To ensure consistency across multiple updates in the same event, this should -- // be a pure function, so that it always returns the same lane for given inputs. -local function findUpdateLane( - lanePriority: LanePriority, - wipLanes: Lanes -): Lane +local function findUpdateLane(lanePriority: LanePriority, wipLanes: Lanes): Lane if lanePriority == NoLanePriority then -- break elseif lanePriority == SyncLanePriority then @@ -530,21 +558,21 @@ local function findUpdateLane( elseif lanePriority == SyncBatchedLanePriority then return SyncBatchedLane elseif lanePriority == InputDiscreteLanePriority then - local lane = pickArbitraryLane(bit32.band(InputDiscreteLanes, bit32.bnot(wipLanes))) + local lane = + pickArbitraryLane(bit32.band(InputDiscreteLanes, bit32.bnot(wipLanes))) if lane == NoLane then -- // Shift to the next priority level return findUpdateLane(InputContinuousLanePriority, wipLanes) end return lane - elseif lanePriority == InputContinuousLanePriority then - local lane = pickArbitraryLane(bit32.band(InputContinuousLanes, bit32.bnot(wipLanes))) + local lane = + pickArbitraryLane(bit32.band(InputContinuousLanes, bit32.bnot(wipLanes))) if lane == NoLane then -- // Shift to the next priority level return findUpdateLane(DefaultLanePriority, wipLanes) end return lane - elseif lanePriority == DefaultLanePriority then local lane = pickArbitraryLane(bit32.band(DefaultLanes, bit32.bnot(wipLanes))) if lane == NoLane then @@ -559,9 +587,9 @@ local function findUpdateLane( end end return lane - - elseif lanePriority == TransitionPriority or -- // Should be handled by findTransitionLane instead - lanePriority == RetryLanePriority -- // Should be handled by findRetryLane instead + elseif + lanePriority == TransitionPriority -- // Should be handled by findTransitionLane instead + or lanePriority == RetryLanePriority -- // Should be handled by findRetryLane instead then -- break elseif lanePriority == IdleLanePriority then @@ -573,13 +601,9 @@ local function findUpdateLane( else -- // The remaining priorities are not valid for updates end - invariant( - false, - 'Invalid update priority: %s. This is a bug in React.', - lanePriority - ) + invariant(false, "Invalid update priority: %s. This is a bug in React.", lanePriority) -- deviation: luau doesn't know that invariant throws, so we error - error('unreachable') + error("unreachable") end exports.findUpdateLane = findUpdateLane @@ -621,9 +645,9 @@ local function getHighestPriorityLane(lanes: Lanes) return bit32.band(lanes, -lanes) end -local function getLowestPriorityLane(lanes: Lanes): Lane +function getLowestPriorityLane(lanes: Lanes): Lane -- // This finds the most significant non-zero bit. - local index = 31 - clz32(lanes) + local index = 31 - bit32.countlz(lanes) if index < 0 then return NoLanes else @@ -645,12 +669,13 @@ end exports.pickArbitraryLane = pickArbitraryLane function pickArbitraryLaneIndex(lanes: Lanes) - return 31 - clz32(lanes) + return 31 - bit32.countlz(lanes) end -local function laneToIndex(lane: Lane) - return pickArbitraryLaneIndex(lane) -end +-- ROBLOX performance: all uses have been inlined +-- local function laneToIndex(lane: Lane) +-- return pickArbitraryLaneIndex(lane) +-- end local function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane) return bit32.band(a, b) ~= NoLanes @@ -665,7 +690,10 @@ exports.isSubsetOfLanes = isSubsetOfLanes local function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes return bit32.bor(a, b) end -exports.mergeLanes = mergeLanes +type MergeLanes = (a: Lanes | Lane, b: Lanes | Lane) -> Lanes +exports.mergeLanes = ( + if FFlagReactInlineMergeLanes then bit32.bor else mergeLanes +) :: MergeLanes local function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes return bit32.band(set, bit32.bnot(subset)) @@ -695,10 +723,7 @@ local function higherPriorityLane(a: Lane, b: Lane) end exports.higherPriorityLane = higherPriorityLane -local function higherLanePriority( - a: LanePriority, - b: LanePriority -): LanePriority +local function higherLanePriority(a: LanePriority, b: LanePriority): LanePriority if a ~= NoLanePriority and a > b then return a else @@ -709,23 +734,51 @@ exports.higherLanePriority = higherLanePriority -- deviation: luau does not support generic functions yet -- local function createLaneMap(initial: T): LaneMap -local function createLaneMap(initial) +local function createLaneMap(initial): LaneMap -- // Intentionally pushing one by one. -- // https://v8.dev/blog/elements-kinds#avoid-creating-holes - local laneMap = {} - for i = 0, TotalLanes do - laneMap[i] = initial - end + -- ROBLOX performance: manually unroll this loop so the table creation is a one-shot + --for i = 0, TotalLanes do + local laneMap = { + [0] = initial, + [1] = initial, + [2] = initial, + [3] = initial, + [4] = initial, + [5] = initial, + [6] = initial, + [7] = initial, + [8] = initial, + [9] = initial, + [10] = initial, + [11] = initial, + [12] = initial, + [13] = initial, + [14] = initial, + [15] = initial, + [16] = initial, + [17] = initial, + [18] = initial, + [19] = initial, + [20] = initial, + [21] = initial, + [22] = initial, + [23] = initial, + [24] = initial, + [25] = initial, + [26] = initial, + [27] = initial, + [28] = initial, + [29] = initial, + [30] = initial, + [31] = initial, + -- ROBLOX TODO: must be updated when TotalLanes is updated! + } return laneMap - -- return table.create(TotalLanes, initial) end exports.createLaneMap = createLaneMap -local function markRootUpdated( - root: FiberRoot, - updateLane: Lane, - eventTime: number -) +local function markRootUpdated(root: FiberRoot, updateLane: Lane, eventTime: number) root.pendingLanes = bit32.bor(root.pendingLanes, updateLane) -- // TODO: Theoretically, any update to any lane can unblock any other lane. But @@ -745,7 +798,9 @@ local function markRootUpdated( root.pingedLanes = bit32.band(root.pingedLanes, higherPriorityLanes) local eventTimes = root.eventTimes - local index = laneToIndex(updateLane) + -- ROBLOX performance: inline laneToIndex in hot path + -- local index = laneToIndex(updateLane) + local index = 31 - bit32.countlz(updateLane) -- // We can always overwrite an existing timestamp because we prefer the most -- // recent event, and we assume time is monotonically increasing. eventTimes[index] = eventTime @@ -770,22 +825,21 @@ local function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) end exports.markRootSuspended = markRootSuspended -local function markRootPinged( - root: FiberRoot, - pingedLanes: Lanes, - eventTime: number -) - root.pingedLanes = bit32.bor(root.pingedLanes, bit32.band(root.suspendedLanes, pingedLanes)) +local function markRootPinged(root: FiberRoot, pingedLanes: Lanes, eventTime: number) + root.pingedLanes = + bit32.bor(root.pingedLanes, bit32.band(root.suspendedLanes, pingedLanes)) end exports.markRootPinged = markRootPinged local function markRootExpired(root: FiberRoot, expiredLanes: Lanes) - root.expiredLanes = bit32.bor(root.expiredLanes, bit32.band(expiredLanes, root.pendingLanes)) + root.expiredLanes = + bit32.bor(root.expiredLanes, bit32.band(expiredLanes, root.pendingLanes)) end exports.markRootExpired = markRootExpired local function markDiscreteUpdatesExpired(root: FiberRoot) - root.expiredLanes = bit32.bor(root.expiredLanes, bit32.band(InputDiscreteLanes, root.pendingLanes)) + root.expiredLanes = + bit32.bor(root.expiredLanes, bit32.band(InputDiscreteLanes, root.pendingLanes)) end exports.markDiscreteUpdatesExpired = markDiscreteUpdatesExpired @@ -795,7 +849,8 @@ end exports.hasDiscreteLanes = hasDiscreteLanes local function markRootMutableRead(root: FiberRoot, updateLane: Lane) - root.mutableReadLanes = bit32.bor(root.mutableReadLanes, bit32.band(updateLane, root.pendingLanes)) + root.mutableReadLanes = + bit32.bor(root.mutableReadLanes, bit32.band(updateLane, root.pendingLanes)) end exports.markRootMutableRead = markRootMutableRead @@ -848,33 +903,35 @@ local function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) end exports.markRootEntangled = markRootEntangled -local function getBumpedLaneForHydration( - root: FiberRoot, - renderLanes: Lanes -): Lane +local function getBumpedLaneForHydration(root: FiberRoot, renderLanes: Lanes): Lane getHighestPriorityLanes(renderLanes) local highestLanePriority = return_highestLanePriority local lane - if highestLanePriority == SyncLanePriority or - highestLanePriority == SyncBatchedLanePriority + if + highestLanePriority == SyncLanePriority + or highestLanePriority == SyncBatchedLanePriority then lane = NoLane - elseif highestLanePriority == InputDiscreteHydrationLanePriority or - highestLanePriority == InputDiscreteLanePriority + elseif + highestLanePriority == InputDiscreteHydrationLanePriority + or highestLanePriority == InputDiscreteLanePriority then lane = InputDiscreteHydrationLane - elseif highestLanePriority == InputContinuousHydrationLanePriority or - highestLanePriority == InputContinuousLanePriority + elseif + highestLanePriority == InputContinuousHydrationLanePriority + or highestLanePriority == InputContinuousLanePriority then lane = InputContinuousHydrationLane - elseif highestLanePriority == DefaultHydrationLanePriority or - highestLanePriority == DefaultLanePriority + elseif + highestLanePriority == DefaultHydrationLanePriority + or highestLanePriority == DefaultLanePriority then lane = DefaultHydrationLane - elseif highestLanePriority == TransitionHydrationPriority or - highestLanePriority == TransitionPriority + elseif + highestLanePriority == TransitionHydrationPriority + or highestLanePriority == TransitionPriority then lane = TransitionHydrationLane elseif highestLanePriority == RetryLanePriority then @@ -883,16 +940,18 @@ local function getBumpedLaneForHydration( lane = TransitionHydrationLane elseif highestLanePriority == SelectiveHydrationLanePriority then lane = SelectiveHydrationLane - elseif highestLanePriority == IdleHydrationLanePriority or - highestLanePriority == IdleLanePriority + elseif + highestLanePriority == IdleHydrationLanePriority + or highestLanePriority == IdleLanePriority then lane = IdleHydrationLane - elseif highestLanePriority == OffscreenLanePriority or - highestLanePriority == NoLanePriority + elseif + highestLanePriority == OffscreenLanePriority + or highestLanePriority == NoLanePriority then lane = NoLane else - invariant(false, 'Invalid lane: %s. This is a bug in React.', lane) + invariant(false, "Invalid lane: %s. This is a bug in React.", tostring(lane)) end -- // Check if the lane we chose is suspended. If so, that indicates that we diff --git a/modules/react-reconciler/src/ReactFiberLazyComponent.new.lua b/modules/react-reconciler/src/ReactFiberLazyComponent.new.lua index ca129e2a..98960f8e 100644 --- a/modules/react-reconciler/src/ReactFiberLazyComponent.new.lua +++ b/modules/react-reconciler/src/ReactFiberLazyComponent.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberLazyComponent.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberLazyComponent.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -8,19 +9,16 @@ * @flow ]] -local Packages = script.Parent.Parent -local LuauPolyfill = require(Packages.LuauPolyfill) -local Object = LuauPolyfill.Object - type Object = { [any]: any } local function resolveDefaultProps(Component: any, baseProps: Object): Object -- ROBLOX deviation: check if type is table before checking defaultProps to prevent non-table index - if typeof(Component) == 'table' and Component and Component.defaultProps then + if Component and typeof(Component) == "table" and Component.defaultProps then -- Resolve default props. Taken from ReactElement - local props = Object.assign({}, baseProps) + -- ROBLOX FIXME Luau: hard cast to object until we can model this better in Luau. avoids Expected type table, got 'Object & any & any & { [any]: any }' instead + local props = table.clone(baseProps) :: Object local defaultProps = Component.defaultProps - for propName, _ in pairs(defaultProps) do + for propName, _ in defaultProps do if props[propName] == nil then props[propName] = defaultProps[propName] end diff --git a/modules/react-reconciler/src/ReactFiberNewContext.new.lua b/modules/react-reconciler/src/ReactFiberNewContext.new.lua index 92ac8de7..3394bbba 100644 --- a/modules/react-reconciler/src/ReactFiberNewContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberNewContext.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/1eaafc9ade46ba708b2361b324dd907d019e3939/packages/react-reconciler/src/ReactFiberNewContext.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/1eaafc9ade46ba708b2361b324dd907d019e3939/packages/react-reconciler/src/ReactFiberNewContext.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,8 +10,10 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Number = LuauPolyfill.Number +local Error = LuauPolyfill.Error -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console @@ -19,10 +22,14 @@ local ReactTypes = require(Packages.Shared) type ReactContext = ReactTypes.ReactContext local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber -type ContextDependency = ReactInternalTypes.ContextDependency +type ContextDependency = ReactInternalTypes.ContextDependency local ReactFiberStack = require(script.Parent["ReactFiberStack.new"]) type StackCursor = ReactFiberStack.StackCursor +local ReactFiberLane = require(script.Parent.ReactFiberLane) +type Lanes = ReactFiberLane.Lanes +local ReactUpdateQueue = require(script.Parent["ReactUpdateQueue.new"]) +type SharedQueue = ReactUpdateQueue.SharedQueue local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) local isPrimaryRenderer = ReactFiberHostConfig.isPrimaryRenderer @@ -33,397 +40,395 @@ local MAX_SIGNED_31_BIT_INT = require(script.Parent.MaxInts).MAX_SIGNED_31_BIT_I local ReactWorkTags = require(script.Parent.ReactWorkTags) local ContextProvider = ReactWorkTags.ContextProvider local ClassComponent = ReactWorkTags.ClassComponent -local DehydratedFragment = ReactWorkTags.DehydratedFragment --- local includesSomeLane = require(script.Parent.ReactFiberLane).includesSomeLane -local ReactFiberLane = require(script.Parent.ReactFiberLane) --- local NoLanes = ReactFiberLane.NoLanes +-- local DehydratedFragment = ReactWorkTags.DehydratedFragment +local NoLanes = ReactFiberLane.NoLanes local NoTimestamp = ReactFiberLane.NoTimestamp local isSubsetOfLanes = ReactFiberLane.isSubsetOfLanes local includesSomeLane = ReactFiberLane.includesSomeLane local mergeLanes = ReactFiberLane.mergeLanes local pickArbitraryLane = ReactFiberLane.pickArbitraryLane --- local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes -local NoLanes = ReactFiberLane.NoLanes -local invariant = require(Packages.Shared).invariant local is = require(Packages.Shared).objectIs -local ReactUpdateQueue = require(script.Parent["ReactUpdateQueue.new"]) local createUpdate = ReactUpdateQueue.createUpdate -local enqueueUpdate = ReactUpdateQueue.enqueueUpdate local ForceUpdate = ReactUpdateQueue.ForceUpdate -- deviation: passed in as an arg to eliminate cycle -- local markWorkInProgressReceivedUpdate = require(script.Parent["ReactFiberBeginWork.new"]).markWorkInProgressReceivedUpdate -local enableSuspenseServerRenderer = require(Packages.Shared).ReactFeatureFlags.enableSuspenseServerRenderer +-- local enableSuspenseServerRenderer = require(Packages.Shared).ReactFeatureFlags.enableSuspenseServerRenderer local exports = {} local valueCursor: StackCursor = createCursor(nil) local rendererSigil -if _G.__DEV__ then - -- Use this to detect multiple renderers using the same context - rendererSigil = {} +if ReactGlobals.__DEV__ then + -- Use this to detect multiple renderers using the same context + rendererSigil = {} end -local currentlyRenderingFiber = nil --- FIXME (roblox): change to `ContextDependency` when ContextDependency --- type can be better aligned -local lastContextDependency = nil +local currentlyRenderingFiber: Fiber | nil = nil +local lastContextDependency: ContextDependency | nil = nil local lastContextWithAllBitsObserved: ReactContext | nil = nil local isDisallowedContextReadInDEV: boolean = false -exports.resetContextDependencies = function() - -- This is called right before React yields execution, to ensure `readContext` - -- cannot be called outside the render phase. - currentlyRenderingFiber = nil - lastContextDependency = nil - lastContextWithAllBitsObserved = nil - if _G.__DEV__ then - isDisallowedContextReadInDEV = false - end +exports.resetContextDependencies = function(): () + -- This is called right before React yields execution, to ensure `readContext` + -- cannot be called outside the render phase. + currentlyRenderingFiber = nil + lastContextDependency = nil + lastContextWithAllBitsObserved = nil + if ReactGlobals.__DEV__ then + isDisallowedContextReadInDEV = false + end end -exports.enterDisallowedContextReadInDEV = function() - if _G.__DEV__ then - isDisallowedContextReadInDEV = true - end +exports.enterDisallowedContextReadInDEV = function(): () + if ReactGlobals.__DEV__ then + isDisallowedContextReadInDEV = true + end end -exports.exitDisallowedContextReadInDEV = function() - if _G.__DEV__ then - isDisallowedContextReadInDEV = false - end +exports.exitDisallowedContextReadInDEV = function(): () + if ReactGlobals.__DEV__ then + isDisallowedContextReadInDEV = false + end end -exports.pushProvider = function(providerFiber: Fiber, nextValue) - local context: ReactContext = providerFiber.type._context - - if isPrimaryRenderer then - push(valueCursor, context._currentValue, providerFiber) - - context._currentValue = nextValue - if _G.__DEV__ then - if - context._currentRenderer ~= nil and - context._currentRenderer ~= rendererSigil - then - console.error( - 'Detected multiple renderers concurrently rendering the ' .. - 'same context provider. This is currently unsupported.' - ) - end - context._currentRenderer = rendererSigil - end - else - push(valueCursor, context._currentValue2, providerFiber) - - context._currentValue2 = nextValue - if _G.__DEV__ then - if - context._currentRenderer2 ~= nil and - context._currentRenderer2 ~= rendererSigil - then - console.error( - 'Detected multiple renderers concurrently rendering the ' .. - 'same context provider. This is currently unsupported.' - ) - end - context._currentRenderer2 = rendererSigil - end - end +exports.pushProvider = function(providerFiber: Fiber, nextValue: T): () + local context: ReactContext = providerFiber.type._context + + if isPrimaryRenderer then + push(valueCursor, context._currentValue, providerFiber) + + context._currentValue = nextValue + if ReactGlobals.__DEV__ then + if + context._currentRenderer ~= nil + and context._currentRenderer ~= rendererSigil + then + console.error( + "Detected multiple renderers concurrently rendering the " + .. "same context provider. This is currently unsupported." + ) + end + context._currentRenderer = rendererSigil + end + else + push(valueCursor, context._currentValue2, providerFiber) + + context._currentValue2 = nextValue + if ReactGlobals.__DEV__ then + if + context._currentRenderer2 ~= nil + and context._currentRenderer2 ~= rendererSigil + then + console.error( + "Detected multiple renderers concurrently rendering the " + .. "same context provider. This is currently unsupported." + ) + end + context._currentRenderer2 = rendererSigil + end + end end exports.popProvider = function(providerFiber: Fiber) - local currentValue = valueCursor.current + local currentValue = valueCursor.current - pop(valueCursor, providerFiber) + pop(valueCursor, providerFiber) - local context: ReactContext = providerFiber.type._context - if isPrimaryRenderer then - context._currentValue = currentValue - else - context._currentValue2 = currentValue - end + local context: ReactContext = providerFiber.type._context + if isPrimaryRenderer then + context._currentValue = currentValue + else + context._currentValue2 = currentValue + end end --- ROBLOX FIXME: Function generics, type refinement --- exports.calculateChangedBits = function( --- context: ReactContext, --- newValue: T, --- oldValue: T, --- ) -exports.calculateChangedBits = function( - context: any, - newValue: any, - oldValue: any +exports.calculateChangedBits = function( + context: ReactContext, + newValue: T, + oldValue: T ) - if is(oldValue, newValue) then - -- No change - return 0 - else - -- deviation: unravel ternary that's unsafe to translate - local changedBits = MAX_SIGNED_31_BIT_INT - if typeof(context._calculateChangedBits) == "function" then - changedBits = context._calculateChangedBits(oldValue, newValue) - end - - if _G.__DEV__ then - if bit32.band(changedBits, MAX_SIGNED_31_BIT_INT) ~= changedBits then - console.error( - "calculateChangedBits: Expected the return value to be a " .. - "31-bit integer. Instead received: %s", - changedBits - ) - end - end - -- deviation: JS does a bitwise OR with 0 presumably to floor the value and - -- coerce to an int; we just use math.floor - return math.floor(changedBits) - end + if is(oldValue, newValue) then + -- No change + return 0 + else + -- deviation: unravel ternary that's unsafe to translate + local changedBits = MAX_SIGNED_31_BIT_INT + if typeof(context._calculateChangedBits) == "function" then + changedBits = context._calculateChangedBits(oldValue, newValue) + end + + -- ROBLOX performance: eliminate nice-to-have compare in hot path that's removed in React 18 + -- if ReactGlobals.__DEV__ then + -- if bit32.band(changedBits, MAX_SIGNED_31_BIT_INT) ~= changedBits then + -- console.error( + -- "calculateChangedBits: Expected the return value to be a " .. + -- "31-bit integer. Instead received: %s", + -- changedBits + -- ) + -- end + -- end + -- deviation: JS does a bitwise OR with 0 presumably to floor the value and + -- coerce to an int; we just use math.floor + return math.floor(changedBits) + end end -exports.scheduleWorkOnParentPath = function( - parent: Fiber | nil, - renderLanes: Lanes -) - -- Update the child lanes of all the ancestors, including the alternates. - local node = parent - while node ~= nil do - local alternate = node.alternate - if not isSubsetOfLanes(node.childLanes, renderLanes) then - node.childLanes = mergeLanes(node.childLanes, renderLanes) - if alternate ~= nil then - alternate.childLanes = mergeLanes(alternate.childLanes, renderLanes) - end - elseif - alternate ~= nil and - not isSubsetOfLanes(alternate.childLanes, renderLanes) - then - alternate.childLanes = mergeLanes(alternate.childLanes, renderLanes) - else - -- Neither alternate was updated, which means the rest of the - -- ancestor path already has sufficient priority. - break - end - node = node.return_ - end +exports.scheduleWorkOnParentPath = function(parent: Fiber | nil, renderLanes: Lanes) + -- Update the child lanes of all the ancestors, including the alternates. + local node = parent + while node ~= nil do + local alternate = node.alternate + if not isSubsetOfLanes(node.childLanes, renderLanes) then + node.childLanes = mergeLanes(node.childLanes, renderLanes) + if alternate ~= nil then + alternate.childLanes = mergeLanes(alternate.childLanes, renderLanes) + end + elseif + alternate ~= nil and not isSubsetOfLanes(alternate.childLanes, renderLanes) + then + alternate.childLanes = mergeLanes(alternate.childLanes, renderLanes) + else + -- Neither alternate was updated, which means the rest of the + -- ancestor path already has sufficient priority. + break + end + node = node.return_ + end end -exports.propagateContextChange = function( - workInProgress: Fiber, - context: ReactContext, - changedBits: number, - renderLanes: Lanes -) - local fiber = workInProgress.child - if fiber ~= nil then - -- Set the return pointer of the child to the work-in-progress fiber. - fiber.return_ = workInProgress - end - while fiber ~= nil do - local nextFiber - - -- Visit this fiber. - local list = fiber.dependencies - if list ~= nil then - nextFiber = fiber.child - - local dependency = list.firstContext - while dependency ~= nil do - -- Check if the context matches. - if - dependency.context == context and - bit32.band(dependency.observedBits, changedBits) ~= 0 - then - -- Match! Schedule an update on this fiber. - - if fiber.tag == ClassComponent then - -- Schedule a force update on the work-in-progress. - local update = createUpdate( - NoTimestamp, - pickArbitraryLane(renderLanes) - ) - update.tag = ForceUpdate - -- TODO: Because we don't have a work-in-progress, this will add the - -- update to the current fiber, too, which means it will persist even if - -- this render is thrown away. Since it's a race condition, not sure it's - -- worth fixing. - enqueueUpdate(fiber, update) - end - fiber.lanes = mergeLanes(fiber.lanes, renderLanes) - local alternate = fiber.alternate - if alternate ~= nil then - alternate.lanes = mergeLanes(alternate.lanes, renderLanes) - end - exports.scheduleWorkOnParentPath(fiber.return_, renderLanes) - - -- Mark the updated lanes on the list, too. - list.lanes = mergeLanes(list.lanes, renderLanes) - - -- Since we already found a match, we can stop traversing the - -- dependency list. - break - end - dependency = dependency.next - end - elseif fiber.tag == ContextProvider then - -- Don't scan deeper if this is a matching provider - if fiber.type == workInProgress.type then - nextFiber = nil - else - nextFiber = fiber.child - end - elseif - enableSuspenseServerRenderer and - fiber.tag == DehydratedFragment - then - -- If a dehydrated suspense boundary is in this subtree, we don't know - -- if it will have any context consumers in it. The best we can do is - -- mark it as having updates. - local parentSuspense = fiber.return_ - invariant( - parentSuspense ~= nil, - "We just came from a parent so we must have had a parent. This is a bug in React." - ) - parentSuspense.lanes = mergeLanes(parentSuspense.lanes, renderLanes) - local alternate = parentSuspense.alternate - if alternate ~= nil then - alternate.lanes = mergeLanes(alternate.lanes, renderLanes) - end - -- This is intentionally passing this fiber as the parent - -- because we want to schedule this fiber as having work - -- on its children. We'll use the childLanes on - -- this fiber to indicate that a context has changed. - exports.scheduleWorkOnParentPath(parentSuspense, renderLanes) - nextFiber = fiber.sibling - else - -- Traverse down. - nextFiber = fiber.child - end - - if nextFiber ~= nil then - -- Set the return pointer of the child to the work-in-progress fiber. - nextFiber.return_ = fiber - else - -- No child. Traverse to next sibling. - nextFiber = fiber - while nextFiber ~= nil do - if nextFiber == workInProgress then - -- We're back to the root of this subtree. Exit. - nextFiber = nil - break - end - local sibling = nextFiber.sibling - if sibling ~= nil then - -- Set the return pointer of the sibling to the work-in-progress fiber. - sibling.return_ = nextFiber.return_ - nextFiber = sibling - break - end - -- No more siblings. Traverse up. - nextFiber = nextFiber.return_ - end - end - fiber = nextFiber - end +exports.propagateContextChange = function( + workInProgress: Fiber, + context: ReactContext, + changedBits: number, + renderLanes: Lanes +): () + local fiber = workInProgress.child + if fiber ~= nil then + -- Set the return pointer of the child to the work-in-progress fiber. + fiber.return_ = workInProgress + end + while fiber ~= nil do + local nextFiber + + -- Visit this fiber. + local list = fiber.dependencies + if list ~= nil then + nextFiber = fiber.child + + local dependency = list.firstContext + while dependency ~= nil do + -- Check if the context matches. + if + dependency.context == context + -- ROBLOX performance: unstable observedBits is removed in React 18 + and bit32.band(dependency.observedBits, changedBits) ~= 0 + then + -- Match! Schedule an update on this fiber. + + if fiber.tag == ClassComponent then + -- Schedule a force update on the work-in-progress. + local update = + createUpdate(NoTimestamp, pickArbitraryLane(renderLanes)) + update.tag = ForceUpdate + -- TODO: Because we don't have a work-in-progress, this will add the + -- update to the current fiber, too, which means it will persist even if + -- this render is thrown away. Since it's a race condition, not sure it's + -- worth fixing. + + -- Inlined `enqueueUpdate` to remove interleaved update check + local updateQueue = fiber.updateQueue + if updateQueue == nil then + -- Only occurs if the fiber has been unmounted. + else + local sharedQueue: SharedQueue = (updateQueue :: any).shared + local pending = sharedQueue.pending + if pending == nil then + -- This is the first update. Create a circular list. + update.next = update + else + update.next = pending.next + pending.next = update + end + sharedQueue.pending = update + end + end + + -- ROBLOX performance: inline mergeLanes(fiber.lanes, renderLanes) + fiber.lanes = bit32.bor(fiber.lanes, renderLanes) + local alternate = fiber.alternate + if alternate ~= nil then + -- ROBLOX performance: inline mergeLanes(alternate.lanes, renderLanes) + alternate.lanes = bit32.bor(alternate.lanes, renderLanes) + end + exports.scheduleWorkOnParentPath(fiber.return_, renderLanes) + + -- Mark the updated lanes on the list, too. + -- ROBLOX performance: inline mergeLanes(list.lanes, renderLanes) + list.lanes = bit32.bor(list.lanes, renderLanes) + + -- Since we already found a match, we can stop traversing the + -- dependency list. + break + end + dependency = dependency.next + end + elseif fiber.tag == ContextProvider then + -- Don't scan deeper if this is a matching provider + if fiber.type == workInProgress.type then + nextFiber = nil + else + nextFiber = fiber.child + end + -- ROBLOX performance: eliminate always-false compare in tab switching hot path + -- elseif + -- enableSuspenseServerRenderer and + -- fiber.tag == DehydratedFragment + -- then + -- -- If a dehydrated suspense boundary is in this subtree, we don't know + -- -- if it will have any context consumers in it. The best we can do is + -- -- mark it as having updates. + -- local parentSuspense = fiber.return_ + -- if parentSuspense == nil then + -- error("We just came from a parent so we must have had a parent. This is a bug in React.") + -- end + -- parentSuspense.lanes = mergeLanes(parentSuspense.lanes, renderLanes) + -- local alternate = parentSuspense.alternate + -- if alternate ~= nil then + -- alternate.lanes = mergeLanes(alternate.lanes, renderLanes) + -- end + -- -- This is intentionally passing this fiber as the parent + -- -- because we want to schedule this fiber as having work + -- -- on its children. We'll use the childLanes on + -- -- this fiber to indicate that a context has changed. + -- exports.scheduleWorkOnParentPath(parentSuspense, renderLanes) + -- nextFiber = fiber.sibling + else + -- Traverse down. + nextFiber = fiber.child + end + + if nextFiber ~= nil then + -- Set the return pointer of the child to the work-in-progress fiber. + nextFiber.return_ = fiber + else + -- No child. Traverse to next sibling. + nextFiber = fiber + while nextFiber ~= nil do + if nextFiber == workInProgress then + -- We're back to the root of this subtree. Exit. + nextFiber = nil + break + end + local sibling = nextFiber.sibling + if sibling ~= nil then + -- Set the return pointer of the sibling to the work-in-progress fiber. + sibling.return_ = nextFiber.return_ + nextFiber = sibling + break + end + -- No more siblings. Traverse up. + nextFiber = nextFiber.return_ + end + end + fiber = nextFiber + end end -- deviation: third argument added to eliminate cycle exports.prepareToReadContext = function( - workInProgress: Fiber, - renderLanes: Lanes, - markWorkInProgressReceivedUpdate: () -> () -) - currentlyRenderingFiber = workInProgress - lastContextDependency = nil - lastContextWithAllBitsObserved = nil - - local dependencies = workInProgress.dependencies - if dependencies ~= nil then - local firstContext = dependencies.firstContext - if firstContext ~= nil then - if includesSomeLane(dependencies.lanes, renderLanes) then - -- Context list has a pending update. Mark that this fiber performed work. - markWorkInProgressReceivedUpdate() - end - -- Reset the work-in-progress list - dependencies.firstContext = nil - end - end + workInProgress: Fiber, + renderLanes: Lanes, + markWorkInProgressReceivedUpdate: () -> () +): () + currentlyRenderingFiber = workInProgress + lastContextDependency = nil + lastContextWithAllBitsObserved = nil + + local dependencies = workInProgress.dependencies + if dependencies ~= nil then + local firstContext = dependencies.firstContext + if firstContext ~= nil then + if includesSomeLane(dependencies.lanes, renderLanes) then + -- Context list has a pending update. Mark that this fiber performed work. + markWorkInProgressReceivedUpdate() + end + -- Reset the work-in-progress list + dependencies.firstContext = nil + end + end end --- FIXME (roblox): introduce generic function signatures --- exports.readContext( --- context: ReactContext, --- observedBits: void | number | boolean, --- ): T { -exports.readContext = function( - context: ReactContext, - observedBits: nil | number | boolean -): any - if _G.__DEV__ then - -- This warning would fire if you read context inside a Hook like useMemo. - -- Unlike the class check below, it's not enforced in production for perf. - if isDisallowedContextReadInDEV then - console.error( - "Context can only be read while React is rendering. " .. - "In classes, you can read it in the render method or getDerivedStateFromProps. " .. - "In function components, you can read it directly in the function body, but not " .. - "inside Hooks like useReducer() or useMemo()." - ) - end - end - - if lastContextWithAllBitsObserved == context then - -- Nothing to do. We already observe everything in this context. - elseif observedBits == false or observedBits == 0 then - -- Do not observe any updates. - else - local resolvedObservedBits -- Avoid deopting on observable arguments or heterogeneous types. - if - typeof(observedBits) ~= "number" or - observedBits == Number.MAX_SAFE_INTEGER - then - -- Observe all updates. - -- lastContextWithAllBitsObserved = ((context: any): ReactContext) - lastContextWithAllBitsObserved = context - resolvedObservedBits = Number.MAX_SAFE_INTEGER - else - resolvedObservedBits = observedBits - end - - local contextItem = { - -- context: ((context: any): ReactContext), - context = context, - observedBits = resolvedObservedBits, - next = nil, - } - - if lastContextDependency == nil then - invariant( - currentlyRenderingFiber ~= nil, - "Context can only be read while React is rendering. " .. - "In classes, you can read it in the render method or getDerivedStateFromProps. " .. - "In function components, you can read it directly in the function body, but not " .. - "inside Hooks like useReducer() or useMemo()." - ) - - -- This is the first dependency for this component. Create a new list. - lastContextDependency = contextItem - currentlyRenderingFiber.dependencies = { - lanes = NoLanes, - firstContext = contextItem, - responders = nil, - } - else - -- Append a new context item. - lastContextDependency.next = contextItem - lastContextDependency = contextItem - end - end - return isPrimaryRenderer and context._currentValue or context._currentValue2 +exports.readContext = function( + context: ReactContext, + observedBits: nil | number | boolean +): T + if ReactGlobals.__DEV__ then + -- This warning would fire if you read context inside a Hook like useMemo. + -- Unlike the class check below, it's not enforced in production for perf. + if isDisallowedContextReadInDEV then + console.error( + "Context can only be read while React is rendering. " + .. "In classes, you can read it in the render method or getDerivedStateFromProps. " + .. "In function components, you can read it directly in the function body, but not " + .. "inside Hooks like useReducer() or useMemo()." + ) + end + end + + if lastContextWithAllBitsObserved == context then + -- Nothing to do. We already observe everything in this context. + elseif observedBits == false or observedBits == 0 then + -- Do not observe any updates. + else + local resolvedObservedBits -- Avoid deopting on observable arguments or heterogeneous types. + if + typeof(observedBits) ~= "number" + or observedBits == Number.MAX_SAFE_INTEGER + then + -- Observe all updates. + -- lastContextWithAllBitsObserved = ((context: any): ReactContext) + lastContextWithAllBitsObserved = context + resolvedObservedBits = Number.MAX_SAFE_INTEGER + else + resolvedObservedBits = observedBits + end + + local contextItem = { + -- context: ((context: any): ReactContext), + context = context, + observedBits = resolvedObservedBits, + next = nil, + } + + if lastContextDependency == nil then + if currentlyRenderingFiber == nil then + error( + Error.new( + "Context can only be read while React is rendering. " + .. "In classes, you can read it in the render method or getDerivedStateFromProps. " + .. "In function components, you can read it directly in the function body, but not " + .. "inside Hooks like useReducer() or useMemo()." + ) + ) + end + + -- This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + (currentlyRenderingFiber :: Fiber).dependencies = { + lanes = NoLanes, + firstContext = contextItem, + responders = nil, + } + else + -- Append a new context item. + (lastContextDependency :: any).next = contextItem + lastContextDependency = contextItem + end + end + return if isPrimaryRenderer then context._currentValue else context._currentValue2 end return exports diff --git a/modules/react-reconciler/src/ReactFiberOffscreenComponent.lua b/modules/react-reconciler/src/ReactFiberOffscreenComponent.lua index ba328509..ce0fe1b5 100644 --- a/modules/react-reconciler/src/ReactFiberOffscreenComponent.lua +++ b/modules/react-reconciler/src/ReactFiberOffscreenComponent.lua @@ -1,5 +1,5 @@ --- Upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberOffscreenComponent.js - +-- ROBLOX upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +--!strict -- /** -- * Copyright (c) Facebook, Inc. and its affiliates. -- * @@ -14,27 +14,26 @@ local ReactTypes = require(Packages.Shared) type ReactNodeList = ReactTypes.ReactNodeList local ReactFiberLanes = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLanes.Lanes; - +type Lanes = ReactFiberLanes.Lanes export type OffscreenProps = { - -- TODO: Pick an API before exposing the Offscreen type. I've chosen an enum - -- for now, since we might have multiple variants. For example, hiding the - -- content without changing the layout. - -- - -- Default mode is visible. Kind of a weird default for a component - -- called "Offscreen." Possible alt: ? - mode: string | nil, - children: ReactNodeList, + -- TODO: Pick an API before exposing the Offscreen type. I've chosen an enum + -- for now, since we might have multiple variants. For example, hiding the + -- content without changing the layout. + -- + -- Default mode is visible. Kind of a weird default for a component + -- called "Offscreen." Possible alt: ? + mode: string | nil, + children: ReactNodeList, } -- We use the existence of the state object as an indicator that the component -- is hidden. export type OffscreenState = { - -- TODO: This doesn't do anything, yet. It's always NoLanes. But eventually it - -- will represent the pending work that must be included in the render in - -- order to unhide the component. - baseLanes: Lanes + -- TODO: This doesn't do anything, yet. It's always NoLanes. But eventually it + -- will represent the pending work that must be included in the render in + -- order to unhide the component. + baseLanes: Lanes, } -return {} \ No newline at end of file +return {} diff --git a/modules/react-reconciler/src/ReactFiberReconciler.lua b/modules/react-reconciler/src/ReactFiberReconciler.lua index 32bb0c27..c3dd9ae7 100644 --- a/modules/react-reconciler/src/ReactFiberReconciler.lua +++ b/modules/react-reconciler/src/ReactFiberReconciler.lua @@ -1,4 +1,13 @@ --- upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-reconciler/src/ReactFiberReconciler.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/faa697f4f9afe9f1c98e315b2a9e70f5a74a7a74/packages/react-reconciler/src/ReactFiberReconciler.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 + ]] -- deviation: old version of reconciler not ported return require(script.Parent["ReactFiberReconciler.new"]) diff --git a/modules/react-reconciler/src/ReactFiberReconciler.new.lua b/modules/react-reconciler/src/ReactFiberReconciler.new.lua index 402d2bb7..63cc2535 100644 --- a/modules/react-reconciler/src/ReactFiberReconciler.new.lua +++ b/modules/react-reconciler/src/ReactFiberReconciler.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/50d9451f320a9aaf94304209193562cc385567d8/packages/react-reconciler/src/ReactFiberReconciler.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/50d9451f320a9aaf94304209193562cc385567d8/packages/react-reconciler/src/ReactFiberReconciler.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,32 +9,38 @@ ]] local Packages = script.Parent.Parent - -local Cryo = require(Packages.Cryo) +local ReactGlobals = require(Packages.ReactGlobals) +local flowtypes = require(Packages.Shared) +type React_Component = flowtypes.React_Component local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local Object = LuauPolyfill.Object +type Function = (...any) -> ...any + +local __DEV__ = ReactGlobals.__DEV__ :: boolean -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console -type Object = { [string]: any }; -type Array = { [number]: T }; +type Object = { [string]: any } +type Array = { [number]: T } local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type SuspenseHydrationCallbacks = ReactInternalTypes.SuspenseHydrationCallbacks; -type FiberRoot = ReactInternalTypes.FiberRoot; +type Fiber = ReactInternalTypes.Fiber +type SuspenseHydrationCallbacks = ReactInternalTypes.SuspenseHydrationCallbacks +type FiberRoot = ReactInternalTypes.FiberRoot local ReactRootTags = require(script.Parent.ReactRootTags) -type RootTag = ReactRootTags.RootTag; +type RootTag = ReactRootTags.RootTag + +local ReactFiberFlags = require(script.Parent.ReactFiberFlags) local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) --- type Instance = ReactFiberHostConfig.Instance; -type TextInstance = ReactFiberHostConfig.TextInstance; -type Container = ReactFiberHostConfig.Container; -type PublicInstance = ReactFiberHostConfig.PublicInstance; -type RendererInspectionConfig = ReactFiberHostConfig.RendererInspectionConfig; +type Instance = ReactFiberHostConfig.Instance +type TextInstance = ReactFiberHostConfig.TextInstance +type Container = ReactFiberHostConfig.Container +type PublicInstance = ReactFiberHostConfig.PublicInstance +type RendererInspectionConfig = ReactFiberHostConfig.RendererInspectionConfig local ReactWorkTags = require(script.Parent.ReactWorkTags) local FundamentalComponent = ReactWorkTags.FundamentalComponent @@ -42,14 +48,16 @@ local ReactTypes = require(Packages.Shared) type ReactNodeList = ReactTypes.ReactNodeList local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lane = ReactFiberLane.Lane; -type LanePriority = ReactFiberLane.LanePriority; -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) -type SuspenseState = ReactFiberSuspenseComponent.SuspenseState; +type Lane = ReactFiberLane.Lane +type LanePriority = ReactFiberLane.LanePriority +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) +type SuspenseState = ReactFiberSuspenseComponent.SuspenseState local ReactFiberTreeReflection = require(script.Parent.ReactFiberTreeReflection) local findCurrentHostFiber = ReactFiberTreeReflection.findCurrentHostFiber -local findCurrentHostFiberWithNoPortals = ReactFiberTreeReflection.findCurrentHostFiberWithNoPortals +local findCurrentHostFiberWithNoPortals = + ReactFiberTreeReflection.findCurrentHostFiberWithNoPortals local getInstance = require(Packages.Shared).ReactInstanceMap.get local HostComponent = ReactWorkTags.HostComponent local ClassComponent = ReactWorkTags.ClassComponent @@ -57,7 +65,9 @@ local HostRoot = ReactWorkTags.HostRoot local SuspenseComponent = ReactWorkTags.SuspenseComponent local getComponentName = require(Packages.Shared).getComponentName local invariant = require(Packages.Shared).invariant -local enableSchedulingProfiler = require(Packages.Shared).ReactFeatureFlags.enableSchedulingProfiler +local describeError = require(Packages.Shared).describeError +local enableSchedulingProfiler = + require(Packages.Shared).ReactFeatureFlags.enableSchedulingProfiler local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals local getPublicInstance = require(script.Parent.ReactFiberHostConfig).getPublicInstance local ReactFiberContext = require(script.Parent["ReactFiberContext.new"]) @@ -87,7 +97,7 @@ local flushPassiveEffects = ReactFiberWorkLoop.flushPassiveEffects local warnIfNotScopedWithMatchingAct = ReactFiberWorkLoop.warnIfNotScopedWithMatchingAct local warnIfUnmockedScheduler = ReactFiberWorkLoop.warnIfUnmockedScheduler local IsThisRendererActing = ReactFiberWorkLoop.IsThisRendererActing -local act = ReactFiberWorkLoop.act +local act = ReactFiberWorkLoop.act :: (() -> ()) -> () local ReactUpdateQueue = require(script.Parent["ReactUpdateQueue.new"]) local createUpdate = ReactUpdateQueue.createUpdate local enqueueUpdate = ReactUpdateQueue.enqueueUpdate @@ -107,26 +117,33 @@ local getHighestPriorityPendingLanes = ReactFiberLane.getHighestPriorityPendingL local higherPriorityLane = ReactFiberLane.higherPriorityLane local getCurrentUpdateLanePriority = ReactFiberLane.getCurrentUpdateLanePriority local setCurrentUpdateLanePriority = ReactFiberLane.setCurrentUpdateLanePriority --- local ReactFiberHotReloading = require(script.Parent["ReactFiberHotReloading.new"]) --- local scheduleRefresh = ReactFiberHotReloading.scheduleRefresh --- local scheduleRoot = ReactFiberHotReloading.scheduleRoot --- local setRefreshHandler = ReactFiberHotReloading.setRefreshHandler --- local findHostInstancesForRefresh = ReactFiberHotReloading.findHostInstancesForRefresh -local markRenderScheduled = require(script.Parent.SchedulingProfiler).markRenderScheduled +local ReactFiberHotReloading = require(script.Parent["ReactFiberHotReloading.new"]) +local scheduleRefresh = ReactFiberHotReloading.scheduleRefresh +local scheduleRoot = ReactFiberHotReloading.scheduleRoot +local setRefreshHandler = ReactFiberHotReloading.setRefreshHandler +local findHostInstancesForRefresh = ReactFiberHotReloading.findHostInstancesForRefresh +local SchedulingProfiler = require(script.Parent.SchedulingProfiler) +local markRenderScheduled = SchedulingProfiler.markRenderScheduled local exports = {} --- ROBLOX deviation: export internals used by the noop and test renderers +-- ROBLOX deviation: explicitly export internal type definitions used by the noop and test renderers exports.ReactRootTags = ReactRootTags --- ROBLOX deviation: export internals used by the test renderer +-- ROBLOX deviation: explicitly export internal type definitions used by the test renderer exports.ReactWorkTags = ReactWorkTags -exports.findCurrentFiberUsingSlowPath = ReactFiberTreeReflection.findCurrentFiberUsingSlowPath + +-- ROBLOX deviation: explicitly export internal type definitions used by the dev tools +exports.ReactTypeOfMode = ReactTypeOfMode +exports.ReactFiberFlags = ReactFiberFlags +exports.getNearestMountedFiber = ReactFiberTreeReflection.getNearestMountedFiber +exports.findCurrentFiberUsingSlowPath = + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath -- exports.registerMutableSourceForHydration = require(script.Parent["ReactMutableSource.new"]).registerMutableSourceForHydration exports.createPortal = require(script.Parent.ReactPortal).createPortal -- local ReactTestSelectors = require(script.Parent.ReactTestSelectors) -- exports.createComponentSelector = ReactTestSelectors.createComponentSelector --- -- FIXME (roblox): Should we deviate and fix this typo? +-- ROBLOX FIXME: Should we deviate and fix this typo? -- exports.createHasPsuedoClassSelector = ReactTestSelectors.createHasPsuedoClassSelector -- exports.createRoleSelector = ReactTestSelectors.createRoleSelector -- exports.createTestNameSelector = ReactTestSelectors.createTestNameSelector @@ -137,11 +154,11 @@ exports.createPortal = require(script.Parent.ReactPortal).createPortal -- exports.focusWithin = ReactTestSelectors.focusWithin -- exports.observeVisibleRects = ReactTestSelectors.observeVisibleRects -type OpaqueRoot = FiberRoot; +type OpaqueRoot = FiberRoot -- 0 is PROD, 1 is DEV. -- Might add PROFILE later. -type BundleType = number; +type BundleType = number type DevToolsConfig = { bundleType: BundleType, @@ -151,19 +168,17 @@ type DevToolsConfig = { -- Used by "inspect clicked DOM element" in React DevTools. findFiberByHostInstance: ((Instance | TextInstance) -> Fiber)?, rendererConfig: RendererInspectionConfig?, -}; +} local didWarnAboutNestedUpdates local didWarnAboutFindNodeInStrictMode -if _G.__DEV__ then +if __DEV__ then didWarnAboutNestedUpdates = false didWarnAboutFindNodeInStrictMode = {} end -local function getContextForSubtree( - parentComponent -): Object +local function getContextForSubtree(parentComponent: any?): Object if not parentComponent then return emptyContextObject end @@ -185,12 +200,13 @@ local function findHostInstance(component: Object): PublicInstance | nil local fiber = getInstance(component) if fiber == nil then if typeof(component.render) == "function" then - invariant(false, 'Unable to find node on an unmounted component.') + invariant(false, "Unable to find node on an unmounted component.") else invariant( false, - 'Argument appears to not be a ReactComponent. Keys: %s', - Cryo.Dictionary.keys(component) + "Argument appears to not be a ReactComponent. Keys: %s", + -- ROBLOX deviation: explicitly coerce the array of strings into a string + table.concat(Object.keys(component)) ) end end @@ -205,16 +221,17 @@ local function findHostInstanceWithWarning( component: Object, methodName: string ): PublicInstance | nil - if _G.__DEV__ then + if __DEV__ then local fiber = getInstance(component) if fiber == nil then if typeof(component.render) == "function" then - invariant(false, 'Unable to find node on an unmounted component.') + invariant(false, "Unable to find node on an unmounted component.") else invariant( false, - 'Argument appears to not be a ReactComponent. Keys: %s', - Cryo.Dictionary.keys(component) + "Argument appears to not be a ReactComponent. Keys: %s", + -- ROBLOX deviation: explicitly convert array into string + table.concat(Object.keys(component)) ) end end @@ -228,32 +245,32 @@ local function findHostInstanceWithWarning( didWarnAboutFindNodeInStrictMode[componentName] = true local previousFiber = ReactCurrentFiber.current - local ok, result = pcall(function() + local ok, result = xpcall(function() setCurrentDebugFiberInDEV(hostFiber) if bit32.band(fiber.mode, StrictMode) ~= 0 then console.error( - "%s is deprecated in StrictMode. " .. - "%s was passed an instance of %s which is inside StrictMode. " .. - "Instead, add a ref directly to the element you want to reference. " .. - "Learn more about using refs safely here: " .. - "https://reactjs.org/link/strict-mode-find-node", + "%s is deprecated in StrictMode. " + .. "%s was passed an instance of %s which is inside StrictMode. " + .. "Instead, add a ref directly to the element you want to reference. " + .. "Learn more about using refs safely here: " + .. "https://reactjs.org/link/strict-mode-find-node", methodName, methodName, componentName ) else console.error( - "%s is deprecated in StrictMode. " .. - "%s was passed an instance of %s which renders StrictMode children. " .. - "Instead, add a ref directly to the element you want to reference. " .. - "Learn more about using refs safely here: " .. - "https://reactjs.org/link/strict-mode-find-node", + "%s is deprecated in StrictMode. " + .. "%s was passed an instance of %s which renders StrictMode children. " + .. "Instead, add a ref directly to the element you want to reference. " + .. "Learn more about using refs safely here: " + .. "https://reactjs.org/link/strict-mode-find-node", methodName, methodName, componentName ) end - end) + end, describeError) -- Ideally this should reset to previous but this shouldn't be called in -- render and there's another warning for that anyway. @@ -286,17 +303,17 @@ exports.updateContainer = function( element: ReactNodeList, container: OpaqueRoot, parentComponent, - callback + callback: Function? ): Lane - if _G.__DEV__ then + if __DEV__ then onScheduleRoot(container, element) end local current = container.current local eventTime = requestEventTime() - if _G.__DEV__ then + if __DEV__ then -- deviation: use TestEZ's __TESTEZ_RUNNING_TEST__ (no jest global) -- $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests - if _G.__TESTEZ_RUNNING_TEST__ then + if ReactGlobals.__TESTEZ_RUNNING_TEST__ then warnIfUnmockedScheduler(current) warnIfNotScopedWithMatchingAct(current) end @@ -314,19 +331,19 @@ exports.updateContainer = function( container.pendingContext = context end - if _G.__DEV__ then + if __DEV__ then if - ReactCurrentFiberIsRendering and - ReactCurrentFiber.current ~= nil and - not didWarnAboutNestedUpdates + ReactCurrentFiberIsRendering + and ReactCurrentFiber.current ~= nil + and not didWarnAboutNestedUpdates then didWarnAboutNestedUpdates = true console.error( - "Render methods should be a pure function of props and state; " .. - "triggering nested component updates from render is not allowed. " .. - "If necessary, trigger nested updates in componentDidUpdate.\n\n" .. - "Check the render method of %s.", - getComponentName(ReactCurrentFiber.current.type) or "Unknown" + "Render methods should be a pure function of props and state; " + .. "triggering nested component updates from render is not allowed. " + .. "If necessary, trigger nested updates in componentDidUpdate.\n\n" + .. "Check the render method of %s.", + getComponentName((ReactCurrentFiber.current :: any).type) or "Unknown" ) end end @@ -346,12 +363,12 @@ exports.updateContainer = function( -- deviation: no undefined, so not needed -- callback = callback == undefined ? nil : callback if callback ~= nil then - if _G.__DEV__ then + if __DEV__ then if typeof(callback) ~= "function" then console.error( - "render(...): Expected the last optional `callback` argument to be a " .. - "function. Instead received: %s.", - callback + "render(...): Expected the last optional `callback` argument to be a " + .. "function. Instead received: %s.", + tostring(callback) ) end end @@ -379,8 +396,7 @@ exports.act = act exports.getPublicRootInstance = function( container: OpaqueRoot -): any --- ): React$Component | PublicInstance | nil { +): React_Component | PublicInstance | nil local containerFiber = container.current if not containerFiber.child then return nil @@ -420,10 +436,8 @@ local function markRetryLaneImpl(fiber: Fiber, retryLane: Lane) local suspenseState: SuspenseState? = fiber.memoizedState if suspenseState then if suspenseState ~= nil and suspenseState.dehydrated ~= nil then - suspenseState.retryLane = higherPriorityLane( - suspenseState.retryLane, - retryLane - ) + suspenseState.retryLane = + higherPriorityLane(suspenseState.retryLane, retryLane) end end end @@ -478,17 +492,16 @@ exports.attemptHydrationAtCurrentPriority = function(fiber: Fiber) markRetryLaneIfNotHydrated(fiber, lane) end --- exports.runWithPriority(priority: LanePriority, fn: () => T) -exports.runWithPriority = function(priority: LanePriority, fn: () -> any) +exports.runWithPriority = function(priority: LanePriority, fn: () -> T): T local previousPriority = getCurrentUpdateLanePriority() - local ok, result = pcall(function() - setCurrentUpdateLanePriority(priority) - return fn() - end) + -- ROBLOX performance: hoist non-throwable out of try{} to eliminate anon function + setCurrentUpdateLanePriority(priority) + local ok, result = xpcall(fn, describeError) setCurrentUpdateLanePriority(previousPriority) if not ok then error(result) end + return result end exports.getCurrentUpdateLanePriority = getCurrentUpdateLanePriority @@ -497,9 +510,7 @@ exports.findHostInstance = findHostInstance exports.findHostInstanceWithWarning = findHostInstanceWithWarning -exports.findHostInstanceWithNoPortals = function( - fiber: Fiber -): PublicInstance? +exports.findHostInstanceWithNoPortals = function(fiber: Fiber): PublicInstance? local hostFiber = findCurrentHostFiberWithNoPortals(fiber) if hostFiber == nil then return nil @@ -527,7 +538,7 @@ local overridePropsRenamePath = nil local scheduleUpdate = nil local setSuspenseHandler = nil -if _G.__DEV__ then +if __DEV__ then -- deviation: FIXME: obj: `Object | Array`, narrowing not possible with `isArray` local function copyWithDeleteImpl( obj: Object, @@ -539,7 +550,7 @@ if _G.__DEV__ then if Array.isArray(obj) then updated = Array.slice(obj) else - updated = Object.assign({}, obj) + updated = table.clone(obj) end if index + 1 == #path then if Array.isArray(updated) then @@ -576,7 +587,7 @@ if _G.__DEV__ then if Array.isArray(obj) then updated = Array.slice(obj) else - updated = Object.assign({}, obj) + updated = table.clone(obj) end if index + 1 == #oldPath then local newKey = newPath[index] @@ -613,7 +624,7 @@ if _G.__DEV__ then for i = 1, #newPath do if oldPath[i] ~= newPath[i] then console.warn( - 'copyWithRename() expects paths to be the same except for the deepest key' + "copyWithRename() expects paths to be the same except for the deepest key" ) return nil end @@ -629,7 +640,7 @@ if _G.__DEV__ then index: number, value: any ) - if index >= #path then + if index >= (#path + 1) then return value end local key = path[index] @@ -637,10 +648,10 @@ if _G.__DEV__ then if Array.isArray(obj) then updated = Array.slice(obj) else - updated = Object.assign({}, obj) + updated = table.clone(obj) end -- $FlowFixMe number or string is fine here - updated[key] = copyWithSetImpl(obj[key], path, index + 1, value) + updated[key] = copyWithSetImpl(obj[key], path, index + 2, value) return updated end @@ -650,14 +661,14 @@ if _G.__DEV__ then path: Array, value: any ): Object | Array - return copyWithSetImpl(obj, path, 0, value) + return copyWithSetImpl(obj, path, 1, value) end local function findHook(fiber: Fiber, id: number) -- For now, the "id" of stateful hooks is just the stateful hook index. -- This may change in the future with e.g. nested hooks. local currentHook = fiber.memoizedState - while currentHook ~= nil and id > 0 do + while currentHook ~= nil and id > 1 do currentHook = currentHook.next id -= 1 end @@ -682,7 +693,7 @@ if _G.__DEV__ then -- (There's no appropriate action type for DevTools overrides.) -- As a result though, React will see the scheduled update as a noop and bailout. -- Shallow cloning props works as a workaround for now to bypass the bailout check. - fiber.memoizedProps = Object.assign({}, fiber.memoizedProps) + fiber.memoizedProps = table.clone(fiber.memoizedProps) scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) end @@ -703,7 +714,7 @@ if _G.__DEV__ then -- (There's no appropriate action type for DevTools overrides.) -- As a result though, React will see the scheduled update as a noop and bailout. -- Shallow cloning props works as a workaround for now to bypass the bailout check. - fiber.memoizedProps = Object.assign({}, fiber.memoizedProps) + fiber.memoizedProps = table.clone(fiber.memoizedProps) scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) end @@ -725,7 +736,7 @@ if _G.__DEV__ then -- (There's no appropriate action type for DevTools overrides.) -- As a result though, React will see the scheduled update as a noop and bailout. -- Shallow cloning props works as a workaround for now to bypass the bailout check. - fiber.memoizedProps = Object.assign({}, fiber.memoizedProps) + fiber.memoizedProps = table.clone(fiber.memoizedProps) scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) end @@ -768,7 +779,7 @@ if _G.__DEV__ then scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp) end - setSuspenseHandler = function(newShouldSuspendImpl: (Fiber) -> (boolean)) + setSuspenseHandler = function(newShouldSuspendImpl: (Fiber) -> boolean) shouldSuspendImpl = newShouldSuspendImpl end end @@ -781,9 +792,7 @@ function findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | nil return hostFiber.stateNode end -function emptyFindFiberByHostInstance( - instance: Instance | TextInstance -): Fiber | nil +function emptyFindFiberByHostInstance(instance: Instance | TextInstance): Fiber | nil return nil end @@ -795,7 +804,7 @@ exports.injectIntoDevTools = function(devToolsConfig: DevToolsConfig): boolean local findFiberByHostInstance = devToolsConfig.findFiberByHostInstance local ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher local getCurrentFiber = nil - if _G.__DEV__ then + if __DEV__ then getCurrentFiber = getCurrentFiberForDevTools end return injectInternals({ @@ -813,17 +822,23 @@ exports.injectIntoDevTools = function(devToolsConfig: DevToolsConfig): boolean scheduleUpdate = scheduleUpdate, currentDispatcherRef = ReactCurrentDispatcher, findHostInstanceByFiber = findHostInstanceByFiber, - findFiberByHostInstance = - findFiberByHostInstance or emptyFindFiberByHostInstance, - -- FIXME: WIP - -- -- React Refresh - -- findHostInstancesForRefresh = _G.__DEV__ and findHostInstancesForRefresh or nil, - -- scheduleRefresh = _G.__DEV__ and scheduleRefresh or nil, - -- scheduleRoot = _G.__DEV__ and scheduleRoot or nil, - -- setRefreshHandler = _G.__DEV__ and setRefreshHandler or nil, + findFiberByHostInstance = findFiberByHostInstance or emptyFindFiberByHostInstance, + -- React Refresh + findHostInstancesForRefresh = if __DEV__ + then findHostInstancesForRefresh + else nil, + scheduleRefresh = if __DEV__ then scheduleRefresh else nil, + scheduleRoot = if __DEV__ then scheduleRoot else nil, + setRefreshHandler = if __DEV__ then setRefreshHandler else nil, -- Enables DevTools to append owner stacks to error messages in DEV mode. - getCurrentFiber = getCurrentFiber + getCurrentFiber = getCurrentFiber, }) end +exports.robloxReactProfiling = require(script.Parent.RobloxReactProfiling) +exports.schedulingProfiler = { + profilerEventTypes = SchedulingProfiler.profilerEventTypes, + registerProfilerEventCallback = SchedulingProfiler.registerProfilerEventCallback, +} + return exports diff --git a/modules/react-reconciler/src/ReactFiberRoot.new.lua b/modules/react-reconciler/src/ReactFiberRoot.new.lua index 59bbfaa5..23700a40 100644 --- a/modules/react-reconciler/src/ReactFiberRoot.new.lua +++ b/modules/react-reconciler/src/ReactFiberRoot.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/96ac799eace5d989de3b4f80e6414e94a08ff77a/packages/react-reconciler/src/ReactFiberRoot.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/96ac799eace5d989de3b4f80e6414e94a08ff77a/packages/react-reconciler/src/ReactFiberRoot.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,14 +10,17 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Set = LuauPolyfill.Set +local Map = LuauPolyfill.Map local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type FiberRoot = ReactInternalTypes.FiberRoot; -type SuspenseHydrationCallbacks = ReactInternalTypes.SuspenseHydrationCallbacks; +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot +type SuspenseHydrationCallbacks = ReactInternalTypes.SuspenseHydrationCallbacks local ReactRootTags = require(script.Parent.ReactRootTags) -type RootTag = ReactRootTags.RootTag; +type RootTag = ReactRootTags.RootTag local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) local noTimeout = ReactFiberHostConfig.noTimeout @@ -43,32 +47,34 @@ local ConcurrentRoot = ReactRootTags.ConcurrentRoot local exports = {} local function FiberRootNode(containerInfo, tag, hydrate) - local rootNode = {} - - rootNode.tag = tag - rootNode.containerInfo = containerInfo - rootNode.pendingChildren = nil - rootNode.current = nil - rootNode.pingCache = nil - rootNode.finishedWork = nil - rootNode.timeoutHandle = noTimeout - rootNode.context = nil - rootNode.pendingContext = nil - rootNode.hydrate = hydrate - rootNode.callbackNode = nil - rootNode.callbackPriority = NoLanePriority - rootNode.eventTimes = createLaneMap(NoLanes) - rootNode.expirationTimes = createLaneMap(NoTimestamp) - - rootNode.pendingLanes = NoLanes - rootNode.suspendedLanes = NoLanes - rootNode.pingedLanes = NoLanes - rootNode.expiredLanes = NoLanes - rootNode.mutableReadLanes = NoLanes - rootNode.finishedLanes = NoLanes - - rootNode.entangledLanes = NoLanes - rootNode.entanglements = createLaneMap(NoLanes) + -- ROBLOX performance: See if this kind of object init is faster in Luau + local rootNode = { + tag = tag, + containerInfo = containerInfo, + pendingChildren = nil, + -- ROBLOX TODO: this isn't typesafe upstream + current = (nil :: any) :: Fiber, + pingCache = nil, + finishedWork = nil, + timeoutHandle = noTimeout, + context = nil, + pendingContext = nil, + hydrate = hydrate, + callbackNode = nil, + callbackPriority = NoLanePriority, + eventTimes = createLaneMap(NoLanes), + expirationTimes = createLaneMap(NoTimestamp), + + pendingLanes = NoLanes, + suspendedLanes = NoLanes, + pingedLanes = NoLanes, + expiredLanes = NoLanes, + mutableReadLanes = NoLanes, + finishedLanes = NoLanes, + + entangledLanes = NoLanes, + entanglements = createLaneMap(NoLanes), + } if supportsHydration then rootNode.mutableSourceEagerHydrationData = nil @@ -77,13 +83,13 @@ local function FiberRootNode(containerInfo, tag, hydrate) if enableSchedulerTracing then rootNode.interactionThreadID = unstable_getThreadID() rootNode.memoizedInteractions = Set.new() - rootNode.pendingInteractionMap = {} + rootNode.pendingInteractionMap = Map.new() end if enableSuspenseCallback then rootNode.hydrationCallbacks = nil end - if _G.__DEV__ then + if ReactGlobals.__DEV__ then if tag == BlockingRoot then rootNode._debugRootType = "createBlockingRoot()" elseif tag == ConcurrentRoot then diff --git a/modules/react-reconciler/src/ReactFiberSchedulerPriorities.roblox.lua b/modules/react-reconciler/src/ReactFiberSchedulerPriorities.roblox.lua index d9176950..31906578 100644 --- a/modules/react-reconciler/src/ReactFiberSchedulerPriorities.roblox.lua +++ b/modules/react-reconciler/src/ReactFiberSchedulerPriorities.roblox.lua @@ -1,5 +1,5 @@ --!strict --- upstream https://github.com/facebook/react/blob/d17086c7c813402a550d15a2f56dc43f1dbd1735/packages/react-reconciler/src/SchedulerWithReactIntegration.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/d17086c7c813402a550d15a2f56dc43f1dbd1735/packages/react-reconciler/src/SchedulerWithReactIntegration.new.js --[[ * Copyright (c) Facebook, Inc. and its affiliates. * @@ -17,17 +17,16 @@ export type ReactPriorityLevel = number local exports: { [string]: ReactPriorityLevel } = { - -- // Except for NoPriority, these correspond to Scheduler priorities. We use - -- // ascending numbers so we can compare them like numbers. They start at 90 to - -- // avoid clashing with Scheduler's priorities. - ImmediatePriority = 99, - UserBlockingPriority = 98, - NormalPriority = 97, - LowPriority = 96, - IdlePriority = 95, - -- // NoPriority is the absence of priority. Also React-only. - NoPriority = 90, + -- // Except for NoPriority, these correspond to Scheduler priorities. We use + -- // ascending numbers so we can compare them like numbers. They start at 90 to + -- // avoid clashing with Scheduler's priorities. + ImmediatePriority = 99, + UserBlockingPriority = 98, + NormalPriority = 97, + LowPriority = 96, + IdlePriority = 95, + -- // NoPriority is the absence of priority. Also React-only. + NoPriority = 90, } - return exports diff --git a/modules/react-reconciler/src/ReactFiberStack.new.lua b/modules/react-reconciler/src/ReactFiberStack.new.lua index 43dca3d5..33c7a921 100644 --- a/modules/react-reconciler/src/ReactFiberStack.new.lua +++ b/modules/react-reconciler/src/ReactFiberStack.new.lua @@ -1,5 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberStack.new.js --- upstream https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberStack.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberStack.new.js --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -10,11 +9,12 @@ * @flow ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber type Array = { [number]: T } -- deviation: use this table when pushing nil values @@ -27,15 +27,13 @@ local valueStack: Array = {} local fiberStack: Array -if _G.__DEV__ then +if ReactGlobals.__DEV__ then fiberStack = {} end local index = 0 --- ROBLOX TODO: needs Luau function generics. --- local function createCursor(defaultValue: T): StackCursor -local function createCursor(defaultValue): StackCursor +local function createCursor(defaultValue: T): StackCursor return { current = defaultValue, } @@ -45,42 +43,39 @@ local function isEmpty(): boolean return index == 0 end --- ROBLOX TODO: needs Luau function generics. --- local function pop(cursor: StackCursor, fiber: Fiber) -local function pop(cursor: StackCursor, fiber: Fiber): () +local function pop(cursor: StackCursor, fiber: Fiber): () if index < 1 then - if _G.__DEV__ then - console.error('Unexpected pop.') + if ReactGlobals.__DEV__ then + console.error("Unexpected pop.") end return end - if _G.__DEV__ then + if ReactGlobals.__DEV__ then -- ROBLOX TODO: workaround for Luau analysis bug if fiber ~= fiberStack[index] :: Fiber then - console.error('Unexpected Fiber popped.') + console.error("Unexpected Fiber popped.") end end local value = valueStack[index] if value == NULL then - cursor.current = nil + -- ROBLOX TODO: this is a sort of incorrect typing in upstream + cursor.current = nil :: any else cursor.current = value end valueStack[index] = nil - if _G.__DEV__ then + if ReactGlobals.__DEV__ then fiberStack[index] = nil end index -= 1 end --- ROBLOX TODO: needs Luau function generics --- local function push(cursor: StackCursor, value: T, fiber: Fiber) -local function push(cursor: StackCursor, value: any, fiber: Fiber): () +local function push(cursor: StackCursor, value: T, fiber: Fiber): () index += 1 local stackValue = cursor.current @@ -90,7 +85,7 @@ local function push(cursor: StackCursor, value: any, fiber: Fiber): () valueStack[index] = stackValue end - if _G.__DEV__ then + if ReactGlobals.__DEV__ then fiberStack[index] = fiber end @@ -98,27 +93,19 @@ local function push(cursor: StackCursor, value: any, fiber: Fiber): () end local function checkThatStackIsEmpty() - if _G.__DEV__ then + if ReactGlobals.__DEV__ then if index ~= 0 then - console.error( - 'Expected an empty stack. Something was not reset properly.' - ) + console.error("Expected an empty stack. Something was not reset properly.") end end end local function resetStackAfterFatalErrorInDev() - if _G.__DEV__ then + if ReactGlobals.__DEV__ then index = 0 - -- deviation: FIXME: Original js simply sets `length`, we clear this - -- manually; would it be reasonable to just set them to new empty - -- tables? Does identity matter here? - for i = 1, #valueStack do - valueStack[i] = nil - end - for i = 1, #fiberStack do - fiberStack[i] = nil - end + -- ROBLOX deviation: Original js simply sets `length` + table.clear(valueStack) + table.clear(fiberStack) end end diff --git a/modules/react-reconciler/src/ReactFiberSuspenseComponent.new.lua b/modules/react-reconciler/src/ReactFiberSuspenseComponent.new.lua index 25e149ed..961a2f93 100644 --- a/modules/react-reconciler/src/ReactFiberSuspenseComponent.new.lua +++ b/modules/react-reconciler/src/ReactFiberSuspenseComponent.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/1faf9e3dd5d6492f3607d5c721055819e4106bc6/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -15,11 +16,11 @@ type ReactNodeList = ReactTypes.ReactNodeList type Wakeable = ReactTypes.Wakeable local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance; +type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lane = ReactFiberLane.Lane; +type Lane = ReactFiberLane.Lane local ReactWorkTags = require(script.Parent.ReactWorkTags) local SuspenseComponent = ReactWorkTags.SuspenseComponent local SuspenseListComponent = ReactWorkTags.SuspenseListComponent @@ -30,7 +31,7 @@ local isSuspenseInstancePending = ReactFiberHostConfig.isSuspenseInstancePending local isSuspenseInstanceFallback = ReactFiberHostConfig.isSuspenseInstanceFallback -- deviation: Common types -type Set = { [T]: boolean }; +type Set = { [T]: boolean } export type SuspenseProps = { children: ReactNodeList?, @@ -40,7 +41,7 @@ export type SuspenseProps = { suspenseCallback: (Set?) -> any, unstable_expectedLoadTime: number?, -}; +} -- A nil SuspenseState represents an unsuspended normal Suspense boundary. -- A non-null SuspenseState means that it is blocked for one reason or another. @@ -58,11 +59,11 @@ export type SuspenseState = { -- OffscreenLane is the default for dehydrated boundaries. -- NoLane is the default for normal boundaries, which turns into "normal" pri. retryLane: Lane, -}; +} -- deviation: Can't use literals for types -- export type SuspenseListTailMode = 'collapsed' | 'hidden' | void -export type SuspenseListTailMode = string?; +export type SuspenseListTailMode = string? export type SuspenseListRenderState = { isBackwards: boolean, @@ -76,7 +77,7 @@ export type SuspenseListRenderState = { tail: Fiber?, -- Tail insertions setting. tailMode: SuspenseListTailMode, -}; +} local exports = {} @@ -120,18 +121,18 @@ exports.findFirstSuspended = function(row: Fiber): Fiber? if state then local dehydrated: SuspenseInstance? = state.dehydrated if - dehydrated == nil or - isSuspenseInstancePending(dehydrated) or - isSuspenseInstanceFallback(dehydrated) + dehydrated == nil + or isSuspenseInstancePending(dehydrated) + or isSuspenseInstanceFallback(dehydrated) then return node end end elseif - node.tag == SuspenseListComponent and + node.tag == SuspenseListComponent -- revealOrder undefined can't be trusted because it don't -- keep track of whether it suspended or not. - node.memoizedProps.revealOrder ~= nil + and node.memoizedProps.revealOrder ~= nil then local didSuspend = bit32.band(node.flags, DidCapture) ~= NoFlags if didSuspend then @@ -149,10 +150,12 @@ exports.findFirstSuspended = function(row: Fiber): Fiber? if node.return_ == nil or node.return_ == row then return nil end - node = node.return_ + -- ROBLOX FIXME Luau: Luau narrowing doesn't understand this loop until nil pattern + node = node.return_ :: Fiber end - node.sibling.return_ = node.return_ - node = node.sibling + -- ROBLOX FIXME Luau: Luau narrowing doesn't understand this loop until nil pattern + (node.sibling :: Fiber).return_ = node.return_ + node = node.sibling :: Fiber end return nil end diff --git a/modules/react-reconciler/src/ReactFiberSuspenseContext.new.lua b/modules/react-reconciler/src/ReactFiberSuspenseContext.new.lua index ff1e244b..c3f9376c 100644 --- a/modules/react-reconciler/src/ReactFiberSuspenseContext.new.lua +++ b/modules/react-reconciler/src/ReactFiberSuspenseContext.new.lua @@ -1,6 +1,6 @@ --- upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js --!strict --- upstream https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/17f582e0453b808860be59ed3437c6a426ae52de/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -10,16 +10,11 @@ * @flow ]] --- deviation: ReactInternalTypes not implemented. Instead of just dropping --- the type, we are defining one so it'll be a minor refactor to switch to --- the futur FiberRoot type. -type Fiber = any +local ReactInternalTypes = require(script.Parent.ReactInternalTypes) +type Fiber = ReactInternalTypes.Fiber -local ReactFiberStack = require(script.Parent['ReactFiberStack.new']) --- deviation: until roblox-cli is able to trace requires with index --- expressions (CLI-31888), we can't import the type so we just copy it --- type StackCursor = ReactFiberStack.StackCursor -type StackCursor = { current: T } +local ReactFiberStack = require(script.Parent["ReactFiberStack.new"]) +type StackCursor = ReactFiberStack.StackCursor local createCursor = ReactFiberStack.createCursor local push = ReactFiberStack.push @@ -57,9 +52,8 @@ exports.InvisibleParentSuspenseContext = InvisibleParentSuspenseContext local ForceSuspenseFallback: ShallowSuspenseContext = 0b10 exports.ForceSuspenseFallback = ForceSuspenseFallback -local suspenseStackCursor: StackCursor = createCursor( - DefaultSuspenseContext -) +local suspenseStackCursor: StackCursor = + createCursor(DefaultSuspenseContext) exports.suspenseStackCursor = suspenseStackCursor function exports.hasSuspenseContext( @@ -79,7 +73,10 @@ function exports.setShallowSuspenseContext( parentContext: SuspenseContext, shallowContext: ShallowSuspenseContext ): SuspenseContext - return bit32.bor(bit32.band(parentContext, SubtreeSuspenseContextMask), shallowContext) + return bit32.bor( + bit32.band(parentContext, SubtreeSuspenseContextMask), + shallowContext + ) end function exports.addSubtreeSuspenseContext( @@ -89,10 +86,7 @@ function exports.addSubtreeSuspenseContext( return bit32.bor(parentContext, subtreeContext) end -function exports.pushSuspenseContext( - fiber: Fiber, - newContext: SuspenseContext -) +function exports.pushSuspenseContext(fiber: Fiber, newContext: SuspenseContext) push(suspenseStackCursor, newContext, fiber) end diff --git a/modules/react-reconciler/src/ReactFiberThrow.new.lua b/modules/react-reconciler/src/ReactFiberThrow.new.lua index 64b65b23..4725ae7a 100644 --- a/modules/react-reconciler/src/ReactFiberThrow.new.lua +++ b/modules/react-reconciler/src/ReactFiberThrow.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberThrow.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberThrow.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,26 +10,31 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Object = LuauPolyfill.Object - -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console +type Error = LuauPolyfill.Error +type Map = { [K]: V } +type Object = { [string]: any } +type Set = { [T]: boolean } local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type FiberRoot = ReactInternalTypes.FiberRoot; -type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel; +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot +type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes; -type Lane = ReactFiberLane.Lane; +type Lanes = ReactFiberLane.Lanes +type Lane = ReactFiberLane.Lane local ReactCapturedValue = require(script.Parent.ReactCapturedValue) type CapturedValue = ReactCapturedValue.CapturedValue local ReactUpdateQueue = require(script.Parent["ReactUpdateQueue.new"]) -type Update = ReactUpdateQueue.Update +type Update = ReactInternalTypes.Update local ReactTypes = require(Packages.Shared) -type Thenable = ReactTypes.Thenable +type React_Component = ReactTypes.React_Component +type Thenable = ReactTypes.Thenable type Wakeable = ReactTypes.Wakeable local ReactFiberSuspenseContext = require(script.Parent["ReactFiberSuspenseContext.new"]) @@ -49,7 +55,8 @@ local NoFlags = ReactFiberFlags.NoFlags local ShouldCapture = ReactFiberFlags.ShouldCapture local LifecycleEffectMask = ReactFiberFlags.LifecycleEffectMask local ForceUpdateForLegacySuspense = ReactFiberFlags.ForceUpdateForLegacySuspense -local shouldCaptureSuspense = require(script.Parent["ReactFiberSuspenseComponent.new"]).shouldCaptureSuspense +local shouldCaptureSuspense = + require(script.Parent["ReactFiberSuspenseComponent.new"]).shouldCaptureSuspense local ReactTypeOfMode = require(script.Parent.ReactTypeOfMode) local NoMode = ReactTypeOfMode.NoMode local BlockingMode = ReactTypeOfMode.BlockingMode @@ -64,13 +71,15 @@ local createUpdate = ReactUpdateQueue.createUpdate local CaptureUpdate = ReactUpdateQueue.CaptureUpdate local ForceUpdate = ReactUpdateQueue.ForceUpdate local enqueueUpdate = ReactUpdateQueue.enqueueUpdate -local markFailedErrorBoundaryForHotReloading = require(script.Parent["ReactFiberHotReloading.new"]).markFailedErrorBoundaryForHotReloading +local markFailedErrorBoundaryForHotReloading = require( + script.Parent["ReactFiberHotReloading.new"] +).markFailedErrorBoundaryForHotReloading local hasSuspenseContext = ReactFiberSuspenseContext.hasSuspenseContext -local InvisibleParentSuspenseContext = ReactFiberSuspenseContext.InvisibleParentSuspenseContext +local InvisibleParentSuspenseContext = + ReactFiberSuspenseContext.InvisibleParentSuspenseContext local suspenseStackCursor = ReactFiberSuspenseContext.suspenseStackCursor - -- ROBLOX FIXME: these will incur a dependency cycle -- onUncaughtError would be very easy to extract out, or to transplant into this file local ReactFiberWorkLoop @@ -78,11 +87,12 @@ local markLegacyErrorBoundaryAsFailedRef, isAlreadyFailedLegacyErrorBoundaryRef, -- ROBLOX deviation: lazy initialize ReactFiberWorkLoop to prevent cyclic module dependency local markLegacyErrorBoundaryAsFailed = function(...) - if not markLegacyErrorBoundaryAsFailedRef then - ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) - markLegacyErrorBoundaryAsFailedRef = ReactFiberWorkLoop.markLegacyErrorBoundaryAsFailed - end - return markLegacyErrorBoundaryAsFailedRef(...) + if not markLegacyErrorBoundaryAsFailedRef then + ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) + markLegacyErrorBoundaryAsFailedRef = + ReactFiberWorkLoop.markLegacyErrorBoundaryAsFailed + end + return markLegacyErrorBoundaryAsFailedRef(...) end -- local { -- renderDidError, @@ -92,23 +102,25 @@ end -- pingSuspendedRoot, -- } = require(Packages../ReactFiberWorkLoop.new' local pingSuspendedRoot = function(...) - if ReactFiberWorkLoop == nil then - ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) - end - pingSuspendedRootRef = ReactFiberWorkLoop.pingSuspendedRoot - return pingSuspendedRootRef(...) + if ReactFiberWorkLoop == nil then + ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) + end + pingSuspendedRootRef = ReactFiberWorkLoop.pingSuspendedRoot + return pingSuspendedRootRef(...) end local isAlreadyFailedLegacyErrorBoundary = function(...) - if ReactFiberWorkLoop == nil then - ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) - end - isAlreadyFailedLegacyErrorBoundaryRef = ReactFiberWorkLoop.isAlreadyFailedLegacyErrorBoundary - return isAlreadyFailedLegacyErrorBoundaryRef(...) + if ReactFiberWorkLoop == nil then + ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) + end + isAlreadyFailedLegacyErrorBoundaryRef = + ReactFiberWorkLoop.isAlreadyFailedLegacyErrorBoundary + return isAlreadyFailedLegacyErrorBoundaryRef(...) end local logCapturedError = require(script.Parent.ReactFiberErrorLogger).logCapturedError --- local {logComponentSuspended} = require(Packages../DebugTracing' -local markComponentSuspended = require(script.Parent.SchedulingProfiler).markComponentSuspended +local logComponentSuspended = require(script.Parent.DebugTracing).logComponentSuspended +local markComponentSuspended = + require(script.Parent.SchedulingProfiler).markComponentSuspended local SyncLane = ReactFiberLane.SyncLane local NoTimestamp = ReactFiberLane.NoTimestamp @@ -118,360 +130,361 @@ local pickArbitraryLane = ReactFiberLane.pickArbitraryLane -- local PossiblyWeakMap = typeof WeakMap == 'function' ? WeakMap : Map --- 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 - -type Set = { [T]: boolean } - function createRootErrorUpdate( - fiber: Fiber, - errorInfo: CapturedValue, - lane: Lane, - -- ROBLOX deviation: parameterize method to avoid circular dependency - onUncaughtError + fiber: Fiber, + errorInfo: CapturedValue, + lane: Lane, + -- ROBLOX deviation: parameterize method to avoid circular dependency + onUncaughtError ): Update - local update = createUpdate(NoTimestamp, lane) - -- Unmount the root by rendering nil. - update.tag = CaptureUpdate - -- Caution: React DevTools currently depends on this property - -- being called "element". - update.payload = {element = Object.None} - local _error = errorInfo.value - update.callback = function() - if onUncaughtError ~= nil then - onUncaughtError(_error) - end - logCapturedError(fiber, errorInfo) - end - return update + local update = createUpdate(NoTimestamp, lane) + -- Unmount the root by rendering nil. + update.tag = CaptureUpdate + -- Caution: React DevTools currently depends on this property + -- being called "element". + update.payload = { element = Object.None } + local _error = errorInfo.value + update.callback = function() + if onUncaughtError ~= nil then + onUncaughtError(_error) + end + logCapturedError(fiber, errorInfo) + end + return update end function createClassErrorUpdate( - fiber: Fiber, - errorInfo: CapturedValue, - lane: Lane + fiber: Fiber, + errorInfo: CapturedValue, + lane: Lane ): Update - local update = createUpdate(NoTimestamp, lane) - update.tag = CaptureUpdate - local getDerivedStateFromError = fiber.type.getDerivedStateFromError - if typeof(getDerivedStateFromError) == 'function' then - local error_ = errorInfo.value - update.payload = function() - logCapturedError(fiber, errorInfo) - return getDerivedStateFromError(error_) - end - end - - local inst = fiber.stateNode - if inst ~= nil and typeof(inst.componentDidCatch) == 'function' then - update.callback = function() - if _G.__DEV__ then - markFailedErrorBoundaryForHotReloading(fiber) - end - if typeof(getDerivedStateFromError) ~= 'function' then - -- To preserve the preexisting retry behavior of error boundaries, - -- we keep track of which ones already failed during this batch. - -- This gets reset before we yield back to the browser. - -- TODO: Warn in strict mode if getDerivedStateFromError is - -- not defined. - -- ROBLOX FIXME: used to be `this` upstream, needs verification by ReactIncremental unwinding test - markLegacyErrorBoundaryAsFailed(inst) - - -- Only log here if componentDidCatch is the only error boundary method defined - logCapturedError(fiber, errorInfo) - end - local error_ = errorInfo.value - local stack = errorInfo.stack - -- ROBLOX FIXME: used to be `this` upstream, needs verification by ReactIncremental unwinding test - inst:componentDidCatch(error_, { - componentStack = stack or '', - }) - if _G.__DEV__ then - if typeof(getDerivedStateFromError) ~= 'function' then - -- If componentDidCatch is the only error boundary method defined, - -- then it needs to call setState to recover from errors. - -- If no state update is scheduled then the boundary will swallow the error. - if not includesSomeLane(fiber.lanes, SyncLane) then - console.error( - '%s: Error boundaries should implement getDerivedStateFromError(). ' .. - 'In that method, return a state update to display an error message or fallback UI.', - getComponentName(fiber.type) or 'Unknown' - ) - end - end - end - end - elseif _G.__DEV__ then - update.callback = function() - markFailedErrorBoundaryForHotReloading(fiber) - end - end - return update + local update = createUpdate(NoTimestamp, lane) + update.tag = CaptureUpdate + local getDerivedStateFromError = (fiber.type :: React_Component).getDerivedStateFromError + if typeof(getDerivedStateFromError) == "function" then + local error_ = errorInfo.value + update.payload = function() + logCapturedError(fiber, errorInfo) + return getDerivedStateFromError(error_) + end + end + + local inst = fiber.stateNode + if inst ~= nil and typeof(inst.componentDidCatch) == "function" then + update.callback = function() + if ReactGlobals.__DEV__ then + markFailedErrorBoundaryForHotReloading(fiber) + end + if typeof(getDerivedStateFromError) ~= "function" then + -- To preserve the preexisting retry behavior of error boundaries, + -- we keep track of which ones already failed during this batch. + -- This gets reset before we yield back to the browser. + -- TODO: Warn in strict mode if getDerivedStateFromError is + -- not defined. + -- ROBLOX FIXME: used to be `this` upstream, needs verification by ReactIncremental unwinding test + markLegacyErrorBoundaryAsFailed(inst) + + -- Only log here if componentDidCatch is the only error boundary method defined + logCapturedError(fiber, errorInfo) + end + local error_ = errorInfo.value + local stack = errorInfo.stack + -- ROBLOX FIXME: used to be `this` upstream, needs verification by ReactIncremental unwinding test + inst:componentDidCatch(error_, { + componentStack = stack or "", + }) + if ReactGlobals.__DEV__ then + if typeof(getDerivedStateFromError) ~= "function" then + -- If componentDidCatch is the only error boundary method defined, + -- then it needs to call setState to recover from errors. + -- If no state update is scheduled then the boundary will swallow the error. + if not includesSomeLane(fiber.lanes, SyncLane) then + console.error( + "%s: Error boundaries should implement getDerivedStateFromError(). " + .. "In that method, return a state update to display an error message or fallback UI.", + getComponentName(fiber.type) or "Unknown" + ) + end + end + end + end + elseif ReactGlobals.__DEV__ then + update.callback = function() + markFailedErrorBoundaryForHotReloading(fiber) + end + end + return update end local function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) - -- Attach a listener to the promise to "ping" the root and retry. But only if - -- one does not already exist for the lanes we're currently rendering (which - -- acts like a "thread ID" here). - local pingCache = root.pingCache - local threadIDs - if pingCache == nil then - -- ROBLOX deviation: use table in place of WeakMap - root.pingCache = {} - pingCache = root.pingCache - threadIDs = {} - pingCache[wakeable] = threadIDs - else - threadIDs = pingCache[wakeable] - if threadIDs == nil then - threadIDs = {} - pingCache[wakeable] = threadIDs - end - end - if not threadIDs[lanes] then - -- Memoize using the thread ID to prevent redundant listeners. - threadIDs[lanes] = true - local ping = function() - return pingSuspendedRoot(root, wakeable, lanes) - end - wakeable:andThen(ping, ping) - end + -- Attach a listener to the promise to "ping" the root and retry. But only if + -- one does not already exist for the lanes we're currently rendering (which + -- acts like a "thread ID" here). + local pingCache: Map | Map>)> | nil = + root.pingCache + local threadIDs + if pingCache == nil then + -- ROBLOX deviation: use table in place of WeakMap + -- ROBLOX performance: slight re-ordering so we initialize the table in one shot + threadIDs = {} :: Set + root.pingCache = { + [wakeable] = threadIDs, + } :: Map | Map>)> + pingCache = root.pingCache :: Map | Map>)> + else + threadIDs = ( + pingCache :: Map | Map>)> + )[wakeable] :: Set + if threadIDs == nil then + threadIDs = {} :: Set; + (pingCache :: Map | Map>)>)[wakeable] = + threadIDs + end + end + if not threadIDs[lanes] then + -- Memoize using the thread ID to prevent redundant listeners. + threadIDs[lanes] = true + local ping = function() + return pingSuspendedRoot(root, wakeable, lanes) + end + wakeable:andThen(ping, ping) + end end function throwException( - root: FiberRoot, - returnFiber: Fiber, - sourceFiber: Fiber, - value: any, - rootRenderLanes: Lanes, - onUncaughtError, - renderDidError + root: FiberRoot, + returnFiber: Fiber, + sourceFiber: Fiber, + value: any, + rootRenderLanes: Lanes, + onUncaughtError, + renderDidError ) - -- The source fiber did not complete. - sourceFiber.flags = bit32.bor(sourceFiber.flags, Incomplete) - - if - value ~= nil and - typeof(value) == 'table' and - typeof(value.andThen) == 'function' - then - -- This is a wakeable. - local wakeable: Wakeable = value - - if _G.__DEV__ then - if enableDebugTracing then - if bit32.band(sourceFiber.mode, DebugTracingMode) ~= 0 then - local _name = getComponentName(sourceFiber.type) or 'Unknown' - unimplemented("logComponentSuspended") - -- logComponentSuspended(name, wakeable) - end - end - end - - if enableSchedulingProfiler then - markComponentSuspended(sourceFiber, wakeable) - end - - if bit32.band(sourceFiber.mode, BlockingMode) == NoMode then - -- Reset the memoizedState to what it was before we attempted - -- to render it. - local currentSource = sourceFiber.alternate - if currentSource then - sourceFiber.updateQueue = currentSource.updateQueue - sourceFiber.memoizedState = currentSource.memoizedState - sourceFiber.lanes = currentSource.lanes - else - sourceFiber.updateQueue = nil - sourceFiber.memoizedState = nil - end - end - - local hasInvisibleParentBoundary = hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext :: SuspenseContext) - ) - - -- Schedule the nearest Suspense to re-render the timed out view. - local workInProgress = returnFiber - repeat - if - workInProgress.tag == SuspenseComponent and - shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary) - then - -- Found the nearest boundary. - - -- Stash the promise on the boundary fiber. If the boundary times out, we'll - -- attach another listener to flip the boundary back to its normal state. - local wakeables: Set = workInProgress.updateQueue - if wakeables == nil then - local updateQueue = {} - updateQueue[wakeable] = true - workInProgress.updateQueue = updateQueue - else - wakeables[wakeable] = true - end - - -- If the boundary is outside of blocking mode, we should *not* - -- suspend the commit. Pretend as if the suspended component rendered - -- nil and keep rendering. In the commit phase, we'll schedule a - -- subsequent synchronous update to re-render the Suspense. - -- - -- Note: It doesn't matter whether the component that suspended was - -- inside a blocking mode tree. If the Suspense is outside of it, we - -- should *not* suspend the commit. - if bit32.band(workInProgress.mode, BlockingMode) == NoMode then - workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) - sourceFiber.flags = bit32.bor(sourceFiber.flags, ForceUpdateForLegacySuspense) - - -- We're going to commit this fiber even though it didn't complete. - -- But we shouldn't call any lifecycle methods or callbacks. Remove - -- all lifecycle effect tags. - sourceFiber.flags = bit32.band(sourceFiber.flags, bit32.bnot(bit32.bor(LifecycleEffectMask, Incomplete))) - - if sourceFiber.tag == ClassComponent then - local currentSourceFiber = sourceFiber.alternate - if currentSourceFiber == nil then - -- This is a new mount. Change the tag so it's not mistaken for a - -- completed class component. For example, we should not call - -- componentWillUnmount if it is deleted. - sourceFiber.tag = IncompleteClassComponent - else - -- When we try rendering again, we should not reuse the current fiber, - -- since it's known to be in an inconsistent state. Use a force update to - -- prevent a bail out. - local update = createUpdate(NoTimestamp, SyncLane) - update.tag = ForceUpdate - enqueueUpdate(sourceFiber, update) - end - end - - -- The source fiber did not complete. Mark it with Sync priority to - -- indicate that it still has pending work. - sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane) - - -- Exit without suspending. - return - end - - -- Confirmed that the boundary is in a concurrent mode tree. Continue - -- with the normal suspend path. - -- - -- After this we'll use a set of heuristics to determine whether this - -- render pass will run to completion or restart or "suspend" the commit. - -- The actual logic for this is spread out in different places. - -- - -- This first principle is that if we're going to suspend when we complete - -- a root, then we should also restart if we get an update or ping that - -- might unsuspend it, and vice versa. The only reason to suspend is - -- because you think you might want to restart before committing. However, - -- it doesn't make sense to restart only while in the period we're suspended. - -- - -- Restarting too aggressively is also not good because it starves out any - -- intermediate loading state. So we use heuristics to determine when. - - -- Suspense Heuristics - -- - -- If nothing threw a Promise or all the same fallbacks are already showing, - -- then don't suspend/restart. - -- - -- If this is an initial render of a new tree of Suspense boundaries and - -- those trigger a fallback, then don't suspend/restart. We want to ensure - -- that we can show the initial loading state as quickly as possible. - -- - -- If we hit a "Delayed" case, such as when we'd switch from content back into - -- a fallback, then we should always suspend/restart. Transitions apply - -- to this case. If none is defined, JND is used instead. - -- - -- If we're already showing a fallback and it gets "retried", allowing us to show - -- another level, but there's still an inner boundary that would show a fallback, - -- then we suspend/restart for 500ms since the last time we showed a fallback - -- anywhere in the tree. This effectively throttles progressive loading into a - -- consistent train of commits. This also gives us an opportunity to restart to - -- get to the completed state slightly earlier. - -- - -- If there's ambiguity due to batching it's resolved in preference of: - -- 1) "delayed", 2) "initial render", 3) "retry". - -- - -- We want to ensure that a "busy" state doesn't get force committed. We want to - -- ensure that new initial loading states can commit as soon as possible. - - attachPingListener(root, wakeable, rootRenderLanes) - - workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) - workInProgress.lanes = rootRenderLanes - - return - end - -- This boundary already captured during this render. Continue to the next - -- boundary. - workInProgress = workInProgress.return_ - until workInProgress == nil - -- No boundary was found. Fallthrough to error mode. - -- TODO: Use invariant so the message is stripped in prod? - value = - (getComponentName(sourceFiber.type) or 'A React component') .. - ' suspended while rendering, but no fallback UI was specified.\n' .. - '\n' .. - 'Add a component higher in the tree to ' .. - 'provide a loading indicator or placeholder to display.' - end - - -- We didn't find a boundary that could handle this type of exception. Start - -- over and traverse parent path again, this time treating the exception - -- as an error. - -- ROBLOX FIXME: fix the FiberThrow -> WorkLoop:renderDidError import cycle - renderDidError() - - value = createCapturedValue(value, sourceFiber) - local workInProgress = returnFiber - repeat - if workInProgress.tag == HostRoot then - local errorInfo = value - workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) - local lane = pickArbitraryLane(rootRenderLanes) - workInProgress.lanes = mergeLanes(workInProgress.lanes, lane) - -- ROBLOX deviation: parameterize method onUncaughtError to avoid circular dependency - local update = createRootErrorUpdate(workInProgress, errorInfo, lane, onUncaughtError) - enqueueCapturedUpdate(workInProgress, update) - return - elseif workInProgress.tag == ClassComponent then - -- Capture and retry - local errorInfo = value - local ctor = workInProgress.type - local instance = workInProgress.stateNode - if - bit32.band(workInProgress.flags, DidCapture) == NoFlags and - (typeof(ctor.getDerivedStateFromError) == 'function' or - (instance ~= nil and - typeof(instance.componentDidCatch) == 'function' and - not isAlreadyFailedLegacyErrorBoundary(instance))) - then - workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) - local lane = pickArbitraryLane(rootRenderLanes) - workInProgress.lanes = mergeLanes(workInProgress.lanes, lane) - -- Schedule the error boundary to re-render using updated state - local update = createClassErrorUpdate( - workInProgress, - errorInfo, - lane - ) - enqueueCapturedUpdate(workInProgress, update) - return - end - end - workInProgress = workInProgress.return_ - until workInProgress == nil + -- The source fiber did not complete. + sourceFiber.flags = bit32.bor(sourceFiber.flags, Incomplete) + + if + value ~= nil + and typeof(value) == "table" + and typeof(value.andThen) == "function" + then + -- This is a wakeable. + local wakeable: Wakeable = value + + if ReactGlobals.__DEV__ then + if enableDebugTracing then + if bit32.band(sourceFiber.mode, DebugTracingMode) ~= 0 then + local name = getComponentName(sourceFiber.type) or "Unknown" + logComponentSuspended(name, wakeable) + end + end + end + + if enableSchedulingProfiler then + markComponentSuspended(sourceFiber, wakeable) + end + + if bit32.band(sourceFiber.mode, BlockingMode) == NoMode then + -- Reset the memoizedState to what it was before we attempted + -- to render it. + local currentSource = sourceFiber.alternate + if currentSource then + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + sourceFiber.updateQueue = currentSource.updateQueue + sourceFiber.memoizedState = currentSource.memoizedState + sourceFiber.lanes = currentSource.lanes + else + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + sourceFiber.updateQueue = nil + sourceFiber.memoizedState = nil + end + end + + local hasInvisibleParentBoundary = hasSuspenseContext( + suspenseStackCursor.current, + InvisibleParentSuspenseContext :: SuspenseContext + ) + + -- Schedule the nearest Suspense to re-render the timed out view. + local workInProgress = returnFiber + repeat + if + workInProgress.tag == SuspenseComponent + and shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary) + then + -- Found the nearest boundary. + + -- Stash the promise on the boundary fiber. If the boundary times out, we'll + -- attach another listener to flip the boundary back to its normal state. + local wakeables: Set = workInProgress.updateQueue + if wakeables == nil then + local updateQueue = { + [wakeable] = true, + } + -- ROBLOX performance TODO: return non-nil updateQueue object to the ReactUpdateQUeue pool + workInProgress.updateQueue = updateQueue + else + wakeables[wakeable] = true + end + + -- If the boundary is outside of blocking mode, we should *not* + -- suspend the commit. Pretend as if the suspended component rendered + -- nil and keep rendering. In the commit phase, we'll schedule a + -- subsequent synchronous update to re-render the Suspense. + -- + -- Note: It doesn't matter whether the component that suspended was + -- inside a blocking mode tree. If the Suspense is outside of it, we + -- should *not* suspend the commit. + if bit32.band(workInProgress.mode, BlockingMode) == NoMode then + workInProgress.flags = bit32.bor(workInProgress.flags, DidCapture) + sourceFiber.flags = + bit32.bor(sourceFiber.flags, ForceUpdateForLegacySuspense) + + -- We're going to commit this fiber even though it didn't complete. + -- But we shouldn't call any lifecycle methods or callbacks. Remove + -- all lifecycle effect tags. + sourceFiber.flags = bit32.band( + sourceFiber.flags, + bit32.bnot(bit32.bor(LifecycleEffectMask, Incomplete)) + ) + + if sourceFiber.tag == ClassComponent then + local currentSourceFiber = sourceFiber.alternate + if currentSourceFiber == nil then + -- This is a new mount. Change the tag so it's not mistaken for a + -- completed class component. For example, we should not call + -- componentWillUnmount if it is deleted. + sourceFiber.tag = IncompleteClassComponent + else + -- When we try rendering again, we should not reuse the current fiber, + -- since it's known to be in an inconsistent state. Use a force update to + -- prevent a bail out. + local update = createUpdate(NoTimestamp, SyncLane) + update.tag = ForceUpdate + enqueueUpdate(sourceFiber, update) + end + end + + -- The source fiber did not complete. Mark it with Sync priority to + -- indicate that it still has pending work. + sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane) + + -- Exit without suspending. + return + end + + -- Confirmed that the boundary is in a concurrent mode tree. Continue + -- with the normal suspend path. + -- + -- After this we'll use a set of heuristics to determine whether this + -- render pass will run to completion or restart or "suspend" the commit. + -- The actual logic for this is spread out in different places. + -- + -- This first principle is that if we're going to suspend when we complete + -- a root, then we should also restart if we get an update or ping that + -- might unsuspend it, and vice versa. The only reason to suspend is + -- because you think you might want to restart before committing. However, + -- it doesn't make sense to restart only while in the period we're suspended. + -- + -- Restarting too aggressively is also not good because it starves out any + -- intermediate loading state. So we use heuristics to determine when. + + -- Suspense Heuristics + -- + -- If nothing threw a Promise or all the same fallbacks are already showing, + -- then don't suspend/restart. + -- + -- If this is an initial render of a new tree of Suspense boundaries and + -- those trigger a fallback, then don't suspend/restart. We want to ensure + -- that we can show the initial loading state as quickly as possible. + -- + -- If we hit a "Delayed" case, such as when we'd switch from content back into + -- a fallback, then we should always suspend/restart. Transitions apply + -- to this case. If none is defined, JND is used instead. + -- + -- If we're already showing a fallback and it gets "retried", allowing us to show + -- another level, but there's still an inner boundary that would show a fallback, + -- then we suspend/restart for 500ms since the last time we showed a fallback + -- anywhere in the tree. This effectively throttles progressive loading into a + -- consistent train of commits. This also gives us an opportunity to restart to + -- get to the completed state slightly earlier. + -- + -- If there's ambiguity due to batching it's resolved in preference of: + -- 1) "delayed", 2) "initial render", 3) "retry". + -- + -- We want to ensure that a "busy" state doesn't get force committed. We want to + -- ensure that new initial loading states can commit as soon as possible. + + attachPingListener(root, wakeable, rootRenderLanes) + + workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) + workInProgress.lanes = rootRenderLanes + + return + end + -- This boundary already captured during this render. Continue to the next + -- boundary. + workInProgress = workInProgress.return_ :: Fiber -- ROBLOX TODO: Luau narrowing doesn't understand this loop until nil pattern + until workInProgress == nil + + -- No boundary was found. Fallthrough to error mode. + -- TODO: Use invariant so the message is stripped in prod? + value = (getComponentName(sourceFiber.type) or "A React component") + .. " suspended while rendering, but no fallback UI was specified.\n" + .. "\n" + .. "Add a component higher in the tree to " + .. "provide a loading indicator or placeholder to display." + end + + -- We didn't find a boundary that could handle this type of exception. Start + -- over and traverse parent path again, this time treating the exception + -- as an error. + renderDidError() + + value = createCapturedValue(value, sourceFiber) + local workInProgress = returnFiber + repeat + if workInProgress.tag == HostRoot then + local errorInfo = value + workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) + local lane = pickArbitraryLane(rootRenderLanes) + workInProgress.lanes = mergeLanes(workInProgress.lanes, lane) + -- ROBLOX deviation: parameterize method onUncaughtError to avoid circular dependency + local update = + createRootErrorUpdate(workInProgress, errorInfo, lane, onUncaughtError) + enqueueCapturedUpdate(workInProgress, update) + return + elseif workInProgress.tag == ClassComponent then + -- Capture and retry + local errorInfo = value + local ctor = workInProgress.type + local instance = workInProgress.stateNode + if + bit32.band(workInProgress.flags, DidCapture) == NoFlags + and ( + typeof(ctor.getDerivedStateFromError) == "function" + or ( + instance ~= nil + and typeof(instance.componentDidCatch) == "function" + and not isAlreadyFailedLegacyErrorBoundary(instance) + ) + ) + then + workInProgress.flags = bit32.bor(workInProgress.flags, ShouldCapture) + local lane = pickArbitraryLane(rootRenderLanes) + workInProgress.lanes = mergeLanes(workInProgress.lanes, lane) + -- Schedule the error boundary to re-render using updated state + local update = createClassErrorUpdate(workInProgress, errorInfo, lane) + enqueueCapturedUpdate(workInProgress, update) + return + end + end + workInProgress = workInProgress.return_ :: Fiber -- ROBLOX TODO: Luau narrowing doesn't understand this loop until nil pattern + until workInProgress == nil end return { - throwException = throwException, - createRootErrorUpdate = createRootErrorUpdate, - createClassErrorUpdate = createClassErrorUpdate + throwException = throwException, + createRootErrorUpdate = createRootErrorUpdate, + createClassErrorUpdate = createClassErrorUpdate, } diff --git a/modules/react-reconciler/src/ReactFiberTransition.lua b/modules/react-reconciler/src/ReactFiberTransition.lua index f6858720..2c9c704b 100644 --- a/modules/react-reconciler/src/ReactFiberTransition.lua +++ b/modules/react-reconciler/src/ReactFiberTransition.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/ddd1faa1972b614dfbfae205f2aa4a6c0b39a759/packages/react-reconciler/src/ReactFiberTransition.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/ddd1faa1972b614dfbfae205f2aa4a6c0b39a759/packages/react-reconciler/src/ReactFiberTransition.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * diff --git a/modules/react-reconciler/src/ReactFiberTreeReflection.lua b/modules/react-reconciler/src/ReactFiberTreeReflection.lua index 28c9ad11..dbbe818a 100644 --- a/modules/react-reconciler/src/ReactFiberTreeReflection.lua +++ b/modules/react-reconciler/src/ReactFiberTreeReflection.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberTreeReflection.js +--!nonstrict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberTreeReflection.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,6 +10,7 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console @@ -17,9 +19,9 @@ type Fiber = ReactInternalTypes.Fiber local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) type Container = ReactFiberHostConfig.Container type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance --- local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) --- type SuspenseState = ReactFiberSuspenseComponent.SuspenseState; -type SuspenseState = any +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) +type SuspenseState = ReactFiberSuspenseComponent.SuspenseState local invariant = require(Packages.Shared).invariant @@ -47,7 +49,8 @@ local exports = {} local function getNearestMountedFiber(fiber: Fiber): Fiber? local node = fiber - local nearestMounted = fiber + -- ROBLOX FIXME Luau: Luau should infer this annotation + local nearestMounted: Fiber | nil = fiber if not fiber.alternate then -- If there is no alternate, this might be a new tree that isn't inserted -- yet. If it is, then it will have a pending insertion effect on it. @@ -95,17 +98,17 @@ exports.getSuspenseInstanceFromFiber = function(fiber: Fiber): SuspenseInstance? end exports.getContainerFromFiber = function(fiber: Fiber): Container? - return fiber.tag == HostRoot and fiber.stateNode.containerInfo or nil + return if fiber.tag == HostRoot then fiber.stateNode.containerInfo else nil end exports.isFiberMounted = function(fiber: Fiber): boolean return getNearestMountedFiber(fiber) == fiber end --- deviation: Missing React$ internal flow types +-- ROBLOX TODO: Missing React$ internal flow types -- exports.isMounted = function(component: React$Component): boolean exports.isMounted = function(component): boolean - if _G.__DEV__ then + if ReactGlobals.__DEV__ then local owner: any = ReactCurrentOwner.current if owner ~= nil and owner.tag == ClassComponent then local ownerFiber: Fiber = owner @@ -283,9 +286,8 @@ exports.findCurrentHostFiber = function(parent: Fiber): Fiber? end -- Next we'll drill down this component to find the first HostComponent/Text. - -- deviation: Type narrowing in Luau doesn't handle this well enough yet - -- local node: Fiber = currentParent - local node: Fiber = currentParent + -- ROBLOX FIXME Luau: Luau doesn't narrow based on above branch + local node: Fiber = currentParent :: Fiber while true do local child = node.child if node.tag == HostComponent or node.tag == HostText then @@ -304,10 +306,12 @@ exports.findCurrentHostFiber = function(parent: Fiber): Fiber? if not return_ or return_ == currentParent then return nil end - node = return_ + -- ROBLOX FIXME Luau: Luau doesn't narrow based on above branch + node = return_ :: Fiber end - sibling.return_ = return_ - node = sibling + -- ROBLOX FIXME Luau: Luau doesn't narrow based on above branch + (sibling :: Fiber).return_ = return_ :: Fiber + node = sibling :: Fiber end -- Flow needs the return nil here, but ESLint complains about it. -- eslint-disable-next-line no-unreachable @@ -321,7 +325,7 @@ exports.findCurrentHostFiberWithNoPortals = function(parent: Fiber): Fiber? end -- Next we'll drill down this component to find the first HostComponent/Text. - local node: Fiber = currentParent + local node: Fiber = currentParent :: Fiber while true do local child = node.child if @@ -344,10 +348,12 @@ exports.findCurrentHostFiberWithNoPortals = function(parent: Fiber): Fiber? if not return_ or return_ == currentParent then return nil end - node = return_ + -- ROBLOX FIXME Luau: Luau doesn't narrow based on above branch + node = return_ :: Fiber end - sibling.return_ = return_ - node = sibling + -- ROBLOX FIXME Luau: Luau doesn't narrow based on above branch + (sibling :: Fiber).return_ = return_ :: Fiber + node = sibling :: Fiber end -- Flow needs the return nil here, but ESLint complains about it. -- eslint-disable-next-line no-unreachable @@ -368,7 +374,8 @@ exports.doesFiberContain = function(parentFiber: Fiber, childFiber: Fiber): bool if node == parentFiber or node == parentFiberAlternate then return true end - node = node.return_ + -- ROBLOX FIXME Luau: Luau doesn't understand loop until not nil pattern + node = node.return_ :: Fiber end return false end diff --git a/modules/react-reconciler/src/ReactFiberUnwindWork.new.lua b/modules/react-reconciler/src/ReactFiberUnwindWork.new.lua index e9c8753a..c09d4166 100644 --- a/modules/react-reconciler/src/ReactFiberUnwindWork.new.lua +++ b/modules/react-reconciler/src/ReactFiberUnwindWork.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactFiberUnwindWork.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -11,13 +12,15 @@ local Packages = script.Parent.Parent local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; +type Fiber = ReactInternalTypes.Fiber local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes; -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +type Lanes = ReactFiberLane.Lanes +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState -local resetMutableSourceWorkInProgressVersions = require(script.Parent["ReactMutableSource.new"]).resetWorkInProgressVersions +local resetMutableSourceWorkInProgressVersions = + require(script.Parent["ReactMutableSource.new"]).resetWorkInProgressVersions local ReactWorkTags = require(script.Parent.ReactWorkTags) -- local {ReactFiberFlags.DidCapture, ReactFiberFlags.NoFlags, ReactFiberFlags.ShouldCapture} = require(script.Parent.ReactFiberFlags) local ReactFiberFlags = require(script.Parent.ReactFiberFlags) @@ -30,8 +33,10 @@ local enableProfilerTimer = ReactFeatureFlags.enableProfilerTimer local ReactFiberHostContext = require(script.Parent["ReactFiberHostContext.new"]) local popHostContainer = ReactFiberHostContext.popHostContainer local popHostContext = ReactFiberHostContext.popHostContext -local popSuspenseContext = require(script.Parent["ReactFiberSuspenseContext.new"]).popSuspenseContext -local resetHydrationState = require(script.Parent["ReactFiberHydrationContext.new"]).resetHydrationState +local popSuspenseContext = + require(script.Parent["ReactFiberSuspenseContext.new"]).popSuspenseContext +local resetHydrationState = + require(script.Parent["ReactFiberHydrationContext.new"]).resetHydrationState local ReactFiberContext = require(script.Parent["ReactFiberContext.new"]) local isLegacyContextProvider = ReactFiberContext.isContextProvider local popLegacyContext = ReactFiberContext.popContext @@ -40,127 +45,148 @@ local popProvider = require(script.Parent["ReactFiberNewContext.new"]).popProvid -- ROBLOX FIXME: this causes a circular require local popRenderLanesRef local popRenderLanes = function(...) - if not popRenderLanesRef then - popRenderLanesRef = require(script.Parent["ReactFiberWorkLoop.new"]).popRenderLanes - end - return popRenderLanesRef(...) + if not popRenderLanesRef then + popRenderLanesRef = + require(script.Parent["ReactFiberWorkLoop.new"]).popRenderLanes + end + return popRenderLanesRef(...) end -local transferActualDuration = require(script.Parent["ReactProfilerTimer.new"]).transferActualDuration +local transferActualDuration = + require(script.Parent["ReactProfilerTimer.new"]).transferActualDuration local invariant = require(Packages.Shared).invariant - -local function unwindWork(workInProgress: Fiber, renderLanes: Lanes) - if workInProgress.tag == ReactWorkTags.ClassComponent then - local Component = workInProgress.type - if isLegacyContextProvider(Component) then - popLegacyContext(workInProgress) - end - local flags = workInProgress.flags - if bit32.band(flags, ReactFiberFlags.ShouldCapture) ~= 0 then - workInProgress.flags = bit32.bor(bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), ReactFiberFlags.DidCapture) - if - enableProfilerTimer and - bit32.band(workInProgress.mode, ReactTypeOfMode.ProfileMode) ~= ReactTypeOfMode.NoMode - then - transferActualDuration(workInProgress) - end - return workInProgress - end - return nil - elseif workInProgress.tag == ReactWorkTags.HostRoot then - popHostContainer(workInProgress) - popTopLevelLegacyContextObject(workInProgress) - resetMutableSourceWorkInProgressVersions() - local flags = workInProgress.flags - invariant( - bit32.band(flags, ReactFiberFlags.DidCapture) == ReactFiberFlags.NoFlags, - 'The root failed to unmount after an error. This is likely a bug in ' .. - 'React. Please file an issue.' - ) - workInProgress.flags = bit32.bor(bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), ReactFiberFlags.DidCapture) - return workInProgress - elseif workInProgress.tag == ReactWorkTags.HostComponent then - -- TODO: popHydrationState - popHostContext(workInProgress) - return nil - elseif workInProgress.tag == ReactWorkTags.SuspenseComponent then - popSuspenseContext(workInProgress) - if enableSuspenseServerRenderer then - local suspenseState = - workInProgress.memoizedState - if suspenseState ~= nil and suspenseState.dehydrated ~= nil then - invariant( - workInProgress.alternate ~= nil, - 'Threw in newly mounted dehydrated component. This is likely a bug in ' .. - 'React. Please file an issue.' - ) - resetHydrationState() - end - end - local flags = workInProgress.flags - if bit32.band(flags, ReactFiberFlags.ShouldCapture) ~= 0 then - workInProgress.flags = bit32.bor(bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), ReactFiberFlags.DidCapture) - -- Captured a suspense effect. Re-render the boundary. - if - enableProfilerTimer and - (bit32.band(workInProgress.mode, ReactTypeOfMode.ProfileMode) ~= ReactTypeOfMode.NoMode) - then - transferActualDuration(workInProgress) - end - return workInProgress - end - return nil - elseif workInProgress.tag == ReactWorkTags.SuspenseListComponent then - popSuspenseContext(workInProgress) - -- SuspenseList doesn't actually catch anything. It should've been - -- caught by a nested boundary. If not, it should bubble through. - return nil - elseif workInProgress.tag == ReactWorkTags.HostPortal then - popHostContainer(workInProgress) - return nil - elseif workInProgress.tag == ReactWorkTags.ContextProvider then - popProvider(workInProgress) - return nil - elseif workInProgress.tag == ReactWorkTags.OffscreenComponent or - workInProgress.tag == ReactWorkTags.LegacyHiddenComponent then - popRenderLanes(workInProgress) - return nil - else - return nil - end +local function unwindWork(workInProgress: Fiber, renderLanes: Lanes): Fiber? + if workInProgress.tag == ReactWorkTags.ClassComponent then + local Component = workInProgress.type + if isLegacyContextProvider(Component) then + popLegacyContext(workInProgress) + end + local flags = workInProgress.flags + if bit32.band(flags, ReactFiberFlags.ShouldCapture) ~= 0 then + workInProgress.flags = bit32.bor( + bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), + ReactFiberFlags.DidCapture + ) + if + enableProfilerTimer + and bit32.band(workInProgress.mode, ReactTypeOfMode.ProfileMode) + ~= ReactTypeOfMode.NoMode + then + transferActualDuration(workInProgress) + end + return workInProgress + end + return nil + elseif workInProgress.tag == ReactWorkTags.HostRoot then + popHostContainer(workInProgress) + popTopLevelLegacyContextObject(workInProgress) + resetMutableSourceWorkInProgressVersions() + local flags = workInProgress.flags + invariant( + bit32.band(flags, ReactFiberFlags.DidCapture) == ReactFiberFlags.NoFlags, + "The root failed to unmount after an error. This is likely a bug in " + .. "React. Please file an issue." + ) + workInProgress.flags = bit32.bor( + bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), + ReactFiberFlags.DidCapture + ) + return workInProgress + elseif workInProgress.tag == ReactWorkTags.HostComponent then + -- TODO: popHydrationState + popHostContext(workInProgress) + return nil + elseif workInProgress.tag == ReactWorkTags.SuspenseComponent then + popSuspenseContext(workInProgress) + if enableSuspenseServerRenderer then + local suspenseState = workInProgress.memoizedState + if suspenseState ~= nil and suspenseState.dehydrated ~= nil then + invariant( + workInProgress.alternate ~= nil, + "Threw in newly mounted dehydrated component. This is likely a bug in " + .. "React. Please file an issue." + ) + resetHydrationState() + end + end + local flags = workInProgress.flags + if bit32.band(flags, ReactFiberFlags.ShouldCapture) ~= 0 then + workInProgress.flags = bit32.bor( + bit32.band(flags, bit32.bnot(ReactFiberFlags.ShouldCapture)), + ReactFiberFlags.DidCapture + ) + -- Captured a suspense effect. Re-render the boundary. + if + enableProfilerTimer + and ( + bit32.band(workInProgress.mode, ReactTypeOfMode.ProfileMode) + ~= ReactTypeOfMode.NoMode + ) + then + transferActualDuration(workInProgress) + end + return workInProgress + end + return nil + elseif workInProgress.tag == ReactWorkTags.SuspenseListComponent then + popSuspenseContext(workInProgress) + -- SuspenseList doesn't actually catch anything. It should've been + -- caught by a nested boundary. If not, it should bubble through. + return nil + elseif workInProgress.tag == ReactWorkTags.HostPortal then + popHostContainer(workInProgress) + return nil + elseif workInProgress.tag == ReactWorkTags.ContextProvider then + popProvider(workInProgress) + return nil + elseif + workInProgress.tag == ReactWorkTags.OffscreenComponent + or workInProgress.tag == ReactWorkTags.LegacyHiddenComponent + then + popRenderLanes(workInProgress) + return nil + else + return nil + end end function unwindInterruptedWork(interruptedWork: Fiber) - if interruptedWork.tag == ReactWorkTags.ClassComponent then - local childContextTypes = interruptedWork.type.childContextTypes - if childContextTypes ~= nil then - popLegacyContext(interruptedWork) - end - elseif interruptedWork.tag == ReactWorkTags.HostRoot then - popHostContainer(interruptedWork) - popTopLevelLegacyContextObject(interruptedWork) - resetMutableSourceWorkInProgressVersions() - elseif interruptedWork.tag == ReactWorkTags.HostComponent then - popHostContext(interruptedWork) - elseif interruptedWork.tag == ReactWorkTags.HostPortal then - popHostContainer(interruptedWork) - elseif interruptedWork.tag == ReactWorkTags.SuspenseComponent then - popSuspenseContext(interruptedWork) - elseif interruptedWork.tag == ReactWorkTags.SuspenseListComponent then - popSuspenseContext(interruptedWork) - elseif interruptedWork.tag == ReactWorkTags.ContextProvider then - popProvider(interruptedWork) - elseif interruptedWork.tag == ReactWorkTags.OffscreenComponent or - interruptedWork.tag == ReactWorkTags.LegacyHiddenComponent then - popRenderLanes(interruptedWork) - return - else -- default - return - end + if interruptedWork.tag == ReactWorkTags.ClassComponent then + -- ROBLOX deviation: Lua doesn't support properties on functions + local childContextTypes + if typeof(interruptedWork.type) == "table" then + childContextTypes = interruptedWork.type.childContextTypes + end + if childContextTypes ~= nil then + popLegacyContext(interruptedWork) + end + elseif interruptedWork.tag == ReactWorkTags.HostRoot then + popHostContainer(interruptedWork) + popTopLevelLegacyContextObject(interruptedWork) + resetMutableSourceWorkInProgressVersions() + elseif interruptedWork.tag == ReactWorkTags.HostComponent then + popHostContext(interruptedWork) + elseif interruptedWork.tag == ReactWorkTags.HostPortal then + popHostContainer(interruptedWork) + elseif interruptedWork.tag == ReactWorkTags.SuspenseComponent then + popSuspenseContext(interruptedWork) + elseif interruptedWork.tag == ReactWorkTags.SuspenseListComponent then + popSuspenseContext(interruptedWork) + elseif interruptedWork.tag == ReactWorkTags.ContextProvider then + popProvider(interruptedWork) + elseif + interruptedWork.tag == ReactWorkTags.OffscreenComponent + or interruptedWork.tag == ReactWorkTags.LegacyHiddenComponent + then + popRenderLanes(interruptedWork) + return + else -- default + return + end end return { - unwindWork = unwindWork, - unwindInterruptedWork = unwindInterruptedWork, + unwindWork = unwindWork, + unwindInterruptedWork = unwindInterruptedWork, } diff --git a/modules/react-reconciler/src/ReactFiberWorkInProgress.lua b/modules/react-reconciler/src/ReactFiberWorkInProgress.lua index 424971da..b3d34700 100644 --- a/modules/react-reconciler/src/ReactFiberWorkInProgress.lua +++ b/modules/react-reconciler/src/ReactFiberWorkInProgress.lua @@ -1,28 +1,38 @@ +--!strict -- ROBLOX deviation: this is an extraction of a single state field -- (and associated mutation/getters) from ReactFiberWorkLooop.new -- which allows us to break dependency cycles involving that module +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiberWorkLoop.new.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 ReactFiberLane = require(script.Parent.ReactFiberLane) local _workInProgressRootSkippedLanes: Lanes = ReactFiberLane.NoLanes local mergeLanes = ReactFiberLane.mergeLanes -type Lanes = ReactFiberLane.Lanes; -type Lane = ReactFiberLane.Lane; +type Lanes = ReactFiberLane.Lanes +type Lane = ReactFiberLane.Lane local exports = {} -exports.workInProgressRootSkippedLanes = function(value) +-- ROBLOX TODO: turn this into newindex property accessor +exports.workInProgressRootSkippedLanes = function(value: Lanes?): Lanes if value == nil then return _workInProgressRootSkippedLanes end - _workInProgressRootSkippedLanes = value - return nil + -- ROBLOX FIXME Luau: Luau should narrow based on guard above + _workInProgressRootSkippedLanes = value :: Lanes + return _workInProgressRootSkippedLanes end -exports.markSkippedUpdateLanes = function(lane: Lane | Lanes) - _workInProgressRootSkippedLanes = mergeLanes( - lane, - _workInProgressRootSkippedLanes - ) +exports.markSkippedUpdateLanes = function(lane: Lane | Lanes): () + _workInProgressRootSkippedLanes = mergeLanes(lane, _workInProgressRootSkippedLanes) end -return exports \ No newline at end of file +return exports diff --git a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua index 60c71e32..28f47666 100644 --- a/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua +++ b/modules/react-reconciler/src/ReactFiberWorkLoop.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +--!nonstrict --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,75 +8,43 @@ * * @flow ]] --- 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 Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local LuauPolyfill = require(Packages.LuauPolyfill) local Set = LuauPolyfill.Set +type Set = LuauPolyfill.Set +type Array = LuauPolyfill.Array --- ROBLOX TODO: inline definition here until luau-polyfill #61 is fixed -type Set = { - size: number, - -- method definitions - add: (Set, T) -> Set, - clear: (Set) -> (), - delete: (Set, T) -> boolean, - has: (Set, T) -> boolean, - ipairs: (Set) -> any, -} -type Array = { [number]: T } +local __DEV__ = ReactGlobals.__DEV__ +local __YOLO__ = ReactGlobals.__YOLO__ local exports: any = {} -local function copySet(from) - local to = Set.new() - if from == nil then - return to - end - for _, v in from:ipairs() do - to:add(v) - end - - return to -end - -local function collectionHasEntries(collection) - return next(collection) ~= nil -end - - local ReactTypes = require(Packages.Shared) -type Thenable = ReactTypes.Thenable +type Thenable = ReactTypes.Thenable type Wakeable = ReactTypes.Wakeable local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel; +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot +type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel local ReactFiberLane = require(script.Parent.ReactFiberLane) -type Lanes = ReactFiberLane.Lanes; -type Lane = ReactFiberLane.Lane; +type Lanes = ReactFiberLane.Lanes +type Lane = ReactFiberLane.Lane -- The scheduler is imported here *only* to detect whether it's been mocked local Scheduler = require(Packages.Scheduler) -- ROBLOX deviation: we import from top-level Scheduler exports to avoid direct file access type Interaction = Scheduler.Interaction -local ReactFiberSuspenseComponent = require(script.Parent["ReactFiberSuspenseComponent.new"]) +local ReactFiberSuspenseComponent = + require(script.Parent["ReactFiberSuspenseComponent.new"]) type SuspenseState = ReactFiberSuspenseComponent.SuspenseState local ReactFiberStack = require(script.Parent["ReactFiberStack.new"]) -type StackCursor = ReactFiberStack.StackCursor; --- ROBLOX TODO: avoid a circular dependency --- local type {FunctionComponentUpdateQueue} = require(script.Parent["ReactFiberHooks.new"]) +type StackCursor = ReactFiberStack.StackCursor local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -- deviation: Use some properties directly instead of localizing to avoid 200 limit @@ -84,14 +53,19 @@ local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -- local warnAboutUnmockedScheduler = ReactFeatureFlags.warnAboutUnmockedScheduler -- local deferRenderPhaseUpdateToNextBatch = ReactFeatureFlags.ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch -- local decoupleUpdatePriorityFromScheduler = ReactFeatureFlags.ReactFeatureFlags.decoupleUpdatePriorityFromScheduler --- local enableDebugTracing = ReactFeatureFlags.enableDebugTracing +local enableDebugTracing = ReactFeatureFlags.enableDebugTracing local enableSchedulingProfiler = ReactFeatureFlags.enableSchedulingProfiler local skipUnmountedBoundaries = ReactFeatureFlags.skipUnmountedBoundaries local enableDoubleInvokingEffects = ReactFeatureFlags.enableDoubleInvokingEffects -local ReactSharedInternals = require(Packages.Shared).ReactSharedInternals -local invariant = require(Packages.Shared).invariant - -local SchedulerWithReactIntegration = require(script.Parent["SchedulerWithReactIntegration.new"]) +local ReactShared = require(Packages.Shared) +-- ROBLOX deviation: we pull in Dispatcher type because we need it for our lazy loading deviations to typecheck +type Dispatcher = ReactShared.Dispatcher +local describeError = require(Packages.Shared).describeError +local ReactSharedInternals = ReactShared.ReactSharedInternals +local invariant = ReactShared.invariant + +local SchedulerWithReactIntegration = + require(script.Parent["SchedulerWithReactIntegration.new"]) local scheduleCallback = SchedulerWithReactIntegration.scheduleCallback local cancelCallback = SchedulerWithReactIntegration.cancelCallback local getCurrentPriorityLevel = SchedulerWithReactIntegration.getCurrentPriorityLevel @@ -105,20 +79,17 @@ local UserBlockingSchedulerPriority = SchedulerWithReactIntegration.UserBlocking local NormalSchedulerPriority = SchedulerWithReactIntegration.NormalPriority local flushSyncCallbackQueue = SchedulerWithReactIntegration.flushSyncCallbackQueue local scheduleSyncCallback = SchedulerWithReactIntegration.scheduleSyncCallback -local ReactHookEffectTags = require(script.Parent.ReactHookEffectTags) -- deviation: Use properties directly instead of localizing to avoid 200 limit --- local NoHookEffect = ReactHookEffectTags.NoFlags --- local HookPassive = ReactHookEffectTags.Passive -- local { --- logCommitStarted, --- logCommitStopped, --- logLayoutEffectsStarted, --- logLayoutEffectsStopped, +-- DebugTracing.logCommitStarted, +-- DebugTracing.logCommitStopped, +-- DebugTracing.logLayoutEffectsStarted, +-- DebugTracing.logLayoutEffectsStopped, -- logPassiveEffectsStarted, --- logPassiveEffectsStopped, --- logRenderStarted, --- logRenderStopped, --- } = require(script.Parent.DebugTracing) +-- DebugTracing.logPassiveEffectsStopped, +-- DebugTracing.logRenderStarted, +-- DebugTracing.logRenderStopped, +local DebugTracing = require(script.Parent.DebugTracing) -- local { -- SchedulingProfiler.markCommitStarted, -- SchedulingProfiler.markCommitStopped, @@ -131,9 +102,9 @@ local ReactHookEffectTags = require(script.Parent.ReactHookEffectTags) -- SchedulingProfiler.markRenderStopped, local SchedulingProfiler = require(script.Parent.SchedulingProfiler) - local SchedulerTracing = require(Packages.Scheduler).tracing -local __interactionsRef, __subscriberRef = SchedulerTracing.__interactionsRef, SchedulerTracing.__subscriberRef +local __interactionsRef, __subscriberRef = + SchedulerTracing.__interactionsRef, SchedulerTracing.__subscriberRef local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -- deviation: Use properties directly instead of localizing to avoid 200 limit @@ -142,7 +113,7 @@ local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -- local scheduleTimeout = ReactFiberHostConfig.scheduleTimeout -- local cancelTimeout = ReactFiberHostConfig.cancelTimeout -- local noTimeout = ReactFiberHostConfig.noTimeout -local warnsIfNotActing = ReactFiberHostConfig.warnsIfNotActing +-- local warnsIfNotActing = ReactFiberHostConfig.warnsIfNotActing -- local beforeActiveInstanceBlur = ReactFiberHostConfig.beforeActiveInstanceBlur -- local afterActiveInstanceBlur = ReactFiberHostConfig.afterActiveInstanceBlur -- local clearContainer = ReactFiberHostConfig.clearContainer @@ -171,10 +142,10 @@ local ReactWorkTags = require(script.Parent.ReactWorkTags) -- local SimpleMemoComponent = ReactWorkTags.SimpleMemoComponent local LegacyRoot = require(script.Parent.ReactRootTags).LegacyRoot local ReactFiberFlags = require(script.Parent.ReactFiberFlags) +type Flags = ReactFiberFlags.Flags -- ROBLOX deviation: Use properties directly instead of localizing to avoid 200 limit -- local NoFlags = ReactFiberFlags.NoFlags -- local Placement = ReactFiberFlags.Placement --- local PassiveStatic = ReactFiberFlags.PassiveStatic -- local Incomplete = ReactFiberFlags.Incomplete -- local HostEffectMask = ReactFiberFlags.HostEffectMask -- local Hydrating = ReactFiberFlags.Hydrating @@ -203,7 +174,8 @@ local returnNextLanesPriority = ReactFiberLane.returnNextLanesPriority local setCurrentUpdateLanePriority = ReactFiberLane.setCurrentUpdateLanePriority local getCurrentUpdateLanePriority = ReactFiberLane.getCurrentUpdateLanePriority local markStarvedLanesAsExpired = ReactFiberLane.markStarvedLanesAsExpired -local getLanesToRetrySynchronouslyOnError = ReactFiberLane.getLanesToRetrySynchronouslyOnError +local getLanesToRetrySynchronouslyOnError = + ReactFiberLane.getLanesToRetrySynchronouslyOnError local getMostRecentEventTime = ReactFiberLane.getMostRecentEventTime local markRootUpdated = ReactFiberLane.markRootUpdated local markRootSuspended_dontCallThisOneDirectly = ReactFiberLane.markRootSuspended @@ -226,88 +198,105 @@ local throwException = ReactFiberThrow.throwException local createRootErrorUpdate = ReactFiberThrow.createRootErrorUpdate local createClassErrorUpdate = ReactFiberThrow.createClassErrorUpdate local ReactFiberCommitWork = require(script.Parent["ReactFiberCommitWork.new"]) -local commitBeforeMutationEffectOnFiber = ReactFiberCommitWork.commitBeforeMutationLifeCycles +local commitBeforeMutationEffectOnFiber = + ReactFiberCommitWork.commitBeforeMutationLifeCycles local commitPlacement = ReactFiberCommitWork.commitPlacement local commitWork = ReactFiberCommitWork.commitWork local commitDeletion = ReactFiberCommitWork.commitDeletion local commitPassiveUnmountOnFiber = ReactFiberCommitWork.commitPassiveUnmount -local commitPassiveUnmountInsideDeletedTreeOnFiber = ReactFiberCommitWork.commitPassiveUnmountInsideDeletedTree +local commitPassiveUnmountInsideDeletedTreeOnFiber = + ReactFiberCommitWork.commitPassiveUnmountInsideDeletedTree local commitPassiveMountOnFiber = ReactFiberCommitWork.commitPassiveMount local commitDetachRef = ReactFiberCommitWork.commitDetachRef -local commitAttachRef = ReactFiberCommitWork.commitAttachRef +-- local commitAttachRef = ReactFiberCommitWork.commitAttachRef -- local commitResetTextContent = ReactFiberCommitWork.commitResetTextContent -- local isSuspenseBoundaryBeingHidden = ReactFiberCommitWork.isSuspenseBoundaryBeingHidden local invokeLayoutEffectMountInDEV = ReactFiberCommitWork.invokeLayoutEffectMountInDEV local invokePassiveEffectMountInDEV = ReactFiberCommitWork.invokePassiveEffectMountInDEV local invokeLayoutEffectUnmountInDEV = ReactFiberCommitWork.invokeLayoutEffectUnmountInDEV -local invokePassiveEffectUnmountInDEV = ReactFiberCommitWork.invokePassiveEffectUnmountInDEV +local invokePassiveEffectUnmountInDEV = + ReactFiberCommitWork.invokePassiveEffectUnmountInDEV local recursivelyCommitLayoutEffects = ReactFiberCommitWork.recursivelyCommitLayoutEffects local Promise = require(Packages.Promise) local enqueueUpdate = require(script.Parent["ReactUpdateQueue.new"]).enqueueUpdate -local resetContextDependencies = require(script.Parent["ReactFiberNewContext.new"]).resetContextDependencies +local resetContextDependencies = + require(script.Parent["ReactFiberNewContext.new"]).resetContextDependencies + +-- ROBLOX deviation: RobloxReactProfiling +local RobloxReactProfiling = require(script.Parent.RobloxReactProfiling) -- ROBLOX deviation: Pre-declare function local ensureRootIsScheduled -- deviation: lazy init for functions from ReactFiberHooks, put in table b/c local var limit local lazyInitRefs = { - resetHooksAfterThrowRef = nil, - ContextOnlyDispatcherRef = nil, - getIsUpdatingOpaqueValueInRenderPhaseInDEVRef = nil, - originalBeginWorkRef = nil, - completeWorkRef = nil + resetHooksAfterThrowRef = (nil :: any) :: (...any) -> ...any, + ContextOnlyDispatcherRef = (nil :: any) :: Dispatcher, + getIsUpdatingOpaqueValueInRenderPhaseInDEVRef = (nil :: any) :: (...any) -> ...any, + originalBeginWorkRef = (nil :: any) :: (...any) -> ...any, + completeWorkRef = (nil :: any) :: (...any) -> ...any, } -- ROBLOX deviation: lazy initialize beginwork to break cyclic dependencies -local originalBeginWork = function(...) - if not lazyInitRefs.originalBeginWorkRef then - lazyInitRefs.originalBeginWorkRef = require(script.Parent["ReactFiberBeginWork.new"]).beginWork - end - return lazyInitRefs.originalBeginWorkRef(...) +local originalBeginWork = function( + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes +): Fiber | nil + if not lazyInitRefs.originalBeginWorkRef then + lazyInitRefs.originalBeginWorkRef = + require(script.Parent["ReactFiberBeginWork.new"]).beginWork + end + return lazyInitRefs.originalBeginWorkRef(current, workInProgress, renderLanes) end -local completeWork = function(...) - if not lazyInitRefs.completeWorkRef then - lazyInitRefs.completeWorkRef = require(script.Parent["ReactFiberCompleteWork.new"]).completeWork - end - return lazyInitRefs.completeWorkRef(...) +local completeWork = function( + current: Fiber | nil, + workInProgress: Fiber, + renderLanes: Lanes +): Fiber | nil + if not lazyInitRefs.completeWorkRef then + lazyInitRefs.completeWorkRef = + require(script.Parent["ReactFiberCompleteWork.new"]).completeWork + end + return (lazyInitRefs.completeWorkRef :: any)(current, workInProgress, renderLanes) end - local ReactFiberHooks -- ROBLOX deviation: lazy init for functions from ReactFiberHooks local function initReactFiberHooks() - ReactFiberHooks = require(script.Parent["ReactFiberHooks.new"]) - lazyInitRefs.resetHooksAfterThrowRef = ReactFiberHooks.resetHooksAfterThrow - lazyInitRefs.ContextOnlyDispatcherRef = ReactFiberHooks.ContextOnlyDispatcher - lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef = ReactFiberHooks.getIsUpdatingOpaqueValueInRenderPhaseInDEV + ReactFiberHooks = require(script.Parent["ReactFiberHooks.new"]) + lazyInitRefs.resetHooksAfterThrowRef = ReactFiberHooks.resetHooksAfterThrow + lazyInitRefs.ContextOnlyDispatcherRef = ReactFiberHooks.ContextOnlyDispatcher + lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef = + ReactFiberHooks.getIsUpdatingOpaqueValueInRenderPhaseInDEV end -- ROBLOX deviation: lazy init for resetHooksAfterThrow from ReactFiberHooks -local resetHooksAfterThrow = function(...) - if not lazyInitRefs.resetHooksAfterThrowRef then - initReactFiberHooks() - end - return lazyInitRefs.resetHooksAfterThrowRef(...) +local resetHooksAfterThrow = function(): () + if not lazyInitRefs.resetHooksAfterThrowRef then + initReactFiberHooks() + end + return lazyInitRefs.resetHooksAfterThrowRef() end -- ROBLOX deviation: lazy init for ContextOnlyDispatcher from ReactFiberHooks -local ContextOnlyDispatcher = function() - if not lazyInitRefs.ContextOnlyDispatcherRef then - initReactFiberHooks() - end - return lazyInitRefs.ContextOnlyDispatcherRef +local ContextOnlyDispatcher = function(): Dispatcher + if not lazyInitRefs.ContextOnlyDispatcherRef then + initReactFiberHooks() + end + return lazyInitRefs.ContextOnlyDispatcherRef end -- ROBLOX deviation: lazy init for getIsUpdatingOpaqueValueInRenderPhaseInDEV from ReactFiberHooks -local getIsUpdatingOpaqueValueInRenderPhaseInDEV = function(...) - if not lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef then - initReactFiberHooks() - end - return lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef(...) +local getIsUpdatingOpaqueValueInRenderPhaseInDEV = function(): boolean? + if not lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef then + initReactFiberHooks() + end + return lazyInitRefs.getIsUpdatingOpaqueValueInRenderPhaseInDEVRef() end local createCapturedValue = require(script.Parent.ReactCapturedValue).createCapturedValue @@ -317,7 +306,7 @@ local createCursor = ReactFiberStack.createCursor local ReactProfilerTimer = require(script.Parent["ReactProfilerTimer.new"]) --- -- DEV stuff +-- DEV stuff local getComponentName = require(Packages.Shared).getComponentName local ReactStrictModeWarnings = require(script.Parent["ReactStrictModeWarnings.new"]) local ReactCurrentFiber = require(script.Parent.ReactCurrentFiber) @@ -330,7 +319,8 @@ local ReactErrorUtils = require(Packages.Shared).ReactErrorUtils local invokeGuardedCallback = ReactErrorUtils.invokeGuardedCallback local hasCaughtError = ReactErrorUtils.hasCaughtError local clearCaughtError = ReactErrorUtils.clearCaughtError -local onCommitRootDevTools = require(script.Parent["ReactFiberDevToolsHook.new"]).onCommitRoot +local onCommitRootDevTools = + require(script.Parent["ReactFiberDevToolsHook.new"]).onCommitRoot local onCommitRootTestSelector = require(script.Parent.ReactTestSelectors).onCommitRoot -- Used by `act` @@ -340,45 +330,53 @@ local doesFiberContain = require(script.Parent.ReactFiberTreeReflection).doesFib local ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher local ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner local IsSomeRendererActing = ReactSharedInternals.IsSomeRendererActing -local captureCommitPhaseErrorOnRoot, flushPassiveMountEffects +local captureCommitPhaseErrorOnRoot, flushPassiveMountEffects -- local ceil = math.ceil -- ROBLOX deviation: Pre-declare functions local mod: { [string]: any } = {} -type ExecutionContext = number; - -local NoContext = --[[ ]] 0b0000000 -local BatchedContext = --[[ ]] 0b0000001 -local EventContext = --[[ ]] 0b0000010 -local DiscreteEventContext = --[[ ]] 0b0000100 -local LegacyUnbatchedContext = --[[ ]] 0b0001000 -local RenderContext = --[[ ]] 0b0010000 -local CommitContext = --[[ ]] 0b0100000 -local RetryAfterError = --[[ ]] 0b1000000 +type ExecutionContext = number + +local NoContext = --[[ ]] + 0b0000000 +local BatchedContext = --[[ ]] + 0b0000001 +local EventContext = --[[ ]] + 0b0000010 +local DiscreteEventContext = --[[ ]] + 0b0000100 +local LegacyUnbatchedContext = --[[ ]] + 0b0001000 +local RenderContext = --[[ ]] + 0b0010000 +local CommitContext = --[[ ]] + 0b0100000 +local RetryAfterError = --[[ ]] + 0b1000000 exports.NoContext = NoContext exports.RetryAfterError = RetryAfterError -- ROBLOX deviation: Luau doesn't yet support literal types -- type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 -type RootExitStatus = number; +type RootExitStatus = number -- ROBLOX deviation: Define constants in a table instead of locals to avoid 200 limit local RootExitStatus: { [string]: RootExitStatus } = { - Incomplete = 0, - FatalErrored = 1, - Errored = 2, - Suspended = 3, - SuspendedWithDelay = 4, - Completed = 5, + Incomplete = 0, + FatalErrored = 1, + Errored = 2, + Suspended = 3, + SuspendedWithDelay = 4, + Completed = 5, } -- Describes where we are in the React execution stack local executionContext: ExecutionContext = NoContext -- The root we're working on -local workInProgressRoot: ReactInternalTypes.FiberRoot? = nil --- -- The fiber we're working on +local workInProgressRoot: FiberRoot? = nil +-- The fiber we're working on local workInProgress: Fiber? = nil -- The lanes we're rendering local workInProgressRootRenderLanes: Lanes = ReactFiberLane.NoLanes @@ -406,13 +404,14 @@ local workInProgressRootIncludedLanes: Lanes = ReactFiberLane.NoLanes -- The work left over by components that were visited during this render. Only -- includes unprocessed updates, not work in bailed out children. local ReactFiberWorkInProgress = require(script.Parent.ReactFiberWorkInProgress) -local workInProgressRootSkippedLanes = ReactFiberWorkInProgress.workInProgressRootSkippedLanes --: Lanes = ReactFiberLane.NoLanes +local workInProgressRootSkippedLanes: (value: Lanes?) -> Lanes = + ReactFiberWorkInProgress.workInProgressRootSkippedLanes --: Lanes = ReactFiberLane.NoLanes -- Lanes that were updated (in an interleaved event) during this render. local workInProgressRootUpdatedLanes: Lanes = ReactFiberLane.NoLanes -- Lanes that were pinged (in an interleaved event) during this render. local workInProgressRootPingedLanes: Lanes = ReactFiberLane.NoLanes -local mostRecentlyUpdatedRoot: ReactInternalTypes.FiberRoot | nil = nil +local mostRecentlyUpdatedRoot: FiberRoot | nil = nil -- The most recent time we committed a fallback. This lets us ensure a train -- model where we don't commit new loading states in too quick succession. @@ -430,29 +429,28 @@ local RENDER_TIMEOUT_MS = 500 local nearestProfilerOnStack: Fiber | nil = nil local function resetRenderTimer() - workInProgressRootRenderTargetTime = now() + RENDER_TIMEOUT_MS + workInProgressRootRenderTargetTime = now() + RENDER_TIMEOUT_MS end exports.getRenderTargetTime = function(): number - return workInProgressRootRenderTargetTime + return workInProgressRootRenderTargetTime end local hasUncaughtError = false local firstUncaughtError = nil -local legacyErrorBoundariesThatAlreadyFailed +local legacyErrorBoundariesThatAlreadyFailed: Set | nil = nil local rootDoesHavePassiveEffects: boolean = false -local rootWithPendingPassiveEffects: ReactInternalTypes.FiberRoot? = nil +local rootWithPendingPassiveEffects: FiberRoot? = nil local pendingPassiveEffectsRenderPriority: ReactPriorityLevel = NoSchedulerPriority local pendingPassiveEffectsLanes: Lanes = ReactFiberLane.NoLanes --- deviation: FIXME restore type Set?, has trouble with narrowing -local rootsWithPendingDiscreteUpdates: any = nil +local rootsWithPendingDiscreteUpdates: Set | nil = nil -- Use these to prevent an infinite loop of nested updates local NESTED_UPDATE_LIMIT = 50 local nestedUpdateCount: number = 0 -local rootWithNestedUpdates: ReactInternalTypes.FiberRoot | nil = nil +local rootWithNestedUpdates: FiberRoot | nil = nil local NESTED_PASSIVE_UPDATE_LIMIT = 50 local nestedPassiveUpdateCount: number = 0 @@ -464,37 +462,36 @@ local nestedPassiveUpdateCount: number = 0 -- TODO: Can use a bitmask instead of an array local spawnedWorkDuringRender: nil | Array = nil --- -- If two updates are scheduled within the same event, we should treat their --- -- event times as simultaneous, even if the actual clock time has advanced --- -- between the first and second call. +-- If two updates are scheduled within the same event, we should treat their +-- event times as simultaneous, even if the actual clock time has advanced +-- between the first and second call. local currentEventTime: number = NoTimestamp local currentEventWipLanes: Lanes = ReactFiberLane.NoLanes local currentEventPendingLanes: Lanes = ReactFiberLane.NoLanes --- Dev only flag that tracks if passive effects are currently being flushed. --- We warn about state updates for unmounted components differently in this case. -local isFlushingPassiveEffects = false - local focusedInstanceHandle: nil | Fiber = nil local shouldFireAfterActiveInstanceBlur: boolean = false -exports.getWorkInProgressRoot = function(): ReactInternalTypes.FiberRoot? - return workInProgressRoot +exports.getWorkInProgressRoot = function(): FiberRoot? + return workInProgressRoot end exports.requestEventTime = function() - if bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) ~= NoContext then - -- We're inside React, so it's fine to read the actual time. - return now() - end - -- We're not inside React, so we may be in the middle of a browser event. - if currentEventTime ~= NoTimestamp then - -- Use the same start time for all updates until we enter React again. - return currentEventTime - end - -- This is the first update since React yielded. Compute a new start time. - currentEventTime = now() - return currentEventTime + if + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) + ~= NoContext + then + -- We're inside React, so it's fine to read the actual time. + return now() + end + -- We're not inside React, so we may be in the middle of a browser event. + if currentEventTime ~= NoTimestamp then + -- Use the same start time for all updates until we enter React again. + return currentEventTime + end + -- This is the first update since React yielded. Compute a new start time. + currentEventTime = now() + return currentEventTime end -- exports.getCurrentTime() @@ -502,285 +499,289 @@ end -- end exports.requestUpdateLane = function(fiber: Fiber): Lane - -- Special cases - local mode = fiber.mode - if bit32.band(mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then - return SyncLane - elseif bit32.band(mode, ReactTypeOfMode.ConcurrentMode) == ReactTypeOfMode.NoMode then - return getCurrentPriorityLevel() == ImmediateSchedulerPriority - and SyncLane - or SyncBatchedLane - elseif - not ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch and - bit32.band(executionContext, RenderContext) ~= NoContext and - workInProgressRootRenderLanes ~= ReactFiberLane.NoLanes - then - -- This is a render phase update. These are not officially supported. The - -- old behavior is to give this the same "thread" (expiration time) as - -- whatever is currently rendering. So if you call `setState` on a component - -- that happens later in the same render, it will flush. Ideally, we want to - -- remove the special case and treat them as if they came from an - -- interleaved event. Regardless, this pattern is not officially supported. - -- This behavior is only a fallback. The flag only exists until we can roll - -- out the setState warning, since existing code might accidentally rely on - -- the current behavior. - return pickArbitraryLane(workInProgressRootRenderLanes) - end - - -- The algorithm for assigning an update to a lane should be stable for all - -- updates at the same priority within the same event. To do this, the inputs - -- to the algorithm must be the same. For example, we use the `renderLanes` - -- to avoid choosing a lane that is already in the middle of rendering. - -- - -- However, the "included" lanes could be mutated in between updates in the - -- same event, like if you perform an update inside `flushSync`. Or any other - -- code path that might call `prepareFreshStack`. - -- - -- The trick we use is to cache the first of each of these inputs within an - -- event. Then reset the cached values once we can be sure the event is over. - -- Our heuristic for that is whenever we enter a concurrent work loop. - -- - -- We'll do the same for `currentEventPendingLanes` below. - if currentEventWipLanes == ReactFiberLane.NoLanes then - currentEventWipLanes = workInProgressRootIncludedLanes - end - - local isTransition = ReactFiberTransition.requestCurrentTransition() ~= ReactFiberTransition.NoTransition - if isTransition then - if currentEventPendingLanes ~= ReactFiberLane.NoLanes then - if mostRecentlyUpdatedRoot ~= nil then - currentEventPendingLanes = (mostRecentlyUpdatedRoot :: ReactInternalTypes.FiberRoot).pendingLanes - else - currentEventPendingLanes = ReactFiberLane.NoLanes - end - end - return findTransitionLane(currentEventWipLanes, currentEventPendingLanes) - end - - -- TODO: Remove this dependency on the Scheduler priority. - -- To do that, we're replacing it with an update lane priority. - local schedulerPriority = getCurrentPriorityLevel() - - -- The old behavior was using the priority level of the Scheduler. - -- This couples React to the Scheduler internals, so we're replacing it - -- with the currentUpdateLanePriority above. As an example of how this - -- could be problematic, if we're not inside `Scheduler.runWithPriority`, - -- then we'll get the priority of the current running Scheduler task, - -- which is probably not what we want. - local lane - if - -- TODO: Temporary. We're removing the concept of discrete updates. - bit32.band(executionContext, DiscreteEventContext) ~= NoContext and - schedulerPriority == UserBlockingSchedulerPriority - then - lane = findUpdateLane(ReactFiberLane.InputDiscreteLanePriority, currentEventWipLanes) - else - local schedulerLanePriority = schedulerPriorityToLanePriority( - schedulerPriority - ) - - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - -- In the new strategy, we will track the current update lane priority - -- inside React and use that priority to select a lane for this update. - -- For now, we're just logging when they're different so we can assess. - local currentUpdateLanePriority = getCurrentUpdateLanePriority() - - if - schedulerLanePriority ~= currentUpdateLanePriority and - currentUpdateLanePriority ~= ReactFiberLane.NoLanePriority - then - if _G.__DEV__ then - console.error( - "Expected current scheduler lane priority %s to match current update lane priority %s", - schedulerLanePriority, - currentUpdateLanePriority - ) - end - end - end - - lane = findUpdateLane(schedulerLanePriority, currentEventWipLanes) - end - - return lane + -- Special cases + local mode = fiber.mode + if bit32.band(mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then + return SyncLane :: Lane + elseif bit32.band(mode, ReactTypeOfMode.ConcurrentMode) == ReactTypeOfMode.NoMode then + return if getCurrentPriorityLevel() == ImmediateSchedulerPriority + then SyncLane :: Lane + else SyncBatchedLane :: Lane + elseif + not ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch + and bit32.band(executionContext, RenderContext) ~= NoContext + and workInProgressRootRenderLanes ~= ReactFiberLane.NoLanes + then + -- This is a render phase update. These are not officially supported. The + -- old behavior is to give this the same "thread" (expiration time) as + -- whatever is currently rendering. So if you call `setState` on a component + -- that happens later in the same render, it will flush. Ideally, we want to + -- remove the special case and treat them as if they came from an + -- interleaved event. Regardless, this pattern is not officially supported. + -- This behavior is only a fallback. The flag only exists until we can roll + -- out the setState warning, since existing code might accidentally rely on + -- the current behavior. + return pickArbitraryLane(workInProgressRootRenderLanes) + end + + -- The algorithm for assigning an update to a lane should be stable for all + -- updates at the same priority within the same event. To do this, the inputs + -- to the algorithm must be the same. For example, we use the `renderLanes` + -- to avoid choosing a lane that is already in the middle of rendering. + -- + -- However, the "included" lanes could be mutated in between updates in the + -- same event, like if you perform an update inside `flushSync`. Or any other + -- code path that might call `prepareFreshStack`. + -- + -- The trick we use is to cache the first of each of these inputs within an + -- event. Then reset the cached values once we can be sure the event is over. + -- Our heuristic for that is whenever we enter a concurrent work loop. + -- + -- We'll do the same for `currentEventPendingLanes` below. + if currentEventWipLanes == ReactFiberLane.NoLanes then + currentEventWipLanes = workInProgressRootIncludedLanes + end + + local isTransition = ReactFiberTransition.requestCurrentTransition() + ~= ReactFiberTransition.NoTransition + if isTransition then + if currentEventPendingLanes ~= ReactFiberLane.NoLanes then + if mostRecentlyUpdatedRoot ~= nil then + currentEventPendingLanes = mostRecentlyUpdatedRoot.pendingLanes + else + currentEventPendingLanes = ReactFiberLane.NoLanes + end + end + return findTransitionLane(currentEventWipLanes, currentEventPendingLanes) + end + + -- TODO: Remove this dependency on the Scheduler priority. + -- To do that, we're replacing it with an update lane priority. + local schedulerPriority = getCurrentPriorityLevel() + + -- The old behavior was using the priority level of the Scheduler. + -- This couples React to the Scheduler internals, so we're replacing it + -- with the currentUpdateLanePriority above. As an example of how this + -- could be problematic, if we're not inside `Scheduler.runWithPriority`, + -- then we'll get the priority of the current running Scheduler task, + -- which is probably not what we want. + local lane + if + -- TODO: Temporary. We're removing the concept of discrete updates. + bit32.band(executionContext, DiscreteEventContext) ~= NoContext + and schedulerPriority == UserBlockingSchedulerPriority + then + lane = + findUpdateLane(ReactFiberLane.InputDiscreteLanePriority, currentEventWipLanes) + else + local schedulerLanePriority = schedulerPriorityToLanePriority(schedulerPriority) + + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + -- In the new strategy, we will track the current update lane priority + -- inside React and use that priority to select a lane for this update. + -- For now, we're just logging when they're different so we can assess. + local currentUpdateLanePriority = getCurrentUpdateLanePriority() + + if + schedulerLanePriority ~= currentUpdateLanePriority + and currentUpdateLanePriority ~= ReactFiberLane.NoLanePriority + then + if __DEV__ then + console.error( + "Expected current scheduler lane priority %s to match current update lane priority %s", + tostring(schedulerLanePriority), + tostring(currentUpdateLanePriority) + ) + end + end + end + + lane = findUpdateLane(schedulerLanePriority, currentEventWipLanes) + end + + return lane end -function requestRetryLane(fiber: Fiber) - -- This is a fork of `requestUpdateLane` designed specifically for Suspense - -- "retries" — a special update that attempts to flip a Suspense boundary - -- from its placeholder state to its primary/resolved state. - - -- Special cases - local mode = fiber.mode - if bit32.band(mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then - return SyncLane - elseif bit32.band(mode, ReactTypeOfMode.ConcurrentMode) == ReactTypeOfMode.NoMode then - return (function() - if getCurrentPriorityLevel() == ImmediateSchedulerPriority then - return SyncLane - end - return SyncBatchedLane - end)() - end - - -- See `requestUpdateLane` for explanation of `currentEventWipLanes` - if currentEventWipLanes == ReactFiberLane.NoLanes then - currentEventWipLanes = workInProgressRootIncludedLanes - end - return findRetryLane(currentEventWipLanes) +function requestRetryLane(fiber: Fiber): Lane + -- This is a fork of `requestUpdateLane` designed specifically for Suspense + -- "retries" — a special update that attempts to flip a Suspense boundary + -- from its placeholder state to its primary/resolved state. + + -- Special cases + local mode = fiber.mode + if bit32.band(mode, ReactTypeOfMode.BlockingMode) == ReactTypeOfMode.NoMode then + return SyncLane :: Lane + elseif bit32.band(mode, ReactTypeOfMode.ConcurrentMode) == ReactTypeOfMode.NoMode then + return if getCurrentPriorityLevel() == ImmediateSchedulerPriority + then SyncLane :: Lane + else SyncBatchedLane :: Lane + end + + -- See `requestUpdateLane` for explanation of `currentEventWipLanes` + if currentEventWipLanes == ReactFiberLane.NoLanes then + currentEventWipLanes = workInProgressRootIncludedLanes + end + return findRetryLane(currentEventWipLanes) end exports.scheduleUpdateOnFiber = function( - fiber: Fiber, - lane: Lane, - eventTime: number -) - mod.checkForNestedUpdates() - mod.warnAboutRenderPhaseUpdatesInDEV(fiber) - - local root = mod.markUpdateLaneFromFiberToRoot(fiber, lane) - if root == nil then - mod.warnAboutUpdateOnUnmountedFiberInDEV(fiber) - return - end - - -- Mark that the root has a pending update. - markRootUpdated(root, lane, eventTime) - - if root == workInProgressRoot then - -- Received an update to a tree that's in the middle of rendering. Mark - -- that there was an interleaved update work on this root. Unless the - -- `ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch` flag is off and this is a render - -- phase update. In that case, we don't treat render phase updates as if - -- they were interleaved, for backwards compat reasons. - if - ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch or - bit32.band(executionContext, RenderContext) == NoContext - then - workInProgressRootUpdatedLanes = mergeLanes( - workInProgressRootUpdatedLanes, - lane - ) - end - if workInProgressRootExitStatus == RootExitStatus.SuspendedWithDelay then - -- The root already suspended with a delay, which means this render - -- definitely won't finish. Since we have a new update, let's mark it as - -- suspended now, right before marking the incoming update. This has the - -- effect of interrupting the current render and switching to the update. - -- TODO: Make sure this doesn't override pings that happen while we've - -- already started rendering. - mod.markRootSuspended(root, workInProgressRootRenderLanes) - end - end - - -- TODO: requestUpdateLanePriority also reads the priority. Pass the - -- priority as an argument to that function and this one. - local priorityLevel = getCurrentPriorityLevel() - - if lane == SyncLane then - if - -- Check if we're inside unbatchedUpdates - bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext and - -- Check if we're not already rendering - bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext - then - -- Register pending interactions on the root to avoid losing traced interaction data. - mod.schedulePendingInteractions(root, lane) - - -- This is a legacy edge case. The initial mount of a ReactDOM.render-ed - -- root inside of batchedUpdates should be synchronous, but layout updates - -- should be deferred until the end of the batch. - mod.performSyncWorkOnRoot(root) - else - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, lane) - if executionContext == NoContext then - -- Flush the synchronous work now, unless we're already working or inside - -- a batch. This is intentionally inside scheduleUpdateOnFiber instead of - -- scheduleCallbackForFiber to preserve the ability to schedule a callback - -- without immediately flushing it. We only do this for user-initiated - -- updates, to preserve historical behavior of legacy mode. - resetRenderTimer() - flushSyncCallbackQueue() - end - end - else - -- Schedule a discrete update but only if it's not Sync. - if - bit32.band(executionContext, DiscreteEventContext) ~= NoContext and - -- Only updates at user-blocking priority or greater are considered - -- discrete, even inside a discrete event. - (priorityLevel == UserBlockingSchedulerPriority or - priorityLevel == ImmediateSchedulerPriority) - then - -- This is the result of a discrete event. Track the lowest priority - -- discrete update per root so we can flush them early, if needed. - if rootsWithPendingDiscreteUpdates == nil then - -- ROBLOX FIXME? not sure this translation is correct - rootsWithPendingDiscreteUpdates = { [root] = true } - else - rootsWithPendingDiscreteUpdates[root] = true - end - end - -- Schedule other updates after in case the callback is sync. - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, lane) - end - - -- We use this when assigning a lane for a transition inside - -- `requestUpdateLane`. We assume it's the same as the root being updated, - -- since in the common case of a single root app it probably is. If it's not - -- the same root, then it's not a huge deal, we just might batch more stuff - -- together more than necessary. - mostRecentlyUpdatedRoot = root + fiber: Fiber, + lane: Lane, + eventTime: number +): FiberRoot | nil + mod.checkForNestedUpdates() + + local root = mod.markUpdateLaneFromFiberToRoot(fiber, lane) + if root == nil then + return nil + end + + -- Mark that the root has a pending update. + markRootUpdated(root, lane, eventTime) + + if root == workInProgressRoot then + -- Received an update to a tree that's in the middle of rendering. Mark + -- that there was an interleaved update work on this root. Unless the + -- `ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch` flag is off and this is a render + -- phase update. In that case, we don't treat render phase updates as if + -- they were interleaved, for backwards compat reasons. + mod.warnAboutRenderPhaseUpdatesInDEV(fiber) + + if + ReactFeatureFlags.deferRenderPhaseUpdateToNextBatch + or bit32.band(executionContext, RenderContext) == NoContext + then + workInProgressRootUpdatedLanes = + mergeLanes(workInProgressRootUpdatedLanes, lane) + end + if workInProgressRootExitStatus == RootExitStatus.SuspendedWithDelay then + -- The root already suspended with a delay, which means this render + -- definitely won't finish. Since we have a new update, let's mark it as + -- suspended now, right before marking the incoming update. This has the + -- effect of interrupting the current render and switching to the update. + -- TODO: Make sure this doesn't override pings that happen while we've + -- already started rendering. + mod.markRootSuspended(root, workInProgressRootRenderLanes) + end + end + + -- TODO: requestUpdateLanePriority also reads the priority. Pass the + -- priority as an argument to that function and this one. + local priorityLevel = getCurrentPriorityLevel() + + if lane == SyncLane then + if + -- Check if we're inside unbatchedUpdates + bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext + -- Check if we're not already rendering + and bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) + == NoContext + then + -- Register pending interactions on the root to avoid losing traced interaction data. + mod.schedulePendingInteractions(root, lane) + + -- This is a legacy edge case. The initial mount of a ReactDOM.render-ed + -- root inside of batchedUpdates should be synchronous, but layout updates + -- should be deferred until the end of the batch. + mod.performSyncWorkOnRoot(root) + else + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, lane) + if executionContext == NoContext then + -- Flush the synchronous work now, unless we're already working or inside + -- a batch. This is intentionally inside scheduleUpdateOnFiber instead of + -- scheduleCallbackForFiber to preserve the ability to schedule a callback + -- without immediately flushing it. We only do this for user-initiated + -- updates, to preserve historical behavior of legacy mode. + resetRenderTimer() + flushSyncCallbackQueue() + end + end + else + -- Schedule a discrete update but only if it's not Sync. + if + bit32.band(executionContext, DiscreteEventContext) ~= NoContext + -- Only updates at user-blocking priority or greater are considered + -- discrete, even inside a discrete event. + and ( + priorityLevel == UserBlockingSchedulerPriority + or priorityLevel == ImmediateSchedulerPriority + ) + then + -- This is the result of a discrete event. Track the lowest priority + -- discrete update per root so we can flush them early, if needed. + if rootsWithPendingDiscreteUpdates == nil then + rootsWithPendingDiscreteUpdates = Set.new({ root }) + else + rootsWithPendingDiscreteUpdates:add(root) + end + end + -- Schedule other updates after in case the callback is sync. + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, lane) + end + + -- We use this when assigning a lane for a transition inside + -- `requestUpdateLane`. We assume it's the same as the root being updated, + -- since in the common case of a single root app it probably is. If it's not + -- the same root, then it's not a huge deal, we just might batch more stuff + -- together more than necessary. + mostRecentlyUpdatedRoot = root + return root end -- This is split into a separate function so we can mark a fiber with pending -- work without treating it as a typical update that originates from an event -- e.g. retrying a Suspense boundary isn't an update, but it does schedule work -- on a fiber. -mod.markUpdateLaneFromFiberToRoot = function( - sourceFiber: Fiber, - lane: Lane -): ReactInternalTypes.FiberRoot? - -- Update the source fiber's lanes - sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane) - local alternate = sourceFiber.alternate - if alternate ~= nil then - alternate.lanes = mergeLanes(alternate.lanes, lane) - end - if _G.__DEV__ then - if - alternate == nil and - bit32.band(sourceFiber.flags, bit32.bor(ReactFiberFlags.Placement, ReactFiberFlags.Hydrating)) ~= ReactFiberFlags.NoFlags - then - mod.warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber) - end - end - -- Walk the parent path to the root and update the child expiration time. - local node = sourceFiber - local parent = sourceFiber.return_ - while parent ~= nil do - parent.childLanes = mergeLanes(parent.childLanes, lane) - alternate = parent.alternate - if alternate ~= nil then - alternate.childLanes = mergeLanes(alternate.childLanes, lane) - else - if _G.__DEV__ then - if bit32.band(parent.flags, bit32.bor(ReactFiberFlags.Placement, ReactFiberFlags.Hydrating)) ~= ReactFiberFlags.NoFlags then - mod.warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber) - end - end - end - node = parent - parent = parent.return_ - end - if node.tag == ReactWorkTags.HostRoot then - local root: ReactInternalTypes.FiberRoot = node.stateNode - return root - else - return nil - end +mod.markUpdateLaneFromFiberToRoot = function(sourceFiber: Fiber, lane: Lane): FiberRoot? + -- Update the source fiber's lanes + sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane) + local alternate = sourceFiber.alternate + if alternate ~= nil then + alternate.lanes = mergeLanes(alternate.lanes, lane) + end + if __DEV__ then + if + alternate == nil + and bit32.band( + sourceFiber.flags, + bit32.bor(ReactFiberFlags.Placement, ReactFiberFlags.Hydrating) + ) + ~= ReactFiberFlags.NoFlags + then + mod.warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber) + end + end + -- Walk the parent path to the root and update the child expiration time. + local node = sourceFiber + local parent = sourceFiber.return_ + while parent ~= nil do + parent.childLanes = mergeLanes(parent.childLanes, lane) + alternate = parent.alternate + if alternate ~= nil then + alternate.childLanes = mergeLanes(alternate.childLanes, lane) + else + if __DEV__ then + if + bit32.band( + parent.flags, + bit32.bor(ReactFiberFlags.Placement, ReactFiberFlags.Hydrating) + ) ~= ReactFiberFlags.NoFlags + then + mod.warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber) + end + end + end + node = parent + parent = parent.return_ + end + if node.tag == ReactWorkTags.HostRoot then + local root: FiberRoot = node.stateNode + return root + else + return nil + end end -- Use this function to schedule a task for a root. There's only one task per @@ -788,194 +789,190 @@ end -- of the existing task is the same as the priority of the next level that the -- root has work on. This function is called on every update, and right before -- exiting a task. -ensureRootIsScheduled = function(root: ReactInternalTypes.FiberRoot, currentTime: number) - local existingCallbackNode = root.callbackNode - - -- Check if any lanes are being starved by other work. If so, mark them as - -- expired so we know to work on those next. - markStarvedLanesAsExpired(root, currentTime) - - -- Determine the next lanes to work on, and their priority. - local lanes - if root == workInProgressRoot then - lanes = workInProgressRootRenderLanes - else - lanes = ReactFiberLane.NoLanes - end - local nextLanes = getNextLanes( - root, - lanes - ) - -- This returns the priority level computed during the `getNextLanes` call. - local newCallbackPriority = returnNextLanesPriority() - - if nextLanes == ReactFiberLane.NoLanes then - -- Special case: There's nothing to work on. - if existingCallbackNode ~= nil then - cancelCallback(existingCallbackNode) - root.callbackNode = nil - root.callbackPriority = ReactFiberLane.NoLanePriority - end - return - end - - -- Check if there's an existing task. We may be able to reuse it. - if existingCallbackNode ~= nil then - local existingCallbackPriority = root.callbackPriority - if existingCallbackPriority == newCallbackPriority then - -- The priority hasn't changed. We can reuse the existing task. Exit. - return - end - -- The priority changed. Cancel the existing callback. We'll schedule a new - -- one below. - cancelCallback(existingCallbackNode) - end - - -- Schedule a new callback. - local newCallbackNode - if newCallbackPriority == ReactFiberLane.SyncLanePriority then - -- Special case: Sync React callbacks are scheduled on a special - -- internal queue - newCallbackNode = scheduleSyncCallback( - function() - return mod.performSyncWorkOnRoot(root) - end - ) - elseif newCallbackPriority == ReactFiberLane.SyncBatchedLanePriority then - newCallbackNode = scheduleCallback( - ImmediateSchedulerPriority, - function() - return mod.performSyncWorkOnRoot(root) - end - ) - else - local schedulerPriorityLevel = lanePriorityToSchedulerPriority( - newCallbackPriority - ) - newCallbackNode = scheduleCallback( - schedulerPriorityLevel, - function() - return mod.performConcurrentWorkOnRoot(root) - end - ) - end - - root.callbackPriority = newCallbackPriority - root.callbackNode = newCallbackNode +ensureRootIsScheduled = function(root: FiberRoot, currentTime: number) + local existingCallbackNode = root.callbackNode + + -- Check if any lanes are being starved by other work. If so, mark them as + -- expired so we know to work on those next. + markStarvedLanesAsExpired(root, currentTime) + + -- Determine the next lanes to work on, and their priority. + local lanes + if root == workInProgressRoot then + lanes = workInProgressRootRenderLanes + else + lanes = ReactFiberLane.NoLanes + end + local nextLanes = getNextLanes(root, lanes) + -- This returns the priority level computed during the `getNextLanes` call. + local newCallbackPriority = returnNextLanesPriority() + + if nextLanes == ReactFiberLane.NoLanes then + -- Special case: There's nothing to work on. + if existingCallbackNode ~= nil then + cancelCallback(existingCallbackNode) + root.callbackNode = nil + root.callbackPriority = ReactFiberLane.NoLanePriority + end + return + end + + -- Check if there's an existing task. We may be able to reuse it. + if existingCallbackNode ~= nil then + local existingCallbackPriority = root.callbackPriority + if existingCallbackPriority == newCallbackPriority then + -- The priority hasn't changed. We can reuse the existing task. Exit. + return + end + -- The priority changed. Cancel the existing callback. We'll schedule a new + -- one below. + cancelCallback(existingCallbackNode) + end + + -- Schedule a new callback. + local newCallbackNode + if newCallbackPriority == ReactFiberLane.SyncLanePriority then + -- Special case: Sync React callbacks are scheduled on a special + -- internal queue + newCallbackNode = scheduleSyncCallback(function() + -- ROBLOX deviation: RobloxReactProfiling + local profileRunning = RobloxReactProfiling.profileRootBeforeUnitOfWork(root) + local ret = mod.performSyncWorkOnRoot(root) + RobloxReactProfiling.profileRootAfterYielding(profileRunning) + return ret + end) + elseif newCallbackPriority == ReactFiberLane.SyncBatchedLanePriority then + newCallbackNode = scheduleCallback(ImmediateSchedulerPriority, function() + -- ROBLOX deviation: RobloxReactProfiling + local profileRunning = RobloxReactProfiling.profileRootBeforeUnitOfWork(root) + local ret = mod.performSyncWorkOnRoot(root) + RobloxReactProfiling.profileRootAfterYielding(profileRunning) + return ret + end) + else + local schedulerPriorityLevel = + lanePriorityToSchedulerPriority(newCallbackPriority) + newCallbackNode = scheduleCallback(schedulerPriorityLevel, function() + -- ROBLOX deviation: RobloxReactProfiling + local profileRunning = RobloxReactProfiling.profileRootBeforeUnitOfWork(root) + local ret = mod.performConcurrentWorkOnRoot(root) + RobloxReactProfiling.profileRootAfterYielding(profileRunning) + return ret + end) + end + + root.callbackPriority = newCallbackPriority + root.callbackNode = newCallbackNode end -- This is the entry point for every concurrent task, i.e. anything that -- goes through Scheduler. -mod.performConcurrentWorkOnRoot = function(root) - -- Since we know we're in a React event, we can clear the current - -- event time. The next update will compute a new event time. - currentEventTime = NoTimestamp - currentEventWipLanes = ReactFiberLane.NoLanes - currentEventPendingLanes = ReactFiberLane.NoLanes - - invariant( - bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, - "Should not already be working." - ) - - -- Flush any pending passive effects before deciding which lanes to work on, - -- in case they schedule additional work. - local originalCallbackNode = root.callbackNode - local didFlushPassiveEffects = exports.flushPassiveEffects() - if didFlushPassiveEffects then - -- Something in the passive effect phase may have canceled the current task. - -- Check if the task node for this root was changed. - if root.callbackNode ~= originalCallbackNode then - -- The current task was canceled. Exit. We don't need to call - -- `ensureRootIsScheduled` because the check above implies either that - -- there's a new task, or that there's no remaining work on this root. - return nil - else - -- Current task was not canceled. Continue. - end - end - - -- Determine the next expiration time to work on, using the fields stored - -- on the root. - local wipLanes - if root == workInProgressRoot then - wipLanes = workInProgressRootRenderLanes - else - wipLanes = ReactFiberLane.NoLanes - end - - local lanes = getNextLanes( - root, - wipLanes - ) - if lanes == ReactFiberLane.NoLanes then - -- Defensive coding. This is never expected to happen. - return nil - end - - local exitStatus = mod.renderRootConcurrent(root, lanes) - - if - includesSomeLane( - workInProgressRootIncludedLanes, - workInProgressRootUpdatedLanes - ) - then - -- The render included lanes that were updated during the render phase. - -- For example, when unhiding a hidden tree, we include all the lanes - -- that were previously skipped when the tree was hidden. That set of - -- lanes is a superset of the lanes we started rendering with. - -- - -- So we'll throw out the current work and restart. - mod.prepareFreshStack(root, ReactFiberLane.NoLanes) - elseif exitStatus ~= RootExitStatus.Incomplete then - if exitStatus == RootExitStatus.Errored then - executionContext = bit32.bor(executionContext, RetryAfterError) - - -- If an error occurred during hydration, - -- discard server response and fall back to client side render. - if root.hydrate then - root.hydrate = false - ReactFiberHostConfig.clearContainer(root.containerInfo) - end - - -- If something threw an error, try rendering one more time. We'll render - -- synchronously to block concurrent data mutations, and we'll includes - -- all pending updates are included. If it still fails after the second - -- attempt, we'll give up and commit the resulting tree. - lanes = getLanesToRetrySynchronouslyOnError(root) - if lanes ~= ReactFiberLane.NoLanes then - exitStatus = mod.renderRootSync(root, lanes) - end - end - - if exitStatus == RootExitStatus.FatalErrored then - local fatalError = workInProgressRootFatalError - mod.prepareFreshStack(root, ReactFiberLane.NoLanes) - mod.markRootSuspended(root, lanes) - ensureRootIsScheduled(root, now()) - error(fatalError) - end - - -- We now have a consistent tree. The next step is either to commit it, - -- or, if something suspended, wait to commit it after a timeout. - -- FIXME (roblox): type coercion - -- local finishedWork: Fiber = (root.current.alternate: any) - local finishedWork = root.current.alternate - root.finishedWork = finishedWork - root.finishedLanes = lanes - mod.finishConcurrentRender(root, exitStatus, lanes) - end - - ensureRootIsScheduled(root, now()) - if root.callbackNode == originalCallbackNode then - -- The task node scheduled for this root is the same one that's - -- currently executed. Need to return a continuation. - return function() - mod.performConcurrentWorkOnRoot(root) - end - end - return nil +-- ROBLOX Luau FIXME: Luau needs explicit annotation with nil-able returns +mod.performConcurrentWorkOnRoot = function(root): (() -> ...any) | nil + -- Since we know we're in a React event, we can clear the current + -- event time. The next update will compute a new event time. + currentEventTime = NoTimestamp + currentEventWipLanes = ReactFiberLane.NoLanes + currentEventPendingLanes = ReactFiberLane.NoLanes + + invariant( + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, + "Should not already be working." + ) + + -- Flush any pending passive effects before deciding which lanes to work on, + -- in case they schedule additional work. + local originalCallbackNode = root.callbackNode + local didFlushPassiveEffects = exports.flushPassiveEffects() + if didFlushPassiveEffects then + -- Something in the passive effect phase may have canceled the current task. + -- Check if the task node for this root was changed. + if root.callbackNode ~= originalCallbackNode then + -- The current task was canceled. Exit. We don't need to call + -- `ensureRootIsScheduled` because the check above implies either that + -- there's a new task, or that there's no remaining work on this root. + return nil + else + -- Current task was not canceled. Continue. + end + end + + -- Determine the next expiration time to work on, using the fields stored + -- on the root. + local lanes = getNextLanes( + root, + if root == workInProgressRoot + then workInProgressRootRenderLanes + else ReactFiberLane.NoLanes + ) + if lanes == ReactFiberLane.NoLanes then + -- Defensive coding. This is never expected to happen. + return nil + end + + local exitStatus = mod.renderRootConcurrent(root, lanes) + + if + includesSomeLane(workInProgressRootIncludedLanes, workInProgressRootUpdatedLanes) + then + -- The render included lanes that were updated during the render phase. + -- For example, when unhiding a hidden tree, we include all the lanes + -- that were previously skipped when the tree was hidden. That set of + -- lanes is a superset of the lanes we started rendering with. + -- + -- So we'll throw out the current work and restart. + mod.prepareFreshStack(root, ReactFiberLane.NoLanes) + elseif exitStatus ~= RootExitStatus.Incomplete then + if exitStatus == RootExitStatus.Errored then + executionContext = bit32.bor(executionContext, RetryAfterError) + + -- If an error occurred during hydration, + -- discard server response and fall back to client side render. + if root.hydrate then + root.hydrate = false + ReactFiberHostConfig.clearContainer(root.containerInfo) + end + + -- If something threw an error, try rendering one more time. We'll render + -- synchronously to block concurrent data mutations, and we'll includes + -- all pending updates are included. If it still fails after the second + -- attempt, we'll give up and commit the resulting tree. + lanes = getLanesToRetrySynchronouslyOnError(root) + if lanes ~= ReactFiberLane.NoLanes then + exitStatus = mod.renderRootSync(root, lanes) + end + end + + if exitStatus == RootExitStatus.FatalErrored then + local fatalError = workInProgressRootFatalError + mod.prepareFreshStack(root, ReactFiberLane.NoLanes) + mod.markRootSuspended(root, lanes) + ensureRootIsScheduled(root, now()) + error(fatalError) + end + + -- We now have a consistent tree. The next step is either to commit it, + -- or, if something suspended, wait to commit it after a timeout. + local finishedWork: Fiber = root.current.alternate :: any + root.finishedWork = finishedWork + root.finishedLanes = lanes + mod.finishConcurrentRender(root, exitStatus, lanes) + end + + ensureRootIsScheduled(root, now()) + if root.callbackNode == originalCallbackNode then + -- The task node scheduled for this root is the same one that's + -- currently executed. Need to return a continuation. + return function() + -- ROBLOX deviation: RobloxReactProfiling + local profileRunning = RobloxReactProfiling.profileRootBeforeUnitOfWork(root) + local ret = mod.performConcurrentWorkOnRoot(root) + RobloxReactProfiling.profileRootAfterYielding(profileRunning) + return ret + end + end + -- ROBLOX Luau FIXME: Luau shouldn't error on nil-able returns + return nil :: any end -- we track the 'depth' of the act() calls with this counter, @@ -984,2058 +981,2090 @@ local actingUpdatesScopeDepth = 0 local didWarnAboutUsingActInProd = false function shouldForceFlushFallbacksInDEV() - -- Never force flush in production. This function should get stripped out. - return _G.__DEV__ and actingUpdatesScopeDepth > 0 + -- Never force flush in production. This function should get stripped out. + return __DEV__ and actingUpdatesScopeDepth > 0 end mod.finishConcurrentRender = function(root, exitStatus, lanes) - if - exitStatus == RootExitStatus.Incomplete or - exitStatus == RootExitStatus.FatalErrored - then - invariant(false, 'Root did not complete. This is a bug in React.') - -- Flow knows about invariant, so it complains if I add a break - -- if I do. eslint-disable-next-line no-fallthrough - -- statement, but eslint doesn't know about invariant, so it complains - elseif exitStatus == RootExitStatus.Errored then - -- We should have already attempted to retry this tree. If we reached - -- this point, it errored again. Commit it. - mod.commitRoot(root) - elseif exitStatus == RootExitStatus.Suspended then - mod.markRootSuspended(root, lanes) - - -- We have an acceptable loading state. We need to figure out if we - -- should immediately commit it or wait a bit. - if - includesOnlyRetries(lanes) and - -- do not delay if we're inside an act() scope - not shouldForceFlushFallbacksInDEV() then - -- This render only included retries, no updates. Throttle committing - -- retries so that we don't show too many loading states too quickly. - local msUntilTimeout = - globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now() - -- Don't bother with a very short suspense time. - if msUntilTimeout > 10 then - local nextLanes = getNextLanes(root, ReactFiberLane.NoLanes) - if nextLanes ~= ReactFiberLane.NoLanes then - -- There's additional work on this root. - return - end - local suspendedLanes = root.suspendedLanes - if not isSubsetOfLanes(suspendedLanes, lanes) then - -- We should prefer to render the fallback of at the last - -- suspended level. Ping the last suspended level to try - -- rendering it again. - -- FIXME: What if the suspended lanes are Idle? Should not restart. - local eventTime = exports.requestEventTime() - markRootPinged(root, suspendedLanes, eventTime) - return - end - - -- The render is suspended, it hasn't timed out, and there's no - -- lower priority work to do. Instead of committing the fallback - -- immediately, wait for more data to arrive. - root.timeoutHandle = ReactFiberHostConfig.scheduleTimeout( - function() return mod.commitRoot(root) end, - msUntilTimeout - ) - return - end - end - -- The work expired. Commit immediately. - mod.commitRoot(root) - elseif exitStatus == RootExitStatus.SuspendedWithDelay then - mod.markRootSuspended(root, lanes) - - if includesOnlyTransitions(lanes) then - -- This is a transition, so we should exit without committing a - -- placeholder and without scheduling a timeout. Delay indefinitely - -- until we receive more data. - return - end - - if not shouldForceFlushFallbacksInDEV() then - -- This is not a transition, but we did trigger an avoided state. - -- Schedule a placeholder to display after a short delay, using the Just - -- Noticeable Difference. - -- TODO: Is the JND optimization worth the added complexity? If this is - -- the only reason we track the event time, then probably not. - -- Consider removing. - - local mostRecentEventTime = getMostRecentEventTime(root, lanes) - local eventTimeMs = mostRecentEventTime - local timeElapsedMs = now() - eventTimeMs - local msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs - - -- Don't bother with a very short suspense time. - if msUntilTimeout > 10 then - -- Instead of committing the fallback immediately, wait for more data - -- to arrive. - root.timeoutHandle = ReactFiberHostConfig.scheduleTimeout( - function() return mod.commitRoot(root) end, - msUntilTimeout - ) - return - end - end - -- Commit the placeholder. - mod.commitRoot(root) - elseif exitStatus == RootExitStatus.Completed then - -- The work completed. Ready to commit. - mod.commitRoot(root) - else - invariant(false, "Unknown root exit status.") - end + if + exitStatus == RootExitStatus.Incomplete + or exitStatus == RootExitStatus.FatalErrored + then + invariant(false, "Root did not complete. This is a bug in React.") + -- Flow knows about invariant, so it complains if I add a break + -- if I do. eslint-disable-next-line no-fallthrough + -- statement, but eslint doesn't know about invariant, so it complains + elseif exitStatus == RootExitStatus.Errored then + -- We should have already attempted to retry this tree. If we reached + -- this point, it errored again. Commit it. + mod.commitRoot(root) + elseif exitStatus == RootExitStatus.Suspended then + mod.markRootSuspended(root, lanes) + + -- We have an acceptable loading state. We need to figure out if we + -- should immediately commit it or wait a bit. + if + includesOnlyRetries(lanes) + -- do not delay if we're inside an act() scope + and not shouldForceFlushFallbacksInDEV() + then + -- This render only included retries, no updates. Throttle committing + -- retries so that we don't show too many loading states too quickly. + local msUntilTimeout = globalMostRecentFallbackTime + + FALLBACK_THROTTLE_MS + - now() + -- Don't bother with a very short suspense time. + if msUntilTimeout > 10 then + local nextLanes = getNextLanes(root, ReactFiberLane.NoLanes) + if nextLanes ~= ReactFiberLane.NoLanes then + -- There's additional work on this root. + return + end + local suspendedLanes = root.suspendedLanes + if not isSubsetOfLanes(suspendedLanes, lanes) then + -- We should prefer to render the fallback of at the last + -- suspended level. Ping the last suspended level to try + -- rendering it again. + -- FIXME: What if the suspended lanes are Idle? Should not restart. + local eventTime = exports.requestEventTime() + markRootPinged(root, suspendedLanes, eventTime) + return + end + + -- The render is suspended, it hasn't timed out, and there's no + -- lower priority work to do. Instead of committing the fallback + -- immediately, wait for more data to arrive. + root.timeoutHandle = ReactFiberHostConfig.scheduleTimeout(function() + return mod.commitRoot(root) + end, msUntilTimeout) + return + end + end + -- The work expired. Commit immediately. + mod.commitRoot(root) + elseif exitStatus == RootExitStatus.SuspendedWithDelay then + mod.markRootSuspended(root, lanes) + + if includesOnlyTransitions(lanes) then + -- This is a transition, so we should exit without committing a + -- placeholder and without scheduling a timeout. Delay indefinitely + -- until we receive more data. + return + end + + if not shouldForceFlushFallbacksInDEV() then + -- This is not a transition, but we did trigger an avoided state. + -- Schedule a placeholder to display after a short delay, using the Just + -- Noticeable Difference. + -- TODO: Is the JND optimization worth the added complexity? If this is + -- the only reason we track the event time, then probably not. + -- Consider removing. + + local mostRecentEventTime = getMostRecentEventTime(root, lanes) + local eventTimeMs = mostRecentEventTime + local timeElapsedMs = now() - eventTimeMs + local msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs + + -- Don't bother with a very short suspense time. + if msUntilTimeout > 10 then + -- Instead of committing the fallback immediately, wait for more data + -- to arrive. + root.timeoutHandle = ReactFiberHostConfig.scheduleTimeout(function() + return mod.commitRoot(root) + end, msUntilTimeout) + return + end + end + -- Commit the placeholder. + mod.commitRoot(root) + elseif exitStatus == RootExitStatus.Completed then + -- The work completed. Ready to commit. + mod.commitRoot(root) + else + invariant(false, "Unknown root exit status.") + end end mod.markRootSuspended = function(root, suspendedLanes) - -- When suspending, we should always exclude lanes that were pinged or (more - -- rarely, since we try to avoid it) updated during the render phase. - -- TODO: Lol maybe there's a better way to factor this besides this - -- obnoxiously named function :) - suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes) - suspendedLanes = removeLanes(suspendedLanes, workInProgressRootUpdatedLanes) - markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes) + -- When suspending, we should always exclude lanes that were pinged or (more + -- rarely, since we try to avoid it) updated during the render phase. + -- TODO: Lol maybe there's a better way to factor this besides this + -- obnoxiously named function :) + suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes) + suspendedLanes = removeLanes(suspendedLanes, workInProgressRootUpdatedLanes) + markRootSuspended_dontCallThisOneDirectly(root, suspendedLanes) end -- This is the entry point for synchronous tasks that don't go -- through Scheduler mod.performSyncWorkOnRoot = function(root) - invariant( - bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, - "Should not already be working." - ) - - exports.flushPassiveEffects() - - local lanes - local exitStatus - if - root == workInProgressRoot and - includesSomeLane(root.expiredLanes, workInProgressRootRenderLanes) - then - -- There's a partial tree, and at least one of its lanes has expired. Finish - -- rendering it before rendering the rest of the expired work. - lanes = workInProgressRootRenderLanes - exitStatus = mod.renderRootSync(root, lanes) - if - includesSomeLane( - workInProgressRootIncludedLanes, - workInProgressRootUpdatedLanes - ) - then - -- The render included lanes that were updated during the render phase. - -- For example, when unhiding a hidden tree, we include all the lanes - -- that were previously skipped when the tree was hidden. That set of - -- lanes is a superset of the lanes we started rendering with. - -- - -- Note that this only happens when part of the tree is rendered - -- concurrently. If the whole tree is rendered synchronously, then there - -- are no interleaved events. - lanes = getNextLanes(root, lanes) - exitStatus = mod.renderRootSync(root, lanes) - end - else - lanes = getNextLanes(root, ReactFiberLane.NoLanes) - exitStatus = mod.renderRootSync(root, lanes) - end - - if root.tag ~= LegacyRoot and exitStatus == RootExitStatus.Errored then - executionContext = bit32.bor(executionContext, RetryAfterError) - - -- If an error occurred during hydration, - -- discard server response and fall back to client side render. - if root.hydrate then - root.hydrate = false - ReactFiberHostConfig.clearContainer(root.containerInfo) - end - - -- If something threw an error, try rendering one more time. We'll render - -- synchronously to block concurrent data mutations, and we'll includes - -- all pending updates are included. If it still fails after the second - -- attempt, we'll give up and commit the resulting tree. - lanes = getLanesToRetrySynchronouslyOnError(root) - if lanes ~= ReactFiberLane.NoLanes then - exitStatus = mod.renderRootSync(root, lanes) - end - end - - if exitStatus == RootExitStatus.FatalErrored then - local fatalError = workInProgressRootFatalError - mod.prepareFreshStack(root, ReactFiberLane.NoLanes) - mod.markRootSuspended(root, lanes) - ensureRootIsScheduled(root, now()) - error(fatalError) - end - - -- We now have a consistent tree. Because this is a sync render, we - -- will commit it even if something suspended. - local finishedWork: Fiber = root.current.alternate - root.finishedWork = finishedWork - root.finishedLanes = lanes - mod.commitRoot(root) - - -- Before exiting, make sure there's a callback scheduled for the next - -- pending level. - ensureRootIsScheduled(root, now()) - - return nil + invariant( + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, + "Should not already be working." + ) + + exports.flushPassiveEffects() + + local lanes + local exitStatus + if + root == workInProgressRoot + and includesSomeLane(root.expiredLanes, workInProgressRootRenderLanes) + then + -- There's a partial tree, and at least one of its lanes has expired. Finish + -- rendering it before rendering the rest of the expired work. + lanes = workInProgressRootRenderLanes + exitStatus = mod.renderRootSync(root, lanes) + if + includesSomeLane( + workInProgressRootIncludedLanes, + workInProgressRootUpdatedLanes + ) + then + -- The render included lanes that were updated during the render phase. + -- For example, when unhiding a hidden tree, we include all the lanes + -- that were previously skipped when the tree was hidden. That set of + -- lanes is a superset of the lanes we started rendering with. + -- + -- Note that this only happens when part of the tree is rendered + -- concurrently. If the whole tree is rendered synchronously, then there + -- are no interleaved events. + lanes = getNextLanes(root, lanes) + exitStatus = mod.renderRootSync(root, lanes) + end + else + lanes = getNextLanes(root, ReactFiberLane.NoLanes) + exitStatus = mod.renderRootSync(root, lanes) + end + + if root.tag ~= LegacyRoot and exitStatus == RootExitStatus.Errored then + executionContext = bit32.bor(executionContext, RetryAfterError) + + -- If an error occurred during hydration, + -- discard server response and fall back to client side render. + if root.hydrate then + root.hydrate = false + ReactFiberHostConfig.clearContainer(root.containerInfo) + end + + -- If something threw an error, try rendering one more time. We'll render + -- synchronously to block concurrent data mutations, and we'll includes + -- all pending updates are included. If it still fails after the second + -- attempt, we'll give up and commit the resulting tree. + lanes = getLanesToRetrySynchronouslyOnError(root) + if lanes ~= ReactFiberLane.NoLanes then + exitStatus = mod.renderRootSync(root, lanes) + end + end + + if exitStatus == RootExitStatus.FatalErrored then + local fatalError = workInProgressRootFatalError + mod.prepareFreshStack(root, ReactFiberLane.NoLanes) + mod.markRootSuspended(root, lanes) + ensureRootIsScheduled(root, now()) + error(fatalError) + end + + -- We now have a consistent tree. Because this is a sync render, we + -- will commit it even if something suspended. + local finishedWork: Fiber = root.current.alternate :: any + root.finishedWork = finishedWork + root.finishedLanes = lanes + mod.commitRoot(root) + + -- Before exiting, make sure there's a callback scheduled for the next + -- pending level. + ensureRootIsScheduled(root, now()) + + return nil end -exports.flushRoot = function(root: ReactInternalTypes.FiberRoot, lanes: Lanes) - markRootExpired(root, lanes) - ensureRootIsScheduled(root, now()) - if bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext then - resetRenderTimer() - flushSyncCallbackQueue() - end +exports.flushRoot = function(root: FiberRoot, lanes: Lanes) + markRootExpired(root, lanes) + ensureRootIsScheduled(root, now()) + if + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) + == NoContext + then + resetRenderTimer() + flushSyncCallbackQueue() + end end exports.getExecutionContext = function(): ExecutionContext - return executionContext + return executionContext end exports.flushDiscreteUpdates = function() - -- TODO: Should be able to flush inside batchedUpdates, but not inside `act`. - -- However, `act` uses `batchedUpdates`, so there's no way to distinguish - -- those two cases. Need to fix this before exposing flushDiscreteUpdates - -- as a public API. - if - bit32.band(executionContext, bit32.bor(BatchedContext, RenderContext, CommitContext)) ~= - NoContext - then - if _G.__DEV__ then - if bit32.band(executionContext, RenderContext) ~= NoContext then - console.error( - "unstable_flushDiscreteUpdates: Cannot flush updates when React is " .. - "already rendering." - ) - end - end - -- We're already rendering, so we can't synchronously flush pending work.s - -- This is probably a nested event dispatch triggered by a lifecycle/effect, - -- like `el.focus()`. Exit. - return - end - mod.flushPendingDiscreteUpdates() - -- If the discrete updates scheduled passive effects, flush them now so that - -- they fire before the next serial event. - exports.flushPassiveEffects() + -- TODO: Should be able to flush inside batchedUpdates, but not inside `act`. + -- However, `act` uses `batchedUpdates`, so there's no way to distinguish + -- those two cases. Need to fix this before exposing flushDiscreteUpdates + -- as a public API. + if + bit32.band( + executionContext, + bit32.bor(BatchedContext, RenderContext, CommitContext) + ) ~= NoContext + then + if __DEV__ then + if bit32.band(executionContext, RenderContext) ~= NoContext then + console.error( + "unstable_flushDiscreteUpdates: Cannot flush updates when React is " + .. "already rendering." + ) + end + end + -- We're already rendering, so we can't synchronously flush pending work.s + -- This is probably a nested event dispatch triggered by a lifecycle/effect, + -- like `el.focus()`. Exit. + return + end + mod.flushPendingDiscreteUpdates() + -- If the discrete updates scheduled passive effects, flush them now so that + -- they fire before the next serial event. + exports.flushPassiveEffects() end --- ROBLOX deviation: FIXME establish generics in signature when Luau supports this --- local function deferredUpdates(fn: () => A): A -exports.deferredUpdates = function(fn: () -> any): any - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - setCurrentUpdateLanePriority(ReactFiberLane.DefaultLanePriority) - return runWithPriority(NormalSchedulerPriority, fn) - end) - else - ok = true - setCurrentUpdateLanePriority(ReactFiberLane.DefaultLanePriority) - result = runWithPriority(NormalSchedulerPriority, fn) - end - - -- finally - setCurrentUpdateLanePriority(previousLanePriority) - - if ok then - return result - else - error(result) - end - else - return runWithPriority(NormalSchedulerPriority, fn) - end +exports.deferredUpdates = function(fn: () -> A): A + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + -- ROBLOX performance: hoist non-throwable out of try{} to eliminate anon function + setCurrentUpdateLanePriority(ReactFiberLane.DefaultLanePriority) + ok, result = + xpcall(runWithPriority, describeError, NormalSchedulerPriority, fn) + else + ok = true + setCurrentUpdateLanePriority(ReactFiberLane.DefaultLanePriority) + result = runWithPriority(NormalSchedulerPriority, fn) + end + + -- finally + setCurrentUpdateLanePriority(previousLanePriority) + + if ok then + return result + else + error(result) + end + else + return runWithPriority(NormalSchedulerPriority, fn) + end end mod.flushPendingDiscreteUpdates = function() - if rootsWithPendingDiscreteUpdates ~= nil then - -- For each root with pending discrete updates, schedule a callback to - -- immediately flush them. - local roots = rootsWithPendingDiscreteUpdates - rootsWithPendingDiscreteUpdates = nil - -- ROBLOX deviation: proper for loop instead of forEach; - -- rootsWithPendingDiscreteUpdates is a Set, so we use the keys - for root, _ in pairs(roots) do - markDiscreteUpdatesExpired(root) - ensureRootIsScheduled(root, now()) - end - end - -- Now flush the immediate queue. - flushSyncCallbackQueue() + if rootsWithPendingDiscreteUpdates ~= nil then + -- For each root with pending discrete updates, schedule a callback to + -- immediately flush them. + local roots = rootsWithPendingDiscreteUpdates + rootsWithPendingDiscreteUpdates = nil + roots:forEach(function(root) + markDiscreteUpdatesExpired(root) + ensureRootIsScheduled(root, now()) + end) + end + -- Now flush the immediate queue. + flushSyncCallbackQueue() end --- ROBLOX deviation: FIXME establish generics in signature when Luau supports this --- local function batchedUpdates(fn: A => R, a: A): R -exports.batchedUpdates = function(fn: (any) -> any, a: any): any - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, BatchedContext) - - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - return fn(a) - end) - else - ok = true - result = fn(a) - end - - -- finally - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if ok then - return result - else - error(result) - end -end +exports.batchedUpdates = function(fn: (A) -> R, a: A): R + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, BatchedContext) + + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + ok, result = xpcall(fn, describeError, a) + else + ok = true + result = fn(a) + end --- ROBLOX deviation: FIXME establish generics in signature when Luau supports this --- local function batchedEventUpdates(fn: A => R, a: A): R -exports.batchedEventUpdates = function(fn: (any) -> any, a: any): any - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, EventContext) - - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - return fn(a) - end) - else - ok = true - result = fn(a) - end - - -- finally - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if ok then - return result - else - error(result) - end + -- finally + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if ok then + return result + else + error(result) + end end +exports.batchedEventUpdates = function(fn: (A) -> R, a: A): R + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, EventContext) + + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + ok, result = xpcall(fn, describeError, a) + else + ok = true + result = fn(a) + end + + -- finally + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if ok then + return result + else + error(result) + end +end --- deviation: FIXME establish generics in signature when Luau supports this --- local function discreteUpdates( --- fn: (A, B, C) => R, --- a: A, --- b: B, --- c: C, --- d: D, --- ): R -exports.discreteUpdates = function(fn: (any, any, any, any) -> any, a, b, c, d): any - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, DiscreteEventContext) - - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - local ok, result = pcall(function() - setCurrentUpdateLanePriority(ReactFiberLane.InputDiscreteLanePriority) - return runWithPriority( - UserBlockingSchedulerPriority, - function() - return fn(a, b, c, d) - end - ) - end) - - -- finally - setCurrentUpdateLanePriority(previousLanePriority) - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if ok then - return result - else - error(result) - end - else - local ok, result = pcall(function() - return runWithPriority( - UserBlockingSchedulerPriority, - function() - return fn(a, b, c, d) - end - ) - end) - - -- finally - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if ok then - return result - else - error(result) - end - end +exports.discreteUpdates = function(fn: (A, B, C, D) -> R, a: A, b: B, c: C, d: D): R + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, DiscreteEventContext) + + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + -- ROBLOX performance: extract non-throwable fn call out of try{} so we can remove an anon function + setCurrentUpdateLanePriority(ReactFiberLane.InputDiscreteLanePriority) + local ok, result = xpcall( + runWithPriority, + describeError, + UserBlockingSchedulerPriority, + function() + return fn(a, b, c, d) + end + ) + + -- finally + setCurrentUpdateLanePriority(previousLanePriority) + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if ok then + return result + else + error(result) + end + else + local ok, result = xpcall( + runWithPriority, + describeError, + UserBlockingSchedulerPriority, + function() + return fn(a, b, c, d) + end + ) + + -- finally + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if ok then + return result + else + error(result) + end + end end --- ROBLOX deviation: FIXME establish generics in signature when Luau supports this --- local function unbatchedUpdates(fn: A => R, a: A): R -exports.unbatchedUpdates = function(fn: (any) -> any, a: any): any - local prevExecutionContext = executionContext - executionContext = bit32.band(executionContext, bit32.bnot(BatchedContext)) - executionContext = bit32.bor(executionContext, LegacyUnbatchedContext) - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - return fn(a) - end) - else - ok = true - result = fn(a) - end - - -- finally - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if ok then - return result - else - error(result) - end +exports.unbatchedUpdates = function(fn: (A) -> R, a: A): R + local prevExecutionContext = executionContext + executionContext = bit32.band(executionContext, bit32.bnot(BatchedContext)) + executionContext = bit32.bor(executionContext, LegacyUnbatchedContext) + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + ok, result = xpcall(fn, describeError, a) + else + ok = true + result = fn(a) + end + + -- finally + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if ok then + return result + else + error(result) + end end --- ROBLOX deviation: FIXME establish generics in signature when Luau supports this --- (fn: A => R, a: A): R -exports.flushSync = function(fn: (any) -> any, a: any): any - local prevExecutionContext = executionContext - if (bit32.band(prevExecutionContext, bit32.bor(RenderContext, CommitContext))) ~= NoContext then - if _G.__DEV__ then - console.error( - 'flushSync was called from inside a lifecycle method. React cannot ' .. - 'flush when React is already rendering. Consider moving this call to ' .. - 'a scheduler task or micro task.' - ) - end - return fn(a) - end - executionContext = bit32.bor(executionContext, BatchedContext) - - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) - if fn then - return runWithPriority(ImmediateSchedulerPriority, function() - return fn(a) - end) - else - return nil - end - end) - else - ok = true - setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) - if fn then - result = runWithPriority(ImmediateSchedulerPriority, function() - return fn(a) - end) - else - result = nil - end - end - - setCurrentUpdateLanePriority(previousLanePriority) - executionContext = prevExecutionContext - -- Flush the immediate callbacks that were scheduled during this batch. - -- Note that this will happen even if batchedUpdates is higher up - -- the stack. - flushSyncCallbackQueue() - - if not ok then - error(result) - -- ROBLOX FIXME: Luau makes us put a return here because it doesn't understand error() is no-return - return nil - end - return result - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - if fn then - return runWithPriority(ImmediateSchedulerPriority, function() - return fn(a) - end) - else - return nil - end - end) - else - ok = true - if fn then - result = runWithPriority(ImmediateSchedulerPriority, function() - return fn(a) - end) - else - result = nil - end - end - -- ROBLOX: finally - executionContext = prevExecutionContext - -- Flush the immediate callbacks that were scheduled during this batch. - -- Note that this will happen even if batchedUpdates is higher up - -- the stack. - flushSyncCallbackQueue() - if not ok then - error(result) - end - return result - end +exports.flushSync = function(fn: (A) -> R, a: A): R + local prevExecutionContext = executionContext + if + (bit32.band(prevExecutionContext, bit32.bor(RenderContext, CommitContext))) + ~= NoContext + then + if __DEV__ then + console.error( + "flushSync was called from inside a lifecycle method. React cannot " + .. "flush when React is already rendering. Consider moving this call to " + .. "a scheduler task or micro task." + ) + end + return fn(a) + end + executionContext = bit32.bor(executionContext, BatchedContext) + + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + + -- ROBLOX performance: extract non-throwable call out of try{} to eliminate an anon function + setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + if fn then + ok, result = xpcall( + runWithPriority, + describeError, + ImmediateSchedulerPriority, + function() + return fn(a) + end + ) + else + ok = true + -- ROBLOX note: return (undefined: $FlowFixMe) + result = nil :: any + end + else + ok = true + setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) + if fn then + result = runWithPriority(ImmediateSchedulerPriority, function() + return fn(a) + end) + else + -- ROBLOX note: return (undefined: $FlowFixMe) + result = nil :: any + end + end + + -- ROBLOX finally + setCurrentUpdateLanePriority(previousLanePriority) + executionContext = prevExecutionContext + -- Flush the immediate callbacks that were scheduled during this batch. + -- Note that this will happen even if batchedUpdates is higher up + -- the stack. + flushSyncCallbackQueue() + + if not ok then + error(result) + end + return result + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + if fn then + ok, result = xpcall( + runWithPriority, + describeError, + ImmediateSchedulerPriority, + function() + return fn(a) + end + ) + else + ok = true + -- ROBLOX note: return (undefined: $FlowFixMe) + result = nil :: any + end + else + ok = true + if fn then + result = runWithPriority(ImmediateSchedulerPriority, function() + return fn(a) + end) + else + -- ROBLOX note: return (undefined: $FlowFixMe) + result = nil :: any + end + end + -- ROBLOX: finally + executionContext = prevExecutionContext + -- Flush the immediate callbacks that were scheduled during this batch. + -- Note that this will happen even if batchedUpdates is higher up + -- the stack. + flushSyncCallbackQueue() + if not ok then + error(result) + end + return result + end end exports.flushControlled = function(fn: () -> any) - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, BatchedContext) - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - local ok, result = pcall(function() - setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) - runWithPriority(ImmediateSchedulerPriority, fn) - end) - - -- finally - setCurrentUpdateLanePriority(previousLanePriority) - - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if not ok then - error(result) - end - else - local ok, result = pcall(function() - runWithPriority(ImmediateSchedulerPriority, fn) - end) - -- finally - executionContext = prevExecutionContext - if executionContext == NoContext then - -- Flush the immediate callbacks that were scheduled during this batch - resetRenderTimer() - flushSyncCallbackQueue() - end - - if not ok then - error(result) - end - end + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, BatchedContext) + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + -- ROBLOX performance: extract non-throwable call out of try{} to eliminate an anon function + setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) + local ok, result = + xpcall(runWithPriority, describeError, ImmediateSchedulerPriority, fn) + + -- finally + setCurrentUpdateLanePriority(previousLanePriority) + + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if not ok then + error(result) + end + else + local ok, result = + xpcall(runWithPriority, describeError, ImmediateSchedulerPriority, fn) + -- finally + executionContext = prevExecutionContext + if executionContext == NoContext then + -- Flush the immediate callbacks that were scheduled during this batch + resetRenderTimer() + flushSyncCallbackQueue() + end + + if not ok then + error(result) + end + end end exports.pushRenderLanes = function(fiber: Fiber, lanes: Lanes) - pushToStack(subtreeRenderLanesCursor, exports.subtreeRenderLanes, fiber) - exports.subtreeRenderLanes = mergeLanes(exports.subtreeRenderLanes, lanes) - workInProgressRootIncludedLanes = mergeLanes( - workInProgressRootIncludedLanes, - lanes - ) + pushToStack(subtreeRenderLanesCursor, exports.subtreeRenderLanes, fiber) + exports.subtreeRenderLanes = mergeLanes(exports.subtreeRenderLanes, lanes) + workInProgressRootIncludedLanes = mergeLanes(workInProgressRootIncludedLanes, lanes) end exports.popRenderLanes = function(fiber: Fiber) - exports.subtreeRenderLanes = subtreeRenderLanesCursor.current - popFromStack(subtreeRenderLanesCursor, fiber) + exports.subtreeRenderLanes = subtreeRenderLanesCursor.current + popFromStack(subtreeRenderLanesCursor, fiber) end -mod.prepareFreshStack = function(root: ReactInternalTypes.FiberRoot, lanes: Lanes) - root.finishedWork = nil - root.finishedLanes = ReactFiberLane.NoLanes - - local timeoutHandle = root.timeoutHandle - if timeoutHandle ~= ReactFiberHostConfig.noTimeout then - -- The root previous suspended and scheduled a timeout to commit a fallback - -- state. Now that we have additional work, cancel the timeout. - root.timeoutHandle = ReactFiberHostConfig.noTimeout - -- $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above - ReactFiberHostConfig.cancelTimeout(timeoutHandle) - end - - if workInProgress ~= nil then - local interruptedWork = (workInProgress :: Fiber).return_ - while interruptedWork ~= nil do - unwindInterruptedWork(interruptedWork) - interruptedWork = interruptedWork.return_ - end - end - workInProgressRoot = root - workInProgress = ReactFiber.createWorkInProgress(root.current, nil) - workInProgressRootRenderLanes = lanes - exports.subtreeRenderLanes = lanes - workInProgressRootIncludedLanes = lanes - workInProgressRootExitStatus = RootExitStatus.Incomplete - workInProgressRootFatalError = nil - workInProgressRootSkippedLanes(ReactFiberLane.NoLanes) - workInProgressRootUpdatedLanes = ReactFiberLane.NoLanes - workInProgressRootPingedLanes = ReactFiberLane.NoLanes - - if ReactFeatureFlags.enableSchedulerTracing then - spawnedWorkDuringRender = nil - end - - if _G.__DEV__ then - ReactStrictModeWarnings.discardPendingWarnings() - end +mod.prepareFreshStack = function(root: FiberRoot, lanes: Lanes) + root.finishedWork = nil + root.finishedLanes = ReactFiberLane.NoLanes + + local timeoutHandle = root.timeoutHandle + if timeoutHandle ~= ReactFiberHostConfig.noTimeout then + -- The root previous suspended and scheduled a timeout to commit a fallback + -- state. Now that we have additional work, cancel the timeout. + root.timeoutHandle = ReactFiberHostConfig.noTimeout + -- $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above + ReactFiberHostConfig.cancelTimeout(timeoutHandle) + end + + if workInProgress ~= nil then + local interruptedWork = workInProgress.return_ + while interruptedWork ~= nil do + unwindInterruptedWork(interruptedWork) + interruptedWork = interruptedWork.return_ + end + end + workInProgressRoot = root + workInProgress = ReactFiber.createWorkInProgress(root.current, nil) + workInProgressRootRenderLanes = lanes + exports.subtreeRenderLanes = lanes + workInProgressRootIncludedLanes = lanes + workInProgressRootExitStatus = RootExitStatus.Incomplete + workInProgressRootFatalError = nil + workInProgressRootSkippedLanes(ReactFiberLane.NoLanes) + workInProgressRootUpdatedLanes = ReactFiberLane.NoLanes + workInProgressRootPingedLanes = ReactFiberLane.NoLanes + + if ReactFeatureFlags.enableSchedulerTracing then + spawnedWorkDuringRender = nil + end + + if __DEV__ then + ReactStrictModeWarnings.discardPendingWarnings() + end end -mod.handleError = function(root, thrownValue) - while true do - local erroredWork = workInProgress - local ok, yetAnotherThrownValue = pcall(function() - -- Reset module-level state that was set during the render phase. - resetContextDependencies() - resetHooksAfterThrow() - resetCurrentDebugFiberInDEV() - -- TODO: I found and added this missing line while investigating a - -- separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = nil - - if erroredWork == nil or erroredWork.return_ == nil then - -- Expected to be working on a non-root fiber. This is a fatal error - -- because there's no ancestor that can handle it; the root is - -- supposed to capture all errors that weren't caught by an error - -- boundary. - workInProgressRootExitStatus = RootExitStatus.FatalErrored - workInProgressRootFatalError = thrownValue - -- Set `workInProgress` to nil. This represents advancing to the next - -- sibling, or the parent if there are no siblings. But since the root - -- has no siblings nor a parent, we set it to nil. Usually this is - -- handled by `completeUnitOfWork` or `unwindWork`, but since we're - -- intentionally not calling those, we need set it here. - -- TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = nil - return - end - - if ReactFeatureFlags.enableProfilerTimer and bit32.band(erroredWork.mode, ReactTypeOfMode.ProfileMode) ~= 0 then - -- Record the time spent rendering before an error was thrown. This - -- avoids inaccurate Profiler durations in the case of a - -- suspended render. - ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true) - end - - -- ROBLOX deviation, we pass in onUncaughtError and renderDidError here since throwException can't call them due to a require cycle - throwException( - root, - erroredWork.return_, - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - exports.onUncaughtError, - exports.renderDidError - ) - mod.completeUnitOfWork(erroredWork) - end) - if not ok then - -- Something in the return path also threw. - thrownValue = yetAnotherThrownValue - if workInProgress == erroredWork and erroredWork ~= nil then - -- If this boundary has already errored, then we had trouble processing - -- the error. Bubble it to the next boundary. - erroredWork = erroredWork.return_ - workInProgress = erroredWork - else - erroredWork = workInProgress - end - continue - end - -- Return to the normal work loop. - return - end +mod.handleError = function(root, thrownValue): () + while true do + local erroredWork = workInProgress + -- ROBLOX FIXME Luau: CLI-49835, "Function only returns 1 value, 2 are required" + local ok, yetAnotherThrownValue = pcall(function() + -- Reset module-level state that was set during the render phase. + resetContextDependencies() + resetHooksAfterThrow() + resetCurrentDebugFiberInDEV() + -- TODO: I found and added this missing line while investigating a + -- separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = nil + + if erroredWork == nil or erroredWork.return_ == nil then + -- Expected to be working on a non-root fiber. This is a fatal error + -- because there's no ancestor that can handle it; the root is + -- supposed to capture all errors that weren't caught by an error + -- boundary. + workInProgressRootExitStatus = RootExitStatus.FatalErrored + workInProgressRootFatalError = thrownValue + -- Set `workInProgress` to nil. This represents advancing to the next + -- sibling, or the parent if there are no siblings. But since the root + -- has no siblings nor a parent, we set it to nil. Usually this is + -- handled by `completeUnitOfWork` or `unwindWork`, but since we're + -- intentionally not calling those, we need set it here. + -- TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = nil + -- ROBLOX FIXME: THIS IS A BUG, WE SHOULD BAIL ON THE OUTER FUNCTION -- NOT THE PCALL! + return + end + + -- ROBLOX Luau FIXME: Luau doesn't narrow based on the erroredWork == nil then return above + if + ReactFeatureFlags.enableProfilerTimer + and bit32.band( + (erroredWork :: Fiber).mode, + ReactTypeOfMode.ProfileMode + ) + ~= 0 + then + -- Record the time spent rendering before an error was thrown. This + -- avoids inaccurate Profiler durations in the case of a + -- suspended render. + ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta( + erroredWork :: Fiber, + true + ) + end + + -- ROBLOX deviation, we pass in onUncaughtError and renderDidError here since throwException can't call them due to a require cycle + throwException( + root, + (erroredWork :: Fiber).return_, + erroredWork :: Fiber, + thrownValue, + workInProgressRootRenderLanes, + exports.onUncaughtError, + exports.renderDidError + ) + mod.completeUnitOfWork(erroredWork) + end) + if not ok then + -- Something in the return path also threw. + thrownValue = yetAnotherThrownValue + if workInProgress == erroredWork and erroredWork ~= nil then + -- If this boundary has already errored, then we had trouble processing + -- the error. Bubble it to the next boundary. + erroredWork = erroredWork.return_ + workInProgress = erroredWork + else + erroredWork = workInProgress + end + continue + end + -- Return to the normal work loop. + return + end end mod.pushDispatcher = function() - local prevDispatcher = ReactCurrentDispatcher.current - - -- ROBLOX deviation: lazy init of ContextOnlyDispatcher wrapped in a function - ReactCurrentDispatcher.current = ContextOnlyDispatcher() - if prevDispatcher == nil then - -- The React isomorphic package does not include a default dispatcher. - -- Instead the first renderer will lazily attach one, in order to give - -- nicer error messages. - - -- ROBLOX deviation: lazy init of ContextOnlyDispatcher wrapped in a function - return ContextOnlyDispatcher() - else - return prevDispatcher - end + local prevDispatcher = ReactCurrentDispatcher.current + + -- ROBLOX deviation: lazy init of ContextOnlyDispatcher wrapped in a function + ReactCurrentDispatcher.current = ContextOnlyDispatcher() + if prevDispatcher == nil then + -- The React isomorphic package does not include a default dispatcher. + -- Instead the first renderer will lazily attach one, in order to give + -- nicer error messages. + + -- ROBLOX deviation: lazy init of ContextOnlyDispatcher wrapped in a function + return ContextOnlyDispatcher() + else + return prevDispatcher + end end mod.popDispatcher = function(prevDispatcher) - ReactCurrentDispatcher.current = prevDispatcher + ReactCurrentDispatcher.current = prevDispatcher end mod.pushInteractions = function(root) - if ReactFeatureFlags.enableSchedulerTracing then - local prevInteractions: Set? = __interactionsRef.current - __interactionsRef.current = root.memoizedInteractions - return prevInteractions - end - return nil + if ReactFeatureFlags.enableSchedulerTracing then + local prevInteractions: Set? = __interactionsRef.current + __interactionsRef.current = root.memoizedInteractions + return prevInteractions + end + return nil end mod.popInteractions = function(prevInteractions) - if ReactFeatureFlags.enableSchedulerTracing then - __interactionsRef.current = prevInteractions - end + if ReactFeatureFlags.enableSchedulerTracing then + __interactionsRef.current = prevInteractions + end end -exports.markCommitTimeOfFallback = function() - globalMostRecentFallbackTime = now() +exports.markCommitTimeOfFallback = function(): () + globalMostRecentFallbackTime = now() end -exports.markSkippedUpdateLanes = function(lane: Lane | Lanes) - ReactFiberWorkInProgress.markSkippedUpdateLanes(lane) +exports.markSkippedUpdateLanes = function(lane: Lane | Lanes): () + ReactFiberWorkInProgress.markSkippedUpdateLanes(lane) end -exports.renderDidSuspend = function() - if workInProgressRootExitStatus == RootExitStatus.Incomplete then - workInProgressRootExitStatus = RootExitStatus.Suspended - end +exports.renderDidSuspend = function(): () + if workInProgressRootExitStatus == RootExitStatus.Incomplete then + workInProgressRootExitStatus = RootExitStatus.Suspended + end end -exports.renderDidSuspendDelayIfPossible = function() - if - workInProgressRootExitStatus == RootExitStatus.Incomplete or - workInProgressRootExitStatus == RootExitStatus.Suspended - then - workInProgressRootExitStatus = RootExitStatus.SuspendedWithDelay - end - - -- Check if there are updates that we skipped tree that might have unblocked - -- this render. - if - workInProgressRoot ~= nil and - (includesNonIdleWork(workInProgressRootSkippedLanes()) or - includesNonIdleWork(workInProgressRootUpdatedLanes)) - then - -- Mark the current render as suspended so that we switch to working on - -- the updates that were skipped. Usually we only suspend at the end of - -- the render phase. - -- TODO: We should probably always mark the root as suspended immediately - -- (inside this function), since by suspending at the end of the render - -- phase introduces a potential mistake where we suspend lanes that were - -- pinged or updated while we were rendering. - mod.markRootSuspended(workInProgressRoot, workInProgressRootRenderLanes) - end +exports.renderDidSuspendDelayIfPossible = function(): () + if + workInProgressRootExitStatus == RootExitStatus.Incomplete + or workInProgressRootExitStatus == RootExitStatus.Suspended + then + workInProgressRootExitStatus = RootExitStatus.SuspendedWithDelay + end + + -- Check if there are updates that we skipped tree that might have unblocked + -- this render. + if + workInProgressRoot ~= nil + and ( + includesNonIdleWork(workInProgressRootSkippedLanes()) + or includesNonIdleWork(workInProgressRootUpdatedLanes) + ) + then + -- Mark the current render as suspended so that we switch to working on + -- the updates that were skipped. Usually we only suspend at the end of + -- the render phase. + -- TODO: We should probably always mark the root as suspended immediately + -- (inside this function), since by suspending at the end of the render + -- phase introduces a potential mistake where we suspend lanes that were + -- pinged or updated while we were rendering. + mod.markRootSuspended(workInProgressRoot, workInProgressRootRenderLanes) + end end exports.renderDidError = function() - if workInProgressRootExitStatus ~= RootExitStatus.Completed then - workInProgressRootExitStatus = RootExitStatus.Errored - end + if workInProgressRootExitStatus ~= RootExitStatus.Completed then + workInProgressRootExitStatus = RootExitStatus.Errored + end end -- Called during render to determine if anything has suspended. -- Returns false if we're not sure. exports.renderHasNotSuspendedYet = function(): boolean - -- If something errored or completed, we can't really be sure, - -- so those are false. - return workInProgressRootExitStatus == RootExitStatus.Incomplete + -- If something errored or completed, we can't really be sure, + -- so those are false. + return workInProgressRootExitStatus == RootExitStatus.Incomplete end -mod.renderRootSync = function(root: ReactInternalTypes.FiberRoot, lanes: Lanes) - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, RenderContext) - local prevDispatcher = mod.pushDispatcher() - - -- If the root or lanes have changed, throw out the existing stack - -- and prepare a fresh one. Otherwise we'll continue where we left off. - if workInProgressRoot ~= root or workInProgressRootRenderLanes ~= lanes then - mod.prepareFreshStack(root, lanes) - mod.startWorkOnPendingInteractions(root, lanes) - end - - local prevInteractions = mod.pushInteractions(root) - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logRenderStarted(lanes) - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markRenderStarted(lanes) - end - - while true do - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, thrownValue - if not _G.__YOLO__ then - ok, thrownValue = pcall(function() - mod.workLoopSync() - end) - else - ok = true - mod.workLoopSync() - end - - if not ok then - mod.handleError(root, thrownValue) - else - break - end - end - resetContextDependencies() - if ReactFeatureFlags.enableSchedulerTracing then - mod.popInteractions(prevInteractions) - end - - executionContext = prevExecutionContext - mod.popDispatcher(prevDispatcher) - - if workInProgress ~= nil then - -- This is a sync render, so we should have finished the whole tree. - invariant( - false, - "Cannot commit an incomplete root. This error is likely caused by a " .. - "bug in React. Please file an issue." - ) - end - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logRenderStopped() - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markRenderStopped() - end - - -- Set this to nil to indicate there's no in-progress render. - workInProgressRoot = nil - workInProgressRootRenderLanes = ReactFiberLane.NoLanes - - return workInProgressRootExitStatus +mod.renderRootSync = function(root: FiberRoot, lanes: Lanes) + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, RenderContext) + local prevDispatcher = mod.pushDispatcher() + + -- If the root or lanes have changed, throw out the existing stack + -- and prepare a fresh one. Otherwise we'll continue where we left off. + if workInProgressRoot ~= root or workInProgressRootRenderLanes ~= lanes then + mod.prepareFreshStack(root, lanes) + mod.startWorkOnPendingInteractions(root, lanes) + end + + local prevInteractions = mod.pushInteractions(root) + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logRenderStarted(lanes) + end + end + + if enableSchedulingProfiler then + SchedulingProfiler.markRenderStarted(lanes) + end + + while true do + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, thrownValue + if not __YOLO__ then + ok, thrownValue = xpcall(mod.workLoopSync, describeError) + else + ok = true + mod.workLoopSync() + end + + if not ok then + mod.handleError(root, thrownValue) + else + break + end + end + resetContextDependencies() + if ReactFeatureFlags.enableSchedulerTracing then + mod.popInteractions(prevInteractions) + end + + executionContext = prevExecutionContext + mod.popDispatcher(prevDispatcher) + + if workInProgress ~= nil then + -- This is a sync render, so we should have finished the whole tree. + invariant( + false, + "Cannot commit an incomplete root. This error is likely caused by a " + .. "bug in React. Please file an issue." + ) + end + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logRenderStopped() + end + end + + if enableSchedulingProfiler then + SchedulingProfiler.markRenderStopped() + end + + -- Set this to nil to indicate there's no in-progress render. + workInProgressRoot = nil + workInProgressRootRenderLanes = ReactFiberLane.NoLanes + + return workInProgressRootExitStatus end -- The work loop is an extremely hot path. Tell Closure not to inline it. --[[* @noinline ]] mod.workLoopSync = function() - -- Already timed out, so perform work without checking if we need to yield. - while workInProgress ~= nil do - mod.performUnitOfWork(workInProgress) - end + -- Already timed out, so perform work without checking if we need to yield. + while workInProgress ~= nil do + mod.performUnitOfWork(workInProgress) + end end -mod.renderRootConcurrent = function(root: ReactInternalTypes.FiberRoot, lanes: Lanes) - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, RenderContext) - local prevDispatcher = mod.pushDispatcher() - - -- If the root or lanes have changed, throw out the existing stack - -- and prepare a fresh one. Otherwise we'll continue where we left off. - if workInProgressRoot ~= root or workInProgressRootRenderLanes ~= lanes then - resetRenderTimer() - mod.prepareFreshStack(root, lanes) - mod.startWorkOnPendingInteractions(root, lanes) - end - - local prevInteractions = mod.pushInteractions(root) - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logRenderStarted(lanes) - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markRenderStarted(lanes) - end - - while true do - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, thrownValue - if not _G.__YOLO__ then - -- ROBLOX deviation: when converting `try` to `pcall`, we can't use break inside it - ok, thrownValue = pcall(function() - mod.workLoopConcurrent() - return "break" - end) - else - ok = true - thrownValue = "break" - mod.workLoopConcurrent() - end - - if thrownValue == "break" then - break - end - if not ok then - mod.handleError(root, thrownValue) - end - end - resetContextDependencies() - if ReactFeatureFlags.enableSchedulerTracing then - mod.popInteractions(prevInteractions) - end - - mod.popDispatcher(prevDispatcher) - executionContext = prevExecutionContext - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logRenderStopped() - end - end - - -- Check if the tree has completed. - if workInProgress ~= nil then - -- Still work remaining. - if enableSchedulingProfiler then - SchedulingProfiler.markRenderYielded() - end - return RootExitStatus.Incomplete - else - -- Completed the tree. - if enableSchedulingProfiler then - SchedulingProfiler.markRenderStopped() - end - - -- Set this to nil to indicate there's no in-progress render. - workInProgressRoot = nil - workInProgressRootRenderLanes = ReactFiberLane.NoLanes - - -- Return the final exit status. - return workInProgressRootExitStatus - end +mod.renderRootConcurrent = function(root: FiberRoot, lanes: Lanes) + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, RenderContext) + local prevDispatcher = mod.pushDispatcher() + + -- If the root or lanes have changed, throw out the existing stack + -- and prepare a fresh one. Otherwise we'll continue where we left off. + if workInProgressRoot ~= root or workInProgressRootRenderLanes ~= lanes then + resetRenderTimer() + mod.prepareFreshStack(root, lanes) + mod.startWorkOnPendingInteractions(root, lanes) + end + + local prevInteractions = mod.pushInteractions(root) + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logRenderStarted(lanes) + end + end + + if enableSchedulingProfiler then + SchedulingProfiler.markRenderStarted(lanes) + end + + while true do + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, thrownValue + if not __YOLO__ then + -- ROBLOX deviation: when converting `try` to `pcall`, we can't use break inside it + ok, thrownValue = xpcall(mod.workLoopConcurrent, describeError) + if ok then + thrownValue = "break" + end + else + ok = true + thrownValue = "break" + mod.workLoopConcurrent() + end + + if thrownValue == "break" then + break + end + if not ok then + mod.handleError(root, thrownValue) + end + end + resetContextDependencies() + if ReactFeatureFlags.enableSchedulerTracing then + mod.popInteractions(prevInteractions) + end + + mod.popDispatcher(prevDispatcher) + executionContext = prevExecutionContext + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logRenderStopped() + end + end + + -- Check if the tree has completed. + if workInProgress ~= nil then + -- Still work remaining. + if enableSchedulingProfiler then + SchedulingProfiler.markRenderYielded() + end + return RootExitStatus.Incomplete + else + -- Completed the tree. + if enableSchedulingProfiler then + SchedulingProfiler.markRenderStopped() + end + + -- Set this to nil to indicate there's no in-progress render. + workInProgressRoot = nil + workInProgressRootRenderLanes = ReactFiberLane.NoLanes + + -- Return the final exit status. + return workInProgressRootExitStatus + end end --[[* @noinline ]] mod.workLoopConcurrent = function() - -- Perform work until Scheduler asks us to yield - while workInProgress ~= nil and not shouldYield() do - mod.performUnitOfWork(workInProgress) - end + -- Perform work until Scheduler asks us to yield + while workInProgress ~= nil and not shouldYield() do + mod.performUnitOfWork(workInProgress) + end end mod.performUnitOfWork = function(unitOfWork: Fiber): () - -- The current, flushed, state of this fiber is the alternate. Ideally - -- nothing should rely on this, but relying on it here means that we don't - -- need an additional field on the work in progress. - local current = unitOfWork.alternate - setCurrentDebugFiberInDEV(unitOfWork) - - local next_ - if ReactFeatureFlags.enableProfilerTimer and bit32.band(unitOfWork.mode, ReactTypeOfMode.ProfileMode) ~= ReactTypeOfMode.NoMode then - ReactProfilerTimer.startProfilerTimer(unitOfWork) - next_ = mod.beginWork(current, unitOfWork, exports.subtreeRenderLanes) - ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true) - else - next_ = mod.beginWork(current, unitOfWork, exports.subtreeRenderLanes) - end - - resetCurrentDebugFiberInDEV() - unitOfWork.memoizedProps = unitOfWork.pendingProps - if next_ == nil then - -- If this doesn't spawn new work, complete the current work. - mod.completeUnitOfWork(unitOfWork) - else - workInProgress = next_ - end - - ReactCurrentOwner.current = nil + -- ROBLOX deviation: RobloxReactProfiling + local profileRunning = RobloxReactProfiling.profileUnitOfWorkBefore(unitOfWork) + + -- The current, flushed, state of this fiber is the alternate. Ideally + -- nothing should rely on this, but relying on it here means that we don't + -- need an additional field on the work in progress. + local current = unitOfWork.alternate + setCurrentDebugFiberInDEV(unitOfWork) + + local next_ + if + ReactFeatureFlags.enableProfilerTimer + and bit32.band(unitOfWork.mode, ReactTypeOfMode.ProfileMode) + ~= ReactTypeOfMode.NoMode + then + ReactProfilerTimer.startProfilerTimer(unitOfWork) + next_ = mod.beginWork(current, unitOfWork, exports.subtreeRenderLanes) + ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true) + else + next_ = mod.beginWork(current, unitOfWork, exports.subtreeRenderLanes) + end + + resetCurrentDebugFiberInDEV() + unitOfWork.memoizedProps = unitOfWork.pendingProps + if next_ == nil then + -- If this doesn't spawn new work, complete the current work. + mod.completeUnitOfWork(unitOfWork) + else + workInProgress = next_ + end + + ReactCurrentOwner.current = nil + + -- ROBLOX deviation: RobloxReactProfiling + RobloxReactProfiling.profileUnitOfWorkAfter(profileRunning) end mod.completeUnitOfWork = function(unitOfWork: Fiber) - -- Attempt to complete the current unit of work, then move to the next - -- sibling. If there are no more siblings, return to the parent fiber. - local completedWork = unitOfWork - repeat - -- The current, flushed, state of this fiber is the alternate. Ideally - -- nothing should rely on this, but relying on it here means that we don't - -- need an additional field on the work in progress. - local current = completedWork.alternate - local returnFiber = completedWork.return_ - - -- Check if the work completed or if something threw. - if bit32.band(completedWork.flags, ReactFiberFlags.Incomplete) == ReactFiberFlags.NoFlags then - setCurrentDebugFiberInDEV(completedWork) - local next_ - if - not ReactFeatureFlags.enableProfilerTimer or - bit32.band(completedWork.mode, ReactTypeOfMode.ProfileMode) == ReactTypeOfMode.NoMode - then - next_ = completeWork(current, completedWork, exports.subtreeRenderLanes) - else - ReactProfilerTimer.startProfilerTimer(completedWork) - next_ = completeWork(current, completedWork, exports.subtreeRenderLanes) - -- Update render duration assuming we didn't error. - ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta(completedWork, false) - end - resetCurrentDebugFiberInDEV() - - if next_ ~= nil then - -- Completing this fiber spawned new work. Work on that next. - workInProgress = next_ - return - end - else - -- This fiber did not complete because something threw. Pop values off - -- the stack without entering the complete phase. If this is a boundary, - -- capture values if possible. - local next_ = unwindWork(completedWork, exports.subtreeRenderLanes) - - -- Because this fiber did not complete, don't reset its expiration time. - - if next_ ~= nil then - -- If completing this work spawned new work, do that next. We'll come - -- back here again. - -- Since we're restarting, remove anything that is not a host effect - -- from the effect tag. - next_.flags = bit32.band(next_.flags, ReactFiberFlags.HostEffectMask) - workInProgress = next_ - return - end - - if - ReactFeatureFlags.enableProfilerTimer and - bit32.band(completedWork.mode, ReactTypeOfMode.ProfileMode) ~= ReactTypeOfMode.NoMode - then - -- Record the render duration for the fiber that errored. - ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta(completedWork, false) - - -- Include the time spent working on failed children before continuing. - local actualDuration = completedWork.actualDuration - local child = completedWork.child - while child ~= nil do - actualDuration += child.actualDuration - child = child.sibling - end - completedWork.actualDuration = actualDuration - end - - if returnFiber ~= nil then - -- Mark the parent fiber as incomplete - returnFiber.flags = bit32.bor(returnFiber.flags, ReactFiberFlags.Incomplete) - returnFiber.subtreeFlags = ReactFiberFlags.NoFlags - returnFiber.deletions = nil - end - end - - local siblingFiber = completedWork.sibling - if siblingFiber ~= nil then - -- If there is more work to do in this returnFiber, do that next. - workInProgress = siblingFiber - return - end - -- Otherwise, return to the parent - completedWork = returnFiber - -- Update the next thing we're working on in case something throws. - workInProgress = completedWork - until completedWork == nil - - -- We've reached the root. - if workInProgressRootExitStatus == RootExitStatus.Incomplete then - workInProgressRootExitStatus = RootExitStatus.Completed - end + -- Attempt to complete the current unit of work, then move to the next + -- sibling. If there are no more siblings, return to the parent fiber. + local completedWork = unitOfWork + repeat + -- The current, flushed, state of this fiber is the alternate. Ideally + -- nothing should rely on this, but relying on it here means that we don't + -- need an additional field on the work in progress. + local current = completedWork.alternate + local returnFiber = completedWork.return_ + + -- Check if the work completed or if something threw. + if + bit32.band(completedWork.flags, ReactFiberFlags.Incomplete) + == ReactFiberFlags.NoFlags + then + setCurrentDebugFiberInDEV(completedWork) + local next_ + if + not ReactFeatureFlags.enableProfilerTimer + or bit32.band(completedWork.mode, ReactTypeOfMode.ProfileMode) + == ReactTypeOfMode.NoMode + then + next_ = completeWork(current, completedWork, exports.subtreeRenderLanes) + else + ReactProfilerTimer.startProfilerTimer(completedWork) + next_ = completeWork(current, completedWork, exports.subtreeRenderLanes) + -- Update render duration assuming we didn't error. + ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta( + completedWork, + false + ) + end + resetCurrentDebugFiberInDEV() + + if next_ ~= nil then + -- Completing this fiber spawned new work. Work on that next. + workInProgress = next_ + return + end + else + -- This fiber did not complete because something threw. Pop values off + -- the stack without entering the complete phase. If this is a boundary, + -- capture values if possible. + local next_ = unwindWork(completedWork, exports.subtreeRenderLanes) + + -- Because this fiber did not complete, don't reset its expiration time. + + if next_ ~= nil then + -- If completing this work spawned new work, do that next. We'll come + -- back here again. + -- Since we're restarting, remove anything that is not a host effect + -- from the effect tag. + next_.flags = bit32.band(next_.flags, ReactFiberFlags.HostEffectMask) + workInProgress = next_ + return + end + + if + ReactFeatureFlags.enableProfilerTimer + and bit32.band(completedWork.mode, ReactTypeOfMode.ProfileMode) + ~= ReactTypeOfMode.NoMode + then + -- Record the render duration for the fiber that errored. + ReactProfilerTimer.stopProfilerTimerIfRunningAndRecordDelta( + completedWork, + false + ) + + -- Include the time spent working on failed children before continuing. + -- ROBLOX TODO: actualDuration is nil-able and only populated with enableProfilerTimer. contribute default value upstream. + local actualDuration = completedWork.actualDuration or 0 + local child = completedWork.child + while child ~= nil do + actualDuration += child.actualDuration or 0 + child = child.sibling + end + completedWork.actualDuration = actualDuration + end + + if returnFiber ~= nil then + -- Mark the parent fiber as incomplete + returnFiber.flags = + bit32.bor(returnFiber.flags, ReactFiberFlags.Incomplete) + returnFiber.subtreeFlags = ReactFiberFlags.NoFlags + returnFiber.deletions = nil + end + end + + local siblingFiber = completedWork.sibling + if siblingFiber ~= nil then + -- If there is more work to do in this returnFiber, do that next. + workInProgress = siblingFiber + return + end + -- Otherwise, return to the parent + -- ROBLOX Luau FIXME: Luau doesn't understand that completedWork is only nil-able at this point in the control flow + completedWork = returnFiber :: any + -- Update the next thing we're working on in case something throws. + workInProgress = completedWork + until completedWork == nil + + -- We've reached the root. + if workInProgressRootExitStatus == RootExitStatus.Incomplete then + workInProgressRootExitStatus = RootExitStatus.Completed + end end mod.commitRoot = function(root) - local renderPriorityLevel = getCurrentPriorityLevel() - runWithPriority( - ImmediateSchedulerPriority, - function() - return mod.commitRootImpl(root, renderPriorityLevel) - end - ) - return nil + local renderPriorityLevel = getCurrentPriorityLevel() + runWithPriority(ImmediateSchedulerPriority, function() + -- ROBLOX deviation: RobloxReactProfiling + RobloxReactProfiling.profileCommitBefore() + local ret = mod.commitRootImpl(root, renderPriorityLevel) + RobloxReactProfiling.profileCommitAfter() + return ret + end) + return nil end -mod.commitRootImpl = function(root, renderPriorityLevel) - repeat - -- `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which - -- means `flushPassiveEffects` will sometimes result in additional - -- passive effects. So we need to keep flushing in a loop until there are - -- no more pending effects. - -- TODO: Might be better if `flushPassiveEffects` did not automatically - -- flush synchronous work at the end, to avoid factoring hazards like this. - exports.flushPassiveEffects() - until rootWithPendingPassiveEffects == nil - flushRenderPhaseStrictModeWarningsInDEV() - - invariant( - bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, - 'Should not already be working.' - ) - - local finishedWork = root.finishedWork - local lanes = root.finishedLanes - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logCommitStarted(lanes) - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markCommitStarted(lanes) - end - - if finishedWork == nil then - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logCommitStopped() - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markCommitStopped() - end - - return nil - end - root.finishedWork = nil - root.finishedLanes = ReactFiberLane.NoLanes - - invariant( - finishedWork ~= root.current, - "Cannot commit the same tree as before. This error is likely caused by " .. - "a bug in React. Please file an issue." - ) - - -- commitRoot never returns a continuation; it always finishes synchronously. - -- So we can clear these now to allow a new callback to be scheduled. - root.callbackNode = nil - - -- Update the first and last pending times on this root. The new first - -- pending time is whatever is left on the root fiber. - local remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes) - markRootFinished(root, remainingLanes) - - -- Clear already finished discrete updates in case that a later call of - -- `flushDiscreteUpdates` starts a useless render pass which may cancels - -- a scheduled timeout. - if rootsWithPendingDiscreteUpdates ~= nil then - if - not hasDiscreteLanes(remainingLanes) and - rootsWithPendingDiscreteUpdates[root] ~= nil - then - rootsWithPendingDiscreteUpdates[root] = nil - end - end - - if root == workInProgressRoot then - -- We can reset these now that they are finished. - workInProgressRoot = nil - workInProgress = nil - workInProgressRootRenderLanes = ReactFiberLane.NoLanes - else - -- This indicates that the last root we worked on is not the same one that - -- we're committing now. This most commonly happens when a suspended root - -- times out. - end - - -- Check if there are any effects in the whole tree. - -- TODO: This is left over from the effect list implementation, where we had - -- to check for the existence of `firstEffect` to satsify Flow. I think the - -- only other reason this optimization exists is because it affects profiling. - -- Reconsider whether this is necessary. - local subtreeHasEffects = - bit32.band(finishedWork.subtreeFlags, - bit32.bor( - ReactFiberFlags.BeforeMutationMask, - ReactFiberFlags.MutationMask, - ReactFiberFlags.LayoutMask, - ReactFiberFlags.PassiveMask - )) ~= ReactFiberFlags.NoFlags - local rootHasEffect = - bit32.band(finishedWork.flags, - bit32.bor( - ReactFiberFlags.BeforeMutationMask, - ReactFiberFlags.MutationMask, - ReactFiberFlags.LayoutMask, - ReactFiberFlags.PassiveMask - )) ~= ReactFiberFlags.NoFlags - - if subtreeHasEffects or rootHasEffect then - local previousLanePriority - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - previousLanePriority = getCurrentUpdateLanePriority() - setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) - end - - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, CommitContext) - local prevInteractions = mod.pushInteractions(root) - - -- Reset this to nil before calling lifecycles - ReactCurrentOwner.current = nil - - -- The commit phase is broken into several sub-phases. We do a separate pass - -- of the effect list for each phase: all mutation effects come before all - -- layout effects, and so on. - - -- The first phase a "before mutation" phase. We use this phase to read the - -- state of the host tree right before we mutate it. This is where - -- getSnapshotBeforeUpdate is called. - focusedInstanceHandle = ReactFiberHostConfig.prepareForCommit(root.containerInfo) - shouldFireAfterActiveInstanceBlur = false - - mod.commitBeforeMutationEffects(finishedWork) - - -- We no longer need to track the active instance fiber - focusedInstanceHandle = nil - - if ReactFeatureFlags.enableProfilerTimer then - -- Mark the current commit time to be shared by all Profilers in this - -- batch. This enables them to be grouped later. - ReactProfilerTimer.recordCommitTime() - end - - -- The next phase is the mutation phase, where we mutate the host tree. - mod.commitMutationEffects(finishedWork, root, renderPriorityLevel) - - if shouldFireAfterActiveInstanceBlur then - ReactFiberHostConfig.afterActiveInstanceBlur() - end - ReactFiberHostConfig.resetAfterCommit(root.containerInfo) - - -- The work-in-progress tree is now the current tree. This must come after - -- the mutation phase, so that the previous tree is still current during - -- componentWillUnmount, but before the layout phase, so that the finished - -- work is current during componentDidMount/Update. - root.current = finishedWork - - -- The next phase is the layout phase, where we call effects that read - -- the host tree after it's been mutated. The idiomatic use case for this is - -- layout, but class component lifecycles also fire here for legacy reasons. - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logLayoutEffectsStarted(lanes) - end - end - if enableSchedulingProfiler then - SchedulingProfiler.markLayoutEffectsStarted(lanes) - end - - if _G.__DEV__ then - setCurrentDebugFiberInDEV(finishedWork) - invokeGuardedCallback( - nil, - recursivelyCommitLayoutEffects, - nil, - finishedWork, - root, - -- ROBLOX deviation: pass in this function to avoid dependency cycle - exports.captureCommitPhaseError, - exports.schedulePassiveEffectCallback - ) - if hasCaughtError() then - local err = clearCaughtError() - captureCommitPhaseErrorOnRoot(finishedWork, finishedWork, err) - end - resetCurrentDebugFiberInDEV() - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - -- ROBLOX deviation: pass in this function to avoid dependency cycle - recursivelyCommitLayoutEffects(finishedWork, root, exports.captureCommitPhaseError, exports.schedulePassiveEffectCallback) - end) - else - ok = true - recursivelyCommitLayoutEffects(finishedWork, root, exports.captureCommitPhaseError, exports.schedulePassiveEffectCallback) - end - - if not ok then - captureCommitPhaseErrorOnRoot(finishedWork, finishedWork, result) - end - end - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logLayoutEffectsStopped() - end - end - if enableSchedulingProfiler then - SchedulingProfiler.markLayoutEffectsStopped() - end - - -- If there are pending passive effects, schedule a callback to process them. - if - bit32.band(finishedWork.subtreeFlags, ReactFiberFlags.PassiveMask) ~= ReactFiberFlags.NoFlags or - bit32.band(finishedWork.flags, ReactFiberFlags.PassiveMask) ~= ReactFiberFlags.NoFlags - then - if not rootDoesHavePassiveEffects then - rootDoesHavePassiveEffects = true - scheduleCallback(NormalSchedulerPriority, function() - exports.flushPassiveEffects() - return nil - end) - end - end - - -- Tell Scheduler to yield at the end of the frame, so the browser has an - -- opportunity to paint. - requestPaint() - - if ReactFeatureFlags.enableSchedulerTracing then - mod.popInteractions(prevInteractions) - end - executionContext = prevExecutionContext - - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler and previousLanePriority ~= nil then - -- Reset the priority to the previous non-sync value. - setCurrentUpdateLanePriority(previousLanePriority) - end - else - -- No effects. - root.current = finishedWork - -- Measure these anyway so the flamegraph explicitly shows that there were - -- no effects. - -- TODO: Maybe there's a better way to report this. - if ReactFeatureFlags.enableProfilerTimer then - ReactProfilerTimer.recordCommitTime() - end - end - - local rootDidHavePassiveEffects = rootDoesHavePassiveEffects - - if rootDoesHavePassiveEffects then - -- This commit has passive effects. Stash a reference to them. But don't - -- schedule a callback until after flushing layout work. - rootDoesHavePassiveEffects = false - rootWithPendingPassiveEffects = root - pendingPassiveEffectsLanes = lanes - pendingPassiveEffectsRenderPriority = renderPriorityLevel - end - - -- Read this again, since an effect might have updated it - remainingLanes = root.pendingLanes - - -- Check if there's remaining work on this root - if remainingLanes ~= ReactFiberLane.NoLanes then - if ReactFeatureFlags.enableSchedulerTracing then - if spawnedWorkDuringRender ~= nil then - local expirationTimes = spawnedWorkDuringRender - spawnedWorkDuringRender = nil - for i = 1, #expirationTimes do - scheduleInteractions( - root, - expirationTimes[i], - root.memoizedInteractions - ) - end - end - mod.schedulePendingInteractions(root, remainingLanes) - end - else - -- If there's no remaining work, we can clear the set of already failed - -- error boundaries. - legacyErrorBoundariesThatAlreadyFailed = nil - end - - if _G.__DEV__ and enableDoubleInvokingEffects then - if not rootDidHavePassiveEffects then - commitDoubleInvokeEffectsInDEV(root.current, false) - end - end - - if ReactFeatureFlags.enableSchedulerTracing then - if not rootDidHavePassiveEffects then - -- If there are no passive effects, then we can complete the pending interactions. - -- Otherwise, we'll wait until after the passive effects are flushed. - -- Wait to do this until after remaining work has been scheduled, - -- so that we don't prematurely signal complete for interactions when there's e.g. hidden work. - mod.finishPendingInteractions(root, lanes) - end - end - - if remainingLanes == SyncLane then - -- Count the number of times the root synchronously re-renders without - -- finishing. If there are too many, it indicates an infinite update loop. - if root == rootWithNestedUpdates then - nestedUpdateCount += 1 - else - nestedUpdateCount = 0 - rootWithNestedUpdates = root - end - else - nestedUpdateCount = 0 - end - - onCommitRootDevTools(finishedWork.stateNode, renderPriorityLevel) - - if _G.__DEV__ then - onCommitRootTestSelector() - end - - -- Always call this before exiting `commitRoot`, to ensure that any - -- additional work on this root is scheduled. - ensureRootIsScheduled(root, now()) - - if hasUncaughtError then - hasUncaughtError = false - local error_ = firstUncaughtError - firstUncaughtError = nil - error(error_) - end - - if bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext then - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logCommitStopped() - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markCommitStopped() - end - - -- This is a legacy edge case. We just committed the initial mount of - -- a ReactDOM.render-ed root inside of batchedUpdates. The commit fired - -- synchronously, but layout updates should be deferred until the end - -- of the batch. - return nil - end - - -- If layout work was scheduled, flush it now. - flushSyncCallbackQueue() - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logCommitStopped() - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markCommitStopped() - end - - return nil +-- ROBLOX Luau FIXME: Luau doesn't infer root as FiberRoot via the callgraph from ensureRootIsScheduled(root: FiberRoot) +mod.commitRootImpl = function(root: FiberRoot, renderPriorityLevel) + repeat + -- `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which + -- means `flushPassiveEffects` will sometimes result in additional + -- passive effects. So we need to keep flushing in a loop until there are + -- no more pending effects. + -- TODO: Might be better if `flushPassiveEffects` did not automatically + -- flush synchronous work at the end, to avoid factoring hazards like this. + exports.flushPassiveEffects() + until rootWithPendingPassiveEffects == nil + flushRenderPhaseStrictModeWarningsInDEV() + + invariant( + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, + "Should not already be working." + ) + + -- ROBLOX Luau FIXME: Luau doesn't narrow finishedWork based on the nil guard below + local finishedWork = root.finishedWork :: Fiber + local lanes = root.finishedLanes + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logCommitStarted(lanes) + end + end + + if enableSchedulingProfiler then + SchedulingProfiler.markCommitStarted(lanes) + end + + if finishedWork == nil then + if __DEV__ then + if enableDebugTracing then + DebugTracing.logCommitStopped() + end + end + + if enableSchedulingProfiler then + -- ROBLOX deviation: pass root for attribution + SchedulingProfiler.markCommitStopped(root) + end + + return nil + end + root.finishedWork = nil + root.finishedLanes = ReactFiberLane.NoLanes + + invariant( + finishedWork ~= root.current, + "Cannot commit the same tree as before. This error is likely caused by " + .. "a bug in React. Please file an issue." + ) + + -- commitRoot never returns a continuation; it always finishes synchronously. + -- So we can clear these now to allow a new callback to be scheduled. + root.callbackNode = nil + + -- Update the first and last pending times on this root. The new first + -- pending time is whatever is left on the root fiber. + local remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes) + markRootFinished(root, remainingLanes) + + -- Clear already finished discrete updates in case that a later call of + -- `flushDiscreteUpdates` starts a useless render pass which may cancels + -- a scheduled timeout. + if rootsWithPendingDiscreteUpdates ~= nil then + if + not hasDiscreteLanes(remainingLanes) + and rootsWithPendingDiscreteUpdates:has(root) + then + rootsWithPendingDiscreteUpdates:delete(root) + end + end + + if root == workInProgressRoot then + -- We can reset these now that they are finished. + workInProgressRoot = nil + workInProgress = nil + workInProgressRootRenderLanes = ReactFiberLane.NoLanes + else + -- This indicates that the last root we worked on is not the same one that + -- we're committing now. This most commonly happens when a suspended root + -- times out. + end + + -- Check if there are any effects in the whole tree. + -- TODO: This is left over from the effect list implementation, where we had + -- to check for the existence of `firstEffect` to satsify Flow. I think the + -- only other reason this optimization exists is because it affects profiling. + -- Reconsider whether this is necessary. + local subtreeHasEffects = bit32.band( + finishedWork.subtreeFlags, + bit32.bor( + ReactFiberFlags.BeforeMutationMask, + ReactFiberFlags.MutationMask, + ReactFiberFlags.LayoutMask, + ReactFiberFlags.PassiveMask + ) + ) ~= ReactFiberFlags.NoFlags + local rootHasEffect = bit32.band( + finishedWork.flags, + bit32.bor( + ReactFiberFlags.BeforeMutationMask, + ReactFiberFlags.MutationMask, + ReactFiberFlags.LayoutMask, + ReactFiberFlags.PassiveMask + ) + ) ~= ReactFiberFlags.NoFlags + + if subtreeHasEffects or rootHasEffect then + local previousLanePriority + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + previousLanePriority = getCurrentUpdateLanePriority() + setCurrentUpdateLanePriority(ReactFiberLane.SyncLanePriority) + end + + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, CommitContext) + local prevInteractions = mod.pushInteractions(root) + + -- Reset this to nil before calling lifecycles + ReactCurrentOwner.current = nil + + -- The commit phase is broken into several sub-phases. We do a separate pass + -- of the effect list for each phase: all mutation effects come before all + -- layout effects, and so on. + + -- The first phase a "before mutation" phase. We use this phase to read the + -- state of the host tree right before we mutate it. This is where + -- getSnapshotBeforeUpdate is called. + focusedInstanceHandle = ReactFiberHostConfig.prepareForCommit(root.containerInfo) + shouldFireAfterActiveInstanceBlur = false + + mod.commitBeforeMutationEffects(finishedWork) + + -- We no longer need to track the active instance fiber + focusedInstanceHandle = nil + + if ReactFeatureFlags.enableProfilerTimer then + -- Mark the current commit time to be shared by all Profilers in this + -- batch. This enables them to be grouped later. + ReactProfilerTimer.recordCommitTime() + end + + -- The next phase is the mutation phase, where we mutate the host tree. + mod.commitMutationEffects(finishedWork, root, renderPriorityLevel) + + if shouldFireAfterActiveInstanceBlur then + ReactFiberHostConfig.afterActiveInstanceBlur() + end + ReactFiberHostConfig.resetAfterCommit(root.containerInfo) + + -- The work-in-progress tree is now the current tree. This must come after + -- the mutation phase, so that the previous tree is still current during + -- componentWillUnmount, but before the layout phase, so that the finished + -- work is current during componentDidMount/Update. + root.current = finishedWork + + -- The next phase is the layout phase, where we call effects that read + -- the host tree after it's been mutated. The idiomatic use case for this is + -- layout, but class component lifecycles also fire here for legacy reasons. + if __DEV__ then + if enableDebugTracing then + DebugTracing.logLayoutEffectsStarted(lanes) + end + end + if enableSchedulingProfiler then + SchedulingProfiler.markLayoutEffectsStarted(lanes) + end + + if __DEV__ then + setCurrentDebugFiberInDEV(finishedWork) + invokeGuardedCallback( + nil, + recursivelyCommitLayoutEffects, + nil, + finishedWork, + root, + -- ROBLOX deviation: pass in this function to avoid dependency cycle + exports.captureCommitPhaseError, + exports.schedulePassiveEffectCallback + ) + if hasCaughtError() then + local err = clearCaughtError() + captureCommitPhaseErrorOnRoot(finishedWork, finishedWork, err) + end + resetCurrentDebugFiberInDEV() + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + -- ROBLOX deviation: pass in captureCommitPhaseError and schedulePassiveEffectCallback to avoid dependency cycle + ok, result = xpcall( + recursivelyCommitLayoutEffects, + describeError, + finishedWork, + root, + exports.captureCommitPhaseError, + exports.schedulePassiveEffectCallback + ) + else + ok = true + recursivelyCommitLayoutEffects( + finishedWork, + root, + exports.captureCommitPhaseError, + exports.schedulePassiveEffectCallback + ) + end + + if not ok then + captureCommitPhaseErrorOnRoot(finishedWork, finishedWork, result) + end + end + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logLayoutEffectsStopped() + end + end + if enableSchedulingProfiler then + SchedulingProfiler.markLayoutEffectsStopped() + end + + -- If there are pending passive effects, schedule a callback to process them. + if + bit32.band(finishedWork.subtreeFlags, ReactFiberFlags.PassiveMask) + ~= ReactFiberFlags.NoFlags + or bit32.band(finishedWork.flags, ReactFiberFlags.PassiveMask) + ~= ReactFiberFlags.NoFlags + then + if not rootDoesHavePassiveEffects then + rootDoesHavePassiveEffects = true + scheduleCallback(NormalSchedulerPriority, function() + exports.flushPassiveEffects() + return nil + end) + end + end + + -- Tell Scheduler to yield at the end of the frame, so the browser has an + -- opportunity to paint. + requestPaint() + + if ReactFeatureFlags.enableSchedulerTracing then + mod.popInteractions(prevInteractions) + end + executionContext = prevExecutionContext + + if + ReactFeatureFlags.decoupleUpdatePriorityFromScheduler + and previousLanePriority ~= nil + then + -- Reset the priority to the previous non-sync value. + setCurrentUpdateLanePriority(previousLanePriority) + end + else + -- No effects. + root.current = finishedWork + -- Measure these anyway so the flamegraph explicitly shows that there were + -- no effects. + -- TODO: Maybe there's a better way to report this. + if ReactFeatureFlags.enableProfilerTimer then + ReactProfilerTimer.recordCommitTime() + end + end + + local rootDidHavePassiveEffects = rootDoesHavePassiveEffects + + if rootDoesHavePassiveEffects then + -- This commit has passive effects. Stash a reference to them. But don't + -- schedule a callback until after flushing layout work. + rootDoesHavePassiveEffects = false + rootWithPendingPassiveEffects = root + pendingPassiveEffectsLanes = lanes + pendingPassiveEffectsRenderPriority = renderPriorityLevel + end + + -- Read this again, since an effect might have updated it + remainingLanes = root.pendingLanes + + -- Check if there's remaining work on this root + if remainingLanes ~= ReactFiberLane.NoLanes then + if ReactFeatureFlags.enableSchedulerTracing then + if spawnedWorkDuringRender ~= nil then + local expirationTimes = spawnedWorkDuringRender + spawnedWorkDuringRender = nil + for i = 1, #expirationTimes do + scheduleInteractions( + root, + expirationTimes[i], + root.memoizedInteractions + ) + end + end + mod.schedulePendingInteractions(root, remainingLanes) + end + else + -- If there's no remaining work, we can clear the set of already failed + -- error boundaries. + legacyErrorBoundariesThatAlreadyFailed = nil + end + + if __DEV__ and enableDoubleInvokingEffects then + if not rootDidHavePassiveEffects then + commitDoubleInvokeEffectsInDEV(root.current, false) + end + end + + if ReactFeatureFlags.enableSchedulerTracing then + if not rootDidHavePassiveEffects then + -- If there are no passive effects, then we can complete the pending interactions. + -- Otherwise, we'll wait until after the passive effects are flushed. + -- Wait to do this until after remaining work has been scheduled, + -- so that we don't prematurely signal complete for interactions when there's e.g. hidden work. + mod.finishPendingInteractions(root, lanes) + end + end + + if remainingLanes == SyncLane then + -- Count the number of times the root synchronously re-renders without + -- finishing. If there are too many, it indicates an infinite update loop. + if root == rootWithNestedUpdates then + nestedUpdateCount += 1 + else + nestedUpdateCount = 0 + rootWithNestedUpdates = root + end + else + nestedUpdateCount = 0 + end + + onCommitRootDevTools(finishedWork.stateNode, renderPriorityLevel) + + if __DEV__ then + onCommitRootTestSelector() + end + + -- Always call this before exiting `commitRoot`, to ensure that any + -- additional work on this root is scheduled. + ensureRootIsScheduled(root, now()) + + if hasUncaughtError then + hasUncaughtError = false + local error_ = firstUncaughtError + firstUncaughtError = nil + -- ROBLOX FIXME: we lose the original stack trace when we re-throw this way + error(error_) + end + + if bit32.band(executionContext, LegacyUnbatchedContext) ~= NoContext then + if __DEV__ then + if enableDebugTracing then + DebugTracing.logCommitStopped() + end + end + + if enableSchedulingProfiler then + -- ROBLOX deviation: pass root for attribution + SchedulingProfiler.markCommitStopped(root) + end + + -- This is a legacy edge case. We just committed the initial mount of + -- a ReactDOM.render-ed root inside of batchedUpdates. The commit fired + -- synchronously, but layout updates should be deferred until the end + -- of the batch. + return nil + end + + -- If layout work was scheduled, flush it now. + flushSyncCallbackQueue() + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logCommitStopped() + end + end + + if enableSchedulingProfiler then + -- ROBLOX deviation: pass root for attribution + SchedulingProfiler.markCommitStopped(root) + end + + return nil end mod.commitBeforeMutationEffects = function(firstChild: Fiber) - local fiber = firstChild - while fiber ~= nil do - if fiber.deletions ~= nil then - mod.commitBeforeMutationEffectsDeletions(fiber.deletions) - end - - if fiber.child ~= nil then - local primarySubtreeFlags = bit32.band(fiber.subtreeFlags, ReactFiberFlags.BeforeMutationMask) - if primarySubtreeFlags ~= ReactFiberFlags.NoFlags then - mod.commitBeforeMutationEffects(fiber.child) - end - end - - if _G.__DEV__ then - setCurrentDebugFiberInDEV(fiber) - invokeGuardedCallback(nil, mod.commitBeforeMutationEffectsImpl, nil, fiber) - if hasCaughtError() then - local error_ = clearCaughtError() - exports.captureCommitPhaseError(fiber, fiber.return_, error_) - end - resetCurrentDebugFiberInDEV() - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, error_ - if not _G.__YOLO__ then - ok, error_ = pcall(function() - mod.commitBeforeMutationEffectsImpl(fiber) - end) - else - ok = true - mod.commitBeforeMutationEffectsImpl(fiber) - end - - if not ok then - exports.captureCommitPhaseError(fiber, fiber.return_, error_) - end - end - fiber = fiber.sibling - end + local fiber = firstChild + while fiber ~= nil do + if fiber.deletions ~= nil then + mod.commitBeforeMutationEffectsDeletions(fiber.deletions) + end + + if fiber.child ~= nil then + local primarySubtreeFlags = + bit32.band(fiber.subtreeFlags, ReactFiberFlags.BeforeMutationMask) + if primarySubtreeFlags ~= ReactFiberFlags.NoFlags then + mod.commitBeforeMutationEffects(fiber.child) + end + end + + if __DEV__ then + setCurrentDebugFiberInDEV(fiber) + invokeGuardedCallback(nil, mod.commitBeforeMutationEffectsImpl, nil, fiber) + if hasCaughtError() then + local error_ = clearCaughtError() + exports.captureCommitPhaseError(fiber, fiber.return_, error_) + end + resetCurrentDebugFiberInDEV() + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, error_ + if not __YOLO__ then + ok, error_ = + xpcall(mod.commitBeforeMutationEffectsImpl, describeError, fiber) + else + ok = true + mod.commitBeforeMutationEffectsImpl(fiber) + end + + if not ok then + exports.captureCommitPhaseError(fiber, fiber.return_, error_) + end + end + -- ROBLOX Luau FIXME: Luau doesn't narrow correctly for the while ~= nil pattern + fiber = fiber.sibling :: Fiber + end end mod.commitBeforeMutationEffectsImpl = function(fiber: Fiber) - local current = fiber.alternate - local flags = fiber.flags - - if not shouldFireAfterActiveInstanceBlur and focusedInstanceHandle ~= nil then - -- Check to see if the focused element was inside of a hidden (Suspense) subtree. - -- TODO: Move this out of the hot path using a dedicated effect tag. - if - fiber.tag == ReactWorkTags.SuspenseComponent and - ReactFiberCommitWork.isSuspenseBoundaryBeingHidden(current, fiber) and - doesFiberContain(fiber, focusedInstanceHandle) - then - shouldFireAfterActiveInstanceBlur = true - ReactFiberHostConfig.beforeActiveInstanceBlur() - end - end - - if bit32.band(flags, ReactFiberFlags.Snapshot) ~= ReactFiberFlags.NoFlags then - setCurrentDebugFiberInDEV(fiber) - commitBeforeMutationEffectOnFiber(current, fiber) - resetCurrentDebugFiberInDEV() - end - - if bit32.band(flags, ReactFiberFlags.Passive) ~= ReactFiberFlags.NoFlags then - -- If there are passive effects, schedule a callback to flush at - -- the earliest opportunity. - if not rootDoesHavePassiveEffects then - rootDoesHavePassiveEffects = true - scheduleCallback(NormalSchedulerPriority, function() - exports.flushPassiveEffects() - return nil - end) - end - end + local current = fiber.alternate + local flags = fiber.flags + + if not shouldFireAfterActiveInstanceBlur and focusedInstanceHandle ~= nil then + -- Check to see if the focused element was inside of a hidden (Suspense) subtree. + -- TODO: Move this out of the hot path using a dedicated effect tag. + if + fiber.tag == ReactWorkTags.SuspenseComponent + and ReactFiberCommitWork.isSuspenseBoundaryBeingHidden(current, fiber) + and doesFiberContain(fiber, focusedInstanceHandle) + then + shouldFireAfterActiveInstanceBlur = true + ReactFiberHostConfig.beforeActiveInstanceBlur() + end + end + + if bit32.band(flags, ReactFiberFlags.Snapshot) ~= ReactFiberFlags.NoFlags then + setCurrentDebugFiberInDEV(fiber) + commitBeforeMutationEffectOnFiber(current, fiber) + resetCurrentDebugFiberInDEV() + end + + if bit32.band(flags, ReactFiberFlags.Passive) ~= ReactFiberFlags.NoFlags then + -- If there are passive effects, schedule a callback to flush at + -- the earliest opportunity. + if not rootDoesHavePassiveEffects then + rootDoesHavePassiveEffects = true + scheduleCallback(NormalSchedulerPriority, function() + exports.flushPassiveEffects() + return nil + end) + end + end end mod.commitBeforeMutationEffectsDeletions = function(deletions: Array) - for i = 1, #deletions do - local fiber = deletions[i] - - -- TODO (effects) It would be nice to avoid calling doesFiberContain() - -- Maybe we can repurpose one of the subtreeFlags positions for this instead? - -- Use it to store which part of the tree the focused instance is in? - -- This assumes we can safely determine that instance during the "render" phase. - - if doesFiberContain(fiber, focusedInstanceHandle) then - shouldFireAfterActiveInstanceBlur = true - ReactFiberHostConfig.beforeActiveInstanceBlur() - end - end + for i = 1, #deletions do + local fiber = deletions[i] + + -- TODO (effects) It would be nice to avoid calling doesFiberContain() + -- Maybe we can repurpose one of the subtreeFlags positions for this instead? + -- Use it to store which part of the tree the focused instance is in? + -- This assumes we can safely determine that instance during the "render" phase. + if doesFiberContain(fiber, (focusedInstanceHandle :: any) :: Fiber) then + shouldFireAfterActiveInstanceBlur = true + ReactFiberHostConfig.beforeActiveInstanceBlur() + end + end end mod.commitMutationEffects = function( - firstChild: Fiber, - root: ReactInternalTypes.FiberRoot, - renderPriorityLevel: ReactPriorityLevel + firstChild: Fiber, + root: FiberRoot, + renderPriorityLevel: ReactPriorityLevel ) - local fiber = firstChild - while fiber ~= nil do - local deletions = fiber.deletions - if deletions ~= nil then - mod.commitMutationEffectsDeletions( - deletions, - fiber, - root, - renderPriorityLevel - ) - end - - if fiber.child ~= nil then - local mutationFlags = bit32.band(fiber.subtreeFlags, ReactFiberFlags.MutationMask) - if mutationFlags ~= ReactFiberFlags.NoFlags then - mod.commitMutationEffects(fiber.child, root, renderPriorityLevel) - end - end - - if _G.__DEV__ then - setCurrentDebugFiberInDEV(fiber) - invokeGuardedCallback( - nil, - mod.commitMutationEffectsImpl, - nil, - fiber, - root, - renderPriorityLevel - ) - if hasCaughtError() then - local error_ = clearCaughtError() - exports.captureCommitPhaseError(fiber, fiber.return_, error_) - end - resetCurrentDebugFiberInDEV() - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - mod.commitMutationEffectsImpl(fiber, root, renderPriorityLevel) - end) - else - ok = true - mod.commitMutationEffectsImpl(fiber, root, renderPriorityLevel) - end - if not ok then - exports.captureCommitPhaseError(fiber, fiber.return_, result) - end - end - fiber = fiber.sibling - end + local fiber = firstChild + while fiber ~= nil do + local deletions = fiber.deletions + if deletions ~= nil then + -- ROBLOX performance: React 18 inlines commitMutationEffectsDeletions, pulling that in based on tab switching hot path + for _, childToDelete in deletions do + -- ROBLOX FIXME Luau: CLI-49835, "Function only returns 1 value, 2 are required" + local ok, error_ = xpcall( + commitDeletion, + describeError, + root, + childToDelete, + fiber, + renderPriorityLevel + ) + if not ok then + exports.captureCommitPhaseError(childToDelete, fiber, error_) + end + end + end + + if fiber.child ~= nil then + local mutationFlags = + bit32.band(fiber.subtreeFlags, ReactFiberFlags.MutationMask) + if mutationFlags ~= ReactFiberFlags.NoFlags then + mod.commitMutationEffects(fiber.child, root, renderPriorityLevel) + end + end + + if __DEV__ then + setCurrentDebugFiberInDEV(fiber) + invokeGuardedCallback( + nil, + mod.commitMutationEffectsImpl, + nil, + fiber, + root, + renderPriorityLevel + ) + if hasCaughtError() then + local error_ = clearCaughtError() + exports.captureCommitPhaseError(fiber, fiber.return_, error_) + end + resetCurrentDebugFiberInDEV() + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + ok, result = xpcall( + mod.commitMutationEffectsImpl, + describeError, + fiber, + root, + renderPriorityLevel + ) + else + ok = true + mod.commitMutationEffectsImpl(fiber, root, renderPriorityLevel) + end + if not ok then + exports.captureCommitPhaseError(fiber, fiber.return_, result) + end + end + -- ROBLOX Luau FIXME: Luau doesn't understand the while ~= nil construct + fiber = fiber.sibling :: Fiber + end end mod.commitMutationEffectsImpl = function( - fiber: Fiber, - root: ReactInternalTypes.FiberRoot, - renderPriorityLevel + fiber: Fiber, + root: FiberRoot, + renderPriorityLevel ) - local flags = fiber.flags - if bit32.band(flags, ReactFiberFlags.ContentReset) ~= 0 then - unimplemented("commitResetTextContent") - -- commitResetTextContent(fiber) - end - - if bit32.band(flags, ReactFiberFlags.Ref) ~= 0 then - local current = fiber.alternate - if current ~= nil then - commitDetachRef(current) - end - if ReactFeatureFlags.enableScopeAPI then - -- TODO: This is a temporary solution that allowed us to transition away from React Flare on www. - if fiber.tag == ReactWorkTags.ScopeComponent then - commitAttachRef(fiber) - end - end - end - - -- The following switch statement is only concerned about placement, - -- updates, and deletions. To avoid needing to add a case for every possible - -- bitmap value, we remove the secondary effects from the effect tag and - -- switch on that value. - local primaryFlags = bit32.band(flags, bit32.bor( - ReactFiberFlags.Placement, - ReactFiberFlags.Update, - ReactFiberFlags.Hydrating - )) - if primaryFlags == ReactFiberFlags.Placement then - commitPlacement(fiber) - -- Clear the "placement" from effect tag so that we know that this is - -- inserted, before any life-cycles like componentDidMount gets called. - -- TODO: findDOMNode doesn't rely on this any more but isMounted does - -- and isMounted is deprecated anyway so we should be able to kill this. - fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Placement)) - elseif primaryFlags == ReactFiberFlags.PlacementAndUpdate then - -- Placement - commitPlacement(fiber) - -- Clear the "placement" from effect tag so that we know that this is - -- inserted, before any life-cycles like componentDidMount gets called. - fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Placement)) - - -- Update - local current = fiber.alternate - commitWork(current, fiber) - elseif primaryFlags == ReactFiberFlags.Hydrating then - fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Hydrating)) - elseif primaryFlags == ReactFiberFlags.HydratingAndUpdate then - fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Hydrating)) - -- Update - local current = fiber.alternate - commitWork(current, fiber) - elseif primaryFlags == ReactFiberFlags.Update then - local current = fiber.alternate - commitWork(current, fiber) - end + local flags = fiber.flags + -- ROBLOX performance: avoid always-false compare for Roblox renderer in hot path + -- if bit32.band(flags, ReactFiberFlags.ContentReset) ~= 0 then + -- unimplemented("commitResetTextContent") + -- commitResetTextContent(fiber) + -- end + + if bit32.band(flags, ReactFiberFlags.Ref) ~= 0 then + local current = fiber.alternate + if current ~= nil then + commitDetachRef(current) + end + -- ROBLOX performance: avoid always-false compare for Roblox renderer in hot path + -- if ReactFeatureFlags.enableScopeAPI then + -- -- TODO: This is a temporary solution that allowed us to transition away from React Flare on www. + -- if fiber.tag == ReactWorkTags.ScopeComponent then + -- commitAttachRef(fiber) + -- end + -- end + end + + -- The following switch statement is only concerned about placement, + -- updates, and deletions. To avoid needing to add a case for every possible + -- bitmap value, we remove the secondary effects from the effect tag and + -- switch on that value. + local primaryFlags = bit32.band( + flags, + bit32.bor( + ReactFiberFlags.Placement, + ReactFiberFlags.Update, + ReactFiberFlags.Hydrating + ) + ) + if primaryFlags == ReactFiberFlags.Placement then + commitPlacement(fiber) + -- Clear the "placement" from effect tag so that we know that this is + -- inserted, before any life-cycles like componentDidMount gets called. + -- TODO: findDOMNode doesn't rely on this any more but isMounted does + -- and isMounted is deprecated anyway so we should be able to kill this. + fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Placement)) + elseif primaryFlags == ReactFiberFlags.PlacementAndUpdate then + -- Placement + commitPlacement(fiber) + -- Clear the "placement" from effect tag so that we know that this is + -- inserted, before any life-cycles like componentDidMount gets called. + fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Placement)) + + -- Update + local current = fiber.alternate + commitWork(current, fiber) + -- ROBLOX performance: avoid always-false compare for Roblox renderer in hot path + -- elseif primaryFlags == ReactFiberFlags.Hydrating then + -- fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Hydrating)) + -- elseif primaryFlags == ReactFiberFlags.HydratingAndUpdate then + -- fiber.flags = bit32.band(fiber.flags, bit32.bnot(ReactFiberFlags.Hydrating)) + -- -- Update + -- local current = fiber.alternate + -- commitWork(current, fiber) + elseif primaryFlags == ReactFiberFlags.Update then + local current = fiber.alternate + commitWork(current, fiber) + end end mod.commitMutationEffectsDeletions = function( - deletions: Array, - nearestMountedAncestor: Fiber, - root: ReactInternalTypes.FiberRoot, - renderPriorityLevel + deletions: Array, + fiber: Fiber, + root: FiberRoot, + renderPriorityLevel ) - for i = 1, #deletions do - local childToDelete = deletions[i] - if _G.__DEV__ then - invokeGuardedCallback( - nil, - commitDeletion, - nil, - root, - childToDelete, - nearestMountedAncestor, - renderPriorityLevel - ) - if hasCaughtError() then - local error_ = clearCaughtError() - exports.captureCommitPhaseError(childToDelete, nearestMountedAncestor, error_) - end - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, error_ - if not _G.__YOLO__ then - ok, error_ = pcall(function() - commitDeletion( - root, - childToDelete, - nearestMountedAncestor, - renderPriorityLevel - ) - end) - else - ok = true - commitDeletion( - root, - childToDelete, - nearestMountedAncestor, - renderPriorityLevel - ) - end - if not ok then - exports.captureCommitPhaseError(childToDelete, nearestMountedAncestor, error_) - end - end - end + -- ROBLOX performance: align to React 18, which ditches the __DEV__ branch and use of invokeGuardedCallback + for _, childToDelete in deletions do + -- ROBLOX FIXME Luau: CLI-49835, "Function only returns 1 value, 2 are required" + local ok, error_ = xpcall( + commitDeletion, + describeError, + root, + childToDelete, + fiber, + renderPriorityLevel + ) + if not ok then + exports.captureCommitPhaseError(childToDelete, fiber, error_) + end + end end exports.schedulePassiveEffectCallback = function() - if not rootDoesHavePassiveEffects then - rootDoesHavePassiveEffects = true - scheduleCallback(NormalSchedulerPriority, function() - exports.flushPassiveEffects() - return nil - end) - end + if not rootDoesHavePassiveEffects then + rootDoesHavePassiveEffects = true + scheduleCallback(NormalSchedulerPriority, function() + exports.flushPassiveEffects() + return nil + end) + end end -- deviation: Pre-declare functions local flushPassiveEffectsImpl exports.flushPassiveEffects = function(): boolean - -- Returns whether passive effects were flushed. - if pendingPassiveEffectsRenderPriority ~= NoSchedulerPriority then - local priorityLevel = - pendingPassiveEffectsRenderPriority > NormalSchedulerPriority - and NormalSchedulerPriority - or pendingPassiveEffectsRenderPriority - pendingPassiveEffectsRenderPriority = NoSchedulerPriority - if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - setCurrentUpdateLanePriority( - schedulerPriorityToLanePriority(priorityLevel) - ) - return runWithPriority(priorityLevel, flushPassiveEffectsImpl) - end) - else - ok = true - setCurrentUpdateLanePriority( - schedulerPriorityToLanePriority(priorityLevel) - ) - result = runWithPriority(priorityLevel, flushPassiveEffectsImpl) - end - - -- finally - setCurrentUpdateLanePriority(previousLanePriority) - - if not ok then - error(result) - end - return result - else - return runWithPriority(priorityLevel, flushPassiveEffectsImpl) - end - end - return false + -- Returns whether passive effects were flushed. + if pendingPassiveEffectsRenderPriority ~= NoSchedulerPriority then + local priorityLevel = if pendingPassiveEffectsRenderPriority + > NormalSchedulerPriority + then NormalSchedulerPriority + else pendingPassiveEffectsRenderPriority + pendingPassiveEffectsRenderPriority = NoSchedulerPriority + if ReactFeatureFlags.decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + + -- ROBLOX performance: extract non-throwable function from try{} to remove need for anon function + setCurrentUpdateLanePriority(schedulerPriorityToLanePriority(priorityLevel)) + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not __YOLO__ then + ok, result = xpcall( + runWithPriority, + describeError, + priorityLevel, + flushPassiveEffectsImpl + ) + else + ok = true + setCurrentUpdateLanePriority( + schedulerPriorityToLanePriority(priorityLevel) + ) + result = runWithPriority(priorityLevel, flushPassiveEffectsImpl) + end + + -- finally + setCurrentUpdateLanePriority(previousLanePriority) + + if not ok then + error(result) + end + return result + else + return runWithPriority(priorityLevel, flushPassiveEffectsImpl) + end + end + return false end flushPassiveMountEffects = function(root, firstChild: Fiber): () - local fiber = firstChild - while fiber ~= nil do - local prevProfilerOnStack = nil - if ReactFeatureFlags.enableProfilerTimer and ReactFeatureFlags.enableProfilerCommitHooks then - if fiber.tag == ReactWorkTags.Profiler then - prevProfilerOnStack = nearestProfilerOnStack - nearestProfilerOnStack = fiber - end - end - - local primarySubtreeFlags = bit32.band(fiber.subtreeFlags, ReactFiberFlags.PassiveMask) - - if fiber.child ~= nil and primarySubtreeFlags ~= ReactFiberFlags.NoFlags then - flushPassiveMountEffects(root, fiber.child) - end - - if bit32.band(fiber.flags, ReactFiberFlags.Passive) ~= ReactFiberFlags.NoFlags then - if _G.__DEV__ then - setCurrentDebugFiberInDEV(fiber) - invokeGuardedCallback( - nil, - commitPassiveMountOnFiber, - nil, - root, - fiber - ) - if hasCaughtError() then - local error_ = clearCaughtError() - exports.captureCommitPhaseError(fiber, fiber.return_, error_) - end - resetCurrentDebugFiberInDEV() - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, error_ - if not _G.__YOLO__ then - ok, error_ = pcall(function() - commitPassiveMountOnFiber(root, fiber) - end) - else - ok = true - commitPassiveMountOnFiber(root, fiber) - end - - if not ok then - exports.captureCommitPhaseError(fiber, fiber.return_, error_) - end - end - end - - if ReactFeatureFlags.enableProfilerTimer and ReactFeatureFlags.enableProfilerCommitHooks then - if fiber.tag == ReactWorkTags.Profiler then - -- Bubble times to the next nearest ancestor Profiler. - -- After we process that Profiler, we'll bubble further up. - if prevProfilerOnStack ~= nil then - prevProfilerOnStack.stateNode.passiveEffectDuration += - fiber.stateNode.passiveEffectDuration - end - - nearestProfilerOnStack = prevProfilerOnStack - end - end - - fiber = fiber.sibling - end + local fiber = firstChild + while fiber ~= nil do + local prevProfilerOnStack = nil + if + ReactFeatureFlags.enableProfilerTimer + and ReactFeatureFlags.enableProfilerCommitHooks + then + if fiber.tag == ReactWorkTags.Profiler then + prevProfilerOnStack = nearestProfilerOnStack + nearestProfilerOnStack = fiber + end + end + + local primarySubtreeFlags = + bit32.band(fiber.subtreeFlags, ReactFiberFlags.PassiveMask) + + if fiber.child ~= nil and primarySubtreeFlags ~= ReactFiberFlags.NoFlags then + flushPassiveMountEffects(root, fiber.child) + end + + if + bit32.band(fiber.flags, ReactFiberFlags.Passive) ~= ReactFiberFlags.NoFlags + then + if __DEV__ then + setCurrentDebugFiberInDEV(fiber) + invokeGuardedCallback(nil, commitPassiveMountOnFiber, nil, root, fiber) + if hasCaughtError() then + local error_ = clearCaughtError() + exports.captureCommitPhaseError(fiber, fiber.return_, error_) + end + resetCurrentDebugFiberInDEV() + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, error_ + if not __YOLO__ then + ok, error_ = + xpcall(commitPassiveMountOnFiber, describeError, root, fiber) + else + ok = true + commitPassiveMountOnFiber(root, fiber) + end + + if not ok then + exports.captureCommitPhaseError(fiber, fiber.return_, error_) + end + end + end + + if + ReactFeatureFlags.enableProfilerTimer + and ReactFeatureFlags.enableProfilerCommitHooks + then + if fiber.tag == ReactWorkTags.Profiler then + -- Bubble times to the next nearest ancestor Profiler. + -- After we process that Profiler, we'll bubble further up. + if prevProfilerOnStack ~= nil then + prevProfilerOnStack.stateNode.passiveEffectDuration += fiber.stateNode.passiveEffectDuration + end + + nearestProfilerOnStack = prevProfilerOnStack + end + end + + -- ROBLOX Luau FIXME: Luau doesn't understand the loop ~= nil construct + fiber = fiber.sibling :: Fiber + end end local function flushPassiveUnmountEffects(firstChild: Fiber): () - local fiber = firstChild - while fiber ~= nil do - local deletions = fiber.deletions - if deletions ~= nil then - for i = 1, #deletions do - local fiberToDelete = deletions[i] - mod.flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete, fiber) - - -- Now that passive effects have been processed, it's safe to detach lingering pointers. - mod.detachFiberAfterEffects(fiberToDelete) - end - end - - local child = fiber.child - if child ~= nil then - -- If any children have passive effects then traverse the subtree. - -- Note that this requires checking subtreeFlags of the current Fiber, - -- rather than the subtreeFlags/effectsTag of the first child, - -- since that would not cover passive effects in siblings. - local passiveFlags = bit32.band(fiber.subtreeFlags, ReactFiberFlags.PassiveMask) - if passiveFlags ~= ReactFiberFlags.NoFlags then - flushPassiveUnmountEffects(child) - end - end - - local primaryFlags = bit32.band(fiber.flags, ReactFiberFlags.Passive) - if primaryFlags ~= ReactFiberFlags.NoFlags then - setCurrentDebugFiberInDEV(fiber) - commitPassiveUnmountOnFiber(fiber) - resetCurrentDebugFiberInDEV() - end - - fiber = fiber.sibling - end + local fiber = firstChild + while fiber ~= nil do + local deletions = fiber.deletions + if deletions ~= nil then + for i = 1, #deletions do + local fiberToDelete = deletions[i] + mod.flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete, fiber) + + -- Now that passive effects have been processed, it's safe to detach lingering pointers. + mod.detachFiberAfterEffects(fiberToDelete) + end + end + + local child = fiber.child + if child ~= nil then + -- If any children have passive effects then traverse the subtree. + -- Note that this requires checking subtreeFlags of the current Fiber, + -- rather than the subtreeFlags/effectsTag of the first child, + -- since that would not cover passive effects in siblings. + local passiveFlags = + bit32.band(fiber.subtreeFlags, ReactFiberFlags.PassiveMask) + if passiveFlags ~= ReactFiberFlags.NoFlags then + flushPassiveUnmountEffects(child) + end + end + + local primaryFlags = bit32.band(fiber.flags, ReactFiberFlags.Passive) + if primaryFlags ~= ReactFiberFlags.NoFlags then + setCurrentDebugFiberInDEV(fiber) + commitPassiveUnmountOnFiber(fiber) + resetCurrentDebugFiberInDEV() + end + + -- ROBLOX FIXME Luau: Luau doesn't understand the loop ~= nil construct + fiber = fiber.sibling :: Fiber + end end mod.flushPassiveUnmountEffectsInsideOfDeletedTree = function( - fiberToDelete: Fiber, - nearestMountedAncestor: Fiber + fiberToDelete: Fiber, + nearestMountedAncestor: Fiber ) - if bit32.band(fiberToDelete.subtreeFlags, ReactFiberFlags.PassiveStatic) ~= ReactFiberFlags.NoFlags then - -- If any children have passive effects then traverse the subtree. - -- Note that this requires checking subtreeFlags of the current Fiber, - -- rather than the subtreeFlags/effectsTag of the first child, - -- since that would not cover passive effects in siblings. - local child = fiberToDelete.child - while child ~= nil do - mod.flushPassiveUnmountEffectsInsideOfDeletedTree( - child, - nearestMountedAncestor - ) - child = child.sibling - end - end - - if bit32.band(fiberToDelete.flags, ReactFiberFlags.PassiveStatic) ~= ReactFiberFlags.NoFlags then - setCurrentDebugFiberInDEV(fiberToDelete) - commitPassiveUnmountInsideDeletedTreeOnFiber( - fiberToDelete, - nearestMountedAncestor - ) - resetCurrentDebugFiberInDEV() - end + if + bit32.band(fiberToDelete.subtreeFlags, ReactFiberFlags.PassiveStatic) + ~= ReactFiberFlags.NoFlags + then + -- If any children have passive effects then traverse the subtree. + -- Note that this requires checking subtreeFlags of the current Fiber, + -- rather than the subtreeFlags/effectsTag of the first child, + -- since that would not cover passive effects in siblings. + local child = fiberToDelete.child + while child ~= nil do + mod.flushPassiveUnmountEffectsInsideOfDeletedTree( + child, + nearestMountedAncestor + ) + child = child.sibling + end + end + + if + bit32.band(fiberToDelete.flags, ReactFiberFlags.PassiveStatic) + ~= ReactFiberFlags.NoFlags + then + setCurrentDebugFiberInDEV(fiberToDelete) + commitPassiveUnmountInsideDeletedTreeOnFiber( + fiberToDelete, + nearestMountedAncestor + ) + resetCurrentDebugFiberInDEV() + end end flushPassiveEffectsImpl = function() - if rootWithPendingPassiveEffects == nil then - return false - end - - local root = rootWithPendingPassiveEffects - local _lanes = pendingPassiveEffectsLanes - rootWithPendingPassiveEffects = nil - pendingPassiveEffectsLanes = ReactFiberLane.NoLanes - - invariant( - bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, - "Cannot flush passive effects while already rendering." - ) - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing") - -- FIXME (roblox): enable tracing logic - -- logPassiveEffectsStarted(lanes) - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markPassiveEffectsStarted(_lanes) - end - - if _G.__DEV__ then - isFlushingPassiveEffects = true - end - - local prevExecutionContext = executionContext - executionContext = bit32.bor(executionContext, CommitContext) - local prevInteractions = mod.pushInteractions(root) - - -- It's important that ALL pending passive effect destroy functions are called - -- before ANY passive effect create functions are called. - -- Otherwise effects in sibling components might interfere with each other. - -- e.g. a destroy function in one component may unintentionally override a ref - -- value set by a create function in another component. - -- Layout effects have the same constraint. - flushPassiveUnmountEffects(root.current) - flushPassiveMountEffects(root, root.current) - - if _G.__DEV__ then - if ReactFeatureFlags.enableDebugTracing then - unimplemented("debug tracing logic") - -- logPassiveEffectsStopped() - end - end - - if enableSchedulingProfiler then - SchedulingProfiler.markPassiveEffectsStopped() - end - - if _G.__DEV__ and enableDoubleInvokingEffects then - commitDoubleInvokeEffectsInDEV(root.current, true) - end - - if _G.__DEV__ then - isFlushingPassiveEffects = false - end - - if ReactFeatureFlags.enableSchedulerTracing then - mod.popInteractions(prevInteractions) - mod.finishPendingInteractions(root, _lanes) - end - - executionContext = prevExecutionContext - - flushSyncCallbackQueue() - - -- If additional passive effects were scheduled, increment a counter. If this - -- exceeds the limit, we'll fire a warning. - if rootWithPendingPassiveEffects == nil then - nestedPassiveUpdateCount = 0 - else - nestedPassiveUpdateCount = nestedPassiveUpdateCount + 1 - end - - return true + if rootWithPendingPassiveEffects == nil then + return false + end + + -- ROBLOX Luau FIXME: Luau doesn't narrow to non-nil with the guard above + local root = rootWithPendingPassiveEffects :: FiberRoot + local lanes = pendingPassiveEffectsLanes + rootWithPendingPassiveEffects = nil + pendingPassiveEffectsLanes = ReactFiberLane.NoLanes + + invariant( + bit32.band(executionContext, bit32.bor(RenderContext, CommitContext)) == NoContext, + "Cannot flush passive effects while already rendering." + ) + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logPassiveEffectsStarted(lanes) + end + end + + if enableSchedulingProfiler then + SchedulingProfiler.markPassiveEffectsStarted(lanes) + end + + local prevExecutionContext = executionContext + executionContext = bit32.bor(executionContext, CommitContext) + local prevInteractions = mod.pushInteractions(root) + + -- It's important that ALL pending passive effect destroy functions are called + -- before ANY passive effect create functions are called. + -- Otherwise effects in sibling components might interfere with each other. + -- e.g. a destroy function in one component may unintentionally override a ref + -- value set by a create function in another component. + -- Layout effects have the same constraint. + flushPassiveUnmountEffects(root.current) + flushPassiveMountEffects(root, root.current) + + if __DEV__ then + if enableDebugTracing then + DebugTracing.logPassiveEffectsStopped() + end + end + + if enableSchedulingProfiler then + -- ROBLOX deviation: pass root for attribution + SchedulingProfiler.markPassiveEffectsStopped(root) + end + + if __DEV__ and enableDoubleInvokingEffects then + commitDoubleInvokeEffectsInDEV(root.current, true) + end + + if ReactFeatureFlags.enableSchedulerTracing then + mod.popInteractions(prevInteractions) + mod.finishPendingInteractions(root, lanes) + end + + executionContext = prevExecutionContext + + flushSyncCallbackQueue() + + -- If additional passive effects were scheduled, increment a counter. If this + -- exceeds the limit, we'll fire a warning. + if rootWithPendingPassiveEffects == nil then + nestedPassiveUpdateCount = 0 + else + nestedPassiveUpdateCount = nestedPassiveUpdateCount + 1 + end + + return true end exports.isAlreadyFailedLegacyErrorBoundary = function(instance): boolean - return - legacyErrorBoundariesThatAlreadyFailed ~= nil and - -- deviation: instead of has - -- legacyErrorBoundariesThatAlreadyFailed.has(instance) - legacyErrorBoundariesThatAlreadyFailed[instance] + return legacyErrorBoundariesThatAlreadyFailed ~= nil + and legacyErrorBoundariesThatAlreadyFailed:has(instance) end exports.markLegacyErrorBoundaryAsFailed = function(instance) - if legacyErrorBoundariesThatAlreadyFailed == nil then - legacyErrorBoundariesThatAlreadyFailed = {[instance] = true} - else - legacyErrorBoundariesThatAlreadyFailed[instance] = true - end + if legacyErrorBoundariesThatAlreadyFailed == nil then + legacyErrorBoundariesThatAlreadyFailed = Set.new({ instance }) + else + legacyErrorBoundariesThatAlreadyFailed:add(instance) + end end -- ROBLOX TODO: this function and the related fields should be extracted/relocated to break a cycle local function prepareToThrowUncaughtError(error_) - if not hasUncaughtError then - hasUncaughtError = true - firstUncaughtError = error_ - end + if not hasUncaughtError then + hasUncaughtError = true + firstUncaughtError = error_ + end end exports.onUncaughtError = prepareToThrowUncaughtError -captureCommitPhaseErrorOnRoot = function( - rootFiber: Fiber, - sourceFiber: Fiber, - error_ -) - local errorInfo = createCapturedValue(error_, sourceFiber) - -- ROBLOX deviation: parameterize method onUncaughtError to avoid circular dependency - local update = createRootErrorUpdate(rootFiber, errorInfo, SyncLane, exports.onUncaughtError) - enqueueUpdate(rootFiber, update) - local eventTime = exports.requestEventTime() - local root = mod.markUpdateLaneFromFiberToRoot(rootFiber, SyncLane) - if root ~= nil then - markRootUpdated(root, SyncLane, eventTime) - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, SyncLane) - end +captureCommitPhaseErrorOnRoot = function(rootFiber: Fiber, sourceFiber: Fiber, error_) + local errorInfo = createCapturedValue(error_, sourceFiber) + -- ROBLOX deviation: parameterize method onUncaughtError to avoid circular dependency + local update = + createRootErrorUpdate(rootFiber, errorInfo, SyncLane, exports.onUncaughtError) + enqueueUpdate(rootFiber, update) + local eventTime = exports.requestEventTime() + local root = mod.markUpdateLaneFromFiberToRoot(rootFiber, SyncLane) + if root ~= nil then + markRootUpdated(root, SyncLane, eventTime) + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, SyncLane) + end end -- exports.captureCommitPhaseError( @@ -3044,124 +3073,121 @@ end -- error: mixed -- ) exports.captureCommitPhaseError = function( - sourceFiber: Fiber, - nearestMountedAncestor, - error_ + sourceFiber: Fiber, + nearestMountedAncestor, + error_ ) - if sourceFiber.tag == ReactWorkTags.HostRoot then - -- Error was thrown at the root. There is no parent, so the root - -- itself should capture it. - captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error_) - return - end - - local fiber = nil - if skipUnmountedBoundaries then - fiber = nearestMountedAncestor - else - fiber = sourceFiber.return_ - end - - while fiber ~= nil do - if fiber.tag == ReactWorkTags.HostRoot then - captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error_) - return - else if fiber.tag == ReactWorkTags.ClassComponent then - local ctor = fiber.type - local instance = fiber.stateNode - if - typeof(ctor.getDerivedStateFromError) == 'function' or - (typeof(instance.componentDidCatch) == 'function' and - not exports.isAlreadyFailedLegacyErrorBoundary(instance)) - then - local errorInfo = createCapturedValue(error_, sourceFiber) - local update = createClassErrorUpdate( - fiber, - errorInfo, - SyncLane - ) - enqueueUpdate(fiber, update) - local eventTime = exports.requestEventTime() - local root = mod.markUpdateLaneFromFiberToRoot(fiber, SyncLane) - if root ~= nil then - markRootUpdated(root, SyncLane, eventTime) - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, SyncLane) - end - return - end - end - fiber = fiber.return_ - end -end + if sourceFiber.tag == ReactWorkTags.HostRoot then + -- Error was thrown at the root. There is no parent, so the root + -- itself should capture it. + captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error_) + return + end + + local fiber = nil + if skipUnmountedBoundaries then + fiber = nearestMountedAncestor + else + fiber = sourceFiber.return_ + end + + while fiber ~= nil do + if fiber.tag == ReactWorkTags.HostRoot then + captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error_) + return + else + if fiber.tag == ReactWorkTags.ClassComponent then + local ctor = fiber.type + local instance = fiber.stateNode + if + typeof(ctor.getDerivedStateFromError) == "function" + or ( + typeof(instance.componentDidCatch) == "function" + and not exports.isAlreadyFailedLegacyErrorBoundary(instance) + ) + then + local errorInfo = createCapturedValue(error_, sourceFiber) + local update = createClassErrorUpdate(fiber, errorInfo, SyncLane) + enqueueUpdate(fiber, update) + local eventTime = exports.requestEventTime() + local root = mod.markUpdateLaneFromFiberToRoot(fiber, SyncLane) + if root ~= nil then + markRootUpdated(root, SyncLane, eventTime) + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, SyncLane) + end + return + end + end + fiber = fiber.return_ + end + end end exports.pingSuspendedRoot = function( - root: ReactInternalTypes.FiberRoot, - wakeable: Wakeable, - pingedLanes: Lanes + root: FiberRoot, + wakeable: Wakeable, + pingedLanes: Lanes ) - local pingCache = root.pingCache - if pingCache ~= nil then - -- The wakeable resolved, so we no longer need to memoize, because it will - -- never be thrown again. - pingCache[wakeable] = nil - end - - local eventTime = exports.requestEventTime() - markRootPinged(root, pingedLanes, eventTime) - - if - workInProgressRoot == root and - isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes) - then - -- Received a ping at the same priority level at which we're currently - -- rendering. We might want to restart this render. This should mirror - -- the logic of whether or not a root suspends once it completes. - - -- TODO: If we're rendering sync either due to Sync, Batched or expired, - -- we should probably never restart. - - -- If we're suspended with delay, or if it's a retry, we'll always suspend - -- so we can always restart. - if - workInProgressRootExitStatus == RootExitStatus.SuspendedWithDelay or - workInProgressRootExitStatus == RootExitStatus.Suspended and - includesOnlyRetries(workInProgressRootRenderLanes) and - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS - then - -- Restart from the root. - mod.prepareFreshStack(root, ReactFiberLane.NoLanes) - else - -- Even though we can't restart right now, we might get an - -- opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes - ) - end - end - - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, pingedLanes) + local pingCache = root.pingCache + if pingCache ~= nil then + -- The wakeable resolved, so we no longer need to memoize, because it will + -- never be thrown again. + pingCache[wakeable] = nil + end + + local eventTime = exports.requestEventTime() + markRootPinged(root, pingedLanes, eventTime) + + if + workInProgressRoot == root + and isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes) + then + -- Received a ping at the same priority level at which we're currently + -- rendering. We might want to restart this render. This should mirror + -- the logic of whether or not a root suspends once it completes. + + -- TODO: If we're rendering sync either due to Sync, Batched or expired, + -- we should probably never restart. + + -- If we're suspended with delay, or if it's a retry, we'll always suspend + -- so we can always restart. + if + workInProgressRootExitStatus == RootExitStatus.SuspendedWithDelay + or workInProgressRootExitStatus == RootExitStatus.Suspended + and includesOnlyRetries(workInProgressRootRenderLanes) + and now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS + then + -- Restart from the root. + mod.prepareFreshStack(root, ReactFiberLane.NoLanes) + else + -- Even though we can't restart right now, we might get an + -- opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = + mergeLanes(workInProgressRootPingedLanes, pingedLanes) + end + end + + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, pingedLanes) end function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) - -- The boundary fiber (a Suspense component or SuspenseList component) - -- previously was rendered in its fallback state. One of the promises that - -- suspended it has resolved, which means at least part of the tree was - -- likely unblocked. Try rendering again, at a new expiration time. - if retryLane == ReactFiberLane.NoLane then - retryLane = requestRetryLane(boundaryFiber) - end - -- TODO: Special case idle priority? - local eventTime = exports.requestEventTime() - local root = mod.markUpdateLaneFromFiberToRoot(boundaryFiber, retryLane) - if root ~= nil then - markRootUpdated(root, retryLane, eventTime) - ensureRootIsScheduled(root, eventTime) - mod.schedulePendingInteractions(root, retryLane) - end + -- The boundary fiber (a Suspense component or SuspenseList component) + -- previously was rendered in its fallback state. One of the promises that + -- suspended it has resolved, which means at least part of the tree was + -- likely unblocked. Try rendering again, at a new expiration time. + if retryLane == ReactFiberLane.NoLane then + retryLane = requestRetryLane(boundaryFiber) + end + -- TODO: Special case idle priority? + local eventTime = exports.requestEventTime() + local root = mod.markUpdateLaneFromFiberToRoot(boundaryFiber, retryLane) + if root ~= nil then + markRootUpdated(root, retryLane, eventTime) + ensureRootIsScheduled(root, eventTime) + mod.schedulePendingInteractions(root, retryLane) + end end -- exports.retryDehydratedSuspenseBoundary(boundaryFiber: Fiber) @@ -3174,36 +3200,37 @@ end -- end exports.resolveRetryWakeable = function(boundaryFiber: Fiber, wakeable: Wakeable) - local retryLane = ReactFiberLane.NoLane -- Default - local retryCache -- : WeakSet | Set | nil - if ReactFeatureFlags.enableSuspenseServerRenderer then - if boundaryFiber.tag == ReactWorkTags.SuspenseComponent then - retryCache = boundaryFiber.stateNode - local suspenseState: nil | SuspenseState = boundaryFiber.memoizedState - if suspenseState ~= nil then - -- ROBLOX FIXME: remove recast - retryLane = (suspenseState :: SuspenseState).retryLane - end - elseif boundaryFiber.tag == ReactWorkTags.SuspenseListComponent then - retryCache = boundaryFiber.stateNode - else - invariant( - false, - 'Pinged unknown suspense boundary type. ' .. - 'This is probably a bug in React.' - ) - end - else - retryCache = boundaryFiber.stateNode - end - - if retryCache ~= nil then - -- The wakeable resolved, so we no longer need to memoize, because it will - -- never be thrown again. - retryCache[wakeable] = nil - end - - retryTimedOutBoundary(boundaryFiber, retryLane) + local retryLane = ReactFiberLane.NoLane -- Default + local retryCache -- : WeakSet | Set | nil + -- ROBLOX performance: avoid always-false comapare + -- if ReactFeatureFlags.enableSuspenseServerRenderer then + -- if boundaryFiber.tag == ReactWorkTags.SuspenseComponent then + -- retryCache = boundaryFiber.stateNode + -- local suspenseState: nil | SuspenseState = boundaryFiber.memoizedState + -- if suspenseState ~= nil then + -- -- ROBLOX TODO: Remove Luau narrowing workaround + -- retryLane = (suspenseState :: SuspenseState).retryLane + -- end + -- elseif boundaryFiber.tag == ReactWorkTags.SuspenseListComponent then + -- retryCache = boundaryFiber.stateNode + -- else + -- invariant( + -- false, + -- 'Pinged unknown suspense boundary type. ' .. + -- 'This is probably a bug in React.' + -- ) + -- end + -- else + retryCache = boundaryFiber.stateNode + -- end + + if retryCache ~= nil then + -- The wakeable resolved, so we no longer need to memoize, because it will + -- never be thrown again. + retryCache:delete(wakeable) + end + + retryTimedOutBoundary(boundaryFiber, retryLane) end -- Computes the next Just Noticeable Difference (JND) boundary. @@ -3215,509 +3242,421 @@ end -- the longer we can wait additionally. At some point we have to give up though. -- We pick a train model where the next boundary commits at a consistent schedule. -- These particular numbers are vague estimates. We expect to adjust them based on research. -function jnd(timeElapsed: number) - if timeElapsed < 120 then - return 120 - elseif timeElapsed < 480 then - return 480 - elseif timeElapsed < 1080 then - return 1080 - elseif timeElapsed < 1920 then - return 1920 - elseif timeElapsed < 3000 then - return 3000 - elseif timeElapsed < 4320 then - return 4320 - else - return math.ceil(timeElapsed / 1960) * 1960 - end +-- ROBLOX Luau FIXME: Luau needs an explicit number annotation here, but should infer: CLI-49832 +function jnd(timeElapsed: number): number + if timeElapsed < 120 then + return 120 + elseif timeElapsed < 480 then + return 480 + elseif timeElapsed < 1080 then + return 1080 + elseif timeElapsed < 1920 then + return 1920 + elseif timeElapsed < 3000 then + return 3000 + elseif timeElapsed < 4320 then + return 4320 + else + return math.ceil(timeElapsed / 1960) * 1960 + end end mod.checkForNestedUpdates = function() - if nestedUpdateCount > NESTED_UPDATE_LIMIT then - nestedUpdateCount = 0 - rootWithNestedUpdates = nil - invariant( - false, - "Maximum update depth exceeded. This can happen when a component " .. - "repeatedly calls setState inside componentWillUpdate or " .. - "componentDidUpdate. React limits the number of nested updates to " .. - "prevent infinite loops." - ) - end - - if _G.__DEV__ then - if nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT then - nestedPassiveUpdateCount = 0 - console.error( - "Maximum update depth exceeded. This can happen when a component " .. - "calls setState inside useEffect, but useEffect either doesn't " .. - "have a dependency array, or one of the dependencies changes on " .. - "every render." - ) - end - end + if nestedUpdateCount > NESTED_UPDATE_LIMIT then + nestedUpdateCount = 0 + rootWithNestedUpdates = nil + invariant( + false, + "Maximum update depth exceeded. This can happen when a component " + .. "repeatedly calls setState inside componentWillUpdate or " + .. "componentDidUpdate. React limits the number of nested updates to " + .. "prevent infinite loops." + ) + end + + if __DEV__ then + if nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT then + nestedPassiveUpdateCount = 0 + console.error( + "Maximum update depth exceeded. This can happen when a component " + .. "calls setState inside useEffect, but useEffect either doesn't " + .. "have a dependency array, or one of the dependencies changes on " + .. "every render." + ) + end + end end function flushRenderPhaseStrictModeWarningsInDEV() - if _G.__DEV__ then - ReactStrictModeWarnings.flushLegacyContextWarning() + if __DEV__ then + ReactStrictModeWarnings.flushLegacyContextWarning() - if ReactFeatureFlags.warnAboutDeprecatedLifecycles then - ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings() - end - end + if ReactFeatureFlags.warnAboutDeprecatedLifecycles then + ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings() + end + end end -function commitDoubleInvokeEffectsInDEV( - fiber: Fiber, - hasPassiveEffects: boolean -) - if _G.__DEV__ and enableDoubleInvokingEffects then - setCurrentDebugFiberInDEV(fiber) - invokeEffectsInDev(fiber, ReactFiberFlags.MountLayoutDev, invokeLayoutEffectUnmountInDEV) - if hasPassiveEffects then - invokeEffectsInDev( - fiber, - ReactFiberFlags.MountPassiveDev, - invokePassiveEffectUnmountInDEV - ) - end - - invokeEffectsInDev(fiber, ReactFiberFlags.MountLayoutDev, invokeLayoutEffectMountInDEV) - if hasPassiveEffects then - invokeEffectsInDev(fiber, ReactFiberFlags.MountPassiveDev, invokePassiveEffectMountInDEV) - end - resetCurrentDebugFiberInDEV() - end +function commitDoubleInvokeEffectsInDEV(fiber: Fiber, hasPassiveEffects: boolean) + if __DEV__ and enableDoubleInvokingEffects then + setCurrentDebugFiberInDEV(fiber) + invokeEffectsInDev( + fiber, + ReactFiberFlags.MountLayoutDev, + invokeLayoutEffectUnmountInDEV + ) + if hasPassiveEffects then + invokeEffectsInDev( + fiber, + ReactFiberFlags.MountPassiveDev, + invokePassiveEffectUnmountInDEV + ) + end + + invokeEffectsInDev( + fiber, + ReactFiberFlags.MountLayoutDev, + invokeLayoutEffectMountInDEV + ) + if hasPassiveEffects then + invokeEffectsInDev( + fiber, + ReactFiberFlags.MountPassiveDev, + invokePassiveEffectMountInDEV + ) + end + resetCurrentDebugFiberInDEV() + end end function invokeEffectsInDev( - firstChild, - fiberFlags, - invokeEffectFn -) - -- ROBLOX TODO: Luau functional types - -- invokeEffectFn: (fiber: Fiber) => void, - if _G.__DEV__ and enableDoubleInvokingEffects then - local fiber = firstChild - while fiber ~= nil do - if fiber.child ~= nil then - local primarySubtreeFlag = bit32.band(fiber.subtreeFlags, fiberFlags) - if primarySubtreeFlag ~= ReactFiberFlags.NoFlags then - invokeEffectsInDev(fiber.child, fiberFlags, invokeEffectFn) - end - end - - if bit32.band(fiber.flags, fiberFlags) ~= ReactFiberFlags.NoFlags then - invokeEffectFn(fiber) - end - fiber = fiber.sibling - end - end + firstChild: Fiber, + fiberFlags: Flags, + invokeEffectFn: (fiber: Fiber) -> () +): () + if __DEV__ and enableDoubleInvokingEffects then + local fiber = firstChild + while fiber ~= nil do + if fiber.child ~= nil then + local primarySubtreeFlag = bit32.band(fiber.subtreeFlags, fiberFlags) + if primarySubtreeFlag ~= ReactFiberFlags.NoFlags then + invokeEffectsInDev(fiber.child, fiberFlags, invokeEffectFn) + end + end + + if bit32.band(fiber.flags, fiberFlags) ~= ReactFiberFlags.NoFlags then + invokeEffectFn(fiber) + end + -- ROBLOX FIXME Luau: Luau doesn't understand the loop ~= nil construct + fiber = fiber.sibling :: Fiber + end + end end -- deviation: FIXME restore type Set?, has trouble with narrowing local didWarnStateUpdateForNotYetMountedComponent: any = nil mod.warnAboutUpdateOnNotYetMountedFiberInDEV = function(fiber) - if _G.__DEV__ then - if bit32.band(executionContext, RenderContext) ~= NoContext then - -- We local the other warning about render phase updates deal with this one. - return - end - - if bit32.band(fiber.mode, bit32.bor(ReactTypeOfMode.BlockingMode, ReactTypeOfMode.ConcurrentMode)) == 0 then - return - end - - local tag = fiber.tag - if - tag ~= ReactWorkTags.IndeterminateComponent and - tag ~= ReactWorkTags.HostRoot and - tag ~= ReactWorkTags.ClassComponent and - tag ~= ReactWorkTags.FunctionComponent and - tag ~= ReactWorkTags.ForwardRef and - tag ~= ReactWorkTags.MemoComponent and - tag ~= ReactWorkTags.SimpleMemoComponent and - tag ~= ReactWorkTags.Block - then - -- Only warn for user-defined components, not internal ones like Suspense. - return - end - - -- We show the whole stack but dedupe on the top component's name because - -- the problematic code almost always lies inside that component. - local componentName = getComponentName(fiber.type) or "ReactComponent" - if didWarnStateUpdateForNotYetMountedComponent ~= nil then - if didWarnStateUpdateForNotYetMountedComponent[componentName] then - return - end - didWarnStateUpdateForNotYetMountedComponent[componentName] = true - else - -- ROBLOX FIXME? not sure this translation is correct - didWarnStateUpdateForNotYetMountedComponent = { [componentName] = true } - end - - local previousFiber = ReactCurrentFiber.current - local ok, result = pcall(function() - setCurrentDebugFiberInDEV(fiber) - console.error( - "Can't perform a React state update on a component that hasn't mounted yet. " .. - "This indicates that you have a side-effect in your render function that " .. - "asynchronously later calls tries to update the component. Move this work to " .. - "useEffect instead." - ) - end) - - -- finally - if previousFiber then - setCurrentDebugFiberInDEV(fiber) - else - resetCurrentDebugFiberInDEV() - end - - if not ok then - error(result) - end - end -end - --- deviation: FIXME restore type Set?, has trouble with narrowing -local didWarnStateUpdateForUnmountedComponent: any = nil -mod.warnAboutUpdateOnUnmountedFiberInDEV = function(fiber) - if _G.__DEV__ then - local tag = fiber.tag - if - tag ~= ReactWorkTags.HostRoot and - tag ~= ReactWorkTags.ClassComponent and - tag ~= ReactWorkTags.FunctionComponent and - tag ~= ReactWorkTags.ForwardRef and - tag ~= ReactWorkTags.MemoComponent and - tag ~= ReactWorkTags.SimpleMemoComponent and - tag ~= ReactWorkTags.Block - then - -- Only warn for user-defined components, not internal ones like Suspense. - return - end - - if bit32.band(fiber.flags, ReactFiberFlags.PassiveStatic) ~= ReactFiberFlags.NoFlags then - -- FIXME (roblox): restore types here - -- local updateQueue: FunctionComponentUpdateQueue | nil = (fiber.updateQueue: any) - local updateQueue = fiber.updateQueue - if updateQueue ~= nil then - local lastEffect = updateQueue.lastEffect - if lastEffect ~= nil then - local firstEffect = lastEffect.next - - local effect = firstEffect - repeat - if effect.destroy ~= nil then - if bit32.band(effect.tag, ReactHookEffectTags.Passive) ~= ReactHookEffectTags.NoFlags then - return - end - end - effect = effect.next - until effect == firstEffect - end - end - end - - -- We show the whole stack but dedupe on the top component's name because - -- the problematic code almost always lies inside that component. - local componentName = getComponentName(fiber.type) or "ReactComponent" - if didWarnStateUpdateForUnmountedComponent ~= nil then - if didWarnStateUpdateForUnmountedComponent[componentName] then - return - end - didWarnStateUpdateForUnmountedComponent[componentName] = true - else - -- ROBLOX FIXME? not sure this translation is correct - didWarnStateUpdateForUnmountedComponent = { [componentName] = true } - end - - if isFlushingPassiveEffects then - -- Do not warn if we are currently flushing passive effects! - -- - -- React can't directly detect a memory leak, but there are some clues that warn about one. - -- One of these clues is when an unmounted React component tries to update its state. - -- For example, if a component forgets to remove an event listener when unmounting, - -- that listener may be called later and try to update state, - -- at which point React would warn about the potential leak. - -- - -- Warning signals are the most useful when they're strong. - -- (So we should avoid false positive warnings.) - -- Updating state from within an effect cleanup function is sometimes a necessary pattern, e.g.: - -- 1. Updating an ancestor that a component had registered itself with on mount. - -- 2. Resetting state when a component is hidden after going offscreen. - else - local previousFiber = ReactCurrentFiber.current - local ok, result = pcall(function() - setCurrentDebugFiberInDEV(fiber) - console.error( - "Can't perform a React state update on an unmounted component. This " .. - "is a no-op, but it indicates a memory leak in your application. To " .. - "fix, cancel all subscriptions and asynchronous tasks in %s.", - tag == ReactWorkTags.ClassComponent - and "the componentWillUnmount method" - or "a useEffect cleanup function" - ) - end) - - -- finally - if previousFiber then - setCurrentDebugFiberInDEV(fiber) - else - resetCurrentDebugFiberInDEV() - end - - if not ok then - error(result) - end - end - end + if __DEV__ then + if bit32.band(executionContext, RenderContext) ~= NoContext then + -- We local the other warning about render phase updates deal with this one. + return + end + + if + bit32.band( + fiber.mode, + bit32.bor(ReactTypeOfMode.BlockingMode, ReactTypeOfMode.ConcurrentMode) + ) == 0 + then + return + end + + local tag = fiber.tag + if + tag ~= ReactWorkTags.IndeterminateComponent + and tag ~= ReactWorkTags.HostRoot + and tag ~= ReactWorkTags.ClassComponent + and tag ~= ReactWorkTags.FunctionComponent + and tag ~= ReactWorkTags.ForwardRef + and tag ~= ReactWorkTags.MemoComponent + and tag ~= ReactWorkTags.SimpleMemoComponent + and tag ~= ReactWorkTags.Block + then + -- Only warn for user-defined components, not internal ones like Suspense. + return + end + + -- We show the whole stack but dedupe on the top component's name because + -- the problematic code almost always lies inside that component. + local componentName = getComponentName(fiber.type) or "ReactComponent" + if didWarnStateUpdateForNotYetMountedComponent ~= nil then + if didWarnStateUpdateForNotYetMountedComponent[componentName] then + return + end + didWarnStateUpdateForNotYetMountedComponent[componentName] = true + else + -- ROBLOX FIXME? not sure this translation is correct + didWarnStateUpdateForNotYetMountedComponent = { [componentName] = true } + end + + local previousFiber = ReactCurrentFiber.current + local ok, result = pcall(function() + setCurrentDebugFiberInDEV(fiber) + console.error( + "Can't perform a React state update on a component that hasn't mounted yet. " + .. "This indicates that you have a side-effect in your render function that " + .. "asynchronously later calls tries to update the component. Move this work to " + .. "useEffect instead." + ) + end) + + -- finally + if previousFiber then + setCurrentDebugFiberInDEV(fiber) + else + resetCurrentDebugFiberInDEV() + end + + if not ok then + error(result) + end + end end -- deviation: Declared on the mod table instead of as a local -if _G.__DEV__ and ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback then - local dummyFiber = nil - mod.beginWork = function(current, unitOfWork, lanes) - -- If a component throws an error, we replay it again in a synchronously - -- dispatched event, so that the debugger will treat it as an uncaught - -- error See ReactErrorUtils for more information. - - -- Before entering the begin phase, copy the work-in-progress onto a dummy - -- fiber. If beginWork throws, we'll use this to reset the state. - local originalWorkInProgressCopy = ReactFiber.assignFiberPropertiesInDEV( - dummyFiber, - unitOfWork - ) - local ok, result = pcall(function() - return originalBeginWork(current, unitOfWork, lanes) - end) - if not ok then - local originalError = result - warn(originalError) - if - originalError ~= nil and - typeof(originalError) == "table" and - typeof(originalError.andThen) == "function" - then - -- Don't replay promises. Treat everything else like an error. - error(originalError) - end - - -- Keep this code in sync with handleError; any changes here must have - -- corresponding changes there. - resetContextDependencies() - resetHooksAfterThrow() - -- Don't reset current debug fiber, since we're about to work on the - -- same fiber again. - - -- Unwind the failed stack frame - unwindInterruptedWork(unitOfWork) - - -- Restore the original properties of the fiber. - ReactFiber.assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy) - - if ReactFeatureFlags.enableProfilerTimer and - bit32.band(unitOfWork.mode, ReactTypeOfMode.ProfileMode) ~= 0 then - -- Reset the profiler timer. - ReactProfilerTimer.startProfilerTimer(unitOfWork) - end - - -- Run beginWork again. - invokeGuardedCallback( - nil, - originalBeginWork, - nil, - current, - unitOfWork, - lanes - ) - - if hasCaughtError() then - local replayError = clearCaughtError() - -- `invokeGuardedCallback` sometimes sets an expando `_suppressLogging`. - -- Rethrow this error instead of the original one. - error(replayError) - else - -- This branch is reachable if the render phase is impure. - error(originalError) - end - end - - return result - end +if __DEV__ and ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback then + local dummyFiber = nil + mod.beginWork = function(current, unitOfWork, lanes) + -- If a component throws an error, we replay it again in a synchronously + -- dispatched event, so that the debugger will treat it as an uncaught + -- error See ReactErrorUtils for more information. + + -- Before entering the begin phase, copy the work-in-progress onto a dummy + -- fiber. If beginWork throws, we'll use this to reset the state. + local originalWorkInProgressCopy = + ReactFiber.assignFiberPropertiesInDEV(dummyFiber, unitOfWork) + local ok, result = + xpcall(originalBeginWork, describeError, current, unitOfWork, lanes) + if not ok then + local originalError = result + + if + originalError ~= nil + and typeof(originalError) == "table" + and typeof(originalError.andThen) == "function" + then + -- Don't replay promises. Treat everything else like an error. + error(originalError) + end + + -- Keep this code in sync with handleError; any changes here must have + -- corresponding changes there. + resetContextDependencies() + resetHooksAfterThrow() + -- Don't reset current debug fiber, since we're about to work on the + -- same fiber again. + + -- Unwind the failed stack frame + unwindInterruptedWork(unitOfWork) + + -- Restore the original properties of the fiber. + ReactFiber.assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy) + + if + ReactFeatureFlags.enableProfilerTimer + and bit32.band(unitOfWork.mode, ReactTypeOfMode.ProfileMode) ~= 0 + then + -- Reset the profiler timer. + ReactProfilerTimer.startProfilerTimer(unitOfWork) + end + + -- Run beginWork again. + invokeGuardedCallback(nil, originalBeginWork, nil, current, unitOfWork, lanes) + + if hasCaughtError() then + local replayError = clearCaughtError() + -- `invokeGuardedCallback` sometimes sets an expando `_suppressLogging`. + -- Rethrow this error instead of the original one. + error(replayError) + else + -- This branch is reachable if the render phase is impure. + error(originalError) + end + end + + return result + end else - mod.beginWork = originalBeginWork + mod.beginWork = originalBeginWork end local didWarnAboutUpdateInRender = false local didWarnAboutUpdateInRenderForAnotherComponent -if _G.__DEV__ then - didWarnAboutUpdateInRenderForAnotherComponent = {} +if __DEV__ then + didWarnAboutUpdateInRenderForAnotherComponent = {} end -mod.warnAboutRenderPhaseUpdatesInDEV = function(fiber) - if _G.__DEV__ then - if - ReactCurrentFiber.isRendering and - bit32.band(executionContext, RenderContext) ~= NoContext and - not getIsUpdatingOpaqueValueInRenderPhaseInDEV() - then - if fiber.tag == ReactWorkTags.FunctionComponent or - fiber.tag == ReactWorkTags.ForwardRef or - fiber.tag == ReactWorkTags.SimpleMemoComponent - then - local renderingComponentName - if workInProgress ~= nil then - -- ROBLOX FIXME: Luau type analsis fails, saying 'type' key doesn't exist even though it does - renderingComponentName = "UNHAPPY MKNAM" -- getComponentName(workInProgress.type) - else - renderingComponentName = "Unknown" - end - -- Dedupe by the rendering component because it's the one that needs to be fixed. - local dedupeKey = renderingComponentName - -- deviation: - -- if !didWarnAboutUpdateInRenderForAnotherComponent.has(dedupeKey)) - if didWarnAboutUpdateInRenderForAnotherComponent[dedupeKey] == nil then - didWarnAboutUpdateInRenderForAnotherComponent[dedupeKey] = true - local setStateComponentName = getComponentName(fiber.type) or "Unknown" - console.error( - "Cannot update a component (`%s`) while rendering a " .. - "different component (`%s`). To locate the bad setState() call inside `%s`, " .. - "follow the stack trace as described in https://reactjs.org/link/setstate-in-render", - setStateComponentName, - renderingComponentName, - renderingComponentName - ) - end - elseif fiber.tag == ReactWorkTags.ClassComponent then - if not didWarnAboutUpdateInRender then - console.error( - "Cannot update during an existing state transition (such as " .. - "within `render`). Render methods should be a pure " .. - "function of props and state." - ) - didWarnAboutUpdateInRender = true - end - end - end - end +mod.warnAboutRenderPhaseUpdatesInDEV = function(fiber: Fiber): () + if __DEV__ then + if + ReactCurrentFiber.isRendering + and bit32.band(executionContext, RenderContext) ~= NoContext + and not getIsUpdatingOpaqueValueInRenderPhaseInDEV() + then + if + fiber.tag == ReactWorkTags.FunctionComponent + or fiber.tag == ReactWorkTags.ForwardRef + or fiber.tag == ReactWorkTags.SimpleMemoComponent + then + local renderingComponentName = (function() + return workInProgress + and getComponentName((workInProgress :: Fiber).type) + or "Unknown" + end)() + -- Dedupe by the rendering component because it's the one that needs to be fixed. + local dedupeKey = renderingComponentName + -- ROBLOX deviation: + -- if !didWarnAboutUpdateInRenderForAnotherComponent.has(dedupeKey)) + if didWarnAboutUpdateInRenderForAnotherComponent[dedupeKey] == nil then + didWarnAboutUpdateInRenderForAnotherComponent[dedupeKey] = true + local setStateComponentName = getComponentName(fiber.type) + or "Unknown" + console.error( + "Cannot update a component (`%s`) while rendering a " + .. "different component (`%s`). To locate the bad setState() call inside `%s`, " + .. "follow the stack trace as described in https://reactjs.org/link/setstate-in-render", + setStateComponentName, + renderingComponentName, + renderingComponentName + ) + end + elseif fiber.tag == ReactWorkTags.ClassComponent then + if not didWarnAboutUpdateInRender then + console.error( + "Cannot update during an existing state transition (such as " + .. "within `render`). Render methods should be a pure " + .. "function of props and state." + ) + didWarnAboutUpdateInRender = true + end + end + end + end end -- a 'shared' variable that changes when act() opens/closes in tests. exports.IsThisRendererActing = { current = false } exports.warnIfNotScopedWithMatchingAct = function(fiber: Fiber) - if _G.__DEV__ then - if - ReactFiberHostConfig.warnsIfNotActing == true and - IsSomeRendererActing.current == true and - exports.IsThisRendererActing.current ~= true - then - local previousFiber = ReactCurrentFiber.current - local ok, result = pcall(function() - setCurrentDebugFiberInDEV(fiber) - -- deviation: error modified to suggest proper lua instead of JS - - -- FIXME (roblox): return to this when we have a better idea of what it - -- looks like; in React, this string is broken up oddly to avoid - -- confusing tools, but we don't have any reason to do that right now - console.error( - "It looks like you're using the wrong act() around your test interactions.\n" .. - "Be sure to use the matching version of act() corresponding to your renderer:\n\n" .. - "-- for react-roblox:\n" .. - "local React = require(Packages.React)\n" .. - "-- ...\n" .. - "React.TestUtils.act(function() ... end)\n\n" .. - "-- for react-test-renderer:\n" .. - "local TestRenderer = require(Packages.ReactTestRenderer)\n" .. - "-- ...\n" .. - "TestRenderer.act(function() ... end)" - ) - end) - - -- finally - if previousFiber then - setCurrentDebugFiberInDEV(fiber) - else - resetCurrentDebugFiberInDEV() - end - - if not ok then - error(result) - end - end - end + if __DEV__ then + if + ReactFiberHostConfig.warnsIfNotActing == true + and IsSomeRendererActing.current == true + and exports.IsThisRendererActing.current ~= true + then + local previousFiber = ReactCurrentFiber.current + local ok, result = pcall(function() + setCurrentDebugFiberInDEV(fiber) + -- deviation: error modified to suggest proper lua instead of JS + + -- FIXME (roblox): return to this when we have a better idea of what it + -- looks like; in React, this string is broken up oddly to avoid + -- confusing tools, but we don't have any reason to do that right now + console.error( + "It looks like you're using the wrong act() around your test interactions.\n" + .. "Be sure to use the matching version of act() corresponding to your renderer:\n\n" + .. "-- for react-roblox:\n" + .. "local React = require(Packages.React)\n" + .. "-- ...\n" + .. "React.TestUtils.act(function() ... end)\n\n" + .. "-- for react-test-renderer:\n" + .. "local TestRenderer = require(Packages.ReactTestRenderer)\n" + .. "-- ...\n" + .. "TestRenderer.act(function() ... end)" + ) + end) + + -- finally + if previousFiber then + setCurrentDebugFiberInDEV(fiber) + else + resetCurrentDebugFiberInDEV() + end + + if not ok then + error(result) + end + end + end end - exports.warnIfNotCurrentlyActingEffectsInDEV = function(fiber: Fiber): () - if _G.__DEV__ then - if - warnsIfNotActing == true and - bit32.band(fiber.mode, ReactTypeOfMode.StrictMode) ~= ReactTypeOfMode.NoMode and - IsSomeRendererActing.current == false and - exports.IsThisRendererActing.current == false - then - console.error( - 'An update to %s ran an effect, but was not wrapped in act(...).\n\n' .. - 'When testing, code that causes React state updates should be ' .. - 'wrapped into act(...):\n\n' .. - 'act(() => {\n' .. - ' --[[ fire events that update state ]]\n' .. - '});\n' .. - '--[[ assert on the output ]]\n\n' .. - "This ensures that you're testing the behavior the user would see " .. - 'in the browser.' .. - ' Learn more at https://reactjs.org/link/wrap-tests-with-act', - getComponentName(fiber.type) - ) - end - end + if __DEV__ then + if + ReactFiberHostConfig.warnsIfNotActing == true + and bit32.band(fiber.mode, ReactTypeOfMode.StrictMode) ~= ReactTypeOfMode.NoMode + and IsSomeRendererActing.current == false + and exports.IsThisRendererActing.current == false + then + -- ROBLOX deviation: Use Lua syntax for example fix + console.error( + "An update to %s ran an effect, but was not wrapped in act(...).\n\n" + .. "When testing, code that causes React state updates should be " + .. "wrapped into act(...):\n\n" + .. "act(function()\n" + .. " --[[ fire events that update state ]]\n" + .. "end)\n" + .. "--[[ assert on the output ]]\n\n" + .. "This ensures that you're testing the behavior the user would see " + .. "in the real client." + .. " Learn more at https://reactjs.org/link/wrap-tests-with-act", + getComponentName(fiber.type) + ) + end + end end -exports.warnIfNotCurrentlyActingUpdatesInDEV = function (fiber: Fiber): () - if _G.__DEV__ then - if - warnsIfNotActing == true and - executionContext == NoContext and - IsSomeRendererActing.current == false and - exports.IsThisRendererActing.current == false then - - local previousFiber = ReactCurrentFiberCurrent - local ok, result = pcall(function() - setCurrentDebugFiberInDEV(fiber) - console.error( - 'An update to %s inside a test was not wrapped in act(...).\n\n' .. - 'When testing, code that causes React state updates should be ' .. - 'wrapped into act(...):\n\n' .. - 'act(() => {\n' .. - ' --[[ fire events that update state ]]\n' .. - '});\n' .. - '--[[ assert on the output ]]\n\n' .. - "This ensures that you're testing the behavior the user would see " .. - 'in the browser.' .. - ' Learn more at https://reactjs.org/link/wrap-tests-with-act', - getComponentName(fiber.type) - ) - end) - - -- Finally - if previousFiber then - setCurrentDebugFiberInDEV(fiber) - else - resetCurrentDebugFiberInDEV() - end - - if ok then - return result - end - end - end - -- ROBLOX deviation: explicit return to silence analyze - return +exports.warnIfNotCurrentlyActingUpdatesInDEV = function(fiber: Fiber): () + if __DEV__ then + if + ReactFiberHostConfig.warnsIfNotActing == true + and executionContext == NoContext + and IsSomeRendererActing.current == false + and exports.IsThisRendererActing.current == false + then + local previousFiber = ReactCurrentFiberCurrent + local ok, result = pcall(function() + setCurrentDebugFiberInDEV(fiber) + -- ROBLOX deviation: Use Lua syntax for example fix + console.error( + "An update to %s inside a test was not wrapped in act(...).\n\n" + .. "When testing, code that causes React state updates should be " + .. "wrapped into act(...):\n\n" + .. "act(function()\n" + .. " --[[ fire events that update state ]]\n" + .. "end)\n" + .. "--[[ assert on the output ]]\n\n" + .. "This ensures that you're testing the behavior the user would see " + .. "in the client application." + .. " Learn more at https://reactjs.org/link/wrap-tests-with-act", + getComponentName(fiber.type) + ) + end) + + -- Finally + if previousFiber then + setCurrentDebugFiberInDEV(fiber) + else + resetCurrentDebugFiberInDEV() + end + + if ok then + return result + end + end + end + -- ROBLOX deviation: explicit return to silence analyze + return end -- In tests, we want to enforce a mocked scheduler. @@ -3728,252 +3667,224 @@ local didWarnAboutUnmockedScheduler = false -- to get their tests right. exports.warnIfUnmockedScheduler = function(fiber: Fiber) - if _G.__DEV__ then - if - didWarnAboutUnmockedScheduler == false and - Scheduler.unstable_flushAllWithoutAsserting == nil - then - if bit32.band(fiber.mode, ReactTypeOfMode.BlockingMode) ~= 0 or bit32.band(fiber.mode, ReactTypeOfMode.ConcurrentMode) ~= 0 then - didWarnAboutUnmockedScheduler = true - -- deviation: error modified to suggest proper lua instead of JS - - -- FIXME (roblox): return to this when jest-roblox is in use; in React, - -- this string is broken up oddly to avoid confusing tools, but we don't - -- have any reason to do that right now - console.error( - "In Concurrent or Sync modes, the 'scheduler' module needs to be mocked " .. - "to guarantee consistent behaviour across tests and browsers. " .. - "For example, with RobloxJest: \n" .. - -- Break up requires to avoid accidentally parsing them as dependencies. - "RobloxJest.mock('scheduler', function() return require('Scheduler.unstable_mock') end)\n\n" .. - "For more info, visit https://reactjs.org/link/mock-scheduler" - ) - elseif ReactFeatureFlags.warnAboutUnmockedScheduler == true then - didWarnAboutUnmockedScheduler = true - -- deviation: error modified to suggest proper lua instead of JS - - -- FIXME (roblox): return to this when jest-roblox is in use; in React, - -- this string is broken up oddly to avoid confusing tools, but we don't - -- have any reason to do that right now - console.error( - "Starting from React v18, the 'scheduler' module will need to be mocked " .. - "to guarantee consistent behaviour across tests and browsers. " .. - "For example, with RobloxJest: \n" .. - -- Break up requires to avoid accidentally parsing them as dependencies. - "RobloxJest.mock('scheduler', function() return require('Scheduler.unstable_mock') end)\n\n" .. - "For more info, visit https://reactjs.org/link/mock-scheduler" - ) - end - end - end + if __DEV__ then + if + didWarnAboutUnmockedScheduler == false + and Scheduler.unstable_flushAllWithoutAsserting == nil + then + if + bit32.band(fiber.mode, ReactTypeOfMode.BlockingMode) ~= 0 + or bit32.band(fiber.mode, ReactTypeOfMode.ConcurrentMode) ~= 0 + then + didWarnAboutUnmockedScheduler = true + -- deviation: error modified to suggest proper lua instead of JS + + -- FIXME (roblox): return to this when jest-roblox is in use; in React, + -- this string is broken up oddly to avoid confusing tools, but we don't + -- have any reason to do that right now + console.error( + "In Concurrent or Sync modes, the 'scheduler' module needs to be mocked " + .. "to guarantee consistent behaviour across tests and client application. " + .. "For example, with Jest: \n" + -- Break up requires to avoid accidentally parsing them as dependencies. + .. "jest.mock('scheduler', function() return require(Packages.Scheduler).unstable_mock end)\n\n" + .. "For more info, visit https://reactjs.org/link/mock-scheduler" + ) + elseif ReactFeatureFlags.warnAboutUnmockedScheduler == true then + didWarnAboutUnmockedScheduler = true + -- deviation: error modified to suggest proper lua instead of JS + + -- FIXME (roblox): return to this when jest-roblox is in use; in React, + -- this string is broken up oddly to avoid confusing tools, but we don't + -- have any reason to do that right now + console.error( + "Starting from React v18, the 'scheduler' module will need to be mocked " + .. "to guarantee consistent behaviour across tests and client applications. " + .. "For example, with Jest: \n" + -- Break up requires to avoid accidentally parsing them as dependencies. + .. "jest.mock('scheduler', function() return require(Packages.Scheduler).unstable_mock end)\n\n" + .. "For more info, visit https://reactjs.org/link/mock-scheduler" + ) + end + end + end end -function computeThreadID(root: ReactInternalTypes.FiberRoot, lane: Lane | Lanes) - -- Interaction threads are unique per root and expiration time. - -- NOTE: Intentionally unsound cast. All that matters is that it's a number - -- and it represents a batch of work. Could make a helper function instead, - -- but meh this is fine for now. - return lane * 1000 + root.interactionThreadID +function computeThreadID(root: FiberRoot, lane: Lane | Lanes) + -- Interaction threads are unique per root and expiration time. + -- NOTE: Intentionally unsound cast. All that matters is that it's a number + -- and it represents a batch of work. Could make a helper function instead, + -- but meh this is fine for now. + return lane * 1000 + root.interactionThreadID end exports.markSpawnedWork = function(lane: Lane | Lanes) - if not ReactFeatureFlags.enableSchedulerTracing then - return - end - if spawnedWorkDuringRender == nil then - spawnedWorkDuringRender = {lane} - else - -- ROBLOX TODO: Luau type narrowing workaround - table.insert(spawnedWorkDuringRender :: Array, lane) - end + if not ReactFeatureFlags.enableSchedulerTracing then + return + end + if spawnedWorkDuringRender == nil then + spawnedWorkDuringRender = { lane } + else + -- ROBLOX FIXME Luau: depends on Luau type states + table.insert(spawnedWorkDuringRender :: Array, lane) + end end function scheduleInteractions( - root: ReactInternalTypes.FiberRoot, - lane: Lane | Lanes, - interactions: Set + root: FiberRoot, + lane: Lane | Lanes, + interactions: Set ) - if not ReactFeatureFlags.enableSchedulerTracing then - return - end - - if collectionHasEntries(interactions) then - local pendingInteractionMap = root.pendingInteractionMap - local pendingInteractions = pendingInteractionMap[lane] - if pendingInteractions ~= nil then - if interactions.ipairs ~= nil then - for _, interaction in interactions:ipairs() do - if not pendingInteractions[interaction] then - -- Update the pending async work count for previously unscheduled interaction. - interaction.__count += 1 - end - - pendingInteractions[interaction] = true - end - else - for _, interaction in ipairs(interactions :: any) do - if not pendingInteractions[interaction] then - -- Update the pending async work count for previously unscheduled interaction. - interaction.__count += 1 - end - - pendingInteractions[interaction] = true - end - end - else - pendingInteractionMap[lane] = copySet(interactions) - - -- Update the pending async work count for the current interactions. - if interactions.ipairs ~= nil then - for _, interaction in interactions:ipairs() do - interaction.__count += 1 - end - else - for _, interaction in ipairs(interactions :: any) do - interaction.__count += 1 - end - end - end - - local subscriber = __subscriberRef.current - if subscriber ~= nil then - local threadID = computeThreadID(root, lane) - subscriber.onWorkScheduled(interactions, threadID) - end - end + if not ReactFeatureFlags.enableSchedulerTracing then + return + end + + if interactions.size > 0 then + local pendingInteractionMap = root.pendingInteractionMap + local pendingInteractions = pendingInteractionMap:get(lane) + if pendingInteractions ~= nil then + interactions:forEach(function(interaction) + if not pendingInteractions:has(interaction) then + -- Update the pending async work count for previously unscheduled interaction. + interaction.__count += 1 + end + + pendingInteractions:add(interaction) + end) + else + pendingInteractionMap:set(lane, Set.new(interactions)) + + -- Update the pending async work count for the current interactions. + for _, interaction in interactions do + interaction.__count += 1 + end + end + + local subscriber = __subscriberRef.current + if subscriber ~= nil then + local threadID = computeThreadID(root, lane) + subscriber.onWorkScheduled(interactions, threadID) + end + end end -mod.schedulePendingInteractions = function(root: ReactInternalTypes.FiberRoot, lane: Lane | Lanes) - -- This is called when work is scheduled on a root. - -- It associates the current interactions with the newly-scheduled expiration. - -- They will be restored when that expiration is later committed. - if not ReactFeatureFlags.enableSchedulerTracing then - return - end +mod.schedulePendingInteractions = function(root: FiberRoot, lane: Lane | Lanes) + -- This is called when work is scheduled on a root. + -- It associates the current interactions with the newly-scheduled expiration. + -- They will be restored when that expiration is later committed. + if not ReactFeatureFlags.enableSchedulerTracing then + return + end - scheduleInteractions(root, lane, __interactionsRef.current) + scheduleInteractions(root, lane, __interactionsRef.current) end -mod.startWorkOnPendingInteractions = function(root: ReactInternalTypes.FiberRoot, lanes: Lanes) - -- This is called when new work is started on a root. - if not ReactFeatureFlags.enableSchedulerTracing then - return - end - - -- Determine which interactions this batch of work currently includes, So that - -- we can accurately attribute time spent working on it, And so that cascading - -- work triggered during the render phase will be associated with it. - local interactions: Set = Set.new() - for scheduledLane, scheduledInteractions in pairs(root.pendingInteractionMap) do - if includesSomeLane(lanes, scheduledLane) then - for _, interaction in scheduledInteractions:ipairs() do - interactions:add(interaction) - end - end - end - - -- Store the current set of interactions on the ReactInternalTypes.FiberRoot for a few reasons: - -- We can re-use it in hot functions like performConcurrentWorkOnRoot() - -- without having to recalculate it. We will also use it in commitWork() to - -- pass to any Profiler onRender() hooks. This also provides DevTools with a - -- way to access it when the onCommitRoot() hook is called. - -- ROBLOX FIXME: manual type check to workaround Luau analyze bug "Type 'Set' could not be converted into 'Set'" - root.memoizedInteractions = interactions :: any - - if collectionHasEntries(interactions) then - local subscriber = __subscriberRef.current - if subscriber ~= nil then - local threadID = computeThreadID(root, lanes) - local ok, error_ = pcall(function() - subscriber.onWorkStarted(interactions, threadID) - end) - if not ok then - -- If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediateSchedulerPriority, function() - error(error_) - end) - end - end - end -end +mod.startWorkOnPendingInteractions = function(root: FiberRoot, lanes: Lanes) + -- This is called when new work is started on a root. + if not ReactFeatureFlags.enableSchedulerTracing then + return + end -mod.finishPendingInteractions = function(root, committedLanes) - if not ReactFeatureFlags.enableSchedulerTracing then - return - end - - local remainingLanesAfterCommit = root.pendingLanes - - local subscriber - - -- ROBLOX try - local ok, error_ = pcall(function() - subscriber = __subscriberRef.current - -- ROBLOX deviation: helper for raw table set/map size > 0 - if subscriber ~= nil and collectionHasEntries(root.memoizedInteractions) then - -- FIXME: More than one lane can finish in a single commit. - local threadID = computeThreadID(root, committedLanes) - subscriber.onWorkStopped(root.memoizedInteractions, threadID) - end - end) - - -- ROBLOX finally - -- Clear completed interactions from the pending Map. - -- Unless the render was suspended or cascading work was scheduled, - -- In which case– leave pending interactions until the subsequent render. - local pendingInteractionMap = root.pendingInteractionMap - for lane, scheduledInteractions in pairs(pendingInteractionMap) do - -- Only decrement the pending interaction count if we're done. - -- If there's still work at the current priority, - -- That indicates that we are waiting for suspense data. - if not includesSomeLane(remainingLanesAfterCommit, lane) then - pendingInteractionMap[lane] = nil - - if scheduledInteractions.size == 0 then - continue - end - if scheduledInteractions.ipairs ~= nil then - for _, interaction in scheduledInteractions:ipairs() do - interaction.__count -= 1 - - if subscriber ~= nil and interaction.__count == 0 then - local ok_, error__ = pcall(function() - subscriber.onInteractionScheduledWorkCompleted(interaction) - end) - if not ok_ then - -- If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediateSchedulerPriority, function() - error(error__) - end) - end - end - end - else - for _, interaction in ipairs(scheduledInteractions) do - interaction.__count -= 1 - - if subscriber ~= nil and interaction.__count == 0 then - local ok_, error__ = pcall(function() - subscriber.onInteractionScheduledWorkCompleted(interaction) - end) - if not ok_ then - -- If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediateSchedulerPriority, function() - error(error__) - end) - end - end - end - end - end + -- Determine which interactions this batch of work currently includes, So that + -- we can accurately attribute time spent working on it, And so that cascading + -- work triggered during the render phase will be associated with it. + local interactions: Set = Set.new() + root.pendingInteractionMap:forEach(function(scheduledInteractions, scheduledLane) + if includesSomeLane(lanes, scheduledLane) then + scheduledInteractions:forEach(function(interaction) + interactions:add(interaction) + end) + end + end) + + -- Store the current set of interactions on the ReactInternalTypes.FiberRoot for a few reasons: + -- We can re-use it in hot functions like performConcurrentWorkOnRoot() + -- without having to recalculate it. We will also use it in commitWork() to + -- pass to any Profiler onRender() hooks. This also provides DevTools with a + -- way to access it when the onCommitRoot() hook is called. + -- ROBLOX FIXME: manual type check to workaround Luau analyze bug "Type 'Set' could not be converted into 'Set'" + root.memoizedInteractions = interactions :: any + + if interactions.size > 0 then + local subscriber = __subscriberRef.current + if subscriber ~= nil then + local threadID = computeThreadID(root, lanes) + local ok, error_ = + xpcall(subscriber.onWorkStarted, describeError, interactions, threadID) + if not ok then + -- If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediateSchedulerPriority, function() + error(error_) + end) + end + end + end end - -- ROBLOX catch - if not ok then - -- If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediateSchedulerPriority, function() - error(error_) - end) - end + +mod.finishPendingInteractions = function(root: FiberRoot, committedLanes) + if not ReactFeatureFlags.enableSchedulerTracing then + return + end + + local remainingLanesAfterCommit = root.pendingLanes + + local subscriber + + -- ROBLOX try + local ok = true + local error_ + if subscriber ~= nil and root.memoizedInteractions.size > 0 then + -- FIXME: More than one lane can finish in a single commit. + -- ROBLOX peformance: hoist non-throwable things out of the pcall() so we can remove an anon function + local threadID = computeThreadID(root, committedLanes) + subscriber = __subscriberRef.current + -- ROBLOX deviation: helper for raw table set/map size > 0 + ok, error_ = xpcall( + subscriber.onWorkStopped, + describeError, + root.memoizedInteractions, + threadID + ) + end + + -- ROBLOX finally + -- Clear completed interactions from the pending Map. + -- Unless the render was suspended or cascading work was scheduled, + -- In which case– leave pending interactions until the subsequent render. + local pendingInteractionMap = root.pendingInteractionMap + pendingInteractionMap:forEach(function(scheduledInteractions, lane) + -- Only decrement the pending interaction count if we're done. + -- If there's still work at the current priority, + -- That indicates that we are waiting for suspense data. + if not includesSomeLane(remainingLanesAfterCommit, lane) then + pendingInteractionMap:delete(lane) + scheduledInteractions:forEach(function(interaction) + interaction.__count -= 1 + + if subscriber ~= nil and interaction.__count == 0 then + local ok_, error__ = xpcall( + subscriber.onInteractionScheduledWorkCompleted, + describeError, + interaction + ) + if not ok_ then + -- If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediateSchedulerPriority, function() + error(error__) + end) + end + end + end) + end + end) + + -- ROBLOX catch + if not ok then + -- If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediateSchedulerPriority, function() + error(error_) + end) + end end -- `act` testing API @@ -3990,228 +3901,230 @@ local isSchedulerMocked = typeof(flushMockScheduler) == "function" -- Returns whether additional work was scheduled. Caller should keep flushing -- until there's no work left. local function flushActWork(): boolean - if flushMockScheduler ~= nil then - local prevIsFlushing = isFlushingAct - isFlushingAct = true - local ok, result = pcall(function() - return flushMockScheduler() - end) - - -- finally - isFlushingAct = prevIsFlushing - - if not ok then - error(result) - else - return result - end - else - -- No mock scheduler available. However, the only type of pending work is - -- passive effects, which we control. So we can flush that. - local prevIsFlushing = isFlushingAct - isFlushingAct = true - local ok, result = pcall(function() - local didFlushWork = false - while exports.flushPassiveEffects() do - didFlushWork = true - end - return didFlushWork - end) - - -- finally - isFlushingAct = prevIsFlushing - - if not ok then - error(result) - else - return result - end - end + if flushMockScheduler ~= nil then + local prevIsFlushing = isFlushingAct + isFlushingAct = true + local ok, result = xpcall(flushMockScheduler, describeError) + + -- finally + isFlushingAct = prevIsFlushing + + if not ok then + error(result) + else + return result + end + else + -- No mock scheduler available. However, the only type of pending work is + -- passive effects, which we control. So we can flush that. + local prevIsFlushing = isFlushingAct + isFlushingAct = true + -- ROBLOX performance? rewrite this loop to eliminate anon function? + local ok, result = xpcall(function() + local didFlushWork = false + while exports.flushPassiveEffects() do + didFlushWork = true + end + return didFlushWork + end, describeError) + + -- finally + isFlushingAct = prevIsFlushing + + if not ok then + error(result) + else + return result + end + end end local function flushWorkAndMicroTasks(onDone: (any?) -> ()) - local ok, result = pcall(function() - flushActWork() - enqueueTask(function() - if flushActWork() then - flushWorkAndMicroTasks(onDone) - else - onDone() - end - end) - end) - - if not ok then - onDone(result) - end + -- ROBLOX performance: split into two pcall to eliminate anonymous func allocation per call + local ok, result = xpcall(flushActWork, describeError) + if ok then + ok, result = xpcall(enqueueTask, describeError, function() + if flushActWork() then + flushWorkAndMicroTasks(onDone) + else + onDone() + end + end) + end + + if not ok then + onDone(result) + end end -exports.act = function(callback: () -> Thenable): Thenable - if not _G.__DEV__ then - if didWarnAboutUsingActInProd == false then - didWarnAboutUsingActInProd = true - -- eslint-disable-next-line react-internal/no-production-logging - console.error( - "act(...) is not supported in production builds of React, and might not behave as expected." - ) - end - end - - local previousActingUpdatesScopeDepth = actingUpdatesScopeDepth - actingUpdatesScopeDepth += 1 - - local previousIsSomeRendererActing = IsSomeRendererActing.current - local previousIsThisRendererActing = exports.IsThisRendererActing.current - local previousIsInsideThisAct = isInsideThisAct - IsSomeRendererActing.current = true - exports.IsThisRendererActing.current = true - isInsideThisAct = true - - local function onDone() - actingUpdatesScopeDepth -= 1 - IsSomeRendererActing.current = previousIsSomeRendererActing - exports.IsThisRendererActing.current = previousIsThisRendererActing - isInsideThisAct = previousIsInsideThisAct - if _G.__DEV__ then - if actingUpdatesScopeDepth > previousActingUpdatesScopeDepth then - -- 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. " - ) - end - end - end - - local ok, result = pcall(function() - return exports.batchedUpdates(callback) - end) - if not ok then - onDone() - error(result) - end - - if - result ~= nil and - typeof(result) == "table" and - typeof(result.andThen) == 'function' - then - -- setup a boolean that gets set to true only - -- once this act() call is await-ed - local called = false - if _G.__DEV__ then - if typeof(Promise) ~= nil then - --eslint-disable-next-line no-undef - Promise.resolve() - :andThen(function() end) - :andThen(function() - if called == false then - -- FIXME (roblox): We should replace this with proper Lua promise - -- logic - console.error( - "You called act(async () => ...) without await. " .. - "This could lead to unexpected testing behaviour, interleaving multiple act " .. - "calls and mixing their scopes. You should - await act(async () => ...);" - ) - end - end) - end - end - - -- in the async case, the returned thenable runs the callback, flushes - -- effects and microtasks in a loop until flushPassiveEffects() == false, - -- and cleans up - return { - andThen = function(self, resolve, reject) - called = true - result:andThen( - function() - if - actingUpdatesScopeDepth > 1 or - (isSchedulerMocked == true and - previousIsSomeRendererActing == true) - then - onDone() - resolve() - return - end - -- we're about to exit the act() scope, - -- now's the time to flush tasks/effects - flushWorkAndMicroTasks(function(err: any?) - onDone() - if err then - reject(err) - else - resolve() - end - end) - end, - function(err) - onDone() - reject(err) - end - ) - end, - } - else - if _G.__DEV__ then - if result ~= nil then - console.error( - "The callback passed to act(...) function " .. - "must return undefined, or a Promise. You returned %s", - result - ) - end - end - - -- flush effects until none remain, and cleanup - local flushOk, flushResult = pcall(function() - if - actingUpdatesScopeDepth == 1 and - (isSchedulerMocked == false or previousIsSomeRendererActing == false) - then - -- we're about to exit the act() scope, - -- now's the time to flush effects - flushActWork() - end - onDone() - end) - - if not flushOk then - onDone() - error(flushResult) - end - - -- in the sync case, the returned thenable only warns *if* await-ed - return { - andThen = function(self, resolve) - if _G.__DEV__ then - console.error( - "Do not await the result of calling act(...) with sync logic, it is not a Promise." - ) - end - resolve() - end, - } - end +exports.act = function(callback: () -> Thenable): Thenable + -- It's only viable to export `act` when we're using mocked scheduling logic. + -- Since there are numerous testing scenarios in which we call `require` on + -- the Roact library _before_ we bootstrap tests, we expose a global to toggle + -- this explicilty + if not (__DEV__ or ReactGlobals.__ROACT_17_MOCK_SCHEDULER__) then + if didWarnAboutUsingActInProd == false then + didWarnAboutUsingActInProd = true + -- eslint-disable-next-line react-internal/no-production-logging + console.error( + "act(...) is not supported in production builds of React, and might not behave as expected." + ) + end + end + + local previousActingUpdatesScopeDepth = actingUpdatesScopeDepth + actingUpdatesScopeDepth += 1 + + local previousIsSomeRendererActing = IsSomeRendererActing.current + local previousIsThisRendererActing = exports.IsThisRendererActing.current + local previousIsInsideThisAct = isInsideThisAct + IsSomeRendererActing.current = true + exports.IsThisRendererActing.current = true + isInsideThisAct = true + + local function onDone() + actingUpdatesScopeDepth -= 1 + IsSomeRendererActing.current = previousIsSomeRendererActing + exports.IsThisRendererActing.current = previousIsThisRendererActing + isInsideThisAct = previousIsInsideThisAct + if __DEV__ then + if actingUpdatesScopeDepth > previousActingUpdatesScopeDepth then + -- 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. " + ) + end + end + end + + local ok, result = xpcall(exports.batchedUpdates, describeError, callback) + if not ok then + onDone() + error(result) + end + + if + result ~= nil + and typeof(result) == "table" + and typeof(result.andThen) == "function" + then + -- setup a boolean that gets set to true only + -- once this act() call is await-ed + local called = false + if __DEV__ then + if typeof(Promise) ~= nil then + --eslint-disable-next-line no-undef + Promise.resolve():andThen(function() end):andThen(function() + if called == false then + -- FIXME (roblox): We should replace this with proper Lua promise + -- logic + console.error( + "You called act(Promise.new(function() --[[ ... ]] end)) without :await() or :expect(). " + .. "This could lead to unexpected testing behaviour, interleaving multiple act " + .. "calls and mixing their scopes. You should - act(function() Promise.new(function() --[[ ... ]] end):await() end);" + ) + end + end) + end + end + + -- in the async case, the returned thenable runs the callback, flushes + -- effects and microtasks in a loop until flushPassiveEffects() == false, + -- and cleans up + return { + -- ROBLOX FIXME Luau: have to explicitly annotate the unused generic arg: CLI-49996 + andThen = function(self, resolve, reject) + called = true + return result:andThen(function() + if + actingUpdatesScopeDepth > 1 + or ( + isSchedulerMocked == true + and previousIsSomeRendererActing == true + ) + then + onDone() + resolve() + return + end + -- we're about to exit the act() scope, + -- now's the time to flush tasks/effects + flushWorkAndMicroTasks(function(err: any?) + onDone() + if err then + reject(err) + else + resolve() + end + end) + end, function(err) + onDone() + reject(err) + end) + end, + } + else + if __DEV__ then + if result ~= nil then + -- ROBLOX deviation: use Lua syntax + console.error( + "The callback passed to act(...) function " + .. "must return nil, or a Promise. You returned %s", + tostring(result) + ) + end + end + + -- flush effects until none remain, and cleanup + local flushOk, flushResult = xpcall(function() + if + actingUpdatesScopeDepth == 1 + and (isSchedulerMocked == false or previousIsSomeRendererActing == false) + then + -- we're about to exit the act() scope, + -- now's the time to flush effects + flushActWork() + end + onDone() + end, describeError) + + if not flushOk then + onDone() + error(flushResult) + end + + -- in the sync case, the returned thenable only warns *if* await-ed + return { + -- ROBLOX FIXME Luau: have to explicitly annotate the unused generic arg: CLI-49996 + andThen = function(self, resolve, reject_) + if __DEV__ then + console.error( + "Do not await the result of calling act(...) with sync logic, it is not a Promise." + ) + end + resolve() + end, + } + end end mod.detachFiberAfterEffects = function(fiber: Fiber) - -- Null out fields to improve GC for references that may be lingering (e.g. DevTools). - -- Note that we already cleared the return pointer in detachFiberMutation(). - fiber.child = nil - fiber.deletions = nil - fiber.dependencies = nil - fiber.memoizedProps = nil - fiber.memoizedState = nil - fiber.pendingProps = nil - fiber.sibling = nil - fiber.stateNode = nil - fiber.updateQueue = nil - - if _G.__DEV__ then - fiber._debugOwner = nil - end + -- Null out fields to improve GC for references that may be lingering (e.g. DevTools). + -- Note that we already cleared the return pointer in detachFiberMutation(). + fiber.child = nil + fiber.deletions = nil + fiber.dependencies = nil + fiber.memoizedProps = nil + fiber.memoizedState = nil + fiber.pendingProps = nil + fiber.sibling = nil + fiber.stateNode = nil + fiber.updateQueue = nil + + if __DEV__ then + fiber._debugOwner = nil + end end return exports diff --git a/modules/react-reconciler/src/ReactHookEffectTags.lua b/modules/react-reconciler/src/ReactHookEffectTags.lua index ecca23c3..2a768be8 100644 --- a/modules/react-reconciler/src/ReactHookEffectTags.lua +++ b/modules/react-reconciler/src/ReactHookEffectTags.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactHookEffectTags.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactHookEffectTags.js --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -12,12 +12,16 @@ export type HookFlags = number return { - NoFlags = --[[ ]] 0b000, + --[[ ]] + NoFlags = 0b000, - -- Represents whether effect should fire. - HasEffect = --[[ ]] 0b001, + -- Represents whether effect should fire. + --[[ ]] + HasEffect = 0b001, - -- Represents the phase in which the effect (not the clean-up) fires. - Layout = --[[ ]] 0b010, - Passive = --[[ ]] 0b100, + -- Represents the phase in which the effect (not the clean-up) fires. + --[[ ]] + Layout = 0b010, + --[[ ]] + Passive = 0b100, } diff --git a/modules/react-reconciler/src/ReactInternalTypes.lua b/modules/react-reconciler/src/ReactInternalTypes.lua index 025a1a04..341a8d1e 100644 --- a/modules/react-reconciler/src/ReactInternalTypes.lua +++ b/modules/react-reconciler/src/ReactInternalTypes.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/7baf9d4128d41903de125527b50285ea9862cf9a/packages/react-reconciler/src/ReactInternalTypes.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/7baf9d4128d41903de125527b50285ea9862cf9a/packages/react-reconciler/src/ReactInternalTypes.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,6 +10,13 @@ ]] local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Array = LuauPolyfill.Array +type Map = LuauPolyfill.Map +type Object = { [string]: any } +type SimpleSet = { [T]: boolean } +type SimpleMap = { [K]: V } +type Set = LuauPolyfill.Set local ReactTypes = require(Packages.Shared) -- ROBLOX deviation: ReactElement is defined at the top level of Shared along @@ -18,91 +26,117 @@ type RefObject = ReactTypes.RefObject type ReactContext = ReactTypes.ReactContext type MutableSourceVersion = ReactTypes.MutableSourceVersion type MutableSource = ReactTypes.MutableSource --- type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn; --- type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn; - --- ROBLOX deviation: These are 'mixed' by default, and specialized by the renderer, need complicated dynamic resolution to do this properly +type MutableSourceSubscribeFn = ReactTypes.MutableSourceSubscribeFn< + Source, + Snapshot +> +type MutableSourceGetSnapshotFn = ReactTypes.MutableSourceGetSnapshotFn< + Source, + Snapshot +> + +-- ROBLOX deviation START: These are 'mixed' by default, and specialized by the renderer, need complicated dynamic resolution to do this properly -- local ReactFiberHostConfig = require(script.Parent.ReactFiberHostConfig) -- type SuspenseInstance = ReactFiberHostConfig.SuspenseInstance -type SuspenseInstance = any; -- FIXME (roblox): type --- type TimeoutHandle = ReactFiberHostConfig.TimeoutHandle; --- type NoTimeout = ReactFiberHostConfig.NoTimeout; -type TimeoutHandle = any; -- FIXME (roblox): type -type NoTimeout = any; -- FIXME (roblox): type - +type SuspenseInstance = any +-- ROBLOX deviation END local ReactWorkTags = require(script.Parent.ReactWorkTags) type WorkTag = ReactWorkTags.WorkTag local ReactTypeOfMode = require(script.Parent.ReactTypeOfMode) type TypeOfMode = ReactTypeOfMode.TypeOfMode local ReactFiberFlags = require(script.Parent.ReactFiberFlags) type Flags = ReactFiberFlags.Flags - --- FIXME (roblox): no support for type literals --- export type HookType = --- | 'useState' --- | 'useReducer' --- | 'useContext' --- | 'useRef' --- | 'useEffect' --- | 'useLayoutEffect' --- | 'useCallback' --- | 'useMemo' --- | 'useImperativeHandle' --- | 'useDebugValue' --- | 'useDeferredValue' --- | 'useTransition' --- | 'useMutableSource' --- | 'useOpaqueIdentifier'; -export type HookType = string; +-- deviation: FiberLane types are defined and exported from here to avoid +-- cyclical requires +export type LanePriority = number +export type Lanes = number +export type Lane = number +export type LaneMap = { [number]: T } + +-- ROBLOX deviation: Update<>, SharedQueue<>, UpdateQueue<> transplanted from ReactUpdateQueue for use by createReactNoop +export type Update = { + -- TODO: Temporary field. Will remove this by storing a map of + -- transition -> event time on the root. + eventTime: number, + lane: Lane, + + -- ROBLOX FIXME Luau: revert when luau supports singleton integers + -- tag: 0 | 1 | 2 | 3, + tag: number, + payload: any, + callback: (() -> ...any)?, + + next: Update?, +} + +export type SharedQueue = { + pending: Update?, +} + +export type UpdateQueue = { + baseState: State, + firstBaseUpdate: Update?, + lastBaseUpdate: Update?, + shared: SharedQueue, + effects: Array>?, +} + +export type HookType = + "useState" + | "useReducer" + | "useContext" + | "useRef" + -- ROBLOX deviation: Bindings are a feature unique to Roact + | "useBinding" + | "useEffect" + | "useLayoutEffect" + | "useCallback" + | "useMemo" + | "useImperativeHandle" + | "useDebugValue" + | "useDeferredValue" + | "useTransition" + | "useMutableSource" + | "useOpaqueIdentifier" local ReactRootTags = require(script.Parent.ReactRootTags) -type RootTag = ReactRootTags.RootTag; - +type RootTag = ReactRootTags.RootTag +-- ROBLOX deviation: we can't import types for dynamic imports like HostConfig files +-- type TimeoutHandle = ReactFiberHostConfig.TimeoutHandle; +-- type NoTimeout = ReactFiberHostConfig.NoTimeout; +type TimeoutHandle = any +type NoTimeout = any -- ROBLOX deviation: type forwarded to top-level export local Shared = require(Packages.Shared) -type Wakeable = Shared.Wakeable; - +type Wakeable = Shared.Wakeable -- ROBLOX deviation: Interaction type forwarded to top-level export local Scheduler = require(Packages.Scheduler) type Interaction = Scheduler.Interaction --- generic types -type Array = { [number]: T }; -type Map = { [K]: V } -type Object = { [any]: any }; -type Set = { [T]: boolean }; +-- ROBLOX deciation: Luau doesn't support type literals: 99 | 98 | 97 | 96 | 95 | 90 +export type ReactPriorityLevel = number --- deviation: FiberLane types are defined and exported from here to avoid --- cyclical requires -export type LanePriority = number; -export type Lanes = number; -export type Lane = number; -export type LaneMap = { [number]: T }; - --- export type ReactPriorityLevel = 99 | 98 | 97 | 96 | 95 | 90 -export type ReactPriorityLevel = number; - --- ROBLOX FIXME: Doesn't play nice with `next: ContextDependency` --- 1 Jul 2022 Luau analyze error: "ReactReconciler/ReactFiberNewContext.new.lua:420:7-32: (E001) Expected type table, got 'ContextDependency?' instead" --- export type ContextDependency = { --- context: ReactContext, --- next: ContextDependency?, -export type ContextDependency = { - context: ReactContext, +export type ContextDependency = { + context: ReactContext, observedBits: number, - next: ContextDependency?, - [any]: any, -}; + -- ROBLOX TODO: upstream only parameterizes this type with `mixed`, so avoid Luau recursive type constraint. upstream this change later. + next: ContextDependency | nil, + -- ROBLOX deviation START: don't allow for extension unless we have to + -- ... + -- ROBLOX deviation END +} export type Dependencies = { lanes: Lanes, - firstContext: ContextDependency?, - [any]: any, -}; + firstContext: ContextDependency | nil, + -- ROBLOX deviation START: don't allow for extension unless we have to + -- ... + -- ROBLOX deviation END +} -- ROBLOX deviation: Roact stable keys - slightly widen the type definition of a -- stable key so that it's likely to work with existing Roact code. Includes -- numbers for mixed/sparse tables -export type RoactStableKey = string | number; +export type RoactStableKey = string | number -- A Fiber is work on a Component that needs to be done or was done. There can -- be more than one per component. @@ -145,9 +179,7 @@ export type Fiber = { -- This is effectively the parent, but there can be multiple parents (two) -- so this is only the parent of the thing we're currently processing. -- It is conceptually the same as the return address of a stack frame. - -- deviation: Luau does not allow us to use `return` as a field name - -- return: Fiber | nil, - return_: Fiber?, + return_: Fiber | nil, -- Singly Linked List Tree Structure. child: Fiber?, @@ -158,7 +190,7 @@ export type Fiber = { -- I'll avoid adding an owner field for prod and model that as functions. -- ROBLOX deviation: Lua doesn't allow fields on functions -- ref: (((any) -> ()) & {_stringRef: string?, [string]: any}) | RefObject, - ref: ((any) -> ()) | {_stringRef: string?, [string]: any} | RefObject, + ref: nil | ((handle: any) -> ()) | { _stringRef: string?, [string]: any } | RefObject, -- Input is the data coming into process this fiber. Arguments. Props. pendingProps: any, -- This type will be more specific once we overload the tag. @@ -227,7 +259,7 @@ export type Fiber = { -- Conceptual aliases -- workInProgress : Fiber -> alternate The alternate used for reuse happens -- to be the same as work in progress. - -- _G.__DEV__ only + -- ReactGlobals.__DEV__ only _debugID: number?, _debugSource: Source?, _debugOwner: Fiber?, @@ -236,13 +268,15 @@ export type Fiber = { -- Used to verify that the order of hooks does not change between renders. _debugHookTypes: Array?, -}; +} export type SuspenseHydrationCallbacks = { onHydrated: ((SuspenseInstance) -> ())?, onDeleted: ((SuspenseInstance) -> ())?, - [any]: any, -}; + -- ROBLOX deviation START: don't allow for extension unless we have to + -- ... + -- ROBLOX deviation END +} -- deviation: Instead of combining all these props after the fact, we define -- them all in one to allow Luau's syntax @@ -264,7 +298,12 @@ export type FiberRoot = { -- The currently active root fiber. This is the mutable root of the tree. current: Fiber, - pingCache: Map | Map>)> | nil, + -- ROBLOX deviation START: we use a lightweight unordered set for performance + pingCache: SimpleMap< + Wakeable, + (SimpleSet | SimpleMap>) + > | nil, + -- ROBLOX deviation END -- A finished work-in-progress HostRoot that's ready to be committed. finishedWork: Fiber?, @@ -311,56 +350,16 @@ export type FiberRoot = { -- The follow fields are only used by enableSuspenseCallback for hydration. hydrationCallbacks: SuspenseHydrationCallbacks?, + -- ROBLOX deviation START: don't allow for extension unless we have to -- ... - [any]: any, -}; + -- ROBLOX deviation END +} type BasicStateAction = ((S) -> S) | S type Dispatch = (A) -> () --- export type Dispatcher = {| --- readContext( --- context: ReactContext, --- observedBits: void | number | boolean, --- ): T, --- useState(initialState: (() => S) | S): [S, Dispatch>], --- useReducer( --- reducer: (S, A) => S, --- initialArg: I, --- init?: (I) => S, --- ): [S, Dispatch], --- useContext( --- context: ReactContext, --- observedBits: void | number | boolean, --- ): T, --- useRef(initialValue: T): {|current: T|}, --- useEffect( --- create: () => (() => void) | void, --- deps: Array | void | nil, --- ): void, --- useLayoutEffect( --- create: () => (() => void) | void, --- deps: Array | void | nil, --- ): void, --- useCallback(callback: T, deps: Array | void | nil): T, --- useMemo(nextCreate: () => T, deps: Array | void | nil): T, --- useImperativeHandle( --- ref: {|current: T | nil|} | ((inst: T | nil) => mixed) | nil | void, --- create: () => T, --- deps: Array | void | nil, --- ): void, --- useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void, --- useDeferredValue(value: T): T, --- useTransition(): [(() => void) => void, boolean], --- useMutableSource( --- source: MutableSource, --- getSnapshot: MutableSourceGetSnapshotFn, --- subscribe: MutableSourceSubscribeFn, --- ): Snapshot, --- useOpaqueIdentifier(): any, - --- unstable_isNewReconciler?: boolean, --- |} +-- ROBLOX deviation: Dispatcher is defined in Shared to avoid circular deps +export type Dispatcher = Shared.Dispatcher -- deviation: Return something so that the module system is happy return {} diff --git a/modules/react-reconciler/src/ReactMutableSource.new.lua b/modules/react-reconciler/src/ReactMutableSource.new.lua index 8335bb51..ffd9cb4e 100644 --- a/modules/react-reconciler/src/ReactMutableSource.new.lua +++ b/modules/react-reconciler/src/ReactMutableSource.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactMutableSource.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactMutableSource.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,6 +9,7 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console @@ -24,78 +25,76 @@ type FiberRoot = ReactInternalTypes.FiberRoot local isPrimaryRenderer = require(script.Parent.ReactFiberHostConfig).isPrimaryRenderer --- Work in progress version numbers only apply to a single render, +-- Work in progress version_ numbers only apply to a single render, -- and should be reset before starting a new render. -- This tracks which mutable sources need to be reset after a render. local workInProgressSources: Array> = {} local rendererSigil -if _G.__DEV__ then - -- Used to detect multiple renderers using the same mutable source. - rendererSigil = {} +if ReactGlobals.__DEV__ then + -- Used to detect multiple renderers using the same mutable source. + rendererSigil = {} end exports.markSourceAsDirty = function(mutableSource: MutableSource) - table.insert(workInProgressSources, mutableSource) + table.insert(workInProgressSources, mutableSource) end exports.resetWorkInProgressVersions = function() - for i, mutableSource in ipairs(workInProgressSources) do - if isPrimaryRenderer then - mutableSource._workInProgressVersionPrimary = nil - else - mutableSource._workInProgressVersionSecondary = nil - end - end - table.clear(workInProgressSources) + for i, mutableSource in workInProgressSources do + if isPrimaryRenderer then + mutableSource._workInProgressVersionPrimary = nil + else + mutableSource._workInProgressVersionSecondary = nil + end + end + table.clear(workInProgressSources) end exports.getWorkInProgressVersion = function( - mutableSource: MutableSource + mutableSource: MutableSource ): nil | MutableSourceVersion - if isPrimaryRenderer then - return mutableSource._workInProgressVersionPrimary - else - return mutableSource._workInProgressVersionSecondary - end + if isPrimaryRenderer then + return mutableSource._workInProgressVersionPrimary + else + return mutableSource._workInProgressVersionSecondary + end end exports.setWorkInProgressVersion = function( - mutableSource: MutableSource, - version: MutableSourceVersion + mutableSource: MutableSource, + version_: MutableSourceVersion ) - if isPrimaryRenderer then - mutableSource._workInProgressVersionPrimary = version - else - mutableSource._workInProgressVersionSecondary = version - end - table.insert(workInProgressSources, mutableSource) + if isPrimaryRenderer then + mutableSource._workInProgressVersionPrimary = version_ + else + mutableSource._workInProgressVersionSecondary = version_ + end + table.insert(workInProgressSources, mutableSource) end -exports.warnAboutMultipleRenderersDEV = function( - mutableSource: MutableSource -) - if _G.__DEV__ then - if isPrimaryRenderer then - if mutableSource._currentPrimaryRenderer == nil then - mutableSource._currentPrimaryRenderer = rendererSigil - elseif mutableSource._currentPrimaryRenderer ~= rendererSigil then - console.error( - 'Detected multiple renderers concurrently rendering the ' .. - 'same mutable source. This is currently unsupported.' - ) - end - else - if mutableSource._currentSecondaryRenderer == nil then - mutableSource._currentSecondaryRenderer = rendererSigil - elseif mutableSource._currentSecondaryRenderer ~= rendererSigil then - console.error( - 'Detected multiple renderers concurrently rendering the ' .. - 'same mutable source. This is currently unsupported.' - ) - end - end - end +exports.warnAboutMultipleRenderersDEV = function(mutableSource: MutableSource) + if ReactGlobals.__DEV__ then + if isPrimaryRenderer then + if mutableSource._currentPrimaryRenderer == nil then + mutableSource._currentPrimaryRenderer = rendererSigil + elseif mutableSource._currentPrimaryRenderer ~= rendererSigil then + console.error( + "Detected multiple renderers concurrently rendering the " + .. "same mutable source. This is currently unsupported." + ) + end + else + if mutableSource._currentSecondaryRenderer == nil then + mutableSource._currentSecondaryRenderer = rendererSigil + elseif mutableSource._currentSecondaryRenderer ~= rendererSigil then + console.error( + "Detected multiple renderers concurrently rendering the " + .. "same mutable source. This is currently unsupported." + ) + end + end + end end -- Eager reads the version of a mutable source and stores it on the root. @@ -103,21 +102,21 @@ end -- that is eventually read during hydration. -- If they don't match there's a potential tear and a full deopt render is required. exports.registerMutableSourceForHydration = function( - root: FiberRoot, - mutableSource: MutableSource + root: FiberRoot, + mutableSource: MutableSource ) - local getVersion = mutableSource._getVersion - local version = getVersion(mutableSource._source) + local getVersion = mutableSource._getVersion + local version_ = getVersion(mutableSource._source) - -- TODO Clear this data once all pending hydration work is finished. - -- Retaining it forever may interfere with GC. - if root.mutableSourceEagerHydrationData == nil then - root.mutableSourceEagerHydrationData = {mutableSource, version} - else - -- ROBLOX FIXME: having trouble with type coercion in this case - -- table.insert(root.mutableSourceEagerHydrationData, mutableSource) - -- table.insert(root.mutableSourceEagerHydrationData, version) - end + -- TODO Clear this data once all pending hydration work is finished. + -- Retaining it forever may interfere with GC. + if root.mutableSourceEagerHydrationData == nil then + root.mutableSourceEagerHydrationData = { mutableSource, version_ } + else + -- ROBLOX FIXME: having trouble with type coercion in this case + -- table.insert(root.mutableSourceEagerHydrationData, mutableSource) + -- table.insert(root.mutableSourceEagerHydrationData, version_) + end end return exports diff --git a/modules/react-reconciler/src/ReactPortal.lua b/modules/react-reconciler/src/ReactPortal.lua index 2d5d3375..56075209 100644 --- a/modules/react-reconciler/src/ReactPortal.lua +++ b/modules/react-reconciler/src/ReactPortal.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactPortal.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactPortal.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -37,5 +37,5 @@ local function createPortal( end return { - createPortal = createPortal + createPortal = createPortal, } diff --git a/modules/react-reconciler/src/ReactProfilerTimer.new.lua b/modules/react-reconciler/src/ReactProfilerTimer.new.lua index a795f6d0..950d37ae 100644 --- a/modules/react-reconciler/src/ReactProfilerTimer.new.lua +++ b/modules/react-reconciler/src/ReactProfilerTimer.new.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactProfilerTimer.new.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/142d4f1c00c66f3d728177082dbc027fd6335115/packages/react-reconciler/src/ReactProfilerTimer.new.js -- /** -- * Copyright (c) Facebook, Inc. and its affiliates. -- * @@ -27,12 +27,12 @@ local Scheduler = require(Packages.Scheduler) local now = Scheduler.unstable_now export type ProfilerTimer = { - getCommitTime: () -> number, - recordCommitTime: () -> (), - startProfilerTimer: (Fiber) -> (), - stopProfilerTimerIfRunning: (Fiber) -> (), - stopProfilerTimerIfRunningAndRecordDelta: (Fiber) -> () - -- ... + getCommitTime: () -> number, + recordCommitTime: () -> (), + startProfilerTimer: (Fiber) -> (), + stopProfilerTimerIfRunning: (Fiber) -> (), + stopProfilerTimerIfRunningAndRecordDelta: (Fiber) -> (), + -- ... } local commitTime: number = 0 @@ -41,138 +41,139 @@ local profilerStartTime: number = -1 local passiveEffectStartTime: number = -1 function getCommitTime(): number - return commitTime + return commitTime end function recordCommitTime(): () - if not enableProfilerTimer then - return - end - commitTime = now() + if not enableProfilerTimer then + return + end + commitTime = now() end function startProfilerTimer(fiber: Fiber): () - if not enableProfilerTimer then - return - end + if not enableProfilerTimer then + return + end - profilerStartTime = now() + profilerStartTime = now() - if fiber.actualStartTime < 0 then - fiber.actualStartTime = now() - end + -- ROBLOX deviation: JS can compare null/undefined to 0, but Lua can't + if fiber.actualStartTime ~= nil and (fiber.actualStartTime :: number) < 0 then + fiber.actualStartTime = now() + end end function stopProfilerTimerIfRunning(fiber: Fiber): () - if not enableProfilerTimer then - return - end - profilerStartTime = -1 + if not enableProfilerTimer then + return + end + profilerStartTime = -1 end function stopProfilerTimerIfRunningAndRecordDelta( - fiber: Fiber, - overrideBaseTime: boolean + fiber: Fiber, + overrideBaseTime: boolean ): () - if not enableProfilerTimer then - return - end - - if profilerStartTime >= 0 then - local elapsedTime = now() - profilerStartTime - fiber.actualDuration += elapsedTime - if overrideBaseTime then - fiber.selfBaseDuration = elapsedTime - end - profilerStartTime = -1 - end + if not enableProfilerTimer then + return + end + + if profilerStartTime >= 0 then + local elapsedTime_ = now() - profilerStartTime + fiber.actualDuration += elapsedTime_ + if overrideBaseTime then + fiber.selfBaseDuration = elapsedTime_ + end + profilerStartTime = -1 + end end function recordLayoutEffectDuration(fiber: Fiber): () - if not enableProfilerTimer or not enableProfilerCommitHooks then - return - end - - if layoutEffectStartTime >= 0 then - local elapsedTime = now() - layoutEffectStartTime - - layoutEffectStartTime = -1 - - -- Store duration on the next nearest Profiler ancestor. - local parentFiber = fiber.return_ - while parentFiber ~= nil do - if parentFiber.tag == Profiler then - local parentStateNode = parentFiber.stateNode - parentStateNode.effectDuration += elapsedTime - break - end - parentFiber = parentFiber.return_ - end - end + if not enableProfilerTimer or not enableProfilerCommitHooks then + return + end + + if layoutEffectStartTime >= 0 then + local elapsedTime_ = now() - layoutEffectStartTime + + layoutEffectStartTime = -1 + + -- Store duration on the next nearest Profiler ancestor. + local parentFiber = fiber.return_ + while parentFiber ~= nil do + if parentFiber.tag == Profiler then + local parentStateNode = parentFiber.stateNode + parentStateNode.effectDuration += elapsedTime_ + break + end + parentFiber = parentFiber.return_ + end + end end function recordPassiveEffectDuration(fiber: Fiber): () - if not enableProfilerTimer or not enableProfilerCommitHooks then - return - end - - if passiveEffectStartTime >= 0 then - local elapsedTime = now() - passiveEffectStartTime - - passiveEffectStartTime = -1 - - -- Store duration on the next nearest Profiler ancestor. - local parentFiber = fiber.return_ - while parentFiber ~= nil do - if parentFiber.tag == Profiler then - local parentStateNode = parentFiber.stateNode - if parentStateNode ~= nil then - -- Detached fibers have their state node cleared out. - -- In this case, the return pointer is also cleared out, - -- so we won't be able to report the time spent in this Profiler's subtree. - parentStateNode.passiveEffectDuration += elapsedTime - end - break - end - parentFiber = parentFiber.return_ - end - end + if not enableProfilerTimer or not enableProfilerCommitHooks then + return + end + + if passiveEffectStartTime >= 0 then + local elapsedTime_ = now() - passiveEffectStartTime + + passiveEffectStartTime = -1 + + -- Store duration on the next nearest Profiler ancestor. + local parentFiber = fiber.return_ + while parentFiber ~= nil do + if parentFiber.tag == Profiler then + local parentStateNode = parentFiber.stateNode + if parentStateNode ~= nil then + -- Detached fibers have their state node cleared out. + -- In this case, the return pointer is also cleared out, + -- so we won't be able to report the time spent in this Profiler's subtree. + parentStateNode.passiveEffectDuration += elapsedTime_ + end + break + end + parentFiber = parentFiber.return_ + end + end end function startLayoutEffectTimer(): () - if not enableProfilerTimer or not enableProfilerCommitHooks then - return - end - layoutEffectStartTime = now() + if not enableProfilerTimer or not enableProfilerCommitHooks then + return + end + layoutEffectStartTime = now() end function startPassiveEffectTimer(): () - if not enableProfilerTimer or not enableProfilerCommitHooks then - return - end - passiveEffectStartTime = now() + if not enableProfilerTimer or not enableProfilerCommitHooks then + return + end + passiveEffectStartTime = now() end function transferActualDuration(fiber: Fiber): () - -- Transfer time spent rendering these children so we don't lose it - -- after we rerender. This is used as a helper in special cases - -- where we should count the work of multiple passes. - local child = fiber.child - while child do - fiber.actualDuration += child.actualDuration - child = child.sibling - end + -- Transfer time spent rendering these children so we don't lose it + -- after we rerender. This is used as a helper in special cases + -- where we should count the work of multiple passes. + local child = fiber.child + while child do + fiber.actualDuration += child.actualDuration + child = child.sibling + end end return { - getCommitTime = getCommitTime, - recordCommitTime = recordCommitTime, - recordLayoutEffectDuration = recordLayoutEffectDuration, - recordPassiveEffectDuration = recordPassiveEffectDuration, - startLayoutEffectTimer = startLayoutEffectTimer, - startPassiveEffectTimer = startPassiveEffectTimer, - startProfilerTimer = startProfilerTimer, - stopProfilerTimerIfRunning = stopProfilerTimerIfRunning, - stopProfilerTimerIfRunningAndRecordDelta = stopProfilerTimerIfRunningAndRecordDelta, - transferActualDuration = transferActualDuration -} \ No newline at end of file + getCommitTime = getCommitTime, + recordCommitTime = recordCommitTime, + recordLayoutEffectDuration = recordLayoutEffectDuration, + recordPassiveEffectDuration = recordPassiveEffectDuration, + startLayoutEffectTimer = startLayoutEffectTimer, + startPassiveEffectTimer = startPassiveEffectTimer, + startProfilerTimer = startProfilerTimer, + stopProfilerTimerIfRunning = stopProfilerTimerIfRunning, + stopProfilerTimerIfRunningAndRecordDelta = stopProfilerTimerIfRunningAndRecordDelta, + transferActualDuration = transferActualDuration, +} diff --git a/modules/react-reconciler/src/ReactRootTags.lua b/modules/react-reconciler/src/ReactRootTags.lua index cc1d6a63..3fa4d281 100644 --- a/modules/react-reconciler/src/ReactRootTags.lua +++ b/modules/react-reconciler/src/ReactRootTags.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/c5d2fc7127654e43de59fff865b74765a103c4a5/packages/react-reconciler/src/ReactRootTags.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/c5d2fc7127654e43de59fff865b74765a103c4a5/packages/react-reconciler/src/ReactRootTags.js --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -12,7 +12,7 @@ export type RootTag = number return { - LegacyRoot = 0, - BlockingRoot = 1, - ConcurrentRoot = 2, + LegacyRoot = 0, + BlockingRoot = 1, + ConcurrentRoot = 2, } diff --git a/modules/react-reconciler/src/ReactStrictModeWarnings.new.lua b/modules/react-reconciler/src/ReactStrictModeWarnings.new.lua index 625c4b49..764985bd 100644 --- a/modules/react-reconciler/src/ReactStrictModeWarnings.new.lua +++ b/modules/react-reconciler/src/ReactStrictModeWarnings.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/702fad4b1b48ac8f626ed3f35e8f86f5ea728084/packages/react-reconciler/src/ReactStrictModeWarnings.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/702fad4b1b48ac8f626ed3f35e8f86f5ea728084/packages/react-reconciler/src/ReactStrictModeWarnings.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -9,6 +10,7 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console @@ -27,356 +29,362 @@ type FiberArray = Array type FiberToFiberComponentsMap = Map local ReactStrictModeWarnings = { - recordUnsafeLifecycleWarnings = function(fiber: Fiber, instance: any) end, - flushPendingUnsafeLifecycleWarnings = function() end, - recordLegacyContextWarning = function(fiber: Fiber, instance: any) end, - flushLegacyContextWarning = function() end, - discardPendingWarnings = function() end + recordUnsafeLifecycleWarnings = function(fiber: Fiber, instance: any) end, + flushPendingUnsafeLifecycleWarnings = function() end, + recordLegacyContextWarning = function(fiber: Fiber, instance: any) end, + flushLegacyContextWarning = function() end, + discardPendingWarnings = function() end, } -if _G.__DEV__ then - local findStrictRoot = function(fiber: Fiber): Fiber | nil - local maybeStrictRoot = nil - - local node = fiber - while node ~= nil do - if bit32.band(node.mode, StrictMode) ~= 0 then - maybeStrictRoot = node - end - node = node.return_ - end - - return maybeStrictRoot - end - - local setToSortedString = function(set) - local array = {} - for key, value in pairs(set) do - table.insert(array, key) - end - table.sort(array) - return table.concat(array, ', ') - end - - local pendingComponentWillMountWarnings: Array = {} - local pendingUNSAFE_ComponentWillMountWarnings: Array = {} - local pendingComponentWillReceivePropsWarnings: Array = {} - local pendingUNSAFE_ComponentWillReceivePropsWarnings: Array = {} - local pendingComponentWillUpdateWarnings: Array = {} - local pendingUNSAFE_ComponentWillUpdateWarnings: Array = {} - - -- Tracks components we have already warned about. - local didWarnAboutUnsafeLifecycles = {} - - ReactStrictModeWarnings.recordUnsafeLifecycleWarnings = function( - fiber: Fiber, - instance: any - ) - -- Dedupe strategy: Warn once per component. - if didWarnAboutUnsafeLifecycles[fiber.type] then - return - end - - if - typeof(instance.componentWillMount) == 'function' - -- Don't warn about react-lifecycles-compat polyfilled components. - -- ROBLOX deviation: Lua doesn't allow fields on function - -- instance.componentWillMount.__suppressDeprecationWarning ~= true - then - table.insert(pendingComponentWillMountWarnings, fiber) - end - - if - bit32.band(fiber.mode, StrictMode) ~= 0 and - typeof(instance.UNSAFE_componentWillMount) == 'function' - then - table.insert(pendingUNSAFE_ComponentWillMountWarnings, fiber) - end - - if - typeof(instance.componentWillReceiveProps) == 'function' - -- ROBLOX deviation: Lua doesn't allow fields on function - -- instance.componentWillReceiveProps.__suppressDeprecationWarning ~= true - then - table.insert(pendingComponentWillReceivePropsWarnings, fiber) - end - - if - bit32.band(fiber.mode, StrictMode) ~= 0 and - typeof(instance.UNSAFE_componentWillReceiveProps) == 'function' - then - table.insert(pendingUNSAFE_ComponentWillReceivePropsWarnings, fiber) - end - - if - typeof(instance.componentWillUpdate) == 'function' - -- ROBLOX deviation: Lua doesn't allow fields on function - -- instance.componentWillUpdate.__suppressDeprecationWarning ~= true - then - table.insert(pendingComponentWillUpdateWarnings, fiber) - end - - if - bit32.band(fiber.mode, StrictMode) ~= 0 and - typeof(instance.UNSAFE_componentWillUpdate) == 'function' - then - table.insert(pendingUNSAFE_ComponentWillUpdateWarnings, fiber) - end - end - - ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings = function() - -- We do an initial pass to gather component names - local componentWillMountUniqueNames = {} - if #pendingComponentWillMountWarnings > 0 then - for i, fiber in ipairs(pendingComponentWillMountWarnings) do - componentWillMountUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - pendingComponentWillMountWarnings = {} - end - - local UNSAFE_componentWillMountUniqueNames = {} - if #pendingUNSAFE_ComponentWillMountWarnings > 0 then - for i, fiber in ipairs(pendingUNSAFE_ComponentWillMountWarnings) do - UNSAFE_componentWillMountUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - pendingUNSAFE_ComponentWillMountWarnings = {} - end - - local componentWillReceivePropsUniqueNames = {} - if #pendingComponentWillReceivePropsWarnings > 0 then - for i, fiber in ipairs(pendingComponentWillReceivePropsWarnings) do - componentWillReceivePropsUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - - pendingComponentWillReceivePropsWarnings = {} - end - - local UNSAFE_componentWillReceivePropsUniqueNames = {} - if #pendingUNSAFE_ComponentWillReceivePropsWarnings > 0 then - for i, fiber in ipairs(pendingUNSAFE_ComponentWillReceivePropsWarnings) do - UNSAFE_componentWillReceivePropsUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - - pendingUNSAFE_ComponentWillReceivePropsWarnings = {} - end - - local componentWillUpdateUniqueNames = {} - if #pendingComponentWillUpdateWarnings > 0 then - for i, fiber in ipairs(pendingComponentWillUpdateWarnings) do - componentWillUpdateUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - - pendingComponentWillUpdateWarnings = {} - end - - local UNSAFE_componentWillUpdateUniqueNames = {} - if #pendingUNSAFE_ComponentWillUpdateWarnings > 0 then - for i, fiber in ipairs(pendingUNSAFE_ComponentWillUpdateWarnings) do - UNSAFE_componentWillUpdateUniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutUnsafeLifecycles[fiber.type] = true - end - - pendingUNSAFE_ComponentWillUpdateWarnings = {} - end - - -- Finally, we flush all the warnings - -- UNSAFE_ ones before the deprecated ones, since they'll be 'louder' - -- deviation: use `next` to determine whether set is empty - if next(UNSAFE_componentWillMountUniqueNames) ~= nil then - local sortedNames = setToSortedString( - UNSAFE_componentWillMountUniqueNames - ) - console.error( - 'Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move code with side effects to componentDidMount, and set initial state in the constructor.\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - - -- deviation: use `next` to determine whether set is empty - if next(UNSAFE_componentWillReceivePropsUniqueNames) ~= nil then - local sortedNames = setToSortedString( - UNSAFE_componentWillReceivePropsUniqueNames - ) - console.error( - 'Using UNSAFE_componentWillReceiveProps in strict mode is not recommended ' .. - 'and may indicate bugs in your code. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move data fetching code or side effects to componentDidUpdate.\n' .. - "* If you're updating state whenever props change, " .. - 'refactor your code to use memoization techniques or move it to ' .. - 'static getDerivedStateFromProps. Learn more at: https://reactjs.org/link/derived-state\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - - -- deviation: use `next` to determine whether set is empty - if next(UNSAFE_componentWillUpdateUniqueNames) ~= nil then - local sortedNames = setToSortedString( - UNSAFE_componentWillUpdateUniqueNames - ) - console.error( - 'Using UNSAFE_componentWillUpdate in strict mode is not recommended ' .. - 'and may indicate bugs in your code. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move data fetching code or side effects to componentDidUpdate.\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - - -- deviation: use `next` to determine whether set is empty - if next(componentWillMountUniqueNames) ~= nil then - local sortedNames = setToSortedString(componentWillMountUniqueNames) - - -- ROBLOX TODO: Make decisions about whether or not we'll support these - -- methods in the first place - -- deviation: Remove some non-applicable information - console.warn( - 'componentWillMount has been renamed, and is not recommended for use. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move code with side effects to componentDidMount, and set initial state in the constructor.\n' .. - '* Rename componentWillMount to UNSAFE_componentWillMount to suppress ' .. - 'this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - - -- deviation: use `next` to determine whether set is empty - if next(componentWillReceivePropsUniqueNames) ~= nil then - local sortedNames = setToSortedString( - componentWillReceivePropsUniqueNames - ) - - -- ROBLOX TODO: Make decisions about whether or not we'll support these - -- methods in the first place - -- deviation: Remove some non-applicable information - console.warn( - 'componentWillReceiveProps has been renamed, and is not recommended for use. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move data fetching code or side effects to componentDidUpdate.\n' .. - "* If you're updating state whenever props change, refactor your " .. - 'code to use memoization techniques or move it to ' .. - 'static getDerivedStateFromProps. Learn more at: https://reactjs.org/link/derived-state\n' .. - '* Rename componentWillReceiveProps to UNSAFE_componentWillReceiveProps to suppress ' .. - 'this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - - -- deviation: use `next` to determine whether set is empty - if next(componentWillUpdateUniqueNames) ~= nil then - local sortedNames = setToSortedString(componentWillUpdateUniqueNames) - - -- ROBLOX TODO: Make decisions about whether or not we'll support these - -- methods in the first place - -- deviation: Remove some non-applicable information - console.warn( - 'componentWillUpdate has been renamed, and is not recommended for use. ' .. - 'See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n' .. - '* Move data fetching code or side effects to componentDidUpdate.\n' .. - '* Rename componentWillUpdate to UNSAFE_componentWillUpdate to suppress ' .. - 'this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n' .. - '\nPlease update the following components: %s', - sortedNames - ) - end - end - - local pendingLegacyContextWarning: FiberToFiberComponentsMap = {} - - -- Tracks components we have already warned about. - local didWarnAboutLegacyContext = {} - - ReactStrictModeWarnings.recordLegacyContextWarning = function( - fiber: Fiber, - instance: any - ) - local strictRoot = findStrictRoot(fiber) - if strictRoot == nil then - console.error( - 'Expected to find a StrictMode component in a strict mode tree. ' .. - 'This error is likely caused by a bug in React. Please file an issue.' - ) - return - end - - -- Dedup strategy: Warn once per component. - if didWarnAboutLegacyContext[fiber.type] then - return - end - - local warningsForRoot = pendingLegacyContextWarning[strictRoot] - - -- ROBLOX deviation: Lua can't have fields on functions - if typeof(fiber.type) ~= 'function' and - (fiber.type.contextTypes ~= nil or - fiber.type.childContextTypes ~= nil or - (instance ~= nil and typeof(instance.getChildContext) == 'function')) - then - if warningsForRoot == nil then - warningsForRoot = {} - pendingLegacyContextWarning[strictRoot] = warningsForRoot - end - table.insert(warningsForRoot, fiber) - end - end - - ReactStrictModeWarnings.flushLegacyContextWarning = function() - for strictRoot, fiberArray in pairs(pendingLegacyContextWarning) do - if #fiberArray == 0 then - return - end - local firstFiber = fiberArray[1] - - local uniqueNames = {} - for i, fiber in ipairs(fiberArray) do - uniqueNames[getComponentName(fiber.type) or 'Component'] = true - didWarnAboutLegacyContext[fiber.type] = true - end - - local sortedNames = setToSortedString(uniqueNames) - - local ok, error_ = pcall(function() - setCurrentDebugFiberInDEV(firstFiber) - console.error( - 'Legacy context API has been detected within a strict-mode tree.' .. - '\n\nThe old API will be supported in all 16.x releases, but applications ' .. - 'using it should migrate to the new version.' .. - '\n\nPlease update the following components: %s' .. - '\n\nLearn more about this warning here: https://reactjs.org/link/legacy-context', - sortedNames - ) - end) - - -- finally - resetCurrentDebugFiberInDEV() - - if not ok then - error(error_) - end - end - end - - ReactStrictModeWarnings.discardPendingWarnings = function() - pendingComponentWillMountWarnings = {} - pendingUNSAFE_ComponentWillMountWarnings = {} - pendingComponentWillReceivePropsWarnings = {} - pendingUNSAFE_ComponentWillReceivePropsWarnings = {} - pendingComponentWillUpdateWarnings = {} - pendingUNSAFE_ComponentWillUpdateWarnings = {} - pendingLegacyContextWarning = {} - end +if ReactGlobals.__DEV__ then + local findStrictRoot = function(fiber: Fiber): Fiber | nil + local maybeStrictRoot = nil + + -- ROBLOX FIXME Luau: Luau needs to understand while not nil loops + local node: Fiber? = fiber + while node ~= nil do + if bit32.band(node.mode, StrictMode) ~= 0 then + maybeStrictRoot = node + end + node = node.return_ + end + + return maybeStrictRoot + end + + local setToSortedString = function(set): string + local array = {} + for key, value in set do + table.insert(array, key) + end + table.sort(array) + return table.concat(array, ", ") + end + + local pendingComponentWillMountWarnings: Array = {} + local pendingUNSAFE_ComponentWillMountWarnings: Array = {} + local pendingComponentWillReceivePropsWarnings: Array = {} + local pendingUNSAFE_ComponentWillReceivePropsWarnings: Array = {} + local pendingComponentWillUpdateWarnings: Array = {} + local pendingUNSAFE_ComponentWillUpdateWarnings: Array = {} + + -- Tracks components we have already warned about. + local didWarnAboutUnsafeLifecycles = {} + + ReactStrictModeWarnings.recordUnsafeLifecycleWarnings = function( + fiber: Fiber, + instance: any + ) + -- Dedupe strategy: Warn once per component. + if didWarnAboutUnsafeLifecycles[fiber.type] then + return + end + + if + typeof(instance.componentWillMount) == "function" + -- Don't warn about react-lifecycles-compat polyfilled components. + -- ROBLOX deviation: Lua doesn't allow fields on function + -- instance.componentWillMount.__suppressDeprecationWarning ~= true + then + table.insert(pendingComponentWillMountWarnings, fiber) + end + + if + bit32.band(fiber.mode, StrictMode) ~= 0 + and typeof(instance.UNSAFE_componentWillMount) == "function" + then + table.insert(pendingUNSAFE_ComponentWillMountWarnings, fiber) + end + + if + typeof(instance.componentWillReceiveProps) == "function" + -- ROBLOX deviation: Lua doesn't allow fields on function + -- instance.componentWillReceiveProps.__suppressDeprecationWarning ~= true + then + table.insert(pendingComponentWillReceivePropsWarnings, fiber) + end + + if + bit32.band(fiber.mode, StrictMode) ~= 0 + and typeof(instance.UNSAFE_componentWillReceiveProps) == "function" + then + table.insert(pendingUNSAFE_ComponentWillReceivePropsWarnings, fiber) + end + + if + typeof(instance.componentWillUpdate) == "function" + -- ROBLOX deviation: Lua doesn't allow fields on function + -- instance.componentWillUpdate.__suppressDeprecationWarning ~= true + then + table.insert(pendingComponentWillUpdateWarnings, fiber) + end + + if + bit32.band(fiber.mode, StrictMode) ~= 0 + and typeof(instance.UNSAFE_componentWillUpdate) == "function" + then + table.insert(pendingUNSAFE_ComponentWillUpdateWarnings, fiber) + end + end + + ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings = function() + -- We do an initial pass to gather component names + local componentWillMountUniqueNames = {} + if #pendingComponentWillMountWarnings > 0 then + for i, fiber in pendingComponentWillMountWarnings do + componentWillMountUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + table.clear(pendingComponentWillMountWarnings) + end + + local UNSAFE_componentWillMountUniqueNames = {} + if #pendingUNSAFE_ComponentWillMountWarnings > 0 then + for i, fiber in pendingUNSAFE_ComponentWillMountWarnings do + UNSAFE_componentWillMountUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + table.clear(pendingUNSAFE_ComponentWillMountWarnings) + end + + local componentWillReceivePropsUniqueNames = {} + if #pendingComponentWillReceivePropsWarnings > 0 then + for i, fiber in pendingComponentWillReceivePropsWarnings do + componentWillReceivePropsUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + + table.clear(pendingComponentWillReceivePropsWarnings) + end + + local UNSAFE_componentWillReceivePropsUniqueNames = {} + if #pendingUNSAFE_ComponentWillReceivePropsWarnings > 0 then + for i, fiber in pendingUNSAFE_ComponentWillReceivePropsWarnings do + UNSAFE_componentWillReceivePropsUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + + table.clear(pendingUNSAFE_ComponentWillReceivePropsWarnings) + end + + local componentWillUpdateUniqueNames = {} + if #pendingComponentWillUpdateWarnings > 0 then + for i, fiber in pendingComponentWillUpdateWarnings do + componentWillUpdateUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + + table.clear(pendingComponentWillUpdateWarnings) + end + + local UNSAFE_componentWillUpdateUniqueNames = {} + if #pendingUNSAFE_ComponentWillUpdateWarnings > 0 then + for i, fiber in pendingUNSAFE_ComponentWillUpdateWarnings do + UNSAFE_componentWillUpdateUniqueNames[getComponentName(fiber.type) or "Component"] = + true + didWarnAboutUnsafeLifecycles[fiber.type] = true + end + + table.clear(pendingUNSAFE_ComponentWillUpdateWarnings) + end + + -- Finally, we flush all the warnings + -- UNSAFE_ ones before the deprecated ones, since they'll be 'louder' + -- deviation: use `next` to determine whether set is empty + if next(UNSAFE_componentWillMountUniqueNames) ~= nil then + local sortedNames = setToSortedString(UNSAFE_componentWillMountUniqueNames) + console.error( + "Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move code with side effects to componentDidMount, and set initial state in the constructor.\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + + -- deviation: use `next` to determine whether set is empty + if next(UNSAFE_componentWillReceivePropsUniqueNames) ~= nil then + local sortedNames = + setToSortedString(UNSAFE_componentWillReceivePropsUniqueNames) + console.error( + "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended " + .. "and may indicate bugs in your code. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move data fetching code or side effects to componentDidUpdate.\n" + .. "* If you're updating state whenever props change, " + .. "refactor your code to use memoization techniques or move it to " + .. "static getDerivedStateFromProps. Learn more at: https://reactjs.org/link/derived-state\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + + -- deviation: use `next` to determine whether set is empty + if next(UNSAFE_componentWillUpdateUniqueNames) ~= nil then + local sortedNames = setToSortedString(UNSAFE_componentWillUpdateUniqueNames) + console.error( + "Using UNSAFE_componentWillUpdate in strict mode is not recommended " + .. "and may indicate bugs in your code. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move data fetching code or side effects to componentDidUpdate.\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + + -- deviation: use `next` to determine whether set is empty + if next(componentWillMountUniqueNames) ~= nil then + local sortedNames = setToSortedString(componentWillMountUniqueNames) + + -- ROBLOX TODO: Make decisions about whether or not we'll support these + -- methods in the first place + -- deviation: Remove some non-applicable information + console.warn( + "componentWillMount has been renamed, and is not recommended for use. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move code with side effects to componentDidMount, and set initial state in the constructor.\n" + .. "* Rename componentWillMount to UNSAFE_componentWillMount to suppress " + .. "this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + + -- deviation: use `next` to determine whether set is empty + if next(componentWillReceivePropsUniqueNames) ~= nil then + local sortedNames = setToSortedString(componentWillReceivePropsUniqueNames) + + -- ROBLOX TODO: Make decisions about whether or not we'll support these + -- methods in the first place + -- deviation: Remove some non-applicable information + console.warn( + "componentWillReceiveProps has been renamed, and is not recommended for use. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move data fetching code or side effects to componentDidUpdate.\n" + .. "* If you're updating state whenever props change, refactor your " + .. "code to use memoization techniques or move it to " + .. "static getDerivedStateFromProps. Learn more at: https://reactjs.org/link/derived-state\n" + .. "* Rename componentWillReceiveProps to UNSAFE_componentWillReceiveProps to suppress " + .. "this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + + -- deviation: use `next` to determine whether set is empty + if next(componentWillUpdateUniqueNames) ~= nil then + local sortedNames = setToSortedString(componentWillUpdateUniqueNames) + + -- ROBLOX TODO: Make decisions about whether or not we'll support these + -- methods in the first place + -- deviation: Remove some non-applicable information + console.warn( + "componentWillUpdate has been renamed, and is not recommended for use. " + .. "See https://reactjs.org/link/unsafe-component-lifecycles for details.\n\n" + .. "* Move data fetching code or side effects to componentDidUpdate.\n" + .. "* Rename componentWillUpdate to UNSAFE_componentWillUpdate to suppress " + .. "this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work.\n" + .. "\nPlease update the following components: %s", + sortedNames + ) + end + end + + local pendingLegacyContextWarning: FiberToFiberComponentsMap = {} + + -- Tracks components we have already warned about. + local didWarnAboutLegacyContext = {} + + ReactStrictModeWarnings.recordLegacyContextWarning = function( + fiber: Fiber, + instance: any + ) + local strictRoot = findStrictRoot(fiber) + if strictRoot == nil then + console.error( + "Expected to find a StrictMode component in a strict mode tree. " + .. "This error is likely caused by a bug in React. Please file an issue." + ) + return + end + + -- Dedup strategy: Warn once per component. + if didWarnAboutLegacyContext[fiber.type] then + return + end + + -- ROBLOX FIXME Luau: Luau should narrow based on the nil guard + local warningsForRoot = pendingLegacyContextWarning[strictRoot :: Fiber] + + -- ROBLOX deviation: Lua can't have fields on functions + if + typeof(fiber.type) ~= "function" + and ( + fiber.type.contextTypes ~= nil + or fiber.type.childContextTypes ~= nil + or (instance ~= nil and typeof(instance.getChildContext) == "function") + ) + then + if warningsForRoot == nil then + warningsForRoot = {} + -- ROBLOX FIXME Luau: Luau should narrow based on the nil guard + pendingLegacyContextWarning[strictRoot :: Fiber] = warningsForRoot + end + table.insert(warningsForRoot, fiber) + end + end + + ReactStrictModeWarnings.flushLegacyContextWarning = function() + for strictRoot, fiberArray in pendingLegacyContextWarning do + if #fiberArray == 0 then + return + end + local firstFiber = fiberArray[1] + + local uniqueNames = {} + for i, fiber in fiberArray do + uniqueNames[getComponentName(fiber.type) or "Component"] = true + didWarnAboutLegacyContext[fiber.type] = true + end + + local sortedNames = setToSortedString(uniqueNames) + + local ok, error_ = pcall(function() + setCurrentDebugFiberInDEV(firstFiber) + console.error( + "Legacy context API has been detected within a strict-mode tree." + .. "\n\nThe old API will be supported in all 16.x releases, but applications " + .. "using it should migrate to the new version." + .. "\n\nPlease update the following components: %s" + .. "\n\nLearn more about this warning here: https://reactjs.org/link/legacy-context", + sortedNames + ) + end) + + -- finally + resetCurrentDebugFiberInDEV() + + if not ok then + error(error_) + end + end + end + + ReactStrictModeWarnings.discardPendingWarnings = function() + -- ROBLOX performance? use table.clear instead of assigning new array + table.clear(pendingComponentWillMountWarnings) + table.clear(pendingUNSAFE_ComponentWillMountWarnings) + table.clear(pendingComponentWillReceivePropsWarnings) + table.clear(pendingUNSAFE_ComponentWillReceivePropsWarnings) + table.clear(pendingComponentWillUpdateWarnings) + table.clear(pendingUNSAFE_ComponentWillUpdateWarnings) + table.clear(pendingLegacyContextWarning) + end end return ReactStrictModeWarnings diff --git a/modules/react-reconciler/src/ReactTestSelectors.lua b/modules/react-reconciler/src/ReactTestSelectors.lua index bec2dd77..f99e202b 100644 --- a/modules/react-reconciler/src/ReactTestSelectors.lua +++ b/modules/react-reconciler/src/ReactTestSelectors.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/3cde22a84e246fc5361f038bf0c23405b2572c22/packages/react-reconciler/src/ReactTestSelectors.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/3cde22a84e246fc5361f038bf0c23405b2572c22/packages/react-reconciler/src/ReactTestSelectors.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -7,13 +7,12 @@ * * @flow ]] - --- deviation: Common types -type Set = { [T]: boolean }; -type Array = { [number]: T }; -type Function = (any) -> any; -type Object = { [any]: any }; - +local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +type Set = { [T]: boolean } +type Array = LuauPolyfill.Array +type Function = (...any) -> ...any +type Object = LuauPolyfill.Object -- local type {Fiber} = require(Packages.react-reconciler/src/ReactInternalTypes' -- local type {Instance} = require(Packages../ReactFiberHostConfig' @@ -377,12 +376,12 @@ local exports = {} -- return nil -- end --- export type BoundingRect = {| --- x: number, --- y: number, --- width: number, --- height: number, --- |} +export type BoundingRect = { + x: number, + y: number, + width: number, + height: number, +} -- exports.findBoundingRects( -- hostRoot: Instance, @@ -510,19 +509,19 @@ local exports = {} local commitHooks: Array = {} -exports.onCommitRoot = function() - if supportsTestSelectors then - for i, commitHook in ipairs(commitHooks) do - commitHook() - end - end +exports.onCommitRoot = function(): () + if supportsTestSelectors then + for i, commitHook in commitHooks do + commitHook() + end + end end export type IntersectionObserverOptions = Object --- export type ObserveVisibleRectsCallback = ( --- intersections: Array<{ratio: number, rect: BoundingRect}>, --- ) => void +export type ObserveVisibleRectsCallback = ( + intersections: Array<{ ratio: number, rect: BoundingRect }> +) -> () -- exports.observeVisibleRects( -- hostRoot: Instance, diff --git a/modules/react-reconciler/src/ReactTypeOfMode.lua b/modules/react-reconciler/src/ReactTypeOfMode.lua index 700ca5eb..d568daf4 100644 --- a/modules/react-reconciler/src/ReactTypeOfMode.lua +++ b/modules/react-reconciler/src/ReactTypeOfMode.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/22dc2e42bdc00d87fc19c5e75fc7c0b3fdcdc572/packages/react-reconciler/src/ReactTypeOfMode.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/22dc2e42bdc00d87fc19c5e75fc7c0b3fdcdc572/packages/react-reconciler/src/ReactTypeOfMode.js --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -12,12 +12,12 @@ export type TypeOfMode = number return { - NoMode = 0b00000, - StrictMode = 0b00001, - -- TODO: Remove BlockingMode and ConcurrentMode by reading from the root - -- tag instead - BlockingMode = 0b00010, - ConcurrentMode = 0b00100, - ProfileMode = 0b01000, - DebugTracingMode = 0b10000, + NoMode = 0b00000, + StrictMode = 0b00001, + -- TODO: Remove BlockingMode and ConcurrentMode by reading from the root + -- tag instead + BlockingMode = 0b00010, + ConcurrentMode = 0b00100, + ProfileMode = 0b01000, + DebugTracingMode = 0b10000, } diff --git a/modules/react-reconciler/src/ReactUpdateQueue.new.lua b/modules/react-reconciler/src/ReactUpdateQueue.new.lua index 74ed0767..ce528403 100644 --- a/modules/react-reconciler/src/ReactUpdateQueue.new.lua +++ b/modules/react-reconciler/src/ReactUpdateQueue.new.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactUpdateQueue.new.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/16654436039dd8f16a63928e71081c7745872e8f/packages/react-reconciler/src/ReactUpdateQueue.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -86,73 +87,74 @@ -- resources, but the final state is always the same. local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Object = LuauPolyfill.Object +local __DEV__ = ReactGlobals.__DEV__ +local __YOLO__ = ReactGlobals.__YOLO__ + -- ROBLOX: use patched console from shared local console = require(Packages.Shared).console local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -type Fiber = ReactInternalTypes.Fiber; -type Lane = ReactInternalTypes.Lane; -type Lanes = ReactInternalTypes.Lanes; +type Fiber = ReactInternalTypes.Fiber +type Lane = ReactInternalTypes.Lane +type Lanes = ReactInternalTypes.Lanes local ReactFiberLane = require(script.Parent.ReactFiberLane) local NoLane = ReactFiberLane.NoLane local NoLanes = ReactFiberLane.NoLanes local isSubsetOfLanes = ReactFiberLane.isSubsetOfLanes local mergeLanes = ReactFiberLane.mergeLanes --- local ReactFiberNewContext = require(script.Parent["ReactFiberNewContext.new"]) + +-- ROBLOX deviation: lazy instantiate to avoid circular require +local ReactFiberNewContext --= require(script.Parent["ReactFiberNewContext.new"]) -- local enterDisallowedContextReadInDEV = ReactFiberNewContext.enterDisallowedContextReadInDEV -- local exitDisallowedContextReadInDEV = ReactFiberNewContext.exitDisallowedContextReadInDEV +local function enterDisallowedContextReadInDEV() + if not ReactFiberNewContext then + ReactFiberNewContext = require(script.Parent["ReactFiberNewContext.new"]) :: any + end + ReactFiberNewContext.enterDisallowedContextReadInDEV() +end +local function exitDisallowedContextReadInDEV() + if not ReactFiberNewContext then + ReactFiberNewContext = require(script.Parent["ReactFiberNewContext.new"]) :: any + end + ReactFiberNewContext.exitDisallowedContextReadInDEV() +end local ReactFiberFlags = require(script.Parent.ReactFiberFlags) local Callback = ReactFiberFlags.Callback local ShouldCapture = ReactFiberFlags.ShouldCapture local DidCapture = ReactFiberFlags.DidCapture local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -local debugRenderPhaseSideEffectsForStrictMode = ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode +local debugRenderPhaseSideEffectsForStrictMode = + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode local ReactTypeOfMode = require(script.Parent.ReactTypeOfMode) local StrictMode = ReactTypeOfMode.StrictMode -- local ReactFiberWorkLoop = require(script.Parent["ReactFiberWorkLoop.new"]) -local markSkippedUpdateLanes = require(script.Parent.ReactFiberWorkInProgress).markSkippedUpdateLanes +local markSkippedUpdateLanes = + require(script.Parent.ReactFiberWorkInProgress).markSkippedUpdateLanes -local invariant = require(Packages.Shared).invariant +-- ROBLOX deviation START: use if-then-error, which avoid string format and function call overhead, as in React 18 +-- local invariant = require(Packages.Shared).invariant +-- ROBLOX deviation END +local describeError = require(Packages.Shared).describeError local ConsolePatchingDev = require(Packages.Shared).ConsolePatchingDev local disableLogs = ConsolePatchingDev.disableLogs local reenableLogs = ConsolePatchingDev.reenableLogs -- deviation: Common types -type Array = { [number]: T }; - -export type Update = { - -- TODO: Temporary field. Will remove this by storing a map of - -- transition -> event time on the root. - eventTime: number, - lane: Lane, - - -- deviation: FIXME revert when luau supports the type spec below - -- tag: 0 | 1 | 2 | 3, - tag: number, - payload: any, - callback: (() -> any)?, - - next: Update?, -}; - -type SharedQueue = { - pending: Update?, -}; +type Array = { [number]: T } -export type UpdateQueue = { - baseState: State, - firstBaseUpdate: Update?, - lastBaseUpdate: Update?, - shared: SharedQueue, - effects: Array>?, -}; +-- ROBLOX deviation: transplants UpdateQueue<> and SharedState<> types to ReactInternalTypes for export to createReactNoop +type Update = ReactInternalTypes.Update +type UpdateQueue = ReactInternalTypes.UpdateQueue +export type SharedQueue = ReactInternalTypes.SharedQueue local exports = {} @@ -171,9 +173,9 @@ exports.CaptureUpdate = CaptureUpdate local hasForceUpdate = false local didWarnUpdateInsideUpdate -local currentlyProcessingQueue +local currentlyProcessingQueue: SharedQueue? -- export local resetCurrentlyProcessingQueue -if _G.__DEV__ then +if __DEV__ then didWarnUpdateInsideUpdate = false currentlyProcessingQueue = nil exports.resetCurrentlyProcessingQueue = function() @@ -181,10 +183,24 @@ if _G.__DEV__ then end end --- deviation: FIXME generics in function signatures --- 'initializeUpdateQueue(fiber: Fiber)' -local function initializeUpdateQueue(fiber: Fiber) - local queue: UpdateQueue = { +-- ROBLOX performance: use a recycle pool for update tables +local poolInitSize = 210 -- TODO: Tune to LuaApps +-- local poolAdditionalSize = 0 +local updatePool = table.create(poolInitSize) +local updatePoolIndex = poolInitSize +for i = 1, poolInitSize do + updatePool[i] = { + eventTime = -1, + lane = -1, + tag = -1, + payload = nil, + callback = nil, + next = nil, + } +end + +local function initializeUpdateQueue(fiber: Fiber): () + local queue: UpdateQueue = { baseState = fiber.memoizedState, firstBaseUpdate = nil, lastBaseUpdate = nil, @@ -197,51 +213,78 @@ local function initializeUpdateQueue(fiber: Fiber) end exports.initializeUpdateQueue = initializeUpdateQueue --- deviation: FIXME generics in function signatures --- 'cloneUpdateQueue(...)' -exports.cloneUpdateQueue = function( - current: Fiber, - workInProgress: Fiber -) +local function cloneUpdateQueue(current: Fiber, workInProgress: Fiber): () -- Clone the update queue from current. Unless it's already a clone. - local queue: UpdateQueue = workInProgress.updateQueue - local currentQueue: UpdateQueue = current.updateQueue + local queue: UpdateQueue = workInProgress.updateQueue :: any + local currentQueue: UpdateQueue = current.updateQueue :: any if queue == currentQueue then - local clone: UpdateQueue = { - baseState = currentQueue.baseState, - firstBaseUpdate = currentQueue.firstBaseUpdate, - lastBaseUpdate = currentQueue.lastBaseUpdate, - shared = currentQueue.shared, - effects = currentQueue.effects, - } + -- ROBLOX deviation START: use our queue method for hot path optimizations + local clone: UpdateQueue = table.clone(currentQueue) + -- ROBLOX deviation END workInProgress.updateQueue = clone end end +exports.cloneUpdateQueue = cloneUpdateQueue -exports.createUpdate = function(eventTime: number, lane: Lane): Update - local update: Update = { +-- ROBLOX deviation START: add extra parameters here so updates can be create in single table ctor +local function createUpdate( + eventTime: number, + lane: Lane, + payload: any?, + callback: (() -> ...any)? +): Update + -- ROBLOX performance: Use pooled update object when available + if updatePoolIndex > 0 then + local update = updatePool[updatePoolIndex] + updatePool[updatePoolIndex] = nil + updatePoolIndex -= 1 + + update.eventTime = eventTime + update.lane = lane + update.tag = UpdateState + update.payload = payload + update.callback = callback :: any + + return update :: Update + end + -- ROBLOX deviation END + + -- ROBLOX performance FIXME: This warning is very noisy in practice and not + -- actionable by Roact developers in any way. We should re-establish the + -- warning once we've done some tuning and thought more about what messaging + -- we want to convey to Roact users + + -- if __DEV__ then + -- poolAdditionalSize += 1 + -- console.warn( + -- "ReactUpdateQueue createUpdate's object pool exhausted, allocating fresh table." + -- .. "\nConsider setting poolInitSize to " .. poolInitSize + poolAdditionalSize + -- .. " to avoid this occurring in the future." + -- ) + -- end + + local update = { eventTime = eventTime, lane = lane, tag = UpdateState, - payload = nil, - callback = nil, + payload = payload, + callback = callback :: any, next = nil, } - return update + return update :: Update end +exports.createUpdate = createUpdate --- deviation: FIXME proper function signature once we have better luau generics --- enqueueUpdate(fiber: Fiber, update: Update) -exports.enqueueUpdate = function(fiber: Fiber, update: Update) +local function enqueueUpdate(fiber: Fiber, update: Update) local updateQueue = fiber.updateQueue if updateQueue == nil then -- Only occurs if the fiber has been unmounted. return end - local sharedQueue: SharedQueue = updateQueue.shared + local sharedQueue: SharedQueue = (updateQueue :: any).shared local pending = sharedQueue.pending if pending == nil then -- This is the first update. Create a circular list. @@ -252,40 +295,30 @@ exports.enqueueUpdate = function(fiber: Fiber, update: Update) end sharedQueue.pending = update - if _G.__DEV__ then - if - currentlyProcessingQueue == sharedQueue and - not didWarnUpdateInsideUpdate - then + if __DEV__ then + if currentlyProcessingQueue == sharedQueue and not didWarnUpdateInsideUpdate then console.error( - "An update (setState, replaceState, or forceUpdate) was scheduled " .. - "from inside an update function. Update functions should be pure, " .. - "with zero side-effects. Consider using componentDidUpdate or a " .. - "callback." + "An update (setState, replaceState, or forceUpdate) was scheduled " + .. "from inside an update function. Update functions should be pure, " + .. "with zero side-effects. Consider using componentDidUpdate or a " + .. "callback." ) didWarnUpdateInsideUpdate = true end end end +exports.enqueueUpdate = enqueueUpdate --- deviation: FIXME proper function signature once we have better luau generics --- exports.enqueueCapturedUpdate( --- workInProgress: Fiber, --- capturedUpdate: Update, --- ) -exports.enqueueCapturedUpdate = function( - workInProgress: Fiber, - capturedUpdate: Update -) +local function enqueueCapturedUpdate(workInProgress: Fiber, capturedUpdate: Update) -- Captured updates are updates that are thrown by a child during the render -- phase. They should be discarded if the render is aborted. Therefore, -- we should only put them on the work-in-progress queue, not the current one. - local queue: UpdateQueue = workInProgress.updateQueue + local queue: UpdateQueue = workInProgress.updateQueue :: any -- Check if the work-in-progress queue is a clone. local current = workInProgress.alternate if current ~= nil then - local currentQueue: UpdateQueue = current.updateQueue + local currentQueue: UpdateQueue = current.updateQueue :: any if queue == currentQueue then -- The work-in-progress queue is the same as current. This happens when -- we bail out on a parent fiber that then captures an error thrown by @@ -300,7 +333,7 @@ exports.enqueueCapturedUpdate = function( -- Loop through the updates and clone them. local update = firstBaseUpdate repeat - local clone: Update = { + local clone: Update = { eventTime = update.eventTime, lane = update.lane, @@ -317,7 +350,8 @@ exports.enqueueCapturedUpdate = function( newLast.next = clone newLast = clone end - update = update.next + -- ROBLOX FIXME Luau: Luau needs to support repeat until nil pattern + update = update.next :: Update until update == nil -- Append the captured update the end of the cloned list. @@ -354,48 +388,41 @@ exports.enqueueCapturedUpdate = function( end queue.lastBaseUpdate = capturedUpdate end +exports.enqueueCapturedUpdate = enqueueCapturedUpdate --- FIXME (roblox): function generics --- function getStateFromUpdate( --- workInProgress: Fiber, --- queue: UpdateQueue, --- update: Update, --- prevState: State, --- nextProps: any, --- instance: any, --- ): any { -local function getStateFromUpdate( +local function getStateFromUpdate( workInProgress: Fiber, - queue: UpdateQueue, - update: Update, - prevState: any, + queue: UpdateQueue, + update: Update, + prevState: State, nextProps: any, instance: any ): any - if update.tag == ReplaceState then + local updateTag = update.tag + if updateTag == ReplaceState then local payload = update.payload - if typeof(payload) == "function" then + if type(payload) == "function" then -- Updater function - if _G.__DEV__ then - warn("Skip `enterDisallowedContextReadInDEV` (cycles)") - -- enterDisallowedContextReadInDEV() + if __DEV__ then + enterDisallowedContextReadInDEV() end - local nextState = payload(instance, prevState, nextProps) - if _G.__DEV__ then + -- ROBLOX deviation: Upstream binds this callback to the instance; + -- in order for us to get the same behavior, we'd need to change the + -- signature of the updater, which doesn't make sense for our case + local nextState = payload(prevState, nextProps) + if __DEV__ then if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 then disableLogs() -- ROBLOX deviation: YOLO flag for disabling pcall local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - payload(instance, prevState, nextProps) - end) + if not __YOLO__ then + ok, result = xpcall(payload, describeError, prevState, nextProps) else ok = true - payload(instance, prevState, nextProps) + payload(prevState, nextProps) end -- finally reenableLogs() @@ -404,43 +431,44 @@ local function getStateFromUpdate( error(result) end end - warn("Skip `exitDisallowedContextReadInDEV` (cycles)") - -- exitDisallowedContextReadInDEV() + exitDisallowedContextReadInDEV() end return nextState end -- State object return payload - elseif update.tag == CaptureUpdate or update.tag == UpdateState then - if update.tag == CaptureUpdate then - workInProgress.flags = - bit32.bor(bit32.band(workInProgress.flags, bit32.bnot(ShouldCapture)), DidCapture) + elseif updateTag == CaptureUpdate or updateTag == UpdateState then + if updateTag == CaptureUpdate then + workInProgress.flags = bit32.bor( + bit32.band(workInProgress.flags, bit32.bnot(ShouldCapture)), + DidCapture + ) end -- Intentional fallthrough local payload = update.payload local partialState - if typeof(payload) == "function" then + if type(payload) == "function" then -- Updater function - if _G.__DEV__ then - warn("Skip `enterDisallowedContextReadInDEV` (cycles)") - -- enterDisallowedContextReadInDEV() + if __DEV__ then + enterDisallowedContextReadInDEV() end - partialState = payload(instance, prevState, nextProps) - if _G.__DEV__ then + -- ROBLOX deviation: Upstream binds this callback to the instance; + -- in order for us to get the same behavior, we'd need to change the + -- signature of the updater, which doesn't make sense for our case + partialState = payload(prevState, nextProps) + if __DEV__ then if - debugRenderPhaseSideEffectsForStrictMode and - bit32.band(workInProgress.mode, StrictMode) ~= 0 + debugRenderPhaseSideEffectsForStrictMode + and bit32.band(workInProgress.mode, StrictMode) ~= 0 then disableLogs() -- ROBLOX deviation: YOLO flag for disabling pcall local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - payload(instance, prevState, nextProps) - end) + if not __YOLO__ then + ok, result = xpcall(payload, describeError, prevState, nextProps) else ok = true - payload(instance, prevState, nextProps) + payload(prevState, nextProps) end -- finally reenableLogs() @@ -449,8 +477,7 @@ local function getStateFromUpdate( error(result) end end - warn("Skip `exitDisallowedContextReadInDEV` (cycles)") - -- exitDisallowedContextReadInDEV() + exitDisallowedContextReadInDEV() end else -- Partial state object @@ -461,8 +488,10 @@ local function getStateFromUpdate( return prevState end -- Merge the partial state and the previous state. + -- ROBLOX TODO: the below optimziation doesn't work because: invalid argument #1 to 'clone' (table has a protected metatable) + -- local newState = if prevState ~= nil then table.clone(prevState :: any) else {} return Object.assign({}, prevState, partialState) - elseif update.tag == ForceUpdate then + elseif updateTag == ForceUpdate then hasForceUpdate = true return prevState end @@ -470,29 +499,24 @@ local function getStateFromUpdate( end exports.getStateFromUpdate = getStateFromUpdate --- FIXME (roblox): function generics --- processUpdateQueue(...) -exports.processUpdateQueue = function( +local function processUpdateQueue( workInProgress: Fiber, props: any, instance: any, renderLanes: Lanes -) +): () -- This is always non-null on a ClassComponent or HostRoot - -- FIXME (roblox): function generics, type coercion - -- local queue: UpdateQueue = (workInProgress.updateQueue: any) - local queue: UpdateQueue = workInProgress.updateQueue + local queue: UpdateQueue = workInProgress.updateQueue :: any hasForceUpdate = false - if _G.__DEV__ then + if __DEV__ then currentlyProcessingQueue = queue.shared end local firstBaseUpdate = queue.firstBaseUpdate local lastBaseUpdate = queue.lastBaseUpdate - -- Check if there are pending updates. If so, transfer them to the base queue. local pendingQueue = queue.shared.pending if pendingQueue ~= nil then @@ -519,9 +543,7 @@ exports.processUpdateQueue = function( local current = workInProgress.alternate if current ~= nil then -- This is always non-null on a ClassComponent or HostRoot - -- FIXME (roblox): function generics, type refinement - -- local currentQueue: UpdateQueue = (current.updateQueue: any) - local currentQueue: UpdateQueue = current.updateQueue + local currentQueue: UpdateQueue = current.updateQueue :: any local currentLastBaseUpdate = currentQueue.lastBaseUpdate if currentLastBaseUpdate ~= lastBaseUpdate then if currentLastBaseUpdate == nil then @@ -554,9 +576,7 @@ exports.processUpdateQueue = function( -- Priority is insufficient. Skip this update. If this is the first -- skipped update, the previous update/state is the new base -- update/state. - -- FIXME (roblox): function generics - -- local clone: Update = { - local clone: Update = { + local clone: Update = { eventTime = updateEventTime, lane = updateLane, @@ -580,9 +600,7 @@ exports.processUpdateQueue = function( -- This update does have sufficient priority. if newLastBaseUpdate ~= nil then - -- FIXME (roblox): function generics - -- local clone: Update = { - local clone: Update = { + local clone: Update = { eventTime = updateEventTime, -- This update is going to be committed so we never want uncommit -- it. Using NoLane works because 0 is a subset of all bitmasks, so @@ -609,17 +627,23 @@ exports.processUpdateQueue = function( instance ) local callback = update.callback - if callback ~= nil then + if + callback ~= nil + -- If the update was already committed, we should not queue its + -- callback again. + and update.lane ~= NoLane + then workInProgress.flags = bit32.bor(workInProgress.flags, Callback) local effects = queue.effects if effects == nil then - queue.effects = {update} + queue.effects = { update } else table.insert(effects, update) end end end - update = update.next + -- ROBLOX FIXME Luau: Luau needs to support repeat until nil pattern + update = update.next :: Update if update == nil then pendingQueue = queue.shared.pending if pendingQueue == nil then @@ -630,9 +654,9 @@ exports.processUpdateQueue = function( local lastPendingUpdate = pendingQueue -- Intentionally unsound. Pending updates form a circular list, but we -- unravel them when transferring them to the base queue. - -- FIXME (roblox): type coercion - -- local firstPendingUpdate = ((lastPendingUpdate.next: any): Update) - local firstPendingUpdate = lastPendingUpdate.next + local firstPendingUpdate = ( + lastPendingUpdate.next :: any + ) :: Update lastPendingUpdate.next = nil update = firstPendingUpdate queue.lastBaseUpdate = lastPendingUpdate @@ -645,9 +669,7 @@ exports.processUpdateQueue = function( newBaseState = newState end - -- FIXME (roblox): type coercion - -- queue.baseState = ((newBaseState: any): State) - queue.baseState = newBaseState + queue.baseState = (newBaseState :: any) :: State queue.firstBaseUpdate = newFirstBaseUpdate queue.lastBaseUpdate = newLastBaseUpdate @@ -663,18 +685,24 @@ exports.processUpdateQueue = function( workInProgress.memoizedState = newState end - if _G.__DEV__ then + if __DEV__ then currentlyProcessingQueue = nil end end - -function callCallback(callback, context) - invariant( - typeof(callback) == 'function', - 'Invalid argument passed as callback. Expected a function. Instead ' .. - 'received: %s', - callback - ) +exports.processUpdateQueue = processUpdateQueue + +local function callCallback(callback, context) + -- ROBLOX deviation START: use if-then-error, which avoid string format and function call overhead, as in React 18 + if type(callback) ~= "function" then + error( + string.format( + "Invalid argument passed as callback. Expected a function. Instead " + .. "received: %s", + tostring(callback) + ) + ) + -- ROBLOX deviation END + end callback(context) end @@ -686,26 +714,28 @@ exports.checkHasForceUpdateAfterProcessing = function(): boolean return hasForceUpdate end --- deviation: FIXME generics in function signatures --- 'commitUpdateQueue(...): void' -exports.commitUpdateQueue = function( +local function commitUpdateQueue( finishedWork: Fiber, - finishedQueue: UpdateQueue, + finishedQueue: UpdateQueue, instance: any -) +): () -- Commit the effects local effects = finishedQueue.effects finishedQueue.effects = nil if effects ~= nil then - for i = 1, #effects do - local effect = effects[i] + for _, effect in effects do local callback = effect.callback if callback ~= nil then - effect.callback = nil callCallback(callback, instance) end + + -- ROBLOX performance: return this object to the pool + table.clear(effect) + table.insert(updatePool, effect :: any) + updatePoolIndex += 1 end end end +exports.commitUpdateQueue = commitUpdateQueue return exports diff --git a/modules/react-reconciler/src/ReactWorkTags.lua b/modules/react-reconciler/src/ReactWorkTags.lua index bd8b4eef..03f029e7 100644 --- a/modules/react-reconciler/src/ReactWorkTags.lua +++ b/modules/react-reconciler/src/ReactWorkTags.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactWorkTags.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/ReactWorkTags.js --!strict --[[* * Copyright (c) Facebook, Inc. and its affiliates. @@ -12,29 +12,29 @@ export type WorkTag = number return { - FunctionComponent = 0, - ClassComponent = 1, - IndeterminateComponent = 2; -- Before we know whether it is function or class - HostRoot = 3; -- Root of a host tree. Could be nested inside another node. - HostPortal = 4; -- A subtree. Could be an entry point to a different renderer. - HostComponent = 5, - HostText = 6, - Fragment = 7, - Mode = 8, - ContextConsumer = 9, - ContextProvider = 10, - ForwardRef = 11, - Profiler = 12, - SuspenseComponent = 13, - MemoComponent = 14, - SimpleMemoComponent = 15, - LazyComponent = 16, - IncompleteClassComponent = 17, - DehydratedFragment = 18, - SuspenseListComponent = 19, - FundamentalComponent = 20, - ScopeComponent = 21, - Block = 22, - OffscreenComponent = 23, - LegacyHiddenComponent = 24, + FunctionComponent = 0, + ClassComponent = 1, + IndeterminateComponent = 2, -- Before we know whether it is function or class + HostRoot = 3, -- Root of a host tree. Could be nested inside another node. + HostPortal = 4, -- A subtree. Could be an entry point to a different renderer. + HostComponent = 5, + HostText = 6, + Fragment = 7, + Mode = 8, + ContextConsumer = 9, + ContextProvider = 10, + ForwardRef = 11, + Profiler = 12, + SuspenseComponent = 13, + MemoComponent = 14, + SimpleMemoComponent = 15, + LazyComponent = 16, + IncompleteClassComponent = 17, + DehydratedFragment = 18, + SuspenseListComponent = 19, + FundamentalComponent = 20, + ScopeComponent = 21, + Block = 22, + OffscreenComponent = 23, + LegacyHiddenComponent = 24, } diff --git a/modules/react-reconciler/src/RobloxReactProfiling.lua b/modules/react-reconciler/src/RobloxReactProfiling.lua new file mode 100644 index 00000000..362d7802 --- /dev/null +++ b/modules/react-reconciler/src/RobloxReactProfiling.lua @@ -0,0 +1,239 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +-- Targeted performance insights for Roblox Microprofiler +local Packages = script.Parent.Parent +local getComponentName = require(Packages.Shared).getComponentName +local ReactWorkTags = require(script.Parent.ReactWorkTags) + +local ReactInternalTypes = require(script.Parent.ReactInternalTypes) +type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot + +-- ReactMicroprofilerLevel Levels -- +local LEVEL_ROOTS_LOG_ONLY = 1 -- Level 1: Roots for Logging only +local LEVEL_ROOTS = 5 -- Level 5: Roots + Commit time in Microprofiler +local LEVEL_FIBERS = 10 -- Level 10: Individual Fiber "units of work" in Microprofiler + +local loadedFlag, ReactMicroprofilerLevel = pcall(function() + return game:DefineFastInt("ReactMicroprofilerLevel5", 0) +end) +if not loadedFlag then + ReactMicroprofilerLevel = 0 +end + +export type Marker = { + id: string, + startTime: number, + endTime: number, +} + +export type SamplerCallback = (Marker) -> () +function noop(...: unknown) end + +local enableRootSampling = false +local timerSamplingCallback: SamplerCallback | nil = nil + +-- used to inhibit profileend() calls that no longer match the originating profilebegin(...) frame +local numActiveProfilesInFrame = 0 + +if ReactMicroprofilerLevel >= LEVEL_ROOTS then + game:GetService("RunService").Heartbeat:Connect(function() + numActiveProfilesInFrame = 0 + end) +end + +local microprofiler = if ReactMicroprofilerLevel >= LEVEL_ROOTS + then { + profilebegin = function(...) + debug.profilebegin(...) + numActiveProfilesInFrame += 1 + end, + profileend = function() + if numActiveProfilesInFrame > 0 then + debug.profileend() + numActiveProfilesInFrame -= 1 + end + end, + } + else { + profilebegin = noop, + profileend = noop, + } + +function startTimerSampling(timerSamplingCallbackFn: SamplerCallback) + if enableRootSampling then + warn("RobloxReactProfiling Timer Sampling already running.") + end + enableRootSampling = true + timerSamplingCallback = timerSamplingCallbackFn +end + +function endTimerSampling() + enableRootSampling = false + timerSamplingCallback = nil +end + +function getFirstStringKey(t: { any: any }) + for key, _ in t do + if type(key) == "string" then + return key + end + end + return nil +end + +function startTimer(marker: Marker) + if enableRootSampling then + marker.startTime = os.clock() + end +end +function endTimer(marker: Marker) + if enableRootSampling then + marker.endTime = os.clock() + if timerSamplingCallback then + timerSamplingCallback(marker) + end + end +end + +function profileRootBeforeUnitOfWork(root: FiberRoot): Marker? + local rootFiber = root.current + local profileId = nil + + if rootFiber then + if rootFiber.memoizedProps then + -- expecting props table with single item + profileId = getFirstStringKey(rootFiber.memoizedProps) + end + + if + profileId == nil + and rootFiber.stateNode + and rootFiber.stateNode.containerInfo + then + profileId = rootFiber.stateNode.containerInfo.Name + end + end + + -- note: investigate HostRoot vs HostPortal for this condition + if profileId == "Folder" and rootFiber.child then + local fiber = rootFiber.child + local folderProfileId = nil + if fiber.memoizedProps then + -- expecting props table with single item + folderProfileId = getFirstStringKey(fiber.memoizedProps) + end + + if + folderProfileId == nil + and fiber.stateNode + and fiber.stateNode.containerInfo + then + folderProfileId = fiber.stateNode.containerInfo.Name + end + if folderProfileId ~= nil then + profileId = folderProfileId + end + end + + if profileId ~= nil then + local marker = { + id = profileId, + startTime = 0, + endTime = 0, + } + startTimer(marker) + microprofiler.profilebegin(profileId) + return marker + end + + return nil +end + +function profileRootAfterYielding(marker: Marker?) + if marker then + endTimer(marker) + microprofiler.profileend() + end +end + +function profileUnitOfWorkBefore(unitOfWork: Fiber) + local profileId = getComponentName(unitOfWork.type) + + if unitOfWork.key then + profileId = tostring(unitOfWork.key) .. "=" .. (profileId or "?") + end + + local rootName = nil + if unitOfWork.stateNode then + if + unitOfWork.tag == ReactWorkTags.HostComponent + or unitOfWork.tag == ReactWorkTags.HostText + then + local layerCollector = + unitOfWork.stateNode:FindFirstAncestorWhichIsA("LayerCollector") + if layerCollector then + rootName = "[" .. layerCollector:GetFullName() .. "] " + end + end + end + + if rootName then + profileId = rootName .. " : " .. (profileId or "?") + end + + if profileId ~= nil then + microprofiler.profilebegin(profileId) + return true + end + + return false +end + +function profileUnitOfWorkAfter(profileRunning: boolean) + if profileRunning then + microprofiler.profileend() + end +end + +function profileCommitBefore() + microprofiler.profilebegin("Commit") +end +function profileCommitAfter() + microprofiler.profileend() +end + +return { + startTimerSampling = startTimerSampling, + endTimerSampling = endTimerSampling, + profileRootBeforeUnitOfWork = if ReactMicroprofilerLevel >= LEVEL_ROOTS_LOG_ONLY + then profileRootBeforeUnitOfWork + else noop, + profileRootAfterYielding = if ReactMicroprofilerLevel >= LEVEL_ROOTS_LOG_ONLY + then profileRootAfterYielding + else noop, + profileUnitOfWorkBefore = if ReactMicroprofilerLevel >= LEVEL_FIBERS + then profileUnitOfWorkBefore + else noop, + profileUnitOfWorkAfter = if ReactMicroprofilerLevel >= LEVEL_FIBERS + then profileUnitOfWorkAfter + else noop, + profileCommitBefore = if ReactMicroprofilerLevel >= LEVEL_ROOTS + then profileCommitBefore + else noop, + profileCommitAfter = if ReactMicroprofilerLevel >= LEVEL_ROOTS + then profileCommitAfter + else noop, +} diff --git a/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua b/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua index e363f154..d4dc377f 100644 --- a/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua +++ b/modules/react-reconciler/src/SchedulerWithReactIntegration.new.lua @@ -1,5 +1,5 @@ --- 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 +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/d17086c7c813402a550d15a2f56dc43f1dbd1735/packages/react-reconciler/src/SchedulerWithReactIntegration.new.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -10,11 +10,12 @@ ]] local Packages = script.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local ReactInternalTypes = require(script.Parent.ReactInternalTypes) -export type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel; +export type ReactPriorityLevel = ReactInternalTypes.ReactPriorityLevel -- Intentionally not named imports because Rollup would use dynamic dispatch for -- CommonJS interop named imports. @@ -22,8 +23,10 @@ local Scheduler = require(Packages.Scheduler) -- local __interactionsRef = require(Packages.Scheduler.tracing).__interactionsRef local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags -- local enableSchedulerTracing = ReactFeatureFlags.enableSchedulerTracing -local decoupleUpdatePriorityFromScheduler = ReactFeatureFlags.decoupleUpdatePriorityFromScheduler +local decoupleUpdatePriorityFromScheduler = + ReactFeatureFlags.decoupleUpdatePriorityFromScheduler local invariant = require(Packages.Shared).invariant +local describeError = require(Packages.Shared).describeError local ReactFiberLane = require(script.Parent.ReactFiberLane) local SyncLanePriority = ReactFiberLane.SyncLanePriority local getCurrentUpdateLanePriority = ReactFiberLane.getCurrentUpdateLanePriority @@ -45,7 +48,8 @@ local Scheduler_IdlePriority = Scheduler.unstable_IdlePriority -- deviation: Instead of defining these here, we require them from a small file -- with _just_ these constant definitions; it helps us avoid a circular require -- issue with `ReactFiberLanes` -local ReactFiberSchedulerPriorities = require(script.Parent["ReactFiberSchedulerPriorities.roblox"]) +local ReactFiberSchedulerPriorities = + require(script.Parent["ReactFiberSchedulerPriorities.roblox"]) local ImmediatePriority = ReactFiberSchedulerPriorities.ImmediatePriority local UserBlockingPriority = ReactFiberSchedulerPriorities.UserBlockingPriority local NormalPriority = ReactFiberSchedulerPriorities.NormalPriority @@ -54,7 +58,7 @@ local IdlePriority = ReactFiberSchedulerPriorities.IdlePriority local NoPriority = ReactFiberSchedulerPriorities.NoPriority -- deviation: common types -type Array = { [number]: T }; +type Array = { [number]: T } -- deviation: pre-declare functions local flushSyncCallbackQueueImpl @@ -73,23 +77,21 @@ local flushSyncCallbackQueueImpl -- ) -- end --- FIXME (roblox): Use self-recursive type once supported --- export type SchedulerCallback = (isSync: boolean) -> SchedulerCallback | nil; -export type SchedulerCallback = (boolean) -> any?; +export type SchedulerCallback = (isSync: boolean) -> SchedulerCallback | nil --- FIXME (roblox): Use better syntax for incomplete definitions +-- ROBLOX deviation START: don't allow extension unless we need to -- type SchedulerCallbackOptions = { timeout: number?, ... }; -type SchedulerCallbackOptions = { timeout: number? } | { [any]: any }; - +type SchedulerCallbackOptions = { timeout: number? } +-- ROBLOX deviation END local fakeCallbackNode = {} local shouldYield = Scheduler_shouldYield local requestPaint -- Fall back gracefully if we're running an older version of Scheduler. if Scheduler_requestPaint ~= nil then - requestPaint = Scheduler_requestPaint + requestPaint = Scheduler_requestPaint else - requestPaint = function() end + requestPaint = function() end end local syncQueue: Array? = nil @@ -104,7 +106,7 @@ local initialTimeMs: number = Scheduler_now() -- -- the behavior of performance.now and keep our times small enough to fit -- -- within 32 bits. -- -- TODO: Consider lifting this into Scheduler. --- -- FIXME (roblox): properly account for ms vs s from tick +-- ROBLOX FIXME: properly account for ms vs s from tick -- local now = initialTimeMs < 10000 -- and Scheduler_now -- or function() @@ -113,232 +115,245 @@ local initialTimeMs: number = Scheduler_now() -- deviation: Roblox uses `tick` under the hood, which is more like the unix -- timestamp behavior referenced above -local function now() - return Scheduler_now() - initialTimeMs +local function now(): number + return Scheduler_now() - initialTimeMs end local function getCurrentPriorityLevel(): ReactPriorityLevel - local currentPriorityLevel = Scheduler_getCurrentPriorityLevel() - if currentPriorityLevel == Scheduler_ImmediatePriority then - return ImmediatePriority - elseif currentPriorityLevel == Scheduler_UserBlockingPriority then - return UserBlockingPriority - elseif currentPriorityLevel == Scheduler_NormalPriority then - return NormalPriority - elseif currentPriorityLevel == Scheduler_LowPriority then - return LowPriority - elseif currentPriorityLevel == Scheduler_IdlePriority then - return IdlePriority - else - invariant(false, "Unknown priority level.") - -- deviation: FIXME: Luau needs explicit return here - return NoPriority - end + local currentPriorityLevel = Scheduler_getCurrentPriorityLevel() + if currentPriorityLevel == Scheduler_ImmediatePriority then + return ImmediatePriority + elseif currentPriorityLevel == Scheduler_UserBlockingPriority then + return UserBlockingPriority + elseif currentPriorityLevel == Scheduler_NormalPriority then + return NormalPriority + elseif currentPriorityLevel == Scheduler_LowPriority then + return LowPriority + elseif currentPriorityLevel == Scheduler_IdlePriority then + return IdlePriority + else + invariant(false, "Unknown priority level.") + -- deviation: FIXME: Luau needs explicit return here + return NoPriority + end end function reactPriorityToSchedulerPriority(reactPriorityLevel) - if reactPriorityLevel == ImmediatePriority then - return Scheduler_ImmediatePriority - elseif reactPriorityLevel == UserBlockingPriority then - return Scheduler_UserBlockingPriority - elseif reactPriorityLevel == NormalPriority then - return Scheduler_NormalPriority - elseif reactPriorityLevel == LowPriority then - return Scheduler_LowPriority - elseif reactPriorityLevel == IdlePriority then - return Scheduler_IdlePriority - else - invariant(false, "Unknown priority level.") - -- deviation: return appeases Luau - return nil - end + if reactPriorityLevel == ImmediatePriority then + return Scheduler_ImmediatePriority + elseif reactPriorityLevel == UserBlockingPriority then + return Scheduler_UserBlockingPriority + elseif reactPriorityLevel == NormalPriority then + return Scheduler_NormalPriority + elseif reactPriorityLevel == LowPriority then + return Scheduler_LowPriority + elseif reactPriorityLevel == IdlePriority then + return Scheduler_IdlePriority + else + invariant(false, "Unknown priority level.") + -- deviation: return appeases Luau + return nil + end end --- FIXME (roblox): restore proper type defs when we have function generics --- exports.runWithPriority( --- reactPriorityLevel: ReactPriorityLevel, --- fn: () => T, --- ): T { -local function runWithPriority( - reactPriorityLevel: ReactPriorityLevel, - fn: () -> any -): any - local priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel) - return Scheduler_runWithPriority(priorityLevel, fn) +-- ROBLOX FIXME Luau: should be T... but hits CLI-50289: failure to unify +local function runWithPriority( + reactPriorityLevel: ReactPriorityLevel, + fn: () -> T... +): ...any + local priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel) + return Scheduler_runWithPriority(priorityLevel, fn) end local function scheduleCallback( - reactPriorityLevel: ReactPriorityLevel, - callback: SchedulerCallback, - options: SchedulerCallbackOptions | nil + reactPriorityLevel: ReactPriorityLevel, + callback: SchedulerCallback, + options: SchedulerCallbackOptions | nil ) - local priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel) - return Scheduler_scheduleCallback(priorityLevel, callback, options) + local priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel) + return Scheduler_scheduleCallback(priorityLevel, callback, options) end local function scheduleSyncCallback(callback: SchedulerCallback) - -- Push this callback into an internal queue. We'll flush these either in - -- the next tick, or earlier if something calls `flushSyncCallbackQueue`. - if syncQueue == nil then - syncQueue = { callback } - -- Flush the queue in the next tick, at the earliest. - immediateQueueCallbackNode = Scheduler_scheduleCallback( - Scheduler_ImmediatePriority, - flushSyncCallbackQueueImpl - ) - else - -- Push onto existing queue. Don't need to schedule a callback because - -- we already scheduled one when we created the queue. - -- FIXME (roblox): need better type refinement - local coercedSyncQueue: any = syncQueue - table.insert(coercedSyncQueue, callback) - end - return fakeCallbackNode + -- Push this callback into an internal queue. We'll flush these either in + -- the next tick, or earlier if something calls `flushSyncCallbackQueue`. + if syncQueue == nil then + syncQueue = { callback } + -- Flush the queue in the next tick, at the earliest. + immediateQueueCallbackNode = Scheduler_scheduleCallback( + Scheduler_ImmediatePriority, + flushSyncCallbackQueueImpl + ) + else + -- Push onto existing queue. Don't need to schedule a callback because + -- we already scheduled one when we created the queue. + local coercedSyncQueue = syncQueue + table.insert(coercedSyncQueue, callback) + end + return fakeCallbackNode end local function cancelCallback(callbackNode: any) - if callbackNode ~= fakeCallbackNode then - Scheduler_cancelCallback(callbackNode) - end + if callbackNode ~= fakeCallbackNode then + Scheduler_cancelCallback(callbackNode) + end end local function flushSyncCallbackQueue(): boolean - if immediateQueueCallbackNode ~= nil then - local node = immediateQueueCallbackNode - immediateQueueCallbackNode = nil - Scheduler_cancelCallback(node) - end - return flushSyncCallbackQueueImpl() + if immediateQueueCallbackNode ~= nil then + local node = immediateQueueCallbackNode + immediateQueueCallbackNode = nil + Scheduler_cancelCallback(node) + end + return flushSyncCallbackQueueImpl() end flushSyncCallbackQueueImpl = function() - if not isFlushingSyncQueue and syncQueue ~= nil then - -- Prevent re-entrancy. - isFlushingSyncQueue = true - local i = 1 - if decoupleUpdatePriorityFromScheduler then - local previousLanePriority = getCurrentUpdateLanePriority() - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - local isSync = true - local queue = syncQueue + if not isFlushingSyncQueue and syncQueue ~= nil then + -- Prevent re-entrancy. + isFlushingSyncQueue = true + local i = 1 + if decoupleUpdatePriorityFromScheduler then + local previousLanePriority = getCurrentUpdateLanePriority() + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok = true + local result + if not ReactGlobals.__YOLO__ then + -- ROBLOX performance: hoist non-throwables out of try{} to eliminate anon function + local isSync = true + local queue = syncQueue + + setCurrentUpdateLanePriority(SyncLanePriority) + ok, result = xpcall( + runWithPriority, + describeError, + ImmediatePriority, + -- ROBLOX FIXME Luau: Luau sees this as returning void, but then sees an explicit return in runWithPriority and errors + function(): ...any + for index, callback in queue do + i = index + repeat + -- ROBLOX FIXME Luau: Luau doesn't understand loop until nil construct + callback = callback(isSync) :: any + until callback == nil + end + end + ) + syncQueue = nil + else + ok = true + local isSync = true + local queue = syncQueue - setCurrentUpdateLanePriority(SyncLanePriority) - runWithPriority(ImmediatePriority, function() - for index, callback in ipairs(queue) do - i = index - repeat - callback = callback(isSync) - until callback == nil - end - end) - syncQueue = nil - end) - else - ok = true - local isSync = true - local queue = syncQueue + setCurrentUpdateLanePriority(SyncLanePriority) + -- ROBLOX FIXME Luau: Luau sees this as returning void, but then sees an explicit return in runWithPriority and errors + runWithPriority(ImmediatePriority, function(): ...any + for index, callback in queue do + i = index + repeat + -- ROBLOX FIXME Luau: Luau doesn't understand loop until nil construct + callback = callback(isSync) :: any + until callback == nil + i += 1 + end + end) + syncQueue = nil + end - setCurrentUpdateLanePriority(SyncLanePriority) - runWithPriority(ImmediatePriority, function() - while i <= #queue do - local callback = queue[i] - repeat - callback = callback(isSync) - until callback == nil - i += 1 - end - end) - syncQueue = nil - end + -- finally + setCurrentUpdateLanePriority(previousLanePriority) + isFlushingSyncQueue = false - -- finally - setCurrentUpdateLanePriority(previousLanePriority) - isFlushingSyncQueue = false + if not ok then + -- If something throws, leave the remaining callbacks on the queue. + if syncQueue ~= nil then + syncQueue = Array.slice(syncQueue, i + 1) + end + -- Resume flushing in the next tick + Scheduler_scheduleCallback( + Scheduler_ImmediatePriority, + flushSyncCallbackQueue + ) + error(result) + end + else + -- ROBLOX deviation: YOLO flag for disabling pcall + local ok, result + if not ReactGlobals.__YOLO__ then + -- ROBLOX performance: hoist non-throwables out of try{} to eliminate anon function + local isSync = true + local queue = syncQueue - if not ok then - -- If something throws, leave the remaining callbacks on the queue. - if syncQueue ~= nil then - syncQueue = Array.slice(syncQueue, i + 1) - end - -- Resume flushing in the next tick - Scheduler_scheduleCallback( - Scheduler_ImmediatePriority, - flushSyncCallbackQueue - ) - error(result) - end - else - -- ROBLOX deviation: YOLO flag for disabling pcall - local ok, result - if not _G.__YOLO__ then - ok, result = pcall(function() - local isSync = true - local queue = syncQueue - runWithPriority(ImmediatePriority, function() - for index, callback in ipairs(queue) do - i = index - repeat - callback = callback(isSync) - until callback == nil - end - end) - syncQueue = nil - end) - else - ok = true - local isSync = true - local queue = syncQueue - runWithPriority(ImmediatePriority, function() - for index, callback in ipairs(queue) do - i = index - repeat - callback = callback(isSync) - until callback == nil - end - end) - syncQueue = nil - end + ok, result = xpcall( + runWithPriority, + describeError, + ImmediatePriority, + -- ROBLOX FIXME Luau: Luau sees this as returning void, but then sees an explicit return in runWithPriority and errors + function(): ...any + for index, callback in queue do + i = index + repeat + -- ROBLOX FIXME Luau: Luau doesn't understand loop until nil construct + callback = callback(isSync) :: any + until callback == nil + end + end + ) + syncQueue = nil + else + ok = true + local isSync = true + local queue = syncQueue + -- ROBLOX FIXME Luau: Luau sees this as returning void, but then sees an explicit return in runWithPriority and errors + runWithPriority(ImmediatePriority, function(): ...any + for index, callback in queue do + i = index + repeat + -- ROBLOX FIXME Luau: Luau doesn't understand loop until nil construct + callback = callback(isSync) :: any + until callback == nil + end + end) + syncQueue = nil + end - -- finally - isFlushingSyncQueue = false + -- finally + isFlushingSyncQueue = false - if not ok then - -- If something throws, leave the remaining callbacks on the queue. - if syncQueue ~= nil then - syncQueue = Array.slice(syncQueue, i + 1) - end - -- Resume flushing in the next tick - Scheduler_scheduleCallback( - Scheduler_ImmediatePriority, - flushSyncCallbackQueue - ) - error(result) - end - end - return true - else - return false - end + if not ok then + -- If something throws, leave the remaining callbacks on the queue. + if syncQueue ~= nil then + syncQueue = Array.slice(syncQueue, i + 1) + end + -- Resume flushing in the next tick + Scheduler_scheduleCallback( + Scheduler_ImmediatePriority, + flushSyncCallbackQueue + ) + error(result) + end + end + return true + else + return false + end end return { - ImmediatePriority = ImmediatePriority, - UserBlockingPriority = UserBlockingPriority, - NormalPriority = NormalPriority, - LowPriority = LowPriority, - IdlePriority = IdlePriority, - NoPriority = NoPriority, + ImmediatePriority = ImmediatePriority, + UserBlockingPriority = UserBlockingPriority, + NormalPriority = NormalPriority, + LowPriority = LowPriority, + IdlePriority = IdlePriority, + NoPriority = NoPriority, - getCurrentPriorityLevel = getCurrentPriorityLevel, - flushSyncCallbackQueue = flushSyncCallbackQueue, - runWithPriority = runWithPriority, - scheduleCallback = scheduleCallback, - scheduleSyncCallback = scheduleSyncCallback, - cancelCallback = cancelCallback, - now = now, - requestPaint = requestPaint, - shouldYield = shouldYield, + getCurrentPriorityLevel = getCurrentPriorityLevel, + flushSyncCallbackQueue = flushSyncCallbackQueue, + runWithPriority = runWithPriority, + scheduleCallback = scheduleCallback, + scheduleSyncCallback = scheduleSyncCallback, + cancelCallback = cancelCallback, + now = now, + requestPaint = requestPaint, + shouldYield = shouldYield, } diff --git a/modules/react-reconciler/src/SchedulingProfiler.lua b/modules/react-reconciler/src/SchedulingProfiler.lua index ac1f53b0..a666f002 100644 --- a/modules/react-reconciler/src/SchedulingProfiler.lua +++ b/modules/react-reconciler/src/SchedulingProfiler.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/SchedulingProfiler.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/56e9feead0f91075ba0a4f725c9e4e343bca1c67/packages/react-reconciler/src/SchedulingProfiler.js -- /** -- * Copyright (c) Facebook, Inc. and its affiliates. -- * @@ -8,9 +9,11 @@ -- * @flow -- */ -type WeakMap = { [K]: V } local exports = {} local Packages = script.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local WeakMap = LuauPolyfill.WeakMap +type WeakMap = LuauPolyfill.WeakMap local ReactFiberLane = require(script.Parent.ReactFiberLane) type Lane = ReactFiberLane.Lane @@ -18,6 +21,7 @@ type Lanes = ReactFiberLane.Lanes local ReactInternalTypes = require(script.Parent.ReactInternalTypes) type Fiber = ReactInternalTypes.Fiber +type FiberRoot = ReactInternalTypes.FiberRoot local ReactTypes = require(Packages.Shared) type Wakeable = ReactTypes.Wakeable @@ -31,152 +35,228 @@ local getComponentName = require(Packages.Shared).getComponentName -- * If performance exists and supports the subset of the User Timing API that we -- * require. -- */ --- ROBLOX TODO: maybe use our debug.profile* API? local supportsUserTiming = _G.performance ~= nil local performance = _G.performance + or { + mark = function(str) + debug.profilebegin(str) + debug.profileend() + end, + } function formatLanes(laneOrLanes: Lane | Lanes): string - return tostring(laneOrLanes) + return tostring(laneOrLanes) end +-- ROBLOX deviation START: profiler event callback +export type ProfilerEvent = number +export type ProfilerEventCallback = (type: ProfilerEvent, root: FiberRoot?) -> () +local profilerEventCallback: ProfilerEventCallback? = nil +local profilerEventTypes = { + CommitStart = 0, + CommitStop = 1, + LayoutEffectsStart = 2, + LayoutEffectsStop = 3, + PassiveEffectsStart = 4, + PassiveEffectsStop = 5, + RenderStart = 6, + RenderYield = 7, + RenderStop = 8, +} :: { [string]: ProfilerEvent } +-- ROBLOX deviation END + -- Create a mark on React initialization if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--react-init-" .. tostring(ReactVersion)) - end + if supportsUserTiming then + performance.mark("--react-init-" .. tostring(ReactVersion)) + end end exports.markCommitStarted = function(lanes: Lanes): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--commit-start-" .. formatLanes(lanes)) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--commit-start-" .. formatLanes(lanes)) + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.CommitStart) + end + end end -exports.markCommitStopped = function(): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark('--commit-stop') - end - end +exports.markCommitStopped = function(root: FiberRoot): () + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--commit-stop") + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.CommitStop, root) + end + end end -- ROBLOX deviation: we use our custom Map -- local PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map -- $FlowFixMe: Flow cannot handle polymorphic WeakMaps -local wakeableIDs: WeakMap = {} +local wakeableIDs: WeakMap = WeakMap.new() local wakeableID: number = 0 function getWakeableID(wakeable: Wakeable): number - if not wakeableIDs[wakeable] ~= nil then - wakeableIDs[wakeable] = wakeableID - wakeableID += 1 - end - return wakeableIDs[wakeable] -end - -exports.markComponentSuspended = function(fiber: Fiber, wakeable: Wakeable): () - if enableSchedulingProfiler then - if supportsUserTiming then - local id = getWakeableID(wakeable) - local componentName = getComponentName(fiber.type) or 'Unknown' - -- 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 - ) - end - end + if not wakeableIDs:has(wakeable) then + wakeableIDs:set(wakeable, wakeableID) + wakeableID += 1 + end + return wakeableIDs:get(wakeable) +end + +exports.markComponentSuspended = function(fiber: Fiber, wakeable: Wakeable): () + if enableSchedulingProfiler then + if supportsUserTiming then + local id = getWakeableID(wakeable) + local componentName = getComponentName(fiber.type) or "Unknown" + -- 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) + end + end end exports.markLayoutEffectsStarted = function(lanes: Lanes): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--layout-effects-start-" .. formatLanes(lanes)) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--layout-effects-start-" .. formatLanes(lanes)) + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.LayoutEffectsStart) + end + end end exports.markLayoutEffectsStopped = function(): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark('--layout-effects-stop') - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--layout-effects-stop") + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.LayoutEffectsStop) + end + end end exports.markPassiveEffectsStarted = function(lanes: Lanes): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--passive-effects-start-" .. formatLanes(lanes)) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--passive-effects-start-" .. formatLanes(lanes)) + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.PassiveEffectsStart) + end + end end -exports.markPassiveEffectsStopped = function(): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark('--passive-effects-stop') - end - end +exports.markPassiveEffectsStopped = function(root: FiberRoot): () + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--passive-effects-stop") + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.PassiveEffectsStop, root) + end + end end exports.markRenderStarted = function(lanes: Lanes): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--render-start-" .. formatLanes(lanes)) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--render-start-" .. formatLanes(lanes)) + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.RenderStart) + end + end end exports.markRenderYielded = function(): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark('--render-yield') - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--render-yield") + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.RenderYield) + end + end end exports.markRenderStopped = function(): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark('--render-stop') - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--render-stop") + end + -- ROBLOX deviation: profiler event callback + if profilerEventCallback then + profilerEventCallback(profilerEventTypes.RenderStop) + end + end end exports.markRenderScheduled = function(lane: Lane): () - if enableSchedulingProfiler then - if supportsUserTiming then - performance.mark("--schedule-render-" .. formatLanes(lane)) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + performance.mark("--schedule-render-" .. formatLanes(lane)) + end + end end exports.markForceUpdateScheduled = function(fiber: Fiber, lane: Lane): () - if enableSchedulingProfiler then - if supportsUserTiming then - local componentName = getComponentName(fiber.type) or 'Unknown' - -- TODO Add component stack id - performance.mark( - "--schedule-forced-update-" .. formatLanes(lane) .. "-" .. componentName - ) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + local componentName = getComponentName(fiber.type) or "Unknown" + -- TODO Add component stack id + performance.mark( + "--schedule-forced-update-" .. formatLanes(lane) .. "-" .. componentName + ) + end + end end exports.markStateUpdateScheduled = function(fiber: Fiber, lane: Lane): () - if enableSchedulingProfiler then - if supportsUserTiming then - local componentName = getComponentName(fiber.type) or 'Unknown' - -- TODO Add component stack id - performance.mark( - "--schedule-state-update-" .. formatLanes(lane) .. "-" .. componentName - ) - end - end + if enableSchedulingProfiler then + if supportsUserTiming then + local componentName = getComponentName(fiber.type) or "Unknown" + -- TODO Add component stack id + performance.mark( + "--schedule-state-update-" .. formatLanes(lane) .. "-" .. componentName + ) + end + end +end + +-- ROBLOX deviation START: profiler event callback +exports.profilerEventTypes = profilerEventTypes + +exports.registerProfilerEventCallback = function(callback: ProfilerEventCallback) + if profilerEventCallback then + warn("SchedulingProfiler: Another event callback was already registered.") + end + profilerEventCallback = callback end +-- ROBLOX deviation END return exports diff --git a/modules/react-reconciler/src/__tests__/DebugTracing-test.internal.spec.lua b/modules/react-reconciler/src/__tests__/DebugTracing-test.internal.spec.lua new file mode 100644 index 00000000..9d949942 --- /dev/null +++ b/modules/react-reconciler/src/__tests__/DebugTracing-test.internal.spec.lua @@ -0,0 +1,531 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/8af27aeedbc6b00bc2ef49729fc84f116c70a27c/packages/react-reconciler/src/__tests__/DebugTracing-test.internal.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. +-- * +-- * @emails react-core +-- * @jest-environment node +-- */ + +-- local function spyOnDevAndProd(object, methodName, fake) +-- local originalMethods = originalObjects[tostring(object)] +-- if originalMethods == nil then +-- originalMethods = {} +-- originalObjects[tostring(object)] = originalMethods +-- end +-- originalMethods[methodName] = object[methodName] +-- object[methodName] = jest:fn(fake) +-- return object[methodName] +-- end + +-- local function restoreDevAndProd() +-- for originalObjectString, originalObject in originalObjects do +-- for methodName, originalMethod in originalObjects[originalObjectString] do +-- originalObject[methodName] = originalMethod +-- end +-- end +-- originalObjects = nil +-- end + +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local jest = JestGlobals.jest +local it = JestGlobals.it +local xit = JestGlobals.xit +local jestExpect = JestGlobals.expect +local console = require(Packages.Shared).console +local Promise = require(Packages.Promise) + +-- ROBLOX Test Noise: jest capabilities needed to spy on console +describe("DebugTracing", function() + local React + local ReactTestRenderer + local Scheduler + beforeEach(function() + jest.resetModules() + + -- ROBLOX deviation: upstream uses special comments to know which flags to flip. we do it manually. + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.enableDebugTracing = true + ReactFeatureFlags.enableSchedulingProfiler = true + ReactFeatureFlags.enableProfilerTimer = true + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = true + ReactFeatureFlags.enableSuspenseServerRenderer = true + ReactFeatureFlags.decoupleUpdatePriorityFromScheduler = true + React = require(Packages.React) + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + Scheduler = require(Packages.Scheduler) + + -- local groups = {} + + -- ROBLOX deviation: we currently don't have a good way to intercept + -- console.log, group, or groupEnd in a reasonably-aligned way + + -- spyOnDevAndProd(console, "log", function(message) + -- table.insert(logs, "log: " .. message) + -- end) + -- spyOnDevAndProd(console, "group", function(message) + -- table.insert(logs, "group: " .. message) + -- table.insert(groups, message) + -- end) + -- spyOnDevAndProd(console, "groupEnd", function() + -- local message = table.remove(groups, 1) + -- table.insert(logs, "groupEnd: " .. message) + -- end) + end) + + -- @gate experimental + it( + "should not log anything for sync render without suspends or state updates", + function() + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement("div") + ) + ) + end).toLogDev({}) + end + ) + + -- @gate experimental + it( + "should not log anything for concurrent render without suspends or state updates", + function() + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement("div") + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({}) + end + ) + + -- ROBLOX FIXME: we never receive "Example resolved", might be a Promise emulation issue + -- @gate experimental && build === 'development' && enableDebugTracing + xit("should log sync render with suspense", function() + -- ROBLOX deviation: evaera Prosmise.resolve doesn't match JS Promise, so we delay(0) to match + local fakeSuspensePromise = Promise.delay(0):andThen(function() + return true + end) + local function Example() + error(fakeSuspensePromise) + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement( + React.Suspense, + { fallback = {} }, + React.createElement(Example) + ) + ) + ) + end).toLogDev({ + -- "* render (0b0000000000000000000000000000001)", + "* Example suspended", + -- "* render (0b0000000000000000000000000000001)" + }, { withoutStack = true }) + + jestExpect(function() + fakeSuspensePromise:await() + end).toLogDev({ + "* Example resolved", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it.skip("should log sync render with CPU suspense", function() + local function Example() + console.log("") + return nil + end + + local function Wrapper(props) + local children = props.children + console.log("") + return children + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement( + Wrapper, + nil, + React.createElement( + React.Suspense, + { fallback = {}, unstable_expectedLoadTime = 1 }, + React.createElement(Example) + ) + ) + ) + ) + end).toLogDev({ + -- "group: * render (0b0000000000000000000000000000001)", + "", + -- "groupEnd: * render (0b0000000000000000000000000000001)", + }, { withoutStack = true }) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * render (0b0000010000000000000000000000000)", + "", + -- "groupEnd: * render (0b0000010000000000000000000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + xit("should log concurrent render with suspense", function() + -- ROBLOX deviation: evaera Prosmise.resolve doesn't match JS Promise, so we delay(0) to match + local fakeSuspensePromise = Promise.delay(0):andThen(function() + return true + end) + local function Example() + error(fakeSuspensePromise) + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement( + React.Suspense, + { fallback = {} }, + React.createElement(Example) + ) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * render (0b0000000000000000000001000000000)", + "* Example suspended", + -- "groupEnd: * render (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + + jestExpect(function() + fakeSuspensePromise:await() + end).toLogDev({ "* Example resolved" }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + xit("should log concurrent render with CPU suspense", function() + local function Example() + console.log("") + return nil + end + + local function Wrapper(props) + local children = props.children + console.log("") + return children + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement( + Wrapper, + nil, + React.createElement( + React.Suspense, + { fallback = {}, unstable_expectedLoadTime = 1 }, + React.createElement(Example) + ) + ) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * render (0b0000000000000000000001000000000)", + "", + -- "groupEnd: * render (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * render (0b0000010000000000000000000000000)", + "", + -- "groupEnd: * render (0b0000010000000000000000000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it("should log cascading class component updates", function() + local Example = React.Component:extend("Example") + function Example:init() + self.state = { didMount = false } + end + function Example:componentDidMount() + self:setState({ didMount = true }) + end + function Example:render() + return nil + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * commit (0b0000000000000000000001000000000)", + -- "group: * layout effects (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000000000000001)", + -- "groupEnd: * layout effects (0b0000000000000000000001000000000)", + -- "groupEnd: * commit (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it("should log render phase state updates for class component", function() + local Example = React.Component:extend("Example") + function Example:init() + self.state = { didRender = false } + end + function Example:render() + if self.state.didRender == false then + self:setState({ didRender = true }) + end + return nil + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toErrorDev("Cannot update during an existing state transition") + end).toLogDev({ + -- "group: * render (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it("should log cascading layout updates", function() + local function Example() + local didMount, setDidMount = React.useState(false) + React.useLayoutEffect(function() + setDidMount(true) + end, {}) + return didMount + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * commit (0b0000000000000000000001000000000)", + -- "group: * layout effects (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000000000000001)", + -- "groupEnd: * layout effects (0b0000000000000000000001000000000)", + -- "groupEnd: * commit (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it("should log cascading passive updates", function() + local function Example() + local didMount, setDidMount = React.useState(false) + React.useEffect(function() + setDidMount(true) + end, {}) + return didMount + end + + jestExpect(function() + ReactTestRenderer.act(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end) + end).toLogDev({ + -- "group: * passive effects (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000010000000000)", + -- "groupEnd: * passive effects (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + it("should log render phase updates", function() + local function Example() + local didRender, setDidRender = React.useState(false) + if not didRender then + setDidRender(true) + end + return didRender + end + + jestExpect(function() + ReactTestRenderer.act(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end) + end).toLogDev({ + -- "group: * render (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000001000000000)", + "* Example updated state (0b0000000000000000000001000000000)", + -- "groupEnd: * render (0b0000000000000000000001000000000)", + }, { withoutStack = true }) + + -- ROBLOX deviation: we don't have build-time gating like upstream + -- gate(function(flags) + -- if flags.new then + -- jestExpect(logs).toEqual({ + -- }) + -- else + -- jestExpect(logs).toEqual({ + -- "group: * render (0b0000000000000000000001000000000)", + -- "log: * Example updated state (0b0000000000000000000010000000000)", + -- "log: * Example updated state (0b0000000000000000000010000000000)", + -- "groupEnd: * render (0b0000000000000000000001000000000)", + -- }) + -- end + -- end) + end) + + -- @gate experimental && build === 'development' && enableDebugTracing + xit("should log when user code logs", function() + local function Example() + console.log("Hello from user code") + return nil + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ), + { unstable_isConcurrent = true } + ) + end).toLogDev({}) + + jestExpect(function() + jestExpect(Scheduler).toFlushUntilNextPaint({}) + end).toLogDev({ + -- "group: * render (0b0000000000000000000001000000000)", + "Hello from user code", + -- "groupEnd: * render (0b0000000000000000000001000000000)", + }) + end) + + -- @gate experimental + it( + "should not log anything outside of a unstable_DebugTracingMode subtree", + function() + local function ExampleThatCascades() + local didMount, setDidMount = React.useState(false) + React.useLayoutEffect(function() + setDidMount(true) + end, {}) + return didMount + end + + local fakeSuspensePromise = Promise.new(function() + return {} + end) + local function ExampleThatSuspends() + error(fakeSuspensePromise) + end + + local function Example() + return nil + end + + jestExpect(function() + ReactTestRenderer.create( + React.createElement( + React.Fragment, + nil, + React.createElement(ExampleThatCascades), + React.createElement( + React.Suspense, + { fallback = {} }, + nil, + React.createElement(ExampleThatSuspends) + ), + React.createElement( + React.unstable_DebugTracingMode, + nil, + React.createElement(Example) + ) + ) + ) + end).toLogDev({}) + end + ) +end) diff --git a/modules/react-reconciler/src/__tests__/ReactClassSetStateCallback.spec.lua b/modules/react-reconciler/src/__tests__/ReactClassSetStateCallback.spec.lua new file mode 100644 index 00000000..15bcca19 --- /dev/null +++ b/modules/react-reconciler/src/__tests__/ReactClassSetStateCallback.spec.lua @@ -0,0 +1,73 @@ +-- ROBLOX upstream: https://github.com/facebook/react/blob/d7dce572c7453737a685e791e7afcbc7e2b2fe16/packages/react-reconciler/src/__tests__/ReactClassSetStateCallback-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. + * + * @emails react-core + * @jest-environment node +]] + +--[[ eslint-disable no-func-assign ]] +local Packages = script.Parent.Parent.Parent +local React + +local ReactNoop +local Scheduler + +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it +local jest = JestGlobals.jest + +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) +end) + +local function Text(props) + Scheduler.unstable_yieldValue(props.text) + return React.createElement("span", { + prop = props.text, + }) +end + +it( + "regression: setState callback (2nd arg) should only fire once, even after a rebase", + function() + local app + local App = React.Component:extend("App") + function App:init() + self:setState({ step = 0 }) + end + function App:render() + app = self + return React.createElement(Text, { text = self.state.step }) + end + + local root = ReactNoop.createRoot() + ReactNoop.act(function() + root.render(React.createElement(App)) + end) + jestExpect(Scheduler).toHaveYielded({ 0 }) + + ReactNoop.act(function() + app:setState({ step = 1 }, function() + return Scheduler.unstable_yieldValue("Callback 1") + end) + + ReactNoop.flushSync(function() + app:setState({ step = 2 }, function() + return Scheduler.unstable_yieldValue("Callback 2") + end) + end) + end) + jestExpect(Scheduler).toHaveYielded({ 2, "Callback 2", 2, "Callback 1" }) + end +) diff --git a/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.roblox.spec.lua index e5a64410..d061b059 100644 --- a/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.roblox.spec.lua @@ -15,69 +15,73 @@ * is *reused*, then that won't be the case and things will appear to work in * some cases. Better to just block all updates in initialization. ]] - --!nolint - - return function() - local HttpService = game:GetService("HttpService") - local Packages = script.Parent.Parent.Parent - local RobloxJest = require(Packages.Dev.RobloxJest) - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - - -- deviation: Move all of the following into the test function body to match - -- convention - local React - -- local ReactDOM - local ReactNoop - -- local ReactTestUtils - -- local PropTypes - - local clone = function(o) - return HttpService:JSONDecode(HttpService:JSONEncode(o)) - end - - local GET_INIT_STATE_RETURN_VAL = { - hasWillMountCompleted = false, - hasRenderCompleted = false, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local INIT_RENDER_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = false, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local DID_MOUNT_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = true, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local NEXT_RENDER_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = true, - hasDidMountCompleted = true, - hasWillUnmountCompleted = false, - } - - local WILL_UNMOUNT_STATE = { - hasWillMountCompleted = true, - hasDidMountCompleted = true, - hasRenderCompleted = true, - hasWillUnmountCompleted = false, - } - - local POST_WILL_UNMOUNT_STATE = { - hasWillMountCompleted = true, - hasDidMountCompleted = true, - hasRenderCompleted = true, - hasWillUnmountCompleted = true, - } - - --[[ + +local HttpService = game:GetService("HttpService") +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local afterEach = JestGlobals.afterEach +local it = JestGlobals.it +local jest = JestGlobals.jest + +-- deviation: Move all of the following into the test function body to match +-- convention +local React +-- local ReactDOM +local ReactNoop +-- local ReactTestUtils +-- local PropTypes + +local clone = function(o) + return HttpService:JSONDecode(HttpService:JSONEncode(o)) +end + +local GET_INIT_STATE_RETURN_VAL = { + hasWillMountCompleted = false, + hasRenderCompleted = false, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local INIT_RENDER_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = false, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local DID_MOUNT_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = true, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local NEXT_RENDER_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = true, + hasDidMountCompleted = true, + hasWillUnmountCompleted = false, +} + +local WILL_UNMOUNT_STATE = { + hasWillMountCompleted = true, + hasDidMountCompleted = true, + hasRenderCompleted = true, + hasWillUnmountCompleted = false, +} + +local POST_WILL_UNMOUNT_STATE = { + hasWillMountCompleted = true, + hasDidMountCompleted = true, + hasRenderCompleted = true, + hasWillUnmountCompleted = true, +} + +--[[ Every React component is in one of these life cycles. * MOUNTED * Mounted components have a DOM node representation and are capable of @@ -85,671 +89,778 @@ * UNMOUNTED * Unmounted components are inactive and cannot receive new props. ]] - type ComponentLifeCycle = string - - local function getLifeCycleState(instance): ComponentLifeCycle - return instance.updater.isMounted(instance) and "MOUNTED" or "UNMOUNTED" - end - - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() - - React = require(Packages.React) - ReactNoop = require(Packages.Dev.ReactNoopRenderer) - -- ReactDOM = require('react-dom') - -- ReactTestUtils = require('react-dom/test-utils') - -- PropTypes = require('prop-types') - - -- ROBLOX deviation: these tests are failing with debugRenderPhaseSideEffectsForStrictMode on. - -- https://github.com/Roblox/roact-alignment/issues/105 - local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false - end) - - it('should correctly determine if a component is mounted', function() - local isMounted - local Component = React.Component:extend("Component") - - function Component:init() - isMounted = function() - -- No longer a public API, but we can test that it works internally by - -- reaching into the updater. - return self.updater.isMounted(self) - end - end - function Component:UNSAFE_componentWillMount() - jestExpect(isMounted()).toBe(false) - end - function Component:didMount() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - jestExpect(isMounted()).toBe(true) - end - function Component:render() - jestExpect(isMounted()).toBe(false) - return React.createElement("div") - end - - local element = React.createElement(Component) - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(isMounted()).toBe(true) - end).toErrorDev( - { - "Component is accessing isMounted inside its render()", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end) - - it('should correctly determine if a nil component is mounted', function() - local isMounted - local Component = React.Component:extend("Component") - - function Component:init() - isMounted = function() - -- No longer a public API, but we can test that it works internally by - -- reaching into the updater. - return self.updater.isMounted(self) - end - end - function Component:UNSAFE_componentWillMount() - jestExpect(isMounted()).toBe(false) - end - function Component:didMount() - jestExpect(isMounted()).toBe(true) - end - function Component:render() - jestExpect(isMounted()).toBe(false) - return nil - end - - local element = React.createElement(Component) - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(isMounted()).toBe(true) - end).toErrorDev( - { - "Component is accessing isMounted inside its render()", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end) - - it('should carry through each of the phases of setup', function() - local _testJournal: any = {} - local getTestLifeCycleState, getInstanceState - local LifeCycleComponent = React.Component:extend("LifeCycleComponent") - function LifeCycleComponent:init() - local initState = { - hasWillMountCompleted = false, - hasDidMountCompleted = false, - hasRenderCompleted = false, - hasWillUnmountCompleted = false, - } - getTestLifeCycleState = function() - return getLifeCycleState(self) - end - getInstanceState = function() - return self.state - end - _testJournal.returnedFromGetInitialState = clone(initState) - _testJournal.lifeCycleAtStartOfGetInitialState = getTestLifeCycleState() - self.state = initState - end - - function LifeCycleComponent:UNSAFE_componentWillMount() - _testJournal.stateAtStartOfWillMount = clone(self.state) - _testJournal.lifeCycleAtStartOfWillMount = getTestLifeCycleState() - self.state.hasWillMountCompleted = true - end - - function LifeCycleComponent:didMount() - _testJournal.stateAtStartOfDidMount = clone(self.state) - _testJournal.lifeCycleAtStartOfDidMount = getTestLifeCycleState() - self:setState({hasDidMountCompleted = true}) - end - - function LifeCycleComponent:render() - local isInitialRender = not self.state.hasRenderCompleted - if isInitialRender then - _testJournal.stateInInitialRender = clone(self.state) - _testJournal.lifeCycleInInitialRender = getTestLifeCycleState() - else - _testJournal.stateInLaterRender = clone(self.state) - _testJournal.lifeCycleInLaterRender = getTestLifeCycleState() - end - -- you would *NEVER* do anything like this in real code! - self.state.hasRenderCompleted = true - return React.createElement("TextLabel", { Text = "I am the inner DIV" }) - end - - function LifeCycleComponent:willUnmount() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - - _testJournal.stateAtStartOfWillUnmount = clone(self.state) - _testJournal.lifeCycleAtStartOfWillUnmount = getTestLifeCycleState() - self.state.hasWillUnmountCompleted = true - end - - -- A component that is merely "constructed" (as in "constructor") but not - -- yet initialized, or rendered. - -- - -- local container = document.createElement('div') - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(LifeCycleComponent)) - end) - end).toErrorDev( - { - "LifeCycleComponent is accessing isMounted inside its render() function", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - - -- getInitialState - jestExpect(_testJournal.returnedFromGetInitialState).toEqual( - GET_INIT_STATE_RETURN_VAL - ) - jestExpect(_testJournal.lifeCycleAtStartOfGetInitialState).toBe( - 'UNMOUNTED' - ) - - -- componentWillMount - jestExpect(_testJournal.stateAtStartOfWillMount).toEqual( - _testJournal.returnedFromGetInitialState - ) - jestExpect(_testJournal.lifeCycleAtStartOfWillMount).toBe('UNMOUNTED') - - -- didMount - jestExpect(_testJournal.stateAtStartOfDidMount).toEqual( - DID_MOUNT_STATE - ) - jestExpect(_testJournal.lifeCycleAtStartOfDidMount).toBe('MOUNTED') - - -- initial render - jestExpect(_testJournal.stateInInitialRender).toEqual( - INIT_RENDER_STATE - ) - jestExpect(_testJournal.lifeCycleInInitialRender).toBe('UNMOUNTED') - - jestExpect(getTestLifeCycleState()).toBe('MOUNTED') - - -- Now *update the component* - -- instance.forceUpdate() - ReactNoop.act(function() - ReactNoop.render(React.createElement(LifeCycleComponent)) - end) - - -- render 2nd time - jestExpect(_testJournal.stateInLaterRender).toEqual(NEXT_RENDER_STATE) - jestExpect(_testJournal.lifeCycleInLaterRender).toBe('MOUNTED') - - jestExpect(getTestLifeCycleState()).toBe('MOUNTED') - - ReactNoop.act(function() - ReactNoop.render(nil) - end) - - jestExpect(_testJournal.stateAtStartOfWillUnmount).toEqual( - WILL_UNMOUNT_STATE - ) - -- willUnmount called right before unmount. - jestExpect(_testJournal.lifeCycleAtStartOfWillUnmount).toBe('MOUNTED') - - -- But the current lifecycle of the component is unmounted. - jestExpect(getTestLifeCycleState()).toBe('UNMOUNTED') - jestExpect(getInstanceState()).toEqual(POST_WILL_UNMOUNT_STATE) - end) - - it('should allow state updates in didMount', function() - local getComponentState - --[[* +type ComponentLifeCycle = string + +local function getLifeCycleState(instance): ComponentLifeCycle + return instance.__updater.isMounted(instance) and "MOUNTED" or "UNMOUNTED" +end + +local prevCompatWarnings +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + + prevCompatWarnings = ReactGlobals.__COMPAT_WARNINGS__ + ReactGlobals.__COMPAT_WARNINGS__ = false + + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + -- ReactDOM = require('react-dom') + -- ReactTestUtils = require('react-dom/test-utils') + -- PropTypes = require('prop-types') + + -- ROBLOX deviation: these tests are failing with debugRenderPhaseSideEffectsForStrictMode on. + -- https://github.com/Roblox/roact-alignment/issues/105 + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false +end) + +afterEach(function() + ReactGlobals.__COMPAT_WARNINGS__ = prevCompatWarnings +end) + +it("should correctly determine if a component is mounted", function() + local isMounted + local Component = React.Component:extend("Component") + + function Component:init() + isMounted = function() + -- No longer a public API, but we can test that it works internally by + -- reaching into the updater. + return self.__updater.isMounted(self) + end + end + function Component:UNSAFE_componentWillMount() + jestExpect(isMounted()).toBe(false) + end + function Component:didMount() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + jestExpect(isMounted()).toBe(true) + end + function Component:render() + jestExpect(isMounted()).toBe(false) + return React.createElement("div") + end + + local element = React.createElement(Component) + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(isMounted()).toBe(true) + end).toErrorDev({ + "Component is accessing isMounted inside its render()", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) +end) + +it("should correctly determine if a nil component is mounted", function() + local isMounted + local Component = React.Component:extend("Component") + + function Component:init() + isMounted = function() + -- No longer a public API, but we can test that it works internally by + -- reaching into the updater. + return self.__updater.isMounted(self) + end + end + function Component:UNSAFE_componentWillMount() + jestExpect(isMounted()).toBe(false) + end + function Component:didMount() + jestExpect(isMounted()).toBe(true) + end + function Component:render() + jestExpect(isMounted()).toBe(false) + return nil + end + + local element = React.createElement(Component) + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(isMounted()).toBe(true) + end).toErrorDev({ + "Component is accessing isMounted inside its render()", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) +end) + +it("should carry through each of the phases of setup", function() + local _testJournal: any = {} + local getTestLifeCycleState, getInstanceState + local LifeCycleComponent = React.Component:extend("LifeCycleComponent") + function LifeCycleComponent:init() + local initState = { + hasWillMountCompleted = false, + hasDidMountCompleted = false, + hasRenderCompleted = false, + hasWillUnmountCompleted = false, + } + getTestLifeCycleState = function() + return getLifeCycleState(self) + end + getInstanceState = function() + return self.state + end + _testJournal.returnedFromGetInitialState = clone(initState) + _testJournal.lifeCycleAtStartOfGetInitialState = getTestLifeCycleState() + self.state = initState + end + + function LifeCycleComponent:UNSAFE_componentWillMount() + _testJournal.stateAtStartOfWillMount = clone(self.state) + _testJournal.lifeCycleAtStartOfWillMount = getTestLifeCycleState() + self.state.hasWillMountCompleted = true + end + + function LifeCycleComponent:didMount() + _testJournal.stateAtStartOfDidMount = clone(self.state) + _testJournal.lifeCycleAtStartOfDidMount = getTestLifeCycleState() + self:setState({ hasDidMountCompleted = true }) + end + + function LifeCycleComponent:render() + local isInitialRender = not self.state.hasRenderCompleted + if isInitialRender then + _testJournal.stateInInitialRender = clone(self.state) + _testJournal.lifeCycleInInitialRender = getTestLifeCycleState() + else + _testJournal.stateInLaterRender = clone(self.state) + _testJournal.lifeCycleInLaterRender = getTestLifeCycleState() + end + -- you would *NEVER* do anything like this in real code! + self.state.hasRenderCompleted = true + return React.createElement("TextLabel", { Text = "I am the inner DIV" }) + end + + function LifeCycleComponent:willUnmount() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + + _testJournal.stateAtStartOfWillUnmount = clone(self.state) + _testJournal.lifeCycleAtStartOfWillUnmount = getTestLifeCycleState() + self.state.hasWillUnmountCompleted = true + end + + -- A component that is merely "constructed" (as in "constructor") but not + -- yet initialized, or rendered. + -- + -- local container = document.createElement('div') + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(LifeCycleComponent)) + end) + end).toErrorDev({ + "LifeCycleComponent is accessing isMounted inside its render() function", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) + + -- getInitialState + jestExpect(_testJournal.returnedFromGetInitialState).toEqual( + GET_INIT_STATE_RETURN_VAL + ) + jestExpect(_testJournal.lifeCycleAtStartOfGetInitialState).toBe("UNMOUNTED") + + -- componentWillMount + jestExpect(_testJournal.stateAtStartOfWillMount).toEqual( + _testJournal.returnedFromGetInitialState + ) + jestExpect(_testJournal.lifeCycleAtStartOfWillMount).toBe("UNMOUNTED") + + -- didMount + jestExpect(_testJournal.stateAtStartOfDidMount).toEqual(DID_MOUNT_STATE) + jestExpect(_testJournal.lifeCycleAtStartOfDidMount).toBe("MOUNTED") + + -- initial render + jestExpect(_testJournal.stateInInitialRender).toEqual(INIT_RENDER_STATE) + jestExpect(_testJournal.lifeCycleInInitialRender).toBe("UNMOUNTED") + + jestExpect(getTestLifeCycleState()).toBe("MOUNTED") + + -- Now *update the component* + -- instance.forceUpdate() + ReactNoop.act(function() + ReactNoop.render(React.createElement(LifeCycleComponent)) + end) + + -- render 2nd time + jestExpect(_testJournal.stateInLaterRender).toEqual(NEXT_RENDER_STATE) + jestExpect(_testJournal.lifeCycleInLaterRender).toBe("MOUNTED") + + jestExpect(getTestLifeCycleState()).toBe("MOUNTED") + + ReactNoop.act(function() + ReactNoop.render(nil) + end) + + jestExpect(_testJournal.stateAtStartOfWillUnmount).toEqual(WILL_UNMOUNT_STATE) + -- willUnmount called right before unmount. + jestExpect(_testJournal.lifeCycleAtStartOfWillUnmount).toBe("MOUNTED") + + -- But the current lifecycle of the component is unmounted. + jestExpect(getTestLifeCycleState()).toBe("UNMOUNTED") + jestExpect(getInstanceState()).toEqual(POST_WILL_UNMOUNT_STATE) +end) + +it("should allow state updates in didMount", function() + local getComponentState + --[[* * calls setState in an didMount. ]] - local SetStateInComponentDidMount = React.Component:extend("SetStateInComponentDidMount") - function SetStateInComponentDidMount:init() - self.state = { - stateField = self.props.valueToUseInitially, - } - getComponentState = function() - return self.state - end - end - - function SetStateInComponentDidMount:didMount() - self:setState({stateField = self.props.valueToUseAfterMount}) - end - - function SetStateInComponentDidMount:render() - return React.createElement("div") - end - - local element = - React.createElement(SetStateInComponentDidMount, { - valueToUseInitially = "hello", - valueToUseAfterMount = "goodbye", - }) - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(getComponentState().stateField).toBe("goodbye") - end) - - it('should call nested legacy lifecycle methods in the right order', function() - local log - local logger = function(msg) - return function() - -- return true for shouldUpdate - table.insert(log, msg) - return true - end - end - - local Outer = React.Component:extend("Outer") - -- pre-declare - local Inner = React.Component:extend("Inner") - Outer.UNSAFE_componentWillMount = logger("outer componentWillMount") - Outer.didMount = logger("outer didMount") - Outer.UNSAFE_componentWillReceiveProps = logger( - "outer componentWillReceiveProps" - ) - Outer.shouldUpdate = logger("outer shouldUpdate") - Outer.willUpdate = logger("outer willUpdate") - Outer.didUpdate = logger("outer didUpdate") - Outer.willUnmount = logger("outer willUnmount") - function Outer:render() - return React.createElement("Frame", {}, - React.createElement(Inner, { - x = self.props.x - }) - ) - end - - Inner.UNSAFE_componentWillMount = logger("inner componentWillMount") - Inner.didMount = logger("inner didMount") - Inner.UNSAFE_componentWillReceiveProps = logger( - "inner componentWillReceiveProps" - ) - Inner.shouldUpdate = logger("inner shouldUpdate") - Inner.willUpdate = logger("inner willUpdate") - Inner.didUpdate = logger("inner didUpdate") - Inner.willUnmount = logger("inner willUnmount") - function Inner:render() - return React.createElement("TextLabel", { Text = self.props.x }) - end - - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 1})) - end) - jestExpect(log).toEqual({ - "outer componentWillMount", - "inner componentWillMount", - "inner didMount", - "outer didMount", - }) - - -- Dedup warnings - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 2})) - end) - jestExpect(log).toEqual({ - "outer componentWillReceiveProps", - "outer shouldUpdate", - "outer willUpdate", - "inner componentWillReceiveProps", - "inner shouldUpdate", - "inner willUpdate", - "inner didUpdate", - "outer didUpdate", - }) - - log = {} - ReactNoop.act(function() - ReactNoop.render(nil) - end) - jestExpect(log).toEqual({ - "outer willUnmount", - "inner willUnmount", - }) - end) - - it('should call nested new lifecycle methods in the right order', function() - local log - local logger = function(msg) - return function() - -- return true for shouldUpdate - table.insert(log, msg) - return true - end - end - local Outer = React.Component:extend("Outer") - -- pre-declare - local Inner = React.Component:extend("Inner") - function Outer:init() - self.state = {} - end - function Outer.getDerivedStateFromProps(props, prevState) - table.insert(log, "outer getDerivedStateFromProps") - return nil - end - Outer.didMount = logger("outer didMount") - Outer.shouldUpdate = logger("outer shouldUpdate") - Outer.getSnapshotBeforeUpdate = logger("outer getSnapshotBeforeUpdate") - Outer.didUpdate = logger("outer didUpdate") - Outer.willUnmount = logger("outer willUnmount") - function Outer:render() - return React.createElement("Frame", {}, - React.createElement(Inner, {x = self.props.x}) - ) - end - - function Inner:init() - self.state = {} - end - function Inner.getDerivedStateFromProps(props, prevState) - table.insert(log, "inner getDerivedStateFromProps") - return nil - end - Inner.didMount = logger("inner didMount") - Inner.shouldUpdate = logger("inner shouldUpdate") - Inner.getSnapshotBeforeUpdate = logger("inner getSnapshotBeforeUpdate") - Inner.didUpdate = logger("inner didUpdate") - Inner.willUnmount = logger("inner willUnmount") - function Inner:render() - return React.createElement("TextLabel", {Text = self.props.x}) - end - - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 1})) - end) - jestExpect(log).toEqual({ - "outer getDerivedStateFromProps", - "inner getDerivedStateFromProps", - "inner didMount", - "outer didMount", - }) - - -- Dedup warnings - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 2})) - end) - jestExpect(log).toEqual({ - "outer getDerivedStateFromProps", - "outer shouldUpdate", - "inner getDerivedStateFromProps", - "inner shouldUpdate", - "inner getSnapshotBeforeUpdate", - "outer getSnapshotBeforeUpdate", - "inner didUpdate", - "outer didUpdate", - }) - - log = {} - ReactNoop.act(function() - ReactNoop.render(nil) - end) - jestExpect(log).toEqual({ - "outer willUnmount", - "inner willUnmount", - }) - end) - - it("should warn if state is not initialized before getDerivedStateFromProps", function() - local MyComponent = React.Component:extend("MyComponent") - function MyComponent.getDerivedStateFromProps() - return nil - end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end).toErrorDev( - -- ROBLOX FIXME: This error message doesn't quite apply as written - "`MyComponent` uses `getDerivedStateFromProps` but its initial state is " .. - "nil. This is not recommended. Instead, define the initial state by " .. - "assigning an object to `self.state` in the `init` method of `MyComponent`. " .. - "This ensures that `getDerivedStateFromProps` arguments have a consistent shape." - ) - - -- De-duped - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end) - - it('should pass the return value from getSnapshotBeforeUpdate to didUpdate', function() - local log = {} - - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:init() - self.state = { - value = 0, - } - end - function MyComponent.getDerivedStateFromProps(nextProps, prevState) - return { - value = prevState.value + 1, - } - end - function MyComponent:getSnapshotBeforeUpdate(prevProps, prevState) - table.insert(log, string.format( - "getSnapshotBeforeUpdate() prevProps:%s prevState:%s", - prevProps.value, - prevState.value - ) - ) - return "abc" - end - function MyComponent:didUpdate(prevProps, prevState, snapshot) - table.insert(log, string.format( - "didUpdate() prevProps:%s prevState:%s snapshot:%s", - prevProps.value, - prevState.value, - snapshot - ) - ) - end - function MyComponent:render() - table.insert(log, "render") - return nil - end - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "foo", - }) - )) - end) - jestExpect(log).toEqual({"render"}) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "bar", - }) - )) - end) - jestExpect(log).toEqual({ - "render", - "getSnapshotBeforeUpdate() prevProps:foo prevState:1", - "didUpdate() prevProps:foo prevState:1 snapshot:abc", - }) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "baz", - }) - )) - end) - jestExpect(log).toEqual({ - "render", - "getSnapshotBeforeUpdate() prevProps:bar prevState:2", - "didUpdate() prevProps:bar prevState:2 snapshot:abc", - }) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame")) - end) - jestExpect(log).toEqual({}) - end) - - it('should pass previous state to shouldUpdate even with getDerivedStateFromProps', function() - local divRef = React.createRef() - local capturedValue - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init(props) - self.state = { - value = props.value, - } - end - - function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) - if nextProps.value == prevState.value then - return nil - end - return {value = nextProps.value} - end - - function SimpleComponent:shouldUpdate(nextProps, nextState) - return nextState.value ~= self.state.value - end - - function SimpleComponent:render() - capturedValue = self.state.value - return React.createElement("Frame", { ref = divRef }, - React.createElement("TextLabel", { Text = self.state.value }) - ) - end - - ReactNoop.act(function() - ReactNoop.render(React.createElement(SimpleComponent, {value = "initial"})) - end) - jestExpect(capturedValue).toBe("initial") - ReactNoop.act(function() - ReactNoop.render(React.createElement(SimpleComponent, {value = "updated"})) - end) - jestExpect(capturedValue).toBe("updated") - end) - - it("should warn if getSnapshotBeforeUpdate is defined with no componentDidUpdate", function() - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:getSnapshotBeforeUpdate() - return nil - end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end).toErrorDev( - "MyComponent: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " .. - "This component defines getSnapshotBeforeUpdate() only." - ) - end) - - -- De-duped - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end) - it("should warn if using old Roact didMount naming", function() - local Foo = React.Component:extend("Foo") - jestExpect(function() - function Foo:didMount() end - end).toWarnDev("Foo is using method 'didMount', which is no longer supported and should be updated to 'componentDidMount'\nFile: ReactComponentLifeCycle.roblox.spec:", {withoutStack = true}) - end) - it("should warn if using old Roact shouldUpdate naming", function() - local Foo = React.Component:extend("Foo") - jestExpect(function() - function Foo:shouldUpdate() end - end).toWarnDev("Foo is using method 'shouldUpdate', which is no longer supported and should be updated to 'shouldComponentUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", {withoutStack = true}) - end) - it("should warn if using old Roact willUpdate naming", function() - local Foo = React.Component:extend("Foo") - jestExpect(function() - function Foo:willUpdate() end - end).toWarnDev("Foo is using method 'willUpdate', which is no longer supported and should be updated to 'UNSAFE_componentWillUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", {withoutStack = true}) - end) - it("should warn if using old Roact didUpdate naming", function() - local Foo = React.Component:extend("Foo") - jestExpect(function() - function Foo:didUpdate() end - end).toWarnDev("Foo is using method 'didUpdate', which is no longer supported and should be updated to 'componentDidUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", {withoutStack = true}) - end) - it("should warn if using old Roact willUnmount naming", function() - local Foo = React.Component:extend("Foo") - jestExpect(function() - function Foo:willUnmount() end - end).toWarnDev("Foo is using method 'willUnmount', which is no longer supported and should be updated to 'componentWillUnmount'\nFile: ReactComponentLifeCycle.roblox.spec:", {withoutStack = true}) - end) - it("should warn if both didMount and componentDidMount are both defined within the same class", function() - local Foo = React.Component:extend("Foo") - function Foo:componentDidMount() end - jestExpect(function() - function Foo:didMount() end - end).toWarnDev("Warning: Foo already defined 'componentDidMount', but it also defining the deprecated Roact method 'didMount'. Foo should only implement one of these methods, preferably using the non-deprecated name.", {withoutStack = true}) - end) - it("should warn if both shouldUpdate and shouldComponentUpdate are both defined within the same class", function() - local Foo = React.Component:extend("Foo") - function Foo:shouldComponentUpdate() end - jestExpect(function() - function Foo:shouldUpdate() end - end).toWarnDev("Warning: Foo already defined 'shouldComponentUpdate', but it also defining the deprecated Roact method 'shouldUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", {withoutStack = true}) - end) - it("should warn if both willUpdate and componentWillUpdate are both defined within the same class", function() - local Foo = React.Component:extend("Foo") - function Foo:componentWillUpdate() end - jestExpect(function() - function Foo:willUpdate() end - end).toWarnDev("Warning: Foo already defined 'UNSAFE_componentWillUpdate', but it also defining the deprecated Roact method 'willUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", {withoutStack = true}) - - -- tests same thing but with UNSAFE_componentWillUpdate() which is the prefered name starting from Roact 16.x - local Bar = React.Component:extend("Bar") - function Foo:UNSAFE_componentWillUpdate() end - jestExpect(function() - function Foo:willUpdate() end - end).toWarnDev("Warning: Foo already defined 'UNSAFE_componentWillUpdate', but it also defining the deprecated Roact method 'willUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", {withoutStack = true}) - end) - it("should worn if both didUpdate and componentDidUpdate are both defined within the same class", function() - local Foo = React.Component:extend("Foo") - function Foo:componentDidUpdate() end - jestExpect(function() - function Foo:didUpdate() end - end).toWarnDev("Warning: Foo already defined 'componentDidUpdate', but it also defining the deprecated Roact method 'didUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", {withoutStack = true}) - end) - it("should warn if both willUnmount and componentWillUnmount are both defined within the same class", function() - local Foo = React.Component:extend("Foo") - function Foo:componentWillUnmount() end - jestExpect(function() - function Foo:willUnmount() end - end).toWarnDev("Warning: Foo already defined 'componentWillUnmount', but it also defining the deprecated Roact method 'willUnmount'. Foo should only implement one of these methods, preferably using the non-deprecated name", {withoutStack = true}) - end) - end \ No newline at end of file + local SetStateInComponentDidMount = + React.Component:extend("SetStateInComponentDidMount") + function SetStateInComponentDidMount:init() + self.state = { + stateField = self.props.valueToUseInitially, + } + getComponentState = function() + return self.state + end + end + + function SetStateInComponentDidMount:didMount() + self:setState({ stateField = self.props.valueToUseAfterMount }) + end + + function SetStateInComponentDidMount:render() + return React.createElement("div") + end + + local element = React.createElement(SetStateInComponentDidMount, { + valueToUseInitially = "hello", + valueToUseAfterMount = "goodbye", + }) + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(getComponentState().stateField).toBe("goodbye") +end) + +it("should call nested legacy lifecycle methods in the right order", function() + local log + local logger = function(msg) + return function() + -- return true for shouldUpdate + table.insert(log, msg) + return true + end + end + + local Outer = React.Component:extend("Outer") + -- pre-declare + local Inner = React.Component:extend("Inner") + Outer.UNSAFE_componentWillMount = logger("outer componentWillMount") + Outer.didMount = logger("outer didMount") + Outer.UNSAFE_componentWillReceiveProps = logger("outer componentWillReceiveProps") + Outer.shouldUpdate = logger("outer shouldUpdate") + Outer.willUpdate = logger("outer willUpdate") + Outer.didUpdate = logger("outer didUpdate") + Outer.willUnmount = logger("outer willUnmount") + function Outer:render() + return React.createElement( + "Frame", + {}, + React.createElement(Inner, { + x = self.props.x, + }) + ) + end + + Inner.UNSAFE_componentWillMount = logger("inner componentWillMount") + Inner.didMount = logger("inner didMount") + Inner.UNSAFE_componentWillReceiveProps = logger("inner componentWillReceiveProps") + Inner.shouldUpdate = logger("inner shouldUpdate") + Inner.willUpdate = logger("inner willUpdate") + Inner.didUpdate = logger("inner didUpdate") + Inner.willUnmount = logger("inner willUnmount") + function Inner:render() + return React.createElement("TextLabel", { Text = self.props.x }) + end + + log = {} + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 1 })) + end) + end).toErrorDev({ + -- ROBLOX: The upstream equivalents of these tests run with react-dom + -- using the legacy root, so they don't throw warnings related to strict + -- mode; we compromise by keeping it in concurrent mode to better match + -- production, but anticipating the warnings + "Using UNSAFE_componentWillMount in strict mode is not recommended", + "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended", + "Using UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = true }) + jestExpect(log).toEqual({ + "outer componentWillMount", + "inner componentWillMount", + "inner didMount", + "outer didMount", + }) + + -- Dedup warnings + log = {} + -- jestExpect(function()) + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 2 })) + end) + jestExpect(log).toEqual({ + "outer componentWillReceiveProps", + "outer shouldUpdate", + "outer willUpdate", + "inner componentWillReceiveProps", + "inner shouldUpdate", + "inner willUpdate", + "inner didUpdate", + "outer didUpdate", + }) + + log = {} + ReactNoop.act(function() + ReactNoop.render(nil) + end) + jestExpect(log).toEqual({ + "outer willUnmount", + "inner willUnmount", + }) +end) + +it("should call nested new lifecycle methods in the right order", function() + local log + local logger = function(msg) + return function() + -- return true for shouldUpdate + table.insert(log, msg) + return true + end + end + local Outer = React.Component:extend("Outer") + -- pre-declare + local Inner = React.Component:extend("Inner") + function Outer:init() + self.state = {} + end + function Outer.getDerivedStateFromProps(props, prevState) + table.insert(log, "outer getDerivedStateFromProps") + return nil + end + Outer.didMount = logger("outer didMount") + Outer.shouldUpdate = logger("outer shouldUpdate") + Outer.getSnapshotBeforeUpdate = logger("outer getSnapshotBeforeUpdate") + Outer.didUpdate = logger("outer didUpdate") + Outer.willUnmount = logger("outer willUnmount") + function Outer:render() + return React.createElement( + "Frame", + {}, + React.createElement(Inner, { x = self.props.x }) + ) + end + + function Inner:init() + self.state = {} + end + function Inner.getDerivedStateFromProps(props, prevState) + table.insert(log, "inner getDerivedStateFromProps") + return nil + end + Inner.didMount = logger("inner didMount") + Inner.shouldUpdate = logger("inner shouldUpdate") + Inner.getSnapshotBeforeUpdate = logger("inner getSnapshotBeforeUpdate") + Inner.didUpdate = logger("inner didUpdate") + Inner.willUnmount = logger("inner willUnmount") + function Inner:render() + return React.createElement("TextLabel", { Text = self.props.x }) + end + + log = {} + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 1 })) + end) + jestExpect(log).toEqual({ + "outer getDerivedStateFromProps", + "inner getDerivedStateFromProps", + "inner didMount", + "outer didMount", + }) + + -- Dedup warnings + log = {} + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 2 })) + end) + jestExpect(log).toEqual({ + "outer getDerivedStateFromProps", + "outer shouldUpdate", + "inner getDerivedStateFromProps", + "inner shouldUpdate", + "inner getSnapshotBeforeUpdate", + "outer getSnapshotBeforeUpdate", + "inner didUpdate", + "outer didUpdate", + }) + + log = {} + ReactNoop.act(function() + ReactNoop.render(nil) + end) + jestExpect(log).toEqual({ + "outer willUnmount", + "inner willUnmount", + }) +end) + +it("should warn if state is not initialized before getDerivedStateFromProps", function() + local MyComponent = React.Component:extend("MyComponent") + function MyComponent.getDerivedStateFromProps() + return nil + end + function MyComponent:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end).toErrorDev( + "`MyComponent` uses `getDerivedStateFromProps` but its initial state has not been initialized. " + .. "This is not recommended. Instead, define the initial state by " + .. "passing an object to `self:setState` in the `init` method of `MyComponent`. " + .. "This ensures that `getDerivedStateFromProps` arguments have a consistent shape." + ) + + -- De-duped + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) +end) + +it("should pass the return value from getSnapshotBeforeUpdate to didUpdate", function() + local log = {} + + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:init() + self.state = { + value = 0, + } + end + function MyComponent.getDerivedStateFromProps(nextProps, prevState) + return { + value = prevState.value + 1, + } + end + function MyComponent:getSnapshotBeforeUpdate(prevProps, prevState) + table.insert( + log, + string.format( + "getSnapshotBeforeUpdate() prevProps:%s prevState:%s", + prevProps.value, + prevState.value + ) + ) + return "abc" + end + function MyComponent:didUpdate(prevProps, prevState, snapshot) + table.insert( + log, + string.format( + "didUpdate() prevProps:%s prevState:%s snapshot:%s", + prevProps.value, + prevState.value, + snapshot + ) + ) + end + function MyComponent:render() + table.insert(log, "render") + return nil + end + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "foo", + }) + ) + ) + end) + jestExpect(log).toEqual({ "render" }) + log = {} + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "bar", + }) + ) + ) + end) + jestExpect(log).toEqual({ + "render", + "getSnapshotBeforeUpdate() prevProps:foo prevState:1", + "didUpdate() prevProps:foo prevState:1 snapshot:abc", + }) + log = {} + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "baz", + }) + ) + ) + end) + jestExpect(log).toEqual({ + "render", + "getSnapshotBeforeUpdate() prevProps:bar prevState:2", + "didUpdate() prevProps:bar prevState:2 snapshot:abc", + }) + log = {} + + ReactNoop.act(function() + ReactNoop.render(React.createElement("Frame")) + end) + jestExpect(log).toEqual({}) +end) + +it( + "should pass previous state to shouldUpdate even with getDerivedStateFromProps", + function() + local divRef = React.createRef() + local capturedValue + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init(props) + self.state = { + value = props.value, + } + end + + function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) + if nextProps.value == prevState.value then + return nil + end + return { value = nextProps.value } + end + + function SimpleComponent:shouldUpdate(nextProps, nextState) + return nextState.value ~= self.state.value + end + + function SimpleComponent:render() + capturedValue = self.state.value + return React.createElement( + "Frame", + { ref = divRef }, + React.createElement("TextLabel", { Text = self.state.value }) + ) + end + + ReactNoop.act(function() + ReactNoop.render(React.createElement(SimpleComponent, { value = "initial" })) + end) + jestExpect(capturedValue).toBe("initial") + ReactNoop.act(function() + ReactNoop.render(React.createElement(SimpleComponent, { value = "updated" })) + end) + jestExpect(capturedValue).toBe("updated") + end +) + +it( + "should warn if getSnapshotBeforeUpdate is defined with no componentDidUpdate", + function() + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:getSnapshotBeforeUpdate() + return nil + end + function MyComponent:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end).toErrorDev( + "MyComponent: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " + .. "This component defines getSnapshotBeforeUpdate() only." + ) + + -- De-duped + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end +) + +describe("Naming conventions", function() + local prevCompatWarnings + beforeEach(function() + prevCompatWarnings = ReactGlobals.__COMPAT_WARNINGS__ + ReactGlobals.__COMPAT_WARNINGS__ = true + jest.resetModules() + jest.useFakeTimers() + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false + end) + + afterEach(function() + ReactGlobals.__COMPAT_WARNINGS__ = prevCompatWarnings + end) + + local function testWithComponentKind(component: string) + it("should warn if using old Roact didMount naming in " .. component, function() + local Foo = React[component]:extend("Foo") + jestExpect(function() + function Foo:didMount() end + end).toWarnDev( + "Foo is using method 'didMount', which is no longer supported and should be updated to 'componentDidMount'\nFile: ReactComponentLifeCycle.roblox.spec:", + { withoutStack = true } + ) + end) + it( + "should warn if using old Roact shouldUpdate naming in " .. component, + function() + local Foo = React[component]:extend("Foo") + jestExpect(function() + function Foo:shouldUpdate() end + end).toWarnDev( + "Foo is using method 'shouldUpdate', which is no longer supported and should be updated to 'shouldComponentUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", + { withoutStack = true } + ) + end + ) + it("should warn if using old Roact willUpdate naming in " .. component, function() + local Foo = React[component]:extend("Foo") + jestExpect(function() + function Foo:willUpdate() end + end).toWarnDev( + "Foo is using method 'willUpdate', which is no longer supported and should be updated to 'UNSAFE_componentWillUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", + { withoutStack = true } + ) + end) + it("should warn if using old Roact didUpdate naming in " .. component, function() + local Foo = React[component]:extend("Foo") + jestExpect(function() + function Foo:didUpdate() end + end).toWarnDev( + "Foo is using method 'didUpdate', which is no longer supported and should be updated to 'componentDidUpdate'\nFile: ReactComponentLifeCycle.roblox.spec:", + { withoutStack = true } + ) + end) + it( + "should warn if using old Roact willUnmount naming in " .. component, + function() + local Foo = React[component]:extend("Foo") + jestExpect(function() + function Foo:willUnmount() end + end).toWarnDev( + "Foo is using method 'willUnmount', which is no longer supported and should be updated to 'componentWillUnmount'\nFile: ReactComponentLifeCycle.roblox.spec:", + { withoutStack = true } + ) + end + ) + it( + "should warn if both didMount and componentDidMount are defined on the same " + .. component, + function() + local Foo = React[component]:extend("Foo") + function Foo:componentDidMount() end + jestExpect(function() + function Foo:didMount() end + end).toWarnDev( + "Warning: Foo already defined 'componentDidMount', but it also defining the deprecated Roact method 'didMount'. Foo should only implement one of these methods, preferably using the non-deprecated name.", + { withoutStack = true } + ) + end + ) + it( + "should warn if both shouldUpdate and shouldComponentUpdate are defined on the same " + .. component, + function() + local Foo = React[component]:extend("Foo") + function Foo:shouldComponentUpdate() end + jestExpect(function() + function Foo:shouldUpdate() end + end).toWarnDev( + "Warning: Foo already defined 'shouldComponentUpdate', but it also defining the deprecated Roact method 'shouldUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", + { withoutStack = true } + ) + end + ) + it( + "should warn if both willUpdate and componentWillUpdate are defined on the same " + .. component, + function() + local Foo = React[component]:extend("Foo") + function Foo:componentWillUpdate() end + jestExpect(function() + function Foo:willUpdate() end + end).toWarnDev( + "Warning: Foo already defined 'UNSAFE_componentWillUpdate', but it also defining the deprecated Roact method 'willUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", + { withoutStack = true } + ) + + -- tests same thing but with UNSAFE_componentWillUpdate() which is the preferred name starting from Roact 16.x + local Bar = React[component]:extend("Bar") + function Bar:UNSAFE_componentWillUpdate() end + jestExpect(function() + function Bar:willUpdate() end + end).toWarnDev( + "Warning: Bar already defined 'UNSAFE_componentWillUpdate', but it also defining the deprecated Roact method 'willUpdate'. Bar should only implement one of these methods, preferably using the non-deprecated name.", + { withoutStack = true } + ) + end + ) + it( + "should worn if both didUpdate and componentDidUpdate are defined on the same " + .. component, + function() + local Foo = React[component]:extend("Foo") + function Foo:componentDidUpdate() end + jestExpect(function() + function Foo:didUpdate() end + end).toWarnDev( + "Warning: Foo already defined 'componentDidUpdate', but it also defining the deprecated Roact method 'didUpdate'. Foo should only implement one of these methods, preferably using the non-deprecated name.", + { withoutStack = true } + ) + end + ) + it( + "should warn if both willUnmount and componentWillUnmount are defined on the same " + .. component, + function() + local Foo = React[component]:extend("Foo") + function Foo:componentWillUnmount() end + jestExpect(function() + function Foo:willUnmount() end + end).toWarnDev( + "Warning: Foo already defined 'componentWillUnmount', but it also defining the deprecated Roact method 'willUnmount'. Foo should only implement one of these methods, preferably using the non-deprecated name", + { withoutStack = true } + ) + end + ) + end + + testWithComponentKind("Component") + testWithComponentKind("PureComponent") +end) diff --git a/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.spec.lua b/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.spec.lua index 6ed1c39b..ebe4c8e6 100644 --- a/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactComponentLifeCycle.spec.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/8e5adfbd7e605bda9c5e96c10e015b3dc0df688e/packages/react-dom/src/__tests__/ReactComponentLifeCycle-test.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/8e5adfbd7e605bda9c5e96c10e015b3dc0df688e/packages/react-dom/src/__tests__/ReactComponentLifeCycle-test.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -14,69 +14,72 @@ * is *reused*, then that won't be the case and things will appear to work in * some cases. Better to just block all updates in initialization. ]] -return function() - local HttpService = game:GetService("HttpService") - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) - - local Error = require(Packages.LuauPolyfill).Error - - -- deviation: Move all of the following into the test function body to match - -- convention - local React - -- local ReactDOM - local ReactNoop - -- local ReactTestUtils - -- local PropTypes - - local clone = function(o) - return HttpService:JSONDecode(HttpService:JSONEncode(o)) - end - - local GET_INIT_STATE_RETURN_VAL = { - hasWillMountCompleted = false, - hasRenderCompleted = false, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local INIT_RENDER_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = false, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local DID_MOUNT_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = true, - hasDidMountCompleted = false, - hasWillUnmountCompleted = false, - } - - local NEXT_RENDER_STATE = { - hasWillMountCompleted = true, - hasRenderCompleted = true, - hasDidMountCompleted = true, - hasWillUnmountCompleted = false, - } - - local WILL_UNMOUNT_STATE = { - hasWillMountCompleted = true, - hasDidMountCompleted = true, - hasRenderCompleted = true, - hasWillUnmountCompleted = false, - } - - local POST_WILL_UNMOUNT_STATE = { - hasWillMountCompleted = true, - hasDidMountCompleted = true, - hasRenderCompleted = true, - hasWillUnmountCompleted = true, - } - - --[[ +local HttpService = game:GetService("HttpService") +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jest = JestGlobals.jest +local jestExpect = JestGlobals.expect +local it = JestGlobals.it +local xit = JestGlobals.xit +local beforeEach = JestGlobals.beforeEach + +local Error = require(Packages.LuauPolyfill).Error + +-- deviation: Move all of the following into the test function body to match +-- convention +local React +-- local ReactDOM +local ReactNoop +-- local ReactTestUtils +-- local PropTypes + +local clone = function(o) + return HttpService:JSONDecode(HttpService:JSONEncode(o)) +end + +local GET_INIT_STATE_RETURN_VAL = { + hasWillMountCompleted = false, + hasRenderCompleted = false, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local INIT_RENDER_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = false, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local DID_MOUNT_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = true, + hasDidMountCompleted = false, + hasWillUnmountCompleted = false, +} + +local NEXT_RENDER_STATE = { + hasWillMountCompleted = true, + hasRenderCompleted = true, + hasDidMountCompleted = true, + hasWillUnmountCompleted = false, +} + +local WILL_UNMOUNT_STATE = { + hasWillMountCompleted = true, + hasDidMountCompleted = true, + hasRenderCompleted = true, + hasWillUnmountCompleted = false, +} + +local POST_WILL_UNMOUNT_STATE = { + hasWillMountCompleted = true, + hasDidMountCompleted = true, + hasRenderCompleted = true, + hasWillUnmountCompleted = true, +} + +--[[ Every React component is in one of these life cycles. * MOUNTED * Mounted components have a DOM node representation and are capable of @@ -84,39 +87,39 @@ return function() * UNMOUNTED * Unmounted components are inactive and cannot receive new props. ]] - type ComponentLifeCycle = string +type ComponentLifeCycle = string - local function getLifeCycleState(instance): ComponentLifeCycle - return instance.updater.isMounted(instance) and "MOUNTED" or "UNMOUNTED" - end +local function getLifeCycleState(instance): ComponentLifeCycle + return instance.__updater.isMounted(instance) and "MOUNTED" or "UNMOUNTED" +end - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() - React = require(Packages.React) - ReactNoop = require(Packages.Dev.ReactNoopRenderer) - -- ReactDOM = require('react-dom') - -- ReactTestUtils = require('react-dom/test-utils') - -- PropTypes = require('prop-types') + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + -- ReactDOM = require('react-dom') + -- ReactTestUtils = require('react-dom/test-utils') + -- PropTypes = require('prop-types') - -- ROBLOX deviation: these tests are failing with debugRenderPhaseSideEffectsForStrictMode on. - -- https://github.com/Roblox/roact-alignment/issues/105 - local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false - end) + -- ROBLOX deviation: these tests are failing with debugRenderPhaseSideEffectsForStrictMode on. + -- https://github.com/Roblox/roact-alignment/issues/105 + local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false +end) - -- ROBLOX TODO: do we need to test this in roblox renderer? - -- xit('should not reuse an instance when it has been unmounted', function() - -- local container = document.createElement('div') +-- ROBLOX TODO: do we need to test this in roblox renderer? +-- xit('should not reuse an instance when it has been unmounted', function() +-- local container = document.createElement('div') - -- class StatefulComponent extends React.Component { - -- state = {} +-- class StatefulComponent extends React.Component { +-- state = {} - -- render() - -- return
- -- end - -- end +-- render() +-- return
+-- end +-- end -- local element = -- local firstInstance = ReactDOM.render(element, container) @@ -175,208 +178,210 @@ return function() -- ]) -- }) - -- You could assign state here, but not access members of it, unless you - -- had provided a getInitialState method. - it("throws when accessing state in componentWillMount", function() - local StatefulComponent = React.Component:extend("StatefulComponent") - - function StatefulComponent:UNSAFE_componentWillMount() - -- ROBLOX deviation: ensure self is non nil - jestExpect(self).never.toEqual(nil) - - return self.state.yada - end - - function StatefulComponent:render() - -- ROBLOX deviation: ensure self is non nil - jestExpect(self).never.toEqual(nil) - return React.createElement("div") - end - - local instance = React.createElement(StatefulComponent) - jestExpect(function() - ReactNoop.act(function() - instance = ReactNoop.render(instance) - end) - -- deviation - end).toThrow("yada") - end) - - it('should allow update state inside of componentWillMount', function() - local StatefulComponent = React.Component:extend("StatefulComponent") - - function StatefulComponent:UNSAFE_componentWillMount() - self:setState({stateField = "something"}) - end - - function StatefulComponent:render() - return React.createElement("div") - end - - local instance = React.createElement(StatefulComponent) - jestExpect(function() - ReactNoop.act(function() - instance = ReactNoop.render(instance) - end) - end).never.toThrow() - end) - - it("warns if setting 'self.state = props'", function() - local StatefulComponent = React.Component:extend("StatefulComponent") - - function StatefulComponent:init() - self.state = self.props - end - - function StatefulComponent:render() - return React.createElement("div") - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(StatefulComponent)) - end) - end).toErrorDev( - "StatefulComponent: It is not recommended to assign props directly to state " .. - "because updates to props won't be reflected in state. " .. - "In most cases, it is better to use props directly." - ) - end) - - -- ROBLOX DEVIATION: skipped because we ARE supporting setState in constructor in roact alignment (see LUAFDN-322) - xit('should not allow update state inside of getInitialState', function() - local StatefulComponent = React.Component:extend("StatefulComponent") - - function StatefulComponent:init() - self:setState({stateField = 'something'}) - self.state = {stateField = 'somethingelse'} - end - - function StatefulComponent:render() - return React.createElement("div") - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(StatefulComponent)) - end) - end).toErrorDev( - "Warning: Can't call setState on a component that is not yet mounted. " .. - "This is a no-op, but it might indicate a bug in your application. " .. - "Instead, assign to `self.state` directly with the desired state " .. - "in the StatefulComponent component's `init` method." - ) - - -- Check deduplication; (no extra warnings should be logged). - ReactNoop.act(function() - ReactNoop.render(React.createElement(StatefulComponent)) - end) - end) - - it('should correctly determine if a component is mounted', function() - local isMounted - local Component = React.Component:extend("Component") - - function Component:init() - isMounted = function() - -- No longer a public API, but we can test that it works internally by - -- reaching into the updater. - return self.updater.isMounted(self) - end - end - function Component:UNSAFE_componentWillMount() - jestExpect(isMounted()).toBe(false) - end - function Component:componentDidMount() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - jestExpect(isMounted()).toBe(true) - end - function Component:render() - jestExpect(isMounted()).toBe(false) - return React.createElement("div") - end - - local element = React.createElement(Component) - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(isMounted()).toBe(true) - end).toErrorDev( - { - "Component is accessing isMounted inside its render()", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end) - - it('should correctly determine if a nil component is mounted', function() - local isMounted - local Component = React.Component:extend("Component") - - function Component:init() - isMounted = function() - -- No longer a public API, but we can test that it works internally by - -- reaching into the updater. - return self.updater.isMounted(self) - end - end - function Component:UNSAFE_componentWillMount() - jestExpect(isMounted()).toBe(false) - end - function Component:componentDidMount() - jestExpect(isMounted()).toBe(true) - end - function Component:render() - jestExpect(isMounted()).toBe(false) - return nil - end - - local element = React.createElement(Component) - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(isMounted()).toBe(true) - end).toErrorDev( - { - "Component is accessing isMounted inside its render()", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end) - - it('isMounted should return false when unmounted', function() - local isMounted - local Component = React.Component:extend("Component") - function Component:init() - isMounted = function() - return self.updater.isMounted(self) - end - end - function Component:render() - return React.createElement("div") - end - - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component)) - end) - - -- No longer a public API, but we can test that it works internally by - -- reaching into the updater. - jestExpect(isMounted()).toBe(true) - - ReactNoop.act(function() - ReactNoop.render(nil) - end) - - jestExpect(isMounted()).toBe(false) - end) +--[[ROBLOX DEVIATION: State is now automatically initialized to a singleton that gives warnings + on access if state has not been initialized with setState. The code in the test below will + no longer throw an error. + ]] +-- You could assign state here, but not access members of it, unless you +-- had provided a getInitialState method. +xit("throws when accessing state in componentWillMount", function() + local StatefulComponent = React.Component:extend("StatefulComponent") + + function StatefulComponent:UNSAFE_componentWillMount() + -- ROBLOX deviation: ensure self is non nil + jestExpect(self).never.toEqual(nil) + + return self.state.yada + end + + function StatefulComponent:render() + -- ROBLOX deviation: ensure self is non nil + jestExpect(self).never.toEqual(nil) + return React.createElement("div") + end + + local instance = React.createElement(StatefulComponent) + jestExpect(function() + ReactNoop.act(function() + instance = ReactNoop.render(instance) + end) + -- deviation + end).toThrow("yada") +end) + +it("should allow update state inside of componentWillMount", function() + local StatefulComponent = React.Component:extend("StatefulComponent") + + function StatefulComponent:UNSAFE_componentWillMount() + self:setState({ stateField = "something" }) + end + + function StatefulComponent:render() + return React.createElement("div") + end + + local instance = React.createElement(StatefulComponent) + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + instance = ReactNoop.render(instance) + end) + end).toErrorDev({ + "Using UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = true }) + end).never.toThrow() +end) + +it("warns if setting 'self.state = props'", function() + local StatefulComponent = React.Component:extend("StatefulComponent") + + function StatefulComponent:init() + self.state = self.props + end + + function StatefulComponent:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(StatefulComponent)) + end) + end).toErrorDev( + "StatefulComponent: It is not recommended to assign props directly to state " + .. "because updates to props won't be reflected in state. " + .. "In most cases, it is better to use props directly." + ) +end) + +-- ROBLOX DEVIATION: skipped because we ARE supporting setState in constructor in roact alignment (see LUAFDN-322) +xit("should not allow update state inside of getInitialState", function() + local StatefulComponent = React.Component:extend("StatefulComponent") + + function StatefulComponent:init() + self:setState({ stateField = "something" }) + self.state = { stateField = "somethingelse" } + end + + function StatefulComponent:render() + return React.createElement("div") + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(StatefulComponent)) + end) + end).toErrorDev( + "Warning: Can't call setState on a component that is not yet mounted. " + .. "This is a no-op, but it might indicate a bug in your application. " + .. "Instead, assign to `self.state` directly with the desired state " + .. "in the StatefulComponent component's `init` method." + ) + + -- Check deduplication; (no extra warnings should be logged). + ReactNoop.act(function() + ReactNoop.render(React.createElement(StatefulComponent)) + end) +end) + +it("should correctly determine if a component is mounted", function() + local isMounted + local Component = React.Component:extend("Component") + + function Component:init() + isMounted = function() + -- No longer a public API, but we can test that it works internally by + -- reaching into the updater. + return self.__updater.isMounted(self) + end + end + function Component:UNSAFE_componentWillMount() + jestExpect(isMounted()).toBe(false) + end + function Component:componentDidMount() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + jestExpect(isMounted()).toBe(true) + end + function Component:render() + jestExpect(isMounted()).toBe(false) + return React.createElement("div") + end + + local element = React.createElement(Component) + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(isMounted()).toBe(true) + end).toErrorDev({ + "Component is accessing isMounted inside its render()", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) +end) + +it("should correctly determine if a nil component is mounted", function() + local isMounted + local Component = React.Component:extend("Component") + + function Component:init() + isMounted = function() + -- No longer a public API, but we can test that it works internally by + -- reaching into the updater. + return self.__updater.isMounted(self) + end + end + function Component:UNSAFE_componentWillMount() + jestExpect(isMounted()).toBe(false) + end + function Component:componentDidMount() + jestExpect(isMounted()).toBe(true) + end + function Component:render() + jestExpect(isMounted()).toBe(false) + return nil + end + + local element = React.createElement(Component) + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(isMounted()).toBe(true) + end).toErrorDev({ + "Component is accessing isMounted inside its render()", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) +end) + +it("isMounted should return false when unmounted", function() + local isMounted + local Component = React.Component:extend("Component") + function Component:init() + isMounted = function() + return self.__updater.isMounted(self) + end + end + function Component:render() + return React.createElement("div") + end + + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component)) + end) + + -- No longer a public API, but we can test that it works internally by + -- reaching into the updater. + jestExpect(isMounted()).toBe(true) + + ReactNoop.act(function() + ReactNoop.render(nil) + end) + + jestExpect(isMounted()).toBe(false) +end) -- xit('warns if findDOMNode is used inside render', function() -- class Component extends React.Component { @@ -397,136 +402,125 @@ return function() -- }).toErrorDev('Component is accessing findDOMNode inside its render()') -- }) - it('should carry through each of the phases of setup', function() - local _testJournal: any = {} - local getTestLifeCycleState, getInstanceState - local LifeCycleComponent = React.Component:extend("LifeCycleComponent") - function LifeCycleComponent:init() - local initState = { - hasWillMountCompleted = false, - hasDidMountCompleted = false, - hasRenderCompleted = false, - hasWillUnmountCompleted = false, - } - getTestLifeCycleState = function() - return getLifeCycleState(self) - end - getInstanceState = function() - return self.state - end - _testJournal.returnedFromGetInitialState = clone(initState) - _testJournal.lifeCycleAtStartOfGetInitialState = getTestLifeCycleState() - self.state = initState - end - - function LifeCycleComponent:UNSAFE_componentWillMount() - _testJournal.stateAtStartOfWillMount = clone(self.state) - _testJournal.lifeCycleAtStartOfWillMount = getTestLifeCycleState() - self.state.hasWillMountCompleted = true - end - - function LifeCycleComponent:componentDidMount() - _testJournal.stateAtStartOfDidMount = clone(self.state) - _testJournal.lifeCycleAtStartOfDidMount = getTestLifeCycleState() - self:setState({hasDidMountCompleted = true}) - end - - function LifeCycleComponent:render() - local isInitialRender = not self.state.hasRenderCompleted - if isInitialRender then - _testJournal.stateInInitialRender = clone(self.state) - _testJournal.lifeCycleInInitialRender = getTestLifeCycleState() - else - _testJournal.stateInLaterRender = clone(self.state) - _testJournal.lifeCycleInLaterRender = getTestLifeCycleState() - end - -- you would *NEVER* do anything like this in real code! - self.state.hasRenderCompleted = true - return React.createElement("TextLabel", { Text = "I am the inner DIV" }) - end - - function LifeCycleComponent:componentWillUnmount() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - - _testJournal.stateAtStartOfWillUnmount = clone(self.state) - _testJournal.lifeCycleAtStartOfWillUnmount = getTestLifeCycleState() - self.state.hasWillUnmountCompleted = true - end - - -- A component that is merely "constructed" (as in "constructor") but not - -- yet initialized, or rendered. - -- - -- local container = document.createElement('div') - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(LifeCycleComponent)) - end) - end).toErrorDev( - { - "LifeCycleComponent is accessing isMounted inside its render() function", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - - -- getInitialState - jestExpect(_testJournal.returnedFromGetInitialState).toEqual( - GET_INIT_STATE_RETURN_VAL - ) - jestExpect(_testJournal.lifeCycleAtStartOfGetInitialState).toBe( - 'UNMOUNTED' - ) - - -- componentWillMount - jestExpect(_testJournal.stateAtStartOfWillMount).toEqual( - _testJournal.returnedFromGetInitialState - ) - jestExpect(_testJournal.lifeCycleAtStartOfWillMount).toBe('UNMOUNTED') - - -- componentDidMount - jestExpect(_testJournal.stateAtStartOfDidMount).toEqual( - DID_MOUNT_STATE - ) - jestExpect(_testJournal.lifeCycleAtStartOfDidMount).toBe('MOUNTED') - - -- initial render - jestExpect(_testJournal.stateInInitialRender).toEqual( - INIT_RENDER_STATE - ) - jestExpect(_testJournal.lifeCycleInInitialRender).toBe('UNMOUNTED') - - jestExpect(getTestLifeCycleState()).toBe('MOUNTED') - - -- Now *update the component* - -- instance.forceUpdate() - ReactNoop.act(function() - ReactNoop.render(React.createElement(LifeCycleComponent)) - end) - - -- render 2nd time - jestExpect(_testJournal.stateInLaterRender).toEqual(NEXT_RENDER_STATE) - jestExpect(_testJournal.lifeCycleInLaterRender).toBe('MOUNTED') - - jestExpect(getTestLifeCycleState()).toBe('MOUNTED') - - ReactNoop.act(function() - ReactNoop.render(nil) - end) - - jestExpect(_testJournal.stateAtStartOfWillUnmount).toEqual( - WILL_UNMOUNT_STATE - ) - -- componentWillUnmount called right before unmount. - jestExpect(_testJournal.lifeCycleAtStartOfWillUnmount).toBe('MOUNTED') - - -- But the current lifecycle of the component is unmounted. - jestExpect(getTestLifeCycleState()).toBe('UNMOUNTED') - jestExpect(getInstanceState()).toEqual(POST_WILL_UNMOUNT_STATE) - end) - - -- getting to the real error here requires commenting out many try/catch, but here it is: +it("should carry through each of the phases of setup", function() + local _testJournal: any = {} + local getTestLifeCycleState, getInstanceState + local LifeCycleComponent = React.Component:extend("LifeCycleComponent") + function LifeCycleComponent:init() + local initState = { + hasWillMountCompleted = false, + hasDidMountCompleted = false, + hasRenderCompleted = false, + hasWillUnmountCompleted = false, + } + getTestLifeCycleState = function() + return getLifeCycleState(self) + end + getInstanceState = function() + return self.state + end + _testJournal.returnedFromGetInitialState = clone(initState) + _testJournal.lifeCycleAtStartOfGetInitialState = getTestLifeCycleState() + self.state = initState + end + + function LifeCycleComponent:UNSAFE_componentWillMount() + _testJournal.stateAtStartOfWillMount = clone(self.state) + _testJournal.lifeCycleAtStartOfWillMount = getTestLifeCycleState() + self.state.hasWillMountCompleted = true + end + + function LifeCycleComponent:componentDidMount() + _testJournal.stateAtStartOfDidMount = clone(self.state) + _testJournal.lifeCycleAtStartOfDidMount = getTestLifeCycleState() + self:setState({ hasDidMountCompleted = true }) + end + + function LifeCycleComponent:render() + local isInitialRender = not self.state.hasRenderCompleted + if isInitialRender then + _testJournal.stateInInitialRender = clone(self.state) + _testJournal.lifeCycleInInitialRender = getTestLifeCycleState() + else + _testJournal.stateInLaterRender = clone(self.state) + _testJournal.lifeCycleInLaterRender = getTestLifeCycleState() + end + -- you would *NEVER* do anything like this in real code! + self.state.hasRenderCompleted = true + return React.createElement("TextLabel", { Text = "I am the inner DIV" }) + end + + function LifeCycleComponent:componentWillUnmount() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + + _testJournal.stateAtStartOfWillUnmount = clone(self.state) + _testJournal.lifeCycleAtStartOfWillUnmount = getTestLifeCycleState() + self.state.hasWillUnmountCompleted = true + end + + -- A component that is merely "constructed" (as in "constructor") but not + -- yet initialized, or rendered. + -- + -- local container = document.createElement('div') + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(LifeCycleComponent)) + end) + end).toErrorDev({ + "LifeCycleComponent is accessing isMounted inside its render() function", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) + + -- getInitialState + jestExpect(_testJournal.returnedFromGetInitialState).toEqual( + GET_INIT_STATE_RETURN_VAL + ) + jestExpect(_testJournal.lifeCycleAtStartOfGetInitialState).toBe("UNMOUNTED") + + -- componentWillMount + jestExpect(_testJournal.stateAtStartOfWillMount).toEqual( + _testJournal.returnedFromGetInitialState + ) + jestExpect(_testJournal.lifeCycleAtStartOfWillMount).toBe("UNMOUNTED") + + -- componentDidMount + jestExpect(_testJournal.stateAtStartOfDidMount).toEqual(DID_MOUNT_STATE) + jestExpect(_testJournal.lifeCycleAtStartOfDidMount).toBe("MOUNTED") + + -- initial render + jestExpect(_testJournal.stateInInitialRender).toEqual(INIT_RENDER_STATE) + jestExpect(_testJournal.lifeCycleInInitialRender).toBe("UNMOUNTED") + + jestExpect(getTestLifeCycleState()).toBe("MOUNTED") + + -- Now *update the component* + -- instance.forceUpdate() + ReactNoop.act(function() + ReactNoop.render(React.createElement(LifeCycleComponent)) + end) + + -- render 2nd time + jestExpect(_testJournal.stateInLaterRender).toEqual(NEXT_RENDER_STATE) + jestExpect(_testJournal.lifeCycleInLaterRender).toBe("MOUNTED") + + jestExpect(getTestLifeCycleState()).toBe("MOUNTED") + + ReactNoop.act(function() + ReactNoop.render(nil) + end) + + jestExpect(_testJournal.stateAtStartOfWillUnmount).toEqual(WILL_UNMOUNT_STATE) + -- componentWillUnmount called right before unmount. + jestExpect(_testJournal.lifeCycleAtStartOfWillUnmount).toBe("MOUNTED") + + -- But the current lifecycle of the component is unmounted. + jestExpect(getTestLifeCycleState()).toBe("UNMOUNTED") + jestExpect(getInstanceState()).toEqual(POST_WILL_UNMOUNT_STATE) +end) + +-- getting to the real error here requires commenting out many try/catch, but here it is: -- LoadedCode.RoactAlignment.Modules.Scheduler.forks.SchedulerHostConfig.mock:172: Already flushing work. -- LoadedCode.RoactAlignment.Modules.Scheduler.forks.SchedulerHostConfig.mock:172 -- LoadedCode.RoactAlignment.Modules.ReactNoopRenderer.createReactNoop:1242 function noopAct @@ -551,640 +545,642 @@ return function() -- LoadedCode.RoactAlignment.Modules.ReactReconciler.__tests__.ReactComponentLifeCycle.spec:575 -- ROBLOX TODO: throws LoadedCode.RoactAlignment.Packages.Modules.Scheduler.forks.SchedulerHostConfig.mock:172: Already flushing work. -xit('should not throw when updating an auxiliary component', function() - local Tooltip = React.Component:extend("Tooltip") - function Tooltip:render() - return React.createElement("div", nil, self.props.children) - end - - function Tooltip:componentDidMount() - self.container = "some container" - self:updateTooltip() - end - - function Tooltip:componentDidUpdate() - self:updateTooltip() - end - - function Tooltip:updateTooltip() - -- Even though this.props.tooltip has an owner, updating it shouldn't - -- throw here because it's mounted as a root component - ReactNoop.act(function() - ReactNoop.renderToRootWithID(self.props.tooltip, self.container) - end) - end - - local Component = React.Component:extend("Component") - - function Component:render() - return React.createElement(Tooltip, - {tooltip = React.createElement("div", nil, self.props.tooltipText)}, - self.props.text - ) - end - - ReactNoop.act(function() - ReactNoop.render( - React.createElement(Component, {text = "uno", tooltipText = "one"}) - ) - end) - - -- Since `instance` is a root component, we can set its props. This also - -- makes Tooltip rerender the tooltip component, which shouldn't throw. - ReactNoop.act(function() - ReactNoop.render( - React.createElement(Component, {text="dos", tooltipText="two"}) - ) - end) - end) - - it('should allow state updates in componentDidMount', function() - local getComponentState - --[[* +xit("should not throw when updating an auxiliary component", function() + local Tooltip = React.Component:extend("Tooltip") + function Tooltip:render() + return React.createElement("div", nil, self.props.children) + end + + function Tooltip:componentDidMount() + self.container = "some container" + self:updateTooltip() + end + + function Tooltip:componentDidUpdate() + self:updateTooltip() + end + + function Tooltip:updateTooltip() + -- Even though this.props.tooltip has an owner, updating it shouldn't + -- throw here because it's mounted as a root component + ReactNoop.act(function() + ReactNoop.renderToRootWithID(self.props.tooltip, self.container) + end) + end + + local Component = React.Component:extend("Component") + + function Component:render() + return React.createElement( + Tooltip, + { tooltip = React.createElement("div", nil, self.props.tooltipText) }, + self.props.text + ) + end + + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Component, { text = "uno", tooltipText = "one" }) + ) + end) + + -- Since `instance` is a root component, we can set its props. This also + -- makes Tooltip rerender the tooltip component, which shouldn't throw. + ReactNoop.act(function() + ReactNoop.render( + React.createElement(Component, { text = "dos", tooltipText = "two" }) + ) + end) +end) + +it("should allow state updates in componentDidMount", function() + local getComponentState + --[[* * calls setState in an componentDidMount. ]] - local SetStateInComponentDidMount = React.Component:extend("SetStateInComponentDidMount") - function SetStateInComponentDidMount:init() - self.state = { - stateField = self.props.valueToUseInitially, - } - getComponentState = function() - return self.state - end - end - - function SetStateInComponentDidMount:componentDidMount() - self:setState({stateField = self.props.valueToUseAfterMount}) - end - - function SetStateInComponentDidMount:render() - return React.createElement("div") - end - - local element = - React.createElement(SetStateInComponentDidMount, { - valueToUseInitially = "hello", - valueToUseAfterMount = "goodbye", - }) - ReactNoop.act(function() - ReactNoop.render(element) - end) - jestExpect(getComponentState().stateField).toBe("goodbye") - end) - - it('should call nested legacy lifecycle methods in the right order', function() - local log - local logger = function(msg) - return function() - -- return true for shouldComponentUpdate - table.insert(log, msg) - return true - end - end - - local Outer = React.Component:extend("Outer") - -- pre-declare - local Inner = React.Component:extend("Inner") - Outer.UNSAFE_componentWillMount = logger("outer componentWillMount") - Outer.componentDidMount = logger("outer componentDidMount") - Outer.UNSAFE_componentWillReceiveProps = logger( - "outer componentWillReceiveProps" - ) - Outer.shouldComponentUpdate = logger("outer shouldComponentUpdate") - Outer.UNSAFE_componentWillUpdate = logger("outer componentWillUpdate") - Outer.componentDidUpdate = logger("outer componentDidUpdate") - Outer.componentWillUnmount = logger("outer componentWillUnmount") - function Outer:render() - return React.createElement("Frame", {}, - React.createElement(Inner, { - x = self.props.x - }) - ) - end - - Inner.UNSAFE_componentWillMount = logger("inner componentWillMount") - Inner.componentDidMount = logger("inner componentDidMount") - Inner.UNSAFE_componentWillReceiveProps = logger( - "inner componentWillReceiveProps" - ) - Inner.shouldComponentUpdate = logger("inner shouldComponentUpdate") - Inner.UNSAFE_componentWillUpdate = logger("inner componentWillUpdate") - Inner.componentDidUpdate = logger("inner componentDidUpdate") - Inner.componentWillUnmount = logger("inner componentWillUnmount") - function Inner:render() - return React.createElement("TextLabel", { Text = self.props.x }) - end - - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 1})) - end) - jestExpect(log).toEqual({ - "outer componentWillMount", - "inner componentWillMount", - "inner componentDidMount", - "outer componentDidMount", - }) - - -- Dedup warnings - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 2})) - end) - jestExpect(log).toEqual({ - "outer componentWillReceiveProps", - "outer shouldComponentUpdate", - "outer componentWillUpdate", - "inner componentWillReceiveProps", - "inner shouldComponentUpdate", - "inner componentWillUpdate", - "inner componentDidUpdate", - "outer componentDidUpdate", - }) - - log = {} - ReactNoop.act(function() - ReactNoop.render(nil) - end) - jestExpect(log).toEqual({ - "outer componentWillUnmount", - "inner componentWillUnmount", - }) - end) - - it('should call nested new lifecycle methods in the right order', function() - local log - local logger = function(msg) - return function() - -- return true for shouldComponentUpdate - table.insert(log, msg) - return true - end - end - local Outer = React.Component:extend("Outer") - -- pre-declare - local Inner = React.Component:extend("Inner") - function Outer:init() - self.state = {} - end - function Outer.getDerivedStateFromProps(props, prevState) - table.insert(log, "outer getDerivedStateFromProps") - return nil - end - Outer.componentDidMount = logger("outer componentDidMount") - Outer.shouldComponentUpdate = logger("outer shouldComponentUpdate") - Outer.getSnapshotBeforeUpdate = logger("outer getSnapshotBeforeUpdate") - Outer.componentDidUpdate = logger("outer componentDidUpdate") - Outer.componentWillUnmount = logger("outer componentWillUnmount") - function Outer:render() - return React.createElement("Frame", {}, - React.createElement(Inner, {x = self.props.x}) - ) - end - - function Inner:init() - self.state = {} - end - function Inner.getDerivedStateFromProps(props, prevState) - table.insert(log, "inner getDerivedStateFromProps") - return nil - end - Inner.componentDidMount = logger("inner componentDidMount") - Inner.shouldComponentUpdate = logger("inner shouldComponentUpdate") - Inner.getSnapshotBeforeUpdate = logger("inner getSnapshotBeforeUpdate") - Inner.componentDidUpdate = logger("inner componentDidUpdate") - Inner.componentWillUnmount = logger("inner componentWillUnmount") - function Inner:render() - return React.createElement("TextLabel", {Text = self.props.x}) - end - - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 1})) - end) - jestExpect(log).toEqual({ - "outer getDerivedStateFromProps", - "inner getDerivedStateFromProps", - "inner componentDidMount", - "outer componentDidMount", - }) - - -- Dedup warnings - log = {} - ReactNoop.act(function() - ReactNoop.render(React.createElement(Outer, {x = 2})) - end) - jestExpect(log).toEqual({ - "outer getDerivedStateFromProps", - "outer shouldComponentUpdate", - "inner getDerivedStateFromProps", - "inner shouldComponentUpdate", - "inner getSnapshotBeforeUpdate", - "outer getSnapshotBeforeUpdate", - "inner componentDidUpdate", - "outer componentDidUpdate", - }) - - log = {} - ReactNoop.act(function() - ReactNoop.render(nil) - end) - jestExpect(log).toEqual({ - "outer componentWillUnmount", - "inner componentWillUnmount", - }) - 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() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - - error(Error('unexpected')) - end - function Component:componentWillUpdate() - error(Error('unexpected')) - end - function Component:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component)) - end) - end).toErrorDev( - 'Unsafe legacy lifecycles will not be called for components using new component APIs.' - ) - end).toWarnDev( - -- We should consider removing this altogether; the old behavior referred - -- to here is unique to React. None of Roact's old behavior is reflected - -- by these messages and is likely to confuse existing users - { - 'componentWillMount has been renamed', - 'componentWillReceiveProps has been renamed', - 'componentWillUpdate has been renamed', - }, - {withoutStack = true} - ) - end) - - -- ROBLOX FIXME: outputs none of the toWarnDev() expected messages in DEV mode - it('should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present', function() - local Component = React.Component:extend("Component") - function Component:init() - self.state = {} - end - function Component:getSnapshotBeforeUpdate() - return nil - end - function Component:componentWillMount() - error(Error('unexpected')) - end - function Component:componentWillReceiveProps() - error(Error('unexpected')) - end - function Component:componentWillUpdate() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - error(Error('unexpected')) - end - function Component:componentDidUpdate() - -- ROBLOX deviation: assert self is non nil - jestExpect(self).never.toEqual(nil) - end - function Component:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component, {value=1})) - end) - end).toErrorDev( - 'Unsafe legacy lifecycles will not be called for components using new component APIs.' - ) - end).toWarnDev( - { - 'componentWillMount has been renamed', - 'componentWillReceiveProps has been renamed', - 'componentWillUpdate has been renamed' - }, - {withoutStack = true} - ) - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component, {value=2})) - end) - end) - - it('should not invoke new unsafe lifecycles (cWM/cWRP/cWU) if 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:UNSAFE_componentWillMount() - error(Error('unexpected')) - end - function Component:UNSAFE_componentWillReceiveProps() - error(Error('unexpected')) - end - function Component:UNSAFE_componentWillUpdate() - error(Error('unexpected')) - end - function Component:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component, {value = 1})) - end) - end).toErrorDev({ - "Unsafe legacy lifecycles will not be called for components using new component APIs.", - -- deviation: ReactNoop runs with a StrictMode root and logs more warnings - "Using UNSAFE_componentWillMount in strict mode is not recommended", - "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended", - "Using UNSAFE_componentWillUpdate in strict mode is not recommended", - }, {withoutStack = 3}) - ReactNoop.act(function() - ReactNoop.render(React.createElement(Component, {value = 2})) - end) - end) - - it('should warn about deprecated lifecycles (cWM/cWRP/cWU) if new static gDSFP is present', function() - local AllLegacyLifecycles = React.Component:extend("AllLegacyLifecycles") - function AllLegacyLifecycles:init() - self.state = {} - end - function AllLegacyLifecycles.getDerivedStateFromProps() - return nil - end - function AllLegacyLifecycles:componentWillMount() end - function AllLegacyLifecycles:UNSAFE_componentWillReceiveProps() end - function AllLegacyLifecycles:componentWillUpdate() end - function AllLegacyLifecycles:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(AllLegacyLifecycles)) - end) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "AllLegacyLifecycles uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" .. - " componentWillMount\n" .. - " UNSAFE_componentWillReceiveProps\n" .. - " componentWillUpdate\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillReceiveProps in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end).toWarnDev( - { - "componentWillMount has been renamed", - "componentWillUpdate has been renamed", - }, - {withoutStack = true} - ) - - local WillMount = React.Component:extend("WillMount") - function WillMount:init() - self.state = {} - end - function WillMount.getDerivedStateFromProps() - return nil - end - function WillMount:UNSAFE_componentWillMount() end - function WillMount:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillMount)) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "WillMount uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" .. - " UNSAFE_componentWillMount\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end) - - local WillMountAndUpdate = React.Component:extend("WillMountAndUpdate") - function WillMountAndUpdate:init() - self.state = {} - end - function WillMountAndUpdate.getDerivedStateFromProps() - return nil - end - function WillMountAndUpdate:componentWillMount() end - function WillMountAndUpdate:UNSAFE_componentWillUpdate() end - function WillMountAndUpdate:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillMountAndUpdate)) - end) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "WillMountAndUpdate uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" .. - " componentWillMount\n" .. - " UNSAFE_componentWillUpdate\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillUpdate in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end).toWarnDev({"componentWillMount has been renamed"}, { - withoutStack = true, - }) - - local WillReceiveProps = React.Component:extend("WillReceiveProps") - function WillReceiveProps:init() - self.state = {} - end - function WillReceiveProps.getDerivedStateFromProps() - return nil - end - function WillReceiveProps:componentWillReceiveProps() end - function WillReceiveProps:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillReceiveProps)) - end) - end).toErrorDev( - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "WillReceiveProps uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" .. - " componentWillReceiveProps\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles" - ) - end).toWarnDev({"componentWillReceiveProps has been renamed"}, { - withoutStack = true, - }) - end) - - it('should warn about deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present', function() - local AllLegacyLifecycles = React.Component:extend("AllLegacyLifecycles") - function AllLegacyLifecycles:init() - self.state = {} - end - function AllLegacyLifecycles:getSnapshotBeforeUpdate() end - function AllLegacyLifecycles:componentWillMount() end - function AllLegacyLifecycles:UNSAFE_componentWillReceiveProps() end - function AllLegacyLifecycles:componentWillUpdate() end - function AllLegacyLifecycles:componentDidUpdate() end - function AllLegacyLifecycles:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(AllLegacyLifecycles)) - end) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "AllLegacyLifecycles uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" .. - " componentWillMount\n" .. - " UNSAFE_componentWillReceiveProps\n" .. - " componentWillUpdate\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillReceiveProps in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end).toWarnDev( - { - "componentWillMount has been renamed", - "componentWillUpdate has been renamed" - }, - {withoutStack = true} - ) - - local WillMount = React.Component:extend("WillMount") - function WillMount:init() - self.state = {} - end - function WillMount:getSnapshotBeforeUpdate() end - function WillMount:UNSAFE_componentWillMount() end - function WillMount:componentDidUpdate() end - function WillMount:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillMount)) - end) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "WillMount uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" .. - " UNSAFE_componentWillMount\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillMount in strict mode is not recommended", - }, - {withoutStack = 1} - ) - - local WillMountAndUpdate = React.Component:extend("WillMountAndUpdate") - function WillMountAndUpdate:init() - self.state = {} - end - function WillMountAndUpdate:getSnapshotBeforeUpdate() end - function WillMountAndUpdate:componentWillMount() end - function WillMountAndUpdate:UNSAFE_componentWillUpdate() end - function WillMountAndUpdate:componentDidUpdate() end - function WillMountAndUpdate:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillMountAndUpdate)) - end) - end).toErrorDev( - { - "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" .. - "WillMountAndUpdate uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" .. - " componentWillMount\n" .. - " UNSAFE_componentWillUpdate\n\n" .. - "The above lifecycles should be removed. Learn more about this warning here:\n" .. - "https://reactjs.org/link/unsafe-component-lifecycles", - "UNSAFE_componentWillUpdate in strict mode is not recommended", - }, - {withoutStack = 1} - ) - end).toWarnDev({"componentWillMount has been renamed"}, { - withoutStack = true - }) - - local WillReceiveProps = React.Component:extend("WillReceiveProps") - function WillReceiveProps:init() - self.state = {} - end - function WillReceiveProps:getSnapshotBeforeUpdate() end - function WillReceiveProps:componentWillReceiveProps() end - function WillReceiveProps:componentDidUpdate() end - function WillReceiveProps:render() - return nil - end - - jestExpect(function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(WillReceiveProps)) - end) - end).toErrorDev( - 'Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n' .. - 'WillReceiveProps uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n' .. - ' componentWillReceiveProps\n\n' .. - 'The above lifecycles should be removed. Learn more about this warning here:\n' .. - 'https://reactjs.org/link/unsafe-component-lifecycles' - ) - end).toWarnDev({'componentWillReceiveProps has been renamed'}, { - withoutStack = true, - }) - end) + local SetStateInComponentDidMount = + React.Component:extend("SetStateInComponentDidMount") + function SetStateInComponentDidMount:init() + self.state = { + stateField = self.props.valueToUseInitially, + } + getComponentState = function() + return self.state + end + end + + function SetStateInComponentDidMount:componentDidMount() + self:setState({ stateField = self.props.valueToUseAfterMount }) + end + + function SetStateInComponentDidMount:render() + return React.createElement("div") + end + + local element = React.createElement(SetStateInComponentDidMount, { + valueToUseInitially = "hello", + valueToUseAfterMount = "goodbye", + }) + ReactNoop.act(function() + ReactNoop.render(element) + end) + jestExpect(getComponentState().stateField).toBe("goodbye") +end) + +it("should call nested legacy lifecycle methods in the right order", function() + local log + local logger = function(msg) + return function() + -- return true for shouldComponentUpdate + table.insert(log, msg) + return true + end + end + + local Outer = React.Component:extend("Outer") + -- pre-declare + local Inner = React.Component:extend("Inner") + Outer.UNSAFE_componentWillMount = logger("outer componentWillMount") + Outer.componentDidMount = logger("outer componentDidMount") + Outer.UNSAFE_componentWillReceiveProps = logger("outer componentWillReceiveProps") + Outer.shouldComponentUpdate = logger("outer shouldComponentUpdate") + Outer.UNSAFE_componentWillUpdate = logger("outer componentWillUpdate") + Outer.componentDidUpdate = logger("outer componentDidUpdate") + Outer.componentWillUnmount = logger("outer componentWillUnmount") + function Outer:render() + return React.createElement( + "Frame", + {}, + React.createElement(Inner, { + x = self.props.x, + }) + ) + end + + Inner.UNSAFE_componentWillMount = logger("inner componentWillMount") + Inner.componentDidMount = logger("inner componentDidMount") + Inner.UNSAFE_componentWillReceiveProps = logger("inner componentWillReceiveProps") + Inner.shouldComponentUpdate = logger("inner shouldComponentUpdate") + Inner.UNSAFE_componentWillUpdate = logger("inner componentWillUpdate") + Inner.componentDidUpdate = logger("inner componentDidUpdate") + Inner.componentWillUnmount = logger("inner componentWillUnmount") + function Inner:render() + return React.createElement("TextLabel", { Text = self.props.x }) + end + + log = {} + -- ROBLOX deviation START: Wrap to catch warnings (see deviation below) + jestExpect(function() + -- ROBLOX deviation END + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 1 })) + end) + -- ROBLOX deviation START: The upstream equivalents of these tests run with react-dom + -- using the legacy root, so they don't throw warnings related to strict + -- mode; we compromise by keeping it in concurrent mode to better match + -- production, but anticipating the warnings + end).toErrorDev({ + "Using UNSAFE_componentWillMount in strict mode is not recommended", + "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended", + "Using UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = true }) + -- ROBLOX deviation END + jestExpect(log).toEqual({ + "outer componentWillMount", + "inner componentWillMount", + "inner componentDidMount", + "outer componentDidMount", + }) + + -- Dedup warnings + log = {} + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 2 })) + end) + jestExpect(log).toEqual({ + "outer componentWillReceiveProps", + "outer shouldComponentUpdate", + "outer componentWillUpdate", + "inner componentWillReceiveProps", + "inner shouldComponentUpdate", + "inner componentWillUpdate", + "inner componentDidUpdate", + "outer componentDidUpdate", + }) + + log = {} + ReactNoop.act(function() + ReactNoop.render(nil) + end) + jestExpect(log).toEqual({ + "outer componentWillUnmount", + "inner componentWillUnmount", + }) +end) + +it("should call nested new lifecycle methods in the right order", function() + local log + local logger = function(msg) + return function() + -- return true for shouldComponentUpdate + table.insert(log, msg) + return true + end + end + local Outer = React.Component:extend("Outer") + -- pre-declare + local Inner = React.Component:extend("Inner") + function Outer:init() + self.state = {} + end + function Outer.getDerivedStateFromProps(props, prevState) + table.insert(log, "outer getDerivedStateFromProps") + return nil + end + Outer.componentDidMount = logger("outer componentDidMount") + Outer.shouldComponentUpdate = logger("outer shouldComponentUpdate") + Outer.getSnapshotBeforeUpdate = logger("outer getSnapshotBeforeUpdate") + Outer.componentDidUpdate = logger("outer componentDidUpdate") + Outer.componentWillUnmount = logger("outer componentWillUnmount") + function Outer:render() + return React.createElement( + "Frame", + {}, + React.createElement(Inner, { x = self.props.x }) + ) + end + + function Inner:init() + self.state = {} + end + function Inner.getDerivedStateFromProps(props, prevState) + table.insert(log, "inner getDerivedStateFromProps") + return nil + end + Inner.componentDidMount = logger("inner componentDidMount") + Inner.shouldComponentUpdate = logger("inner shouldComponentUpdate") + Inner.getSnapshotBeforeUpdate = logger("inner getSnapshotBeforeUpdate") + Inner.componentDidUpdate = logger("inner componentDidUpdate") + Inner.componentWillUnmount = logger("inner componentWillUnmount") + function Inner:render() + return React.createElement("TextLabel", { Text = self.props.x }) + end + + log = {} + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 1 })) + end) + jestExpect(log).toEqual({ + "outer getDerivedStateFromProps", + "inner getDerivedStateFromProps", + "inner componentDidMount", + "outer componentDidMount", + }) + + -- Dedup warnings + log = {} + ReactNoop.act(function() + ReactNoop.render(React.createElement(Outer, { x = 2 })) + end) + jestExpect(log).toEqual({ + "outer getDerivedStateFromProps", + "outer shouldComponentUpdate", + "inner getDerivedStateFromProps", + "inner shouldComponentUpdate", + "inner getSnapshotBeforeUpdate", + "outer getSnapshotBeforeUpdate", + "inner componentDidUpdate", + "outer componentDidUpdate", + }) + + log = {} + ReactNoop.act(function() + ReactNoop.render(nil) + end) + jestExpect(log).toEqual({ + "outer componentWillUnmount", + "inner componentWillUnmount", + }) +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() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + + error(Error("unexpected")) + end + function Component:componentWillUpdate() + error(Error("unexpected")) + end + function Component:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component)) + end) + end).toErrorDev( + "Unsafe legacy lifecycles will not be called for components using new component APIs." + ) + end).toWarnDev( + -- We should consider removing this altogether; the old behavior referred + -- to here is unique to React. None of Roact's old behavior is reflected + -- by these messages and is likely to confuse existing users + { + "componentWillMount has been renamed", + "componentWillReceiveProps has been renamed", + "componentWillUpdate has been renamed", + }, + { withoutStack = true } + ) + end +) + +-- ROBLOX FIXME: outputs none of the toWarnDev() expected messages in DEV mode +it( + "should not invoke deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present", + function() + local Component = React.Component:extend("Component") + function Component:init() + self.state = {} + end + function Component:getSnapshotBeforeUpdate() + return nil + end + function Component:componentWillMount() + error(Error("unexpected")) + end + function Component:componentWillReceiveProps() + error(Error("unexpected")) + end + function Component:componentWillUpdate() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + error(Error("unexpected")) + end + function Component:componentDidUpdate() + -- ROBLOX deviation: assert self is non nil + jestExpect(self).never.toEqual(nil) + end + function Component:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component, { value = 1 })) + end) + end).toErrorDev( + "Unsafe legacy lifecycles will not be called for components using new component APIs." + ) + end).toWarnDev({ + "componentWillMount has been renamed", + "componentWillReceiveProps has been renamed", + "componentWillUpdate has been renamed", + }, { withoutStack = true }) + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component, { value = 2 })) + end) + end +) + +it( + "should not invoke new unsafe lifecycles (cWM/cWRP/cWU) if 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:UNSAFE_componentWillMount() + error(Error("unexpected")) + end + function Component:UNSAFE_componentWillReceiveProps() + error(Error("unexpected")) + end + function Component:UNSAFE_componentWillUpdate() + error(Error("unexpected")) + end + function Component:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component, { value = 1 })) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.", + -- deviation: ReactNoop runs with a StrictMode root and logs more warnings + "Using UNSAFE_componentWillMount in strict mode is not recommended", + "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended", + "Using UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = 3 }) + ReactNoop.act(function() + ReactNoop.render(React.createElement(Component, { value = 2 })) + end) + end +) + +it( + "should warn about deprecated lifecycles (cWM/cWRP/cWU) if new static gDSFP is present", + function() + local AllLegacyLifecycles = React.Component:extend("AllLegacyLifecycles") + function AllLegacyLifecycles:init() + self.state = {} + end + function AllLegacyLifecycles.getDerivedStateFromProps() + return nil + end + function AllLegacyLifecycles:componentWillMount() end + function AllLegacyLifecycles:UNSAFE_componentWillReceiveProps() end + function AllLegacyLifecycles:componentWillUpdate() end + function AllLegacyLifecycles:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(AllLegacyLifecycles)) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "AllLegacyLifecycles uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" + .. " componentWillMount\n" + .. " UNSAFE_componentWillReceiveProps\n" + .. " componentWillUpdate\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillReceiveProps in strict mode is not recommended", + }, { withoutStack = 1 }) + end).toWarnDev({ + "componentWillMount has been renamed", + "componentWillUpdate has been renamed", + }, { withoutStack = true }) + + local WillMount = React.Component:extend("WillMount") + function WillMount:init() + self.state = {} + end + function WillMount.getDerivedStateFromProps() + return nil + end + function WillMount:UNSAFE_componentWillMount() end + function WillMount:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillMount)) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillMount uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" + .. " UNSAFE_componentWillMount\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) + end) + + local WillMountAndUpdate = React.Component:extend("WillMountAndUpdate") + function WillMountAndUpdate:init() + self.state = {} + end + function WillMountAndUpdate.getDerivedStateFromProps() + return nil + end + function WillMountAndUpdate:componentWillMount() end + function WillMountAndUpdate:UNSAFE_componentWillUpdate() end + function WillMountAndUpdate:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillMountAndUpdate)) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillMountAndUpdate uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" + .. " componentWillMount\n" + .. " UNSAFE_componentWillUpdate\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = 1 }) + end).toWarnDev({ "componentWillMount has been renamed" }, { + withoutStack = true, + }) + + local WillReceiveProps = React.Component:extend("WillReceiveProps") + function WillReceiveProps:init() + self.state = {} + end + function WillReceiveProps.getDerivedStateFromProps() + return nil + end + function WillReceiveProps:componentWillReceiveProps() end + function WillReceiveProps:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillReceiveProps)) + end) + end).toErrorDev( + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillReceiveProps uses getDerivedStateFromProps() but also contains the following legacy lifecycles:\n" + .. " componentWillReceiveProps\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles" + ) + end).toWarnDev({ "componentWillReceiveProps has been renamed" }, { + withoutStack = true, + }) + end +) + +it( + "should warn about deprecated lifecycles (cWM/cWRP/cWU) if new getSnapshotBeforeUpdate is present", + function() + local AllLegacyLifecycles = React.Component:extend("AllLegacyLifecycles") + function AllLegacyLifecycles:init() + self.state = {} + end + function AllLegacyLifecycles:getSnapshotBeforeUpdate() end + function AllLegacyLifecycles:componentWillMount() end + function AllLegacyLifecycles:UNSAFE_componentWillReceiveProps() end + function AllLegacyLifecycles:componentWillUpdate() end + function AllLegacyLifecycles:componentDidUpdate() end + function AllLegacyLifecycles:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(AllLegacyLifecycles)) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "AllLegacyLifecycles uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" + .. " componentWillMount\n" + .. " UNSAFE_componentWillReceiveProps\n" + .. " componentWillUpdate\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillReceiveProps in strict mode is not recommended", + }, { withoutStack = 1 }) + end).toWarnDev({ + "componentWillMount has been renamed", + "componentWillUpdate has been renamed", + }, { withoutStack = true }) + + local WillMount = React.Component:extend("WillMount") + function WillMount:init() + self.state = {} + end + function WillMount:getSnapshotBeforeUpdate() end + function WillMount:UNSAFE_componentWillMount() end + function WillMount:componentDidUpdate() end + function WillMount:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillMount)) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillMount uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" + .. " UNSAFE_componentWillMount\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillMount in strict mode is not recommended", + }, { withoutStack = 1 }) + + local WillMountAndUpdate = React.Component:extend("WillMountAndUpdate") + function WillMountAndUpdate:init() + self.state = {} + end + function WillMountAndUpdate:getSnapshotBeforeUpdate() end + function WillMountAndUpdate:componentWillMount() end + function WillMountAndUpdate:UNSAFE_componentWillUpdate() end + function WillMountAndUpdate:componentDidUpdate() end + function WillMountAndUpdate:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillMountAndUpdate)) + end) + end).toErrorDev({ + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillMountAndUpdate uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" + .. " componentWillMount\n" + .. " UNSAFE_componentWillUpdate\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles", + "UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = 1 }) + end).toWarnDev({ "componentWillMount has been renamed" }, { + withoutStack = true, + }) + + local WillReceiveProps = React.Component:extend("WillReceiveProps") + function WillReceiveProps:init() + self.state = {} + end + function WillReceiveProps:getSnapshotBeforeUpdate() end + function WillReceiveProps:componentWillReceiveProps() end + function WillReceiveProps:componentDidUpdate() end + function WillReceiveProps:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(WillReceiveProps)) + end) + end).toErrorDev( + "Unsafe legacy lifecycles will not be called for components using new component APIs.\n\n" + .. "WillReceiveProps uses getSnapshotBeforeUpdate() but also contains the following legacy lifecycles:\n" + .. " componentWillReceiveProps\n\n" + .. "The above lifecycles should be removed. Learn more about this warning here:\n" + .. "https://reactjs.org/link/unsafe-component-lifecycles" + ) + end).toWarnDev({ "componentWillReceiveProps has been renamed" }, { + withoutStack = true, + }) + end +) -- if !require('shared/ReactFeatureFlags').disableModulePatternComponents) -- it('calls effects on module-pattern component', function() @@ -1268,255 +1264,304 @@ xit('should not throw when updating an auxiliary component', function() -- ReactDOM.render(, div) -- }) - it("should warn if state is not initialized before getDerivedStateFromProps", function() - local MyComponent = React.Component:extend("MyComponent") - function MyComponent.getDerivedStateFromProps() - return nil - end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end).toErrorDev( - -- ROBLOX FIXME: This error message doesn't quite apply as written - "`MyComponent` uses `getDerivedStateFromProps` but its initial state is " .. - "nil. This is not recommended. Instead, define the initial state by " .. - "assigning an object to `self.state` in the `init` method of `MyComponent`. " .. - "This ensures that `getDerivedStateFromProps` arguments have a consistent shape." - ) - - -- De-duped - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end) - - it("should invoke both deprecated and new lifecycles if both are present", function() - local log = {} - - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:componentWillMount() - table.insert(log, "componentWillMount") - end - function MyComponent:componentWillReceiveProps() - table.insert(log, "componentWillReceiveProps") - end - function MyComponent:componentWillUpdate() - table.insert(log, "componentWillUpdate") - end - function MyComponent:UNSAFE_componentWillMount() - table.insert(log, "UNSAFE_componentWillMount") - end - function MyComponent:UNSAFE_componentWillReceiveProps() - table.insert(log, "UNSAFE_componentWillReceiveProps") - end - function MyComponent:UNSAFE_componentWillUpdate() - table.insert(log, "UNSAFE_componentWillUpdate") - end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent, {foo = "bar"})) - end) - end).toWarnDev( - { - "componentWillMount has been renamed", - "componentWillReceiveProps has been renamed", - "componentWillUpdate has been renamed", - }, - {withoutStack = true} - ) - jestExpect(log).toEqual({"componentWillMount", "UNSAFE_componentWillMount"}) - - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent, {foo = "baz"})) - end) - jestExpect(log).toEqual({ - "componentWillReceiveProps", - "UNSAFE_componentWillReceiveProps", - "componentWillUpdate", - "UNSAFE_componentWillUpdate", - }) - end) - --- -- This would be hard to get working without more DOM logic simulation --- xit('should not override state with stale values if prevState is spread within getDerivedStateFromProps', function() --- local divRef = React.createRef() --- local childInstance - --- class Child extends React.Component { --- state = {local: 0} --- static getDerivedStateFromProps(nextProps, prevState) --- return {...prevState, remote: nextProps.remote} --- end --- updateState = function() --- this.setState(state => ({local: state.local + 1})) --- this.props.onChange(this.state.remote + 1) --- end --- render() --- childInstance = this --- return ( ---
{`remote:${this.state.remote}, local:${this.state.local}`}
--- ) --- end --- end - --- class Parent extends React.Component { --- state = {value: 0} --- handleChange = value => { --- this.setState({value}) --- end --- render() --- return --- end --- end - --- ReactNoop.act(function() --- ReactNoop.render() --- }) - --- jestExpect(divRef.current.textContent).toBe('remote:0, local:0') - --- -- Trigger setState() calls --- childInstance.updateState() --- jestExpect(divRef.current.textContent).toBe('remote:1, local:1') - --- -- Trigger batched setState() calls --- divRef.current.click() --- jestExpect(divRef.current.textContent).toBe('remote:2, local:2') --- }) - - it('should pass the return value from getSnapshotBeforeUpdate to componentDidUpdate', function() - local log = {} - - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:init() - self.state = { - value = 0, - } - end - function MyComponent.getDerivedStateFromProps(nextProps, prevState) - return { - value = prevState.value + 1, - } - end - function MyComponent:getSnapshotBeforeUpdate(prevProps, prevState) - table.insert(log, string.format( - "getSnapshotBeforeUpdate() prevProps:%s prevState:%s", - prevProps.value, - prevState.value - ) - ) - return "abc" - end - function MyComponent:componentDidUpdate(prevProps, prevState, snapshot) - table.insert(log, string.format( - "componentDidUpdate() prevProps:%s prevState:%s snapshot:%s", - prevProps.value, - prevState.value, - snapshot - ) - ) - end - function MyComponent:render() - table.insert(log, "render") - return nil - end - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "foo", - }) - )) - end) - jestExpect(log).toEqual({"render"}) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "bar", - }) - )) - end) - jestExpect(log).toEqual({ - "render", - "getSnapshotBeforeUpdate() prevProps:foo prevState:1", - "componentDidUpdate() prevProps:foo prevState:1 snapshot:abc", - }) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame", {}, - React.createElement(MyComponent, { - value = "baz", - }) - )) - end) - jestExpect(log).toEqual({ - "render", - "getSnapshotBeforeUpdate() prevProps:bar prevState:2", - "componentDidUpdate() prevProps:bar prevState:2 snapshot:abc", - }) - log = {} - - ReactNoop.act(function() - ReactNoop.render(React.createElement("Frame")) - end) - jestExpect(log).toEqual({}) - end) - - it('should pass previous state to shouldComponentUpdate even with getDerivedStateFromProps', function() - local divRef = React.createRef() - local capturedValue - local SimpleComponent = React.Component:extend("SimpleComponent") - function SimpleComponent:init(props) - self.state = { - value = props.value, - } - end - - function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) - if nextProps.value == prevState.value then - return nil - end - return {value = nextProps.value} - end - - function SimpleComponent:shouldComponentUpdate(nextProps, nextState) - return nextState.value ~= self.state.value - end - - function SimpleComponent:render() - capturedValue = self.state.value - return React.createElement("Frame", { ref = divRef }, - React.createElement("TextLabel", { Text = self.state.value }) - ) - end - - ReactNoop.act(function() - ReactNoop.render(React.createElement(SimpleComponent, {value = "initial"})) - end) - jestExpect(capturedValue).toBe("initial") - ReactNoop.act(function() - ReactNoop.render(React.createElement(SimpleComponent, {value = "updated"})) - end) - jestExpect(capturedValue).toBe("updated") - end) +it("should warn if state is not initialized before getDerivedStateFromProps", function() + local MyComponent = React.Component:extend("MyComponent") + function MyComponent.getDerivedStateFromProps() + return nil + end + function MyComponent:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end).toErrorDev( + "Warning: `MyComponent` uses `getDerivedStateFromProps` but its initial state has not been initialized. " + .. "This is not recommended. Instead, define the initial state by " + .. "passing an object to `self:setState` in the `init` method of `MyComponent`. " + .. "This ensures that `getDerivedStateFromProps` arguments have a consistent shape." + ) + + -- De-duped + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) +end) + +it("should invoke both deprecated and new lifecycles if both are present", function() + local log = {} + + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:componentWillMount() + table.insert(log, "componentWillMount") + end + function MyComponent:componentWillReceiveProps() + table.insert(log, "componentWillReceiveProps") + end + function MyComponent:componentWillUpdate() + table.insert(log, "componentWillUpdate") + end + function MyComponent:UNSAFE_componentWillMount() + table.insert(log, "UNSAFE_componentWillMount") + end + function MyComponent:UNSAFE_componentWillReceiveProps() + table.insert(log, "UNSAFE_componentWillReceiveProps") + end + function MyComponent:UNSAFE_componentWillUpdate() + table.insert(log, "UNSAFE_componentWillUpdate") + end + function MyComponent:render() + return nil + end + + jestExpect(function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent, { foo = "bar" })) + end) + end).toWarnDev({ + "componentWillMount has been renamed", + "componentWillReceiveProps has been renamed", + "componentWillUpdate has been renamed", + }, { withoutStack = true }) + end).toErrorDev({ + "Using UNSAFE_componentWillMount in strict mode is not recommended", + "Using UNSAFE_componentWillReceiveProps in strict mode is not recommended", + "Using UNSAFE_componentWillUpdate in strict mode is not recommended", + }, { withoutStack = true }) + jestExpect(log).toEqual({ "componentWillMount", "UNSAFE_componentWillMount" }) + + log = {} + + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent, { foo = "baz" })) + end) + jestExpect(log).toEqual({ + "componentWillReceiveProps", + "UNSAFE_componentWillReceiveProps", + "componentWillUpdate", + "UNSAFE_componentWillUpdate", + }) +end) + +-- ROBLOX TODO: possibly a bug in the test due to divRef deviations, but a function state update doesn't get all the way through +xit( + "should not override state with stale values if prevState is spread within getDerivedStateFromProps", + function() + local divRef = React.createRef() + local childInstance + -- ROBLOX deviation: Noop renderer doesn't udpated the divRef like reactDOM does. figure out idiomatic way to update + local capturedValue + + local Child = React.Component:extend("Child") + function Child:init() + self.state = { local_ = 0 } + end + function Child.getDerivedStateFromProps(nextProps, prevState) + prevState.remote = nextProps.remote + return prevState + end + function Child:updateState() + self:setState(function(state) + return { local_ = state.local_ + 1 } + end) + self.props.onChange(self, self.state.remote + 1) + end + function Child:render() + capturedValue = "remote:" + .. tostring(self.state.remote) + .. ", local:" + .. tostring(self.state.local_) + childInstance = self + local renderedDiv = React.createElement("div", { + onClick = self.updateState, + ref = divRef, + }, capturedValue) + divRef.current = renderedDiv + return renderedDiv + end + + local Parent = React.Component:extend("Parent") + function Parent:init() + self.state = { value = 0 } + end + function Parent:handleChange(value) + self:setState({ value = value }) + end + function Parent:render() + return React.createElement( + Child, + { remote = self.state.value, onChange = self.handleChange } + ) + end + + ReactNoop.act(function() + ReactNoop.render(React.createElement(Parent)) + end) + + -- ROBLOX TODO: divRef doesn't get updated with Noop renderer like it does in DOM + -- jestExpect(divRef.current.textContent).toBe('remote:0, local:0') + jestExpect(capturedValue).toBe("remote:0, local:0") + + ReactNoop.act(function() + -- Trigger setState() calls + childInstance:updateState() + end) + -- ROBLOX TODO: remote is still 0 on this next line + -- jestExpect(divRef.current.textContent).toBe('remote:1, local:1') + jestExpect(capturedValue).toBe("remote:1, local:1") + + -- Trigger batched setState() calls + divRef.current.click() + jestExpect(divRef.current.textContent).toBe("remote:2, local:2") + end +) + +it( + "should pass the return value from getSnapshotBeforeUpdate to componentDidUpdate", + function() + local log = {} + + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:init() + self.state = { + value = 0, + } + end + function MyComponent.getDerivedStateFromProps(nextProps, prevState) + return { + value = prevState.value + 1, + } + end + function MyComponent:getSnapshotBeforeUpdate(prevProps, prevState) + table.insert( + log, + string.format( + "getSnapshotBeforeUpdate() prevProps:%s prevState:%s", + prevProps.value, + prevState.value + ) + ) + return "abc" + end + function MyComponent:componentDidUpdate(prevProps, prevState, snapshot) + table.insert( + log, + string.format( + "componentDidUpdate() prevProps:%s prevState:%s snapshot:%s", + prevProps.value, + prevState.value, + snapshot + ) + ) + end + function MyComponent:render() + table.insert(log, "render") + return nil + end + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "foo", + }) + ) + ) + end) + jestExpect(log).toEqual({ "render" }) + log = {} + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "bar", + }) + ) + ) + end) + jestExpect(log).toEqual({ + "render", + "getSnapshotBeforeUpdate() prevProps:foo prevState:1", + "componentDidUpdate() prevProps:foo prevState:1 snapshot:abc", + }) + log = {} + + ReactNoop.act(function() + ReactNoop.render( + React.createElement( + "Frame", + {}, + React.createElement(MyComponent, { + value = "baz", + }) + ) + ) + end) + jestExpect(log).toEqual({ + "render", + "getSnapshotBeforeUpdate() prevProps:bar prevState:2", + "componentDidUpdate() prevProps:bar prevState:2 snapshot:abc", + }) + log = {} + + ReactNoop.act(function() + ReactNoop.render(React.createElement("Frame")) + end) + jestExpect(log).toEqual({}) + end +) + +it( + "should pass previous state to shouldComponentUpdate even with getDerivedStateFromProps", + function() + local divRef = React.createRef() + local capturedValue + local SimpleComponent = React.Component:extend("SimpleComponent") + function SimpleComponent:init(props) + self.state = { + value = props.value, + } + end + + function SimpleComponent.getDerivedStateFromProps(nextProps, prevState) + if nextProps.value == prevState.value then + return nil + end + return { value = nextProps.value } + end + + function SimpleComponent:shouldComponentUpdate(nextProps, nextState) + return nextState.value ~= self.state.value + end + + function SimpleComponent:render() + capturedValue = self.state.value + return React.createElement( + "Frame", + { ref = divRef }, + React.createElement("TextLabel", { Text = self.state.value }) + ) + end + + -- ROBLOX TODO: upstream uses reactDOM renderer, which means divRef gets updated properly. figure out what can work for Noop + ReactNoop.act(function() + ReactNoop.render(React.createElement(SimpleComponent, { value = "initial" })) + end) + jestExpect(capturedValue).toBe("initial") + ReactNoop.act(function() + ReactNoop.render(React.createElement(SimpleComponent, { value = "updated" })) + end) + jestExpect(capturedValue).toBe("updated") + end +) -- -- ROBLOX TODO? Don't think we can convert this, since it relies on refs and DOM objects -- xit('should call getSnapshotBeforeUpdate before mutations are committed', function() @@ -1580,76 +1625,76 @@ xit('should not throw when updating an auxiliary component', function() -- ReactDOM.render(, div) -- }) - it("should warn if getSnapshotBeforeUpdate is defined with no componentDidUpdate", function() - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:getSnapshotBeforeUpdate() - return nil - end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end).toErrorDev( - "MyComponent: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " .. - "This component defines getSnapshotBeforeUpdate() only." - ) - end) - - -- De-duped - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent)) - end) - end) - - it('warns about deprecated unsafe lifecycles', function() - local MyComponent = React.Component:extend("MyComponent") - function MyComponent:componentWillMount() end - function MyComponent:componentWillReceiveProps() end - function MyComponent:componentWillUpdate() end - function MyComponent:render() - return nil - end - - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent, {x = 1})) - end) - end).toWarnDev( - { - --[[ eslint-disable max-len ]] - [[Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. +it( + "should warn if getSnapshotBeforeUpdate is defined with no componentDidUpdate", + function() + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:getSnapshotBeforeUpdate() + return nil + end + function MyComponent:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end).toErrorDev( + "MyComponent: getSnapshotBeforeUpdate() should be used with componentDidUpdate(). " + .. "This component defines getSnapshotBeforeUpdate() only." + ) + + -- De-duped + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent)) + end) + end +) + +it("warns about deprecated unsafe lifecycles", function() + local MyComponent = React.Component:extend("MyComponent") + function MyComponent:componentWillMount() end + function MyComponent:componentWillReceiveProps() end + function MyComponent:componentWillUpdate() end + function MyComponent:render() + return nil + end + + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent, { x = 1 })) + end) + end).toWarnDev({ + --[[ eslint-disable max-len ]] + [[Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. * Move code with side effects to componentDidMount, and set initial state in the constructor. * Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. Please update the following components: MyComponent]], - [[Warning: componentWillReceiveProps has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. + [[Warning: componentWillReceiveProps has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. * Move data fetching code or side effects to componentDidUpdate. * If you're updating state whenever props change, refactor your code to use memoization techniques or move it to static getDerivedStateFromProps. Learn more at: https://reactjs.org/link/derived-state * Rename componentWillReceiveProps to UNSAFE_componentWillReceiveProps to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. Please update the following components: MyComponent]], - [[Warning: componentWillUpdate has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. + [[Warning: componentWillUpdate has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details. * Move data fetching code or side effects to componentDidUpdate. * Rename componentWillUpdate to UNSAFE_componentWillUpdate to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. Please update the following components: MyComponent]], - --[[ eslint-enable max-len ]] - }, - {withoutStack = true} - ) - - -- Dedupe check (update and instantiate new) - ReactNoop.act(function() - ReactNoop.render(React.createElement(MyComponent, {x = 2})) - ReactNoop.render(React.createElement(MyComponent, {key = "new", x = 1})) - end) - end) + --[[ eslint-enable max-len ]] + }, { withoutStack = true }) + + -- Dedupe check (update and instantiate new) + ReactNoop.act(function() + ReactNoop.render(React.createElement(MyComponent, { x = 2 })) + ReactNoop.render(React.createElement(MyComponent, { key = "new", x = 1 })) + end) +end) -- describe('react-lifecycles-compat', function() -- local {polyfill} = require('react-lifecycles-compat') @@ -1698,4 +1743,3 @@ Please update the following components: MyComponent]], -- ) -- }) -- }) -end diff --git a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua index 62ddd8f5..12ec58e4 100644 --- a/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactErrorBoundaries-internal.spec.lua @@ -6,9 +6,8 @@ -- * -- * @emails react-core -- */ --- !strict +--!strict local Packages = script.Parent.Parent.Parent -local RobloxJest = require(Packages.Dev.RobloxJest) -- local PropTypes local React @@ -16,21 +15,31 @@ local React local ReactNoop -- local act local Scheduler +local ReactGlobals = require(Packages.ReactGlobals) local LuauPolyfill = require(Packages.LuauPolyfill) local Array = LuauPolyfill.Array local ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local xit = JestGlobals.xit +local jest = JestGlobals.jest + +-- ROBLOX Test Noise: in upstream, jest setup config makes these tests hide +-- the error boundary warnings they trigger (scripts/jest/setupTests.js:72) -- ROBLOX deviation: using textContent helper in place of upstream .textContent() local function textContent(node) local res local queue = Array.slice(node.getChildren()) while #queue > 0 do - local currentNode = table.remove(queue) - if currentNode.text then + local currentNode: any? = table.remove(queue) + if currentNode and currentNode.text then res = currentNode.text .. (res or "") end - if currentNode.children then - for _, value in ipairs(currentNode.children) do + if currentNode and currentNode.children then + for _, value in currentNode.children do table.insert(queue, value) end end @@ -38,729 +47,845 @@ local function textContent(node) return res or "" end -return function() - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - - describe("ReactErrorBoundaries", function() - local BrokenConstructor - local BrokenComponentWillMount - local BrokenComponentDidMount - local BrokenComponentWillReceiveProps - local BrokenComponentWillUpdate - local BrokenComponentDidUpdate - local BrokenComponentWillUnmount - local BrokenRenderErrorBoundary - local BrokenComponentWillMountErrorBoundary - local BrokenComponentDidMountErrorBoundary - local BrokenRender - local BrokenUseEffect - local BrokenUseLayoutEffect - local ErrorBoundary - local ErrorMessage - local NoopErrorBoundary - local RetryErrorBoundary - local Normal - - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() - - -- PropTypes = require('prop-types') - ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false - -- ReactDOM = require('react-dom') - React = require(Packages.React) - ReactNoop = require(Packages.Dev.ReactNoopRenderer) - Scheduler = require(Packages.Scheduler) - -- act = require('react-dom/test-utils').unstable_concurrentAct - - BrokenConstructor = React.Component:extend("BrokenConstructor") - - function BrokenConstructor:init() - Scheduler.unstable_yieldValue("BrokenConstructor constructor [!]") - error("Hello", 0) - end - function BrokenConstructor:render() - Scheduler.unstable_yieldValue("BrokenConstructor render") - return React.createElement("div", nil, self.props.children) - end - function BrokenConstructor:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenConstructor componentWillMount") - end - function BrokenConstructor:componentDidMount() - Scheduler.unstable_yieldValue("BrokenConstructor componentDidMount") - end - function BrokenConstructor:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenConstructor componentWillReceiveProps") - end - function BrokenConstructor:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenConstructor componentWillUpdate") - end - function BrokenConstructor:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenConstructor componentDidUpdate") - end +describe("ReactErrorBoundaries", function() + local BrokenConstructor + local BrokenComponentWillMount + local BrokenComponentDidMount + local BrokenComponentWillReceiveProps + local BrokenComponentWillUpdate + local BrokenComponentDidUpdate + local BrokenComponentWillUnmount + local BrokenRenderErrorBoundary + local BrokenComponentWillMountErrorBoundary + local BrokenComponentDidMountErrorBoundary + local BrokenRender + local BrokenUseEffect + local BrokenUseLayoutEffect + local ErrorBoundary + local ErrorMessage + local NoopErrorBoundary + local RetryErrorBoundary + local Normal + + beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + + -- PropTypes = require('prop-types') + ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false + -- ReactDOM = require('react-dom') + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + -- act = require('react-dom/test-utils').unstable_concurrentAct + + BrokenConstructor = React.Component:extend("BrokenConstructor") + + function BrokenConstructor:init() + Scheduler.unstable_yieldValue("BrokenConstructor constructor [!]") + error("Hello", 0) + end + function BrokenConstructor:render() + Scheduler.unstable_yieldValue("BrokenConstructor render") + return React.createElement("div", nil, self.props.children) + end + function BrokenConstructor:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenConstructor componentWillMount") + end + function BrokenConstructor:componentDidMount() + Scheduler.unstable_yieldValue("BrokenConstructor componentDidMount") + end + function BrokenConstructor:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue("BrokenConstructor componentWillReceiveProps") + end + function BrokenConstructor:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue("BrokenConstructor componentWillUpdate") + end + function BrokenConstructor:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenConstructor componentDidUpdate") + end - function BrokenConstructor:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenConstructor componentWillUnmount") - end + function BrokenConstructor:componentWillUnmount() + Scheduler.unstable_yieldValue("BrokenConstructor componentWillUnmount") + end - BrokenComponentWillMount = React.Component:extend("BrokenComponentWillMount") + BrokenComponentWillMount = React.Component:extend("BrokenComponentWillMount") - function BrokenComponentWillMount:init() - Scheduler.unstable_yieldValue("BrokenComponentWillMount constructor") - end + function BrokenComponentWillMount:init() + Scheduler.unstable_yieldValue("BrokenComponentWillMount constructor") + end - function BrokenComponentWillMount:render() - Scheduler.unstable_yieldValue("BrokenComponentWillMount render") - return React.createElement("div", nil, self.props.children) - end + function BrokenComponentWillMount:render() + Scheduler.unstable_yieldValue("BrokenComponentWillMount render") + return React.createElement("div", nil, self.props.children) + end - function BrokenComponentWillMount:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillMount [!]") - error("Hello", 0) - end + function BrokenComponentWillMount:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillMount componentWillMount [!]" + ) + error("Hello", 0) + end - function BrokenComponentWillMount:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentDidMount") - end - function BrokenComponentWillMount:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillReceiveProps") - end + function BrokenComponentWillMount:componentDidMount() + Scheduler.unstable_yieldValue("BrokenComponentWillMount componentDidMount") + end + function BrokenComponentWillMount:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentWillMount componentWillReceiveProps" + ) + end - function BrokenComponentWillMount:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillUpdate") - end + function BrokenComponentWillMount:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillUpdate") + end - function BrokenComponentWillMount:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentDidUpdate") - end + function BrokenComponentWillMount:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenComponentWillMount componentDidUpdate") + end - function BrokenComponentWillMount:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillUnmount") - end + function BrokenComponentWillMount:componentWillUnmount() + Scheduler.unstable_yieldValue("BrokenComponentWillMount componentWillUnmount") + end - BrokenComponentDidMount = React.Component:extend("BrokenComponentDidMount") + BrokenComponentDidMount = React.Component:extend("BrokenComponentDidMount") - function BrokenComponentDidMount:init() - Scheduler.unstable_yieldValue("BrokenComponentDidMount constructor") - end - function BrokenComponentDidMount:render() - Scheduler.unstable_yieldValue("BrokenComponentDidMount render") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentDidMount:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillMount") - end - function BrokenComponentDidMount:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentDidMount [!]") - error("Hello", 0) - end - function BrokenComponentDidMount:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillReceiveProps") - end - function BrokenComponentDidMount:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillUpdate") - end - function BrokenComponentDidMount:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentDidUpdate") - end - function BrokenComponentDidMount:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillUnmount") - end + function BrokenComponentDidMount:init() + Scheduler.unstable_yieldValue("BrokenComponentDidMount constructor") + end + function BrokenComponentDidMount:render() + Scheduler.unstable_yieldValue("BrokenComponentDidMount render") + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentDidMount:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillMount") + end + function BrokenComponentDidMount:componentDidMount() + Scheduler.unstable_yieldValue("BrokenComponentDidMount componentDidMount [!]") + error("Hello", 0) + end + function BrokenComponentDidMount:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentDidMount componentWillReceiveProps" + ) + end + function BrokenComponentDidMount:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillUpdate") + end + function BrokenComponentDidMount:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenComponentDidMount componentDidUpdate") + end + function BrokenComponentDidMount:componentWillUnmount() + Scheduler.unstable_yieldValue("BrokenComponentDidMount componentWillUnmount") + end - BrokenComponentWillReceiveProps = React.Component:extend("BrokenComponentWillReceiveProps") + BrokenComponentWillReceiveProps = + React.Component:extend("BrokenComponentWillReceiveProps") - function BrokenComponentWillReceiveProps:init() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps constructor") - end - function BrokenComponentWillReceiveProps:render() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps render") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentWillReceiveProps:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentWillMount") - end - function BrokenComponentWillReceiveProps:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentDidMount") - end - function BrokenComponentWillReceiveProps:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentWillReceiveProps [!]") - error("Hello", 0) - end - function BrokenComponentWillReceiveProps:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentWillUpdate") - end - function BrokenComponentWillReceiveProps:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentDidUpdate") - end - function BrokenComponentWillReceiveProps:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps componentWillUnmount") - end + function BrokenComponentWillReceiveProps:init() + Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps constructor") + end + function BrokenComponentWillReceiveProps:render() + Scheduler.unstable_yieldValue("BrokenComponentWillReceiveProps render") + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentWillReceiveProps:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentWillMount" + ) + end + function BrokenComponentWillReceiveProps:componentDidMount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentDidMount" + ) + end + function BrokenComponentWillReceiveProps:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentWillReceiveProps [!]" + ) + error("Hello", 0) + end + function BrokenComponentWillReceiveProps:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentWillUpdate" + ) + end + function BrokenComponentWillReceiveProps:componentDidUpdate() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentDidUpdate" + ) + end + function BrokenComponentWillReceiveProps:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillReceiveProps componentWillUnmount" + ) + end - BrokenComponentWillUpdate = React.Component:extend("BrokenComponentWillUpdate") + BrokenComponentWillUpdate = React.Component:extend("BrokenComponentWillUpdate") - function BrokenComponentWillUpdate:init() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate constructor") - end - function BrokenComponentWillUpdate:render() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate render") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentWillUpdate:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentWillMount") - end - function BrokenComponentWillUpdate:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentDidMount") - end - function BrokenComponentWillUpdate:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentWillReceiveProps") - end - function BrokenComponentWillUpdate:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentWillUpdate [!]") - error("Hello", 0) - end - function BrokenComponentWillUpdate:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentDidUpdate") - end - function BrokenComponentWillUpdate:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentWillUnmount") - end + function BrokenComponentWillUpdate:init() + Scheduler.unstable_yieldValue("BrokenComponentWillUpdate constructor") + end + function BrokenComponentWillUpdate:render() + Scheduler.unstable_yieldValue("BrokenComponentWillUpdate render") + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentWillUpdate:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentWillMount") + end + function BrokenComponentWillUpdate:componentDidMount() + Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentDidMount") + end + function BrokenComponentWillUpdate:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUpdate componentWillReceiveProps" + ) + end + function BrokenComponentWillUpdate:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUpdate componentWillUpdate [!]" + ) + error("Hello", 0) + end + function BrokenComponentWillUpdate:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenComponentWillUpdate componentDidUpdate") + end + function BrokenComponentWillUpdate:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUpdate componentWillUnmount" + ) + end - BrokenComponentDidUpdate = React.Component:extend("BrokenComponentDidUpdate") + BrokenComponentDidUpdate = React.Component:extend("BrokenComponentDidUpdate") - function BrokenComponentDidUpdate:init() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate constructor") - end - function BrokenComponentDidUpdate:render() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate render") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentDidUpdate:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillMount") - end - function BrokenComponentDidUpdate:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentDidMount") - end - function BrokenComponentDidUpdate:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillReceiveProps") - end - function BrokenComponentDidUpdate:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillUpdate") - end - function BrokenComponentDidUpdate:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentDidUpdate [!]") + function BrokenComponentDidUpdate:init() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate constructor") + end + function BrokenComponentDidUpdate:render() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate render") + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentDidUpdate:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillMount") + end + function BrokenComponentDidUpdate:componentDidMount() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentDidMount") + end + function BrokenComponentDidUpdate:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentDidUpdate componentWillReceiveProps" + ) + end + function BrokenComponentDidUpdate:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillUpdate") + end + function BrokenComponentDidUpdate:componentDidUpdate() + Scheduler.unstable_yieldValue( + "BrokenComponentDidUpdate componentDidUpdate [!]" + ) - -- ROBLOX deviation: or 'Hello' in place of setting defaultProps - error(self.props.errorText or "Hello", 0) - end - function BrokenComponentDidUpdate:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillUnmount") - end + -- ROBLOX deviation: or 'Hello' in place of setting defaultProps + error(self.props.errorText or "Hello", 0) + end + function BrokenComponentDidUpdate:componentWillUnmount() + Scheduler.unstable_yieldValue("BrokenComponentDidUpdate componentWillUnmount") + end - BrokenComponentWillUnmount = React.Component:extend("BrokenComponentWillUnmount") + BrokenComponentWillUnmount = React.Component:extend("BrokenComponentWillUnmount") - function BrokenComponentWillUnmount:init() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount constructor") - end - function BrokenComponentWillUnmount:render() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount render") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentWillUnmount:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillMount") - end - function BrokenComponentWillUnmount:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentDidMount") - end - function BrokenComponentWillUnmount:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillReceiveProps") - end - function BrokenComponentWillUnmount:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillUpdate") - end - function BrokenComponentWillUnmount:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentDidUpdate") - end - function BrokenComponentWillUnmount:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillUnmount [!]") - -- ROBLOX deviation: or 'Hello' in place of setting defaultProps - error(self.props.errorText or "Hello", 0) - end + function BrokenComponentWillUnmount:init() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount constructor") + end + function BrokenComponentWillUnmount:render() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount render") + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentWillUnmount:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillMount") + end + function BrokenComponentWillUnmount:componentDidMount() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentDidMount") + end + function BrokenComponentWillUnmount:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUnmount componentWillReceiveProps" + ) + end + function BrokenComponentWillUnmount:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUnmount componentWillUpdate" + ) + end + function BrokenComponentWillUnmount:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentDidUpdate") + end + function BrokenComponentWillUnmount:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUnmount componentWillUnmount [!]" + ) + -- ROBLOX deviation: or 'Hello' in place of setting defaultProps + error(self.props.errorText or "Hello", 0) + end - BrokenComponentWillMountErrorBoundary = React.Component:extend("BrokenComponentWillMountErrorBoundary") + BrokenComponentWillMountErrorBoundary = + React.Component:extend("BrokenComponentWillMountErrorBoundary") - function BrokenComponentWillMountErrorBoundary:init() - self.state = { error = nil } - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary constructor") - end - function BrokenComponentWillMountErrorBoundary:render() - if self.state.error then - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary render error") - return React.createElement("div", nil, "Caught an error: " .. tostring(self.state.error.message)) - end - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary render success") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentWillMountErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary componentWillMount [!]") - error("Hello", 0) - end - function BrokenComponentWillMountErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary componentDidMount") - end - function BrokenComponentWillMountErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary componentWillUnmount") - end - function BrokenComponentWillMountErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("BrokenComponentWillMountErrorBoundary static getDerivedStateFromError") - return { error = error_ } + function BrokenComponentWillMountErrorBoundary:init() + self.state = { error = nil } + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary constructor" + ) + end + function BrokenComponentWillMountErrorBoundary:render() + if self.state.error then + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary render error" + ) + return React.createElement( + "div", + nil, + "Caught an error: " .. tostring(self.state.error.message) + ) end + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary render success" + ) + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentWillMountErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary componentWillMount [!]" + ) + error("Hello", 0) + end + function BrokenComponentWillMountErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary componentDidMount" + ) + end + function BrokenComponentWillMountErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary componentWillUnmount" + ) + end + function BrokenComponentWillMountErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue( + "BrokenComponentWillMountErrorBoundary static getDerivedStateFromError" + ) + return { error = error_ } + end - BrokenComponentDidMountErrorBoundary = React.Component:extend("BrokenComponentDidMountErrorBoundary") + BrokenComponentDidMountErrorBoundary = + React.Component:extend("BrokenComponentDidMountErrorBoundary") - function BrokenComponentDidMountErrorBoundary:init() - self.state = { error = nil } - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary constructor") - end - function BrokenComponentDidMountErrorBoundary:render() - if self.state.error then - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary render error") - return React.createElement("div", nil, "Caught an error: " .. tostring(self.state.error.message)) - end - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary render success") - return React.createElement("div", nil, self.props.children) - end - function BrokenComponentDidMountErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary componentWillMount") + function BrokenComponentDidMountErrorBoundary:init() + self.state = { error = nil } + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary constructor" + ) + end + function BrokenComponentDidMountErrorBoundary:render() + if self.state.error then + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary render error" + ) + return React.createElement( + "div", + nil, + "Caught an error: " .. tostring(self.state.error.message) + ) end - function BrokenComponentDidMountErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary componentDidMount [!]") + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary render success" + ) + return React.createElement("div", nil, self.props.children) + end + function BrokenComponentDidMountErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary componentWillMount" + ) + end + function BrokenComponentDidMountErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary componentDidMount [!]" + ) + error("Hello", 0) + end + function BrokenComponentDidMountErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary componentWillUnmount" + ) + end + function BrokenComponentDidMountErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue( + "BrokenComponentDidMountErrorBoundary static getDerivedStateFromError" + ) + return { error = error_ } + end + + BrokenRenderErrorBoundary = React.Component:extend("BrokenRenderErrorBoundary") + + function BrokenRenderErrorBoundary:init() + self.state = { error = nil } + Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary constructor") + end + function BrokenRenderErrorBoundary:render() + if self.state.error then + Scheduler.unstable_yieldValue( + "BrokenRenderErrorBoundary render error [!]" + ) error("Hello", 0) end - function BrokenComponentDidMountErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary componentWillUnmount") - end - function BrokenComponentDidMountErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("BrokenComponentDidMountErrorBoundary static getDerivedStateFromError") - return { error = error_ } - end + Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary render success") + return React.createElement("div", nil, self.props.children) + end + function BrokenRenderErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary componentWillMount") + end + function BrokenRenderErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary componentDidMount") + end + function BrokenRenderErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenRenderErrorBoundary componentWillUnmount" + ) + end + function BrokenRenderErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue( + "BrokenRenderErrorBoundary static getDerivedStateFromError" + ) + return { error = error_ } + end - BrokenRenderErrorBoundary = React.Component:extend("BrokenRenderErrorBoundary") + BrokenRender = React.Component:extend("BrokenRender") - function BrokenRenderErrorBoundary:init() - self.state = { error = nil } - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary constructor") - end - function BrokenRenderErrorBoundary:render() - if self.state.error then - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary render error [!]") - error("Hello", 0) - end - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary render success") - return React.createElement("div", nil, self.props.children) - end - function BrokenRenderErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary componentWillMount") - end - function BrokenRenderErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary componentDidMount") - end - function BrokenRenderErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary componentWillUnmount") - end - function BrokenRenderErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("BrokenRenderErrorBoundary static getDerivedStateFromError") - return { error = error_ } - end + function BrokenRender:init() + Scheduler.unstable_yieldValue("BrokenRender constructor") + end + function BrokenRender:render() + Scheduler.unstable_yieldValue("BrokenRender render [!]") + error("Hello", 0) + end + function BrokenRender:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("BrokenRender componentWillMount") + end + function BrokenRender:componentDidMount() + Scheduler.unstable_yieldValue("BrokenRender componentDidMount") + end + function BrokenRender:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue("BrokenRender componentWillReceiveProps") + end + function BrokenRender:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue("BrokenRender componentWillUpdate") + end + function BrokenRender:componentDidUpdate() + Scheduler.unstable_yieldValue("BrokenRender componentDidUpdate") + end + function BrokenRender:componentWillUnmount() + Scheduler.unstable_yieldValue("BrokenRender componentWillUnmount") + end - BrokenRender = React.Component:extend("BrokenRender") + BrokenUseEffect = function(props) + local children = props.children - function BrokenRender:init() - Scheduler.unstable_yieldValue("BrokenRender constructor") - end - function BrokenRender:render() - Scheduler.unstable_yieldValue("BrokenRender render [!]") + Scheduler.unstable_yieldValue("BrokenUseEffect render") + React.useEffect(function() + Scheduler.unstable_yieldValue("BrokenUseEffect useEffect [!]") error("Hello", 0) - end - function BrokenRender:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("BrokenRender componentWillMount") - end - function BrokenRender:componentDidMount() - Scheduler.unstable_yieldValue("BrokenRender componentDidMount") - end - function BrokenRender:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue("BrokenRender componentWillReceiveProps") - end - function BrokenRender:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue("BrokenRender componentWillUpdate") - end - function BrokenRender:componentDidUpdate() - Scheduler.unstable_yieldValue("BrokenRender componentDidUpdate") - end - function BrokenRender:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenRender componentWillUnmount") - end + end) - BrokenUseEffect = function(props) - local children = props.children + return children + end + BrokenUseLayoutEffect = function(props) + local children = props.children - Scheduler.unstable_yieldValue("BrokenUseEffect render") - React.useEffect(function() - Scheduler.unstable_yieldValue("BrokenUseEffect useEffect [!]") - error("Hello", 0) - end) + Scheduler.unstable_yieldValue("BrokenUseLayoutEffect render") + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue("BrokenUseLayoutEffect useLayoutEffect [!]") + error("Hello", 0) + end) - return children - end - BrokenUseLayoutEffect = function(props) - local children = props.children + return children + end - Scheduler.unstable_yieldValue("BrokenUseLayoutEffect render") - React.useLayoutEffect(function() - Scheduler.unstable_yieldValue("BrokenUseLayoutEffect useLayoutEffect [!]") - error("Hello", 0) - end) + NoopErrorBoundary = React.Component:extend("NoopErrorBoundary") - return children - end + function NoopErrorBoundary:init() + Scheduler.unstable_yieldValue("NoopErrorBoundary constructor") + end + function NoopErrorBoundary:render() + Scheduler.unstable_yieldValue("NoopErrorBoundary render") + return React.createElement(BrokenRender) + end + function NoopErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("NoopErrorBoundary componentWillMount") + end + function NoopErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue("NoopErrorBoundary componentDidMount") + end + function NoopErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue("NoopErrorBoundary componentWillUnmount") + end + function NoopErrorBoundary.getDerivedStateFromError() + Scheduler.unstable_yieldValue( + "NoopErrorBoundary static getDerivedStateFromError" + ) + return nil + end - NoopErrorBoundary = React.Component:extend("NoopErrorBoundary") + Normal = React.Component:extend("Normal") - function NoopErrorBoundary:init() - Scheduler.unstable_yieldValue("NoopErrorBoundary constructor") - end - function NoopErrorBoundary:render() - Scheduler.unstable_yieldValue("NoopErrorBoundary render") - return React.createElement(BrokenRender) - end - function NoopErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("NoopErrorBoundary componentWillMount") - end - function NoopErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue("NoopErrorBoundary componentDidMount") - end - function NoopErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue("NoopErrorBoundary componentWillUnmount") - end - function NoopErrorBoundary.getDerivedStateFromError() - Scheduler.unstable_yieldValue("NoopErrorBoundary static getDerivedStateFromError") - end + Normal.defaultProps = { + logName = "Normal", + } + function Normal:init() + Scheduler.unstable_yieldValue(self.props.logName .. " constructor") + end + function Normal:render() + Scheduler.unstable_yieldValue(self.props.logName .. " render") + return React.createElement("div", nil, self.props.children) + end + function Normal:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillMount") + end + function Normal:componentDidMount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentDidMount") + end + function Normal:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + self.props.logName .. " componentWillReceiveProps" + ) + end + function Normal:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUpdate") + end + function Normal:componentDidUpdate() + Scheduler.unstable_yieldValue(self.props.logName .. " componentDidUpdate") + end + function Normal:componentWillUnmount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUnmount") + end - Normal = React.Component:extend("Normal") + ErrorBoundary = React.Component:extend("ErrorBoundary") - Normal.defaultProps = { - logName = "Normal", + function ErrorBoundary:init() + self.state = { + error = nil, } - function Normal:init() - Scheduler.unstable_yieldValue(self.props.logName .. " constructor") - end - function Normal:render() - Scheduler.unstable_yieldValue(self.props.logName .. " render") - return React.createElement("div", nil, self.props.children) - end - function Normal:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillMount") - end - function Normal:componentDidMount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentDidMount") - end - function Normal:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillReceiveProps") - end - function Normal:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUpdate") - end - function Normal:componentDidUpdate() - Scheduler.unstable_yieldValue(self.props.logName .. " componentDidUpdate") - end - function Normal:componentWillUnmount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUnmount") - end - - ErrorBoundary = React.Component:extend("ErrorBoundary") - - function ErrorBoundary:init() - self.state = { - error = nil, - } - Scheduler.unstable_yieldValue(self.props.logName .. " constructor") + Scheduler.unstable_yieldValue(self.props.logName .. " constructor") + end + function ErrorBoundary:render() + if self.state.error and not self.props.forceRetry then + Scheduler.unstable_yieldValue(self.props.logName .. " render error") + return self.props.renderError(self.state.error, self.props) end - function ErrorBoundary:render() - if self.state.error and not self.props.forceRetry then - Scheduler.unstable_yieldValue(self.props.logName .. " render error") - return self.props.renderError(self.state.error, self.props) + Scheduler.unstable_yieldValue(self.props.logName .. " render success") + return React.createElement("div", nil, self.props.children) + end + function ErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") + return { error = error_ } + end + function ErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillMount") + end + function ErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentDidMount") + end + function ErrorBoundary:UNSAFE_componentWillReceiveProps() + Scheduler.unstable_yieldValue( + self.props.logName .. " componentWillReceiveProps" + ) + end + function ErrorBoundary:UNSAFE_componentWillUpdate() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUpdate") + end + function ErrorBoundary:componentDidUpdate() + Scheduler.unstable_yieldValue(self.props.logName .. " componentDidUpdate") + end + function ErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUnmount") + end + ErrorBoundary.defaultProps = { + logName = "ErrorBoundary", + renderError = function(error_, props) + local errorMessage = error_ + -- ROBLOX deviation: additional logic to deal with pre-polyfill error object + if typeof(error_) == "table" then + errorMessage = error_.message end - Scheduler.unstable_yieldValue(self.props.logName .. " render success") - return React.createElement("div", nil, self.props.children) - end - function ErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") - return { error = error_ } - end - function ErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillMount") - end - function ErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentDidMount") - end - function ErrorBoundary:UNSAFE_componentWillReceiveProps() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillReceiveProps") - end - function ErrorBoundary:UNSAFE_componentWillUpdate() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUpdate") - end - function ErrorBoundary:componentDidUpdate() - Scheduler.unstable_yieldValue(self.props.logName .. " componentDidUpdate") - end - function ErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue(self.props.logName .. " componentWillUnmount") - end - ErrorBoundary.defaultProps = { - logName = "ErrorBoundary", - renderError = function(error_, props) - local errorMessage = error_ - -- ROBLOX deviation: additional logic to deal with pre-polyfill error object - if typeof(error_) == "table" then - errorMessage = error_.message - end - return React.createElement("div", { - ref = props.errorMessageRef, - }, "Caught an error: ", errorMessage, ".") - end, - } + return React.createElement("div", { + ref = props.errorMessageRef, + }, "Caught an error: ", errorMessage, ".") + end, + } - RetryErrorBoundary = React.Component:extend("RetryErrorBoundary") + RetryErrorBoundary = React.Component:extend("RetryErrorBoundary") - function RetryErrorBoundary:init() - Scheduler.unstable_yieldValue("RetryErrorBoundary constructor") - end - function RetryErrorBoundary:render() - Scheduler.unstable_yieldValue("RetryErrorBoundary render") - return React.createElement(BrokenRender) - end - function RetryErrorBoundary:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("RetryErrorBoundary componentWillMount") - end - function RetryErrorBoundary:componentDidMount() - Scheduler.unstable_yieldValue("RetryErrorBoundary componentDidMount") - end - function RetryErrorBoundary:componentWillUnmount() - Scheduler.unstable_yieldValue("RetryErrorBoundary componentWillUnmount") - end - function RetryErrorBoundary.getDerivedStateFromError(error) - Scheduler.unstable_yieldValue("RetryErrorBoundary static getDerivedStateFromError [!]") - -- In Fiber, calling setState() (and failing) is treated as a rethrow. - return {} - end + function RetryErrorBoundary:init() + Scheduler.unstable_yieldValue("RetryErrorBoundary constructor") + end + function RetryErrorBoundary:render() + Scheduler.unstable_yieldValue("RetryErrorBoundary render") + return React.createElement(BrokenRender) + end + function RetryErrorBoundary:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("RetryErrorBoundary componentWillMount") + end + function RetryErrorBoundary:componentDidMount() + Scheduler.unstable_yieldValue("RetryErrorBoundary componentDidMount") + end + function RetryErrorBoundary:componentWillUnmount() + Scheduler.unstable_yieldValue("RetryErrorBoundary componentWillUnmount") + end + function RetryErrorBoundary.getDerivedStateFromError(error) + Scheduler.unstable_yieldValue( + "RetryErrorBoundary static getDerivedStateFromError [!]" + ) + -- In Fiber, calling setState() (and failing) is treated as a rethrow. + return {} + end - ErrorMessage = React.Component:extend("ErrorMessage") + ErrorMessage = React.Component:extend("ErrorMessage") - function ErrorMessage:init() - Scheduler.unstable_yieldValue("ErrorMessage constructor") - end - function ErrorMessage:UNSAFE_componentWillMount() - Scheduler.unstable_yieldValue("ErrorMessage componentWillMount") - end - function ErrorMessage:componentDidMount() - Scheduler.unstable_yieldValue("ErrorMessage componentDidMount") - end - function ErrorMessage:componentWillUnmount() - Scheduler.unstable_yieldValue("ErrorMessage componentWillUnmount") - end - function ErrorMessage:render() - Scheduler.unstable_yieldValue("ErrorMessage render") - return React.createElement("div", nil, "Caught an error: " .. tostring(self.props.message)) - end - end) - it("does not swallow exceptions on mounting without boundaries", function() - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(BrokenRender)) - end) - end).toThrow("Hello") + function ErrorMessage:init() + Scheduler.unstable_yieldValue("ErrorMessage constructor") + end + function ErrorMessage:UNSAFE_componentWillMount() + Scheduler.unstable_yieldValue("ErrorMessage componentWillMount") + end + function ErrorMessage:componentDidMount() + Scheduler.unstable_yieldValue("ErrorMessage componentDidMount") + end + function ErrorMessage:componentWillUnmount() + Scheduler.unstable_yieldValue("ErrorMessage componentWillUnmount") + end + function ErrorMessage:render() + Scheduler.unstable_yieldValue("ErrorMessage render") + return React.createElement( + "div", + nil, + "Caught an error: " .. tostring(self.props.message) + ) + end + end) + it("does not swallow exceptions on mounting without boundaries", function() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(BrokenRender)) + end) + end).toThrow("Hello") - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(BrokenComponentWillMount)) - end) - end).toThrow("Hello") + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(BrokenComponentWillMount)) + end) + end).toThrow("Hello") - jestExpect(function() - ReactNoop.act(function() - ReactNoop.render(React.createElement(BrokenComponentDidMount)) - end) - end).toThrow("Hello") - end) - it("does not swallow exceptions on updating without boundaries", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + jestExpect(function() + ReactNoop.act(function() + ReactNoop.render(React.createElement(BrokenComponentDidMount)) + end) + end).toThrow("Hello") + end) + it("does not swallow exceptions on updating without boundaries", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render(React.createElement(BrokenComponentWillUpdate)) + jestExpect(function() root.render(React.createElement(BrokenComponentWillUpdate)) - jestExpect(function() - root.render(React.createElement(BrokenComponentWillUpdate)) - end).toThrow("Hello") + end).toThrow("Hello") - root = ReactNoop.createLegacyRoot() + root = ReactNoop.createLegacyRoot() + root.render(React.createElement(BrokenComponentWillReceiveProps)) + jestExpect(function() root.render(React.createElement(BrokenComponentWillReceiveProps)) - jestExpect(function() - root.render(React.createElement(BrokenComponentWillReceiveProps)) - end).toThrow("Hello") - - root = ReactNoop.createLegacyRoot() - - root.render(React.createElement(BrokenComponentDidUpdate)) - jestExpect(function() - root.render(React.createElement(BrokenComponentDidUpdate)) - end).toThrow("Hello") - end) - it("does not swallow exceptions on unmounting without boundaries", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement(BrokenComponentWillUnmount)) - jestExpect(function() - -- ROBLOX deviation: render nil to unmount - root.render(nil) - end).toThrow("Hello") - end) - it("prevents errors from leaking into other roots", function() - local root1 = ReactNoop.createLegacyRoot() - local root2 = ReactNoop.createLegacyRoot() - local root3 = ReactNoop.createLegacyRoot() + end).toThrow("Hello") - root1.render(React.createElement("span", nil, "Before 1")) - jestExpect(function() - root2.render(React.createElement(BrokenRender)) - end).toThrow("Hello") - root3.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - jestExpect(root1.getChildren()[1].text).toEqual("Before 1") - jestExpect(root2.getChildren()[1]).toEqual(nil) - - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root3)).toEqual("Caught an error: Hello.") - - root1.render(React.createElement("span", nil, "After 1"), root1) - root2.render(React.createElement("span", nil, "After 2"), root2) - root3.render(React.createElement(ErrorBoundary, { forceRetry = true }, "After 3"), root3) - jestExpect(root1.getChildren()[1].text).toEqual("After 1") - jestExpect(root2.getChildren()[1].text).toEqual("After 2") - jestExpect(root3.getChildren()[1].text).toEqual("After 3") - root1.render(nil) - root2.render(nil) - root3.render(nil) - jestExpect(root1.getChildren()[1]).toEqual(nil) - jestExpect(root2.getChildren()[1]).toEqual(nil) - jestExpect(root3.getChildren()[1]).toEqual(nil) - end) - it("logs a single error when using error boundary", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + root = ReactNoop.createLegacyRoot() - jestExpect(function() - return root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - end).toErrorDev("The above error occurred in the component:", { logAllErrors = true }) + root.render(React.createElement(BrokenComponentDidUpdate)) + jestExpect(function() + root.render(React.createElement(BrokenComponentDidUpdate)) + end).toThrow("Hello") + end) + it("does not swallow exceptions on unmounting without boundaries", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Catch and render an error message - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("renders an error state if child throws in render", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Catch and render an error message - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) + root.render(React.createElement(BrokenComponentWillUnmount)) + jestExpect(function() -- ROBLOX deviation: render nil to unmount root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("renders an error state if child throws in constructor", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + end).toThrow("Hello") + end) + it("prevents errors from leaking into other roots", function() + local root1 = ReactNoop.createLegacyRoot() + local root2 = ReactNoop.createLegacyRoot() + local root3 = ReactNoop.createLegacyRoot() + + root1.render(React.createElement("span", nil, "Before 1")) + jestExpect(function() + root2.render(React.createElement(BrokenRender)) + end).toThrow("Hello") + root3.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + jestExpect(root1.getChildren()[1].text).toEqual("Before 1") + jestExpect(root2.getChildren()[1]).toEqual(nil) + + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root3)).toEqual("Caught an error: Hello.") + + root1.render(React.createElement("span", nil, "After 1"), root1) + root2.render(React.createElement("span", nil, "After 2"), root2) + root3.render( + React.createElement(ErrorBoundary, { forceRetry = true }, "After 3"), + root3 + ) + jestExpect(root1.getChildren()[1].text).toEqual("After 1") + jestExpect(root2.getChildren()[1].text).toEqual("After 2") + jestExpect(root3.getChildren()[1].text).toEqual("After 3") + root1.render(nil) + root2.render(nil) + root3.render(nil) + jestExpect(root1.getChildren()[1]).toEqual(nil) + jestExpect(root2.getChildren()[1]).toEqual(nil) + jestExpect(root3.getChildren()[1]).toEqual(nil) + end) + it("logs a single error when using error boundary", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + jestExpect(function() + return root.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + end).toErrorDev( + "The above error occurred in the component:", + { logAllErrors = true } + ) + + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Catch and render an error message + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("renders an error state if child throws in render", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Catch and render an error message + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("renders an error state if child throws in constructor", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenConstructor))) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenConstructor constructor [!]", - -- Catch and render an error message - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("renders an error state if child throws in componentWillMount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenConstructor) + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenConstructor constructor [!]", + -- Catch and render an error message + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("renders an error state if child throws in componentWillMount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenComponentWillMount))) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenComponentWillMount constructor", - "BrokenComponentWillMount componentWillMount [!]", - -- Catch and render an error message - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("renders an error state if context provider throws in componentWillMount", function() + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentWillMount) + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenComponentWillMount constructor", + "BrokenComponentWillMount componentWillMount [!]", + -- Catch and render an error message + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it( + "renders an error state if context provider throws in componentWillMount", + function() -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM local root = ReactNoop.createLegacyRoot() - local BrokenComponentWillMountWithContext = React.Component:extend("BrokenComponentWillMountWithContext") + local BrokenComponentWillMountWithContext = + React.Component:extend("BrokenComponentWillMountWithContext") function BrokenComponentWillMountWithContext:getChildContext() return { foo = 42 } @@ -777,11 +902,20 @@ return function() foo = 0, } - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenComponentWillMountWithContext))) + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentWillMountWithContext) + ) + ) jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - end) - if not ReactFeatureFlags.disableModulePatternComponents then - it("renders an error state if module-style context provider throws in componentWillMount", function() + end + ) + if not ReactFeatureFlags.disableModulePatternComponents then + it( + "renders an error state if module-style context provider throws in componentWillMount", + function() -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM local root = ReactNoop.createLegacyRoot() @@ -805,370 +939,402 @@ return function() -- } jestExpect(function() - return root.render(React.createElement( - ErrorBoundary, - nil, - React.createElement(BrokenComponentWillMountWithContext) - )) - -- ROBLOX FIXME: error message has JS example that needs to be Lua + return root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentWillMountWithContext) + ) + ) end).toErrorDev( "Warning: The component appears to be a function component that " .. "returns a class instance. " .. "Change BrokenComponentWillMountWithContext to a class that extends React.Component instead. " - .. "If you can't use a class try assigning the prototype on the function as a workaround. " - .. "`BrokenComponentWillMountWithContext.prototype = React.Component.prototype`. " - .. "Don't use an arrow function since it cannot be called with `new` by React." + -- ROBLOX deviation: Remove JS specifics + -- .. "If you can't use a class try assigning the prototype on the function as a workaround. " + -- .. "`BrokenComponentWillMountWithContext.prototype = React.Component.prototype`. " + -- .. "Don't use an arrow function since it cannot be called with `new` by React." ) -- ROBLOX FIXME: fails here, but error message above is correct. root appears to have empty children tables -- jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - end) - end - it("mounts the error message if mounting fails", function() - local function renderError(error_) - local errorMessage = error_ - -- ROBLOX deviation: additional logic to deal with pre-polyfill error object - if typeof(error_) == "table" then - errorMessage = error_.message - end - return React.createElement(ErrorMessage, { - message = errorMessage, - }) end + ) + end + it("mounts the error message if mounting fails", function() + local function renderError(error_) + local errorMessage = error_ + -- ROBLOX deviation: additional logic to deal with pre-polyfill error object + if typeof(error_) == "table" then + errorMessage = error_.message + end + return React.createElement(ErrorMessage, { + message = errorMessage, + }) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, { renderError = renderError }, React.createElement(BrokenRender) - )) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Catch and render an error message - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorMessage constructor", - "ErrorMessage componentWillMount", - "ErrorMessage render", - "ErrorMessage componentDidMount", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - "ErrorMessage componentWillUnmount", - }) - end) - it("propagates errors on retry on mounting", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + ) + ) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Catch and render an error message + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorMessage constructor", + "ErrorMessage componentWillMount", + "ErrorMessage render", + "ErrorMessage componentDidMount", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + "ErrorMessage componentWillUnmount", + }) + end) + it("propagates errors on retry on mounting", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, - React.createElement(RetryErrorBoundary, nil, React.createElement(BrokenRender)) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "RetryErrorBoundary constructor", - "RetryErrorBoundary componentWillMount", - "RetryErrorBoundary render", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Retry - "RetryErrorBoundary static getDerivedStateFromError [!]", - "RetryErrorBoundary componentWillMount", - "RetryErrorBoundary render", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- This time, the error propagates to the higher boundary - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("propagates errors inside boundary during componentWillMount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + React.createElement( + RetryErrorBoundary, + nil, + React.createElement(BrokenRender) + ) + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "RetryErrorBoundary constructor", + "RetryErrorBoundary componentWillMount", + "RetryErrorBoundary render", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Retry + "RetryErrorBoundary static getDerivedStateFromError [!]", + "RetryErrorBoundary componentWillMount", + "RetryErrorBoundary render", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- This time, the error propagates to the higher boundary + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("propagates errors inside boundary during componentWillMount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(BrokenComponentWillMountErrorBoundary) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenComponentWillMountErrorBoundary constructor", - "BrokenComponentWillMountErrorBoundary componentWillMount [!]", - -- The error propagates to the higher boundary - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("propagates errors inside boundary while rendering error state", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenComponentWillMountErrorBoundary constructor", + "BrokenComponentWillMountErrorBoundary componentWillMount [!]", + -- The error propagates to the higher boundary + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("propagates errors inside boundary while rendering error state", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, - React.createElement(BrokenRenderErrorBoundary, nil, React.createElement(BrokenRender)) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRenderErrorBoundary constructor", - "BrokenRenderErrorBoundary componentWillMount", - "BrokenRenderErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Attempt to handle the error - "BrokenRenderErrorBoundary static getDerivedStateFromError", - "BrokenRenderErrorBoundary componentWillMount", - "BrokenRenderErrorBoundary render error [!]", - -- Attempt to handle the error again - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("does not call componentWillUnmount when aborting initial mount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + React.createElement( + BrokenRenderErrorBoundary, + nil, + React.createElement(BrokenRender) + ) + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRenderErrorBoundary constructor", + "BrokenRenderErrorBoundary componentWillMount", + "BrokenRenderErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Attempt to handle the error + "BrokenRenderErrorBoundary static getDerivedStateFromError", + "BrokenRenderErrorBoundary componentWillMount", + "BrokenRenderErrorBoundary render error [!]", + -- Attempt to handle the error again + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("does not call componentWillUnmount when aborting initial mount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenRender), React.createElement(Normal) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - -- Render first child - "Normal constructor", - "Normal componentWillMount", - "Normal render", - -- Render second child (it throws) - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Render third child, even though an earlier sibling threw. - "Normal constructor", - "Normal componentWillMount", - "Normal render", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("resets callback refs if mounting aborts", function() - local function childRef(x) - -- throw new Error(typeof(x)) - -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables - local XAsString = x and "[object HTMLDivElement]" or "nil" - Scheduler.unstable_yieldValue("Child ref is set to " .. XAsString) - end - local function errorMessageRef(x) - -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables - local XAsString = x and "[object HTMLDivElement]" or "nil" - Scheduler.unstable_yieldValue("Error message ref is set to " .. XAsString) - end + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + -- Render first child + "Normal constructor", + "Normal componentWillMount", + "Normal render", + -- Render second child (it throws) + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Render third child, even though an earlier sibling threw. + "Normal constructor", + "Normal componentWillMount", + "Normal render", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("resets callback refs if mounting aborts", function() + local function childRef(x) + -- throw new Error(typeof(x)) + -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables + local XAsString = x and "[object HTMLDivElement]" or "nil" + Scheduler.unstable_yieldValue("Child ref is set to " .. XAsString) + end + local function errorMessageRef(x) + -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables + local XAsString = x and "[object HTMLDivElement]" or "nil" + Scheduler.unstable_yieldValue("Error message ref is set to " .. XAsString) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, { errorMessageRef = errorMessageRef }, React.createElement("div", { ref = childRef }), React.createElement(BrokenRender) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "Error message ref is set to [object HTMLDivElement]", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - "Error message ref is set to nil", - }) - end) - it("resets object refs if mounting aborts", function() - local childRef = React.createRef() - local errorMessageRef = React.createRef() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement( + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "Error message ref is set to [object HTMLDivElement]", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + "Error message ref is set to nil", + }) + end) + it("resets object refs if mounting aborts", function() + local childRef = React.createRef() + local errorMessageRef = React.createRef() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render( + React.createElement( ErrorBoundary, { errorMessageRef = errorMessageRef }, React.createElement("div", { ref = childRef }), React.createElement(BrokenRender) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillMount", - "ErrorBoundary render error", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: workaround for object '[object HTMLDivElement]' - jestExpect(tostring(errorMessageRef.current):find("table: ") ~= nil).toEqual(true) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - jestExpect(errorMessageRef.current).toEqual(nil) - end) - it("successfully mounts if no error occurs", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillMount", + "ErrorBoundary render error", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: workaround for object '[object HTMLDivElement]' + jestExpect(string.find(tostring(errorMessageRef.current), "table: ") ~= nil).toEqual( + true + ) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + jestExpect(errorMessageRef.current).toEqual(nil) + end) + it("successfully mounts if no error occurs", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement("div", nil, { "Mounted successfully." }) - )) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Mounted successfully.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "ErrorBoundary componentDidMount", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches if child throws in constructor during update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) - Scheduler.unstable_clearYields() - root.render(React.createElement( + ) + ) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Mounted successfully.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "ErrorBoundary componentDidMount", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches if child throws in constructor during update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) + Scheduler.unstable_clearYields() + root.render( + React.createElement( ErrorBoundary, - nil, - React.createElement(Normal), - React.createElement(Normal, { - logName = "Normal2", - }), - React.createElement(BrokenConstructor) - )) - - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - -- Normal2 will attempt to mount: - "Normal2 constructor", - "Normal2 componentWillMount", - "Normal2 render", - -- BrokenConstructor will abort rendering: - "BrokenConstructor constructor [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - -- Render the error message - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches if child throws in componentWillMount during update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) - Scheduler.unstable_clearYields() - root.render(React.createElement( + nil, + React.createElement(Normal), + React.createElement(Normal, { + logName = "Normal2", + }), + React.createElement(BrokenConstructor) + ) + ) + + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + -- Normal2 will attempt to mount: + "Normal2 constructor", + "Normal2 componentWillMount", + "Normal2 render", + -- BrokenConstructor will abort rendering: + "BrokenConstructor constructor [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + -- Render the error message + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches if child throws in componentWillMount during update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) + Scheduler.unstable_clearYields() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), @@ -1176,127 +1342,137 @@ return function() logName = "Normal2", }), React.createElement(BrokenComponentWillMount) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - -- Normal2 will attempt to mount: - "Normal2 constructor", - "Normal2 componentWillMount", - "Normal2 render", - -- BrokenComponentWillMount will abort rendering: - "BrokenComponentWillMount constructor", - "BrokenComponentWillMount componentWillMount [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - -- Render the error message - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches if child throws in componentWillReceiveProps during update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + -- Normal2 will attempt to mount: + "Normal2 constructor", + "Normal2 componentWillMount", + "Normal2 render", + -- BrokenComponentWillMount will abort rendering: + "BrokenComponentWillMount constructor", + "BrokenComponentWillMount componentWillMount [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + -- Render the error message + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches if child throws in componentWillReceiveProps during update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenComponentWillReceiveProps) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement( + ) + ) + Scheduler.unstable_clearYields() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenComponentWillReceiveProps) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - -- BrokenComponentWillReceiveProps will abort rendering: - "BrokenComponentWillReceiveProps componentWillReceiveProps [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - -- Render the error message - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "BrokenComponentWillReceiveProps componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches if child throws in componentWillUpdate during update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + -- BrokenComponentWillReceiveProps will abort rendering: + "BrokenComponentWillReceiveProps componentWillReceiveProps [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + -- Render the error message + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "BrokenComponentWillReceiveProps componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches if child throws in componentWillUpdate during update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenComponentWillUpdate) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement( + ) + ) + Scheduler.unstable_clearYields() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenComponentWillUpdate) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - -- BrokenComponentWillUpdate will abort rendering: - "BrokenComponentWillUpdate componentWillReceiveProps", - "BrokenComponentWillUpdate componentWillUpdate [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "BrokenComponentWillUpdate componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches if child throws in render during update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) - Scheduler.unstable_clearYields() - root.render(React.createElement( + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + -- BrokenComponentWillUpdate will abort rendering: + "BrokenComponentWillUpdate componentWillReceiveProps", + "BrokenComponentWillUpdate componentWillUpdate [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "BrokenComponentWillUpdate componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches if child throws in render during update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) + Scheduler.unstable_clearYields() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), @@ -1304,423 +1480,502 @@ return function() logName = "Normal2", }), React.createElement(BrokenRender) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - -- Normal2 will attempt to mount: - "Normal2 constructor", - "Normal2 componentWillMount", - "Normal2 render", - -- BrokenRender will abort rendering: - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - -- ROBLOX deviation: render nil to unmount - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("keeps refs up-to-date during updates", function() - local function child1Ref(x) - -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables - local XAsString = x and "[object HTMLDivElement]" or "nil" - Scheduler.unstable_yieldValue("Child1 ref is set to " .. XAsString) - end - local function child2Ref(x) - -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables - local XAsString = x and "[object HTMLDivElement]" or "nil" - Scheduler.unstable_yieldValue("Child2 ref is set to " .. XAsString) - end - local function errorMessageRef(x) - -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables - local XAsString = x and "[object HTMLDivElement]" or "nil" - Scheduler.unstable_yieldValue("Error message ref is set to " .. XAsString) - end + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + -- Normal2 will attempt to mount: + "Normal2 constructor", + "Normal2 componentWillMount", + "Normal2 render", + -- BrokenRender will abort rendering: + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + -- ROBLOX deviation: render nil to unmount + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("keeps refs up-to-date during updates", function() + local function child1Ref(x) + -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables + local XAsString = x and "[object HTMLDivElement]" or "nil" + Scheduler.unstable_yieldValue("Child1 ref is set to " .. XAsString) + end + local function child2Ref(x) + -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables + local XAsString = x and "[object HTMLDivElement]" or "nil" + Scheduler.unstable_yieldValue("Child2 ref is set to " .. XAsString) + end + local function errorMessageRef(x) + -- ROBLOX deviation: use explicit '[object HTMLDivElement]' string because Lua uses tables + local XAsString = x and "[object HTMLDivElement]" or "nil" + Scheduler.unstable_yieldValue("Error message ref is set to " .. XAsString) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, { errorMessageRef = errorMessageRef }, React.createElement("div", { ref = child1Ref }) - )) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "Child1 ref is set to [object HTMLDivElement]", - "ErrorBoundary componentDidMount", - }) - root.render(React.createElement( + ) + ) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "Child1 ref is set to [object HTMLDivElement]", + "ErrorBoundary componentDidMount", + }) + root.render( + React.createElement( ErrorBoundary, { errorMessageRef = errorMessageRef }, React.createElement("div", { ref = child1Ref }), React.createElement("div", { ref = child2Ref }), React.createElement(BrokenRender) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - -- BrokenRender will abort rendering: - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - -- Update Child1 ref since Child1 has been unmounted - -- Child2 ref is never set because its mounting aborted - "Child1 ref is set to nil", - "Error message ref is set to [object HTMLDivElement]", - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - "Error message ref is set to nil", - }) - end) - it("recovers from componentWillUnmount errors on update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + -- BrokenRender will abort rendering: + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + -- Update Child1 ref since Child1 has been unmounted + -- Child2 ref is never set because its mounting aborted + "Child1 ref is set to nil", + "Error message ref is set to [object HTMLDivElement]", + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + "Error message ref is set to nil", + }) + end) + it("recovers from componentWillUnmount errors on update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(BrokenComponentWillUnmount), React.createElement(BrokenComponentWillUnmount), React.createElement(Normal) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenComponentWillUnmount))) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - -- Update existing child: - "BrokenComponentWillUnmount componentWillReceiveProps", - "BrokenComponentWillUnmount componentWillUpdate", - "BrokenComponentWillUnmount render", - -- Unmounting throws: - "BrokenComponentWillUnmount componentWillUnmount [!]", - -- Fiber proceeds with lifecycles despite errors - "Normal componentWillUnmount", - -- The components have updated in this phase - "BrokenComponentWillUnmount componentDidUpdate", - "ErrorBoundary componentDidUpdate", - -- The initial render was aborted, so - -- Fiber retries from the root. - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "BrokenComponentWillUnmount componentWillUnmount [!]", - "ErrorBoundary componentDidUpdate", - -- The second willUnmount error should be captured and logged, too. - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - -- Render an error now (stack will do it later) - "ErrorBoundary render error", - -- Attempt to unmount previous child: - -- Done - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("recovers from nested componentWillUnmount errors on update", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + ) + ) + Scheduler.unstable_clearYields() + -- ROBLOX DEVIATION: Pass a key to the "BrokenWillUnmount" element. This allows for + -- compatibility when we go from rendering multiple children to rendering a single + -- child, as we apply keys to children in an array by default, but not to a single + -- element + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentWillUnmount, { key = 1 }) + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + -- Update existing child: + "BrokenComponentWillUnmount componentWillReceiveProps", + "BrokenComponentWillUnmount componentWillUpdate", + "BrokenComponentWillUnmount render", + -- Unmounting throws: + "BrokenComponentWillUnmount componentWillUnmount [!]", + -- Fiber proceeds with lifecycles despite errors + "Normal componentWillUnmount", + -- The components have updated in this phase + "BrokenComponentWillUnmount componentDidUpdate", + "ErrorBoundary componentDidUpdate", + -- The initial render was aborted, so + -- Fiber retries from the root. + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "BrokenComponentWillUnmount componentWillUnmount [!]", + "ErrorBoundary componentDidUpdate", + -- The second willUnmount error should be captured and logged, too. + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + -- Render an error now (stack will do it later) + "ErrorBoundary render error", + -- Attempt to unmount previous child: + -- Done + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("recovers from nested componentWillUnmount errors on update", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( ErrorBoundary, nil, - React.createElement(Normal, nil, React.createElement(BrokenComponentWillUnmount)), + React.createElement( + Normal, + nil, + React.createElement(BrokenComponentWillUnmount) + ), React.createElement(BrokenComponentWillUnmount) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement( + ) + ) + Scheduler.unstable_clearYields() + -- ROBLOX DEVIATION: Pass a key to the "Normal" element. This allows for compatibility + -- when we go from rendering multiple children to rendering a single child, as we + -- apply keys to children in an array by default, but not to a single element + root.render( + React.createElement( ErrorBoundary, nil, - React.createElement(Normal, nil, React.createElement(BrokenComponentWillUnmount)) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - -- Update existing children: - "Normal componentWillReceiveProps", - "Normal componentWillUpdate", - "Normal render", - "BrokenComponentWillUnmount componentWillReceiveProps", - "BrokenComponentWillUnmount componentWillUpdate", - "BrokenComponentWillUnmount render", - -- Unmounting throws: - "BrokenComponentWillUnmount componentWillUnmount [!]", - -- Fiber proceeds with lifecycles despite errors - "BrokenComponentWillUnmount componentDidUpdate", - "Normal componentDidUpdate", - "ErrorBoundary componentDidUpdate", - -- Now that commit phase is done, Fiber handles errors - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "Normal componentWillUnmount", - "BrokenComponentWillUnmount componentWillUnmount [!]", - "ErrorBoundary componentDidUpdate", - -- The second willUnmount error should be captured and logged, too. - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - -- Render an error now (stack will do it later) - "ErrorBoundary render error", - -- Done - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("picks the right boundary when handling unmounting errors", function() - local function renderInnerError(error_) - return React.createElement("div", nil, "Caught an inner error: ", error_, ".") - end - local function renderOuterError(error_) - return React.createElement("div", nil, "Caught an outer error: ", error_, ".") - end + React.createElement( + Normal, + { key = 1 }, + React.createElement(BrokenComponentWillUnmount) + ) + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + -- Update existing children: + "Normal componentWillReceiveProps", + "Normal componentWillUpdate", + "Normal render", + "BrokenComponentWillUnmount componentWillReceiveProps", + "BrokenComponentWillUnmount componentWillUpdate", + "BrokenComponentWillUnmount render", + -- Unmounting throws: + "BrokenComponentWillUnmount componentWillUnmount [!]", + -- Fiber proceeds with lifecycles despite errors + "BrokenComponentWillUnmount componentDidUpdate", + "Normal componentDidUpdate", + "ErrorBoundary componentDidUpdate", + -- Now that commit phase is done, Fiber handles errors + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "Normal componentWillUnmount", + "BrokenComponentWillUnmount componentWillUnmount [!]", + "ErrorBoundary componentDidUpdate", + -- The second willUnmount error should be captured and logged, too. + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + -- Render an error now (stack will do it later) + "ErrorBoundary render error", + -- Done + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("picks the right boundary when handling unmounting errors", function() + local function renderInnerError(error_) + return React.createElement( + "div", + nil, + "Caught an inner error: ", + error_.message, + "." + ) + end + local function renderOuterError(error_) + return React.createElement( + "div", + nil, + "Caught an outer error: ", + error_.message, + "." + ) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( - ErrorBoundary, - { - logName = "OuterErrorBoundary", - renderError = renderOuterError, - }, - React.createElement(ErrorBoundary, { - logName = "InnerErrorBoundary", - renderError = renderInnerError, - }, React.createElement(BrokenComponentWillUnmount)) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement( + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render(React.createElement( + ErrorBoundary, + { + logName = "OuterErrorBoundary", + renderError = renderOuterError, + }, + React.createElement(ErrorBoundary, { + logName = "InnerErrorBoundary", + renderError = renderInnerError, + }, React.createElement(BrokenComponentWillUnmount)) + )) + Scheduler.unstable_clearYields() + root.render(React.createElement( + ErrorBoundary, + { + logName = "OuterErrorBoundary", + renderError = renderOuterError, + }, + React.createElement(ErrorBoundary, { + logName = "InnerErrorBoundary", + renderError = renderInnerError, + }) + )) + jestExpect(textContent(root)).toEqual("Caught an inner error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + -- Update outer boundary + "OuterErrorBoundary componentWillReceiveProps", + "OuterErrorBoundary componentWillUpdate", + "OuterErrorBoundary render success", + -- Update inner boundary + "InnerErrorBoundary componentWillReceiveProps", + "InnerErrorBoundary componentWillUpdate", + "InnerErrorBoundary render success", + -- Try unmounting child + "BrokenComponentWillUnmount componentWillUnmount [!]", + -- Now that commit phase is done, Fiber handles errors + -- Only inner boundary receives the error: + "InnerErrorBoundary componentDidUpdate", + "OuterErrorBoundary componentDidUpdate", + "ErrorBoundary static getDerivedStateFromError", + "InnerErrorBoundary componentWillUpdate", + -- Render an error now + "InnerErrorBoundary render error", + -- In Fiber, this was a local update to the + -- inner boundary so only its hook fires + "InnerErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "OuterErrorBoundary componentWillUnmount", + "InnerErrorBoundary componentWillUnmount", + }) + end) + it("can recover from error state", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) + -- Error boundary doesn't retry by itself + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + -- Force the success path: + Scheduler.unstable_clearYields() + + root.render( + React.createElement( ErrorBoundary, - { - logName = "OuterErrorBoundary", - renderError = renderOuterError, - }, - React.createElement(ErrorBoundary, { - logName = "InnerErrorBoundary", - renderError = renderInnerError, - }) - )) - jestExpect(textContent(root)).toEqual("Caught an inner error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - -- Update outer boundary - "OuterErrorBoundary componentWillReceiveProps", - "OuterErrorBoundary componentWillUpdate", - "OuterErrorBoundary render success", - -- Update inner boundary - "InnerErrorBoundary componentWillReceiveProps", - "InnerErrorBoundary componentWillUpdate", - "InnerErrorBoundary render success", - -- Try unmounting child - "BrokenComponentWillUnmount componentWillUnmount [!]", - -- Now that commit phase is done, Fiber handles errors - -- Only inner boundary receives the error: - "InnerErrorBoundary componentDidUpdate", - "OuterErrorBoundary componentDidUpdate", - "ErrorBoundary static getDerivedStateFromError", - "InnerErrorBoundary componentWillUpdate", - -- Render an error now - "InnerErrorBoundary render error", - -- In Fiber, this was a local update to the - -- inner boundary so only its hook fires - "InnerErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "OuterErrorBoundary componentWillUnmount", - "InnerErrorBoundary componentWillUnmount", - }) - end) - it("can recover from error state", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - root.render(React.createElement(ErrorBoundary, nil, React.createElement(Normal))) - -- Error boundary doesn't retry by itself - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - -- Force the success path: - Scheduler.unstable_clearYields() - - root.render(React.createElement(ErrorBoundary, { forceRetry = true }, React.createElement(Normal))) - - jestExpect(textContent(root)).never.toContain("Caught an error") - - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - -- Mount children: - "Normal constructor", - "Normal componentWillMount", - "Normal render", - -- Finalize updates: - "Normal componentDidMount", - "ErrorBoundary componentDidUpdate", - }) - - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - "Normal componentWillUnmount", - }) - end) - it("can update multiple times in error state", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender))) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - - -- ROBLOX deviation: render nil to clear children for textContent - root.render(nil) - root.render(React.createElement("div", nil, "Other screen")) - jestExpect(textContent(root)).toEqual("Other screen") - root.render(nil) - end) - it("doesn't get into inconsistent state during removals", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + { forceRetry = true }, + React.createElement(Normal) + ) + ) + + jestExpect(textContent(root)).never.toContain("Caught an error") + + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + -- Mount children: + "Normal constructor", + "Normal componentWillMount", + "Normal render", + -- Finalize updates: + "Normal componentDidMount", + "ErrorBoundary componentDidUpdate", + }) + + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + "Normal componentWillUnmount", + }) + end) + it("can update multiple times in error state", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + root.render( + React.createElement(ErrorBoundary, nil, React.createElement(BrokenRender)) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + + -- ROBLOX deviation: render nil to clear children for textContent + root.render(nil) + root.render(React.createElement("div", nil, "Other screen")) + jestExpect(textContent(root)).toEqual("Other screen") + root.render(nil) + end) + it("doesn't get into inconsistent state during removals", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenComponentWillUnmount), React.createElement(Normal) - )) - root.render(React.createElement(ErrorBoundary)) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - Scheduler.unstable_clearYields() - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("doesn't get into inconsistent state during additions", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary)) - root.render(React.createElement( + ) + ) + root.render(React.createElement(ErrorBoundary)) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + Scheduler.unstable_clearYields() + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("doesn't get into inconsistent state during additions", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render(React.createElement(ErrorBoundary)) + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Normal), React.createElement(BrokenRender), React.createElement(Normal) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - Scheduler.unstable_clearYields() - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("doesn't get into inconsistent state during reorders", function() - local fail_ = false - - local MaybeBrokenRender = React.Component:extend("MaybeBrokenRender") - function MaybeBrokenRender:render() - if fail_ then - error("Hello", 0) - end + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + Scheduler.unstable_clearYields() + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("doesn't get into inconsistent state during reorders", function() + local fail_ = false - return React.createElement("div", nil, self.props.children) + local MaybeBrokenRender = React.Component:extend("MaybeBrokenRender") + function MaybeBrokenRender:render() + if fail_ then + error("Hello", 0) end - local function getAMixOfNormalAndBrokenRenderElements() - local elements = {} + return React.createElement("div", nil, self.props.children) + end - for i = 1, 100 do - table.insert(elements, React.createElement(Normal, { key = i })) - end + local function getAMixOfNormalAndBrokenRenderElements() + local elements = {} - table.insert(elements, React.createElement(MaybeBrokenRender, { key = 101 })) + -- ROBLOX deviation START: loop more times to test our object caching logic + for i = 1, 1000 do + table.insert(elements, React.createElement(Normal, { key = i })) + end + -- ROBLOX deviation END - local currentIndex = #elements + table.insert(elements, React.createElement(MaybeBrokenRender, { key = 101 })) - while 0 ~= currentIndex do - local randomIndex = math.floor(math.random() * currentIndex) + local currentIndex = #elements - currentIndex = currentIndex - 1 + while 1 ~= currentIndex do + local randomIndex = math.floor(math.random() * currentIndex) + 1 - local temporaryValue = elements[currentIndex] + currentIndex -= 1 - elements[currentIndex] = elements[randomIndex] - elements[randomIndex] = temporaryValue - end + local temporaryValue = elements[currentIndex] - return elements + elements[currentIndex] = elements[randomIndex] + elements[randomIndex] = temporaryValue end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, getAMixOfNormalAndBrokenRenderElements())) - jestExpect(textContent(root)).never.toContain("Caught an error") + return elements + end - fail_ = true + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + -- ROBLOX FIXME LUAU: For some reason, `{React_Element}` + -- is not a valid type for children here + root.render( + React.createElement( + ErrorBoundary, + nil, + getAMixOfNormalAndBrokenRenderElements() :: any + ) + ) + jestExpect(textContent(root)).never.toContain("Caught an error") - root.render(React.createElement(ErrorBoundary, nil, getAMixOfNormalAndBrokenRenderElements())) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - Scheduler.unstable_clearYields() - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches errors originating downstream", function() - local fail_ = false - local Stateful = React.Component:extend("Stateful") - - function Stateful:init() - self.state = { - shouldThrow = false, - } - end - function Stateful:render() - if fail_ then - Scheduler.unstable_yieldValue("Stateful render [!]") - error("Hello", 0) - end + fail_ = true - return React.createElement("div", nil, self.props.children) + -- ROBLOX FIXME LUAU: For some reason, `{React_Element}` + -- is not a valid type for children here + root.render( + React.createElement( + ErrorBoundary, + nil, + getAMixOfNormalAndBrokenRenderElements() :: any + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + Scheduler.unstable_clearYields() + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches errors originating downstream", function() + local fail_ = false + local Stateful = React.Component:extend("Stateful") + + function Stateful:init() + self.state = { + shouldThrow = false, + } + end + function Stateful:render() + if fail_ then + Scheduler.unstable_yieldValue("Stateful render [!]") + error("Hello", 0) end - local statefulInst - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + return React.createElement("div", nil, self.props.children) + end + + local statefulInst + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(Stateful, { @@ -1728,174 +1983,198 @@ return function() statefulInst = inst end, }) - )) - Scheduler.unstable_clearYields() - jestExpect(function() - fail_ = true - statefulInst:forceUpdate() - end).never.toThrow() - jestExpect(Scheduler).toHaveYielded({ - "Stateful render [!]", - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches errors in componentDidMount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + ) + ) + Scheduler.unstable_clearYields() + jestExpect(function() + fail_ = true + statefulInst:forceUpdate() + end).never.toThrow() + jestExpect(Scheduler).toHaveYielded({ + "Stateful render [!]", + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches errors in componentDidMount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( ErrorBoundary, nil, - React.createElement(BrokenComponentWillUnmount, nil, React.createElement(Normal)), + React.createElement( + BrokenComponentWillUnmount, + nil, + React.createElement(Normal) + ), React.createElement(BrokenComponentDidMount), React.createElement(Normal, { logName = "LastChild", }) - )) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenComponentWillUnmount constructor", - "BrokenComponentWillUnmount componentWillMount", - "BrokenComponentWillUnmount render", - "Normal constructor", - "Normal componentWillMount", - "Normal render", - "BrokenComponentDidMount constructor", - "BrokenComponentDidMount componentWillMount", - "BrokenComponentDidMount render", - "LastChild constructor", - "LastChild componentWillMount", - "LastChild render", - -- Start flushing didMount queue - "Normal componentDidMount", - "BrokenComponentWillUnmount componentDidMount", - "BrokenComponentDidMount componentDidMount [!]", - -- Continue despite the error - "LastChild componentDidMount", - -- Now we are ready to handle the error - "ErrorBoundary componentDidMount", - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - -- Safely unmount every child - "BrokenComponentWillUnmount componentWillUnmount [!]", - -- Continue unmounting safely despite any errors - "Normal componentWillUnmount", - "BrokenComponentDidMount componentWillUnmount", - "LastChild componentWillUnmount", - -- The willUnmount error should be captured and logged, too. - "ErrorBoundary componentDidUpdate", - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - -- The update has finished - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("catches errors in componentDidUpdate", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenComponentDidUpdate))) - Scheduler.unstable_clearYields() - root.render(React.createElement(ErrorBoundary, nil, React.createElement(BrokenComponentDidUpdate))) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillReceiveProps", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render success", - "BrokenComponentDidUpdate componentWillReceiveProps", - "BrokenComponentDidUpdate componentWillUpdate", - "BrokenComponentDidUpdate render", - -- All lifecycles run - "BrokenComponentDidUpdate componentDidUpdate [!]", - "ErrorBoundary componentDidUpdate", - -- Then, error is handled - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "BrokenComponentDidUpdate componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - -- ROBLOX TODO: Eyeballing result shows expected functionality. ReactNoop.act() doesn't flush in the same way - -- that ReactDOM's act method does; need to resolve this with either ReactDOM port or closer match of act() in another renderer - xit("catches errors in useEffect", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - ReactNoop.act(function() - root.render(React.createElement( + ) + ) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenComponentWillUnmount constructor", + "BrokenComponentWillUnmount componentWillMount", + "BrokenComponentWillUnmount render", + "Normal constructor", + "Normal componentWillMount", + "Normal render", + "BrokenComponentDidMount constructor", + "BrokenComponentDidMount componentWillMount", + "BrokenComponentDidMount render", + "LastChild constructor", + "LastChild componentWillMount", + "LastChild render", + -- Start flushing didMount queue + "Normal componentDidMount", + "BrokenComponentWillUnmount componentDidMount", + "BrokenComponentDidMount componentDidMount [!]", + -- Continue despite the error + "LastChild componentDidMount", + -- Now we are ready to handle the error + "ErrorBoundary componentDidMount", + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + -- Safely unmount every child + "BrokenComponentWillUnmount componentWillUnmount [!]", + -- Continue unmounting safely despite any errors + "Normal componentWillUnmount", + "BrokenComponentDidMount componentWillUnmount", + "LastChild componentWillUnmount", + -- The willUnmount error should be captured and logged, too. + "ErrorBoundary componentDidUpdate", + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + -- The update has finished + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("catches errors in componentDidUpdate", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentDidUpdate) + ) + ) + Scheduler.unstable_clearYields() + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenComponentDidUpdate) + ) + ) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillReceiveProps", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render success", + "BrokenComponentDidUpdate componentWillReceiveProps", + "BrokenComponentDidUpdate componentWillUpdate", + "BrokenComponentDidUpdate render", + -- All lifecycles run + "BrokenComponentDidUpdate componentDidUpdate [!]", + "ErrorBoundary componentDidUpdate", + -- Then, error is handled + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "BrokenComponentDidUpdate componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + -- ROBLOX TODO: Eyeballing result shows expected functionality. ReactNoop.act() doesn't flush in the same way + -- that ReactDOM's act method does; need to resolve this with either ReactDOM port or closer match of act() in another renderer + xit("catches errors in useEffect", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + ReactNoop.act(function() + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(BrokenUseEffect, nil, "Initial value") - )) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenUseEffect render", - "ErrorBoundary componentDidMount", - }) - jestExpect(textContent(root)).toEqual("Initial value") - Scheduler.unstable_clearYields() - end) - - -- verify flushed passive effects and handle the error - jestExpect(Scheduler).toHaveYielded({ - "BrokenUseEffect useEffect [!]", - -- Handle the error - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "ErrorBoundary componentDidUpdate", - }) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - end) - it("catches errors in useLayoutEffect", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement( - ErrorBoundary, - nil, - React.createElement(BrokenUseLayoutEffect, nil, "Initial value") - )) + ) + ) jestExpect(Scheduler).toHaveYielded({ "ErrorBoundary constructor", "ErrorBoundary componentWillMount", "ErrorBoundary render success", - "BrokenUseLayoutEffect render", - "BrokenUseLayoutEffect useLayoutEffect [!]", - -- Fiber proceeds with the hooks + "BrokenUseEffect render", "ErrorBoundary componentDidMount", - -- The error propagates to the higher boundary - "ErrorBoundary static getDerivedStateFromError", - -- Fiber retries from the root - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "ErrorBoundary componentDidUpdate", }) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(textContent(root)).toEqual("Initial value") + Scheduler.unstable_clearYields() end) - it("propagates errors inside boundary during componentDidMount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + -- verify flushed passive effects and handle the error + jestExpect(Scheduler).toHaveYielded({ + "BrokenUseEffect useEffect [!]", + -- Handle the error + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "ErrorBoundary componentDidUpdate", + }) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + end) + it("catches errors in useLayoutEffect", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render( + React.createElement( + ErrorBoundary, + nil, + React.createElement(BrokenUseLayoutEffect, nil, "Initial value") + ) + ) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenUseLayoutEffect render", + "BrokenUseLayoutEffect useLayoutEffect [!]", + -- Fiber proceeds with the hooks + "ErrorBoundary componentDidMount", + -- The error propagates to the higher boundary + "ErrorBoundary static getDerivedStateFromError", + -- Fiber retries from the root + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "ErrorBoundary componentDidUpdate", + }) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + end) + it("propagates errors inside boundary during componentDidMount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(BrokenComponentDidMountErrorBoundary, { @@ -1909,325 +2188,344 @@ return function() ) end, }) - )) - jestExpect(textContent(root)).toEqual("Caught an error: Hello.") - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary constructor", - "ErrorBoundary componentWillMount", - "ErrorBoundary render success", - "BrokenComponentDidMountErrorBoundary constructor", - "BrokenComponentDidMountErrorBoundary componentWillMount", - "BrokenComponentDidMountErrorBoundary render success", - "BrokenComponentDidMountErrorBoundary componentDidMount [!]", - -- Fiber proceeds with the hooks - "ErrorBoundary componentDidMount", - -- The error propagates to the higher boundary - "ErrorBoundary static getDerivedStateFromError", - -- Fiber retries from the root - "ErrorBoundary componentWillUpdate", - "ErrorBoundary render error", - "BrokenComponentDidMountErrorBoundary componentWillUnmount", - "ErrorBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "ErrorBoundary componentWillUnmount", - }) - end) - it("calls static getDerivedStateFromError for each error that is captured", function() - local function renderUnmountError(error_) - return React.createElement("div", nil, "Caught an unmounting error: ", error_, ".") - end - local function renderUpdateError(error_) - return React.createElement("div", nil, "Caught an updating error: ", error_, ".") - end + ) + ) + jestExpect(textContent(root)).toEqual("Caught an error: Hello.") + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary constructor", + "ErrorBoundary componentWillMount", + "ErrorBoundary render success", + "BrokenComponentDidMountErrorBoundary constructor", + "BrokenComponentDidMountErrorBoundary componentWillMount", + "BrokenComponentDidMountErrorBoundary render success", + "BrokenComponentDidMountErrorBoundary componentDidMount [!]", + -- Fiber proceeds with the hooks + "ErrorBoundary componentDidMount", + -- The error propagates to the higher boundary + "ErrorBoundary static getDerivedStateFromError", + -- Fiber retries from the root + "ErrorBoundary componentWillUpdate", + "ErrorBoundary render error", + "BrokenComponentDidMountErrorBoundary componentWillUnmount", + "ErrorBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "ErrorBoundary componentWillUnmount", + }) + end) + it("calls static getDerivedStateFromError for each error that is captured", function() + local function renderUnmountError(error_) + return React.createElement( + "div", + nil, + "Caught an unmounting error: ", + error_.message, + "." + ) + end + local function renderUpdateError(error_) + return React.createElement( + "div", + nil, + "Caught an updating error: ", + error_.message, + "." + ) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement( + root.render(React.createElement( + ErrorBoundary, + { + logName = "OuterErrorBoundary", + }, + React.createElement( ErrorBoundary, { - logName = "OuterErrorBoundary", + logName = "InnerUnmountBoundary", + renderError = renderUnmountError, }, - React.createElement( - ErrorBoundary, - { - logName = "InnerUnmountBoundary", - renderError = renderUnmountError, - }, - React.createElement(BrokenComponentWillUnmount, { - errorText = "E1", - }), - React.createElement(BrokenComponentWillUnmount, { - errorText = "E2", - }) - ), - React.createElement( - ErrorBoundary, - { - logName = "InnerUpdateBoundary", - renderError = renderUpdateError, - }, - React.createElement(BrokenComponentDidUpdate, { - errorText = "E3", - }), - React.createElement(BrokenComponentDidUpdate, { - errorText = "E4", - }) - ) - )) - Scheduler.unstable_clearYields() - root.render(React.createElement( + React.createElement(BrokenComponentWillUnmount, { + errorText = "E1", + }), + React.createElement(BrokenComponentWillUnmount, { + errorText = "E2", + }) + ), + React.createElement( ErrorBoundary, { - logName = "OuterErrorBoundary", + logName = "InnerUpdateBoundary", + renderError = renderUpdateError, }, - React.createElement(ErrorBoundary, { - logName = "InnerUnmountBoundary", - renderError = renderUnmountError, + React.createElement(BrokenComponentDidUpdate, { + errorText = "E3", }), - React.createElement( - ErrorBoundary, - { - logName = "InnerUpdateBoundary", - renderError = renderUpdateError, - }, - React.createElement(BrokenComponentDidUpdate, { - errorText = "E3", - }), - React.createElement(BrokenComponentDidUpdate, { - errorText = "E4", - }) - ) - )) - jestExpect(textContent(root)).toEqual("Caught an unmounting error: E2.Caught an updating error: E4.") - jestExpect(Scheduler).toHaveYielded({ - -- Begin update phase - "OuterErrorBoundary componentWillReceiveProps", - "OuterErrorBoundary componentWillUpdate", - "OuterErrorBoundary render success", - "InnerUnmountBoundary componentWillReceiveProps", - "InnerUnmountBoundary componentWillUpdate", - "InnerUnmountBoundary render success", - "InnerUpdateBoundary componentWillReceiveProps", - "InnerUpdateBoundary componentWillUpdate", - "InnerUpdateBoundary render success", - -- First come the updates - "BrokenComponentDidUpdate componentWillReceiveProps", - "BrokenComponentDidUpdate componentWillUpdate", - "BrokenComponentDidUpdate render", - "BrokenComponentDidUpdate componentWillReceiveProps", - "BrokenComponentDidUpdate componentWillUpdate", - "BrokenComponentDidUpdate render", - -- We're in commit phase now, deleting - "BrokenComponentWillUnmount componentWillUnmount [!]", - "BrokenComponentWillUnmount componentWillUnmount [!]", - -- Continue despite errors, handle them after commit is done - "InnerUnmountBoundary componentDidUpdate", - -- We're still in commit phase, now calling update lifecycles - "BrokenComponentDidUpdate componentDidUpdate [!]", - -- Again, continue despite errors, we'll handle them later - "BrokenComponentDidUpdate componentDidUpdate [!]", - "InnerUpdateBoundary componentDidUpdate", - "OuterErrorBoundary componentDidUpdate", - -- After the commit phase, attempt to recover from any errors that - -- were captured - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary static getDerivedStateFromError", - "InnerUnmountBoundary componentWillUpdate", - "InnerUnmountBoundary render error", - "ErrorBoundary static getDerivedStateFromError", - "ErrorBoundary static getDerivedStateFromError", - "InnerUpdateBoundary componentWillUpdate", - "InnerUpdateBoundary render error", - "BrokenComponentDidUpdate componentWillUnmount", - "BrokenComponentDidUpdate componentWillUnmount", - "InnerUnmountBoundary componentDidUpdate", - "InnerUpdateBoundary componentDidUpdate", - }) - root.render(nil) - jestExpect(Scheduler).toHaveYielded({ - "OuterErrorBoundary componentWillUnmount", - "InnerUnmountBoundary componentWillUnmount", - "InnerUpdateBoundary componentWillUnmount", - }) - end) - it("discards a bad root if the root component fails", function() - local X = nil - local Y = nil - local err1 - local err2 - local ok, result - - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - ok, result = pcall(function() - jestExpect(function() - return root.render(React.createElement(X)) - end).toErrorDev( - "React.createElement: type is invalid -- expected a string " - .. "(for built-in components) or a class/function " - .. "(for composite components) but got: null." - ) - end) - if not ok then - err1 = result - end + React.createElement(BrokenComponentDidUpdate, { + errorText = "E4", + }) + ) + )) + Scheduler.unstable_clearYields() + root.render(React.createElement( + ErrorBoundary, + { + logName = "OuterErrorBoundary", + }, + React.createElement(ErrorBoundary, { + logName = "InnerUnmountBoundary", + renderError = renderUnmountError, + }), + React.createElement( + ErrorBoundary, + { + logName = "InnerUpdateBoundary", + renderError = renderUpdateError, + }, + React.createElement(BrokenComponentDidUpdate, { + errorText = "E3", + }), + React.createElement(BrokenComponentDidUpdate, { + errorText = "E4", + }) + ) + )) + jestExpect(textContent(root)).toEqual( + "Caught an unmounting error: E2.Caught an updating error: E4." + ) + jestExpect(Scheduler).toHaveYielded({ + -- Begin update phase + "OuterErrorBoundary componentWillReceiveProps", + "OuterErrorBoundary componentWillUpdate", + "OuterErrorBoundary render success", + "InnerUnmountBoundary componentWillReceiveProps", + "InnerUnmountBoundary componentWillUpdate", + "InnerUnmountBoundary render success", + "InnerUpdateBoundary componentWillReceiveProps", + "InnerUpdateBoundary componentWillUpdate", + "InnerUpdateBoundary render success", + -- First come the updates + "BrokenComponentDidUpdate componentWillReceiveProps", + "BrokenComponentDidUpdate componentWillUpdate", + "BrokenComponentDidUpdate render", + "BrokenComponentDidUpdate componentWillReceiveProps", + "BrokenComponentDidUpdate componentWillUpdate", + "BrokenComponentDidUpdate render", + -- We're in commit phase now, deleting + "BrokenComponentWillUnmount componentWillUnmount [!]", + "BrokenComponentWillUnmount componentWillUnmount [!]", + -- Continue despite errors, handle them after commit is done + "InnerUnmountBoundary componentDidUpdate", + -- We're still in commit phase, now calling update lifecycles + "BrokenComponentDidUpdate componentDidUpdate [!]", + -- Again, continue despite errors, we'll handle them later + "BrokenComponentDidUpdate componentDidUpdate [!]", + "InnerUpdateBoundary componentDidUpdate", + "OuterErrorBoundary componentDidUpdate", + -- After the commit phase, attempt to recover from any errors that + -- were captured + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary static getDerivedStateFromError", + "InnerUnmountBoundary componentWillUpdate", + "InnerUnmountBoundary render error", + "ErrorBoundary static getDerivedStateFromError", + "ErrorBoundary static getDerivedStateFromError", + "InnerUpdateBoundary componentWillUpdate", + "InnerUpdateBoundary render error", + "BrokenComponentDidUpdate componentWillUnmount", + "BrokenComponentDidUpdate componentWillUnmount", + "InnerUnmountBoundary componentDidUpdate", + "InnerUpdateBoundary componentDidUpdate", + }) + root.render(nil) + jestExpect(Scheduler).toHaveYielded({ + "OuterErrorBoundary componentWillUnmount", + "InnerUnmountBoundary componentWillUnmount", + "InnerUpdateBoundary componentWillUnmount", + }) + end) + it("discards a bad root if the root component fails", function() + local X = nil + local Y = nil + local err1 + local err2 + local ok, result - ok, result = pcall(function() - jestExpect(function() - return root.render(React.createElement(Y)) - end).toErrorDev( - "React.createElement: type is invalid -- expected a string " - .. "(for built-in components) or a class/function " - .. "(for composite components) but got: null." - ) - end) - if not ok then - err2 = result - end + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - -- ROBLOX deviation: workaround for toMatch() - jestExpect(err1.message:find("but got: nil") ~= nil).toEqual(true) - jestExpect(err2.message:find("but got: nil") ~= nil).toEqual(true) + ok, result = pcall(function() + jestExpect(function() + return root.render(React.createElement(X)) + end).toErrorDev( + "React.createElement: type is invalid -- expected a string " + .. "(for built-in components) or a class/function " + .. "(for composite components) but got: nil." + ) end) - it("renders empty output if error boundary does not handle the error", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + if not ok then + err1 = result + end + ok, result = pcall(function() jestExpect(function() - return root.render(React.createElement( + return root.render(React.createElement(Y)) + end).toErrorDev( + "React.createElement: type is invalid -- expected a string " + .. "(for built-in components) or a class/function " + .. "(for composite components) but got: nil." + ) + end) + if not ok then + err2 = result + end + + jestExpect(err1.message).toMatch("but got: nil") + jestExpect(err2.message).toMatch("but got: nil") + end) + it("renders empty output if error boundary does not handle the error", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + jestExpect(function() + return root.render( + React.createElement( "div", nil, "Sibling", - React.createElement(NoopErrorBoundary, nil, React.createElement(BrokenRender)) - )) - end).toThrow("Hello") - jestExpect(textContent(root)).toEqual("") - jestExpect(Scheduler).toHaveYielded({ - "NoopErrorBoundary constructor", - "NoopErrorBoundary componentWillMount", - "NoopErrorBoundary render", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - -- Noop error boundaries retry render (and fail again) - "NoopErrorBoundary static getDerivedStateFromError", - "NoopErrorBoundary render", - "BrokenRender constructor", - "BrokenRender componentWillMount", - "BrokenRender render [!]", - }) - end) - it("passes first error when two errors happen in commit", function() - local errors = {} - local caughtError + React.createElement( + NoopErrorBoundary, + nil, + React.createElement(BrokenRender) + ) + ) + ) + end).toThrow("Hello") + jestExpect(textContent(root)).toEqual("") + jestExpect(Scheduler).toHaveYielded({ + "NoopErrorBoundary constructor", + "NoopErrorBoundary componentWillMount", + "NoopErrorBoundary render", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + -- Noop error boundaries retry render (and fail again) + "NoopErrorBoundary static getDerivedStateFromError", + "NoopErrorBoundary render", + "BrokenRender constructor", + "BrokenRender componentWillMount", + "BrokenRender render [!]", + }) + end) + it("passes first error when two errors happen in commit", function() + local errors = {} + local caughtError - local Child = React.Component:extend("Child") + local Child = React.Component:extend("Child") - function Child:render() - return React.createElement("div") - end - function Child:componentDidMount() - table.insert(errors, "child sad") - error("child sad") - end - local Parent = React.Component:extend("Parent") + function Child:render() + return React.createElement("div") + end + function Child:componentDidMount() + table.insert(errors, "child sad") + error("child sad") + end + local Parent = React.Component:extend("Parent") - function Parent:render() - return React.createElement(Child) - end - function Parent:componentDidMount() - table.insert(errors, "parent sad") - error("parent sad") - end + function Parent:render() + return React.createElement(Child) + end + function Parent:componentDidMount() + table.insert(errors, "parent sad") + error("parent sad") + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - local ok, result = pcall(function() - -- Here, we test the behavior where there is no error boundary and we - -- delegate to the host root. - root.render(React.createElement(Parent)) - end) - if not ok then - if not (result:sub(-#"parent sad") == "parent sad") and not (result:sub(-#"child sad") == "child sad") then - error(result) - end - caughtError = result + -- Here, we test the behavior where there is no error boundary and we + -- delegate to the host root. + local ok, e = pcall(root.render, React.createElement(Parent)) + if not ok then + if e.message ~= "parent sad" and e.message ~= "child sad" then + error(e) end + caughtError = e + end - jestExpect(errors).toEqual({ - "child sad", - "parent sad", - }) - -- Error should be the first thrown - jestExpect(caughtError:sub(-#"child sad")).toEqual("child sad") - end) - it("propagates uncaught error inside unbatched initial mount", function() - local function Foo() - error("foo error", 0) - end + jestExpect(errors).toEqual({ + "child sad", + "parent sad", + }) + -- Error should be the first thrown + jestExpect(caughtError.message).toBe("child sad") + end) + it("propagates uncaught error inside unbatched initial mount", function() + local function Foo() + error("foo error", 0) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - jestExpect(function() - ReactNoop.batchedUpdates(function() - root.render(React.createElement(Foo)) - end) - end).toThrow("foo error") - end) - it("handles errors that occur in before-mutation commit hook", function() - local errors = {} - local caughtError + jestExpect(function() + ReactNoop.batchedUpdates(function() + root.render(React.createElement(Foo)) + end) + end).toThrow("foo error") + end) + it("handles errors that occur in before-mutation commit hook", function() + local errors = {} + local caughtError - local Child = React.Component:extend("Child") + local Child = React.Component:extend("Child") - function Child:getSnapshotBeforeUpdate() - table.insert(errors, "child sad") - error("child sad") - end - function Child:componentDidUpdate() - end - function Child:render() - return React.createElement("div") - end + function Child:getSnapshotBeforeUpdate() + table.insert(errors, "child sad") + error("child sad") + end + function Child:componentDidUpdate() end + function Child:render() + return React.createElement("div") + end - local Parent = React.Component:extend("Parent") - function Parent:getSnapshotBeforeUpdate() - table.insert(errors, "parent sad") - error("parent sad") - end - function Parent:componentDidUpdate() - end - function Parent:render() - return React.createElement(Child, self.props) - end + local Parent = React.Component:extend("Parent") + function Parent:getSnapshotBeforeUpdate() + table.insert(errors, "parent sad") + error("parent sad") + end + function Parent:componentDidUpdate() end + function Parent:render() + return React.createElement(Child, self.props) + end - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - root.render(React.createElement(Parent, { value = 1 })) - local ok, result = pcall(function() - root.render(React.createElement(Parent, { value = 2 })) - end) - if not ok then - if not (result:sub(-#"parent sad") == "parent sad") and not (result:sub(-#"child sad") == "child sad") then - error(result) - end - caughtError = result + root.render(React.createElement(Parent, { value = 1 })) + local ok, e = pcall(root.render, React.createElement(Parent, { value = 2 })) + if not ok then + if + not (string.sub(e.message, -#"parent sad") == "parent sad") + and not (string.sub(e.message, -#"child sad") == "child sad") + then + error(e) end - jestExpect(errors).toEqual({ - "child sad", - "parent sad", - }) - -- Error should be the first thrown - jestExpect(caughtError:sub(-#"child sad")).toEqual("child sad") - end) - it("should warn if an error boundary with only componentDidCatch does not update state", function() + caughtError = e + end + jestExpect(errors).toEqual({ + "child sad", + "parent sad", + }) + -- Error should be the first thrown + jestExpect(caughtError.message).toBe("child sad") + end) + it( + "should warn if an error boundary with only componentDidCatch does not update state", + function() local InvalidErrorBoundary = React.Component:extend("InvalidErrorBoundary") function InvalidErrorBoundary:componentDidCatch(error, info) @@ -2239,7 +2537,7 @@ return function() return self.props.children end - local Throws = function() + local function Throws() error("expected") end @@ -2248,25 +2546,29 @@ 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 one of your React components:\ -\ - in LoadedCode.RoactAlignment._Workspace.ReactReconciler.ReactReconciler.__tests__.ReactErrorBoundaries-internal.spec (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.", - }) + root.render( + React.createElement( + InvalidErrorBoundary, + nil, + React.createElement(Throws) + ) + ) + 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() + end + ) + it( + "should call both componentDidCatch and getDerivedStateFromError if both exist on a component", + function() -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM local root = ReactNoop.createLegacyRoot() local componentDidCatchError, getDerivedStateFromErrorError - local ErrorBoundaryWithBothMethods = React.Component:extend("ErrorBoundaryWithBothMethods") + local ErrorBoundaryWithBothMethods = + React.Component:extend("ErrorBoundaryWithBothMethods") function ErrorBoundaryWithBothMethods:init() self.state = {} end @@ -2288,293 +2590,312 @@ React will try to recreate this component tree from scratch using the error boun end)() end - -- ROBLOX deviation: using a string 'expected' thrownError in place of JS's error object. - local thrownError = "expected" + local thrownError = LuauPolyfill.Error.new("expected") local Throws = function() error(thrownError, 0) end - root.render(React.createElement(ErrorBoundaryWithBothMethods, nil, React.createElement(Throws))) + root.render( + React.createElement( + ErrorBoundaryWithBothMethods, + nil, + React.createElement(Throws) + ) + ) jestExpect(textContent(root)).toEqual("ErrorBoundary") - -- ROBLOX deviation: using a string 'expected' thrownError in place of JS's error object. - jestExpect(componentDidCatchError).toEqual("expected") - jestExpect(getDerivedStateFromErrorError).toEqual("expected") - end) - -- ROBLOX TODO: ReactDOMComponent not translated - xit("should catch errors from invariants in completion phase", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - - root.render(React.createElement( + jestExpect(componentDidCatchError).toEqual(thrownError) + jestExpect(getDerivedStateFromErrorError).toEqual(thrownError) + end + ) + -- ROBLOX TODO: ReactDOMComponent not translated + xit("should catch errors from invariants in completion phase", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + root.render( + React.createElement( ErrorBoundary, nil, React.createElement("input", nil, React.createElement("div")) - )) - jestExpect(textContent(root)).toContain("Caught an error: input is a void element tag") - end) - it("should catch errors from errors in the throw phase from boundaries", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - local thrownError = "original error" - local Throws = function() - error(thrownError, 0) - end - local EvilErrorBoundary = React.Component:extend("EvilErrorBoundary") + ) + ) + jestExpect(textContent(root)).toContain( + "Caught an error: input is a void element tag" + ) + end) + it("should catch errors from errors in the throw phase from boundaries", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + local thrownError = "original error" + local Throws = function() + error(thrownError, 0) + end + local EvilErrorBoundary = React.Component:extend("EvilErrorBoundary") - function EvilErrorBoundary:componentDidCatch() - error("gotta catch em all", 0) - end + function EvilErrorBoundary:componentDidCatch() + error("gotta catch em all", 0) + end - function EvilErrorBoundary:render() - return self.props.children - end + function EvilErrorBoundary:render() + return self.props.children + end - root.render(React.createElement( + root.render( + React.createElement( ErrorBoundary, nil, React.createElement(EvilErrorBoundary, nil, React.createElement(Throws)) - )) - jestExpect(textContent(root)).toContain("Caught an error: gotta catch em all.") - end) - -- ROBLOX TODO: when focused this test passes, however it causes 'runAllTimers flushes all scheduled callbacks' and - -- 'executes callbacks in order of priority' tests to fail in SchedulerNoDOM but only in __DEV__ mode - local skipIfDev = (function() - if _G.__DEV__ then - return itSKIP - end - return it - end)() - skipIfDev("should protect errors from errors in the stack generation", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() - local evilError = setmetatable({ - message = "gotta catch em all", - }, { - __index = function(mytable, key) - -- ROBLOX TODO: when we convert to LuauPolyfill errors, ensure this stack error is handled correctly - if key == "stack" then - error("gotta catch em all") - else - return mytable - end - end, - }) - - -- ROBLOX deviation: uses Class component instead of functional component so we can set displayName - local Throws = React.Component:extend("Throws") - function Throws:render() - error(evilError) - end + ) + ) + jestExpect(textContent(root)).toContain("Caught an error: gotta catch em all.") + end) + -- ROBLOX TODO: when focused this test passes, however it causes 'runAllTimers flushes all scheduled callbacks' and + -- 'executes callbacks in order of priority' tests to fail in SchedulerNoDOM but only in __DEV__ mode + local skipIfDev = (function() + if ReactGlobals.__DEV__ then + return it.skip + end + return it + end)() + skipIfDev("should protect errors from errors in the stack generation", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + local evilError = setmetatable({ + message = "gotta catch em all", + }, { + __index = function(mytable, key) + -- ROBLOX TODO: when we convert to LuauPolyfill errors, ensure this stack error is handled correctly + if key == "stack" then + error("gotta catch em all") + else + return mytable + end + end, + }) - Throws = setmetatable(Throws, { - __index = function(mytable, key) - if key == "displayName" then - error("gotta catch em all", 0) - else - return Throws - end - end, - }) + -- ROBLOX deviation: uses Class component instead of functional component so we can set displayName + local Throws = React.Component:extend("Throws") + function Throws:render() + error(evilError) + end - local function Wrapper() - return React.createElement(Throws) - end + Throws = setmetatable(Throws, { + __index = function(mytable, key) + if key == "displayName" then + error("gotta catch em all", 0) + else + return Throws + end + end, + }) - root.render(React.createElement(ErrorBoundary, nil, React.createElement(Wrapper))) - -- ROBLOX deviation: using textContent helper in place of upstream .textContent() - jestExpect(textContent(root)).toContain("Caught an error: gotta catch em all.") - end) - -- @gate skipUnmountedBoundaries - it("catches errors thrown in componentWillUnmount", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + local function Wrapper() + return React.createElement(Throws :: any) + end - local Component = React.Component:extend("Component") - function Component:render() - local id = self.props.id - Scheduler.unstable_yieldValue("Component render " .. id) - return id - end + root.render(React.createElement(ErrorBoundary, nil, React.createElement(Wrapper))) + -- ROBLOX deviation: using textContent helper in place of upstream .textContent() + jestExpect(textContent(root)).toContain("Caught an error: gotta catch em all.") + end) + -- @gate skipUnmountedBoundaries + it("catches errors thrown in componentWillUnmount", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() + + local Component = React.Component:extend("Component") + function Component:render() + local id = self.props.id + Scheduler.unstable_yieldValue("Component render " .. id) + return id + end - local LocalErrorBoundary = React.Component:extend("LocalErrorBoundary") + local LocalErrorBoundary = React.Component:extend("LocalErrorBoundary") - function LocalErrorBoundary:init() - self.state = {} - end + function LocalErrorBoundary:init() + self.state = {} + end - function LocalErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") - return { error = error_ } - end - function LocalErrorBoundary:render() - local children, id, fallbackID = self.props.children, self.props.id, self.props.fallbackID - local error_ = self.state.error + function LocalErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") + return { error = error_ } + end + function LocalErrorBoundary:render() + local children, id, fallbackID = + self.props.children, self.props.id, self.props.fallbackID + local error_ = self.state.error - if error_ then - Scheduler.unstable_yieldValue(("%s render error"):format(id)) + if error_ then + Scheduler.unstable_yieldValue(string.format("%s render error", id)) - return React.createElement(Component, { id = fallbackID }) - end + return React.createElement(Component, { id = fallbackID }) + end - Scheduler.unstable_yieldValue(("%s render success"):format(id)) + Scheduler.unstable_yieldValue(string.format("%s render success", id)) - return children or nil - end + return children or nil + end - local LocalBrokenComponentWillUnmount = React.Component:extend("LocalBrokenComponentWillUnmount") + local LocalBrokenComponentWillUnmount = + React.Component:extend("LocalBrokenComponentWillUnmount") - function LocalBrokenComponentWillUnmount:componentWillUnmount() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount componentWillUnmount") - error("Expected") - end - function LocalBrokenComponentWillUnmount:render() - Scheduler.unstable_yieldValue("BrokenComponentWillUnmount render") - return "broken" - end + function LocalBrokenComponentWillUnmount:componentWillUnmount() + Scheduler.unstable_yieldValue( + "BrokenComponentWillUnmount componentWillUnmount" + ) + error("Expected") + end + function LocalBrokenComponentWillUnmount:render() + Scheduler.unstable_yieldValue("BrokenComponentWillUnmount render") + return "broken" + end - root.render(React.createElement( - LocalErrorBoundary, - { - id = "OuterBoundary", - fallbackID = "OuterFallback", - }, - React.createElement(Component, { - id = "sibling", - }), - React.createElement(LocalErrorBoundary, { - id = "InnerBoundary", - fallbackID = "InnerFallback", - }, React.createElement(LocalBrokenComponentWillUnmount)) - )) - jestExpect(root.getChildren()[1].text).toEqual("sibling") - jestExpect(root.getChildren()[2].text).toEqual("broken") - jestExpect(Scheduler).toHaveYielded({ - "OuterBoundary render success", - "Component render sibling", - "InnerBoundary render success", - "BrokenComponentWillUnmount render", - }) - root.render(React.createElement(LocalErrorBoundary, { + root.render(React.createElement( + LocalErrorBoundary, + { id = "OuterBoundary", fallbackID = "OuterFallback", - }, React.createElement( - Component, - { - id = "sibling", - } - ))) - -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. - jestExpect(textContent(root)).toEqual("OuterFallback") - jestExpect(textContent(root)).toEqual("OuterFallback") - jestExpect(Scheduler).toHaveYielded({ - "OuterBoundary render success", - "Component render sibling", - "BrokenComponentWillUnmount componentWillUnmount", - "ErrorBoundary static getDerivedStateFromError", - "OuterBoundary render error", - "Component render OuterFallback", - }) - end) - -- @gate skipUnmountedBoundaries - it("catches errors thrown while detaching refs", function() - -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM - local root = ReactNoop.createLegacyRoot() + }, + React.createElement(Component, { + id = "sibling", + }), + React.createElement(LocalErrorBoundary, { + id = "InnerBoundary", + fallbackID = "InnerFallback", + }, React.createElement(LocalBrokenComponentWillUnmount)) + )) + jestExpect(root.getChildren()[1].text).toEqual("sibling") + jestExpect(root.getChildren()[2].text).toEqual("broken") + jestExpect(Scheduler).toHaveYielded({ + "OuterBoundary render success", + "Component render sibling", + "InnerBoundary render success", + "BrokenComponentWillUnmount render", + }) + root.render(React.createElement( + LocalErrorBoundary, + { + id = "OuterBoundary", + fallbackID = "OuterFallback", + }, + React.createElement(Component, { + id = "sibling", + }) + )) + -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. + jestExpect(textContent(root)).toEqual("OuterFallback") + jestExpect(textContent(root)).toEqual("OuterFallback") + jestExpect(Scheduler).toHaveYielded({ + "OuterBoundary render success", + "Component render sibling", + "BrokenComponentWillUnmount componentWillUnmount", + "ErrorBoundary static getDerivedStateFromError", + "OuterBoundary render error", + "Component render OuterFallback", + }) + end) + -- @gate skipUnmountedBoundaries + it("catches errors thrown while detaching refs", function() + -- ROBLOX deviation: using legacy root of Noop renderer instead of ReactDOM + local root = ReactNoop.createLegacyRoot() - local Component = React.Component:extend("Component") + local Component = React.Component:extend("Component") - function Component:render() - local id = self.props.id + function Component:render() + local id = self.props.id - Scheduler.unstable_yieldValue("Component render " .. id) + Scheduler.unstable_yieldValue("Component render " .. id) - return id - end + return id + end - local LocalErrorBoundary = React.Component:extend("LocalErrorBoundary") + local LocalErrorBoundary = React.Component:extend("LocalErrorBoundary") - function LocalErrorBoundary:init() - self.state = {} - end - function LocalErrorBoundary.getDerivedStateFromError(error_) - Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") - return { error = error_ } - end - function LocalErrorBoundary:render() - local children, id, fallbackID = self.props.children, self.props.id, self.props.fallbackID - local error_ = self.state.error + function LocalErrorBoundary:init() + self.state = {} + end + function LocalErrorBoundary.getDerivedStateFromError(error_) + Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") + return { error = error_ } + end + function LocalErrorBoundary:render() + local children, id, fallbackID = + self.props.children, self.props.id, self.props.fallbackID + local error_ = self.state.error - if error_ then - Scheduler.unstable_yieldValue(("%s render error"):format(id)) + if error_ then + Scheduler.unstable_yieldValue(string.format("%s render error", id)) - return React.createElement(Component, { id = fallbackID }) - end + return React.createElement(Component, { id = fallbackID }) + end - Scheduler.unstable_yieldValue(("%s render success"):format(id)) + Scheduler.unstable_yieldValue(string.format("%s render success", id)) - return children or nil - end + return children or nil + end - local LocalBrokenCallbackRef = React.Component:extend("LocalBrokenCallbackRef") + local LocalBrokenCallbackRef = React.Component:extend("LocalBrokenCallbackRef") - function LocalBrokenCallbackRef._ref(ref) - Scheduler.unstable_yieldValue("LocalBrokenCallbackRef ref " .. tostring(not not ref)) - if ref == nil then - error("Expected") - end + function LocalBrokenCallbackRef._ref(ref) + Scheduler.unstable_yieldValue( + "LocalBrokenCallbackRef ref " .. tostring(not not ref) + ) + if ref == nil then + error("Expected") end - function LocalBrokenCallbackRef:render() - Scheduler.unstable_yieldValue("LocalBrokenCallbackRef render") + end + function LocalBrokenCallbackRef:render() + Scheduler.unstable_yieldValue("LocalBrokenCallbackRef render") - return React.createElement("div", { - ref = self._ref, - }, "ref") - end + return React.createElement("div", { + ref = self._ref, + }, "ref") + end - root.render(React.createElement( - LocalErrorBoundary, - { - id = "OuterBoundary", - fallbackID = "OuterFallback", - }, - React.createElement(Component, { - id = "sibling", - }), - React.createElement(LocalErrorBoundary, { - id = "InnerBoundary", - fallbackID = "InnerFallback", - }, React.createElement(LocalBrokenCallbackRef)) - )) - jestExpect(root.getChildren()[1].text).toEqual("sibling") - jestExpect(root.getChildren()[2].text).toEqual("ref") - jestExpect(Scheduler).toHaveYielded({ - "OuterBoundary render success", - "Component render sibling", - "InnerBoundary render success", - "LocalBrokenCallbackRef render", - "LocalBrokenCallbackRef ref true", - }) - root.render(React.createElement(LocalErrorBoundary, { + root.render(React.createElement( + LocalErrorBoundary, + { id = "OuterBoundary", fallbackID = "OuterFallback", - }, React.createElement( - Component, - { - id = "sibling", - } - ))) - -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. - local rootChildren = root.getChildren() - jestExpect(rootChildren[1].text).toEqual("OuterFallback") - jestExpect(rootChildren[#rootChildren].text).toEqual("OuterFallback") - jestExpect(Scheduler).toHaveYielded({ - "OuterBoundary render success", - "Component render sibling", - "LocalBrokenCallbackRef ref false", - "ErrorBoundary static getDerivedStateFromError", - "OuterBoundary render error", - "Component render OuterFallback", - }) - end) + }, + React.createElement(Component, { + id = "sibling", + }), + React.createElement(LocalErrorBoundary, { + id = "InnerBoundary", + fallbackID = "InnerFallback", + }, React.createElement(LocalBrokenCallbackRef)) + )) + jestExpect(root.getChildren()[1].text).toEqual("sibling") + jestExpect(root.getChildren()[2].text).toEqual("ref") + jestExpect(Scheduler).toHaveYielded({ + "OuterBoundary render success", + "Component render sibling", + "InnerBoundary render success", + "LocalBrokenCallbackRef render", + "LocalBrokenCallbackRef ref true", + }) + root.render(React.createElement( + LocalErrorBoundary, + { + id = "OuterBoundary", + fallbackID = "OuterFallback", + }, + React.createElement(Component, { + id = "sibling", + }) + )) + -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. + local rootChildren = root.getChildren() + jestExpect(rootChildren[1].text).toEqual("OuterFallback") + jestExpect(rootChildren[#rootChildren].text).toEqual("OuterFallback") + jestExpect(Scheduler).toHaveYielded({ + "OuterBoundary render success", + "Component render sibling", + "LocalBrokenCallbackRef ref false", + "ErrorBoundary static getDerivedStateFromError", + "OuterBoundary render error", + "Component render OuterFallback", + }) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberComponentStack.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberComponentStack.roblox.spec.lua new file mode 100644 index 00000000..c2b82f77 --- /dev/null +++ b/modules/react-reconciler/src/__tests__/ReactFiberComponentStack.roblox.spec.lua @@ -0,0 +1,70 @@ +local Packages = script.Parent.Parent.Parent +local LuauPolyfill = require(Packages.LuauPolyfill) +local Error = LuauPolyfill.Error +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it +local jest = JestGlobals.jest +local ReactInternalTypes = require(script.Parent.Parent.ReactInternalTypes) +type Fiber = ReactInternalTypes.Fiber + +local ReactFiberComponentStack + +describe("ReactFiberComponentStack", function() + beforeEach(function() + jest.resetModules() + ReactFiberComponentStack = require(script.Parent.Parent.ReactFiberComponentStack) + end) + + it("given a nil fiber then it gives correct error message", function() + local message = + ReactFiberComponentStack.getStackByFiberInDevAndProd((nil :: any) :: Fiber) + jestExpect(message).toContain("attempt to index nil") + end) + + it("given a fiber that throws Error then it gives correct error message", function() + local throwingFiber = {} + setmetatable(throwingFiber, { + __index = function(t, k) + if k == "tag" then + error(Error.new("this was an error object in a spec file")) + end + return nil + end, + }) + + local message = ReactFiberComponentStack.getStackByFiberInDevAndProd( + (throwingFiber :: any) :: Fiber + ) + jestExpect(message).toContain("this was an error object in a spec file") + end) + + it( + "given a fiber that throws a non-Error table then it gives correct error message", + function() + local customErrorTable = {} + setmetatable(customErrorTable, { + __tostring = function(t, k) + return "this was a custom __tostring" + end, + }) + + local throwingFiber = {} + setmetatable(throwingFiber, { + __index = function(t, k) + if k == "tag" then + error(customErrorTable) + end + return nil + end, + }) + + local message = ReactFiberComponentStack.getStackByFiberInDevAndProd( + (throwingFiber :: any) :: Fiber + ) + jestExpect(message).toContain("this was a custom __tostring") + end + ) +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberContext-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberContext-internal.spec.lua index cf76c016..cc8c8d51 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberContext-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberContext-internal.spec.lua @@ -9,47 +9,49 @@ * @jest-environment node ]] -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it - local ReactFiberContext - local ReactFiber - local ReactRootTags - local ReactFeatureFlags +local ReactFiberContext +local ReactFiber +local ReactRootTags +local ReactFeatureFlags - beforeEach(function() - RobloxJest.resetModules() +beforeEach(function() + jest.resetModules() - ReactFiberContext = require(script.Parent.Parent["ReactFiberContext.new"]) - ReactFiber = require(script.Parent.Parent["ReactFiber.new"]) - ReactRootTags = require(script.Parent.Parent.ReactRootTags) - ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags - ReactFeatureFlags.disableLegacyContext = false - end) + ReactFiberContext = require(script.Parent.Parent["ReactFiberContext.new"]) + ReactFiber = require(script.Parent.Parent["ReactFiber.new"]) + ReactRootTags = require(script.Parent.Parent.ReactRootTags) + ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + ReactFeatureFlags.disableLegacyContext = false +end) - describe("Context stack", function() - it("should throw when pushing to top level of non-empty stack", function() - local fiber = ReactFiber.createHostRootFiber(ReactRootTags.BlockingRoot) - local context = { - foo = 1, +describe("Context stack", function() + it("should throw when pushing to top level of non-empty stack", function() + local fiber = ReactFiber.createHostRootFiber(ReactRootTags.BlockingRoot) + local context = { + foo = 1, + } + -- The first call here is a valid use of pushTopLevelContextObject + ReactFiberContext.pushTopLevelContextObject(fiber, context, true) + jestExpect(function() + local moreContext = { + bar = 2, } - -- The first call here is a valid use of pushTopLevelContextObject - ReactFiberContext.pushTopLevelContextObject(fiber, context, true) - jestExpect(function() - local moreContext = { - bar = 2, - } - ReactFiberContext.pushTopLevelContextObject(fiber, moreContext, true) - end).toThrow("Unexpected context found on stack.") - end) + ReactFiberContext.pushTopLevelContextObject(fiber, moreContext, true) + end).toThrow("Unexpected context found on stack.") + end) - it("should throw if when invalidating a provider that isn't initialized", function() - local fiber = ReactFiber.createHostRootFiber(ReactRootTags.BlockingRoot) - jestExpect(function() - ReactFiberContext.invalidateContextProvider(fiber, nil, true) - end).toThrow("Expected to have an instance by this point.") - end) + it("should throw if when invalidating a provider that isn't initialized", function() + local fiber = ReactFiber.createHostRootFiber(ReactRootTags.BlockingRoot) + jestExpect(function() + ReactFiberContext.invalidateContextProvider(fiber, nil, true) + end).toThrow("Expected to have an instance by this point.") end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberDevToolsHook-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberDevToolsHook-internal.spec.lua index 8718efdc..3272a3a3 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberDevToolsHook-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberDevToolsHook-internal.spec.lua @@ -9,45 +9,92 @@ * @jest-environment node ]] -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local jest = JestGlobals.jest +local describe = JestGlobals.describe +local beforeAll = JestGlobals.beforeAll +local afterAll = JestGlobals.afterAll +local it = JestGlobals.it - local ReactFiberDevToolsHook +local Map = require(Packages.LuauPolyfill).Map - beforeEach(function() - RobloxJest.resetModules() +local ReactFiberDevToolsHook, ReactDevtoolsShared - ReactFiberDevToolsHook = require(script.Parent.Parent["ReactFiberDevToolsHook.new"]) +beforeEach(function() + jest.resetModules() + + ReactFiberDevToolsHook = require(script.Parent.Parent["ReactFiberDevToolsHook.new"]) + ReactDevtoolsShared = require(Packages.Dev.ReactDevtoolsShared) +end) + +describe("DevTools hook detection", function() + local originalDevtoolsState + beforeAll(function() + originalDevtoolsState = ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ end) - describe("DevTools hook detection", function() - local originalDevtoolsState - beforeAll(function() - originalDevtoolsState = _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ - end) - - afterAll(function() - _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ = originalDevtoolsState - end) - - if _G.__DEV__ then - it("should log an error when fibers aren't supported", function() - _G.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { - isDisabled = false, - supportsHooks = false, - } - jestExpect(function() - local result = ReactFiberDevToolsHook.injectInternals({}) - -- expect logs to include error - jestExpect(result).toBe(true) - -- ROBLOX deviation: assert the console error, upstream doesn't - end).toErrorDev( - "The installed version of React DevTools is too old", - {withoutStack = true} - ) - end) - end + afterAll(function() + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = originalDevtoolsState + end) + + local itIfDev = if ReactGlobals.__DEV__ then it else it.skip :: any + itIfDev("should log an error when fibers aren't supported", function() + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + isDisabled = false, + supportsHooks = false, + } + jestExpect(function() + local result = ReactFiberDevToolsHook.injectInternals({}) + -- expect logs to include error + jestExpect(result).toBe(true) + -- ROBLOX deviation: assert the console error, upstream doesn't + end).toErrorDev( + "The installed version of React DevTools is too old", + { withoutStack = true } + ) + end) + + -- ROBLOX deviation START: verify that renderers are attached correctly + it("attaches renderers", function() + local renderer123 = { + findFiberByHostInstance = function() end, + } + local renderer456 = { + findFiberByHostInstance = function() end, + } + local hook = { + renderers = Map.new({ + { 123, renderer123 }, + { 456, renderer456 }, + }), + rendererInterfaces = Map.new(), + emit = jest.fn(), + sub = jest.fn(), + } + local agent = { + addListener = jest.fn(), + } + ReactGlobals.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook + + ReactDevtoolsShared.backend.initBackend(hook, agent, {}) + + jestExpect(hook.emit).toHaveBeenCalledTimes(3) + + jestExpect(hook.emit).toHaveBeenNthCalledWith(1, "renderer-attached", { + id = 123, + renderer = renderer123, + rendererInterface = jestExpect.anything(), + }) + jestExpect(hook.emit).toHaveBeenNthCalledWith(2, "renderer-attached", { + id = 456, + renderer = renderer456, + rendererInterface = jestExpect.anything(), + }) + jestExpect(hook.emit).toHaveBeenNthCalledWith(3, "react-devtools", agent) end) -end + -- ROBLOX deviation END +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberHostContext-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberHostContext-internal.spec.lua index a7b8f8e6..371e27bf 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberHostContext-internal.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberHostContext-internal.spec.lua @@ -9,128 +9,127 @@ * @jest-environment node ]] -return function() - describe('ReactFiberHostContext', function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it - local ReactFiberReconciler - local ConcurrentRoot - local React +describe("ReactFiberHostContext", function() + local ReactFiberReconciler + local ConcurrentRoot + local React - beforeEach(function() - RobloxJest.resetModules() - React = require(Packages.React) - ReactFiberReconciler = require(script.Parent.Parent) - ConcurrentRoot = require(script.Parent.Parent.ReactRootTags) - end) + beforeEach(function() + jest.resetModules() + React = require(Packages.React) + ReactFiberReconciler = require(script.Parent.Parent) + ConcurrentRoot = require(script.Parent.Parent.ReactRootTags) + end) - it('works with nil host context', function() - local creates = 0 - local Renderer = ReactFiberReconciler({ - prepareForCommit = function() - return nil - end, - resetAfterCommit = function() end, - getRootHostContext = function() - return nil - end, - getChildHostContext = function() - return nil - end, - shouldSetTextContent = function() - return false - end, - createInstance = function() - creates += 1 - end, - finalizeInitialChildren = function() - return nil - end, - appendInitialChild = function() - return nil - end, - now = function() - return 0 - end, - appendChildToContainer = function() - return nil - end, - clearContainer= function() end, - supportsMutation= true - }) + it("works with nil host context", function() + local creates = 0 + local Renderer = ReactFiberReconciler({ + prepareForCommit = function() + return nil + end, + resetAfterCommit = function() end, + getRootHostContext = function() + return nil + end, + getChildHostContext = function() + return nil + end, + shouldSetTextContent = function() + return false + end, + createInstance = function() + creates += 1 + end, + finalizeInitialChildren = function() + return nil + end, + appendInitialChild = function() + return nil + end, + now = function() + return 0 + end, + appendChildToContainer = function() + return nil + end, + clearContainer = function() end, + supportsMutation = true, + }) - local container = Renderer.createContainer( - --[[ root: ]] nil, - ConcurrentRoot, - false, - nil - ) - Renderer.updateContainer( - React.createElement("a", nil, - React.createElement("b") - ), - container, - --[[ parentComponent: ]] nil, - --[[ callback: ]] nil - ) + local container = Renderer.createContainer( + --[[ root: ]] + nil, + ConcurrentRoot, + false, + nil + ) + Renderer.updateContainer( + React.createElement("a", nil, React.createElement("b")), + container, + --[[ parentComponent: ]] + nil, + --[[ callback: ]] + nil + ) - jestExpect(creates).toBe(2) - end) + jestExpect(creates).toBe(2) + end) - it('should send the context to prepareForCommit and resetAfterCommit', function() - local rootContext = {} - local Renderer = ReactFiberReconciler({ - prepareForCommit= function(hostContext) - jestExpect(hostContext).toBe(rootContext) - return nil - end, - resetAfterCommit= function(hostContext) - jestExpect(hostContext).toBe(rootContext) - end, - getRootHostContext= function() - return nil - end, - getChildHostContext= function() - return nil - end, - shouldSetTextContent= function() - return false - end, - createInstance= function() - return nil - end, - finalizeInitialChildren= function() - return nil - end, - appendInitialChild= function() - return nil - end, - now= function() - return 0 - end, - appendChildToContainer= function() - return nil - end, - clearContainer= function() end, - supportsMutation= true - }) + it("should send the context to prepareForCommit and resetAfterCommit", function() + local rootContext = {} + local Renderer = ReactFiberReconciler({ + prepareForCommit = function(hostContext) + jestExpect(hostContext).toBe(rootContext) + return nil + end, + resetAfterCommit = function(hostContext) + jestExpect(hostContext).toBe(rootContext) + end, + getRootHostContext = function() + return nil + end, + getChildHostContext = function() + return nil + end, + shouldSetTextContent = function() + return false + end, + createInstance = function() + return nil + end, + finalizeInitialChildren = function() + return nil + end, + appendInitialChild = function() + return nil + end, + now = function() + return 0 + end, + appendChildToContainer = function() + return nil + end, + clearContainer = function() end, + supportsMutation = true, + }) - local container = Renderer.createContainer( - rootContext, - ConcurrentRoot, - false, - nil - ) - Renderer.updateContainer( - React.createElement("a", nil, - React.createElement("b") - ), - container, - --[[ parentComponent= ]] nil, - --[[ callback= ]] nil - ) - end) - end) -end \ No newline at end of file + local container = + Renderer.createContainer(rootContext, ConcurrentRoot, false, nil) + Renderer.updateContainer( + React.createElement("a", nil, React.createElement("b")), + container, + --[[ parentComponent= ]] + nil, + --[[ callback= ]] + nil + ) + end) +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberLane.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberLane.roblox.spec.lua index 2b4b743a..218cc00b 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberLane.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberLane.roblox.spec.lua @@ -9,375 +9,380 @@ * @jest-environment node ]] -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local LuauPolyfill = require(Packages.LuauPolyfill) - local Object = LuauPolyfill.Object - - local RobloxJest = require(Packages.Dev.RobloxJest) - - local ReactFiberSchedulerPriorities = require(script.Parent.Parent["ReactFiberSchedulerPriorities.roblox"]) - local ImmediatePriority = ReactFiberSchedulerPriorities.ImmediatePriority - local NormalPriority = ReactFiberSchedulerPriorities.NormalPriority - local NoPriority = ReactFiberSchedulerPriorities.NoPriority - local UserBlockingPriority = ReactFiberSchedulerPriorities.UserBlockingPriority - - local ReactFiberLane - - beforeEach(function() - RobloxJest.resetModules() - ReactFiberLane = require(script.Parent.Parent.ReactFiberLane) - end) - - describe("lanePriorityToSchedulerPriority", function() - local INVALID_PRIORITY_LANE = 999 - - local expectedPriorities = { - SyncLanePriority = ImmediatePriority, - SyncBatchedLanePriority = ImmediatePriority, - InputDiscreteLanePriority = UserBlockingPriority, - InputContinuousLanePriority = UserBlockingPriority, - DefaultLanePriority = NormalPriority, - TransitionPriority = NormalPriority, - NoLanePriority = NoPriority, - } - - for priorityName, priority in pairs(expectedPriorities) do - it(("returns the expected priority (%d) for lane %s"):format(priority, priorityName), function() +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object + +local ReactFiberSchedulerPriorities = + require(script.Parent.Parent["ReactFiberSchedulerPriorities.roblox"]) +local ImmediatePriority = ReactFiberSchedulerPriorities.ImmediatePriority +local NormalPriority = ReactFiberSchedulerPriorities.NormalPriority +local NoPriority = ReactFiberSchedulerPriorities.NoPriority +local UserBlockingPriority = ReactFiberSchedulerPriorities.UserBlockingPriority + +local ReactFiberLane + +beforeEach(function() + jest.resetModules() + ReactFiberLane = require(script.Parent.Parent.ReactFiberLane) +end) + +describe("lanePriorityToSchedulerPriority", function() + local INVALID_PRIORITY_LANE = 999 + + local expectedPriorities = { + SyncLanePriority = ImmediatePriority, + SyncBatchedLanePriority = ImmediatePriority, + InputDiscreteLanePriority = UserBlockingPriority, + InputContinuousLanePriority = UserBlockingPriority, + DefaultLanePriority = NormalPriority, + TransitionPriority = NormalPriority, + NoLanePriority = NoPriority, + } + -- ROBLOX FIXME Luau: need to fix CLI-56768 to remove any casts + for priorityName, priority in expectedPriorities :: any do + it( + string.format( + "returns the expected priority (%d) for lane %s", + priority, + priorityName + ), + function() local lane = ReactFiberLane[priorityName] jestExpect(lane).toBeDefined() jestExpect(ReactFiberLane.lanePriorityToSchedulerPriority(lane)).toBe( priority ) - end) - end - - it("throws when giving an invalid lane priority", function() - jestExpect(function() - ReactFiberLane.lanePriorityToSchedulerPriority(INVALID_PRIORITY_LANE) - end).toThrow( - ("Invalid update priority: %s. This is a bug in React"):format(tostring(INVALID_PRIORITY_LANE)) + end + ) + end + + it("throws when giving an invalid lane priority", function() + jestExpect(function() + ReactFiberLane.lanePriorityToSchedulerPriority(INVALID_PRIORITY_LANE) + end).toThrow( + string.format( + "Invalid update priority: %s. This is a bug in React", + tostring(INVALID_PRIORITY_LANE) ) - end) + ) end) +end) - describe("getHighestPriorityPendingLanes", function() - it("returns the Sync lane and sets the sync lane priority", function() - local root = { pendingLanes = ReactFiberLane.SyncLane } - local lane = ReactFiberLane.getHighestPriorityPendingLanes(root) +describe("getHighestPriorityPendingLanes", function() + it("returns the Sync lane and sets the sync lane priority", function() + local root = { pendingLanes = ReactFiberLane.SyncLane } + local lane = ReactFiberLane.getHighestPriorityPendingLanes(root) - jestExpect(lane).toBe(ReactFiberLane.SyncLane) - local nextLanesPriority = ReactFiberLane.returnNextLanesPriority() - jestExpect(nextLanesPriority).toBe(ReactFiberLane.SyncLanePriority) - end) + jestExpect(lane).toBe(ReactFiberLane.SyncLane) + local nextLanesPriority = ReactFiberLane.returnNextLanesPriority() + jestExpect(nextLanesPriority).toBe(ReactFiberLane.SyncLanePriority) end) +end) - describe('getNextLanes', function() - describe('given no pending lanes', function() - local root +describe("getNextLanes", function() + describe("given no pending lanes", function() + local root - beforeEach(function() - root = {pendingLanes = ReactFiberLane.NoLanes} - end) + beforeEach(function() + root = { pendingLanes = ReactFiberLane.NoLanes } + end) - it('returns no lanes', function() - jestExpect( - ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) - ).toBe(ReactFiberLane.NoLanes) - end) + it("returns no lanes", function() + jestExpect(ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes)).toBe( + ReactFiberLane.NoLanes + ) + end) - it('sets the highest lane priority to no lane', function() - ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) - jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( - ReactFiberLane.NoLanePriority - ) - end) + it("sets the highest lane priority to no lane", function() + ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) + jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( + ReactFiberLane.NoLanePriority + ) end) - describe('given expired lanes', function() - local root - - beforeEach(function() - root = { - pendingLanes = ReactFiberLane.SyncLane, - expiredLanes = ReactFiberLane.SyncLane, - suspendedLanes = ReactFiberLane.NoLanes, - pingedLanes = ReactFiberLane.NoLanes, - entangledLanes = ReactFiberLane.NoLanes, - } - end) + end) + describe("given expired lanes", function() + local root + + beforeEach(function() + root = { + pendingLanes = ReactFiberLane.SyncLane, + expiredLanes = ReactFiberLane.SyncLane, + suspendedLanes = ReactFiberLane.NoLanes, + pingedLanes = ReactFiberLane.NoLanes, + entangledLanes = ReactFiberLane.NoLanes, + } + end) + + describe("no entangled lanes", function() + describe("pending lanes with higher priority than expired lanes", function() + beforeEach(function() + root = Object.assign(root, { + pendingLanes = ReactFiberLane.mergeLanes( + ReactFiberLane.SomeRetryLane, + ReactFiberLane.DefaultHydrationLane + ), + expiredLanes = ReactFiberLane.DefaultLanes, + }) + end) - describe('no entangled lanes', function() - describe('pending lanes with higher priority than expired lanes', function() - beforeEach(function() - root = Object.assign(root, { - pendingLanes = ReactFiberLane.mergeLanes( - ReactFiberLane.SomeRetryLane, - ReactFiberLane.DefaultHydrationLane - ), - expiredLanes = ReactFiberLane.DefaultLanes, - }) - end) - - it('returns the lanes above or equal to the priority of the expired lanes', function() - local result = ReactFiberLane.getNextLanes( - root, - ReactFiberLane.NoLanes - ) + it( + "returns the lanes above or equal to the priority of the expired lanes", + function() + local result = + ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) jestExpect(result).toBe(ReactFiberLane.DefaultHydrationLane) - end) + end + ) - it('sets the highest lane priority to sync lane', function() - ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) - jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe(ReactFiberLane.SyncLanePriority) - end) + it("sets the highest lane priority to sync lane", function() + ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) + jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( + ReactFiberLane.SyncLanePriority + ) + end) + end) + + describe("pending lanes with lower priority than expired lanes", function() + beforeEach(function() + root = Object.assign(root, { + pendingLanes = ReactFiberLane.SyncBatchedLane, + expiredLanes = ReactFiberLane.SyncLane, + }) end) - describe('pending lanes with lower priority than expired lanes', function() - beforeEach(function() - root = Object.assign(root, { - pendingLanes = ReactFiberLane.SyncBatchedLane, - expiredLanes = ReactFiberLane.SyncLane, - }) - end) - - it('returns no lanes', function() - local result = ReactFiberLane.getNextLanes( - root, - ReactFiberLane.NoLanes - ) - jestExpect(result).toBe(ReactFiberLane.NoLanes) - end) - - it('sets the highest lane priority to sync lane', function() + it("returns no lanes", function() + local result = ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) - jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( - ReactFiberLane.SyncLanePriority - ) - end) + jestExpect(result).toBe(ReactFiberLane.NoLanes) end) - end) - it('sets the highest lane priority to sync lane', function() - local result = ReactFiberLane.getNextLanes( - root, - ReactFiberLane.NoLanes - ) - jestExpect(result).toBe(ReactFiberLane.SyncLane) - jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( - ReactFiberLane.SyncLanePriority - ) + it("sets the highest lane priority to sync lane", function() + ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) + jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( + ReactFiberLane.SyncLanePriority + ) + end) end) + end) - it('sets the highest lane priority to sync lane 2', function() - root = Object.assign(root, { - expiredLanes = ReactFiberLane.SyncBatchedLane, - }) - local result = ReactFiberLane.getNextLanes( - root, - ReactFiberLane.NoLanes - ) + it("sets the highest lane priority to sync lane", function() + local result = ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) + jestExpect(result).toBe(ReactFiberLane.SyncLane) + jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( + ReactFiberLane.SyncLanePriority + ) + end) - jestExpect(result).toBe(ReactFiberLane.SyncLane) - jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( - ReactFiberLane.SyncLanePriority - ) - end) + it("sets the highest lane priority to sync lane 2", function() + root = Object.assign(root, { + expiredLanes = ReactFiberLane.SyncBatchedLane, + }) + local result = ReactFiberLane.getNextLanes(root, ReactFiberLane.NoLanes) + + jestExpect(result).toBe(ReactFiberLane.SyncLane) + jestExpect(ReactFiberLane.returnNextLanesPriority()).toBe( + ReactFiberLane.SyncLanePriority + ) end) end) +end) + +describe("includesNonIdleWork", function() + local nonIdleLaneNames = { + "SyncLane", + "SyncBatchedLane", + "InputDiscreteHydrationLane", + "DefaultHydrationLane", + "SomeRetryLane", + "SelectiveHydrationLane", + } + + for _, laneName in nonIdleLaneNames do + it(string.format("is true for %s", laneName), function() + local lane = ReactFiberLane[laneName] + + jestExpect(lane).toBeDefined() + jestExpect(ReactFiberLane.includesNonIdleWork(lane)).toBe(true) + end) + end - describe("includesNonIdleWork", function() - local nonIdleLaneNames = { - "SyncLane", - "SyncBatchedLane", - "InputDiscreteHydrationLane", - "DefaultHydrationLane", - "SomeRetryLane", - "SelectiveHydrationLane", - } + local idleLaneNames = { + "NoLane", + "OffscreenLane", + "IdleHydrationLane", + } - for _, laneName in ipairs(nonIdleLaneNames) do - it(("is true for %s"):format(laneName), function() - local lane = ReactFiberLane[laneName] + for _, laneName in idleLaneNames do + it(string.format("is false for %s", laneName), function() + local lane = ReactFiberLane[laneName] - jestExpect(lane).toBeDefined() - jestExpect(ReactFiberLane.includesNonIdleWork(lane)).toBe(true) - end) - end - - local idleLaneNames = { - "NoLane", - "OffscreenLane", - "IdleHydrationLane", - } + jestExpect(lane).toBeDefined() + jestExpect(ReactFiberLane.includesNonIdleWork(lane)).toBe(false) + end) + end +end) + +describe("includesOnlyRetries", function() + it("is true for a retry lane", function() + jestExpect(ReactFiberLane.includesOnlyRetries(ReactFiberLane.SomeRetryLane)).toBe( + true + ) + end) - for _, laneName in ipairs(idleLaneNames) do - it(("is false for %s"):format(laneName), function() - local lane = ReactFiberLane[laneName] + it("is false for the sync lane", function() + jestExpect(ReactFiberLane.includesOnlyRetries(ReactFiberLane.SyncLane)).toBe( + false + ) + end) - jestExpect(lane).toBeDefined() - jestExpect(ReactFiberLane.includesNonIdleWork(lane)).toBe(false) - end) - end + it("is false for a retry lane merged with another lane", function() + local mergedLanes = ReactFiberLane.mergeLanes( + ReactFiberLane.SyncLane, + ReactFiberLane.SomeRetryLane + ) + jestExpect(ReactFiberLane.includesOnlyRetries(mergedLanes)).toBe(false) end) +end) - describe("includesOnlyRetries", function() - it("is true for a retry lane", function() - jestExpect( - ReactFiberLane.includesOnlyRetries(ReactFiberLane.SomeRetryLane - )).toBe(true) - end) +describe("includesSomeLane", function() + it("is true given the same lane", function() + local lane = ReactFiberLane.SyncLane + jestExpect(ReactFiberLane.includesSomeLane(lane, lane)).toBe(true) + end) - it("is false for the sync lane", function() - jestExpect( - ReactFiberLane.includesOnlyRetries(ReactFiberLane.SyncLane - )).toBe(false) - end) + it("is true given lanes that includes the other", function() + local lane = ReactFiberLane.SyncLane + local mergedLanes = + ReactFiberLane.mergeLanes(lane, ReactFiberLane.DefaultHydrationLane) - it("is false for a retry lane merged with another lane", function() - local mergedLanes = ReactFiberLane.mergeLanes( - ReactFiberLane.SyncLane, - ReactFiberLane.SomeRetryLane - ) - jestExpect(ReactFiberLane.includesOnlyRetries(mergedLanes)).toBe(false) - end) + jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, lane)).toBe(true) end) - describe("includesSomeLane", function() - it("is true given the same lane", function() - local lane = ReactFiberLane.SyncLane - jestExpect(ReactFiberLane.includesSomeLane(lane, lane)).toBe(true) - end) - - it("is true given lanes that includes the other", function() - local lane = ReactFiberLane.SyncLane - local mergedLanes = ReactFiberLane.mergeLanes( - lane, + it("is false for two seperate lanes", function() + jestExpect( + ReactFiberLane.includesSomeLane( + ReactFiberLane.SyncLane, ReactFiberLane.DefaultHydrationLane ) + ).toBe(false) + end) +end) - jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, lane)).toBe(true) - end) +describe("isSubsetOfLanes", function() + it("is true given the same lane", function() + local lane = ReactFiberLane.SyncLane - it("is false for two seperate lanes", function() - jestExpect( - ReactFiberLane.includesSomeLane( - ReactFiberLane.SyncLane, - ReactFiberLane.DefaultHydrationLane - ) - ).toBe(false) - end) + jestExpect(ReactFiberLane.isSubsetOfLanes(lane, lane)).toBe(true) end) - describe("isSubsetOfLanes", function() - it("is true given the same lane", function() - local lane = ReactFiberLane.SyncLane + it("is true given lanes that includes the other", function() + local subset = ReactFiberLane.mergeLanes( + ReactFiberLane.SyncLane, + ReactFiberLane.DefaultHydrationLane + ) + local mergedLanes = + ReactFiberLane.mergeLanes(subset, ReactFiberLane.SyncBatchedLane) - jestExpect(ReactFiberLane.isSubsetOfLanes(lane, lane)).toBe(true) - end) + jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, subset)).toBe(true) + end) - it("is true given lanes that includes the other", function() - local subset = ReactFiberLane.mergeLanes( + it("is false for two seperate lanes", function() + jestExpect( + ReactFiberLane.includesSomeLane( ReactFiberLane.SyncLane, ReactFiberLane.DefaultHydrationLane ) - local mergedLanes = ReactFiberLane.mergeLanes( - subset, - ReactFiberLane.SyncBatchedLane - ) - - jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, subset)).toBe(true) - end) - - it("is false for two seperate lanes", function() - jestExpect( - ReactFiberLane.includesSomeLane( - ReactFiberLane.SyncLane, - ReactFiberLane.DefaultHydrationLane - ) - ).toBe(false) - end) + ).toBe(false) end) +end) - describe("mergeLanes", function() - it("returns a lane that includes both inputs", function() - local laneA = ReactFiberLane.SyncLane - local laneB = ReactFiberLane.DefaultHydrationLane - local mergedLanes = ReactFiberLane.mergeLanes(laneA, laneB) - - jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, laneA)).toBe(true) - jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, laneB)).toBe(true) - end) +describe("mergeLanes", function() + it("returns a lane that includes both inputs", function() + local laneA = ReactFiberLane.SyncLane + local laneB = ReactFiberLane.DefaultHydrationLane + local mergedLanes = ReactFiberLane.mergeLanes(laneA, laneB) - it("returns the same lane given two identical lanes", function() - local lane = ReactFiberLane.SyncLane - jestExpect(ReactFiberLane.mergeLanes(lane, lane)).toBe(lane) - end) + jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, laneA)).toBe(true) + jestExpect(ReactFiberLane.includesSomeLane(mergedLanes, laneB)).toBe(true) end) - describe("removeLanes", function() - it("returns the lanes without the given lane", function() - local laneA = ReactFiberLane.SyncLane - local laneB = ReactFiberLane.DefaultHydrationLane - local mergedLanes = ReactFiberLane.mergeLanes(laneA, laneB) - - jestExpect(ReactFiberLane.removeLanes(mergedLanes, laneA)).toBe(laneB) - jestExpect(ReactFiberLane.removeLanes(mergedLanes, laneB)).toBe(laneA) - end) - - it("returns the same lane when removing a lane not included", function() - local lanes = ReactFiberLane.mergeLanes( - ReactFiberLane.SyncLane, - ReactFiberLane.DefaultHydrationLane - ) - jestExpect(ReactFiberLane.removeLanes(lanes, ReactFiberLane.SyncBatchedLane)).toBe(lanes) - end) + it("returns the same lane given two identical lanes", function() + local lane = ReactFiberLane.SyncLane + jestExpect(ReactFiberLane.mergeLanes(lane, lane)).toBe(lane) end) +end) - describe("higherPriorityLane", function() - it("returns the other lane if one is NoLane", function() - local lane = ReactFiberLane.SyncLane +describe("removeLanes", function() + it("returns the lanes without the given lane", function() + local laneA = ReactFiberLane.SyncLane + local laneB = ReactFiberLane.DefaultHydrationLane + local mergedLanes = ReactFiberLane.mergeLanes(laneA, laneB) - jestExpect( - ReactFiberLane.higherPriorityLane(ReactFiberLane.NoLane, lane) - ).toBe(lane) - jestExpect( - ReactFiberLane.higherPriorityLane(lane, ReactFiberLane.NoLane) - ).toBe(lane) - end) + jestExpect(ReactFiberLane.removeLanes(mergedLanes, laneA)).toBe(laneB) + jestExpect(ReactFiberLane.removeLanes(mergedLanes, laneB)).toBe(laneA) + end) - it("returns the higher priority lane", function() - local higherLane = ReactFiberLane.SyncLane - local otherLane = ReactFiberLane.OffscreenLane - jestExpect( - ReactFiberLane.higherPriorityLane(higherLane, otherLane) - ).toBe(higherLane) - jestExpect( - ReactFiberLane.higherPriorityLane(otherLane, higherLane) - ).toBe(higherLane) - end) + it("returns the same lane when removing a lane not included", function() + local lanes = ReactFiberLane.mergeLanes( + ReactFiberLane.SyncLane, + ReactFiberLane.DefaultHydrationLane + ) + jestExpect(ReactFiberLane.removeLanes(lanes, ReactFiberLane.SyncBatchedLane)).toBe( + lanes + ) + end) +end) + +describe("higherPriorityLane", function() + it("returns the other lane if one is NoLane", function() + local lane = ReactFiberLane.SyncLane + + jestExpect(ReactFiberLane.higherPriorityLane(ReactFiberLane.NoLane, lane)).toBe( + lane + ) + jestExpect(ReactFiberLane.higherPriorityLane(lane, ReactFiberLane.NoLane)).toBe( + lane + ) end) - describe("higherLanePriority", function() - it("returns the other priority if one is NoLanePriority", function() - local priority = ReactFiberLane.DefaultLanePriority - jestExpect( - ReactFiberLane.higherLanePriority(ReactFiberLane.NoLanePriority, priority) - ).toBe(priority) - jestExpect( - ReactFiberLane.higherLanePriority(priority, ReactFiberLane.NoLanePriority) - ).toBe(priority) - end) + it("returns the higher priority lane", function() + local higherLane = ReactFiberLane.SyncLane + local otherLane = ReactFiberLane.OffscreenLane + jestExpect(ReactFiberLane.higherPriorityLane(higherLane, otherLane)).toBe( + higherLane + ) + jestExpect(ReactFiberLane.higherPriorityLane(otherLane, higherLane)).toBe( + higherLane + ) + end) +end) + +describe("higherLanePriority", function() + it("returns the other priority if one is NoLanePriority", function() + local priority = ReactFiberLane.DefaultLanePriority + jestExpect( + ReactFiberLane.higherLanePriority(ReactFiberLane.NoLanePriority, priority) + ).toBe(priority) + jestExpect( + ReactFiberLane.higherLanePriority(priority, ReactFiberLane.NoLanePriority) + ).toBe(priority) + end) - it("returns the higher lane priority", function() - local higherPriority = ReactFiberLane.SyncLanePriority - local otherPriority = ReactFiberLane.TransitionPriority - jestExpect( - ReactFiberLane.higherLanePriority(higherPriority, otherPriority) - ).toBe(higherPriority) - jestExpect( - ReactFiberLane.higherLanePriority(otherPriority, higherPriority) - ).toBe(higherPriority) - end) + it("returns the higher lane priority", function() + local higherPriority = ReactFiberLane.SyncLanePriority + local otherPriority = ReactFiberLane.TransitionPriority + jestExpect(ReactFiberLane.higherLanePriority(higherPriority, otherPriority)).toBe( + higherPriority + ) + jestExpect(ReactFiberLane.higherLanePriority(otherPriority, higherPriority)).toBe( + higherPriority + ) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberRoot.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberRoot.roblox.spec.lua index 82b33b2c..5e49a202 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberRoot.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberRoot.roblox.spec.lua @@ -9,25 +9,27 @@ * @jest-environment node ]] -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it - local ReactFiberRoot - local ReactRootTags +local ReactFiberRoot +local ReactRootTags - beforeEach(function() - RobloxJest.resetModules() +beforeEach(function() + jest.resetModules() - ReactFiberRoot = require(script.Parent.Parent["ReactFiberRoot.new"]) - ReactRootTags = require(script.Parent.Parent.ReactRootTags) - end) + ReactFiberRoot = require(script.Parent.Parent["ReactFiberRoot.new"]) + ReactRootTags = require(script.Parent.Parent.ReactRootTags) +end) - it("should properly initialize a fiber created with createFiberRoot", function() - local fiberRoot = ReactFiberRoot.createFiberRoot({}, ReactRootTags.BlockingRoot, false, nil) +it("should properly initialize a fiber created with createFiberRoot", function() + local fiberRoot = + ReactFiberRoot.createFiberRoot({}, ReactRootTags.BlockingRoot, false) - jestExpect(fiberRoot.current).toBeDefined() - jestExpect(fiberRoot.current.updateQueue).toBeDefined() - end) -end + jestExpect(fiberRoot.current).toBeDefined() + jestExpect(fiberRoot.current.updateQueue).toBeDefined() +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberStack-test.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberStack-test.roblox.spec.lua index d19af34b..5379e563 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberStack-test.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberStack-test.roblox.spec.lua @@ -1,59 +1,66 @@ -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local jest = JestGlobals.jest +local it = JestGlobals.it - local ReactFiberStack +local ReactFiberStack - describe("ReactFiberStack", function() - beforeEach(function() - RobloxJest.resetModules() - ReactFiberStack = require(script.Parent.Parent["ReactFiberStack.new"]) - end) +describe("ReactFiberStack", function() + beforeEach(function() + jest.resetModules() + ReactFiberStack = require(script.Parent.Parent["ReactFiberStack.new"]) + end) - it("creates a cursor with the given default value", function() - local defaultValue = {foo = 3} - jestExpect(ReactFiberStack.createCursor(defaultValue)).toEqual({current = defaultValue}) - end) + it("creates a cursor with the given default value", function() + local defaultValue = { foo = 3 } + jestExpect(ReactFiberStack.createCursor(defaultValue)).toEqual({ + current = defaultValue, + }) + end) - it("initializes the stack empty", function() - jestExpect(ReactFiberStack.isEmpty()).toBe(true) - end) + it("initializes the stack empty", function() + jestExpect(ReactFiberStack.isEmpty()).toBe(true) + end) - describe("stack manipulations", function() - local cursor - local fiber + describe("stack manipulations", function() + local cursor + local fiber - beforeEach(function() - cursor = ReactFiberStack.createCursor(nil) - fiber = {} - end) + beforeEach(function() + cursor = ReactFiberStack.createCursor(nil) + fiber = {} + end) - it("pushes an element and the stack is not empty", function() - ReactFiberStack.push(cursor, true, fiber) - jestExpect(ReactFiberStack.isEmpty()).toBe(false) - end) + it("pushes an element and the stack is not empty", function() + ReactFiberStack.push(cursor, true, fiber) + jestExpect(ReactFiberStack.isEmpty()).toBe(false) + end) - it("pushes an element and assigns the value to the cursor", function() - local pushedElement = {foo = 3} - ReactFiberStack.push(cursor, pushedElement, fiber) - jestExpect(cursor.current).toEqual(pushedElement) - end) + it("pushes an element and assigns the value to the cursor", function() + local pushedElement = { foo = 3 } + ReactFiberStack.push(cursor, pushedElement, fiber) + jestExpect(cursor.current).toEqual(pushedElement) + end) - it("pushes an element, pops it back and the stack is empty", function() - ReactFiberStack.push(cursor, true, fiber) - ReactFiberStack.pop(cursor, fiber) - jestExpect(ReactFiberStack.isEmpty()).toBe(true) - end) + it("pushes an element, pops it back and the stack is empty", function() + ReactFiberStack.push(cursor, true, fiber) + ReactFiberStack.pop(cursor, fiber) + jestExpect(ReactFiberStack.isEmpty()).toBe(true) + end) - it("pushes an element, pops it back and the cursor has its initial value", function() + it( + "pushes an element, pops it back and the cursor has its initial value", + function() local initialCursorValue = "foo" cursor.current = initialCursorValue ReactFiberStack.push(cursor, true, fiber) ReactFiberStack.pop(cursor, fiber) jestExpect(cursor.current).toBe(initialCursorValue) - end) - end) + end + ) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberSuspenseComponent.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberSuspenseComponent.roblox.spec.lua index 5364d7e7..5afe8c69 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberSuspenseComponent.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberSuspenseComponent.roblox.spec.lua @@ -1,88 +1,90 @@ ---!nolint LocalShadowPedantic -return function() - local Reconciler = script.Parent.Parent - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Reconciler = script.Parent.Parent +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it - local ReactFiberSuspenseComponent +local ReactFiberSuspenseComponent - describe("ReactFiberSuspenseComponent", function() - beforeEach(function() - RobloxJest.resetModules() +describe("ReactFiberSuspenseComponent", function() + beforeEach(function() + jest.resetModules() - ReactFiberSuspenseComponent = require(Reconciler["ReactFiberSuspenseComponent.new"]) - end) + ReactFiberSuspenseComponent = + require(Reconciler["ReactFiberSuspenseComponent.new"]) + end) - describe("shouldCaptureSuspense", function() - local shouldCaptureSuspense - local fiber + describe("shouldCaptureSuspense", function() + local shouldCaptureSuspense + local fiber - beforeEach(function() - shouldCaptureSuspense = ReactFiberSuspenseComponent.shouldCaptureSuspense - fiber = { - memoizedState = nil, - memoizedProps = {}, - } - end) + beforeEach(function() + shouldCaptureSuspense = ReactFiberSuspenseComponent.shouldCaptureSuspense + fiber = { + memoizedState = nil, + memoizedProps = {}, + } + end) - local function generateTest(expected, hasInvisibleParent, it) - it = it or getfenv(2).it - if hasInvisibleParent == nil then - generateTest(expected, true, it) - generateTest(expected, false, it) - else - local testName = ("is %s if it %s invisible parent"):format( - tostring(expected), - hasInvisibleParent and "does not have" or "has" + local function generateTest(expected, hasInvisibleParent) + if hasInvisibleParent == nil then + generateTest(expected, true) + generateTest(expected, false) + else + local testName = string.format( + "is %s if it %s invisible parent", + tostring(expected), + hasInvisibleParent and "does not have" or "has" + ) + it(testName, function() + jestExpect(shouldCaptureSuspense(fiber, hasInvisibleParent)).toBe( + expected ) - it(testName, function() - jestExpect( - shouldCaptureSuspense(fiber, hasInvisibleParent) - ).toBe(expected) - end) - end + end) end + end - describe("with a memoizedState", function() + describe("with a memoizedState", function() + beforeEach(function() + fiber.memoizedState = { dehydrated = nil } + end) + describe("memoizedState.dehydrated is not null", function() beforeEach(function() - fiber.memoizedState = {dehydrated = nil} - end) - describe("memoizedState.dehydrated is not null", function() - beforeEach(function() - fiber.memoizedState.dehydrated = {} - end) - generateTest(true) + fiber.memoizedState.dehydrated = {} end) + generateTest(true) + end) - describe("memoizedState.dehydrated is null", function() - generateTest(false) - end) + describe("memoizedState.dehydrated is null", function() + generateTest(false) end) + end) - describe("with no memoizedState", function() - describe("without fallback prop", function() - generateTest(false) - end) + describe("with no memoizedState", function() + describe("without fallback prop", function() + generateTest(false) + end) - describe("with fallback prop", function() - beforeEach(function() - fiber.memoizedProps.fallback = {} - end) + describe("with fallback prop", function() + beforeEach(function() + fiber.memoizedProps.fallback = {} + end) - describe("without flag unstable_avoidThisFallback", function() - generateTest(true) - end) + describe("without flag unstable_avoidThisFallback", function() + generateTest(true) + end) - describe("with flag unstable_avoidThisFallback", function() - beforeEach(function() - fiber.memoizedProps.unstable_avoidThisFallback = true - end) - generateTest(false, true) - generateTest(true, false) + describe("with flag unstable_avoidThisFallback", function() + beforeEach(function() + fiber.memoizedProps.unstable_avoidThisFallback = true end) + generateTest(false, true) + generateTest(true, false) end) end) end) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberSuspenseContext.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberSuspenseContext.roblox.spec.lua index 1e467d9d..710eea24 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberSuspenseContext.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberSuspenseContext.roblox.spec.lua @@ -1,56 +1,61 @@ -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local ReactFiber = require(script.Parent.Parent["ReactFiber.new"]) + +local ReactFiberSuspenseContext + +describe("ReactFiberSuspenseContext", function() + beforeEach(function() + jest.resetModules() + ReactFiberSuspenseContext = + require(script.Parent.Parent["ReactFiberSuspenseContext.new"]) + end) - local ReactFiberSuspenseContext + describe("suspense context stack", function() + local someContext + local fiber + local suspenseStackCursor - describe("ReactFiberSuspenseContext", function() beforeEach(function() - RobloxJest.resetModules() - ReactFiberSuspenseContext = require(script.Parent.Parent["ReactFiberSuspenseContext.new"]) + someContext = 0b1000 + fiber = ReactFiber.createFiberFromText("", 0, 0) + suspenseStackCursor = ReactFiberSuspenseContext.suspenseStackCursor + end) + + it("pushes the context and assigns the value to the cursor", function() + ReactFiberSuspenseContext.pushSuspenseContext(fiber, someContext) + jestExpect(suspenseStackCursor).toEqual({ current = someContext }) + end) + + it("pushes and pops and sets the cursor to its initial value", function() + local initialValue = suspenseStackCursor.current + + ReactFiberSuspenseContext.pushSuspenseContext(fiber, someContext) + ReactFiberSuspenseContext.popSuspenseContext(fiber) + jestExpect(suspenseStackCursor).toEqual({ current = initialValue }) end) + end) + + describe("hasSuspenseContext", function() + it("is true for parent context and its subtree context", function() + local subtree = 0b1000 + local parent = + ReactFiberSuspenseContext.addSubtreeSuspenseContext(10000, subtree) - describe("suspense context stack", function() - local someContext - local fiber - local suspenseStackCursor - - beforeEach(function() - someContext = 0b1000 - fiber = {} - suspenseStackCursor = ReactFiberSuspenseContext.suspenseStackCursor - end) - - it("pushes the context and assigns the value to the cursor", function() - ReactFiberSuspenseContext.pushSuspenseContext(fiber, someContext) - jestExpect(suspenseStackCursor).toEqual({current = someContext}) - end) - - it("pushes and pops and sets the cursor to its initial value", function() - local initialValue = suspenseStackCursor.current - - ReactFiberSuspenseContext.pushSuspenseContext(fiber, someContext) - ReactFiberSuspenseContext.popSuspenseContext(fiber) - jestExpect(suspenseStackCursor).toEqual({current = initialValue}) - end) + jestExpect(ReactFiberSuspenseContext.hasSuspenseContext(parent, subtree)).toBe( + true + ) end) - describe("hasSuspenseContext", function() - it("is true for parent context and its subtree context", function() - local subtree = 0b1000 - local parent = ReactFiberSuspenseContext.addSubtreeSuspenseContext(10000, subtree) - - jestExpect( - ReactFiberSuspenseContext.hasSuspenseContext(parent, subtree) - ).toBe(true) - end) - - it("is false for two different context", function() - jestExpect( - ReactFiberSuspenseContext.hasSuspenseContext(0b1000, 0b10000) - ).toBe(false) - end) + it("is false for two different context", function() + jestExpect(ReactFiberSuspenseContext.hasSuspenseContext(0b1000, 0b10000)).toBe( + false + ) end) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactFiberTreeReflection.roblox.spec.lua b/modules/react-reconciler/src/__tests__/ReactFiberTreeReflection.roblox.spec.lua index 18cae4a1..1013e6a0 100644 --- a/modules/react-reconciler/src/__tests__/ReactFiberTreeReflection.roblox.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactFiberTreeReflection.roblox.spec.lua @@ -1,374 +1,427 @@ ---!nolint LocalShadowPedantic -return function() - local Reconciler = script.Parent.Parent - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) - local LuauPolyfill = require(Packages.LuauPolyfill) - local Object = LuauPolyfill.Object - - local setInstance = require(Packages.Shared).ReactInstanceMap.set - local ReactWorkTags = require(Reconciler.ReactWorkTags) - local ClassComponent = ReactWorkTags.ClassComponent - local HostRoot = ReactWorkTags.HostRoot - local FunctionComponent = ReactWorkTags.FunctionComponent - local SuspenseComponent = ReactWorkTags.SuspenseComponent - - local ReactFiberFlags = require(Reconciler.ReactFiberFlags) - local NoFlags = ReactFiberFlags.NoFlags - local Placement = ReactFiberFlags.Placement - local Hydrating = ReactFiberFlags.Hydrating - - local ReactFiberTreeReflection - - describe("ReactFiberTreeReflection", function() +local Reconciler = script.Parent.Parent +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local LuauPolyfill = require(Packages.LuauPolyfill) +local Object = LuauPolyfill.Object + +local setInstance = require(Packages.Shared).ReactInstanceMap.set +local ReactWorkTags = require(Reconciler.ReactWorkTags) +local ClassComponent = ReactWorkTags.ClassComponent +local HostRoot = ReactWorkTags.HostRoot +local FunctionComponent = ReactWorkTags.FunctionComponent +local SuspenseComponent = ReactWorkTags.SuspenseComponent + +local ReactFiberFlags = require(Reconciler.ReactFiberFlags) +local NoFlags = ReactFiberFlags.NoFlags +local Placement = ReactFiberFlags.Placement +local Hydrating = ReactFiberFlags.Hydrating +local ReactFiberLane = require(Reconciler.ReactFiberLane) + +local ReactFiberTreeReflection + +describe("ReactFiberTreeReflection", function() + beforeEach(function() + jest.resetModules() + + ReactFiberTreeReflection = require(Reconciler.ReactFiberTreeReflection) + end) + + describe("getSuspenseInstanceFromFiber", function() + local getSuspenseInstanceFromFiber + beforeEach(function() - RobloxJest.resetModules() + getSuspenseInstanceFromFiber = + ReactFiberTreeReflection.getSuspenseInstanceFromFiber + end) - ReactFiberTreeReflection = require(Reconciler.ReactFiberTreeReflection) + local function mockFiber(mock) + return Object.assign({ + tag = SuspenseComponent, + alternate = nil, + memoizedState = nil, + }, mock) + end + + it("returns the dehydrated memoized state from the fiber", function() + local suspenseInstance = {} + local fiber = mockFiber({ + memoizedState = { dehydrated = suspenseInstance }, + }) + jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(suspenseInstance) end) - describe("getSuspenseInstanceFromFiber", function() - local getSuspenseInstanceFromFiber + it("returns the dehydrated memoized state from the alternate fiber", function() + local suspenseInstance = {} + local fiber = mockFiber({ + alternate = mockFiber({ + memoizedState = { dehydrated = suspenseInstance }, + }), + }) + jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(suspenseInstance) + end) - beforeEach(function() - getSuspenseInstanceFromFiber = ReactFiberTreeReflection.getSuspenseInstanceFromFiber - end) + it("returns null if the fiber does not have the SuspenseComponent tag", function() + local fiber = mockFiber({ tag = FunctionComponent }) + jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(nil) + end) + end) - local function mockFiber(mock) - return Object.assign({ - tag = SuspenseComponent, - alternate = nil, - memoizedState = nil, - }, mock) - end + describe("getContainerFromFiber", function() + local getContainerFromFiber - it('returns the dehydrated memoized state from the fiber', function() - local suspenseInstance = {} - local fiber = mockFiber({ - memoizedState = {dehydrated = suspenseInstance}, - }) - jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(suspenseInstance) - end) + beforeEach(function() + getContainerFromFiber = ReactFiberTreeReflection.getContainerFromFiber + end) - it('returns the dehydrated memoized state from the alternate fiber', function() - local suspenseInstance = {} - local fiber = mockFiber({ - alternate = mockFiber({ - memoizedState = {dehydrated = suspenseInstance}, - }), - }) - jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(suspenseInstance) - end) + local function mockFiber(mock) + return Object.assign({ + tag = HostRoot, + }, mock) + end + + it("returns a container if fiber is a host root", function() + local container = {} + local fiber = mockFiber({ + stateNode = { containerInfo = container }, + }) + jestExpect(getContainerFromFiber(fiber)).toBe(container) + end) - it('returns null if the fiber does not have the SuspenseComponent tag', function() - local fiber = mockFiber({tag = FunctionComponent}) - jestExpect(getSuspenseInstanceFromFiber(fiber)).toBe(nil) - end) + it("returns null if the fiber is not a host root", function() + local fiber = mockFiber({ tag = FunctionComponent }) + jestExpect(getContainerFromFiber(fiber)).toBe(nil) end) + end) - describe('getContainerFromFiber', function() - local getContainerFromFiber + describe("different fiber states", function() + local function mockFiber(mock) + return Object.assign({ + alternate = nil, + return_ = nil, + tag = FunctionComponent, + flags = NoFlags, + subtreeFlags = NoFlags, + lanes = ReactFiberLane.NoLanes, + childLanes = ReactFiberLane.NoLanes, + }, mock) + end + local fiber + + local function generateIsFiberMounted(expected) + it(string.format("isFiberMounted() is %s", tostring(expected)), function() + jestExpect(ReactFiberTreeReflection.isFiberMounted(fiber)).toBe(expected) + end) + end - beforeEach(function() - getContainerFromFiber = ReactFiberTreeReflection.getContainerFromFiber + local function generateIsMounted(expected) + it(string.format("isMounted() is %s", tostring(expected)), function() + local component = {} + setInstance(component, fiber) + jestExpect(ReactFiberTreeReflection.isMounted(component)).toBe(expected) end) + end + + describe("with an alternate fiber", function() + describe("last return node has the HostRoot tag", function() + beforeEach(function() + local rootFiber = mockFiber({ tag = HostRoot }) + local middleFiber = mockFiber({ return_ = rootFiber }) + fiber = mockFiber({ + alternate = mockFiber(), + return_ = middleFiber, + }) + end) - local function mockFiber(mock) - return Object.assign({ - tag = HostRoot, - }, mock) - end + generateIsFiberMounted(true) + generateIsMounted(true) - it('returns a container if fiber is a host root', function() - local container = {} - local fiber = mockFiber({ - stateNode = {containerInfo = container}, - }) - jestExpect(getContainerFromFiber(fiber)).toBe(container) + it("getNearestMountedFiber() returns the same fiber", function() + jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe( + fiber + ) + end) end) - it('returns null if the fiber is not a host root', function() - local fiber = mockFiber({tag = FunctionComponent}) - jestExpect(getContainerFromFiber(fiber)).toBe(nil) - end) - end) + describe("last return node does not have the HostRoot tag", function() + beforeEach(function() + local rootFiber = mockFiber() - describe('different fiber states', function() - local function mockFiber(mock) - return Object.assign({ - alternate = nil, - return_ = nil, - tag = FunctionComponent, - flags = NoFlags, - }, mock) - end - local fiber + fiber = mockFiber({ + alternate = mockFiber(), + return_ = rootFiber, + }) + end) - local function generateIsFiberMounted(expected) - local it = getfenv(2).it - it(('isFiberMounted() is %s'):format(tostring(expected)), function() - jestExpect(ReactFiberTreeReflection.isFiberMounted(fiber)).toBe(expected) + it("getNearestMountedFiber() returns null", function() + jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe( + nil + ) end) - end - local function generateIsMounted(expected) - local it = getfenv(2).it - it(('isMounted() is %s'):format(tostring(expected)), function() - local component = {} - setInstance(component, fiber) - jestExpect(ReactFiberTreeReflection.isMounted(component)).toBe(expected) + it("findCurrentFiberUsingSlowPath() throws", function() + jestExpect(function() + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber) + end).toThrow("Unable to find node on an unmounted component") end) - end - describe('with an alternate fiber', function() - describe('last return node has the HostRoot tag', function() - beforeEach(function() - local rootFiber = mockFiber({tag = HostRoot}) - local middleFiber = mockFiber({return_ = rootFiber}) - fiber = mockFiber({ - alternate = mockFiber(), - return_ = middleFiber, - }) - end) + generateIsFiberMounted(false) + generateIsMounted(false) + end) - generateIsFiberMounted(true) - generateIsMounted(true) + describe("fiber has the RootHost tag", function() + beforeEach(function() + fiber = mockFiber({ + alternate = mockFiber(), + tag = HostRoot, + }) + end) - it('getNearestMountedFiber() returns the same fiber', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(fiber) - end) + it("getNearestMountedFiber() returns the same fiber", function() + jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe( + fiber + ) end) - describe('last return node does not have the HostRoot tag', function() - beforeEach(function() - local rootFiber = mockFiber() + it( + "findCurrentFiberUsingSlowPath() returns the same fiber " + .. "if the stateNode.current is the fiber", + function() + fiber.stateNode = { current = fiber } - fiber = mockFiber({ - alternate = mockFiber(), - return_ = rootFiber, - }) - end) + jestExpect( + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber) + ).toBe(fiber) + end + ) - it('getNearestMountedFiber() returns null', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(nil) - end) + it( + "findCurrentFiberUsingSlowPath() returns the alternate fiber " + .. "if the stateNode.current is not the given fiber", + function() + fiber.stateNode = { current = nil } - it('findCurrentFiberUsingSlowPath() throws', function() - jestExpect(function() + jestExpect( ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber) - end).toThrow('Unable to find node on an unmounted component') - end) + ).toBe(fiber.alternate) + end + ) - generateIsFiberMounted(false) - generateIsMounted(false) - end) + generateIsFiberMounted(true) + generateIsMounted(true) + end) + end) - describe('fiber has the RootHost tag', function() + describe("without an alternate fiber", function() + describe( + "all its return nodes do not have the placement or hydrating flag", + function() beforeEach(function() - fiber = mockFiber({ - alternate = mockFiber(), - tag = HostRoot, - }) - end) + local rootFiber = mockFiber({ tag = HostRoot }) + local middleFiber = mockFiber({ return_ = rootFiber }) - it('getNearestMountedFiber() returns the same fiber', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(fiber) + fiber = mockFiber({ return_ = middleFiber }) end) - it( - 'findCurrentFiberUsingSlowPath() returns the same fiber ' .. - 'if the stateNode.current is the fiber', - function() - fiber.stateNode = {current = fiber} - - jestExpect(ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber)).toBe(fiber) - end - ) + it("getNearestMountedFiber() returns the same fiber", function() + jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe( + fiber + ) + end) it( - 'findCurrentFiberUsingSlowPath() returns the alternate fiber ' .. - 'if the stateNode.current is not the given fiber', + "findCurrentFiberUsingSlowPath() returns the same fiber", function() - fiber.stateNode = {current = nil} - - jestExpect(ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber)).toBe(fiber.alternate) + jestExpect( + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath( + fiber + ) + ).toBe(fiber) end ) generateIsFiberMounted(true) generateIsMounted(true) - end) - end) - - describe('without an alternate fiber', function() - describe('all its return nodes do not have the placement or hydrating flag', function() - beforeEach(function() - local rootFiber = mockFiber({tag = HostRoot}) - local middleFiber = mockFiber({return_ = rootFiber}) - - fiber = mockFiber({return_ = middleFiber}) - end) + end + ) + + local fiberFlags = { + placement = Placement, + hydrating = Hydrating, + } + -- ROBLOX FIXME Luau: need to fix CLI-56768 to remove any casts + for name, flag in fiberFlags :: any do + describe( + string.format("one of the return node has the %s flag", name), + function() + local rootFiber - it('getNearestMountedFiber() returns the same fiber', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(fiber) - end) + beforeEach(function() + rootFiber = mockFiber({ tag = HostRoot }) - it('findCurrentFiberUsingSlowPath() returns the same fiber', function() - jestExpect(ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber)).toBe(fiber) - end) + local middleFiber = mockFiber({ + return_ = rootFiber, + flags = flag, + }) - generateIsFiberMounted(true) - generateIsMounted(true) - end) + fiber = mockFiber({ return_ = middleFiber }) + end) - local fiberFlags = { - placement = Placement, - hydrating = Hydrating, - } + it("getNearestMountedFiber() returns the parent fiber", function() + jestExpect( + ReactFiberTreeReflection.getNearestMountedFiber(fiber) + ).toBe(rootFiber) + end) - for name, flag in pairs(fiberFlags) do - describe(('one of the return node has the %s flag'):format(name), function() - local rootFiber + it("findCurrentFiberUsingSlowPath() returns null", function() + jestExpect( + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath( + fiber + ) + ).toBe(nil) + end) + generateIsFiberMounted(false) + generateIsMounted(false) + end + ) + + describe( + string.format( + "the return node of the fiber where it has the %s ", + name + ) .. "flag does not have the HostRoot tags", + function() beforeEach(function() - rootFiber = mockFiber({tag = HostRoot}) - + local rootFiber = mockFiber() local middleFiber = mockFiber({ return_ = rootFiber, flags = flag, }) - fiber = mockFiber({return_ = middleFiber}) + fiber = mockFiber({ return_ = middleFiber }) end) - it('getNearestMountedFiber() returns the parent fiber', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(rootFiber) + it("getNearestMountedFiber() returns null", function() + jestExpect( + ReactFiberTreeReflection.getNearestMountedFiber(fiber) + ).toBe(nil) end) - it('findCurrentFiberUsingSlowPath() returns null', function() - jestExpect(ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber)).toBe(nil) + it("findCurrentFiberUsingSlowPath() throws", function() + jestExpect(function() + ReactFiberTreeReflection.findCurrentFiberUsingSlowPath( + fiber + ) + end).toThrow( + "Unable to find node on an unmounted component" + ) end) generateIsFiberMounted(false) generateIsMounted(false) - end) - - describe( - ('the return node of the fiber where it has the %s '):format(name) .. - 'flag does not have the HostRoot tags', - function() - beforeEach(function() - local rootFiber = mockFiber() - local middleFiber = mockFiber({ - return_ = rootFiber, - flags = flag, - }) - - fiber = mockFiber({return_ = middleFiber}) - end) - - it('getNearestMountedFiber() returns null', function() - jestExpect(ReactFiberTreeReflection.getNearestMountedFiber(fiber)).toBe(nil) - end) - - it('findCurrentFiberUsingSlowPath() throws', function() - jestExpect(function() - ReactFiberTreeReflection.findCurrentFiberUsingSlowPath(fiber) - end).toThrow('Unable to find node on an unmounted component') - end) - - generateIsFiberMounted(false) - generateIsMounted(false) - end - ) - end - end) + end + ) + end end) + end) - describe('isFiberSuspenseAndTimedOut', function() - local isFiberSuspenseAndTimedOut - beforeEach(function() - isFiberSuspenseAndTimedOut = ReactFiberTreeReflection.isFiberSuspenseAndTimedOut - end) + describe("isFiberSuspenseAndTimedOut", function() + local isFiberSuspenseAndTimedOut + beforeEach(function() + isFiberSuspenseAndTimedOut = + ReactFiberTreeReflection.isFiberSuspenseAndTimedOut + end) - it('is true for a SuspenseComponent fiber when memoizedState.dehydrated is null', function() + it( + "is true for a SuspenseComponent fiber when memoizedState.dehydrated is null", + function() local fiber = { tag = SuspenseComponent, - memoizedState = {dehydrated = nil}, + memoizedState = { dehydrated = nil }, } jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(true) - end) - - it('is false if the fiber is not tagged as SuspenseComponent', function() - local fiber = { - tag = ClassComponent, - memoizedState = {dehydrated = nil}, - } - jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) - end) + end + ) + + it("is false if the fiber is not tagged as SuspenseComponent", function() + local fiber = { + tag = ClassComponent, + memoizedState = { dehydrated = nil }, + } + jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) + end) - it('is false if the fiber does not have memoizedState', function() - local fiber = { - tag = SuspenseComponent, - memoizedState = nil, - } - jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) - end) + it("is false if the fiber does not have memoizedState", function() + local fiber = { + tag = SuspenseComponent, + memoizedState = nil, + } + jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) + end) - it('is false if the fiber memoizedState.dehydrated is not null', function() - local fiber = { - tag = SuspenseComponent, - memoizedState = { - dehydrated = 'foo', - }, - } - jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) - end) + it("is false if the fiber memoizedState.dehydrated is not null", function() + local fiber = { + tag = SuspenseComponent, + memoizedState = { + dehydrated = "foo", + }, + } + jestExpect(isFiberSuspenseAndTimedOut(fiber)).toBe(false) end) + end) - describe('doesFiberContain', function() - local doesFiberContain - beforeEach(function() - doesFiberContain = ReactFiberTreeReflection.doesFiberContain - end) + describe("doesFiberContain", function() + local doesFiberContain + beforeEach(function() + doesFiberContain = ReactFiberTreeReflection.doesFiberContain + end) - local function mockFiber(mock) - return Object.assign({ - return_ = nil, - alternate = nil, - }, mock or {}) - end + local function mockFiber(mock) + return Object.assign({ + return_ = nil, + alternate = nil, + }, mock or {}) + end - it('is true if the parent and the child are the same fiber', function() - local fiber = mockFiber() - jestExpect(doesFiberContain(fiber, fiber)).toBe(true) - end) + it("is true if the parent and the child are the same fiber", function() + local fiber = mockFiber() + jestExpect(doesFiberContain(fiber, fiber)).toBe(true) + end) - it('is true if the parent alternate and the child are the same fiber', function() - local fiber = mockFiber() - local parent = mockFiber({alternate = fiber}) - jestExpect(doesFiberContain(parent, fiber)).toBe(true) - end) + it("is true if the parent alternate and the child are the same fiber", function() + local fiber = mockFiber() + local parent = mockFiber({ alternate = fiber }) + jestExpect(doesFiberContain(parent, fiber)).toBe(true) + end) - it('is true if the child return node and the parent are the same fiber', function() + it( + "is true if the child return node and the parent are the same fiber", + function() local parent = mockFiber() - local child = mockFiber({return_ = parent}) + local child = mockFiber({ return_ = parent }) jestExpect(doesFiberContain(parent, child)).toBe(true) - end) + end + ) - it('is true if the child return node and the parent alternate are the same fiber', function() + it( + "is true if the child return node and the parent alternate are the same fiber", + function() local parentAlternate = mockFiber() - local parent = mockFiber({alternate = parentAlternate}) - local child = mockFiber({return_ = parentAlternate}) + local parent = mockFiber({ alternate = parentAlternate }) + local child = mockFiber({ return_ = parentAlternate }) jestExpect(doesFiberContain(parent, child)).toBe(true) - end) + end + ) - it('is false if none of the child parents are the parent fiber', function() - local parent = mockFiber() - local child = mockFiber() - jestExpect(doesFiberContain(parent, child)).toBe(false) - end) + it("is false if none of the child parents are the parent fiber", function() + local parent = mockFiber() + local child = mockFiber() + jestExpect(doesFiberContain(parent, child)).toBe(false) end) end) -end +end) diff --git a/modules/react-reconciler/src/__tests__/ReactHooks-internal.spec.lua b/modules/react-reconciler/src/__tests__/ReactHooks-internal.spec.lua new file mode 100644 index 00000000..3c42e12d --- /dev/null +++ b/modules/react-reconciler/src/__tests__/ReactHooks-internal.spec.lua @@ -0,0 +1,1949 @@ +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/d13f5b9538e48f74f7c571ef3cde652ca887cca0/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.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. +-- * +-- * @emails react-core +-- * @jest-environment node +-- */ + +local React +local ReactFeatureFlags +local ReactTestRenderer +local Scheduler +-- local ReactDOMServer +local act +local Packages = script.Parent.Parent.Parent +local ReactGlobals = require(Packages.ReactGlobals) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local jest = JestGlobals.jest +local it = JestGlobals.it +local xit = JestGlobals.xit +local describe = JestGlobals.describe +describe("ReactHooks", function() + local Promise = require(Packages.Promise) + local LuauPolyfill = require(Packages.LuauPolyfill) + local Array = LuauPolyfill.Array + type Array = LuauPolyfill.Array + type Function = (...any) -> ...any + local Error = LuauPolyfill.Error + + beforeEach(function() + jest.resetModules() + ReactFeatureFlags = require(Packages.Shared).ReactFeatureFlags + React = require(Packages.React) + ReactTestRenderer = require(Packages.Dev.ReactTestRenderer) + Scheduler = require(Packages.Scheduler) + -- ReactDOMServer = require("react-dom/server") + act = ReactTestRenderer.unstable_concurrentAct + end) + if ReactGlobals.__DEV__ then + it("useDebugValue throws when used in a class component", function() + type Example = { render: any } --[[ ROBLOX TODO: replace 'any' type/ add missing ]] + local Example = React.Component:extend("Example") + function Example:render() + React.useDebugValue("abc") + return nil + end + -- ROBLOX Test Noise: jest setup config makes this hide error + -- boundary warnings in upstream (scripts/jest/setupTests.js:72) + jestExpect(function() + ReactTestRenderer.create(React.createElement(Example)) + end).toThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + end) + end + it("bails out in the render phase if all of the state is the same", function() + local useState, useLayoutEffect = React.useState, React.useLayoutEffect + local function Child(props) + local text = props.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter1 + local setCounter2 + local function Parent() + local counter1, _setCounter1 = useState(0) + setCounter1 = _setCounter1 + local counter2, _setCounter2 = useState(0) + setCounter2 = _setCounter2 + local text = string.format("%s, %s", tostring(counter1), tostring(counter2)) + Scheduler.unstable_yieldValue(string.format("Parent: %s", text)) + useLayoutEffect(function() + Scheduler.unstable_yieldValue(string.format("Effect: %s", text)) + end) + return React.createElement(Child, { text = text }) + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + + root.update(React.createElement(Parent)) + jestExpect(Scheduler).toFlushAndYield({ + "Parent: 0, 0", + "Child: 0, 0", + "Effect: 0, 0", + }) + jestExpect(root).toMatchRenderedOutput("0, 0") + act(function() + setCounter1(1) + setCounter2(1) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1, 1", + "Child: 1, 1", + "Effect: 1, 1", + }) + act(function() + return setCounter1(1) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1, 1" }) + act(function() + setCounter1(1) + setCounter2(2) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1, 2", + "Child: 1, 2", + "Effect: 1, 2", + }) + act(function() + setCounter1(9) + setCounter2(3) + setCounter1(4) + setCounter2(7) + setCounter1(1) + setCounter2(2) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1, 2" }) + act(function() + setCounter1(0 / -1) + setCounter2(0 / 0) + end) + jestExpect(Scheduler).toHaveYielded({ + -- ROBLOX deviation: use Luau stringified versions of the math + "Parent: -0, nan", + "Child: -0, nan", + "Effect: -0, nan", + }) + act(function() + setCounter1(0 / -1) + setCounter2(0 / 0) + setCounter2(math.huge) + setCounter2(0 / 0) + end) + -- ROBLOX deviation: use Luau stringified versions of the math + jestExpect(Scheduler).toHaveYielded({ "Parent: -0, nan" }) + act(function() + setCounter1(0) + end) + jestExpect(Scheduler).toHaveYielded({ + -- ROBLOX deviation: use Luau stringified versions of the math + "Parent: 0, nan", + "Child: 0, nan", + "Effect: 0, nan", + }) + end) + it( + "bails out in render phase if all the state is the same and props bail out with memo", + function() + local useState, memo = React.useState, React.memo + local function Child(props) + local text = props.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter1 + local setCounter2 + local function Parent(props) + local theme = props.theme + local counter1, _setCounter1 = useState(0) + setCounter1 = _setCounter1 + local counter2, _setCounter2 = useState(0) + setCounter2 = _setCounter2 + local text = string.format( + "%s, %s (%s)", + tostring(counter1), + tostring(counter2), + theme + ) + Scheduler.unstable_yieldValue(string.format("Parent: %s", text)) + return React.createElement(Child, { text = text }) + end + -- ROBLOX TODO: contribute this rename upstream, it makes the code and types sane + local ParentMemo = memo(Parent) + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(ParentMemo, { theme = "light" })) + jestExpect(Scheduler).toFlushAndYield({ + "Parent: 0, 0 (light)", + "Child: 0, 0 (light)", + }) + jestExpect(root).toMatchRenderedOutput("0, 0 (light)") + act(function() + setCounter1(1) + setCounter2(1) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1, 1 (light)", + "Child: 1, 1 (light)", + }) + act(function() + return setCounter1(1) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1, 1 (light)" }) + act(function() + setCounter1(1) + setCounter2(2) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1, 2 (light)", + "Child: 1, 2 (light)", + }) + act(function() + setCounter1(1) + setCounter2(2) + root.update(React.createElement(ParentMemo, { theme = "dark" })) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1, 2 (dark)", + "Child: 1, 2 (dark)", + }) + act(function() + setCounter1(1) + setCounter2(2) + root.update(React.createElement(ParentMemo, { theme = "dark" })) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1, 2 (dark)" }) + end + ) + it("warns about setState second argument", function() + local useState = React.useState + local setCounter + local function Counter() + local counter, _setCounter = useState(0) + setCounter = _setCounter + Scheduler.unstable_yieldValue(string.format("Count: %s", tostring(counter))) + return counter + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(root).toMatchRenderedOutput("0") + jestExpect(function() + act(function() + -- ROBLOX deviation: Luau types prevent us from even trying this abuse case unless we cast away safety + return (setCounter :: any)(1, function() + error(Error.new("Expected to ignore the callback.")) + end) + end) + end).toErrorDev( + "State updates from the useState() and useReducer() Hooks " + .. "don't support the second callback argument. " + .. "To execute a side effect after rendering, " + .. "declare it in the component body with useEffect().", + { withoutStack = true } + ) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(root).toMatchRenderedOutput("1") + end) + + it("warns about dispatch second argument", function() + local useReducer = React.useReducer + local dispatch + local function Counter() + local counter, _dispatch = useReducer(function(s, a) + return a + end, 0) + dispatch = _dispatch + Scheduler.unstable_yieldValue(string.format("Count: %s", counter)) + return counter + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(root).toMatchRenderedOutput("0") + jestExpect(function() + act(function() + -- ROBLOX deviation: Luau types prevent us from even trying this abuse case unless we cast away safety + return (dispatch :: any)(1, function() + error(Error.new("Expected to ignore the callback.")) + end) + end) + end).toErrorDev( + "State updates from the useState() and useReducer() Hooks " + .. "don't support the second callback argument. " + .. "To execute a side effect after rendering, " + .. "declare it in the component body with useEffect().", + { withoutStack = true } + ) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(root).toMatchRenderedOutput("1") + end) + it("never bails out if context has changed", function() + local useState, useLayoutEffect, useContext = + React.useState, React.useLayoutEffect, React.useContext + local ThemeContext = React.createContext("light") + local setTheme + local function ThemeProvider(props) + local children = props.children + local theme, _setTheme = useState("light") + Scheduler.unstable_yieldValue("Theme: " .. tostring(theme)) + setTheme = _setTheme + return React.createElement(ThemeContext.Provider, { value = theme }, children) + end + local function Child(ref) + local text = ref.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter + local function Parent() + local counter, _setCounter = useState(0) + setCounter = _setCounter + local theme = useContext(ThemeContext) + local text = string.format("%d (%s)", counter, theme) + Scheduler.unstable_yieldValue(string.format("Parent: %s", text)) + useLayoutEffect(function() + Scheduler.unstable_yieldValue(string.format("Effect: %s", text)) + end) + return React.createElement(Child, { text = text }) + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + act(function() + root.update( + React.createElement(ThemeProvider, nil, React.createElement(Parent)) + ) + end) + jestExpect(Scheduler).toHaveYielded({ + "Theme: light", + "Parent: 0 (light)", + "Child: 0 (light)", + "Effect: 0 (light)", + }) + jestExpect(root).toMatchRenderedOutput("0 (light)") + setTheme("light") + jestExpect(Scheduler).toFlushAndYield({}) + jestExpect(root).toMatchRenderedOutput("0 (light)") + act(function() + return setCounter(1) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1 (light)", + "Child: 1 (light)", + "Effect: 1 (light)", + }) + jestExpect(root).toMatchRenderedOutput("1 (light)") + act(function() + return setCounter(1) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1 (light)" }) + jestExpect(root).toMatchRenderedOutput("1 (light)") + act(function() + setCounter(1) + setTheme("dark") + end) + jestExpect(Scheduler).toHaveYielded({ + "Theme: dark", + "Parent: 1 (dark)", + "Child: 1 (dark)", + "Effect: 1 (dark)", + }) + jestExpect(root).toMatchRenderedOutput("1 (dark)") + end) + it( + "can bail out without calling render phase (as an optimization) if queue is known to be empty", + function() + local useState, useLayoutEffect = React.useState, React.useLayoutEffect + local function Child(ref) + local text = ref.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter + local function Parent() + local counter, _setCounter = useState(0) + setCounter = _setCounter + Scheduler.unstable_yieldValue("Parent: " .. tostring(counter)) + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Effect: " .. tostring(counter)) + end) + return React.createElement(Child, { text = counter }) + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(Parent)) + jestExpect(Scheduler).toFlushAndYield({ + "Parent: 0", + "Child: 0", + "Effect: 0", + }) + jestExpect(root).toMatchRenderedOutput("0") + act(function() + return setCounter(1) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 1", + "Child: 1", + "Effect: 1", + }) + jestExpect(root).toMatchRenderedOutput("1") + act(function() + return setCounter(1) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 1" }) + jestExpect(root).toMatchRenderedOutput("1") + act(function() + return setCounter(1) + end) + jestExpect(Scheduler).toFlushAndYield({}) + jestExpect(root).toMatchRenderedOutput("1") + act(function() + return setCounter(2) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 2", + "Child: 2", + "Effect: 2", + }) + jestExpect(root).toMatchRenderedOutput("2") + act(function() + setCounter(0) + end) + jestExpect(Scheduler).toHaveYielded({ + "Parent: 0", + "Child: 0", + "Effect: 0", + }) + jestExpect(root).toMatchRenderedOutput("0") + act(function() + setCounter(0) + end) + jestExpect(Scheduler).toHaveYielded({ "Parent: 0" }) + jestExpect(root).toMatchRenderedOutput("0") + act(function() + setCounter(0) + end) + jestExpect(Scheduler).toFlushAndYield({}) + jestExpect(root).toMatchRenderedOutput("0") + act(function() + setCounter(0 / -1) + end) + -- ROBLOX deviation: use Luau stringified versions of the math + jestExpect(Scheduler).toHaveYielded({ + "Parent: -0", + "Child: -0", + "Effect: -0", + }) + jestExpect(root).toMatchRenderedOutput("-0") + end + ) + it("bails out multiple times in a row without entering render phase", function() + local useState = React.useState + local function Child(ref) + local text = ref.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter + local function Parent() + local counter, _setCounter = useState(0) + setCounter = _setCounter + Scheduler.unstable_yieldValue("Parent: " .. tostring(counter)) + return React.createElement(Child, { text = counter }) + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(Parent)) + jestExpect(Scheduler).toFlushAndYield({ "Parent: 0", "Child: 0" }) + jestExpect(root).toMatchRenderedOutput("0") + local function update(value) + setCounter(function(previous) + Scheduler.unstable_yieldValue( + string.format( + "Compute state (%s -> %s)", + tostring(previous), + tostring(value) + ) + ) + return value + end) + end + ReactTestRenderer.unstable_batchedUpdates(function() + update(0) + update(0) + update(0) + update(1) + update(2) + update(3) + end) + jestExpect(Scheduler).toHaveYielded({ + "Compute state (0 -> 0)", + "Compute state (0 -> 0)", + "Compute state (0 -> 0)", + "Compute state (0 -> 1)", + }) + jestExpect(Scheduler).toFlushAndYield({ + "Compute state (1 -> 2)", + "Compute state (2 -> 3)", + "Parent: 3", + "Child: 3", + }) + jestExpect(root).toMatchRenderedOutput("3") + end) + it("can rebase on top of a previously skipped update", function() + local useState = React.useState + local function Child(ref) + local text = ref.text + Scheduler.unstable_yieldValue("Child: " .. tostring(text)) + return text + end + local setCounter + local function Parent() + local counter, _setCounter = useState(1) + setCounter = _setCounter + Scheduler.unstable_yieldValue("Parent: " .. tostring(counter)) + return React.createElement(Child, { text = counter }) + end + local root = ReactTestRenderer.create(nil, { unstable_isConcurrent = true }) + root.update(React.createElement(Parent)) + jestExpect(Scheduler).toFlushAndYield({ "Parent: 1", "Child: 1" }) + jestExpect(root).toMatchRenderedOutput("1") + local function update(compute) + setCounter(function(previous) + local value = compute(previous) + Scheduler.unstable_yieldValue( + string.format( + "Compute state (%s -> %s)", + tostring(previous), + tostring(value) + ) + ) + return value + end) + end + ReactTestRenderer.unstable_batchedUpdates(function() + return update(function(n) + return n * 100 + end) + end) + jestExpect(Scheduler).toHaveYielded({ "Compute state (1 -> 100)" }) + root.unstable_flushSync(function() + update(function(n) + return n + 5 + end) + end) + jestExpect(Scheduler).toHaveYielded({ + "Compute state (1 -> 6)", + "Parent: 6", + "Child: 6", + }) + jestExpect(root).toMatchRenderedOutput("6") + jestExpect(Scheduler).toFlushAndYield({ + "Compute state (100 -> 105)", + "Parent: 105", + "Child: 105", + }) + jestExpect(root).toMatchRenderedOutput("105") + end) + -- ROBLOX deviation START: Skip this test, as the warning has been + -- silenced to accomodate nils in dependency arrays. We'd like to + -- address this possible misuse with a lint instead + -- https://jira.rbx.com/browse/LUAFDN-1175 + xit("warns about variable number of dependencies", function() + local useLayoutEffect = React.useLayoutEffect + local function App(props) + useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "Did commit: " .. tostring(Array.join(props.dependencies, ", ")) + ) + end, props.dependencies) + return props.dependencies + end + local root = + ReactTestRenderer.create(React.createElement(App, { dependencies = { "A" } })) + jestExpect(Scheduler).toHaveYielded({ "Did commit: A" }) + jestExpect(function() + root.update(React.createElement(App, { dependencies = { "A", "B" } })) + end).toErrorDev({ + "Warning: The final argument passed to useLayoutEffect changed size " + .. "between renders. The order and size of this array must remain " + .. "constant.\n\n" + .. 'Previous: ["A"]\n' + .. 'Incoming: ["A", "B"]\n', + }) + end) + -- ROBLOX deviation END + it("warns if switching from dependencies to no dependencies", function() + local useMemo = React.useMemo + local function App(props) + local text, hasDeps = props.text, props.hasDeps + local resolvedText = useMemo( + function() + Scheduler.unstable_yieldValue("Compute") + -- ROBLOX TODO: add String.toUpperCase to polyfill + return string.upper(text) + end, + -- ROBLOX Luau FIXME: Luau forced me put in this annotation to avoid Type '{string}' could not be converted into 'nil' + (function(): { string } | nil + if hasDeps then + return nil + else + return { text } + end + end)() + ) + return resolvedText + end + local root = ReactTestRenderer.create(nil) + root.update(React.createElement(App, { text = "Hello", hasDeps = true })) + jestExpect(Scheduler).toHaveYielded({ "Compute" }) + jestExpect(root).toMatchRenderedOutput("HELLO") + jestExpect(function() + root.update(React.createElement(App, { text = "Hello", hasDeps = false })) + end).toErrorDev({ + "Warning: useMemo received a final argument during this render, but " + .. "not during the previous render. Even though the final argument is " + .. "optional, its type cannot change between renders.", + }) + end) + it("warns if deps is not an array", function() + local useEffect, useLayoutEffect, useMemo, useCallback = + React.useEffect, React.useLayoutEffect, React.useMemo, React.useCallback + local function App(props) + useEffect(function() end, props.deps) + useLayoutEffect(function() end, props.deps) + -- ROBLOX TODO: upstream this type safety fix + useMemo(function() + return nil + end, props.deps) + useCallback(function() end, props.deps) + return nil + end + jestExpect(function() + act(function() + -- ROBLOX TODO: upstream this hard cast, since this abuse case violates the API + ReactTestRenderer.create( + React.createElement(App, { deps = "hello" :: any }) + ) + end) + end).toErrorDev({ + "Warning: useEffect received a final argument that is not an array (instead, received `string`). " + .. "When specified, the final argument must be an array.", + "Warning: useLayoutEffect received a final argument that is not an array (instead, received `string`). " + .. "When specified, the final argument must be an array.", + "Warning: useMemo received a final argument that is not an array (instead, received `string`). " + .. "When specified, the final argument must be an array.", + "Warning: useCallback received a final argument that is not an array (instead, received `string`). " + .. "When specified, the final argument must be an array.", + }) + jestExpect(function() + act(function() + -- ROBLOX TODO: upstream this hard cast, since this abuse case violates the API + ReactTestRenderer.create( + React.createElement(App, { deps = 100500 :: any }) + ) + end) + end).toErrorDev({ + "Warning: useEffect received a final argument that is not an array (instead, received `number`). " + .. "When specified, the final argument must be an array.", + "Warning: useLayoutEffect received a final argument that is not an array (instead, received `number`). " + .. "When specified, the final argument must be an array.", + "Warning: useMemo received a final argument that is not an array (instead, received `number`). " + .. "When specified, the final argument must be an array.", + "Warning: useCallback received a final argument that is not an array (instead, received `number`). " + .. "When specified, the final argument must be an array.", + }) + jestExpect(function() + act(function() + -- ROBLOX deviation: empty table isn't distinguishable from an array + ReactTestRenderer.create( + -- ROBLOX TODO: upstream this hard cast, since this abuse case violates the API + React.createElement(App, { deps = { notempty = true } :: any }) + ) + end) + end).toErrorDev({ + -- ROBLOX deviation: table type instead of object + "Warning: useEffect received a final argument that is not an array (instead, received `table`). " + .. "When specified, the final argument must be an array.", + "Warning: useLayoutEffect received a final argument that is not an array (instead, received `table`). " + .. "When specified, the final argument must be an array.", + "Warning: useMemo received a final argument that is not an array (instead, received `table`). " + .. "When specified, the final argument must be an array.", + "Warning: useCallback received a final argument that is not an array (instead, received `table`). " + .. "When specified, the final argument must be an array.", + }) + act(function() + ReactTestRenderer.create(React.createElement(App, { deps = {} })) + ReactTestRenderer.create(React.createElement(App, { deps = nil })) + ReactTestRenderer.create(React.createElement(App, { deps = nil })) + end) + end) + -- ROBLOX deviation START: regresses an issue where valid use of dependencies arrays with nils would result in warnings + it("does not warn for sparse dep arrays", function() + local useEffect, useLayoutEffect, useMemo, useCallback = + React.useEffect, React.useLayoutEffect, React.useMemo, React.useCallback + local function App(props) + useEffect(function() end, props.deps) + useLayoutEffect(function() end, props.deps) + -- ROBLOX TODO: upstream this type safety fix + useMemo(function() + return nil + end, props.deps) + useCallback(function() end, props.deps) + return nil + end + jestExpect(function() + act(function() + ReactTestRenderer.create( + React.createElement(App, { deps = { nil, "world", "!" } :: { any } }) + ) + end) + end).toErrorDev({}) + jestExpect(function() + act(function() + ReactTestRenderer.create( + React.createElement( + App, + { deps = { "hello", "world", "!" } :: { any } } + ) + ) + end) + end).toErrorDev({}) + jestExpect(function() + act(function() + ReactTestRenderer.create( + React.createElement(App, { deps = { "hello", nil, "!" } :: { any } }) + ) + end) + end).toErrorDev({}) + end) + -- ROBLOX deviation END + -- ROBLOX FIXME: this test depends on fix in https://github.com/Roblox/luau-polyfill/pull/112 + xit("warns if deps is not an array for useImperativeHandle", function() + local useImperativeHandle = React.useImperativeHandle + local App = React.forwardRef(function(props: { deps: any }, ref) + useImperativeHandle(ref, function() + return nil + end, props.deps) + return nil + end) + jestExpect(function() + ReactTestRenderer.create(React.createElement(App, { deps = "hello" })) + end).toErrorDev({ + "Warning: useImperativeHandle received a final argument that is not an array (instead, received `string`). " + .. "When specified, the final argument must be an array.", + }, { withoutStack = true }) -- ROBLOX FIXME: upstream doesn't need withoutStack = true + ReactTestRenderer.create(React.createElement(App, { deps = {} })) + ReactTestRenderer.create(React.createElement(App, { deps = nil })) + ReactTestRenderer.create(React.createElement(App, { deps = nil })) + end) + it("does not forget render phase useState updates inside an effect", function() + local useState, useEffect = React.useState, React.useEffect + local function Counter() + local counter, setCounter = useState(0) + if counter == 0 then + setCounter(function(x) + return x + 1 + end) + setCounter(function(x) + return x + 1 + end) + end + useEffect(function() + setCounter(function(x) + return x + 1 + end) + setCounter(function(x) + return x + 1 + end) + end, {}) + return counter + end + local root = ReactTestRenderer.create(nil) + act(function() + root.update(React.createElement(Counter)) + end) + jestExpect(root).toMatchRenderedOutput("4") + end) + it( + "does not forget render phase useReducer updates inside an effect with hoisted reducer", + function() + local useReducer, useEffect = React.useReducer, React.useEffect + -- ROBLOX Luau FIXME: Luau should know x is number because of the useReducer<> generic: https://jira.rbx.com/browse/CLI-29033 + -- ROBLOX Luau FIXME: I have to explicit add _action: nil, but it should be inferred: https://jira.rbx.com/browse/CLI-49121 + local function reducer(x: number, _action: nil) + return x + 1 + end + local function Counter() + local counter, increment = useReducer(reducer, 0) + if counter == 0 then + increment() + increment() + end + useEffect(function() + increment() + increment() + end, {}) + return counter + end + local root = ReactTestRenderer.create(nil) + act(function() + root.update(React.createElement(Counter)) + end) + jestExpect(root).toMatchRenderedOutput("4") + end + ) + it( + "does not forget render phase useReducer updates inside an effect with inline reducer", + function() + local useReducer, useEffect = React.useReducer, React.useEffect + local function Counter() + -- ROBLOX Luau FIXME: Luau should know x is number because of the useReducer<> generic: https://jira.rbx.com/browse/CLI-29033 + -- ROBLOX Luau FIXME: I have to explicit add _action: nil, but it should be inferred: https://jira.rbx.com/browse/CLI-49121 + local counter, increment = useReducer(function(x: number, _action: nil) + return x + 1 + end, 0) + if counter == 0 then + increment() + increment() + end + useEffect(function() + increment() + increment() + end, {}) + return counter + end + local root = ReactTestRenderer.create(nil) + act(function() + root.update(React.createElement(Counter)) + end) + jestExpect(root).toMatchRenderedOutput("4") + end + ) + it("warns for bad useImperativeHandle first arg", function() + local useImperativeHandle = React.useImperativeHandle + local function App() + -- ROBLOX deviation: Luau types prevent this abuse, so we cast away to any to test the scenario + (useImperativeHandle :: any)({ focus = function(self) end }) + return nil + end + jestExpect(function() + jestExpect(function() + ReactTestRenderer.create(React.createElement(App)) + -- ROBLOX deviation: Lua has different error when trying to call a nil + end).toThrow("attempt to call a nil value") + end).toErrorDev({ + "Expected useImperativeHandle() first argument to either be a " + .. "ref callback or React.createRef() object. " + .. "Instead received: an object with keys {focus}.", + "Warning: Expected useImperativeHandle() second argument to be a function " + -- ROBLOX deviation: nil instead of undefined + .. "that creates a handle. Instead received: nil.", + }) + end) + it("warns for bad useImperativeHandle second arg", function() + local useImperativeHandle = React.useImperativeHandle + local App = React.forwardRef(function(props, ref) + -- ROBLOX deviation: Luau types prevent this abuse, so we cast away to any to test the scenario + (useImperativeHandle :: any)(ref, { focus = function(self) end }) + return nil + end) + jestExpect(function() + ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev({ + -- ROBLOX deviation: Lua says `table` instead of `object` + "Expected useImperativeHandle() second argument to be a function " + .. "that creates a handle. Instead received: table.", + }) + end) + -- it("works with ReactDOMServer calls inside a component", function() + -- local useState = React.useState + -- local function App(props) + -- local markup1 = ReactDOMServer:renderToString( + -- React.createElement("p", nil, "hello") + -- ) + -- local markup2 = ReactDOMServer:renderToStaticMarkup( + -- React.createElement("p", nil, "bye") + -- ) + -- local counter = useState(0) + -- return markup1 + counter + markup2 + -- end + -- local root = ReactTestRenderer.create(React.createElement(App)) + -- jestExpect(root.toJSON()).toMatchSnapshot() + -- end) + it("throws when calling hooks inside .memo's compare function", function() + local useState = React.useState + local function App() + useState(0) + return nil + end + local MemoApp = React.memo(App, function() + useState(0) + return false + end) + local root = ReactTestRenderer.create(React.createElement(MemoApp)) + jestExpect(function() + return root.update(React.createElement(MemoApp)) + end).toThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + jestExpect(function() + return root.update(React.createElement(MemoApp)) + end).never.toThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + jestExpect(function() + return root.update(React.createElement(MemoApp)) + end).toThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + end) + it("warns when calling hooks inside useMemo", function() + local useMemo, useState = React.useMemo, React.useState + local function App() + useMemo(function() + useState(0) + return nil + end) + return nil + end + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev( + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks." + ) + end) + it("warns when reading context inside useMemo", function() + local useMemo, createContext = React.useMemo, React.createContext + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = createContext("light") + local function App() + return useMemo(function() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + return ReactCurrentDispatcher.current.readContext(ThemeContext) + end, {}) + end + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev("Context can only be read while React is rendering") + end) + it("warns when reading context inside useMemo after reading outside it", function() + local useMemo, createContext = React.useMemo, React.createContext + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = createContext("light") + local firstRead, secondRead + local function App() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert(ReactCurrentDispatcher.current ~= nil, "current dispatcher is nil!") + firstRead = ReactCurrentDispatcher.current.readContext(ThemeContext) + useMemo(function() + return nil + end) + secondRead = ReactCurrentDispatcher.current.readContext(ThemeContext) + return useMemo(function() + return ReactCurrentDispatcher.current.readContext(ThemeContext) + end, {}) + end + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev("Context can only be read while React is rendering") + jestExpect(firstRead).toBe("light") + jestExpect(secondRead).toBe("light") + end) + -- ROBLOX FIXME: throw happens in ReactFiberHooks, but error doesn't propagate + xit("throws when reading context inside useEffect", function() + local useEffect, createContext = React.useEffect, React.createContext + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = createContext("light") + local function App() + useEffect(function() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + ReactCurrentDispatcher.current.readContext(ThemeContext) + end) + return nil + end + jestExpect(function() + act(function() + ReactTestRenderer.create(React.createElement(App)) + end) + end).toThrow("Context can only be read while React is rendering") + end) + it("throws when reading context inside useLayoutEffect", function() + local useLayoutEffect, createContext = React.useLayoutEffect, React.createContext + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = createContext("light") + local function App() + useLayoutEffect(function() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + ReactCurrentDispatcher.current.readContext(ThemeContext) + end) + return nil + end + -- ROBLOX Test Noise: jest setup config makes this hide error + -- boundary warnings in upstream (scripts/jest/setupTests.js:72) + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toThrow("Context can only be read while React is rendering") + end) + it("warns when reading context inside useReducer", function() + local useReducer, createContext = React.useReducer, React.createContext + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = createContext("light") + local function App() + local state, dispatch = useReducer(function(s, action) + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + ReactCurrentDispatcher.current.readContext(ThemeContext) + return action + end, 0) + if state == 0 then + dispatch(1) + end + return nil + end + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev({ "Context can only be read while React is rendering" }) + end) + it("warns when reading context inside eager useReducer", function() + local useState, createContext = React.useState, React.createContext + local ThemeContext = createContext("light") + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local _setState + local function Fn() + -- ROBLOX TODO: this is `0` upstream, which is incorrect + local _, setState = useState(nil :: string?) + _setState = setState + return nil + end + local Cls = React.Component:extend("Cls") + function Cls:render() + _setState(function() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + return ReactCurrentDispatcher.current.readContext(ThemeContext) + end) + return nil + end + jestExpect(function() + return ReactTestRenderer.create( + React.createElement( + React.Fragment, + nil, + React.createElement(Fn), + React.createElement(Cls) + ) + ) + end).toErrorDev({ + "Context can only be read while React is rendering", + "Cannot update a component (`Fn`) while rendering a different component (`Cls`).", + }) + end) + it("warns when calling hooks inside useReducer", function() + local useReducer, useState, useRef = + React.useReducer, React.useState, React.useRef + local function App() + -- ROBLOX Luau FIXME: Luau should know x is number because of the useReducer<> generic: https://jira.rbx.com/browse/CLI-29033 + -- ROBLOX Luau FIXME: I have to explicit add _action: nil, but it should be inferred: https://jira.rbx.com/browse/CLI-49121 + local value, dispatch = useReducer(function(state: number, _action) + useRef(0) + return state + 1 + end, 0) + if value == 0 then + dispatch("foo") + end + useState(0) + return value + end + jestExpect(function() + jestExpect(function() + ReactTestRenderer.create(React.createElement(App)) + end).toThrow("Rendered more hooks than during the previous render.") + end).toErrorDev({ + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. "1. useReducer useReducer\n" + .. "2. useState useRef\n" + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", + }) + end) + it("warns when calling hooks inside useState's initialize function", function() + local useState, useRef = React.useState, React.useRef + local function App() + useState(function() + useRef(0) + return 0 + end) + return nil + end + jestExpect(function() + return ReactTestRenderer.create(React.createElement(App)) + end).toErrorDev( + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks." + ) + end) + it("resets warning internal state when interrupted by an error", function() + local ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher + local ThemeContext = React.createContext("light") + local function App() + React.useMemo(function() + -- ROBLOX TODO: this assert/invariant should be in upstream + assert( + ReactCurrentDispatcher.current ~= nil, + "current dispatcher is nil!" + ) + ReactCurrentDispatcher.current.readContext(ThemeContext) + React.useRef(0) + error(Error.new("No.")) + end, {}) + end + type Boundary = { state: any, render: any } --[[ ROBLOX TODO: replace 'any' type/ add missing ]] + local Boundary = React.Component:extend("Boundary") + function Boundary:init() + self:setState({}) + end + function Boundary.getDerivedStateFromError(error_) + return { err = true } + end + function Boundary:render() + if self.state.err then + return "Oops" + end + return self.props.children + end + jestExpect(function() + ReactTestRenderer.create( + React.createElement(Boundary, nil, React.createElement(App)) + ) + end).toErrorDev({ + "Context can only be read while React is rendering", + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + "Context can only be read while React is rendering", + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + }) + local function Valid() + React.useState(0) + React.useMemo(function() + return nil + end) + React.useReducer(function() + return nil + end, 0) + React.useEffect(function() end) + React.useLayoutEffect(function() end) + React.useCallback(function() end) + React.useRef(0) + React.useImperativeHandle(function() + return nil + end, function() + return nil + end) + if ReactGlobals.__DEV__ then + React.useDebugValue(0) + end + return nil + end + act(function() + ReactTestRenderer.create(React.createElement(Valid)) + end) + jestExpect(function() + ReactTestRenderer.create( + React.createElement(Boundary, nil, React.createElement(App)) + ) + end).toErrorDev({ + "Context can only be read while React is rendering", + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + "Context can only be read while React is rendering", + "Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks", + }) + end) + -- ROBLOX deviation: upstream has 2 tests with same name, which TestEZ doesn't allow + it("double-invokes components with Hooks in Strict Mode", function() + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = true + local useState, StrictMode = React.useState, React.StrictMode + local renderCount = 0 + local function NoHooks() + renderCount += 1 + return React.createElement("div") + end + local function HasHooks() + useState(0) + renderCount += 1 + return React.createElement("div") + end + local FwdRef = React.forwardRef(function(props, ref) + renderCount += 1 + return React.createElement("div") + end) + local FwdRefHasHooks = React.forwardRef(function(props, ref) + useState(0) + renderCount += 1 + return React.createElement("div") + end) + local Memo = React.memo(function(props) + renderCount += 1 + return React.createElement("div") + end) + local MemoHasHooks = React.memo(function(props) + useState(0) + renderCount += 1 + return React.createElement("div") + end) + local function Factory() + return { + state = {}, + render = function(self) + renderCount += 1 + return React.createElement("div") + end, + } + end + local renderer = ReactTestRenderer.create(nil) + renderCount = 0 + renderer.update(React.createElement(NoHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(NoHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(NoHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(NoHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update(React.createElement(FwdRef)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(FwdRef)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(StrictMode, nil, React.createElement(FwdRef))) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update(React.createElement(StrictMode, nil, React.createElement(FwdRef))) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update(React.createElement(Memo, { arg = 1 })) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(Memo, { arg = 2 })) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(Memo, { arg = 1 })) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(Memo, { arg = 2 })) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + if not ReactFeatureFlags.disableModulePatternComponents then + renderCount = 0 + jestExpect(function() + return renderer.update(React.createElement(Factory)) + end).toErrorDev( + "Warning: The component appears to be a function component that returns a class instance. " + .. "Change Factory to a class that extends React.Component instead. " + -- ROBLOX deviation: we exclude this JS specific advice + -- .. "If you can't use a class try assigning the prototype on the function as a workaround. " + -- .. "`Factory.prototype = React.Component.prototype`. " + -- .. "Don't use an arrow function since it cannot be called with `new` by React." + ) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(Factory)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(Factory)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(Factory)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + end + renderCount = 0 + renderer.update(React.createElement(HasHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(HasHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(HasHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(HasHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update(React.createElement(FwdRefHasHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(FwdRefHasHooks)) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(FwdRefHasHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement(StrictMode, nil, React.createElement(FwdRefHasHooks)) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update(React.createElement(MemoHasHooks, { arg = 1 })) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update(React.createElement(MemoHasHooks, { arg = 2 })) + jestExpect(renderCount).toBe(1) + renderCount = 0 + renderer.update( + React.createElement( + StrictMode, + nil, + React.createElement(MemoHasHooks, { arg = 1 }) + ) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + renderCount = 0 + renderer.update( + React.createElement( + StrictMode, + nil, + React.createElement(MemoHasHooks, { arg = 2 }) + ) + ) + jestExpect(renderCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false + end) + it("double-invokes useMemo in DEV StrictMode despite []", function() + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = true + local useMemo, StrictMode = React.useMemo, React.StrictMode + local useMemoCount = 0 + local function BadUseMemo() + useMemo(function() + useMemoCount += 1 + return nil + end, {}) + return React.createElement("div") + end + useMemoCount = 0 + ReactTestRenderer.create( + React.createElement(StrictMode, nil, React.createElement(BadUseMemo)) + ) + jestExpect(useMemoCount).toBe(ReactGlobals.__DEV__ and 2 or 1) + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false + end) + describe("hook ordering", function() + local function useCallbackHelper() + return React.useCallback(function() end, {}) + end + local function useContextHelper() + return React.useContext(React.createContext(0)) + end + local function useDebugValueHelper() + React.useDebugValue("abc") + end + local function useEffectHelper() + React.useEffect(function() + return function() end + end, {}) + end + local function useImperativeHandleHelper() + React.useImperativeHandle({ current = 0 }, function() + return 0 + end, {}) + end + local function useLayoutEffectHelper() + React.useLayoutEffect(function() + return function() end + end, {}) + end + local function useMemoHelper() + return React.useMemo(function() + return 123 + end, {}) + end + local function useReducerHelper() + return React.useReducer(function(s, a) + return a + end, 0) + end + local function useRefHelper() + return React.useRef(nil) + end + local function useStateHelper(): number + return React.useState(0) + end + local orderedHooks: Array = { + useCallbackHelper, + useContextHelper, + useDebugValueHelper, + useEffectHelper, + useLayoutEffectHelper, + useMemoHelper, + useReducerHelper, + useRefHelper, + useStateHelper, + } + local hooksInList: Array = { + useCallbackHelper, + useEffectHelper, + useImperativeHandleHelper, + useLayoutEffectHelper, + useMemoHelper, + useReducerHelper, + useRefHelper, + useStateHelper, + } + -- ROBLOX TODO: unflag this when we implement useTransition and useDeferredValueHelper + if ReactGlobals.__EXPERIMENTAL__ then + -- local function useTransitionHelper() + -- return React.useTransition() + -- end + -- local function useDeferredValueHelper() + -- return React.useDeferredValue(0, { timeoutMs = 1000 }) + -- end + -- table.insert(orderedHooks, useTransitionHelper) + -- table.insert(orderedHooks, useDeferredValueHelper) + -- table.insert(hooksInList, useTransitionHelper) + -- table.insert(hooksInList, useDeferredValueHelper) + end + local function formatHookNamesToMatchErrorMessage(hookNameA, hookNameB) + return string.format( + "use%s%s%s", + hookNameA, + string.rep(" ", 24 - string.len(hookNameA)), + (function() + if hookNameB then + return string.format("use%s", hookNameB) + else + return "" + end + end)() + ) + end + -- ROBLOX Luau FIXME: Luau should infer the correct T and not require explicit annotations here + Array.forEach(orderedHooks, function(firstHelper: () -> (), index: number) + local secondHelper = (function() + if + index + > 0 --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + then + return orderedHooks[index] + else + return orderedHooks[#orderedHooks] + end + end)() + -- ROBLOX deviation: functions can't have fields in Lua + -- local hookNameA = firstHelper.name + local hookNameA = + debug.info(firstHelper, "n"):gsub("use", ""):gsub("Helper", "") + -- ROBLOX deviation: functions can't have fields in Lua + -- local hookNameB = secondHelper.name + local hookNameB = + debug.info(secondHelper, "n"):gsub("use", ""):gsub("Helper", "") + -- ROBLOX FIXME: gives error about fewer hooks than expected instead + xit( + ("warns on using differently ordered hooks (%s, %s) on subsequent renders"):format( + hookNameA, + hookNameB + ), + function() + local function App(props) + if props.update then + secondHelper() + firstHelper() + else + firstHelper() + secondHelper() + end + useRefHelper() + return nil + end + local root + act(function() + root = ReactTestRenderer.create( + React.createElement(App, { update = false }) + ) + end) + jestExpect(function() + xpcall(function() + act(function() + root.update(React.createElement(App, { update = true })) + end) + end, function(error_) end) + end).toErrorDev({ + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. string.format( + "1. %s\n", + formatHookNamesToMatchErrorMessage(hookNameA, hookNameB) + ) + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n" + .. " in App (at **)", + }) + xpcall(function() + act(function() + root.update(React.createElement(App, { update = false })) + end) + end, function(error_) end) + end + ) + it( + string.format( + "warns when more hooks (%s, %s) are used during update than mount", + hookNameA, + hookNameB + ), + function() + local function App(props) + if props.update then + firstHelper() + secondHelper() + else + firstHelper() + end + return nil + end + local root + act(function() + root = ReactTestRenderer.create( + React.createElement(App, { update = false }) + ) + end) + jestExpect(function() + xpcall(function() + act(function() + root.update(React.createElement(App, { update = true })) + end) + end, function(error_) end) + end).toErrorDev({ + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. string.format( + "1. %s\n", + formatHookNamesToMatchErrorMessage(hookNameA, hookNameA) + ) + .. ("2. undefined use%s\n"):format(hookNameB) + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n" + .. " in App (at **)", + }) + end + ) + end) + -- ROBLOX Luau FIXME: Luau should infer the correct T and not require explicit annotations here + Array.forEach(hooksInList, function(firstHelper: () -> (), index: number) + local secondHelper = (function() + if + index + > 0 --[[ ROBLOX CHECK: operator '>' works only if either both arguments are strings or both are a number ]] + then + return hooksInList[index] + else + return hooksInList[#hooksInList] + end + end)() + -- ROBLOX deviation: functions can't have fields in Lua + -- local hookNameA = firstHelper.name + local hookNameA = + debug.info(firstHelper, "n"):gsub("use", ""):gsub("Helper", "") + -- ROBLOX deviation: functions can't have fields in Lua + -- local hookNameB = secondHelper.name + local hookNameB = + debug.info(secondHelper, "n"):gsub("use", ""):gsub("Helper", "") + -- ROBLOX FIXME: it is throwing the error, but toThrowError isn't unpacking the Error object? + xit( + string.format( + "warns when fewer hooks (%s, %s) are used during update than mount", + hookNameA, + hookNameB + ), + function() + local function App(props) + if props.update then + firstHelper() + else + firstHelper() + secondHelper() + end + return nil + end + local root + act(function() + root = ReactTestRenderer.create( + React.createElement(App, { update = false }) + ) + end) + jestExpect(function() + act(function() + root.update(React.createElement(App, { update = true })) + end) + end).toThrow("Rendered fewer hooks than expected.") + end + ) + end) + -- ROBLOX FIXME: gives fewer hooks rendered error instead + xit( + "warns on using differently ordered hooks " + .. "(useImperativeHandleHelper, useMemoHelper) on subsequent renders", + function() + local function App(props) + if props.update then + useMemoHelper() + useImperativeHandleHelper() + else + useImperativeHandleHelper() + useMemoHelper() + end + useRefHelper() + return nil + end + local root = + ReactTestRenderer.create(React.createElement(App, { update = false })) + jestExpect(function() + xpcall(function() + root.update(React.createElement(App, { update = true })) + end, function(error_) end) + end).toErrorDev({ + -- ROBLOX deviation: we put 'Warning' on the front of this for some reason + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. string.format( + "1. %s\n", + formatHookNamesToMatchErrorMessage("ImperativeHandle", "Memo") + ) + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n" + .. " in App (at **)", + }) + root.update(React.createElement(App, { update = false })) + end + ) + it("detects a bad hook order even if the component throws", function() + local useState, useReducer = React.useState, React.useReducer + local function useCustomHook() + useState(0) + end + local function App(props) + if props.update then + useCustomHook() + useReducer(function(s, a) + return a + end, 0) + error(Error.new("custom error")) + else + useReducer(function(s, a) + return a + end, 0) + useCustomHook() + end + return nil + end + local root = + ReactTestRenderer.create(React.createElement(App, { update = false })) + jestExpect(function() + jestExpect(function() + return root.update(React.createElement(App, { update = true })) + end).toThrow("custom error") + end).toErrorDev({ + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. "1. useReducer useState\n" + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", + }) + end) + end) + it( + "does not swallow original error when updating another component in render phase", + function() + local useState = React.useState + local _setState + local function A() + local _, setState = useState(0) + _setState = setState + return nil + end + local function B() + _setState(function() + error(Error.new("Hello")) + end) + return nil + end + jestExpect(function() + act(function() + ReactTestRenderer.unstable_batchedUpdates(function() + ReactTestRenderer.create( + React.createElement( + React.Fragment, + nil, + React.createElement(A), + React.createElement(B) + ) + ) + jestExpect(function() + Scheduler.unstable_flushAll() + end).toThrow("Hello") + end) + end) + -- ROBLOX deviation: use toErrorDev instead of spyOn(console, 'error') + end).toErrorDev( + "Warning: Cannot update a component (`A`) while rendering " + .. "a different component (`B`)." + ) + end + ) + -- Regression test for https://github.com/facebook/react/issues/15057 + it( + "does not fire a false positive warning when previous effect unmounts the component", + function() + local A, B, C + local useState, useEffect = React.useState, React.useEffect + local globalListener + function A() + local show, setShow = useState(true) + local function hideMe() + setShow(false) + end + return if show then React.createElement(B, { hideMe = hideMe }) else nil + end + function B(props) + return React.createElement(C, props) + end + function C(ref) + local hideMe = ref.hideMe + local _, setState = useState("") + useEffect(function() + local isStale = false + globalListener = function() + if not isStale then + setState("hello") + end + end + return function() + isStale = true + hideMe() + end + end) + return nil + end + act(function() + ReactTestRenderer.create(React.createElement(A)) + end) + jestExpect(function() + globalListener() + globalListener() + end).toErrorDev({ + "An update to C inside a test was not wrapped in act", + "An update to C inside a test was not wrapped in act", + }) + end + ) + it("does not fire a false positive warning when suspending memo", function() + local Suspense, useState = React.Suspense, React.useState + local wasSuspended = false + local function trySuspend() + if not wasSuspended then + error(Promise.delay(0):andThen(function(resolve) + wasSuspended = true + resolve() + end)) + end + end + local function Child() + useState(0) + trySuspend() + return "hello" + end + local Wrapper = React.memo(Child) + local root = ReactTestRenderer.create( + React.createElement( + Suspense, + { fallback = "loading" }, + React.createElement(Wrapper) + ) + ) + jestExpect(root).toMatchRenderedOutput("loading") + Promise.delay(0):await() + Scheduler.unstable_flushAll() + jestExpect(root).toMatchRenderedOutput("hello") + end) + it("does not fire a false positive warning when suspending forwardRef", function() + local Suspense, useState = React.Suspense, React.useState + local wasSuspended = false + local function trySuspend() + if not wasSuspended then + error(Promise.delay(0):andThen(function(resolve) + wasSuspended = true + resolve() + end)) + end + end + local function render(props, ref) + useState(0) + trySuspend() + return "hello" + end + local Wrapper = React.forwardRef(render) + local root = ReactTestRenderer.create( + React.createElement( + Suspense, + { fallback = "loading" }, + React.createElement(Wrapper) + ) + ) + jestExpect(root).toMatchRenderedOutput("loading") + Promise.delay(0):await() + Scheduler.unstable_flushAll() + jestExpect(root).toMatchRenderedOutput("hello") + end) + it( + "does not fire a false positive warning when suspending memo(forwardRef)", + function() + local Suspense, useState = React.Suspense, React.useState + local wasSuspended = false + local function trySuspend() + if not wasSuspended then + error(Promise.delay(0):andThen(function(resolve) + wasSuspended = true + resolve() + end)) + end + end + local function render(props, ref) + useState(0) + trySuspend() + return "hello" + end + local Wrapper = React.memo(React.forwardRef(render)) + local root = ReactTestRenderer.create( + React.createElement( + Suspense, + { fallback = "loading" }, + React.createElement(Wrapper) + ) + ) + jestExpect(root).toMatchRenderedOutput("loading") + Promise.delay(0):await() + Scheduler.unstable_flushAll() + jestExpect(root).toMatchRenderedOutput("hello") + end + ) + it("resets hooks when an error is thrown in the middle of a list of hooks", function() + local useEffect, useState = React.useEffect, React.useState + -- ROBLOX deviation: hoist + local function Wrapper(props) + local children = props.children + return children + end + local ErrorBoundary = React.Component:extend("ErrorBoundary") + function ErrorBoundary:init() + self:setState({}) + end + function ErrorBoundary.getDerivedStateFromError() + return { hasError = true } + end + function ErrorBoundary:render() + return React.createElement( + Wrapper, + nil, + self.state.hasError and "Error!" or self.props.children + ) + end + local setShouldThrow + local function Thrower() + local shouldThrow, _setShouldThrow = useState(false) + setShouldThrow = _setShouldThrow + if shouldThrow then + error(Error.new("Throw!")) + end + useEffect(function() end, {}) + return "Throw!" + end + local root + act(function() + root = ReactTestRenderer.create( + React.createElement(ErrorBoundary, nil, React.createElement(Thrower)) + ) + end) + jestExpect(root).toMatchRenderedOutput("Throw!") + act(function() + return setShouldThrow(true) + end) + jestExpect(root).toMatchRenderedOutput("Error!") + end) +end) diff --git a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua index aff7c1c9..e9c598fb 100644 --- a/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer.spec.lua @@ -1,4 +1,5 @@ --- upstream: https://github.com/facebook/react/blob/99cae887f3a8bde760a111516d254c1225242edf/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +--!strict +-- ROBLOX upstream: https://github.com/facebook/react/blob/99cae887f3a8bde760a111516d254c1225242edf/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -13,12 +14,12 @@ local Packages = script.Parent.Parent.Parent local React +local Promise local LuauPolyfill = require(Packages.LuauPolyfill) local clearTimeout = LuauPolyfill.clearTimeout local setTimeout = LuauPolyfill.setTimeout local Array = LuauPolyfill.Array -local Promise = require(Packages.Promise) - +type Array = LuauPolyfill.Array -- local textCache -- local readText @@ -34,6 +35,8 @@ local useLayoutEffect local useCallback local useMemo local useRef +-- ROBLOX deviation: binding support +local useBinding local useImperativeHandle -- local useTransition -- local useDeferredValue @@ -41,1342 +44,1376 @@ local forwardRef local memo local act -return function() - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) - - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() - - React = require(Packages.React) - ReactNoop = require(Packages.Dev.ReactNoopRenderer) - Scheduler = require(Packages.Scheduler) - -- SchedulerTracing = require(Scheduler.tracing) - useState = React.useState - useReducer = React.useReducer - useEffect = React.useEffect - useLayoutEffect = React.useLayoutEffect - useCallback = React.useCallback - useMemo = React.useMemo - useRef = React.useRef - useImperativeHandle = React.useImperativeHandle - forwardRef = React.forwardRef - memo = React.memo - -- useTransition = React.unstable_useTransition - -- useDeferredValue = React.unstable_useDeferredValue - Suspense = React.Suspense - act = ReactNoop.act - - -- textCache = new Map() - - -- readText = text => { - -- local record = textCache.get(text) - -- if record ~= undefined) - -- switch (record.status) - -- case 'pending': - -- throw record.promise - -- case 'rejected': - -- throw Error('Failed to load: ' .. text) - -- case 'resolved': - -- return text - -- end - -- } else { - -- local ping - -- local promise = new Promise(resolve => (ping = resolve)) - -- local newRecord = { - -- status: 'pending', - -- ping: ping, - -- promise, - -- end - -- textCache.set(text, newRecord) - -- throw promise - -- end - -- end - - -- resolveText = text => { - -- local record = textCache.get(text) - -- if record ~= undefined) - -- if record.status == 'pending') - -- Scheduler.unstable_yieldValue(`Promise resolved [${text}]`) - -- record.ping() - -- record.ping = nil - -- record.status = 'resolved' - -- clearTimeout(record.promise._timer) - -- record.promise = nil - -- end - -- } else { - -- local newRecord = { - -- ping: nil, - -- status: 'resolved', - -- promise: nil, - -- end - -- textCache.set(text, newRecord) - -- end - -- end - end) - - local function span(prop) - return { type = "span", hidden = false, children = {}, prop = prop } - end - - local function Text(props) - Scheduler.unstable_yieldValue(props.text) - return React.createElement("span", { - prop = props.text, - }) - end - - -- function AsyncText(props) - -- local text = props.text - -- try { - -- readText(text) - -- Scheduler.unstable_yieldValue(text) - -- return - -- } catch (promise) - -- if typeof promise.then == 'function') - -- Scheduler.unstable_yieldValue(`Suspend! [${text}]`) - -- if typeof props.ms == 'number' and promise._timer == undefined) - -- promise._timer = setTimeout(function() - -- resolveText(text) - -- }, props.ms) +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local jest = JestGlobals.jest +local beforeEach = JestGlobals.beforeEach +local describe = JestGlobals.describe +local it = JestGlobals.it +local xit = JestGlobals.xit + +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + Promise = require(Packages.Promise) + + LuauPolyfill = require(Packages.LuauPolyfill) + clearTimeout = LuauPolyfill.clearTimeout + setTimeout = LuauPolyfill.setTimeout + + React = require(Packages.React) + ReactNoop = require(Packages.Dev.ReactNoopRenderer) + Scheduler = require(Packages.Scheduler) + -- SchedulerTracing = require(Scheduler.tracing) + useState = React.useState + useReducer = React.useReducer + useEffect = React.useEffect + useLayoutEffect = React.useLayoutEffect + useCallback = React.useCallback + useMemo = React.useMemo + useRef = React.useRef + -- ROBLOX deviation: binding support + useBinding = React.useBinding + useImperativeHandle = React.useImperativeHandle + forwardRef = React.forwardRef + memo = React.memo + -- useTransition = React.unstable_useTransition + -- useDeferredValue = React.unstable_useDeferredValue + Suspense = React.Suspense + act = ReactNoop.act + + -- textCache = new Map() + + -- readText = text => { + -- local record = textCache.get(text) + -- if record ~= undefined) + -- switch (record.status) + -- case 'pending': + -- throw record.promise + -- case 'rejected': + -- throw Error('Failed to load: ' .. text) + -- case 'resolved': + -- return text -- end -- } else { - -- Scheduler.unstable_yieldValue(`Error! [${text}]`) + -- local ping + -- local promise = new Promise(resolve => (ping = resolve)) + -- local newRecord = { + -- status: 'pending', + -- ping: ping, + -- promise, + -- end + -- textCache.set(text, newRecord) + -- throw promise -- end - -- throw promise -- end - -- end - -- function advanceTimers(ms) - -- -- Note: This advances Jest's virtual time but not React's. Use - -- -- ReactNoop.expire for that. - -- if typeof ms ~= 'number') - -- throw new Error('Must specify ms') + -- resolveText = text => { + -- local record = textCache.get(text) + -- if record ~= undefined) + -- if record.status == 'pending') + -- Scheduler.unstable_yieldValue(`Promise resolved [${text}]`) + -- record.ping() + -- record.ping = nil + -- record.status = 'resolved' + -- clearTimeout(record.promise._timer) + -- record.promise = nil + -- end + -- } else { + -- local newRecord = { + -- ping: nil, + -- status: 'resolved', + -- promise: nil, + -- end + -- textCache.set(text, newRecord) + -- end -- end - -- jest.advanceTimersByTime(ms) - -- -- Wait until the end of the current tick - -- -- We cannot use a timer since we're faking them - -- return Promise.resolve().then(function()}) - -- end +end) + +local function span(prop) + return { type = "span", hidden = false, children = {}, prop = prop } +end + +local function Text(props) + Scheduler.unstable_yieldValue(props.text) + return React.createElement("span", { + prop = props.text, + }) +end + +-- function AsyncText(props) +-- local text = props.text +-- try { +-- readText(text) +-- Scheduler.unstable_yieldValue(text) +-- return +-- } catch (promise) +-- if typeof promise.then == 'function') +-- Scheduler.unstable_yieldValue(`Suspend! [${text}]`) +-- if typeof props.ms == 'number' and promise._timer == undefined) +-- promise._timer = setTimeout(function() +-- resolveText(text) +-- }, props.ms) +-- end +-- } else { +-- Scheduler.unstable_yieldValue(`Error! [${text}]`) +-- end +-- throw promise +-- end +-- end + +-- function advanceTimers(ms) +-- -- Note: This advances Jest's virtual time but not React's. Use +-- -- ReactNoop.expire for that. +-- if typeof ms ~= 'number') +-- throw new Error('Must specify ms') +-- end +-- jest.advanceTimersByTime(ms) +-- -- Wait until the end of the current tick +-- -- We cannot use a timer since we're faking them +-- return Promise.resolve().then(function()}) +-- end + +-- ROBLOX Test Noise: jest setup config makes this hide error +-- boundary warnings in upstream (scripts/jest/setupTests.js:72) +it("resumes after an interruption", function() + local function Counter(props: { label: string }, ref) + local count, updateCount = useState(0) + useImperativeHandle(ref, function() + return { updateCount = updateCount } + end) + return React.createElement( + Text, + { text = tostring(props.label) .. ": " .. count } + ) + end + -- ROBLOX TODO: upstream this rename so this can typecheck and be generally sane + local CounterRef = forwardRef(Counter) + + -- Initial mount + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { label = "Count", ref = counter })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + + -- Schedule some updates + ReactNoop.batchedUpdates(function() + counter.current.updateCount(1) + counter.current.updateCount(function(count: number) + return count + 10 + end) + end) + + -- Partially flush without committing + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - it("resumes after an interruption", function() + -- Interrupt with a high priority update + ReactNoop.flushSync(function() + ReactNoop.render(React.createElement(CounterRef, { label = "Total" })) + end) + jestExpect(Scheduler).toHaveYielded({ "Total: 0" }) + + -- Resume rendering + jestExpect(Scheduler).toFlushAndYield({ "Total: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Total: 11") }) +end) + +it("throws inside class components", function() + local BadCounter = React.Component:extend("BadCounter") + function BadCounter:render() + local count = useState(0) + return React.createElement(Text, { text = self.props.label + ": " .. count }) + end + ReactNoop.render(React.createElement(BadCounter)) + + jestExpect(Scheduler).toFlushAndThrow( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + ) + + -- Confirm that a subsequent hook works properly. + local function GoodCounter(props, ref) + local count = useState(props.initialCount) + return React.createElement(Text, { text = count }) + end + ReactNoop.render(React.createElement(GoodCounter, { initialCount = 10 })) + jestExpect(Scheduler).toFlushAndYield({ 10 }) +end) + +-- if !require('shared/ReactFeatureFlags').disableModulePatternComponents) +-- it('throws inside module-style components', function() +-- function Counter() +-- return { +-- render() +-- local [count] = useState(0) +-- return +-- }, +-- end +-- end +-- ReactNoop.render() +-- jestExpect(function() +-- jestExpect(Scheduler).toFlushAndThrow( +-- '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:\n' .. +-- '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' .. +-- '2. You might be breaking the Rules of Hooks\n' .. +-- '3. You might have more than one copy of React in the same app\n' .. +-- 'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.', +-- ), +-- ).toErrorDev( +-- 'Warning: The component appears to be a function component that returns a class instance. ' .. +-- 'Change Counter to a class that extends React.Component instead. ' .. +-- "If you can't use a class try assigning the prototype on the function as a workaround. " .. +-- '`Counter.prototype = React.Component.prototype`. ' .. +-- "Don't use an arrow function since it cannot be called with `new` by React.", +-- ) + +-- -- Confirm that a subsequent hook works properly. +-- function GoodCounter(props) +-- local [count] = useState(props.initialCount) +-- return +-- end +-- ReactNoop.render() +-- jestExpect(Scheduler).toFlushAndYield([10]) +-- }) +-- end + +-- ROBLOX note: test aligned to React 18 so we get a hot path optimization in upstream +it("throws when called outside the render phase", function() + jestExpect(function() + jestExpect(function() + useState(0) + end).toThrow( + -- ROBLOX deviation: Lua-specific error on nil deref + "attempt to index nil with 'useState'" + ) + end).toErrorDev( + "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:\n" + .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + .. "2. You might be breaking the Rules of Hooks\n" + .. "3. You might have more than one copy of React in the same app\n" + .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.", + { withoutStack = true } + ) +end) + +describe("useState", function() + it("simple mount and update", function() local function Counter(props, ref) local count, updateCount = useState(0) useImperativeHandle(ref, function() return { updateCount = updateCount } end) - return React.createElement(Text, { text = tostring(props.label) .. ": " .. count }) + return React.createElement(Text, { text = "Count: " .. count }) end - Counter = forwardRef(Counter) - - -- Initial mount - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { label = "Count", ref = counter })) + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { ref = counter })) jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - -- Schedule some updates - ReactNoop.batchedUpdates(function() - counter.current.updateCount(1) - counter.current.updateCount(function(count: number) - return count + 10 - end) + act(function() + return counter.current.updateCount(1) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - -- Partially flush without committing - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - - -- Interrupt with a high priority update - ReactNoop.flushSync(function() - ReactNoop.render(React.createElement(Counter, { label = "Total" })) + act(function() + -- ROBLOX FIXME Luau: Luau should know updateCount takes is ((number) -> number) + return counter.current.updateCount(function(count_: number) + return count_ + 10 + end) end) - jestExpect(Scheduler).toHaveYielded({ "Total: 0" }) - - -- Resume rendering - jestExpect(Scheduler).toFlushAndYield({ "Total: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Total: 11") }) + jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) end) - it("throws inside class components", function() - local BadCounter = React.Component:extend("BadCounter") - function BadCounter:render() - local count = useState(0) - return React.createElement(Text, { text = self.props.label + ": " .. count }) + it("lazy state initializer", function() + local function Counter(props, ref) + local count, updateCount = useState(function() + Scheduler.unstable_yieldValue("getInitialState") + return props.initialState + end) + useImperativeHandle(ref, function() + return { updateCount = updateCount } + end) + return React.createElement(Text, { text = "Count: " .. count }) end - ReactNoop.render(React.createElement(BadCounter)) - - jestExpect(Scheduler).toFlushAndThrow( - "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:\n" - .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" - .. "2. You might be breaking the Rules of Hooks\n" - .. "3. You might have more than one copy of React in the same app\n" - .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render( + React.createElement(CounterRef, { initialState = "42", ref = counter }) ) + jestExpect(Scheduler).toFlushAndYield({ "getInitialState", "Count: 42" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 42") }) - -- Confirm that a subsequent hook works properly. - local function GoodCounter(props, ref) - local count = useState(props.initialCount) - return React.createElement(Text, { text = count }) - end - ReactNoop.render(React.createElement(GoodCounter, { initialCount = 10 })) - jestExpect(Scheduler).toFlushAndYield({ 10 }) + act(function() + return counter.current.updateCount(7) + end) + jestExpect(Scheduler).toHaveYielded({ "Count: 7" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 7") }) end) - -- if !require('shared/ReactFeatureFlags').disableModulePatternComponents) - -- it('throws inside module-style components', function() - -- function Counter() - -- return { - -- render() - -- local [count] = useState(0) - -- return - -- }, - -- end - -- end - -- ReactNoop.render() - -- jestExpect(function() - -- jestExpect(Scheduler).toFlushAndThrow( - -- '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:\n' .. - -- '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' .. - -- '2. You might be breaking the Rules of Hooks\n' .. - -- '3. You might have more than one copy of React in the same app\n' .. - -- 'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.', - -- ), - -- ).toErrorDev( - -- 'Warning: The component appears to be a function component that returns a class instance. ' .. - -- 'Change Counter to a class that extends React.Component instead. ' .. - -- "If you can't use a class try assigning the prototype on the function as a workaround. " .. - -- '`Counter.prototype = React.Component.prototype`. ' .. - -- "Don't use an arrow function since it cannot be called with `new` by React.", - -- ) - - -- -- Confirm that a subsequent hook works properly. - -- function GoodCounter(props) - -- local [count] = useState(props.initialCount) - -- return - -- end - -- ReactNoop.render() - -- jestExpect(Scheduler).toFlushAndYield([10]) - -- }) - -- end + it("multiple states", function() + local function Counter(props, ref) + local count, updateCount = useState(0) + local label, updateLabel = useState("Count") + useImperativeHandle(ref, function() + return { + updateCount = updateCount, + updateLabel = updateLabel, + } + end) + return React.createElement(Text, { text = label .. ": " .. count }) + end + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { ref = counter })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - it("throws when called outside the render phase", function() - jestExpect(function() - useState(0) - end).toThrow( - "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:\n" - .. "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" - .. "2. You might be breaking the Rules of Hooks\n" - .. "3. You might have more than one copy of React in the same app\n" - .. "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem." - ) + act(function() + return counter.current.updateCount(7) + end) + jestExpect(Scheduler).toHaveYielded({ "Count: 7" }) + + act(function() + return counter.current.updateLabel("Total") + end) + jestExpect(Scheduler).toHaveYielded({ "Total: 7" }) end) - describe("useState", function() - it("simple mount and update", function() - local function Counter(props, ref) - local count, updateCount = useState(0) - useImperativeHandle(ref, function() - return { updateCount = updateCount } - end) - return React.createElement(Text, { text = "Count: " .. count }) - end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { ref = counter })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + it("returns the same updater function every time", function() + local updater = nil + local function Counter() + local count, updateCount = useState(0) + updater = updateCount + return React.createElement(Text, { text = "Count: " .. count }) + end + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - act(function() - return counter.current.updateCount(1) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + local firstUpdater = updater - act(function() - return counter.current.updateCount(function(count_) - return count_ + 10 - end) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) + act(function() + return firstUpdater(1) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - it("lazy state initializer", function() - local function Counter(props, ref) - local count, updateCount = useState(function() - Scheduler.unstable_yieldValue("getInitialState") - return props.initialState - end) - useImperativeHandle(ref, function() - return { updateCount = updateCount } - end) - return React.createElement(Text, { text = "Count: " .. count }) - end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { initialState = 42, ref = counter })) - jestExpect(Scheduler).toFlushAndYield({ "getInitialState", "Count: 42" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 42") }) + local secondUpdater = updater - act(function() - return counter.current.updateCount(7) + act(function() + return firstUpdater(function(count) + return count + 10 end) - jestExpect(Scheduler).toHaveYielded({ "Count: 7" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 7") }) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) - it("multiple states", function() - local function Counter(props, ref) - local count, updateCount = useState(0) - local label, updateLabel = useState("Count") - useImperativeHandle(ref, function() - return { - updateCount = updateCount, - updateLabel = updateLabel, - } - end) - return React.createElement(Text, { text = label .. ": " .. count }) - end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { ref = counter })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + jestExpect(firstUpdater).toEqual(secondUpdater) + end) - act(function() - return counter.current.updateCount(7) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 7" }) + it("does not warn on set after unmount", function() + local _, updateCount + local function Counter(props, ref) + _, updateCount = useState(0) + return nil + end - act(function() - return counter.current.updateLabel("Total") - end) - jestExpect(Scheduler).toHaveYielded({ "Total: 7" }) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushWithoutYielding() + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushWithoutYielding() + act(function() + updateCount(1) end) + end) - it("returns the same updater function every time", function() - local updater = nil - local function Counter() - local count, updateCount = useState(0) - updater = updateCount - return React.createElement(Text, { text = "Count: " .. count }) - end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - - local firstUpdater = updater - - act(function() - return firstUpdater(1) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + it("works with memo", function() + local count, updateCount + local function Counter() + count, updateCount = useState(0) + return React.createElement(Text, { text = "Count: " .. count }) + end + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterMemo = memo(Counter) - local secondUpdater = updater + ReactNoop.render(React.createElement(CounterMemo)) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - act(function() - return firstUpdater(function(count) - return count + 10 - end) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) + ReactNoop.render(React.createElement(CounterMemo)) + jestExpect(Scheduler).toFlushAndYield({}) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - jestExpect(firstUpdater).toEqual(secondUpdater) + act(function() + return updateCount(1) end) - - -- ROBLOX FIXME: error message has incorrect component name "LoadedCode.RoactAlignment.Packages.Modules.React.ReactHooks" - xit("warns on set after unmount", function() - local updateCount - local function Counter(props, ref) - _, updateCount = useState(0) - return nil + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + end) +end) + +describe("updates during the render phase", function() + it("restarts the render function and applies the new updates on top", function() + local function ScrollView(props) + -- ROBLOX LUAU FIXME: without annotation, generates the + -- following error: + -- roact-alignment\modules\react-reconciler\src\__tests__\ReactHooksWithNoopRenderer.spec.lua:473:53-63: (E001) TypeError: Type '{ row: number }' could not be converted into 'any?' + -- caused by: + -- None of the union options are compatible. For example: Type '{ row: number }' could not be converted into '{+ row: never +}' + -- caused by: + -- Property 'row' is not compatible. Type 'number' could not be converted into 'never' + local newRow: number = props.row + local isScrollingDown, setIsScrollingDown = useState(false) + local row, setRow = useState(nil :: number?) + + if row ~= newRow then + -- Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row ~= nil and newRow > row) + setRow(newRow) end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushWithoutYielding() - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushWithoutYielding() - jestExpect(function() - act(function() - updateCount(1) - end) - end).toErrorDev( - "Warning: Can't perform a React state update on an unmounted " - .. "component. This is a no-op, but it indicates a memory leak in your " - .. "application. To fix, cancel all subscriptions and asynchronous " - .. "tasks in a useEffect cleanup function.\n" - .. " in Counter (at **)" - ) - end) + return React.createElement(Text, { + text = string.format("Scrolling down: %s", tostring(isScrollingDown)), + }) + end - -- ROBLOX FIXME: error message has incorrect component name "LoadedCode.RoactAlignment.Packages.Modules.React.ReactHooks" - xit("dedupes the warning by component name", function() - local _useStateCount, updateCountA - local function CounterA(props, ref) - _useStateCount, updateCountA = useState(0) - return nil - end - local updateCountB - local function CounterB(props, ref) - _useStateCount, updateCountB = useState(0) - return nil - end + ReactNoop.render(React.createElement(ScrollView, { row = 1 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) - ReactNoop.render({ React.createElement(CounterA, { key = "A" }), React.createElement(CounterB, { key = "B" }) }) - jestExpect(Scheduler).toFlushWithoutYielding() - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushWithoutYielding() - jestExpect(function() - act(function() - updateCountA(1) - end) - end).toErrorDev( - "Warning: Can't perform a React state update on an unmounted " - .. "component. This is a no-op, but it indicates a memory leak in your " - .. "application. To fix, cancel all subscriptions and asynchronous " - .. "tasks in a useEffect cleanup function.\n" - .. " in CounterA (at **)" - ) - -- already cached so this logs no error - act(function() - updateCountA(2) - end) - jestExpect(function() - act(function() - updateCountB(1) - end) - end).toErrorDev( - "Warning: Can't perform a React state update on an unmounted " - .. "component. This is a no-op, but it indicates a memory leak in your " - .. "application. To fix, cancel all subscriptions and asynchronous " - .. "tasks in a useEffect cleanup function.\n" - .. " in CounterB (at **)" - ) - end) + ReactNoop.render(React.createElement(ScrollView, { row = 5 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) - it("works with memo", function() - local count, updateCount - local function Counter() - count, updateCount = useState(0) - return React.createElement(Text, { text = "Count: " .. count }) - end - Counter = memo(Counter) + ReactNoop.render(React.createElement(ScrollView, { row = 5 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.render(React.createElement(ScrollView, { row = 10 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({}) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.render(React.createElement(ScrollView, { row = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) - act(function() - return updateCount(1) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - end) + ReactNoop.render(React.createElement(ScrollView, { row = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) end) - describe("updates during the render phase", function() - it("restarts the render function and applies the new updates on top", function() - local function ScrollView(props) - local newRow = props.row - local isScrollingDown, setIsScrollingDown = useState(false) - local row, setRow = useState(nil) - - if row ~= newRow then - -- Row changed since last render. Update isScrollingDown. - setIsScrollingDown(row ~= nil and newRow > row) - setRow(newRow) - end - - return React.createElement(Text, { text = ("Scrolling down: %s"):format(tostring(isScrollingDown)) }) - end - - ReactNoop.render(React.createElement(ScrollView, { row = 1 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) + -- ROBLOX TODO: this test uses await, need to figure that out + -- it('warns about render phase update on a different component', async function() + -- local setStep + -- function Foo() + -- local [step, _setStep] = useState(0) + -- setStep = _setStep + -- return + -- end - ReactNoop.render(React.createElement(ScrollView, { row = 5 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) + -- function Bar({triggerUpdate}) + -- if triggerUpdate) + -- setStep(x => x + 1) + -- end + -- return + -- end - ReactNoop.render(React.createElement(ScrollView, { row = 5 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) + -- local root = ReactNoop.createRoot() - ReactNoop.render(React.createElement(ScrollView, { row = 10 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: true" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: true") }) + -- await ReactNoop.act(async function() + -- root.render( + -- <> + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded(['Foo [0]', 'Bar']) - ReactNoop.render(React.createElement(ScrollView, { row = 2 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) + -- -- Bar will update Foo during its render phase. React should warn. + -- await ReactNoop.act(async function() + -- root.render( + -- <> + -- + -- + -- , + -- ) + -- jestExpect(function() + -- jestExpect(Scheduler).toFlushAndYield( + -- __DEV__ + -- ? ['Foo [0]', 'Bar', 'Foo [2]'] + -- : ['Foo [0]', 'Bar', 'Foo [1]'], + -- ), + -- ).toErrorDev([ + -- 'Cannot update a component (`Foo`) while rendering a ' .. + -- 'different component (`Bar`). To locate the bad setState() call inside `Bar`', + -- ]) + -- }) - ReactNoop.render(React.createElement(ScrollView, { row = 2 })) - jestExpect(Scheduler).toFlushAndYield({ "Scrolling down: false" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Scrolling down: false") }) - end) + -- -- It should not warn again (deduplication). + -- await ReactNoop.act(async function() + -- root.render( + -- <> + -- + -- + -- , + -- ) + -- jestExpect(Scheduler).toFlushAndYield( + -- __DEV__ + -- ? ['Foo [2]', 'Bar', 'Foo [4]'] + -- : ['Foo [1]', 'Bar', 'Foo [2]'], + -- ) + -- }) + -- }) - -- ROBLOX TODO: this test uses await, need to figure that out - -- it('warns about render phase update on a different component', async function() - -- local setStep - -- function Foo() - -- local [step, _setStep] = useState(0) - -- setStep = _setStep - -- return - -- end - - -- function Bar({triggerUpdate}) - -- if triggerUpdate) - -- setStep(x => x + 1) - -- end - -- return - -- end - - -- local root = ReactNoop.createRoot() - - -- await ReactNoop.act(async function() - -- root.render( - -- <> - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded(['Foo [0]', 'Bar']) - - -- -- Bar will update Foo during its render phase. React should warn. - -- await ReactNoop.act(async function() - -- root.render( - -- <> - -- - -- - -- , - -- ) - -- jestExpect(function() - -- jestExpect(Scheduler).toFlushAndYield( - -- __DEV__ - -- ? ['Foo [0]', 'Bar', 'Foo [2]'] - -- : ['Foo [0]', 'Bar', 'Foo [1]'], - -- ), - -- ).toErrorDev([ - -- 'Cannot update a component (`Foo`) while rendering a ' .. - -- 'different component (`Bar`). To locate the bad setState() call inside `Bar`', - -- ]) - -- }) - - -- -- It should not warn again (deduplication). - -- await ReactNoop.act(async function() - -- root.render( - -- <> - -- - -- - -- , - -- ) - -- jestExpect(Scheduler).toFlushAndYield( - -- __DEV__ - -- ? ['Foo [2]', 'Bar', 'Foo [4]'] - -- : ['Foo [1]', 'Bar', 'Foo [2]'], - -- ) - -- }) - -- }) - - it("keeps restarting until there are no more new updates", function() - local function Counter() - local count, setCount = useState(0) - if count < 3 then - setCount(count + 1) - end - Scheduler.unstable_yieldValue("Render: " .. count) - return React.createElement(Text, { text = count }) + it("keeps restarting until there are no more new updates", function() + local function Counter() + local count, setCount = useState(0) + if count < 3 then + setCount(count + 1) end + Scheduler.unstable_yieldValue("Render: " .. count) + return React.createElement(Text, { text = count }) + end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ - "Render: 0", - "Render: 1", - "Render: 2", - "Render: 3", - 3, - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(3) }) - end) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ + "Render: 0", + "Render: 1", + "Render: 2", + "Render: 3", + 3, + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(3) }) + end) - it("updates multiple times within same render function", function() - local function Counter() - local count, setCount = useState(0) - if count < 12 then - setCount(function(c) - return c + 1 - end) - setCount(function(c) - return c + 1 - end) - setCount(function(c) - return c + 1 - end) - end - Scheduler.unstable_yieldValue("Render: " .. count) - return React.createElement(Text, { text = count }) + it("updates multiple times within same render function", function() + local function Counter() + local count, setCount = useState(0) + if count < 12 then + setCount(function(c) + return c + 1 + end) + setCount(function(c) + return c + 1 + end) + setCount(function(c) + return c + 1 + end) end + Scheduler.unstable_yieldValue("Render: " .. count) + return React.createElement(Text, { text = count }) + end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ - -- Should increase by three each time - "Render: 0", - "Render: 3", - "Render: 6", - "Render: 9", - "Render: 12", - 12, - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(12) }) - end) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ + -- Should increase by three each time + "Render: 0", + "Render: 3", + "Render: 6", + "Render: 9", + "Render: 12", + 12, + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(12) }) + end) - it("throws after too many iterations", function() - local function Counter() - local count, setCount = useState(0) - setCount(count + 1) - Scheduler.unstable_yieldValue("Render: " .. count) - return React.createElement(Text, { text = count }) - end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndThrow( - "Too many re-renders. React limits the number of renders to prevent " .. "an infinite loop." - ) - end) + it("throws after too many iterations", function() + local function Counter() + local count, setCount = useState(0) + setCount(count + 1) + Scheduler.unstable_yieldValue("Render: " .. count) + return React.createElement(Text, { text = count }) + end + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndThrow( + "Too many re-renders. React limits the number of renders to prevent " + .. "an infinite loop." + ) + end) - it("works with useReducer", function() - local function reducer(state, action) - local returnVal = state - if action == "increment" then - returnVal = state + 1 - end - return returnVal + it("works with useReducer", function() + local function reducer(state: number, action) + local returnVal = state + if action == "increment" then + returnVal = state + 1 end - local function Counter(props) - local count, dispatch = useReducer(reducer, 0) - if count < 3 then - dispatch("increment") - end - Scheduler.unstable_yieldValue("Render: " .. count) - return React.createElement(Text, { text = count }) + return returnVal + end + local function Counter(props) + local count, dispatch = useReducer(reducer, 0) + if count < 3 then + dispatch("increment") end + Scheduler.unstable_yieldValue("Render: " .. count) + return React.createElement(Text, { text = count }) + end - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ - "Render: 0", - "Render: 1", - "Render: 2", - "Render: 3", - 3, - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(3) }) - end) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ + "Render: 0", + "Render: 1", + "Render: 2", + "Render: 3", + 3, + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(3) }) + end) - it("uses reducer passed at time of render, not time of dispatch", function() - -- This test is a bit contrived but it demonstrates a subtle edge case. + it("uses reducer passed at time of render, not time of dispatch", function() + -- This test is a bit contrived but it demonstrates a subtle edge case. - -- Reducer A increments by 1. Reducer B increments by 10. - local function reducerA(state, action) - if action == "increment" then - return state + 1 - elseif action == "reset" then - return 0 - else - return - end + -- Reducer A increments by 1. Reducer B increments by 10. + local function reducerA(state: number, action) + if action == "increment" then + return state + 1 + elseif action == "reset" then + return 0 end + -- ROBLOX deviation: upstream has no case, but Luau wants an explicit return + return 0 + end - local function reducerB(state, action) - if action == "increment" then - return state + 10 - elseif action == "reset" then - return 0 - else - return - end + local function reducerB(state: number, action) + if action == "increment" then + return state + 10 + elseif action == "reset" then + return 0 end + -- ROBLOX deviation: upstream has no case, but Luau wants an explicit return + return 0 + end - local function Counter(props, ref) - local reducer_, setReducer = useState(function() - return reducerA - end) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - return { dispatch = dispatch } - end) - if count < 20 then - dispatch("increment") - -- Swap reducers each time we increment - if reducer_ == reducerA then - setReducer(function() - return reducerB - end) - else - setReducer(function() - return reducerA - end) - end + local function Counter(props, ref) + local reducer_, setReducer = useState(function() + return reducerA + end) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + return { dispatch = dispatch } + end) + if count < 20 then + dispatch("increment") + -- Swap reducers each time we increment + if reducer_ == reducerA then + setReducer(function() + return reducerB + end) + else + setReducer(function() + return reducerA + end) end - Scheduler.unstable_yieldValue("Render: " .. count) - return React.createElement(Text, { text = count }) end + Scheduler.unstable_yieldValue("Render: " .. count) + return React.createElement(Text, { text = count }) + end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { ref = counter })) - jestExpect(Scheduler).toFlushAndYield({ - -- The count should increase by alternating amounts of 10 and 1 - -- until we reach 21. - "Render: 0", - "Render: 10", - "Render: 11", - "Render: 21", - 21, - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(21) }) - - -- Test that it works on update, too. This time the log is a bit different - -- because we started with reducerB instead of reducerA. - ReactNoop.act(function() - counter.current.dispatch("reset") - end) - ReactNoop.render(React.createElement(Counter, { ref = counter })) - jestExpect(Scheduler).toHaveYielded({ - "Render: 0", - "Render: 1", - "Render: 11", - "Render: 12", - "Render: 22", - 22, - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(22) }) + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { ref = counter })) + jestExpect(Scheduler).toFlushAndYield({ + -- The count should increase by alternating amounts of 10 and 1 + -- until we reach 21. + "Render: 0", + "Render: 10", + "Render: 11", + "Render: 21", + 21, + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(21) }) + + -- Test that it works on update, too. This time the log is a bit different + -- because we started with reducerB instead of reducerA. + ReactNoop.act(function() + counter.current.dispatch("reset") end) - it('discards render phase updates if something suspends', function() - local thenable = {andThen = function() end} - local Bar - - local function Foo(props) - local signal = props.signal - return React.createElement(Suspense, {fallback="Loading..."}, React.createElement(Bar, {signal=signal})) - end + ReactNoop.render(React.createElement(CounterRef, { ref = counter })) + jestExpect(Scheduler).toHaveYielded({ + "Render: 0", + "Render: 1", + "Render: 11", + "Render: 12", + "Render: 22", + 22, + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(22) }) + end) + it("discards render phase updates if something suspends", function() + local thenable = { andThen = function() end } + local Bar + + local function Foo(props) + local signal = props.signal + return React.createElement( + Suspense, + { fallback = "Loading..." }, + React.createElement(Bar, { signal = signal }) + ) + end - function Bar(props) - local newSignal = props.signal - local counter, setCounter = useState(0) - local signal, setSignal = useState(true) + function Bar(props) + local newSignal = props.signal + local counter, setCounter = useState(0) + local signal, setSignal = useState(true) - -- Increment a counter every time the signal changes - if signal ~= newSignal then - setCounter(function(c) + -- Increment a counter every time the signal changes + if signal ~= newSignal then + setCounter(function(c) return c + 1 end) - setSignal(newSignal) - if counter == 0 then - -- We're suspending during a render that includes render phase - -- updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!') - error(thenable) - end - end - - return React.createElement(Text, {text=counter}) - end + setSignal(newSignal) + if counter == 0 then + -- We're suspending during a render that includes render phase + -- updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue("Suspend!") + error(thenable) + end + end - local root = ReactNoop.createRoot() - root.render(React.createElement(Foo, {signal=true})) + return React.createElement(Text, { text = counter }) + end - jestExpect(Scheduler).toFlushAndYield({0}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop=0})) + local root = ReactNoop.createRoot() + root.render(React.createElement(Foo, { signal = true })) + jestExpect(Scheduler).toFlushAndYield({ 0 }) + jestExpect(root).toMatchRenderedOutput(React.createElement("span", { prop = 0 })) - root.render(React.createElement(Foo, {signal=false})) - jestExpect(Scheduler).toFlushAndYield({'Suspend!'}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop=0})) + root.render(React.createElement(Foo, { signal = false })) + jestExpect(Scheduler).toFlushAndYield({ "Suspend!" }) + jestExpect(root).toMatchRenderedOutput(React.createElement("span", { prop = 0 })) - -- Rendering again should suspend again. - root.render(React.createElement(Foo, {signal=false})) - jestExpect(Scheduler).toFlushAndYield({'Suspend!'}) - end) - it('discards render phase updates if something suspends, but not other updates in the same component', function() - local thenable = {andThen = function() end} + -- Rendering again should suspend again. + root.render(React.createElement(Foo, { signal = false })) + jestExpect(Scheduler).toFlushAndYield({ "Suspend!" }) + end) + it( + "discards render phase updates if something suspends, but not other updates in the same component", + function() + local thenable = { andThen = function() end } local Bar - local function Foo(props) + local function Foo(props) local signal = props.signal - return React.createElement(Suspense, {fallback="Loading..."}, React.createElement(Bar, {signal=signal})) - end + return React.createElement( + Suspense, + { fallback = "Loading..." }, + React.createElement(Bar, { signal = signal }) + ) + end - local setLabel + local setLabel function Bar(props) - local newSignal = props.signal - local counter, setCounter = useState(0) - - if counter == 1 then - -- We're suspending during a render that includes render phase - -- updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!') - error(thenable) - end + local newSignal = props.signal + local counter, setCounter = useState(0) + + if counter == 1 then + -- We're suspending during a render that includes render phase + -- updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue("Suspend!") + error(thenable) + end - local signal, setSignal = useState(true) + local signal, setSignal = useState(true) - -- Increment a counter every time the signal changes - if signal ~= newSignal then - setCounter(function(c) - return c + 1 - end) - setSignal(newSignal) - end + -- Increment a counter every time the signal changes + if signal ~= newSignal then + setCounter(function(c) + return c + 1 + end) + setSignal(newSignal) + end - local label, _setLabel = useState('A') - setLabel = _setLabel + local label, _setLabel = useState("A") + setLabel = _setLabel - return React.createElement(Text, - {text = label .. ":" .. tostring(counter) } - ) - end + return React.createElement( + Text, + { text = label .. ":" .. tostring(counter) } + ) + end - local root = ReactNoop.createRoot() - root.render(React.createElement(Foo, {signal=true})) + local root = ReactNoop.createRoot() + root.render(React.createElement(Foo, { signal = true })) - jestExpect(Scheduler).toFlushAndYield({'A:0'}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop="A:0"})) + jestExpect(Scheduler).toFlushAndYield({ "A:0" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "A:0" }) + ) - ReactNoop.act(function() - root.render(React.createElement(Foo, {signal=false})) - setLabel('B') + ReactNoop.act(function() + root.render(React.createElement(Foo, { signal = false })) + setLabel("B") - jestExpect(Scheduler).toFlushAndYield({'Suspend!'}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop="A:0"})) + jestExpect(Scheduler).toFlushAndYield({ "Suspend!" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "A:0" }) + ) - -- Rendering again should suspend again. - root.render(React.createElement(Foo, {signal=false})) - jestExpect(Scheduler).toFlushAndYield({'Suspend!'}) + -- Rendering again should suspend again. + root.render(React.createElement(Foo, { signal = false })) + jestExpect(Scheduler).toFlushAndYield({ "Suspend!" }) - -- Flip the signal back to "cancel" the update. However, the update to - -- label should still proceed. It shouldn't have been dropped. - root.render(React.createElement(Foo, {signal=true})) - jestExpect(Scheduler).toFlushAndYield({'B:0'}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop="B:0"})) - return Promise.resolve() + -- Flip the signal back to "cancel" the update. However, the update to + -- label should still proceed. It shouldn't have been dropped. + root.render(React.createElement(Foo, { signal = true })) + jestExpect(Scheduler).toFlushAndYield({ "B:0" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "B:0" }) + ) + return Promise.resolve() end) - end) + end + ) - it("regression: render phase updates cause lower pri work to be dropped", function() - local setRow - local function ScrollView() - local row, _setRow = useState(10) - setRow = _setRow + it("regression: render phase updates cause lower pri work to be dropped", function() + local setRow + local function ScrollView() + local row, _setRow = useState(10) + setRow = _setRow - local scrollDirection, setScrollDirection = useState("Up") - local prevRow, setPrevRow = useState(nil) + local scrollDirection, setScrollDirection = useState("Up") + -- ROBLOX TODO Luau? it would be nice if setPrevRow usage in the closure informed this `nil` so we didn't need this annotation + local prevRow, setPrevRow = useState((nil :: any) :: number) - if prevRow ~= row then + if prevRow ~= row then local direction = "Up" if prevRow ~= nil and row > prevRow then direction = "Down" end setScrollDirection(direction) + -- ROBLOX FIXME Luau: even with explicit nubmer? annotation above, we still get None of the union options are compatible. For example: Type 'number' could not be converted into '(nil) -> nil' setPrevRow(row) - end + end - return React.createElement(Text, {text = scrollDirection}) - end + return React.createElement(Text, { text = scrollDirection }) + end - local root = ReactNoop.createRoot() + local root = ReactNoop.createRoot() - act(function() - root.render(React.createElement(ScrollView, {row = 10})) - end) - jestExpect(Scheduler).toHaveYielded({"Up"}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop="Up"})) + act(function() + root.render(React.createElement(ScrollView, { row = 10 })) + end) + jestExpect(Scheduler).toHaveYielded({ "Up" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "Up" }) + ) - act(function() - ReactNoop.discreteUpdates(function() - setRow(5) - end) - setRow(20) + act(function() + ReactNoop.discreteUpdates(function() + setRow(5) end) - jestExpect(Scheduler).toHaveYielded({"Up", "Down"}) - jestExpect(root).toMatchRenderedOutput(React.createElement("span", {prop="Down"})) + setRow(20) end) - - -- -- TODO: This should probably warn - -- -- @gate experimental - -- it('calling startTransition inside render phase', async function() - -- local startTransition - -- function App() - -- local [counter, setCounter] = useState(0) - -- local [_startTransition] = useTransition() - -- startTransition = _startTransition - - -- if counter == 0) - -- startTransition(function() - -- setCounter(c => c + 1) - -- }) - -- end - - -- return - -- end - - -- local root = ReactNoop.createRoot() - -- root.render() - -- jestExpect(Scheduler).toFlushAndYield([1]) - -- jestExpect(root).toMatchRenderedOutput() - -- }) + jestExpect(Scheduler).toHaveYielded({ "Up", "Down" }) + jestExpect(root).toMatchRenderedOutput( + React.createElement("span", { prop = "Down" }) + ) end) - describe("useReducer", function() - it("simple mount and update", function() - local INCREMENT = "INCREMENT" - local DECREMENT = "DECREMENT" + -- -- TODO: This should probably warn + -- -- @gate experimental + -- it('calling startTransition inside render phase', async function() + -- local startTransition + -- function App() + -- local [counter, setCounter] = useState(0) + -- local [_startTransition] = useTransition() + -- startTransition = _startTransition - local function reducer_(state, action) - if action == "INCREMENT" then - return state + 1 - elseif action == "DECREMENT" then - return state - 1 - else - return state - end - end + -- if counter == 0) + -- startTransition(function() + -- setCounter(c => c + 1) + -- }) + -- end - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - return { dispatch = dispatch } - end) - return React.createElement(Text, { - text = "Count: " .. count, - }) + -- return + -- end + + -- local root = ReactNoop.createRoot() + -- root.render() + -- jestExpect(Scheduler).toFlushAndYield([1]) + -- jestExpect(root).toMatchRenderedOutput() + -- }) +end) + +describe("useReducer", function() + it("simple mount and update", function() + local INCREMENT = "INCREMENT" + local DECREMENT = "DECREMENT" + + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == "INCREMENT" then + return state + 1 + elseif action == "DECREMENT" then + return state - 1 + else + return state end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end - act(function() - return counter.current.dispatch(INCREMENT) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - act(function() - counter.current.dispatch(DECREMENT) - counter.current.dispatch(DECREMENT) - counter.current.dispatch(DECREMENT) + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + return { dispatch = dispatch } end) + return React.createElement(Text, { + text = "Count: " .. count, + }) + end + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - jestExpect(Scheduler).toHaveYielded({ "Count: -2" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: -2") }) + act(function() + return counter.current.dispatch(INCREMENT) + end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + act(function() + counter.current.dispatch(DECREMENT) + counter.current.dispatch(DECREMENT) + counter.current.dispatch(DECREMENT) end) - it("lazy init", function() - local INCREMENT = "INCREMENT" - local DECREMENT = "DECREMENT" - - local function reducer_(state, action) - if action == "INCREMENT" then - return state + 1 - elseif action == "DECREMENT" then - return state - 1 - else - return state - end - end + jestExpect(Scheduler).toHaveYielded({ "Count: -2" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: -2") }) + end) - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, props, function(p) - Scheduler.unstable_yieldValue("Init") - return p.initialCount - end) - useImperativeHandle(ref, function() - return { dispatch = dispatch } - end) - return React.createElement(Text, { - text = "Count: " .. count, - }) + it("lazy init", function() + local INCREMENT = "INCREMENT" + local DECREMENT = "DECREMENT" + + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == "INCREMENT" then + return state + 1 + elseif action == "DECREMENT" then + return state - 1 + else + return state end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { - initialCount = 10, - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Init", "Count: 10" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 10") }) + end - act(function() - return counter.current.dispatch(INCREMENT) + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, props, function(p) + Scheduler.unstable_yieldValue("Init") + return p.initialCount end) - jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) - - act(function() - counter.current.dispatch(DECREMENT) - counter.current.dispatch(DECREMENT) - counter.current.dispatch(DECREMENT) + useImperativeHandle(ref, function() + return { dispatch = dispatch } end) + return React.createElement(Text, { + text = "Count: " .. count, + }) + end + -- ROBLOX TODO: upstream this rename, which keeps the code and types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { + initialCount = 10, + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Init", "Count: 10" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 10") }) - jestExpect(Scheduler).toHaveYielded({ "Count: 8" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 8") }) + act(function() + return counter.current.dispatch(INCREMENT) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 11") }) - -- Regression test for https://github.com/facebook/react/issues/14360 - it("handles dispatches with mixed priorities", function() - local INCREMENT = "INCREMENT" + act(function() + counter.current.dispatch(DECREMENT) + counter.current.dispatch(DECREMENT) + counter.current.dispatch(DECREMENT) + end) - local function reducer_(state, action) - if action == INCREMENT then - return state + 1 - else - return state - end - end - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - return { dispatch = dispatch } - end) + jestExpect(Scheduler).toHaveYielded({ "Count: 8" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 8") }) + end) - return React.createElement(Text, { - text = "Count: " .. count, - }) + -- Regression test for https://github.com/facebook/react/issues/14360 + it("handles dispatches with mixed priorities", function() + local INCREMENT = "INCREMENT" + + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == INCREMENT then + return state + 1 + else + return state end + end + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + return { dispatch = dispatch } + end) - Counter = forwardRef(Counter) + return React.createElement(Text, { + text = "Count: " .. count, + }) + end - local counter = React.createRef(nil) + -- ROBLOX TODO: upstream this rename to make code/types more sane + local CounterRef = forwardRef(Counter) - ReactNoop.render(React.createElement(Counter, { ref = counter })) - jestExpect(Scheduler).toFlushAndYield({ - "Count: 0", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 0"), - }) - ReactNoop.batchedUpdates(function() - counter.current.dispatch(INCREMENT) - counter.current.dispatch(INCREMENT) - counter.current.dispatch(INCREMENT) - end) - ReactNoop.flushSync(function() - counter.current.dispatch(INCREMENT) - end) - jestExpect(Scheduler).toHaveYielded({ - "Count: 1", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 1"), - }) - jestExpect(Scheduler).toFlushAndYield({ - "Count: 4", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 4"), - }) + local counter = React.createRef() + + ReactNoop.render(React.createElement(CounterRef, { ref = counter })) + jestExpect(Scheduler).toFlushAndYield({ + "Count: 0", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 0"), + }) + ReactNoop.batchedUpdates(function() + counter.current.dispatch(INCREMENT) + counter.current.dispatch(INCREMENT) + counter.current.dispatch(INCREMENT) end) + ReactNoop.flushSync(function() + counter.current.dispatch(INCREMENT) + end) + jestExpect(Scheduler).toHaveYielded({ + "Count: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 1"), + }) + jestExpect(Scheduler).toFlushAndYield({ + "Count: 4", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 4"), + }) end) +end) - describe("useEffect", function() - it("simple mount and update", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue(("Passive effect [%d]"):format(props.count)) - end) - return React.createElement(Text, { - text = "Count: " .. props.count, - }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { +describe("useEffect", function() + it("simple mount and update", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Passive effect [%d]", props.count) + ) + end) + return React.createElement(Text, { + text = "Count: " .. props.count, + }) + end + act(function() + ReactNoop.render( + React.createElement(Counter, { count = 0, - }), function() + }), + function() Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - -- Effects are deferred until after the commit - jestExpect(Scheduler).toFlushAndYield({ "Passive effect [0]" }) - end) + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + -- Effects are deferred until after the commit + jestExpect(Scheduler).toFlushAndYield({ "Passive effect [0]" }) + end) - act(function() - ReactNoop.render(React.createElement(Counter, { + act(function() + ReactNoop.render( + React.createElement(Counter, { count = 1, - }), function() + }), + function() Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - -- Effects are deferred until after the commit - jestExpect(Scheduler).toFlushAndYield({ "Passive effect [1]" }) + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + -- Effects are deferred until after the commit + jestExpect(Scheduler).toFlushAndYield({ "Passive effect [1]" }) + end) + end) + + it("flushes passive effects even with sibling deletions", function() + local function LayoutEffect(props) + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Layout effect") end) + return React.createElement(Text, { text = "Layout" }) + end + local function PassiveEffect(props) + useEffect(function() + Scheduler.unstable_yieldValue("Passive effect") + end, {}) + return React.createElement(Text, { text = "Passive" }) + end + local passive = React.createElement(PassiveEffect, { key = "p" }) + act(function() + ReactNoop.render({ + React.createElement(LayoutEffect, { key = "l" }), + passive, + }) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Layout", + "Passive", + "Layout effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Layout"), + span("Passive"), + }) + -- Destroying the first child shouldn't prevent the passive effect from + -- being executed + ReactNoop.render({ passive }) + jestExpect(Scheduler).toFlushAndYield({ "Passive effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Passive") }) end) + -- exiting act calls flushPassiveEffects(), but there are none left to flush. + jestExpect(Scheduler).toHaveYielded({}) + end) - it("flushes passive effects even with sibling deletions", function() - local function LayoutEffect(props) - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Layout effect") - end) - return React.createElement(Text, { text = "Layout" }) - end - local function PassiveEffect(props) - useEffect(function() - Scheduler.unstable_yieldValue("Passive effect") - end, {}) - return React.createElement(Text, { text = "Passive" }) - end - local passive = React.createElement(PassiveEffect, { key = "p" }) - act(function() - ReactNoop.render({ React.createElement(LayoutEffect, { key = "l" }), passive }) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Layout", - "Passive", - "Layout effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Layout"), - span("Passive"), - }) - -- Destroying the first child shouldn't prevent the passive effect from - -- being executed - ReactNoop.render({ passive }) - jestExpect(Scheduler).toFlushAndYield({ "Passive effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Passive") }) + it("flushes passive effects even if siblings schedule an update", function() + local function PassiveEffect(props) + useEffect(function() + Scheduler.unstable_yieldValue("Passive effect") + end) + return React.createElement(Text, { text = "Passive" }) + end + local function LayoutEffect(props) + local count, setCount = useState(0) + useLayoutEffect(function() + -- Scheduling work shouldn't interfere with the queued passive effect + if count == 0 then + setCount(1) + end + Scheduler.unstable_yieldValue("Layout effect " .. count) end) - -- exiting act calls flushPassiveEffects(), but there are none left to flush. - jestExpect(Scheduler).toHaveYielded({}) + return React.createElement(Text, { text = "Layout" }) + end + + ReactNoop.render({ + React.createElement(PassiveEffect, { key = "p" }), + React.createElement(LayoutEffect, { key = "l" }), + }) + + act(function() + jestExpect(Scheduler).toFlushAndYield({ + "Passive", + "Layout", + "Layout effect 0", + "Passive effect", + "Layout", + "Layout effect 1", + }) end) - it("flushes passive effects even if siblings schedule an update", function() - local function PassiveEffect(props) - useEffect(function() - Scheduler.unstable_yieldValue("Passive effect") - end) - return React.createElement(Text, { text = "Passive" }) - end - local function LayoutEffect(props) - local count, setCount = useState(0) - useLayoutEffect(function() - -- Scheduling work shouldn't interfere with the queued passive effect - if count == 0 then - setCount(1) - end - Scheduler.unstable_yieldValue("Layout effect " .. count) - end) - return React.createElement(Text, { text = "Layout" }) - end + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Passive"), + span("Layout"), + }) + end) + it("flushes passive effects even if siblings schedule a new root", function() + local function PassiveEffect(props) + useEffect(function() + Scheduler.unstable_yieldValue("Passive effect") + end, {}) + return React.createElement(Text, { text = "Passive" }) + end + local function LayoutEffect(props) + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Layout effect") + -- Scheduling work shouldn't interfere with the queued passive effect + ReactNoop.renderToRootWithID( + React.createElement(Text, { text = "New Root" }), + "root2" + ) + end) + return React.createElement(Text, { text = "Layout" }) + end + act(function() ReactNoop.render({ React.createElement(PassiveEffect, { key = "p" }), React.createElement(LayoutEffect, { key = "l" }), }) - - act(function() - jestExpect(Scheduler).toFlushAndYield({ - "Passive", - "Layout", - "Layout effect 0", - "Passive effect", - "Layout", - "Layout effect 1", - }) - end) - + jestExpect(Scheduler).toFlushAndYield({ + "Passive", + "Layout", + "Layout effect", + "Passive effect", + "New Root", + }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Passive"), span("Layout"), }) end) + end) - it("flushes passive effects even if siblings schedule a new root", function() - local function PassiveEffect(props) - useEffect(function() - Scheduler.unstable_yieldValue("Passive effect") - end, {}) - return React.createElement(Text, { text = "Passive" }) + it( + "flushes effects serially by flushing old effects before flushing " + .. "new ones, if they haven't already fired", + function() + local function getCommittedText() + local children = ReactNoop.getChildren() + if children == nil then + return nil + end + return children[1].prop end - local function LayoutEffect(props) - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Layout effect") - -- Scheduling work shouldn't interfere with the queued passive effect - ReactNoop.renderToRootWithID(React.createElement(Text, { text = "New Root" }), "root2") + + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + "Committed state when effect was fired: " + .. tostring(getCommittedText()) + ) end) - return React.createElement(Text, { text = "Layout" }) + return React.createElement(Text, { text = props.count }) end act(function() - ReactNoop.render({ - React.createElement(PassiveEffect, { key = "p" }), - React.createElement(LayoutEffect, { key = "l" }), - }) - jestExpect(Scheduler).toFlushAndYield({ - "Passive", - "Layout", - "Layout effect", - "Passive effect", - "New Root", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Passive"), - span("Layout"), - }) - end) - end) - - it( - "flushes effects serially by flushing old effects before flushing " - .. "new ones, if they haven't already fired", - function() - local function getCommittedText() - local children = ReactNoop.getChildren() - if children == nil then - return nil - end - return children[1].prop - end - - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue( - "Committed state when effect was fired: " .. tostring(getCommittedText()) - ) - end) - return React.createElement(Text, { text = props.count }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ 0, "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(0) }) - -- Before the effects have a chance to flush, schedule another update - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - -- The previous effect flushes before the reconciliation - "Committed state when effect was fired: 0", - 1, - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(1) }) + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) - - jestExpect(Scheduler).toHaveYielded({ - "Committed state when effect was fired: 1", - }) - end - ) - - it("defers passive effect destroy functions during unmount", function() - local function Child(props) - local bar = props.bar - local foo = props.foo - React.useEffect(function() - Scheduler.unstable_yieldValue("passive bar create") - return function() - Scheduler.unstable_yieldValue("passive bar destroy") - end - end, { - bar, - }) - React.useLayoutEffect(function() - Scheduler.unstable_yieldValue("layout bar create") - return function() - Scheduler.unstable_yieldValue("layout bar destroy") - end - end, { - bar, - }) - React.useEffect(function() - Scheduler.unstable_yieldValue("passive foo create") - return function() - Scheduler.unstable_yieldValue("passive foo destroy") - end - end, { - foo, - }) - React.useLayoutEffect(function() - Scheduler.unstable_yieldValue("layout foo create") - return function() - Scheduler.unstable_yieldValue("layout foo destroy") - end - end, { - foo, - }) - Scheduler.unstable_yieldValue("render") - return nil - end - - act(function() - ReactNoop.render(React.createElement(Child, { bar = 1, foo = 1 }), function() + jestExpect(Scheduler).toFlushAndYieldThrough({ 0, "Sync effect" } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(0) }) + -- Before the effects have a chance to flush, schedule another update + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ - "render", - "layout bar create", - "layout foo create", + -- The previous effect flushes before the reconciliation + "Committed state when effect was fired: 0", + 1, "Sync effect", - }) - -- Effects are deferred until after the commit - jestExpect(Scheduler).toFlushAndYield({ - "passive bar create", - "passive foo create", - }) + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(1) }) end) - -- This update is exists to test an internal implementation detail: - -- Effects without updating dependencies lose their layout/passive tag during an update. - act(function() - ReactNoop.render(React.createElement(Child, { bar = 1, foo = 2 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "render", - "layout foo destroy", - "layout foo create", - "Sync effect", - }) - -- Effects are deferred until after the commit - jestExpect(Scheduler).toFlushAndYield({ - "passive foo destroy", - "passive foo create", - }) + jestExpect(Scheduler).toHaveYielded({ + "Committed state when effect was fired: 1", + }) + end + ) + + it("defers passive effect destroy functions during unmount", function() + local function Child(props) + local bar = props.bar + local foo = props.foo + React.useEffect(function() + Scheduler.unstable_yieldValue("passive bar create") + return function() + Scheduler.unstable_yieldValue("passive bar destroy") + end + end, { + bar, + }) + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue("layout bar create") + return function() + Scheduler.unstable_yieldValue("layout bar destroy") + end + end, { + bar, + }) + React.useEffect(function() + Scheduler.unstable_yieldValue("passive foo create") + return function() + Scheduler.unstable_yieldValue("passive foo destroy") + end + end, { + foo, + }) + React.useLayoutEffect(function() + Scheduler.unstable_yieldValue("layout foo create") + return function() + Scheduler.unstable_yieldValue("layout foo destroy") + end + end, { + foo, + }) + Scheduler.unstable_yieldValue("render") + return nil + end + + act(function() + ReactNoop.render(React.createElement(Child, { bar = 1, foo = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "render", + "layout bar create", + "layout foo create", + "Sync effect", + }) + -- Effects are deferred until after the commit + jestExpect(Scheduler).toFlushAndYield({ + "passive bar create", + "passive foo create", + }) + end) - -- Unmount the component and verify that passive destroy functions are deferred until post-commit. - act(function() - ReactNoop.render(nil, function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "layout bar destroy", - "layout foo destroy", - "Sync effect", - }) - -- Effects are deferred until after the commit - jestExpect(Scheduler).toFlushAndYield({ - "passive bar destroy", - "passive foo destroy", - }) + -- This update is exists to test an internal implementation detail: + -- Effects without updating dependencies lose their layout/passive tag during an update. + act(function() + ReactNoop.render(React.createElement(Child, { bar = 1, foo = 2 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "render", + "layout foo destroy", + "layout foo create", + "Sync effect", + }) + -- Effects are deferred until after the commit + jestExpect(Scheduler).toFlushAndYield({ + "passive foo destroy", + "passive foo create", + }) + end) + + -- Unmount the component and verify that passive destroy functions are deferred until post-commit. + act(function() + ReactNoop.render(nil, function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "layout bar destroy", + "layout foo destroy", + "Sync effect", + }) + -- Effects are deferred until after the commit + jestExpect(Scheduler).toFlushAndYield({ + "passive bar destroy", + "passive foo destroy", + }) end) + end) - it("does not warn about state updates for unmounted components with pending passive unmounts", function() + it( + "does not warn about state updates for unmounted components with pending passive unmounts", + function() local completePendingRequest = nil local function Component() Scheduler.unstable_yieldValue("Component") @@ -1401,9 +1438,13 @@ return function() end act(function() - ReactNoop.renderToRootWithID(React.createElement(Component), "root", function() - Scheduler.unstable_yieldValue("Sync effect") - end) + ReactNoop.renderToRootWithID( + React.createElement(Component), + "root", + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) jestExpect(Scheduler).toFlushAndYieldThrough({ "Component", "layout create", @@ -1423,115 +1464,126 @@ return function() ReactNoop.flushPassiveEffects() jestExpect(Scheduler).toHaveYielded({ "passive destroy" }) end) - end) + end + ) - it( - "does not warn about state updates for unmounted components with pending passive unmounts for alternates", - function() - local setParentState = nil - local setChildStates = {} - - -- deviation: reordered so Parent function could reference Child - local function Child(props) - -- deviation: list deconstruction doesn't work in Lua - local label = props.label - local state, setState = useState(0) - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Child " .. label .. " commit") - end) - useEffect(function() - table.insert(setChildStates, setState) - Scheduler.unstable_yieldValue("Child " .. label .. " passive create") - return function() - Scheduler.unstable_yieldValue("Child " .. label .. " passive destroy") - end - end, {}) - Scheduler.unstable_yieldValue("Child " .. label .. " render") - return state - end + it( + "does not warn about state updates for unmounted components with pending passive unmounts for alternates", + function() + local setParentState = nil + local setChildStates = {} - local function Parent() - local state, setState = useState(true) - setParentState = setState - Scheduler.unstable_yieldValue("Parent " .. tostring(state) .. " render") - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Parent " .. tostring(state) .. " commit") - end) - if state then - return React.createElement( - React.Fragment, - nil, - React.createElement(Child, { label = "one" }), - React.createElement(Child, { label = "two" }) + -- deviation: reordered so Parent function could reference Child + local function Child(props) + -- ROBLOX FIXME Luau: shouldn't need this annotation, it should see the .. operator and infer the type for props argument + local label: string = props.label + local state, setState = useState(0) + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Child " .. label .. " commit") + end) + useEffect(function() + table.insert(setChildStates, setState) + Scheduler.unstable_yieldValue("Child " .. label .. " passive create") + return function() + Scheduler.unstable_yieldValue( + "Child " .. label .. " passive destroy" ) - else - return nil end + end, {}) + Scheduler.unstable_yieldValue("Child " .. label .. " render") + return state + end + + local function Parent() + local state, setState = useState(true) + setParentState = setState + Scheduler.unstable_yieldValue("Parent " .. tostring(state) .. " render") + useLayoutEffect(function() + Scheduler.unstable_yieldValue( + "Parent " .. tostring(state) .. " commit" + ) + end) + if state then + return React.createElement( + React.Fragment, + nil, + React.createElement(Child, { label = "one" }), + React.createElement(Child, { label = "two" }) + ) + else + -- ROBLOX FIXME Luau: needs local type inference and normalization + return nil :: any end + end - -- Schedule debounced state update for child (prob a no-op for this test) - -- later tick: schedule unmount for parent - -- start process unmount (but don't flush passive effectS) - -- State update on child - act(function() - ReactNoop.render(React.createElement(Parent)) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Parent true render", - "Child one render", - "Child two render", - "Child one commit", - "Child two commit", - "Parent true commit", - "Child one passive create", - "Child two passive create", - }) - - -- Update children. - -- deviation: forEach() translated using Array.map - Array.map(setChildStates, function(setChildState) - return setChildState(1) - end) + -- Schedule debounced state update for child (prob a no-op for this test) + -- later tick: schedule unmount for parent + -- start process unmount (but don't flush passive effectS) + -- State update on child + act(function() + ReactNoop.render(React.createElement(Parent)) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Parent true render", + "Child one render", + "Child two render", + "Child one commit", + "Child two commit", + "Parent true commit", + "Child one passive create", + "Child two passive create", + }) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Child one render", - "Child two render", - "Child one commit", - "Child two commit", - }) + -- Update children. + -- deviation: forEach() translated using Array.map + Array.map(setChildStates, function(setChildState) + return setChildState(1) + end) - -- Schedule another update for children, and partially process it. - -- deviation: forEach() translated using Array.map + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Child one render", + "Child two render", + "Child one commit", + "Child two commit", + }) - Array.map(setChildStates, function(setChildState) - return setChildState(2) - end) + -- Schedule another update for children, and partially process it. + -- deviation: forEach() translated using Array.map - jestExpect(Scheduler).toFlushAndYieldThrough({ "Child one render" }) + Array.map(setChildStates, function(setChildState) + return setChildState(2) + end) + + jestExpect(Scheduler).toFlushAndYieldThrough({ "Child one render" }) - -- Schedule unmount for the parent that unmounts children with pending update. - Scheduler.unstable_runWithPriority(Scheduler.unstable_UserBlockingPriority, function() + -- Schedule unmount for the parent that unmounts children with pending update. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + function() return setParentState(false) - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Parent false render", - "Parent false commit", - }) - - -- Schedule updates for children too (which should be ignored) - -- deviation: forEach() translated using Array.map - Array.map(setChildStates, function(setChildState) - return setChildState(2) - end) + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Parent false render", + "Parent false commit", + }) - jestExpect(Scheduler).toFlushAndYield({ - "Child one passive destroy", - "Child two passive destroy", - }) + -- Schedule updates for children too (which should be ignored) + -- deviation: forEach() translated using Array.map + Array.map(setChildStates, function(setChildState) + return setChildState(2) end) - end - ) - it("warns about state updates for unmounted components with no pending passive unmounts", function() + jestExpect(Scheduler).toFlushAndYield({ + "Child one passive destroy", + "Child two passive destroy", + }) + end) + end + ) + + it( + "does not warn about state updates for unmounted components with no pending passive unmounts", + function() local completePendingRequest = nil local function Component() Scheduler.unstable_yieldValue("Component") @@ -1550,9 +1602,13 @@ return function() end act(function() - ReactNoop.renderToRootWithID(React.createElement(Component), "root", function() - Scheduler.unstable_yieldValue("Sync effect") - end) + ReactNoop.renderToRootWithID( + React.createElement(Component), + "root", + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) jestExpect(Scheduler).toFlushAndYieldThrough({ "Component", "layout create", @@ -1564,11 +1620,14 @@ return function() jestExpect(Scheduler).toFlushAndYieldThrough({ "layout destroy" }) -- Simulate an XHR completing. - jestExpect(completePendingRequest).toErrorDev("Warning: Can't perform a React state update on an unmounted component.") + completePendingRequest() end) - end) + end + ) - it("still warns if there are pending passive unmount effects but not for the current fiber", function() + it( + "does not warn if there are pending passive unmount effects but not for the current fiber", + function() local completePendingRequest = nil local function ComponentWithXHR() Scheduler.unstable_yieldValue("Component") @@ -1628,12 +1687,14 @@ return function() jestExpect(Scheduler).toFlushAndYieldThrough({ "a:layout destroy" }) -- Simulate an XHR completing in the component without a pending passive effect.. - jestExpect(completePendingRequest).toErrorDev("Warning: Can't perform a React state update on an unmounted component.") + completePendingRequest() end) - 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( + "does not warn if there are updates after pending passive unmount effects have been flushed", + function() local updaterFunction local function Component() @@ -1650,9 +1711,13 @@ return function() end act(function() - ReactNoop.renderToRootWithID(React.createElement(Component), "root", function() - Scheduler.unstable_yieldValue("Sync effect") - end) + ReactNoop.renderToRootWithID( + React.createElement(Component), + "root", + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) end) jestExpect(Scheduler).toHaveYielded({ "Component", @@ -1664,383 +1729,358 @@ return function() jestExpect(Scheduler).toFlushAndYield({ "passive destroy" }) act(function() - jestExpect(function() - updaterFunction(true) - end).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component. " - .. "This is a no-op, but it indicates a memory leak in your application. " - .. "To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.\n" - .. " in Component (at **)" - ) + updaterFunction(true) end) - end) - - it( - "does not show a warning when a component updates its own state from within passive unmount function", - function() - local function Component() - Scheduler.unstable_yieldValue("Component") - local didLoad, setDidLoad = React.useState(false) - React.useEffect(function() - Scheduler.unstable_yieldValue("passive create") - return function() - setDidLoad(true) - Scheduler.unstable_yieldValue("passive destroy") - end - end, {}) - return didLoad - end + end + ) - act(function() - ReactNoop.renderToRootWithID(React.createElement(Component), "root", function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Component", - "Sync effect", - "passive create", - }) - - -- Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID("root") - jestExpect(Scheduler).toFlushAndYield({ "passive destroy" }) - end) + it( + "does not show a warning when a component updates its own state from within passive unmount function", + function() + local function Component() + Scheduler.unstable_yieldValue("Component") + local didLoad, setDidLoad = React.useState(false) + React.useEffect(function() + Scheduler.unstable_yieldValue("passive create") + return function() + setDidLoad(true) + Scheduler.unstable_yieldValue("passive destroy") + end + end, {}) + return didLoad end - ) - it( - "does not show a warning when a component updates a childs state from within passive unmount function", - function() - local Child - local function Parent() - Scheduler.unstable_yieldValue("Parent") - local updaterRef = React.useRef(nil) - React.useEffect(function() - Scheduler.unstable_yieldValue("Parent passive create") - return function() - updaterRef.current(true) - Scheduler.unstable_yieldValue("Parent passive destroy") - end - end, {}) - return React.createElement(Child, { updaterRef = updaterRef }) - end + act(function() + ReactNoop.renderToRootWithID( + React.createElement(Component), + "root", + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Component", + "Sync effect", + "passive create", + }) - function Child(props) - local updaterRef = props.updaterRef - Scheduler.unstable_yieldValue("Child") - local state, setState = React.useState(false) - React.useEffect(function() - Scheduler.unstable_yieldValue("Child passive create") - updaterRef.current = setState - end, {}) - return state - end + -- Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID("root") + jestExpect(Scheduler).toFlushAndYield({ "passive destroy" }) + end) + end + ) + + it( + "does not show a warning when a component updates a childs state from within passive unmount function", + function() + local Child + local function Parent() + Scheduler.unstable_yieldValue("Parent") + -- ROBLOX TODO: this explicit typing should be upstreamed + local updaterRef = React.useRef(nil :: ((boolean) -> ())?) + React.useEffect(function() + Scheduler.unstable_yieldValue("Parent passive create") + return function() + -- ROBLOX deviation: this is a real nil-ability issue, but Luau doesn't grok expect().never.toBe(nil) yet + assert( + updaterRef.current ~= nil, + "updaterRef was't initialized before render" + ) + updaterRef.current(true) + Scheduler.unstable_yieldValue("Parent passive destroy") + end + end, {}) + return React.createElement(Child, { updaterRef = updaterRef }) + end - act(function() - ReactNoop.renderToRootWithID(React.createElement(Parent), "root") - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Parent", - "Child", - "Child passive create", - "Parent passive create", - }) - - -- Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID("root") - jestExpect(Scheduler).toFlushAndYield({ "Parent passive destroy" }) - end) + function Child(props) + local updaterRef = props.updaterRef + Scheduler.unstable_yieldValue("Child") + local state, setState = React.useState(false) + React.useEffect(function() + Scheduler.unstable_yieldValue("Child passive create") + -- ROBLOX FIXME: Assigning to ref.current like this is + -- not allowed in legacy Roact, and it appears that it + -- was previously disallowed in React as well. There was + -- quite a bit of discussion about it here: + -- https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 + + -- For now, we've relaxed this restriction to maximize + -- compatibility. We should consider using a binding + -- here, which would be the idiomatic approach in legacy + -- Roact, and re-introducing the restriction. + updaterRef.current = setState + end, {}) + return state end - ) - it( - "does not show a warning when a component updates a parents state from within passive unmount function", - function() - local Child - local function Parent() - local state, setState = React.useState(false) - Scheduler.unstable_yieldValue("Parent") - return React.createElement(Child, { setState = setState, state = state }) - end + act(function() + ReactNoop.renderToRootWithID(React.createElement(Parent), "root") + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Parent", + "Child", + "Child passive create", + "Parent passive create", + }) - function Child(props) - local state = props.state - local setState = props.setState - Scheduler.unstable_yieldValue("Child") - React.useEffect(function() - Scheduler.unstable_yieldValue("Child passive create") - return function() - Scheduler.unstable_yieldValue("Child passive destroy") - setState(true) - end - end, {}) - return state - end + -- Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID("root") + jestExpect(Scheduler).toFlushAndYield({ "Parent passive destroy" }) + end) + end + ) - act(function() - ReactNoop.renderToRootWithID(React.createElement(Parent), "root") - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Parent", - "Child", - "Child passive create", - }) - - -- Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID("root") - jestExpect(Scheduler).toFlushAndYield({ "Child passive destroy" }) - end) + it( + "does not show a warning when a component updates a parents state from within passive unmount function", + function() + local Child + local function Parent() + local state, setState = React.useState(false) + Scheduler.unstable_yieldValue("Parent") + return React.createElement(Child, { setState = setState, state = state }) end - ) - it("updates have async priority", function() - local function Counter(props) - local count, updateCount = useState("(empty)") - useEffect(function() - Scheduler.unstable_yieldValue(("Schedule update {%s}"):format(props.count)) - updateCount(props.count) - end, { - props.count, - }) - return React.createElement(Text, { text = "Count: " .. count }) + function Child(props) + local state = props.state + local setState = props.setState + Scheduler.unstable_yieldValue("Child") + React.useEffect(function() + Scheduler.unstable_yieldValue("Child passive create") + return function() + Scheduler.unstable_yieldValue("Child passive destroy") + setState(true) + end + end, {}) + return state end + act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) + ReactNoop.renderToRootWithID(React.createElement(Parent), "root") jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: (empty)", - "Sync effect", + "Parent", + "Child", + "Child passive create", }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) - ReactNoop.flushPassiveEffects() - jestExpect(Scheduler).toHaveYielded({ "Schedule update {0}" }) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + + -- Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID("root") + jestExpect(Scheduler).toFlushAndYield({ "Child passive destroy" }) end) + end + ) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - ReactNoop.flushPassiveEffects() - jestExpect(Scheduler).toHaveYielded({ "Schedule update {1}" }) - jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) + it("updates have async priority", function() + local function Counter(props) + local count, updateCount = useState("(empty)") + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Schedule update {%d}", props.count) + ) + updateCount(tostring(props.count)) + end, { + props.count, + }) + return React.createElement(Text, { text = "Count: " .. count }) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: (empty)", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) + ReactNoop.flushPassiveEffects() + jestExpect(Scheduler).toHaveYielded({ "Schedule update {0}" }) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) end) - it("updates have async priority even if effects are flushed early", function() - local function Counter(props) - local count, updateCount = useState("(empty)") - useEffect(function() - Scheduler.unstable_yieldValue(("Schedule update {%s}"):format(props.count)) - updateCount(props.count) - end, { - props.count, - }) - return React.createElement(Text, { text = "Count: " .. count }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: (empty)", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.flushPassiveEffects() + jestExpect(Scheduler).toHaveYielded({ "Schedule update {1}" }) + jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) + end) + end) - -- Rendering again should flush the previous commit's effects - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Schedule update {0}", - "Count: 0", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) + it("updates have async priority even if effects are flushed early", function() + local function Counter(props) + local count, updateCount = useState("(empty)") + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Schedule update {%d}", props.count) + ) + updateCount(tostring(props.count)) + end, { + props.count, + }) + return React.createElement(Text, { text = "Count: " .. count }) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: (empty)", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - ReactNoop.flushPassiveEffects() - jestExpect(Scheduler).toHaveYielded({ "Schedule update {1}" }) - jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + -- Rendering again should flush the previous commit's effects + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Schedule update {0}", + "Count: 0", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: (empty)") }) + + jestExpect(Scheduler).toFlushAndYieldThrough({ "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.flushPassiveEffects() + jestExpect(Scheduler).toHaveYielded({ "Schedule update {1}" }) + jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) end) + end) - it("does not flush non-discrete passive effects when flushing sync", function() - local _updateCount - local function Counter(props) - local count, updateCount = useState(0) - _updateCount = updateCount - useEffect(function() - Scheduler.unstable_yieldValue("Will set count to 1") - updateCount(1) - end, {}) - return React.createElement(Text, { text = "Count: " .. tostring(count) }) - end + it("does not flush non-discrete passive effects when flushing sync", function() + local _updateCount + local function Counter(props) + local count, updateCount = useState(0) + _updateCount = updateCount + useEffect(function() + Scheduler.unstable_yieldValue("Will set count to 1") + updateCount(1) + end, {}) + return React.createElement(Text, { text = "Count: " .. tostring(count) }) + end - -- we explicitly wait for missing act() warnings here since - -- it's a lot harder to simulate this condition inside an act scope - -- jestExpect(function() + -- we explicitly wait for missing act() warnings here since + -- it's a lot harder to simulate this condition inside an act scope + jestExpect(function() ReactNoop.render(React.createElement(Counter, { count = 0 }), function() Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - -- end).toErrorDev({'An update to Counter ran an effect'}) + end).toErrorDev({ "An update to Counter ran an effect" }) - -- A flush sync doesn't cause the passive effects to fire. - -- So we haven't added the other update yet. - act(function() - ReactNoop.flushSync(function() - _updateCount(2) - end) + -- A flush sync doesn't cause the passive effects to fire. + -- So we haven't added the other update yet. + act(function() + ReactNoop.flushSync(function() + _updateCount(2) end) + end) - -- As a result we, somewhat surprisingly, commit them in the opposite order. - -- This should be fine because any non-discrete set of work doesn't guarantee order - -- and easily could've happened slightly later too. - jestExpect(Scheduler).toHaveYielded({ - "Will set count to 1", - "Count: 2", - "Count: 1", - }) + -- As a result we, somewhat surprisingly, commit them in the opposite order. + -- This should be fine because any non-discrete set of work doesn't guarantee order + -- and easily could've happened slightly later too. + jestExpect(Scheduler).toHaveYielded({ + "Will set count to 1", + "Count: 2", + "Count: 1", + }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - end) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + end) - -- ROBLOX TODO: schedulerTracing - -- @gate enableSchedulerTracing - -- it('does not flush non-discrete passive effects when flushing sync (with tracing)', function() - -- local onInteractionScheduledWorkCompleted = jest.fn() - -- local onWorkCanceled = jest.fn() - - -- SchedulerTracing.unstable_subscribe({ - -- onInteractionScheduledWorkCompleted = onInteractionScheduledWorkCompleted, - -- onInteractionTraced = jest.fn(), - -- onWorkCanceled = onWorkCanceled, - -- onWorkScheduled = jest.fn(), - -- onWorkStarted = jest.fn(), - -- onWorkStopped = jest.fn(), - -- }) - - -- local _updateCount - - -- local function Counter(props) - -- local _useState, _useState2, count, updateCount = useState(0), _slicedToArray(_useState, 2), _useState2[0], _useState2[1] - - -- _updateCount = updateCount - - -- useEffect(function() - -- jestExpect(SchedulerTracing.unstable_getCurrent()).toMatchInteractions({tracingEvent}) - -- Scheduler.unstable_yieldValue('Will set count to 1') - -- updateCount(1) - -- end, {}) - - -- return React.createElement(Text, { - -- text = 'Count: ' + count, - -- }) - -- end - - -- local tracingEvent = { - -- id = 0, - -- name = 'hello', - -- timestamp = 0, - -- } - - -- jestExpect(function() - -- SchedulerTracing.unstable_trace(tracingEvent.name, tracingEvent.timestamp, function() - -- ReactNoop.render(React.createElement(Counter, {count = 0}), function() - -- return Scheduler.unstable_yieldValue('Sync effect') - -- end) - -- end) - -- jestExpect(Scheduler).toFlushAndYieldThrough({ - -- 'Count: 0', - -- 'Sync effect', - -- }) - -- jestExpect(ReactNoop.getChildren()).toEqual({ - -- span('Count: 0'), - -- }) - -- end).toErrorDev({ - -- 'An update to Counter ran an effect', - -- }) - -- jestExpect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0) - -- act(function() - -- ReactNoop.flushSync(function() - -- _updateCount(2) - -- end) - -- end) - -- jestExpect(Scheduler).toHaveYielded({ - -- 'Will set count to 1', - -- 'Count: 2', - -- 'Count: 1', - -- }) - -- jestExpect(ReactNoop.getChildren()).toEqual({ - -- span('Count: 1'), - -- }) - -- jestExpect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1) - -- jestExpect(onWorkCanceled).toHaveBeenCalledTimes(0) - -- end) - - it( - "in legacy mode, useEffect is deferred and updates finish synchronously (in a single batch)", - function() - local function Counter(props) - local count, updateCount = useState("(empty)") - - useEffect(function() - -- Update multiple times. These should all be batched together in - -- a single render. - updateCount(props.count) - updateCount(props.count) - updateCount(props.count) - updateCount(props.count) - updateCount(props.count) - updateCount(props.count) - end, { - props.count, - }) - - return React.createElement(Text, { - text = "Count: " .. count, - }) - end + -- ROBLOX TODO: schedulerTracing + -- @gate enableSchedulerTracing + -- it('does not flush non-discrete passive effects when flushing sync (with tracing)', function() + -- local onInteractionScheduledWorkCompleted = jest.fn() + -- local onWorkCanceled = jest.fn() + + -- SchedulerTracing.unstable_subscribe({ + -- onInteractionScheduledWorkCompleted = onInteractionScheduledWorkCompleted, + -- onInteractionTraced = jest.fn(), + -- onWorkCanceled = onWorkCanceled, + -- onWorkScheduled = jest.fn(), + -- onWorkStarted = jest.fn(), + -- onWorkStopped = jest.fn(), + -- }) - act(function() - ReactNoop.renderLegacySyncRoot(React.createElement(Counter, { count = 0 })) - -- Even in legacy mode, effects are deferred until after paint - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: (empty)", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: (empty)"), - }) - end) - -- effects get forced on exiting act() - -- There were multiple updates, but there should only be a - -- single render - jestExpect(Scheduler).toHaveYielded({ - "Count: 0", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 0"), - }) - end - ) + -- local _updateCount + + -- local function Counter(props) + -- local _useState, _useState2, count, updateCount = useState(0), _slicedToArray(_useState, 2), _useState2[0], _useState2[1] + + -- _updateCount = updateCount + + -- useEffect(function() + -- jestExpect(SchedulerTracing.unstable_getCurrent()).toMatchInteractions({tracingEvent}) + -- Scheduler.unstable_yieldValue('Will set count to 1') + -- updateCount(1) + -- end, {}) + + -- return React.createElement(Text, { + -- text = 'Count: ' + count, + -- }) + -- end - it("flushSync is not allowed", function() + -- local tracingEvent = { + -- id = 0, + -- name = 'hello', + -- timestamp = 0, + -- } + + -- jestExpect(function() + -- SchedulerTracing.unstable_trace(tracingEvent.name, tracingEvent.timestamp, function() + -- ReactNoop.render(React.createElement(Counter, {count = 0}), function() + -- return Scheduler.unstable_yieldValue('Sync effect') + -- end) + -- end) + -- jestExpect(Scheduler).toFlushAndYieldThrough({ + -- 'Count: 0', + -- 'Sync effect', + -- }) + -- jestExpect(ReactNoop.getChildren()).toEqual({ + -- span('Count: 0'), + -- }) + -- end).toErrorDev({ + -- 'An update to Counter ran an effect', + -- }) + -- jestExpect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0) + -- act(function() + -- ReactNoop.flushSync(function() + -- _updateCount(2) + -- end) + -- end) + -- jestExpect(Scheduler).toHaveYielded({ + -- 'Will set count to 1', + -- 'Count: 2', + -- 'Count: 1', + -- }) + -- jestExpect(ReactNoop.getChildren()).toEqual({ + -- span('Count: 1'), + -- }) + -- jestExpect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1) + -- jestExpect(onWorkCanceled).toHaveBeenCalledTimes(0) + -- end) + + it( + "in legacy mode, useEffect is deferred and updates finish synchronously (in a single batch)", + function() local function Counter(props) local count, updateCount = useState("(empty)") useEffect(function() - Scheduler.unstable_yieldValue(("Schedule update [%s]"):format(props.count)) - ReactNoop.flushSync(function() - updateCount(props.count) - end) - jestExpect(ReactNoop.getChildren()).never.toEqual({ - span("Count: " .. props.count), - }) + -- Update multiple times. These should all be batched together in + -- a single render. + updateCount(tostring(props.count)) + updateCount(tostring(props.count)) + updateCount(tostring(props.count)) + updateCount(tostring(props.count)) + updateCount(tostring(props.count)) + updateCount(tostring(props.count)) end, { props.count, }) @@ -2050,301 +2090,518 @@ return function() }) end - jestExpect(function() - return act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: (empty)", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: (empty)"), - }) - end) - end).toErrorDev("flushSync was called from inside a lifecycle method") + act(function() + ReactNoop.renderLegacySyncRoot( + React.createElement(Counter, { count = 0 }) + ) + -- Even in legacy mode, effects are deferred until after paint + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: (empty)", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: (empty)"), + }) + end) + -- effects get forced on exiting act() + -- There were multiple updates, but there should only be a + -- single render + jestExpect(Scheduler).toHaveYielded({ + "Count: 0", + }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0"), }) - end) - - it("unmounts previous effect", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue("Did create [" .. tostring(props.count) .. "]") - return function() - Scheduler.unstable_yieldValue("Did destroy [" .. tostring(props.count) .. "]") - end - end) - return React.createElement(Text, { text = "Count: " .. props.count }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - end) + end + ) - jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) + it("flushSync is not allowed", function() + local function Counter(props) + local count, updateCount = useState("(empty)") - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Schedule update [%d]", props.count) + ) + ReactNoop.flushSync(function() + updateCount(tostring(props.count)) end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - end) + jestExpect(ReactNoop.getChildren()).never.toEqual({ + span(string.format("Count: %d", props.count)), + }) + end, { + props.count, + }) - jestExpect(Scheduler).toHaveYielded({ "Did destroy [0]", "Did create [1]" }) - end) + return React.createElement(Text, { + text = "Count: " .. count, + }) + end - it("unmounts on deletion", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue("Did create [" .. tostring(props.count) .. "]") - return function() - Scheduler.unstable_yieldValue("Did destroy [" .. tostring(props.count) .. "]") - end - end) - return React.createElement(Text, { text = "Count: " .. tostring(props.count) }) - end - act(function() + jestExpect(function() + return act(function() ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") + return Scheduler.unstable_yieldValue("Sync effect") end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: (empty)", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: (empty)"), + }) end) + end).toErrorDev("flushSync was called from inside a lifecycle method") + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 0"), + }) + end) - jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) - - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushAndYield({ "Did destroy [0]" }) - jestExpect(ReactNoop.getChildren()).toEqual({}) - end) - - it("unmounts on deletion after skipped effect", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue(("Did create [%d]"):format(props.count)) - return function() - Scheduler.unstable_yieldValue(("Did destroy [%d]"):format(props.count)) - end - end, {}) - return React.createElement(Text, { text = "Count: " .. props.count }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + it("unmounts previous effect", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Did create [%d]", props.count) + ) + return function() + Scheduler.unstable_yieldValue( + string.format("Did destroy [%d]", props.count) + ) + end end) - - jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) - - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + return React.createElement(Text, { text = "Count: " .. props.count }) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) - jestExpect(Scheduler).toHaveYielded({}) + jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushAndYield({ "Did destroy [0]" }) - jestExpect(ReactNoop.getChildren()).toEqual({}) + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) end) - it("always fires effects if no dependencies are provided", function() - local function effect() - Scheduler.unstable_yieldValue("Did create") + jestExpect(Scheduler).toHaveYielded({ "Did destroy [0]", "Did create [1]" }) + end) + + it("unmounts on deletion", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + "Did create [" .. tostring(props.count) .. "]" + ) return function() - Scheduler.unstable_yieldValue("Did destroy") + Scheduler.unstable_yieldValue( + "Did destroy [" .. tostring(props.count) .. "]" + ) end - end - local function Counter(props) - useEffect(effect) - return React.createElement(Text, { text = "Count: " .. props.count }) - end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) end) + return React.createElement( + Text, + { text = "Count: " .. tostring(props.count) } + ) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) - jestExpect(Scheduler).toHaveYielded({ "Did create" }) + jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYield({ "Did destroy [0]" }) + jestExpect(ReactNoop.getChildren()).toEqual({}) + end) + + it("unmounts on deletion after skipped effect", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + string.format("Did create [%d]", props.count) + ) + return function() + Scheduler.unstable_yieldValue( + string.format("Did destroy [%d]", props.count) + ) + end + end, {}) + return React.createElement(Text, { text = "Count: " .. props.count }) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) - jestExpect(Scheduler).toHaveYielded({ "Did destroy", "Did create" }) + jestExpect(Scheduler).toHaveYielded({ "Did create [0]" }) - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushAndYield({ "Did destroy" }) - jestExpect(ReactNoop.getChildren()).toEqual({}) + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) end) - it("skips effect if inputs have not changed", function() - local function Counter(props) - local text = tostring(props.label) .. ": " .. tostring(props.count) - useEffect(function() - Scheduler.unstable_yieldValue("Did create [" .. text .. "]") - return function() - Scheduler.unstable_yieldValue("Did destroy [" .. text .. "]") - end - end, { - props.label, - props.count, - }) - return React.createElement(Text, { text = text }) + jestExpect(Scheduler).toHaveYielded({}) + + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYield({ "Did destroy [0]" }) + jestExpect(ReactNoop.getChildren()).toEqual({}) + end) + + it("always fires effects if no dependencies are provided", function() + local function effect() + Scheduler.unstable_yieldValue("Did create") + return function() + Scheduler.unstable_yieldValue("Did destroy") end - act(function() - ReactNoop.render(React.createElement(Counter, { label = "Count", count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + end + local function Counter(props) + useEffect(effect) + return React.createElement( + Text, + { text = "Count: " .. tostring(props.count) } + ) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + return Scheduler.unstable_yieldValue("Sync effect") end) - - jestExpect(Scheduler).toHaveYielded({ "Did create [Count: 0]" }) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) - act(function() - ReactNoop.render(React.createElement(Counter, { label = "Count", count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - -- Count changed - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + jestExpect(Scheduler).toHaveYielded({ "Did create" }) + + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + return Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + end) - jestExpect(Scheduler).toHaveYielded({ - "Did destroy [Count: 0]", - "Did create [Count: 1]", - }) + jestExpect(Scheduler).toHaveYielded({ "Did destroy", "Did create" }) - act(function() - ReactNoop.render(React.createElement(Counter, { label = "Count", count = 1 }), function() + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYield({ "Did destroy" }) + jestExpect(ReactNoop.getChildren()).toEqual({}) + end) + + it("skips effect if inputs have not changed", function() + local function Counter(props) + local text = tostring(props.label) .. ": " .. tostring(props.count) + useEffect(function() + Scheduler.unstable_yieldValue("Did create [" .. text .. "]") + return function() + Scheduler.unstable_yieldValue("Did destroy [" .. text .. "]") + end + end, { + props.label, + props.count, + }) + return React.createElement(Text, { text = text }) + end + act(function() + ReactNoop.render( + React.createElement(Counter, { label = "Count", count = 0 }), + function() Scheduler.unstable_yieldValue("Sync effect") - end) - -- Nothing changed, so no effect should have fired - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - end) + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + end) - jestExpect(Scheduler).toHaveYielded({}) + jestExpect(Scheduler).toHaveYielded({ "Did create [Count: 0]" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + + act(function() + ReactNoop.render( + React.createElement(Counter, { label = "Count", count = 1 }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + -- Count changed + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + end) - act(function() - ReactNoop.render(React.createElement(Counter, { label = "Total", count = 1 }), function() + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [Count: 0]", + "Did create [Count: 1]", + }) + + act(function() + ReactNoop.render( + React.createElement(Counter, { label = "Count", count = 1 }), + function() Scheduler.unstable_yieldValue("Sync effect") - end) - -- Label changed - jestExpect(Scheduler).toFlushAndYieldThrough({ "Total: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Total: 1") }) - end) + end + ) + -- Nothing changed, so no effect should have fired + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + end) - jestExpect(Scheduler).toHaveYielded({ - "Did destroy [Count: 1]", - "Did create [Total: 1]", - }) + jestExpect(Scheduler).toHaveYielded({}) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + + act(function() + ReactNoop.render( + React.createElement(Counter, { label = "Total", count = 1 }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + -- Label changed + jestExpect(Scheduler).toFlushAndYieldThrough({ "Total: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Total: 1") }) end) - it("multiple effects", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue("Did commit 1 [" .. tostring(props.count) .. "]") - end) - useEffect(function() - Scheduler.unstable_yieldValue("Did commit 2 [" .. tostring(props.count) .. "]") - end) - return React.createElement(Text, { text = "Count: " .. tostring(props.count) }) + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [Count: 1]", + "Did create [Total: 1]", + }) + end) + + -- ROBLOX deviation START: Test that deps can include nil values + it("skips or reruns effects correctly when deps have nil values", function() + local function Counter(props) + local deps = {} + -- Create a list of deps for each non-'.' character, like: + -- { "A", nil, nil, nil, nil } or { nil, nil, nil, nil, "E" } + for i, char in string.split(props.deps, "") do + deps[i] = if char ~= "." then char else nil end + useEffect(function() + Scheduler.unstable_yieldValue("Did create [" .. props.deps .. "]") + return function() + Scheduler.unstable_yieldValue("Did destroy [" .. props.deps .. "]") + end + end, deps) + return React.createElement(Text, { text = props.deps }) + end + jestExpect(function() act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.render( + React.createElement(Counter, { deps = "A...." }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "A....", + "Sync effect", + }) end) + end).toErrorDev({--[[no errors]] + }) - jestExpect(Scheduler).toHaveYielded({ "Did commit 1 [0]", "Did commit 2 [0]" }) + jestExpect(Scheduler).toHaveYielded({ "Did create [A....]" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("A....") }) + jestExpect(function() act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + ReactNoop.render( + React.createElement(Counter, { deps = "A...E" }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "A...E", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("A...E") }) end) - jestExpect(Scheduler).toHaveYielded({ "Did commit 1 [1]", "Did commit 2 [1]" }) - end) + end).toErrorDev({--[[no errors]] + }) - it("unmounts all previous effects before creating any new ones", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue("Mount A [" .. props.count .. "]") - return function() - Scheduler.unstable_yieldValue("Unmount A [" .. props.count .. "]") - end - end) - useEffect(function() - Scheduler.unstable_yieldValue("Mount B [" .. props.count .. "]") - return function() - Scheduler.unstable_yieldValue("Unmount B [" .. props.count .. "]") + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [A....]", + "Did create [A...E]", + }) + + jestExpect(function() + act(function() + ReactNoop.render( + React.createElement(Counter, { deps = "ABCDE" }), + function() + Scheduler.unstable_yieldValue("Sync effect") end - end) - return React.createElement(Text, { text = "Count: " .. props.count }) - end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "ABCDE", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("ABCDE") }) + end) + end).toErrorDev({--[[no errors]] + }) + + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [A...E]", + "Did create [ABCDE]", + }) + + jestExpect(function() act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + ReactNoop.render( + React.createElement(Counter, { deps = "....E" }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "....E", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("....E") }) end) + end).toErrorDev({--[[no errors]] + }) - jestExpect(Scheduler).toHaveYielded({ "Mount A [0]", "Mount B [0]" }) + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [ABCDE]", + "Did create [....E]", + }) + jestExpect(function() act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + ReactNoop.render( + React.createElement(Counter, { deps = "..C.." }), + function() + Scheduler.unstable_yieldValue("Sync effect") + end + ) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "..C..", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("..C..") }) end) - jestExpect(Scheduler).toHaveYielded({ - "Unmount A [0]", - "Unmount B [0]", - "Mount A [1]", - "Mount B [1]", - }) + end).toErrorDev({--[[no errors]] + }) + + jestExpect(Scheduler).toHaveYielded({ + "Did destroy [....E]", + "Did create [..C..]", + }) + end) + -- ROBLOX deviation END + + it("multiple effects", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue( + "Did commit 1 [" .. tostring(props.count) .. "]" + ) + end) + useEffect(function() + Scheduler.unstable_yieldValue( + "Did commit 2 [" .. tostring(props.count) .. "]" + ) + end) + return React.createElement( + Text, + { text = "Count: " .. tostring(props.count) } + ) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) + + jestExpect(Scheduler).toHaveYielded({ "Did commit 1 [0]", "Did commit 2 [0]" }) + + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) end) + jestExpect(Scheduler).toHaveYielded({ "Did commit 1 [1]", "Did commit 2 [1]" }) + end) + + it("unmounts all previous effects before creating any new ones", function() + -- ROBLOX FIXME Luau: inference should know the prop shape based on string concat below and narrowed by createElement later + local function Counter(props: { count: number }) + useEffect(function() + Scheduler.unstable_yieldValue("Mount A [" .. props.count .. "]") + return function() + Scheduler.unstable_yieldValue("Unmount A [" .. props.count .. "]") + end + end) + useEffect(function() + Scheduler.unstable_yieldValue("Mount B [" .. props.count .. "]") + return function() + Scheduler.unstable_yieldValue("Unmount B [" .. props.count .. "]") + end + end) + return React.createElement(Text, { text = "Count: " .. props.count }) + end + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + end) + + jestExpect(Scheduler).toHaveYielded({ "Mount A [0]", "Mount B [0]" }) + + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", "Sync effect" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + end) + jestExpect(Scheduler).toHaveYielded({ + "Unmount A [0]", + "Unmount B [0]", + "Mount A [1]", + "Mount B [1]", + }) + end) - it("unmounts all previous effects between siblings before creating any new ones", function() + it( + "unmounts all previous effects between siblings before creating any new ones", + function() local function Counter(props) local count, label = props.count, props.label useEffect(function() - Scheduler.unstable_yieldValue(("Mount %s [%s]"):format(label, count)) + Scheduler.unstable_yieldValue( + string.format("Mount %s [%d]", label, count) + ) return function() - Scheduler.unstable_yieldValue(("Unmount %s [%s]"):format(label, count)) + Scheduler.unstable_yieldValue( + string.format("Unmount %s [%d]", label, count) + ) end end) return React.createElement(Text, { - text = ("%s %s"):format(label, count), + text = string.format("%s %d", label, count), }) end @@ -2448,1086 +2705,1254 @@ return function() "Mount B [2]", "Mount C [0]", }) - end) - it("handles errors in create on mount", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue(("Mount A [%s]"):format(props.count)) + end + ) + it("handles errors in create on mount", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue(string.format("Mount A [%d]", props.count)) - return function() - Scheduler.unstable_yieldValue(("Unmount A [%s]"):format(props.count)) - end - end) - useEffect(function() - Scheduler.unstable_yieldValue("Oops!") - error("Oops!") - -- deviation: upstream notes that following code is unreachable. - -- Scheduler.unstable_yieldValue(('Mount B [%s]'):format(props.count)) - -- return function() - -- Scheduler.unstable_yieldValue(('Unmount B [%s]'):format(props.count)) - -- end - end) + return function() + Scheduler.unstable_yieldValue( + string.format("Unmount A [%d]", props.count) + ) + end + end) + useEffect(function() + Scheduler.unstable_yieldValue("Oops!") + error("Oops!") + -- deviation: upstream notes that following code is unreachable. + -- Scheduler.unstable_yieldValue(string.format('Mount B [%s]', props.count)) + -- return function() + -- Scheduler.unstable_yieldValue(string.format('Unmount B [%s]', props.count)) + -- end + end) - return React.createElement(Text, { - text = "Count: " .. props.count, - }) - end + return React.createElement(Text, { + text = "Count: " .. props.count, + }) + end - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: 0", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 0"), - }) - jestExpect(function() - return ReactNoop.flushPassiveEffects() - end).toThrow("Oops") + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + return Scheduler.unstable_yieldValue("Sync effect") end) - jestExpect(Scheduler).toHaveYielded({ - "Mount A [0]", - "Oops!", - "Unmount A [0]", + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: 0", + "Sync effect", }) - jestExpect(ReactNoop.getChildren()).toEqual({}) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 0"), + }) + jestExpect(function() + return ReactNoop.flushPassiveEffects() + end).toThrow("Oops") end) - it("handles errors in create on update", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue(("Mount A [%s]"):format(props.count)) - - return function() - Scheduler.unstable_yieldValue(("Unmount A [%s]"):format(props.count)) - end - end) - useEffect(function() - if props.count == 1 then - Scheduler.unstable_yieldValue("Oops!") - error("Oops!") - end - - Scheduler.unstable_yieldValue(("Mount B [%s]"):format(props.count)) + jestExpect(Scheduler).toHaveYielded({ + "Mount A [0]", + "Oops!", + "Unmount A [0]", + }) + jestExpect(ReactNoop.getChildren()).toEqual({}) + end) + it("handles errors in create on update", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue(string.format("Mount A [%d]", props.count)) - return function() - Scheduler.unstable_yieldValue(("Unmount B [%s]"):format(props.count)) - end - end) + return function() + Scheduler.unstable_yieldValue( + string.format("Unmount A [%d]", props.count) + ) + end + end) + useEffect(function() + if props.count == 1 then + Scheduler.unstable_yieldValue("Oops!") + error("Oops!") + end - return React.createElement(Text, { - text = "Count: " .. props.count, - }) - end + Scheduler.unstable_yieldValue(string.format("Mount B [%d]", props.count)) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: 0", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 0"), - }) - ReactNoop.flushPassiveEffects() - jestExpect(Scheduler).toHaveYielded({ - "Mount A [0]", - "Mount B [0]", - }) + return function() + Scheduler.unstable_yieldValue( + string.format("Unmount B [%d]", props.count) + ) + end end) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: 1", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 1"), - }) - jestExpect(function() - return ReactNoop.flushPassiveEffects() - end).toThrow("Oops") - jestExpect(Scheduler).toHaveYielded({ - "Unmount A [0]", - "Unmount B [0]", - "Mount A [1]", - "Oops!", - }) - jestExpect(ReactNoop.getChildren()).toEqual({}) + return React.createElement(Text, { + text = "Count: " .. props.count, + }) + end + + act(function() + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + return Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: 0", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 0"), + }) + ReactNoop.flushPassiveEffects() jestExpect(Scheduler).toHaveYielded({ - "Unmount A [1]", + "Mount A [0]", + "Mount B [0]", }) end) - it("handles errors in destroy on update", function() - local function Counter(props) - useEffect(function() - Scheduler.unstable_yieldValue(("Mount A [%s]"):format(props.count)) - - return function() - Scheduler.unstable_yieldValue("Oops!") - - if props.count == 0 then - error("Oops!") - end - end - end) - useEffect(function() - Scheduler.unstable_yieldValue(("Mount B [%s]"):format(props.count)) - - return function() - Scheduler.unstable_yieldValue(("Unmount B [%s]"):format(props.count)) - end - end) - - return React.createElement(Text, { - text = "Count: " .. props.count, - }) - end - - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: 0", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 0"), - }) - ReactNoop.flushPassiveEffects() - jestExpect(Scheduler).toHaveYielded({ - "Mount A [0]", - "Mount B [0]", - }) - end) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Count: 1", - "Sync effect", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Count: 1"), - }) - jestExpect(function() - return ReactNoop.flushPassiveEffects() - end).toThrow("Oops") - jestExpect(Scheduler).toHaveYielded({ - "Oops!", - "Unmount B [0]", - "Mount A [1]", - "Mount B [1]", - }) + act(function() + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + return Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: 1", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 1"), + }) + jestExpect(function() + return ReactNoop.flushPassiveEffects() + end).toThrow("Oops") + jestExpect(Scheduler).toHaveYielded({ + "Unmount A [0]", + "Unmount B [0]", + "Mount A [1]", "Oops!", - "Unmount B [1]", }) jestExpect(ReactNoop.getChildren()).toEqual({}) end) + jestExpect(Scheduler).toHaveYielded({ + "Unmount A [1]", + }) + end) + it("handles errors in destroy on update", function() + local function Counter(props) + useEffect(function() + Scheduler.unstable_yieldValue(string.format("Mount A [%d]", props.count)) - it("works with memo", function() - local function Counter(props) - local count = props.count - - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Mount: " .. count) + return function() + Scheduler.unstable_yieldValue("Oops!") - return function() - return Scheduler.unstable_yieldValue("Unmount: " .. count) + if props.count == 0 then + error("Oops!") end - end) + end + end) + useEffect(function() + Scheduler.unstable_yieldValue(string.format("Mount B [%d]", props.count)) - return React.createElement(Text, { - text = "Count: " .. count, - }) - end + return function() + Scheduler.unstable_yieldValue( + string.format("Unmount B [%d]", props.count) + ) + end + end) - Counter = memo(Counter) + return React.createElement(Text, { + text = "Count: " .. props.count, + }) + end + act(function() ReactNoop.render(React.createElement(Counter, { count = 0 }), function() return Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 0", - "Mount: 0", "Sync effect", }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0"), }) + ReactNoop.flushPassiveEffects() + jestExpect(Scheduler).toHaveYielded({ + "Mount A [0]", + "Mount B [0]", + }) + end) + act(function() ReactNoop.render(React.createElement(Counter, { count = 1 }), function() return Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ "Count: 1", - "Unmount: 0", - "Mount: 1", "Sync effect", }) jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1"), }) - ReactNoop.render(nil) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Unmount: 1", + jestExpect(function() + return ReactNoop.flushPassiveEffects() + end).toThrow("Oops") + jestExpect(Scheduler).toHaveYielded({ + "Oops!", + "Unmount B [0]", + "Mount A [1]", + "Mount B [1]", }) - jestExpect(ReactNoop.getChildren()).toEqual({}) end) + jestExpect(Scheduler).toHaveYielded({ + "Oops!", + "Unmount B [1]", + }) + jestExpect(ReactNoop.getChildren()).toEqual({}) + end) + + it("works with memo", function() + local function Counter(props: { count: number }) + local count = props.count + + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Mount: " .. count) + + return function() + return Scheduler.unstable_yieldValue("Unmount: " .. count) + end + end) + + return React.createElement(Text, { + text = "Count: " .. count, + }) + end - -- ROBLOX FIXME: Error Boundaries are implemented now, most of these should now pass - -- describe('errors thrown in passive destroy function within unmounted trees', function() - -- local BrokenUseEffectCleanup - -- local ErrorBoundary - -- local DerivedStateOnlyErrorBoundary - -- local LogOnlyErrorBoundary - - -- beforeEach(function() - -- BrokenUseEffectCleanup = function() - -- useEffect(function() - -- Scheduler.unstable_yieldValue('BrokenUseEffectCleanup useEffect') - -- return function() - -- Scheduler.unstable_yieldValue( - -- 'BrokenUseEffectCleanup useEffect destroy', - -- ) - -- throw new Error('Expected error') - -- end - -- }, []) - - -- return 'inner child' - -- end - - -- ErrorBoundary = class extends React.Component { - -- state = {error: nil} - -- static getDerivedStateFromError(error) - -- Scheduler.unstable_yieldValue( - -- `ErrorBoundary static getDerivedStateFromError`, - -- ) - -- return {error} - -- end - -- componentDidCatch(error, info) - -- Scheduler.unstable_yieldValue(`ErrorBoundary componentDidCatch`) - -- end - -- render() - -- if this.state.error) - -- Scheduler.unstable_yieldValue('ErrorBoundary render error') - -- return - -- end - -- Scheduler.unstable_yieldValue('ErrorBoundary render success') - -- return this.props.children or nil - -- end - -- end - - -- DerivedStateOnlyErrorBoundary = class extends React.Component { - -- state = {error: nil} - -- static getDerivedStateFromError(error) - -- Scheduler.unstable_yieldValue( - -- `DerivedStateOnlyErrorBoundary static getDerivedStateFromError`, - -- ) - -- return {error} - -- end - -- render() - -- if this.state.error) - -- Scheduler.unstable_yieldValue( - -- 'DerivedStateOnlyErrorBoundary render error', - -- ) - -- return - -- end - -- Scheduler.unstable_yieldValue( - -- 'DerivedStateOnlyErrorBoundary render success', - -- ) - -- return this.props.children or nil - -- end - -- end - - -- LogOnlyErrorBoundary = class extends React.Component { - -- componentDidCatch(error, info) - -- Scheduler.unstable_yieldValue( - -- `LogOnlyErrorBoundary componentDidCatch`, - -- ) - -- end - -- render() - -- Scheduler.unstable_yieldValue(`LogOnlyErrorBoundary render`) - -- return this.props.children or nil - -- end - -- end - -- }) - - -- -- @gate old - -- it('should call componentDidCatch() for the nearest unmounted log-only boundary', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'LogOnlyErrorBoundary render', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- jestExpect(Scheduler).toFlushAndYieldThrough([ - -- 'ErrorBoundary render success', - -- ]) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'LogOnlyErrorBoundary componentDidCatch', - -- ]) - -- }) - - -- -- @gate old - -- it('should call componentDidCatch() for the nearest unmounted logging-capable boundary', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- jestExpect(Scheduler).toFlushAndYieldThrough([ - -- 'ErrorBoundary render success', - -- ]) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'ErrorBoundary componentDidCatch', - -- ]) - -- }) - - -- -- @gate old - -- it('should not call getDerivedStateFromError for unmounted error boundaries', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'ErrorBoundary componentDidCatch', - -- ]) - -- }) - - -- -- @gate old - -- it('should not throw if there are no unmounted logging-capable boundaries to call', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'DerivedStateOnlyErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'BrokenUseEffectCleanup useEffect destroy', - -- ]) - -- }) - - -- -- @gate new - -- it('should use the nearest still-mounted boundary if there are no unmounted boundaries', function() - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'LogOnlyErrorBoundary render', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'LogOnlyErrorBoundary render', - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'LogOnlyErrorBoundary componentDidCatch', - -- ]) - -- }) - - -- -- @gate new - -- it('should skip unmounted boundaries and use the nearest still-mounted boundary', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'LogOnlyErrorBoundary render', - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'LogOnlyErrorBoundary render', - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'LogOnlyErrorBoundary componentDidCatch', - -- ]) - -- }) - - -- -- @gate new - -- it('should call getDerivedStateFromError in the nearest still-mounted boundary', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect destroy', - -- 'ErrorBoundary static getDerivedStateFromError', - -- 'ErrorBoundary render error', - -- 'ErrorBoundary componentDidCatch', - -- ]) - - -- jestExpect(ReactNoop.getChildren()).toEqual([ - -- span('ErrorBoundary fallback'), - -- ]) - -- }) - - -- -- @gate new - -- it('should rethrow error if there are no still-mounted boundaries', function() - -- function Conditional({showChildren}) - -- if showChildren) - -- return ( - -- - -- - -- - -- ) - -- } else { - -- return nil - -- end - -- end - - -- act(function() - -- ReactNoop.render() - -- }) - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'ErrorBoundary render success', - -- 'BrokenUseEffectCleanup useEffect', - -- ]) - - -- jestExpect(function() - -- act(function() - -- ReactNoop.render() - -- }) - -- }).toThrow('Expected error') - - -- jestExpect(Scheduler).toHaveYielded([ - -- 'BrokenUseEffectCleanup useEffect destroy', - -- ]) - - -- jestExpect(ReactNoop.getChildren()).toEqual([]) - -- }) - -- }) - - -- it('calls passive effect destroy functions for memoized components', function() - -- local Wrapper = ({children}) => children - -- function Child() - -- React.useEffect(function() - -- Scheduler.unstable_yieldValue('passive create') - -- return function() - -- Scheduler.unstable_yieldValue('passive destroy') - -- end - -- }, []) - -- React.useLayoutEffect(function() - -- Scheduler.unstable_yieldValue('layout create') - -- return function() - -- Scheduler.unstable_yieldValue('layout destroy') - -- end - -- }, []) - -- Scheduler.unstable_yieldValue('render') - -- return nil - -- end - - -- local isEqual = (prevProps, nextProps) => - -- prevProps.prop == nextProps.prop - -- local MemoizedChild = React.memo(Child, isEqual) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'render', - -- 'layout create', - -- 'passive create', - -- ]) - - -- -- Include at least one no-op (memoized) update to trigger original bug. - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'render', - -- 'layout destroy', - -- 'layout create', - -- 'passive destroy', - -- 'passive create', - -- ]) - - -- act(function() - -- ReactNoop.render(null) - -- }) - -- jestExpect(Scheduler).toHaveYielded(['layout destroy', 'passive destroy']) - -- }) - - -- it('calls passive effect destroy functions for descendants of memoized components', function() - -- local Wrapper = ({children}) => children - -- function Child() - -- return - -- end - - -- function Grandchild() - -- React.useEffect(function() - -- Scheduler.unstable_yieldValue('passive create') - -- return function() - -- Scheduler.unstable_yieldValue('passive destroy') - -- end - -- }, []) - -- React.useLayoutEffect(function() - -- Scheduler.unstable_yieldValue('layout create') - -- return function() - -- Scheduler.unstable_yieldValue('layout destroy') - -- end - -- }, []) - -- Scheduler.unstable_yieldValue('render') - -- return nil - -- end - - -- local isEqual = (prevProps, nextProps) => - -- prevProps.prop == nextProps.prop - -- local MemoizedChild = React.memo(Child, isEqual) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'render', - -- 'layout create', - -- 'passive create', - -- ]) - - -- -- Include at least one no-op (memoized) update to trigger original bug. - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([]) - - -- act(function() - -- ReactNoop.render( - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'render', - -- 'layout destroy', - -- 'layout create', - -- 'passive destroy', - -- 'passive create', - -- ]) - - -- act(function() - -- ReactNoop.render(null) - -- }) - -- jestExpect(Scheduler).toHaveYielded(['layout destroy', 'passive destroy']) - -- }) + -- ROBLOX TODO: contribute this rename upstream, it makes the code/types sane + local CounterMemo = memo(Counter) + + ReactNoop.render(React.createElement(CounterMemo, { count = 0 }), function() + return Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: 0", + "Mount: 0", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 0"), + }) + ReactNoop.render(React.createElement(CounterMemo, { count = 1 }), function() + return Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Count: 1", + "Unmount: 0", + "Mount: 1", + "Sync effect", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Count: 1"), + }) + ReactNoop.render(nil) + jestExpect(Scheduler).toFlushAndYieldThrough({ + "Unmount: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({}) end) - describe("useLayoutEffect", function() - it("fires layout effects after the host has been mutated", function() - local function getCommittedText() - local yields = Scheduler.unstable_clearYields() - local children = ReactNoop.getChildren() - Scheduler.unstable_yieldValue(yields) - if children == nil then - return nil + -- ROBLOX FIXME: Error Boundaries are implemented now, most of these should now pass + -- describe('errors thrown in passive destroy function within unmounted trees', function() + -- local BrokenUseEffectCleanup + -- local ErrorBoundary + -- local DerivedStateOnlyErrorBoundary + -- local LogOnlyErrorBoundary + + -- beforeEach(function() + -- BrokenUseEffectCleanup = function() + -- useEffect(function() + -- Scheduler.unstable_yieldValue('BrokenUseEffectCleanup useEffect') + -- return function() + -- Scheduler.unstable_yieldValue( + -- 'BrokenUseEffectCleanup useEffect destroy', + -- ) + -- throw new Error('Expected error') + -- end + -- }, []) + + -- return 'inner child' + -- end + + -- ErrorBoundary = class extends React.Component { + -- state = {error: nil} + -- static getDerivedStateFromError(error) + -- Scheduler.unstable_yieldValue( + -- `ErrorBoundary static getDerivedStateFromError`, + -- ) + -- return {error} + -- end + -- componentDidCatch(error, info) + -- Scheduler.unstable_yieldValue(`ErrorBoundary componentDidCatch`) + -- end + -- render() + -- if this.state.error) + -- Scheduler.unstable_yieldValue('ErrorBoundary render error') + -- return + -- end + -- Scheduler.unstable_yieldValue('ErrorBoundary render success') + -- return this.props.children or nil + -- end + -- end + + -- DerivedStateOnlyErrorBoundary = class extends React.Component { + -- state = {error: nil} + -- static getDerivedStateFromError(error) + -- Scheduler.unstable_yieldValue( + -- `DerivedStateOnlyErrorBoundary static getDerivedStateFromError`, + -- ) + -- return {error} + -- end + -- render() + -- if this.state.error) + -- Scheduler.unstable_yieldValue( + -- 'DerivedStateOnlyErrorBoundary render error', + -- ) + -- return + -- end + -- Scheduler.unstable_yieldValue( + -- 'DerivedStateOnlyErrorBoundary render success', + -- ) + -- return this.props.children or nil + -- end + -- end + + -- LogOnlyErrorBoundary = class extends React.Component { + -- componentDidCatch(error, info) + -- Scheduler.unstable_yieldValue( + -- `LogOnlyErrorBoundary componentDidCatch`, + -- ) + -- end + -- render() + -- Scheduler.unstable_yieldValue(`LogOnlyErrorBoundary render`) + -- return this.props.children or nil + -- end + -- end + -- }) + + -- -- @gate old + -- it('should call componentDidCatch() for the nearest unmounted log-only boundary', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'LogOnlyErrorBoundary render', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- jestExpect(Scheduler).toFlushAndYieldThrough([ + -- 'ErrorBoundary render success', + -- ]) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'LogOnlyErrorBoundary componentDidCatch', + -- ]) + -- }) + + -- -- @gate old + -- it('should call componentDidCatch() for the nearest unmounted logging-capable boundary', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- jestExpect(Scheduler).toFlushAndYieldThrough([ + -- 'ErrorBoundary render success', + -- ]) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'ErrorBoundary componentDidCatch', + -- ]) + -- }) + + -- -- @gate old + -- it('should not call getDerivedStateFromError for unmounted error boundaries', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'ErrorBoundary componentDidCatch', + -- ]) + -- }) + + -- -- @gate old + -- it('should not throw if there are no unmounted logging-capable boundaries to call', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'DerivedStateOnlyErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'BrokenUseEffectCleanup useEffect destroy', + -- ]) + -- }) + + -- -- @gate new + -- it('should use the nearest still-mounted boundary if there are no unmounted boundaries', function() + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'LogOnlyErrorBoundary render', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'LogOnlyErrorBoundary render', + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'LogOnlyErrorBoundary componentDidCatch', + -- ]) + -- }) + + -- -- @gate new + -- it('should skip unmounted boundaries and use the nearest still-mounted boundary', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'LogOnlyErrorBoundary render', + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'LogOnlyErrorBoundary render', + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'LogOnlyErrorBoundary componentDidCatch', + -- ]) + -- }) + + -- -- @gate new + -- it('should call getDerivedStateFromError in the nearest still-mounted boundary', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect destroy', + -- 'ErrorBoundary static getDerivedStateFromError', + -- 'ErrorBoundary render error', + -- 'ErrorBoundary componentDidCatch', + -- ]) + + -- jestExpect(ReactNoop.getChildren()).toEqual([ + -- span('ErrorBoundary fallback'), + -- ]) + -- }) + + -- -- @gate new + -- it('should rethrow error if there are no still-mounted boundaries', function() + -- function Conditional({showChildren}) + -- if showChildren) + -- return ( + -- + -- + -- + -- ) + -- } else { + -- return nil + -- end + -- end + + -- act(function() + -- ReactNoop.render() + -- }) + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'ErrorBoundary render success', + -- 'BrokenUseEffectCleanup useEffect', + -- ]) + + -- jestExpect(function() + -- act(function() + -- ReactNoop.render() + -- }) + -- }).toThrow('Expected error') + + -- jestExpect(Scheduler).toHaveYielded([ + -- 'BrokenUseEffectCleanup useEffect destroy', + -- ]) + + -- jestExpect(ReactNoop.getChildren()).toEqual([]) + -- }) + -- }) + + -- it('calls passive effect destroy functions for memoized components', function() + -- local Wrapper = ({children}) => children + -- function Child() + -- React.useEffect(function() + -- Scheduler.unstable_yieldValue('passive create') + -- return function() + -- Scheduler.unstable_yieldValue('passive destroy') + -- end + -- }, []) + -- React.useLayoutEffect(function() + -- Scheduler.unstable_yieldValue('layout create') + -- return function() + -- Scheduler.unstable_yieldValue('layout destroy') + -- end + -- }, []) + -- Scheduler.unstable_yieldValue('render') + -- return nil + -- end + + -- local isEqual = (prevProps, nextProps) => + -- prevProps.prop == nextProps.prop + -- local MemoizedChild = React.memo(Child, isEqual) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([ + -- 'render', + -- 'layout create', + -- 'passive create', + -- ]) + + -- -- Include at least one no-op (memoized) update to trigger original bug. + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([ + -- 'render', + -- 'layout destroy', + -- 'layout create', + -- 'passive destroy', + -- 'passive create', + -- ]) + + -- act(function() + -- ReactNoop.render(null) + -- }) + -- jestExpect(Scheduler).toHaveYielded(['layout destroy', 'passive destroy']) + -- }) + + -- it('calls passive effect destroy functions for descendants of memoized components', function() + -- local Wrapper = ({children}) => children + -- function Child() + -- return + -- end + + -- function Grandchild() + -- React.useEffect(function() + -- Scheduler.unstable_yieldValue('passive create') + -- return function() + -- Scheduler.unstable_yieldValue('passive destroy') + -- end + -- }, []) + -- React.useLayoutEffect(function() + -- Scheduler.unstable_yieldValue('layout create') + -- return function() + -- Scheduler.unstable_yieldValue('layout destroy') + -- end + -- }, []) + -- Scheduler.unstable_yieldValue('render') + -- return nil + -- end + + -- local isEqual = (prevProps, nextProps) => + -- prevProps.prop == nextProps.prop + -- local MemoizedChild = React.memo(Child, isEqual) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([ + -- 'render', + -- 'layout create', + -- 'passive create', + -- ]) + + -- -- Include at least one no-op (memoized) update to trigger original bug. + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([]) + + -- act(function() + -- ReactNoop.render( + -- + -- + -- , + -- ) + -- }) + -- jestExpect(Scheduler).toHaveYielded([ + -- 'render', + -- 'layout destroy', + -- 'layout create', + -- 'passive destroy', + -- 'passive create', + -- ]) + + -- act(function() + -- ReactNoop.render(null) + -- }) + -- jestExpect(Scheduler).toHaveYielded(['layout destroy', 'passive destroy']) + -- }) +end) + +describe("useLayoutEffect", function() + it("fires layout effects after the host has been mutated", function() + local function getCommittedText() + local yields = Scheduler.unstable_clearYields() + local children = ReactNoop.getChildren() + Scheduler.unstable_yieldValue(yields) + if children == nil then + return nil + end + return children[1].prop + end + + local function Counter(props) + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Current: " .. tostring(getCommittedText())) + end) + return React.createElement(Text, { text = props.count }) + end + + ReactNoop.render(React.createElement(Counter, { count = 0 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + { 0 }, + "Current: 0", + "Sync effect", + -- ROBLOX FIXME Luau: Luau needs to support mixed array inference + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(0) }) + + ReactNoop.render(React.createElement(Counter, { count = 1 }), function() + Scheduler.unstable_yieldValue("Sync effect") + end) + jestExpect(Scheduler).toFlushAndYieldThrough({ + { 1 }, + "Current: 1", + "Sync effect", + -- ROBLOX FIXME Luau: Luau needs to support mixed array inference + } :: Array) + jestExpect(ReactNoop.getChildren()).toEqual({ span(1) }) + end) + + it("force flushes passive effects before firing new layout effects", function() + local committedText = "(empty)" + + local function Counter(props) + useLayoutEffect(function() + -- Normally this would go in a mutation effect, but this test + -- intentionally omits a mutation effect. + -- ROBLOX TODO: js2lua should translate `x + ''` to tostring(x) + committedText = tostring(props.count) + + Scheduler.unstable_yieldValue( + "Mount layout [current: " .. committedText .. "]" + ) + return function() + Scheduler.unstable_yieldValue( + "Unmount layout [current: " .. committedText .. "]" + ) + end + end) + useEffect(function() + Scheduler.unstable_yieldValue( + "Mount normal [current: " .. committedText .. "]" + ) + return function() + Scheduler.unstable_yieldValue( + "Unmount normal [current: " .. committedText .. "]" + ) end - return children[1].prop - end - - local function Counter(props) - useLayoutEffect(function() - Scheduler.unstable_yieldValue("Current: " .. getCommittedText()) - end) - return React.createElement(Text, { text = props.count }) - end + end) + return nil + end + act(function() ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - Scheduler.unstable_yieldValue("Sync effect") + return Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ - { 0 }, - "Current: 0", + "Mount layout [current: 0]", "Sync effect", }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(0) }) - + jestExpect(committedText).toEqual("0") ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - Scheduler.unstable_yieldValue("Sync effect") + return Scheduler.unstable_yieldValue("Sync effect") end) jestExpect(Scheduler).toFlushAndYieldThrough({ - { 1 }, - "Current: 1", + "Mount normal [current: 0]", + "Unmount layout [current: 0]", + "Mount layout [current: 1]", "Sync effect", }) - jestExpect(ReactNoop.getChildren()).toEqual({ span(1) }) + jestExpect(committedText).toEqual("1") end) - it("force flushes passive effects before firing new layout effects", function() - local committedText = "(empty)" - - local function Counter(props) - useLayoutEffect(function() - -- Normally this would go in a mutation effect, but this test - -- intentionally omits a mutation effect. - committedText = props.count .. "" - - Scheduler.unstable_yieldValue("Mount layout [current: " .. committedText .. "]") - return function() - Scheduler.unstable_yieldValue("Unmount layout [current: " .. committedText .. "]") - end - end) - useEffect(function() - Scheduler.unstable_yieldValue("Mount normal [current: " .. committedText .. "]") - return function() - Scheduler.unstable_yieldValue("Unmount normal [current: " .. committedText .. "]") - end - end) - return nil - end + jestExpect(Scheduler).toHaveYielded({ + "Unmount normal [current: 1]", + "Mount normal [current: 1]", + }) + end) - act(function() - ReactNoop.render(React.createElement(Counter, { count = 0 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Mount layout [current: 0]", - "Sync effect", - }) - jestExpect(committedText).toEqual("0") - ReactNoop.render(React.createElement(Counter, { count = 1 }), function() - return Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ - "Mount normal [current: 0]", - "Unmount layout [current: 0]", - "Mount layout [current: 1]", - "Sync effect", - }) - jestExpect(committedText).toEqual("1") - end) + -- ROBLOX TODO: this failing, but is it a bug? or this gate not enabled in our config? + -- @gate skipUnmountedBoundaries + xit("catches errors thrown in useLayoutEffect", function() + local ErrorBoundary = React.Component:extend("ErrorBoundary") + function ErrorBoundary:init() + self.state = { error = nil } + end - jestExpect(Scheduler).toHaveYielded({ - "Unmount normal [current: 1]", - "Mount normal [current: 1]", - }) - end) + function ErrorBoundary.getDerivedStateFromError(errorMsg) + Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") + return { error = errorMsg } + end - -- ROBLOX TODO: this failing, but is it a bug? or this gate not enabled in our config? - -- @gate skipUnmountedBoundaries - xit("catches errors thrown in useLayoutEffect", function() - local ErrorBoundary = React.Component:extend("ErrorBoundary") - function ErrorBoundary:init() - self.state = { error = nil } - end + -- deviation: raised to be above where its used + local function Component(props) + local id = props.id + Scheduler.unstable_yieldValue("Component render " .. id) + return React.createElement(span, { prop = id }) + end - function ErrorBoundary.getDerivedStateFromError(errorMsg) - Scheduler.unstable_yieldValue("ErrorBoundary static getDerivedStateFromError") - return { errorMsg } + function ErrorBoundary:render() + local children = self.props.children + local id = self.props.id + local fallbackID = self.props.fallbackID + local errorMsg = self.state.error + if errorMsg then + Scheduler.unstable_yieldValue(id .. " render error") + return React.createElement(Component, { id = fallbackID }) end + Scheduler.unstable_yieldValue(id .. " render success") - -- deviation: raised to be above where its used - local function Component(props) - local id = props.id - Scheduler.unstable_yieldValue("Component render " .. id) - return React.createElement(span, { prop = id }) - end + -- deviation: or nil not necessary in Lua + return children + end - function ErrorBoundary:render() - local children = self.props.children - local id = self.props.id - local fallbackID = self.props.fallbackID - local errorMsg = self.state.error - if errorMsg then - Scheduler.unstable_yieldValue(id .. " render error") - return React.createElement(Component, { id = fallbackID }) + local function BrokenLayoutEffectDestroy() + useLayoutEffect(function() + return function() + Scheduler.unstable_yieldValue( + "BrokenLayoutEffectDestroy useLayoutEffect destroy" + ) + error("Expected") end - Scheduler.unstable_yieldValue(id .. " render success") - - -- deviation: or nil not necessary in Lua - return children - end - - local function BrokenLayoutEffectDestroy() - useLayoutEffect(function() - return function() - Scheduler.unstable_yieldValue("BrokenLayoutEffectDestroy useLayoutEffect destroy") - error("Expected") - end - end, {}) + end, {}) - Scheduler.unstable_yieldValue("BrokenLayoutEffectDestroy render") - return React.createElement(span, { prop = "broken" }) - end + Scheduler.unstable_yieldValue("BrokenLayoutEffectDestroy render") + return React.createElement(span, { prop = "broken" }) + end - ReactNoop.render(React.createElement(ErrorBoundary, { id = "OuterBoundary", fallbackID = "OuterFallback" }, { - React.createElement(Component, { id = "sibling" }), - React.createElement( - ErrorBoundary, - { id = "InnerBoundary", fallbackID = "InnerFallback" }, - React.createElement(BrokenLayoutEffectDestroy) - ), - })) + ReactNoop.render( + React.createElement( + ErrorBoundary, + { id = "OuterBoundary", fallbackID = "OuterFallback" }, + { + React.createElement(Component, { id = "sibling" }), + React.createElement( + ErrorBoundary, + { id = "InnerBoundary", fallbackID = "InnerFallback" }, + React.createElement(BrokenLayoutEffectDestroy) + ), + } + ) + ) - jestExpect(Scheduler).toFlushAndYield({ - "OuterBoundary render success", - "Component render sibling", - "InnerBoundary render success", - "BrokenLayoutEffectDestroy render", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - React.createElement(span, { id = "sibling" }), - React.createElement(span, { id = "broken" }), - }) + jestExpect(Scheduler).toFlushAndYield({ + "OuterBoundary render success", + "Component render sibling", + "InnerBoundary render success", + "BrokenLayoutEffectDestroy render", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + React.createElement(span, { id = "sibling" }), + React.createElement(span, { id = "broken" }), + }) - ReactNoop.render(React.createElement( + ReactNoop.render( + React.createElement( ErrorBoundary, { id = "OuterBoundary", fallbackID = "OuterFallback" }, React.createElement(Component, { id = "sibling" }) - )) + ) + ) - -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. - jestExpect(Scheduler).toFlushAndYield({ - "OuterBoundary render success", - "Component render sibling", - "BrokenLayoutEffectDestroy useLayoutEffect destroy", - "ErrorBoundary static getDerivedStateFromError", - "OuterBoundary render error", - "Component render OuterFallback", + -- React should skip over the unmounting boundary and find the nearest still-mounted boundary. + jestExpect(Scheduler).toFlushAndYield({ + "OuterBoundary render success", + "Component render sibling", + "BrokenLayoutEffectDestroy useLayoutEffect destroy", + "ErrorBoundary static getDerivedStateFromError", + "OuterBoundary render error", + "Component render OuterFallback", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("OuterFallback") }) + end) +end) + +describe("useCallback", function() + it("memoizes callback by comparing inputs", function() + -- ROBLOX deviation: hoist local + local button = React.createRef() + local IncrementButton = React.PureComponent:extend("IncrementButton") + function IncrementButton:increment() + self.props.increment() + end + function IncrementButton:render() + return React.createElement(Text, { text = "Increment" }) + end + + -- ROBLOX deviation: we need to hold the instance so we can pass it as an explicit self argument, since Lua doesn't have function bindings + local incrementButtonInstance + + local function Counter(props) + local incrementBy = props.incrementBy + local count, updateCount = useState(0) + local increment = useCallback(function() + return updateCount(function(c) + return c + incrementBy + end) + end, { + incrementBy, + }) + -- ROBLOX deviation: we need to hold the instance so we can pass it as an explicit self argument, since Lua doesn't have function bindings + -- ROBLOX deviation: we also assign explicit keys to quiet a warning in DEV mode + incrementButtonInstance = React.createElement( + IncrementButton, + { key = "1", increment = increment, ref = button } + ) + return React.createElement(React.Fragment, {}, { + incrementButtonInstance, + React.createElement(Text, { key = "2", text = "Count: " .. count }), }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("OuterFallback") }) + end + + ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) + jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 0"), + }) + + -- ROBLOX deviation: call ref increment() with an explicit self argument + act(function() + button.current.increment(incrementButtonInstance) + end) + jestExpect(Scheduler).toHaveYielded({ + -- Button should not re-render, because its props haven't changed + -- 'Increment', + "Count: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 1"), + }) + + -- Increase the increment amount + ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) + jestExpect(Scheduler).toFlushAndYield({ + -- Inputs did change this time + "Increment", + "Count: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 1"), + }) + + -- Callback should have updated + -- ROBLOX deviation: call ref increment() with an explicit self argument + act(function() + button.current.increment(incrementButtonInstance) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 11"), + }) end) - describe("useCallback", function() - -- ROBLOX FIXME: Rendered fewer hooks than expected - xit("memoizes callback by comparing inputs", function() - -- ROBLOX deviation: hoist local - local button = React.createRef(nil) - local IncrementButton = React.PureComponent:extend("IncrementButton") - function IncrementButton:increment() - self.props.increment() - end - function IncrementButton:render() - return React.createElement(Text, { text = "Increment" }) + it("correctly interprets input changes with nil values", function() + local incrementCallback + local IncrementButton = React.PureComponent:extend("IncrementButton") + function IncrementButton:render() + incrementCallback = self.props.increment + return React.createElement(Text, { text = "Increment" }) + end + + local function Counter(props) + local deps = {} + -- Create a list of deps for each non-'.' character, like: + -- { "A", nil, nil, nil, nil } or { nil, nil, nil, nil, "E" } + for i, char in string.split(props.input, "") do + deps[i] = if char ~= "." then char else nil end + local count, updateCount = useState(0) + local increment = useCallback(function() + return updateCount(function(c) + -- normally, we'd make `incrementBy` a dependency in the + -- array, but this test uses contrived logic + return c + props.incrementBy + end) + end, deps) + return React.createElement( + React.Fragment, + {}, + React.createElement(IncrementButton, { increment = increment }), + React.createElement(Text, { text = "Count: " .. count }) + ) + end - -- ROBLOX deviation: we need to hold the instance so we can pass it as an explicit self argument, since Lua doesn't have function bindings - local incrementButtonInstance + ReactNoop.render( + React.createElement(Counter, { input = "A....", incrementBy = 1 }) + ) + jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 0"), + }) - local function Counter(props) - local incrementBy = props.incrementBy - local count, updateCount = useState(0) - local increment = useCallback(function() - return updateCount(function(c) - return c + incrementBy - end) - end, { - incrementBy, - }) - -- ROBLOX deviation: we need to hold the instance so we can pass it as an explicit self argument, since Lua doesn't have function bindings - -- ROBLOX deviation: we also assign explicit keys to quiet a warning in DEV mode - incrementButtonInstance = React.createElement( - IncrementButton, - { key = "1", increment = increment, ref = button } - ) - return { - incrementButtonInstance, - React.createElement(Text, { key = "2", text = "Count: " .. count }), - } - end + act(incrementCallback) + jestExpect(Scheduler).toHaveYielded({ + -- Button should not re-render, because its props haven't changed + -- 'Increment', + "Count: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 1"), + }) - ReactNoop.render(React.createElement(Counter, { incrementBy = 1 })) - jestExpect(Scheduler).toFlushAndYield({ "Increment", "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Increment"), - span("Count: 0"), - }) + -- Increase the increment amount + ReactNoop.render( + React.createElement(Counter, { input = "A....", incrementBy = 10 }) + ) + jestExpect(Scheduler).toFlushAndYield({ + -- Button should not re-render, because we haven't changed input + -- "Increment", + "Count: 1", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 1"), + }) - -- ROBLOX deviation: call ref increment() with an explicit self argument - act(function() - button.current.increment(incrementButtonInstance) - end) - jestExpect(Scheduler).toHaveYielded({ - -- Button should not re-render, because its props haven't changed - -- 'Increment', - "Count: 1", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Increment"), - span("Count: 1"), - }) + -- Callback should not have updated since the input did not + act(incrementCallback) + jestExpect(Scheduler).toHaveYielded({ "Count: 2" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) - -- Increase the increment amount - ReactNoop.render(React.createElement(Counter, { incrementBy = 10 })) - jestExpect(Scheduler).toFlushAndYield({ - -- Inputs did change this time - "Increment", - "Count: 1", - }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Increment"), - span("Count: 1"), - }) + -- Increase the increment amount + ReactNoop.render( + React.createElement(Counter, { input = "A...E", incrementBy = 10 }) + ) + jestExpect(Scheduler).toFlushAndYield({ + -- Inputs did change this time + "Increment", + "Count: 2", + }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 2"), + }) - -- Callback should have updated - -- ROBLOX deviation: call ref increment() with an explicit self argument - act(function() - button.current.increment(incrementButtonInstance) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 11" }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("Increment"), - span("Count: 11"), + act(incrementCallback) + jestExpect(Scheduler).toHaveYielded({ "Count: 12" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("Increment"), + span("Count: 12"), + }) + end) +end) + +describe("useMemo", function() + it("memoizes value by comparing to previous inputs", function() + local function CapitalizedText(props) + -- ROBLOX FIXME Luau: remove string annotation below once Luau infers .. text -> props: { text: string } + local text: string = props.text + local capitalizedText = useMemo(function() + Scheduler.unstable_yieldValue("Capitalize '" .. text .. "'") + return string.upper(text) + end, { + text, }) - end) + return React.createElement(Text, { text = capitalizedText }) + end + + ReactNoop.render(React.createElement(CapitalizedText, { text = "hello" })) + jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'hello'", "HELLO" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("HELLO") }) + + ReactNoop.render(React.createElement(CapitalizedText, { text = "hi" })) + jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'hi'", "HI" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("HI") }) + + ReactNoop.render(React.createElement(CapitalizedText, { text = "hi" })) + jestExpect(Scheduler).toFlushAndYield({ "HI" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("HI") }) + + ReactNoop.render(React.createElement(CapitalizedText, { text = "goodbye" })) + jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'goodbye'", "GOODBYE" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("GOODBYE") }) end) - describe("useMemo", function() - it("memoizes value by comparing to previous inputs", function() - local function CapitalizedText(props) - local text = props.text - local capitalizedText = useMemo(function() - Scheduler.unstable_yieldValue("Capitalize '" .. text .. "'") - return text:upper() - end, { - text, - }) - return React.createElement(Text, { text = capitalizedText }) - end + -- ROBLOX deviation START: multi-return is a luau language feature + it("returns multiple input values", function() + local function Doubler(props: { x: number, y: number }) + local x = props.x + local y = props.y + local xMinusY, xPlusY = useMemo(function() + local a = x - y + local b = x + y + Scheduler.unstable_yieldValue( + "x - y = " .. tostring(a) .. ", x + y = " .. tostring(b) + ) + return a, b + end, { + x, + y, + }) + return React.createElement( + Text, + { text = tostring(xMinusY) .. tostring(xPlusY) } + ) + end - ReactNoop.render(React.createElement(CapitalizedText, { text = "hello" })) - jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'hello'", "HELLO" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("HELLO") }) + ReactNoop.render(React.createElement(Doubler, { x = 1, y = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "x - y = -1, x + y = 3", "-13" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("-13") }) - ReactNoop.render(React.createElement(CapitalizedText, { text = "hi" })) - jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'hi'", "HI" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("HI") }) + ReactNoop.render(React.createElement(Doubler, { x = 4, y = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "x - y = 2, x + y = 6", "26" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("26") }) - ReactNoop.render(React.createElement(CapitalizedText, { text = "hi" })) - jestExpect(Scheduler).toFlushAndYield({ "HI" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("HI") }) + ReactNoop.render(React.createElement(Doubler, { x = 4, y = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "26" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("26") }) - ReactNoop.render(React.createElement(CapitalizedText, { text = "goodbye" })) - jestExpect(Scheduler).toFlushAndYield({ "Capitalize 'goodbye'", "GOODBYE" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("GOODBYE") }) - end) + ReactNoop.render(React.createElement(Doubler, { x = 8, y = 2 })) + jestExpect(Scheduler).toFlushAndYield({ "x - y = 6, x + y = 10", "610" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("610") }) + end) + -- ROBLOX deviation END - it("always re-computes if no inputs are provided", function() - local function LazyCompute(props) - local computed = useMemo(props.compute) - return React.createElement(Text, { text = computed }) - end + it("always re-computes if no inputs are provided", function() + local function LazyCompute(props) + local computed = useMemo(props.compute) + return React.createElement(Text, { text = computed }) + end - local function computeA() - Scheduler.unstable_yieldValue("compute A") - return "A" - end + local function computeA() + Scheduler.unstable_yieldValue("compute A") + return "A" + end - local function computeB() - Scheduler.unstable_yieldValue("compute B") - return "B" - end + local function computeB() + Scheduler.unstable_yieldValue("compute B") + return "B" + end - ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) - jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) + ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) + jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) - ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) - jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) + ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) + jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) - ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) - jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) + ReactNoop.render(React.createElement(LazyCompute, { compute = computeA })) + jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) - ReactNoop.render(React.createElement(LazyCompute, { compute = computeB })) - jestExpect(Scheduler).toFlushAndYield({ "compute B", "B" }) - end) + ReactNoop.render(React.createElement(LazyCompute, { compute = computeB })) + jestExpect(Scheduler).toFlushAndYield({ "compute B", "B" }) + end) - it("should not invoke memoized function during re-renders unless inputs change", function() + it( + "should not invoke memoized function during re-renders unless inputs change", + function() local function LazyCompute(props) local computed = useMemo(function() return props.compute(props.input) @@ -3544,532 +3969,715 @@ return function() return val end - ReactNoop.render(React.createElement(LazyCompute, { compute = compute, input = "A" })) + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "A" }) + ) jestExpect(Scheduler).toFlushAndYield({ "compute A", "A" }) - ReactNoop.render(React.createElement(LazyCompute, { compute = compute, input = "A" })) + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "A" }) + ) jestExpect(Scheduler).toFlushAndYield({ "A" }) - ReactNoop.render(React.createElement(LazyCompute, { compute = compute, input = "B" })) + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "B" }) + ) jestExpect(Scheduler).toFlushAndYield({ "compute B", "B" }) - end) - end) + end + ) + + -- ROBLOX deviation START: Test that deps can include nil values + it("correctly interprets input changes with nil values", function() + local function LazyCompute(props) + local deps = {} + -- Create a list of deps for each non-'.' character, like: + -- { "A", nil, nil, nil, nil } or { nil, nil, nil, nil, "E" } + for i, char in string.split(props.input, "") do + deps[i] = if char ~= "." then char else nil + end + local computed = useMemo(function() + return props.compute(props.input) + end, deps) + return React.createElement(Text, { text = computed }) + end - 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() - local jest = RobloxJest - jest.useFakeTimers() + local function compute(val) + Scheduler.unstable_yieldValue("compute " .. val) + return val + end - local function useDebouncedCallback(callback, ms, inputs) - local timeoutID = useRef(-1) - useEffect(function() - return function() + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "A...." }) + ) + jestExpect(Scheduler).toFlushAndYield({ "compute A....", "A...." }) + + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "A...E" }) + ) + jestExpect(Scheduler).toFlushAndYield({ "compute A...E", "A...E" }) + + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "ABCDE" }) + ) + jestExpect(Scheduler).toFlushAndYield({ "compute ABCDE", "ABCDE" }) + + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "....E" }) + ) + jestExpect(Scheduler).toFlushAndYield({ "compute ....E", "....E" }) + + ReactNoop.render( + React.createElement(LazyCompute, { compute = compute, input = "..C.." }) + ) + jestExpect(Scheduler).toFlushAndYield({ "compute ..C..", "..C.." }) + end) + -- ROBLOX deviation END +end) + +describe("useRef", function() + -- ROBLOX TODO: clearTimeout: attempt to index number with userdata (LUAFDN-293) + it("creates a ref object initialized with the provided value", function() + local function useDebouncedCallback(callback, ms, inputs) + -- ROBLOX FIXME: Our setTimeout returns a table that doesn't convert into a number, resolve this + local timeoutID = useRef(setTimeout(function() end, 0)) + useEffect(function() + return function() + if typeof(timeoutID.current) == "table" then clearTimeout(timeoutID.current) end - end, {}) - local debouncedCallback = useCallback(function(...) + end + end, {}) + local debouncedCallback = useCallback(function(...) + if typeof(timeoutID.current) == "table" then clearTimeout(timeoutID.current) - timeoutID.current = setTimeout(callback, ms, ...) - end, { - callback, - ms, - }) - return useCallback(debouncedCallback, inputs) - end + end + timeoutID.current = setTimeout(callback, ms, ...) + end, { + callback, + ms, + } :: Array) + return useCallback(debouncedCallback, inputs) + end - local ping - local function App() - ping = useDebouncedCallback(function(value) - Scheduler.unstable_yieldValue("ping: " .. value) - end, 100, {}) - return nil - end + local ping + local function App() + ping = useDebouncedCallback(function(value) + Scheduler.unstable_yieldValue("ping: " .. value) + end, 100, {}) + return nil + end - act(function() - ReactNoop.render(React.createElement(App)) - end) - jestExpect(Scheduler).toHaveYielded({}) + act(function() + ReactNoop.render(React.createElement(App)) + end) + jestExpect(Scheduler).toHaveYielded({}) + + ping(1) + ping(2) + ping(3) + + jestExpect(Scheduler).toHaveYielded({}) + + jest.advanceTimersByTime(100) + + jestExpect(Scheduler).toHaveYielded({ "ping: 3" }) - ping(1) - ping(2) - ping(3) + ping(4) + jest.advanceTimersByTime(20) + jestExpect(Scheduler).toHaveYielded({}) + ping(5) + jestExpect(Scheduler).toHaveYielded({}) + ping(6) + jestExpect(Scheduler).toHaveYielded({}) + jest.advanceTimersByTime(80) - jestExpect(Scheduler).toHaveYielded({}) + jestExpect(Scheduler).toHaveYielded({}) - jest.advanceTimersByTime(100) + jest.advanceTimersByTime(20) + jestExpect(Scheduler).toHaveYielded({ "ping: 6" }) + end) + + it("should return the same ref during re-renders", function() + local function Counter() + local ref = useRef("val") + local count, setCount = useState(0) + local firstRef = useState(ref) + + if firstRef ~= ref then + error("should never change") + end + + if count < 3 then + setCount(count + 1) + end + + return React.createElement(Text, { text = ref.current }) + end - jestExpect(Scheduler).toHaveYielded({ "ping: 3" }) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "val" }) - ping(4) - jest.advanceTimersByTime(20) - ping(5) - ping(6) - jest.advanceTimersByTime(80) + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "val" }) + end) +end) + +-- ROBLOX deviation START: binding support (these tests mimic the ref tests) +describe("useBinding", function() + -- ROBLOX TODO: clearTimeout: attempt to index number with userdata (LUAFDN-293) + it("creates a binding object initialized with the provided value", function() + local function useDebouncedCallback(callback, ms, inputs) + -- ROBLOX FIXME: Our setTimeout returns a table that doesn't convert into a number, resolve this + local timeoutID, updateTimeout = useBinding(setTimeout(function() end, 0)) + useEffect(function() + return function() + if typeof(timeoutID:getValue()) == "table" then + clearTimeout(timeoutID:getValue()) + end + end + end, {}) + local debouncedCallback = useCallback(function(...) + if typeof(timeoutID:getValue()) == "table" then + clearTimeout(timeoutID:getValue()) + end + updateTimeout(setTimeout(callback, ms, ...)) + end, { + callback, + ms, + } :: Array) + return useCallback(debouncedCallback, inputs) + end - jestExpect(Scheduler).toHaveYielded({}) + local ping + local function App() + ping = useDebouncedCallback(function(value) + Scheduler.unstable_yieldValue("ping: " .. value) + end, 100, {}) + return nil + end - jest.advanceTimersByTime(20) - jestExpect(Scheduler).toHaveYielded({ "ping: 6" }) + act(function() + ReactNoop.render(React.createElement(App)) end) + jestExpect(Scheduler).toHaveYielded({}) - it("should return the same ref during re-renders", function() - local function Counter() - local ref = useRef("val") - local count, setCount = useState(0) - local firstRef = useState(ref) + ping(1) + ping(2) + ping(3) - if firstRef ~= ref then - error("should never change") - end + jestExpect(Scheduler).toHaveYielded({}) - if count < 3 then - setCount(count + 1) - end + jest.advanceTimersByTime(100) - return React.createElement(Text, { text = ref.current }) - end + jestExpect(Scheduler).toHaveYielded({ "ping: 3" }) - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ "val" }) + ping(4) + jest.advanceTimersByTime(20) + jestExpect(Scheduler).toHaveYielded({}) + ping(5) + jestExpect(Scheduler).toHaveYielded({}) + ping(6) + jestExpect(Scheduler).toHaveYielded({}) + jest.advanceTimersByTime(80) - ReactNoop.render(React.createElement(Counter)) - jestExpect(Scheduler).toFlushAndYield({ "val" }) - end) + jestExpect(Scheduler).toHaveYielded({}) + + jest.advanceTimersByTime(20) + jestExpect(Scheduler).toHaveYielded({ "ping: 6" }) end) - describe("useImperativeHandle", function() - it("does not update when deps are the same", function() - local INCREMENT = "INCREMENT" + it("should return the same binding value during re-renders", function() + local function Counter() + local binding, _updater = useBinding("val") + local count, setCount = useState(0) + local firstBinding = useState(binding) - local function reducer_(state, action) - if action == INCREMENT then - return state + 1 - else - return state - end + if firstBinding ~= binding then + error("should never change") end - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - return { count = count, dispatch = dispatch } - end, {}) - return React.createElement(Text, { text = "Count: " .. count }) + if count < 3 then + setCount(count + 1) end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - jestExpect(counter.current.count).toEqual(0) - - act(function() - counter.current.dispatch(INCREMENT) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - -- Intentionally not updated because of [] deps: - jestExpect(counter.current.count).toEqual(0) - end) + return React.createElement(Text, { text = binding:getValue() }) + end - -- Regression test for https://github.com/facebook/react/issues/14782 - it("automatically updates when deps are not specified", function() - local INCREMENT = "INCREMENT" + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "val" }) - local function reducer_(state, action) - if action == INCREMENT then - return state + 1 - else - return state - end + ReactNoop.render(React.createElement(Counter)) + jestExpect(Scheduler).toFlushAndYield({ "val" }) + end) +end) +-- ROBLOX deviation END + +describe("useImperativeHandle", function() + it("does not update when deps are the same", function() + local INCREMENT = "INCREMENT" + + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == INCREMENT then + return state + 1 + else + return state end + end - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - return { count = count, dispatch = dispatch } - end) - return React.createElement(Text, { text = "Count: " .. count }) - end + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + return { count = count, dispatch = dispatch } + end, {}) + return React.createElement(Text, { text = "Count: " .. count }) + end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - jestExpect(counter.current.count).toEqual(0) + -- ROBLOX TODO: upstream this rename to make code/types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + jestExpect(counter.current.count).toEqual(0) - act(function() - counter.current.dispatch(INCREMENT) - end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - jestExpect(counter.current.count).toEqual(1) + act(function() + counter.current.dispatch(INCREMENT) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + -- Intentionally not updated because of [] deps: + jestExpect(counter.current.count).toEqual(0) + end) - it("updates when deps are different", function() - local INCREMENT = "INCREMENT" - - local function reducer_(state, action) - if action == INCREMENT then - return state + 1 - else - return state - end - end + -- Regression test for https://github.com/facebook/react/issues/14782 + it("automatically updates when deps are not specified", function() + local INCREMENT = "INCREMENT" - local totalRefUpdates = 0 - local function Counter(props, ref) - local count, dispatch = useReducer(reducer_, 0) - useImperativeHandle(ref, function() - totalRefUpdates = totalRefUpdates + 1 - return { count = count, dispatch = dispatch } - end, { - count, - }) - return React.createElement(Text, { text = "Count: " .. count }) + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == INCREMENT then + return state + 1 + else + return state end + end - Counter = forwardRef(Counter) - local counter = React.createRef(nil) - ReactNoop.render(React.createElement(Counter, { - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) - jestExpect(counter.current.count).toEqual(0) - jestExpect(totalRefUpdates).toEqual(1) - - act(function() - counter.current.dispatch(INCREMENT) + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + return { count = count, dispatch = dispatch } end) - jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - jestExpect(counter.current.count).toEqual(1) - jestExpect(totalRefUpdates).toEqual(2) + return React.createElement(Text, { text = "Count: " .. count }) + end - -- Update that doesn't change the ref dependencies - ReactNoop.render(React.createElement(Counter, { - ref = counter, - })) - jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) - jestExpect(counter.current.count).toEqual(1) - jestExpect(totalRefUpdates).toEqual(2) -- Should not increase since last time + -- ROBLOX TODO: upstream this rename to make code/types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + jestExpect(counter.current.count).toEqual(0) + + act(function() + counter.current.dispatch(INCREMENT) end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + jestExpect(counter.current.count).toEqual(1) end) - -- describe('useTransition', function() - -- -- @gate experimental - -- it('delays showing loading state until after timeout', async function() - -- local transition - -- function App() - -- local [show, setShow] = useState(false) - -- local [startTransition, isPending] = useTransition({ - -- timeoutMs: 1000, - -- }) - -- transition = function() - -- startTransition(function() - -- setShow(true) - -- }) - -- end - -- return ( - -- }> - -- {show ? ( - -- - -- ) : ( - -- - -- )} - -- - -- ) - -- end - -- ReactNoop.render() - -- jestExpect(Scheduler).toFlushAndYield(['Before... Pending: false']) - -- jestExpect(ReactNoop.getChildren()).toEqual([ - -- span('Before... Pending: false'), - -- ]) - - -- await act(async function() - -- Scheduler.unstable_runWithPriority( - -- Scheduler.unstable_UserBlockingPriority, - -- transition, - -- ) - -- jestExpect(Scheduler).toFlushAndYield([ - -- 'Before... Pending: true', - -- 'Suspend! [After... Pending: false]', - -- 'Loading... Pending: false', - -- ]) - -- jestExpect(ReactNoop.getChildren()).toEqual([ - -- span('Before... Pending: true'), - -- ]) - -- Scheduler.unstable_advanceTime(500) - -- await advanceTimers(500) + it("updates when deps are different", function() + local INCREMENT = "INCREMENT" - -- -- Even after a long amount of time, we still don't show a placeholder. - -- Scheduler.unstable_advanceTime(100000) - -- await advanceTimers(100000) - -- jestExpect(ReactNoop.getChildren()).toEqual([ - -- span('Before... Pending: true'), - -- ]) + -- ROBLOX FIXME Luau: based on useReducer() call below, we should know that state is a number without an annotation + local function reducer_(state: number, action) + if action == INCREMENT then + return state + 1 + else + return state + end + end - -- await resolveText('After... Pending: false') - -- jestExpect(Scheduler).toHaveYielded([ - -- 'Promise resolved [After... Pending: false]', - -- ]) - -- jestExpect(Scheduler).toFlushAndYield(['After... Pending: false']) - -- jestExpect(ReactNoop.getChildren()).toEqual([ - -- span('After... Pending: false'), - -- ]) - -- }) - -- }) - -- }) + local totalRefUpdates = 0 + local function Counter(props, ref) + local count, dispatch = useReducer(reducer_, 0) + useImperativeHandle(ref, function() + totalRefUpdates = totalRefUpdates + 1 + return { count = count, dispatch = dispatch } + end, { + count, + }) + return React.createElement(Text, { text = "Count: " .. count }) + end - -- describe('useDeferredValue', function() - -- -- @gate experimental - -- it('defers text value', async function() - -- function TextBox({text}) - -- return - -- end + -- ROBLOX TODO: upstream this rename to make code/types more sane + local CounterRef = forwardRef(Counter) + local counter = React.createRef() + ReactNoop.render(React.createElement(CounterRef, { + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 0") }) + jestExpect(counter.current.count).toEqual(0) + jestExpect(totalRefUpdates).toEqual(1) - -- local _setText - -- function App() - -- local [text, setText] = useState('A') - -- local deferredText = useDeferredValue(text, { - -- timeoutMs: 500, - -- }) - -- _setText = setText - -- return ( - -- <> - -- - -- }> - -- - -- - -- - -- ) - -- end + act(function() + counter.current.dispatch(INCREMENT) + end) + jestExpect(Scheduler).toHaveYielded({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + jestExpect(counter.current.count).toEqual(1) + jestExpect(totalRefUpdates).toEqual(2) + + -- Update that doesn't change the ref dependencies + ReactNoop.render(React.createElement(CounterRef, { + ref = counter, + })) + jestExpect(Scheduler).toFlushAndYield({ "Count: 1" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("Count: 1") }) + jestExpect(counter.current.count).toEqual(1) + jestExpect(totalRefUpdates).toEqual(2) -- Should not increase since last time + end) +end) +-- describe('useTransition', function() +-- -- @gate experimental +-- it('delays showing loading state until after timeout', async function() +-- local transition +-- function App() +-- local [show, setShow] = useState(false) +-- local [startTransition, isPending] = useTransition({ +-- timeoutMs: 1000, +-- }) +-- transition = function() +-- startTransition(function() +-- setShow(true) +-- }) +-- end +-- return ( +-- }> +-- {show ? ( +-- +-- ) : ( +-- +-- )} +-- +-- ) +-- end +-- ReactNoop.render() +-- jestExpect(Scheduler).toFlushAndYield(['Before... Pending: false']) +-- jestExpect(ReactNoop.getChildren()).toEqual([ +-- span('Before... Pending: false'), +-- ]) + +-- await act(async function() +-- Scheduler.unstable_runWithPriority( +-- Scheduler.unstable_UserBlockingPriority, +-- transition, +-- ) + +-- jestExpect(Scheduler).toFlushAndYield([ +-- 'Before... Pending: true', +-- 'Suspend! [After... Pending: false]', +-- 'Loading... Pending: false', +-- ]) +-- jestExpect(ReactNoop.getChildren()).toEqual([ +-- span('Before... Pending: true'), +-- ]) +-- Scheduler.unstable_advanceTime(500) +-- await advanceTimers(500) + +-- -- Even after a long amount of time, we still don't show a placeholder. +-- Scheduler.unstable_advanceTime(100000) +-- await advanceTimers(100000) +-- jestExpect(ReactNoop.getChildren()).toEqual([ +-- span('Before... Pending: true'), +-- ]) + +-- await resolveText('After... Pending: false') +-- jestExpect(Scheduler).toHaveYielded([ +-- 'Promise resolved [After... Pending: false]', +-- ]) +-- jestExpect(Scheduler).toFlushAndYield(['After... Pending: false']) +-- jestExpect(ReactNoop.getChildren()).toEqual([ +-- span('After... Pending: false'), +-- ]) +-- }) +-- }) +-- }) + +-- describe('useDeferredValue', function() +-- -- @gate experimental +-- it('defers text value', async function() +-- function TextBox({text}) +-- return +-- end + +-- local _setText +-- function App() +-- local [text, setText] = useState('A') +-- local deferredText = useDeferredValue(text, { +-- timeoutMs: 500, +-- }) +-- _setText = setText +-- return ( +-- <> +-- +-- }> +-- +-- +-- +-- ) +-- end + +-- act(function() +-- ReactNoop.render() +-- }) + +-- jestExpect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading')]) + +-- await resolveText('A') +-- jestExpect(Scheduler).toHaveYielded(['Promise resolved [A]']) +-- jestExpect(Scheduler).toFlushAndYield(['A']) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]) + +-- await act(async function() +-- _setText('B') +-- jestExpect(Scheduler).toFlushAndYield([ +-- 'B', +-- 'A', +-- 'B', +-- 'Suspend! [B]', +-- 'Loading', +-- ]) +-- jestExpect(Scheduler).toFlushAndYield([]) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) +-- }) + +-- await act(async function() +-- Scheduler.unstable_advanceTime(250) +-- await advanceTimers(250) +-- }) +-- jestExpect(Scheduler).toHaveYielded([]) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) + +-- -- Even after a long amount of time, we don't show a fallback +-- Scheduler.unstable_advanceTime(100000) +-- await advanceTimers(100000) +-- jestExpect(Scheduler).toFlushAndYield([]) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) + +-- await act(async function() +-- await resolveText('B') +-- }) +-- jestExpect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B', 'B']) +-- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]) +-- }) +-- }) + +describe("progressive enhancement (not supported)", function() + it("mount additional state", function() + local updateA + local updateB + -- local updateC + + local function App(props) + local A, _updateA = useState(0) + local B, _updateB = useState(0) + updateA = _updateA + updateB = _updateB + + local C + if props.loadC then + useState(0) + else + C = "[not loaded]" + end - -- act(function() - -- ReactNoop.render() - -- }) + return React.createElement(Text, { + text = string.format( + "A: %s, B: %s, C: %s", + tostring(A), + tostring(B), + tostring(C) + ), + }) + end - -- jestExpect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading')]) - - -- await resolveText('A') - -- jestExpect(Scheduler).toHaveYielded(['Promise resolved [A]']) - -- jestExpect(Scheduler).toFlushAndYield(['A']) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]) - - -- await act(async function() - -- _setText('B') - -- jestExpect(Scheduler).toFlushAndYield([ - -- 'B', - -- 'A', - -- 'B', - -- 'Suspend! [B]', - -- 'Loading', - -- ]) - -- jestExpect(Scheduler).toFlushAndYield([]) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) - -- }) + ReactNoop.render(React.createElement(App, { loadC = false })) + jestExpect(Scheduler).toFlushAndYield({ "A: 0, B: 0, C: [not loaded]" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("A: 0, B: 0, C: [not loaded]"), + }) - -- await act(async function() - -- Scheduler.unstable_advanceTime(250) - -- await advanceTimers(250) - -- }) - -- jestExpect(Scheduler).toHaveYielded([]) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) + act(function() + updateA(2) + updateB(3) + end) - -- -- Even after a long amount of time, we don't show a fallback - -- Scheduler.unstable_advanceTime(100000) - -- await advanceTimers(100000) - -- jestExpect(Scheduler).toFlushAndYield([]) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]) + jestExpect(Scheduler).toHaveYielded({ "A: 2, B: 3, C: [not loaded]" }) + jestExpect(ReactNoop.getChildren()).toEqual({ + span("A: 2, B: 3, C: [not loaded]"), + }) - -- await act(async function() - -- await resolveText('B') - -- }) - -- jestExpect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B', 'B']) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]) - -- }) - -- }) - - describe("progressive enhancement (not supported)", function() - -- ROBLOX FIXME: has the top error message cut off the stack - xit("mount additional state", function() - local updateA - local updateB - -- local updateC - - local function App(props) - local A, _updateA = useState(0) - local B, _updateB = useState(0) - updateA = _updateA - updateB = _updateB - - local C - if props.loadC then - useState(0) - else - C = "[not loaded]" - end + ReactNoop.render(React.createElement(App, { loadC = true })) + jestExpect(function() + jestExpect(function() + jestExpect(Scheduler).toFlushAndYield({ "A: 2, B: 3, C: 0" }) + end).toThrow("Rendered more hooks than during the previous render") + end).toErrorDev({ + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. "1. useState useState\n" + .. "2. useState useState\n" + .. "3. undefined useState\n" + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", + }) - return React.createElement(Text, { - text = ("A: %s, B: %s, C: %s"):format(A, B, C), - }) - end + -- Uncomment if/when we support this again + -- jestExpect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]) - ReactNoop.render(React.createElement(App, { loadC = false })) - jestExpect(Scheduler).toFlushAndYield({ "A: 0, B: 0, C: [not loaded]" }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("A: 0, B: 0, C: [not loaded]"), - }) + -- updateC(4) + -- jestExpect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']) + -- jestExpect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]) + end) - act(function() - updateA(2) - updateB(3) - end) + it("unmount state", function() + local updateA + local updateB + local updateC - jestExpect(Scheduler).toHaveYielded({ "A: 2, B: 3, C: [not loaded]" }) - jestExpect(ReactNoop.getChildren()).toEqual({ - span("A: 2, B: 3, C: [not loaded]"), - }) + local function App(props) + local A, _updateA = useState(0) + local B, _updateB = useState(0) + updateA = _updateA + updateB = _updateB + + -- ROBLOX FIXME Luau: Luau should infer this annotation + local C: string | number + if props.loadC then + local _C, _updateC = useState(0) + C = _C + updateC = _updateC + else + C = "[not loaded]" + end - ReactNoop.render(React.createElement(App, { loadC = true })) - jestExpect(function() - jestExpect(function() - jestExpect(Scheduler).toFlushAndYield({ "A: 2, B: 3, C: 0" }) - end).toThrow("Rendered more hooks than during the previous render") - end).toErrorDev({ - "Warning: React has detected a change in the order of Hooks called by App. " .. "This will lead to bugs and errors if not fixed. For more information, " .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" .. " Previous render Next render\n" .. " ------------------------------------------------------\n" .. "1. useState useState\n" .. "2. useState useState\n" .. "3. undefined useState\n" .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", + return React.createElement(Text, { + text = string.format( + "A: %s, B: %s, C: %s", + tostring(A), + tostring(B), + tostring(C) + ), }) + end - -- Uncomment if/when we support this again - -- jestExpect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]) - - -- updateC(4) - -- jestExpect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']) - -- jestExpect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]) + ReactNoop.render(React.createElement(App, { loadC = true })) + jestExpect(Scheduler).toFlushAndYield({ "A: 0, B: 0, C: 0" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("A: 0, B: 0, C: 0") }) + act(function() + updateA(2) + updateB(3) + updateC(4) end) + jestExpect(Scheduler).toHaveYielded({ "A: 2, B: 3, C: 4" }) + jestExpect(ReactNoop.getChildren()).toEqual({ span("A: 2, B: 3, C: 4") }) + ReactNoop.render(React.createElement(App, { loadC = false })) + jestExpect(Scheduler).toFlushAndThrow( + "Rendered fewer hooks than expected. This may be caused by an " + .. "accidental early return statement." + ) + end) - it("unmount state", function() - local updateA - local updateB - local updateC - - local function App(props) - local A, _updateA = useState(0) - local B, _updateB = useState(0) - updateA = _updateA - updateB = _updateB - - local C - if props.loadC then - local _C, _updateC = useState(0) - C = _C - updateC = _updateC - else - C = "[not loaded]" + it("unmount effects", function() + local function App(props) + useEffect(function() + Scheduler.unstable_yieldValue("Mount A") + return function() + Scheduler.unstable_yieldValue("Unmount A") end + end, {}) - return React.createElement(Text, { - text = ("A: %s, B: %s, C: %s"):format(A, B, C), - }) - end - - ReactNoop.render(React.createElement(App, { loadC = true })) - jestExpect(Scheduler).toFlushAndYield({ "A: 0, B: 0, C: 0" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("A: 0, B: 0, C: 0") }) - act(function() - updateA(2) - updateB(3) - updateC(4) - end) - jestExpect(Scheduler).toHaveYielded({ "A: 2, B: 3, C: 4" }) - jestExpect(ReactNoop.getChildren()).toEqual({ span("A: 2, B: 3, C: 4") }) - ReactNoop.render(React.createElement(App, { loadC = false })) - jestExpect(Scheduler).toFlushAndThrow( - "Rendered fewer hooks than expected. This may be caused by an " .. "accidental early return statement." - ) - end) - - -- ROBLOX FIXME: missing the top line of the error message - xit("unmount effects", function() - local function App(props) + if props.showMore then useEffect(function() - Scheduler.unstable_yieldValue("Mount A") + Scheduler.unstable_yieldValue("Mount B") return function() - Scheduler.unstable_yieldValue("Unmount A") + Scheduler.unstable_yieldValue("Unmount B") end end, {}) - - if props.showMore then - useEffect(function() - Scheduler.unstable_yieldValue("Mount B") - return function() - Scheduler.unstable_yieldValue("Unmount B") - end - end, {}) - end - - return nil end - act(function() - ReactNoop.render(React.createElement(App, { showMore = false }), function() - Scheduler.unstable_yieldValue("Sync effect") - end) - jestExpect(Scheduler).toFlushAndYieldThrough({ "Sync effect" }) + return nil + end + + act(function() + ReactNoop.render(React.createElement(App, { showMore = false }), function() + Scheduler.unstable_yieldValue("Sync effect") end) + jestExpect(Scheduler).toFlushAndYieldThrough({ "Sync effect" }) + end) - jestExpect(Scheduler).toHaveYielded({ "Mount A" }) + jestExpect(Scheduler).toHaveYielded({ "Mount A" }) - act(function() - ReactNoop.render(React.createElement(App, { showMore = true })) + act(function() + ReactNoop.render(React.createElement(App, { showMore = true })) + jestExpect(function() jestExpect(function() - jestExpect(function() - jestExpect(Scheduler).toFlushAndYield({}) - end).toThrow("Rendered more hooks than during the previous render") - end).toErrorDev({ - "Warning: React has detected a change in the order of Hooks called by App. " .. "This will lead to bugs and errors if not fixed. For more information, " .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" .. " Previous render Next render\n" .. " ------------------------------------------------------\n" .. "1. useEffect useEffect\n" .. "2. undefined useEffect\n" .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", - }) - end) + jestExpect(Scheduler).toFlushAndYield({}) + end).toThrow("Rendered more hooks than during the previous render") + end).toErrorDev({ + "Warning: React has detected a change in the order of Hooks called by App. " + .. "This will lead to bugs and errors if not fixed. For more information, " + .. "read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n" + .. " Previous render Next render\n" + .. " ------------------------------------------------------\n" + .. "1. useEffect useEffect\n" + .. "2. undefined useEffect\n" + .. " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n", + }) + end) - -- Uncomment if/when we support this again - -- ReactNoop.flushPassiveEffects() - -- jestExpect(Scheduler).toHaveYielded(['Mount B']) + -- Uncomment if/when we support this again + -- ReactNoop.flushPassiveEffects() + -- jestExpect(Scheduler).toHaveYielded(['Mount B']) - -- ReactNoop.render() - -- jestExpect(Scheduler).toFlushAndThrow( - -- 'Rendered fewer hooks than expected. This may be caused by an ' .. - -- 'accidental early return statement.', - -- ) - end) + -- ReactNoop.render() + -- jestExpect(Scheduler).toFlushAndThrow( + -- 'Rendered fewer hooks than expected. This may be caused by an ' .. + -- 'accidental early return statement.', + -- ) end) +end) - it("eager bailout optimization should always compare to latest rendered reducer", function() +it( + "eager bailout optimization should always compare to latest rendered reducer", + function() -- Edge case based on a bug report local counter, setCounter local function Component(props) -- ROBLOX deviation: can't destructure list in Lua function arguments local count = props.count - local state, dispatch = useReducer(function() + -- ROBLOX Luau FIXME: I have to explicit add _action: nil, but it should be inferred: https://jira.rbx.com/browse/CLI-49121 + local state, dispatch = useReducer(function(_, _action: nil) -- This reducer closes over a value from props. If the reducer is not -- properly updated, the eager reducer will compare to an old value -- and bail out incorrectly. - Scheduler.unstable_yieldValue("Reducer: " .. count) + Scheduler.unstable_yieldValue("Reducer: " .. tostring(count)) return count end, -1) useEffect(function() - Scheduler.unstable_yieldValue("Effect: " .. count) + Scheduler.unstable_yieldValue("Effect: " .. tostring(count)) dispatch() end, { count, @@ -4108,76 +4716,80 @@ return function() }) jestExpect(ReactNoop).toMatchRenderedOutput("2") - end) - - -- ROBLOX FIXME: this test needs to be enabled - -- -- Regression test. Covers a case where an internal state variable - -- -- (`didReceiveUpdate`) is not reset properly. - -- it('state bail out edge case (#16359)', async function() - -- local setCounterA - -- local setCounterB - - -- function CounterA() - -- local [counter, setCounter] = useState(0) - -- setCounterA = setCounter - -- Scheduler.unstable_yieldValue('Render A: ' .. counter) - -- useEffect(function() - -- Scheduler.unstable_yieldValue('Commit A: ' .. counter) - -- }) - -- return counter - -- end - - -- function CounterB() - -- local [counter, setCounter] = useState(0) - -- setCounterB = setCounter - -- Scheduler.unstable_yieldValue('Render B: ' .. counter) - -- useEffect(function() - -- Scheduler.unstable_yieldValue('Commit B: ' .. counter) - -- }) - -- return counter - -- end - - -- local root = ReactNoop.createRoot(null) - -- await ReactNoop.act(async function() - -- root.render( - -- <> - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'Render A: 0', - -- 'Render B: 0', - -- 'Commit A: 0', - -- 'Commit B: 0', - -- ]) - - -- await ReactNoop.act(async function() - -- setCounterA(1) - - -- -- In the same batch, update B twice. To trigger the condition we're - -- -- testing, the first update is necessary to bypass the early - -- -- bailout optimization. - -- setCounterB(1) - -- setCounterB(0) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'Render A: 1', - -- 'Render B: 0', - -- 'Commit A: 1', - -- -- B should not fire an effect because the update bailed out - -- -- 'Commit B: 0', - -- ]) - -- }) - - it("should update latest rendered reducer when a preceding state receives a render phase update", function() + end +) + +-- ROBLOX FIXME: this test needs to be enabled +-- Regression test. Covers a case where an internal state variable +-- (`didReceiveUpdate`) is not reset properly. +-- it('state bail out edge case (#16359)', async function() +-- local setCounterA +-- local setCounterB + +-- function CounterA() +-- local [counter, setCounter] = useState(0) +-- setCounterA = setCounter +-- Scheduler.unstable_yieldValue('Render A: ' .. counter) +-- useEffect(function() +-- Scheduler.unstable_yieldValue('Commit A: ' .. counter) +-- }) +-- return counter +-- end + +-- function CounterB() +-- local [counter, setCounter] = useState(0) +-- setCounterB = setCounter +-- Scheduler.unstable_yieldValue('Render B: ' .. counter) +-- useEffect(function() +-- Scheduler.unstable_yieldValue('Commit B: ' .. counter) +-- }) +-- return counter +-- end + +-- local root = ReactNoop.createRoot(null) +-- await ReactNoop.act(async function() +-- root.render( +-- <> +-- +-- +-- , +-- ) +-- }) +-- jestExpect(Scheduler).toHaveYielded([ +-- 'Render A: 0', +-- 'Render B: 0', +-- 'Commit A: 0', +-- 'Commit B: 0', +-- ]) + +-- await ReactNoop.act(async function() +-- setCounterA(1) + +-- -- In the same batch, update B twice. To trigger the condition we're +-- -- testing, the first update is necessary to bypass the early +-- -- bailout optimization. +-- setCounterB(1) +-- setCounterB(0) +-- }) +-- jestExpect(Scheduler).toHaveYielded([ +-- 'Render A: 1', +-- 'Render B: 0', +-- 'Commit A: 1', +-- -- B should not fire an effect because the update bailed out +-- -- 'Commit B: 0', +-- ]) +-- }) + +it( + "should update latest rendered reducer when a preceding state receives a render phase update", + function() -- Similar to previous test, except using a preceding render phase update -- instead of new props. local shadow, dispatch local function App() local step, setStep = useState(0) - shadow, dispatch = useReducer(function() + -- ROBLOX Luau FIXME: I have to explicit add _action: nil, but it should be inferred: https://jira.rbx.com/browse/CLI-49121 + shadow, dispatch = useReducer(function(_, __: nil) return step end, step) @@ -4206,91 +4818,197 @@ return function() end) jestExpect(Scheduler).toHaveYielded({ "Step: 5, Shadow: 5" }) jestExpect(ReactNoop).toMatchRenderedOutput("5") + end +) + +it("should process the rest pending updates after a render phase update", function() + -- Similar to previous test, except using a preceding render phase update + -- instead of new props. + local updateA + local updateC + local function App() + local a, setA = useState(false) + local b, setB = useState(false) + if a ~= b then + setB(a) + end + -- Even though we called setB above, + -- we should still apply the changes to C, + -- during this render pass. + local c, setC = useState(false) + updateA = setA + updateC = setC + return string.format( + "%s%s%s", + a and "A" or "a", + b and "B" or "b", + c and "C" or "c" + ) + end + + act(function() + ReactNoop.render(React.createElement(App)) end) + jestExpect(ReactNoop).toMatchRenderedOutput("abc") - it("should process the rest pending updates after a render phase update", function() - -- Similar to previous test, except using a preceding render phase update - -- instead of new props. - local updateA - local updateC - local function App() - local a, setA = useState(false) - local b, setB = useState(false) - if a ~= b then - setB(a) + act(function() + updateA(true) + -- This update should not get dropped. + updateC(true) + end) + jestExpect(ReactNoop).toMatchRenderedOutput("ABC") +end) + +it("regression test: don't unmount effects on siblings of deleted nodes", function() + local root = ReactNoop.createRoot() + + local function Child(props) + local label = props.label + useLayoutEffect(function() + Scheduler.unstable_yieldValue("Mount layout " .. label) + return function() + Scheduler.unstable_yieldValue("Unmount layout " .. label) + end + end, { label }) + useEffect(function() + Scheduler.unstable_yieldValue("Mount passive " .. label) + return function() + Scheduler.unstable_yieldValue("Unmount passive " .. label) + end + end, { label }) + return label + end + + act(function() + root.render( + React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "A", label = "A" }), + React.createElement(Child, { key = "B", label = "B" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({ + "Mount layout A", + "Mount layout B", + "Mount passive A", + "Mount passive B", + }) + + -- Delete A. This should only unmount the effect on A. In the regression, + -- B's effect would also unmount. + act(function() + root.render( + React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "B", label = "B" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({ "Unmount layout A", "Unmount passive A" }) + + -- Now delete and unmount B. + act(function() + root.render(nil) + end) + jestExpect(Scheduler).toHaveYielded({ "Unmount layout B", "Unmount passive B" }) +end) + +it("regression: deleting a tree and unmounting its effects after a reorder", function() + local root = ReactNoop.createRoot() + + local function Child(props) + local label = props.label + useEffect(function() + Scheduler.unstable_yieldValue("Mount " .. label) + return function() + Scheduler.unstable_yieldValue("Unmount " .. label) end - -- Even though we called setB above, - -- we should still apply the changes to C, - -- during this render pass. - local c, setC = useState(false) - updateA = setA - updateC = setC - return ("%s%s%s"):format(a and "A" or "a", b and "B" or "b", c and "C" or "c") + end, { label }) + return label + end + + act(function() + root.render( + React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "A", label = "A" }), + React.createElement(Child, { key = "B", label = "B" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({ + "Mount A", + "Mount B", + }) + + act(function() + root.render( + React.createElement( + React.Fragment, + nil, + React.createElement(Child, { key = "B", label = "B" }), + React.createElement(Child, { key = "A", label = "A" }) + ) + ) + end) + jestExpect(Scheduler).toHaveYielded({}) + + act(function() + root.render(nil) + end) + jestExpect(Scheduler).toHaveYielded({ + "Unmount B", + -- In the regression, the reorder would cause Child A to "forget" that it + -- contains passive effects. Then when we deleted the tree, A's unmount + -- effect would not fire. + "Unmount A", + }) +end) + +it("effect dependencies are persisted after a render phase update", function() + local handleClick + local function Test() + local count, setCount = useState(0) + useEffect(function() + Scheduler.unstable_yieldValue("Effect: " .. count) + end, { count }) + + if count > 0 then + setCount(0) end - act(function() - ReactNoop.render(React.createElement(App)) - end) - jestExpect(ReactNoop).toMatchRenderedOutput("abc") + handleClick = function() + return setCount(2) + end - act(function() - updateA(true) - -- This update should not get dropped. - updateC(true) - end) - jestExpect(ReactNoop).toMatchRenderedOutput("ABC") + return React.createElement(Text, { text = string.format("Render: %d", count) }) + end + + act(function() + ReactNoop.render(React.createElement(Test)) end) - -- ROBLOX TODO: enable this test - -- it("regression test: don't unmount effects on siblings of deleted nodes", async function() - -- local root = ReactNoop.createRoot() + jestExpect(Scheduler).toHaveYielded({ "Render: 0", "Effect: 0" }) - -- function Child({label}) - -- useLayoutEffect(function() - -- Scheduler.unstable_yieldValue('Mount layout ' .. label) - -- return function() - -- Scheduler.unstable_yieldValue('Unmount layout ' .. label) - -- end - -- }, [label]) - -- useEffect(function() - -- Scheduler.unstable_yieldValue('Mount passive ' .. label) - -- return function() - -- Scheduler.unstable_yieldValue('Unmount passive ' .. label) - -- end - -- }, [label]) - -- return label - -- end + act(function() + handleClick() + end) - -- await act(async function() - -- root.render( - -- <> - -- - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded([ - -- 'Mount layout A', - -- 'Mount layout B', - -- 'Mount passive A', - -- 'Mount passive B', - -- ]) - - -- -- Delete A. This should only unmount the effect on A. In the regression, - -- -- B's effect would also unmount. - -- await act(async function() - -- root.render( - -- <> - -- - -- , - -- ) - -- }) - -- jestExpect(Scheduler).toHaveYielded(['Unmount layout A', 'Unmount passive A']) + jestExpect(Scheduler).toHaveYielded({ "Render: 0" }) - -- -- Now delete and unmount B. - -- await act(async function() - -- root.render(null) - -- }) - -- jestExpect(Scheduler).toHaveYielded(['Unmount layout B', 'Unmount passive B']) - -- end) -end + act(function() + handleClick() + end) + + jestExpect(Scheduler).toHaveYielded({ "Render: 0" }) + + act(function() + handleClick() + end) + + jestExpect(Scheduler).toHaveYielded({ "Render: 0" }) +end) diff --git a/modules/react-reconciler/src/__tests__/ReactIdentity.spec.lua b/modules/react-reconciler/src/__tests__/ReactIdentity.spec.lua index fb53f478..2ca2584a 100644 --- a/modules/react-reconciler/src/__tests__/ReactIdentity.spec.lua +++ b/modules/react-reconciler/src/__tests__/ReactIdentity.spec.lua @@ -1,4 +1,4 @@ --- upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-dom/src/__tests__/ReactIdentity-test.js +-- ROBLOX upstream: https://github.com/facebook/react/blob/v17.0.2/packages/react-dom/src/__tests__/ReactIdentity-test.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. * @@ -12,370 +12,457 @@ local React, Scheduler local ReactRoblox, reactRobloxRoot -return function() - local Packages = script.Parent.Parent.Parent - local jestExpect = require(Packages.Dev.JestRoblox).Globals.expect - local RobloxJest = require(Packages.Dev.RobloxJest) - - beforeEach(function() - RobloxJest.resetModules() - RobloxJest.useFakeTimers() - React = require(Packages.React) - ReactRoblox = require(Packages.Dev.ReactRoblox) - Scheduler = require(Packages.Scheduler) - local parent = Instance.new("Folder") - reactRobloxRoot = ReactRoblox.createRoot(parent) - end) - - -- ROBLOX deviation: This test can hit succeed erroneously in luau, since - -- table iteration order is unpredictable; with only two elements and no - -- guaranteed order, react might happen to assign the right values even if key - -- behavior isn't working. - -- it("should allow key property to express identity", function() - -- local ref = React.createRef(nil) - -- local function Component(props) - -- return React.createElement("Frame", {ref=ref}, - -- React.createElement("Frame", {key=props.swap and "banana" or "apple", prop="Hello"}), - -- React.createElement("Frame", {key=props.swap and "apple" or "banana", prop="World"}) - -- ) - -- end - - -- local function childrenByProp(children) - -- local byProp = {} - -- for _, instance in ipairs(children) do - -- byProp[instance.prop] = instance - -- end - -- return byProp - -- end - - -- -- ROBLOX deviation: Use react-noop + act instead of rendering into document - -- ReactNoop.act(function() - -- ReactNoop.render(React.createElement(Component)) - -- end) - -- local origChildren = childrenByProp(ref.current.children) - -- ReactNoop.act(function() - -- ReactNoop.render(React.createElement(Component, {swap=true})) - -- end) - -- local newChildren = childrenByProp(ref.current.children) - - -- -- After rendering with `swap=true`, the keys will have switched and the - -- -- prop values will correspond to the opposite children - -- jestExpect(origChildren["Hello"]).toBe(newChildren["World"]) - -- jestExpect(origChildren["World"]).toBe(newChildren["Hello"]) - -- end) - - -- ROBLOX deviation: Replaces the above test. This new test verifies the - -- behavior expected above, but uses enough table keys to greatly reduce the - -- likelihood of coincidental success. - it("should allow key property to express identity", function() - local ref = React.createRef(nil) - local function Component(props) - local children = {} - for i = 1, 50 do - local key = props.invert and tostring(51 - i) or tostring(i) - children[key] = React.createElement("TextLabel", { Text = i }) - end - - return React.createElement("Frame", { ref = ref }, unpack(children)) +local Packages = script.Parent.Parent.Parent +local JestGlobals = require(Packages.Dev.JestGlobals) +local jestExpect = JestGlobals.expect +local beforeEach = JestGlobals.beforeEach +local it = JestGlobals.it +local xit = JestGlobals.xit +local jest = JestGlobals.jest + +beforeEach(function() + jest.resetModules() + jest.useFakeTimers() + React = require(Packages.React) + ReactRoblox = require(Packages.Dev.ReactRoblox) + Scheduler = require(Packages.Scheduler) + local parent = Instance.new("Folder") + reactRobloxRoot = ReactRoblox.createRoot(parent) +end) + +-- ROBLOX deviation: This test can hit succeed erroneously in luau, since +-- table iteration order is unpredictable; with only two elements and no +-- guaranteed order, react might happen to assign the right values even if key +-- behavior isn't working. +-- it("should allow key property to express identity", function() +-- local ref = React.createRef() +-- local function Component(props) +-- return React.createElement("Frame", {ref=ref}, +-- React.createElement("Frame", {key=props.swap and "banana" or "apple", prop="Hello"}), +-- React.createElement("Frame", {key=props.swap and "apple" or "banana", prop="World"}) +-- ) +-- end + +-- local function childrenByProp(children) +-- local byProp = {} +-- for _, instance in children do +-- byProp[instance.prop] = instance +-- end +-- return byProp +-- end + +-- -- ROBLOX deviation: Use react-noop + act instead of rendering into document +-- ReactNoop.act(function() +-- ReactNoop.render(React.createElement(Component)) +-- end) +-- local origChildren = childrenByProp(ref.current.children) +-- ReactNoop.act(function() +-- ReactNoop.render(React.createElement(Component, {swap=true})) +-- end) +-- local newChildren = childrenByProp(ref.current.children) + +-- -- After rendering with `swap=true`, the keys will have switched and the +-- -- prop values will correspond to the opposite children +-- jestExpect(origChildren["Hello"]).toBe(newChildren["World"]) +-- jestExpect(origChildren["World"]).toBe(newChildren["Hello"]) +-- end) + +-- ROBLOX deviation START: Replaces the above test. This new test verifies the +-- behavior expected above, but uses enough table keys to greatly reduce the +-- likelihood of coincidental success. +it("should allow key property to express identity", function() + local ref = React.createRef() + local function Component(props) + local children = {} + for i = 1, 500 do + local key = props.invert and tostring(51 - i) or tostring(i) + children[key] = React.createElement("TextLabel", { Text = i }) end - local function childrenByProp(children) - local byProp = {} - for _, instance in ipairs(children) do - byProp[instance.Text] = instance - end - return byProp + return React.createElement("Frame", { ref = ref }, unpack(children)) + end + + local function childrenByProp(children) + local byProp = {} + for _, instance in children do + byProp[instance.Text] = instance end + return byProp + end - reactRobloxRoot:render(React.createElement(Component)) - Scheduler.unstable_flushAllWithoutAsserting() + reactRobloxRoot:render(React.createElement(Component)) + Scheduler.unstable_flushAllWithoutAsserting() - local origChildren = childrenByProp(ref.current:GetChildren()) + local origChildren = childrenByProp(ref.current:GetChildren()) - reactRobloxRoot:render(React.createElement(Component, { invert = true })) - Scheduler.unstable_flushAllWithoutAsserting() + reactRobloxRoot:render(React.createElement(Component, { invert = true })) + Scheduler.unstable_flushAllWithoutAsserting() - local newChildren = childrenByProp(ref.current:GetChildren()) + local newChildren = childrenByProp(ref.current:GetChildren()) - -- After rendering with `invert=true`, the keys will have switched and the - -- prop values will correspond to the opposite children + -- After rendering with `invert=true`, the keys will have switched and the + -- prop values will correspond to the opposite children + for i = 1, 500 do + jestExpect(origChildren[i]).toBe(newChildren[51 - i]) + jestExpect(origChildren[51 - i]).toBe(newChildren[i]) + end +end) +-- ROBLOX deviation END + +-- ROBLOX deviation: Verify equivalent behavior with table keys, an adaptation +-- to be compatible with currently-released Roact +it("should allow table key to express identity", function() + local ref = React.createRef() + local function Component(props) + local children = {} for i = 1, 50 do - jestExpect(origChildren[i]).toBe(newChildren[51 - i]) - jestExpect(origChildren[51 - i]).toBe(newChildren[i]) - end - end) - - -- ROBLOX deviation: Verify equivalent behavior with table keys, an adaptation - -- to be compatible with currently-released Roact - it("should allow table key to express identity", function() - local ref = React.createRef(nil) - local function Component(props) - local children = {} - for i = 1, 50 do - local key = props.invert and tostring(51 - i) or tostring(i) - children[key] = React.createElement("TextLabel", { Text = i }) - end - - return React.createElement("Frame", { ref = ref }, children) + local key = props.invert and tostring(51 - i) or tostring(i) + children[key] = React.createElement("TextLabel", { Text = i }) end - local function childrenByProp(children) - local byProp = {} - for _, instance in ipairs(children) do - byProp[instance.Text] = instance - end - return byProp + return React.createElement("Frame", { ref = ref }, children) + end + + local function childrenByProp(children) + local byProp = {} + for _, instance in children do + byProp[instance.Text] = instance end + return byProp + end - reactRobloxRoot:render(React.createElement(Component)) - Scheduler.unstable_flushAllWithoutAsserting() + reactRobloxRoot:render(React.createElement(Component)) + Scheduler.unstable_flushAllWithoutAsserting() - local origChildren = childrenByProp(ref.current:GetChildren()) + local origChildren = childrenByProp(ref.current:GetChildren()) - reactRobloxRoot:render(React.createElement(Component, { invert = true })) - Scheduler.unstable_flushAllWithoutAsserting() + reactRobloxRoot:render(React.createElement(Component, { invert = true })) + Scheduler.unstable_flushAllWithoutAsserting() - local newChildren = childrenByProp(ref.current:GetChildren()) + local newChildren = childrenByProp(ref.current:GetChildren()) - -- After rendering with `invert=true`, the keys will have switched and the - -- prop values will correspond to the opposite children - for i = 1, 50 do - jestExpect(origChildren[i]).toBe(newChildren[51 - i]) - jestExpect(origChildren[51 - i]).toBe(newChildren[i]) - end - end) - - it("should defer to provided key if both are present", function() - local ref = React.createRef(nil) - local function Component(props) - local children = {} - for i = 1, 50 do - local key = props.invert and tostring(51 - i) or tostring(i) - -- provide both explicit key and table key, where table-key does not - -- obey the `invert` prop and should not be the one that's used. - children[tostring(i)] = React.createElement("TextLabel", { key = key, Text = i }) - end - - return React.createElement("Frame", { ref = ref }, children) + -- After rendering with `invert=true`, the keys will have switched and the + -- prop values will correspond to the opposite children + for i = 1, 50 do + jestExpect(origChildren[i]).toBe(newChildren[51 - i]) + jestExpect(origChildren[51 - i]).toBe(newChildren[i]) + end +end) + +-- ROBLOX deviation: Verify equivalent behavior with table keys, an adaptation +-- to be compatible with currently-released Roact +it("should use table key to express identity when updating children type", function() + local ref = React.createRef() + + local function Component(props) + local children = {} + for i = 1, props.count do + children[tostring(i)] = + React.createElement("TextLabel", { Text = tostring(i) }) end - local function childrenByProp(children) - local byProp = {} - for _, instance in ipairs(children) do - byProp[instance.Text] = instance - end - return byProp + if props.count == 0 then + children["Test"] = React.createElement("Frame") end - reactRobloxRoot:render(React.createElement(Component)) - Scheduler.unstable_flushAllWithoutAsserting() + return React.createElement("Frame", { ref = ref }, children) + end - local origChildren = childrenByProp(ref.current:GetChildren()) + reactRobloxRoot:render(React.createElement(Component, { + count = 0, + })) + Scheduler.unstable_flushAllWithoutAsserting() - reactRobloxRoot:render(React.createElement(Component, { invert = true })) - Scheduler.unstable_flushAllWithoutAsserting() + jestExpect(ref.current:FindFirstChild(tostring("Test"))).never.toBe(nil) - local newChildren = childrenByProp(ref.current:GetChildren()) + reactRobloxRoot:render(React.createElement(Component, { + count = 15, + complexComponents = false, + })) + Scheduler.unstable_flushAllWithoutAsserting() - -- After rendering with `invert=true`, the keys will have switched and the - -- prop values will correspond to the opposite children - for i = 1, 50 do - jestExpect(origChildren[i]).toBe(newChildren[51 - i]) - jestExpect(origChildren[51 - i]).toBe(newChildren[i]) - end - end) + for i = 1, 15 do + jestExpect(ref.current:FindFirstChild(tostring(i))).never.toBe(nil) + end +end) - it("should use composite identity", function() - local Wrapper = React.Component:extend("Wrapper") - function Wrapper:render() - return React.createElement("Frame", nil, self.props.children) +it("should defer to provided key if both are present", function() + local ref = React.createRef() + local function Component(props) + local children = {} + for i = 1, 50 do + local key = props.invert and tostring(51 - i) or tostring(i) + -- provide both explicit key and table key, where table-key does not + -- obey the `invert` prop and should not be the one that's used. + children[tostring(i)] = + React.createElement("TextLabel", { key = key, Text = i }) end - local ref1 = React.createRef(nil) - local ref2 = React.createRef(nil) + return React.createElement("Frame", { ref = ref }, children) + end - reactRobloxRoot:render( - React.createElement(Wrapper, { key = "wrap1" }, React.createElement("Frame", { ref = ref1 })) - ) - Scheduler.unstable_flushAllWithoutAsserting() + local function childrenByProp(children) + local byProp = {} + for _, instance in children do + byProp[instance.Text] = instance + end + return byProp + end - reactRobloxRoot:render( - React.createElement(Wrapper, { key = "wrap2" }, React.createElement("Frame", { ref = ref2 })) - ) + jestExpect(function() + reactRobloxRoot:render(React.createElement(Component)) Scheduler.unstable_flushAllWithoutAsserting() + end).toErrorDev({ + -- We expect to see warnings caused by using both kinds of key + 'Please provide only one key definition. When both are present, the "key" prop will take precedence.', + }) - jestExpect(ref1.current).never.toBe(ref2.current) - end) + local origChildren = childrenByProp(ref.current:GetChildren()) - local function renderAComponentWithKeyIntoContainer(key, container) - local ref = React.createRef() + reactRobloxRoot:render(React.createElement(Component, { invert = true })) + Scheduler.unstable_flushAllWithoutAsserting() - local Wrapper = React.Component:extend("Wrapper") - function Wrapper:render() - return React.createElement("Frame", nil, React.createElement("Frame", { ref = ref, key = key })) - end - - reactRobloxRoot:render(React.createElement(Wrapper), container) - Scheduler.unstable_flushAllWithoutAsserting() + local newChildren = childrenByProp(ref.current:GetChildren()) - local span = ref.current - jestExpect(span).never.toBe(nil) + -- After rendering with `invert=true`, the keys will have switched and the + -- prop values will correspond to the opposite children + for i = 1, 50 do + jestExpect(origChildren[i]).toBe(newChildren[51 - i]) + jestExpect(origChildren[51 - i]).toBe(newChildren[i]) end +end) - it("should allow any character as a key, in a detached parent", function() - local detachedContainer = React.createElement("Frame") - renderAComponentWithKeyIntoContainer("<'WEIRD/&\\key'>", detachedContainer) - end) - - it("should allow any character as a key, in an attached parent", function() - -- This test exists to protect against implementation details that - -- incorrectly query escaped IDs using DOM tools like getElementById. - local attachedContainer = React.createElement("Frame") - reactRobloxRoot:render(attachedContainer) - Scheduler.unstable_flushAllWithoutAsserting() +it("should use composite identity", function() + local Wrapper = React.Component:extend("Wrapper") + function Wrapper:render() + return React.createElement("Frame", nil, self.props.children) + end - renderAComponentWithKeyIntoContainer("<'WEIRD/&\\key'>", attachedContainer) - end) + local ref1 = React.createRef() + local ref2 = React.createRef() - -- ROBLOX deviation: this test not relevant to Roblox or reconciler, since - -- script execution doesn't work this way to begin with - -- it('should not allow scripts in keys to execute', function() - -- local h4x0rKey = - -- '">
", detachedContainer) +end) + +it("should allow any character as a key, in an attached parent", function() + -- This test exists to protect against implementation details that + -- incorrectly query escaped IDs using DOM tools like getElementById. + local attachedContainer = React.createElement("Frame") + reactRobloxRoot:render(attachedContainer) + Scheduler.unstable_flushAllWithoutAsserting() + + renderAComponentWithKeyIntoContainer("<'WEIRD/&\\key'>", attachedContainer) +end) + +-- ROBLOX deviation: this test not relevant to Roblox or reconciler, since +-- script execution doesn't work this way to begin with +-- it('should not allow scripts in keys to execute', function() +-- local h4x0rKey = +-- '">