Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Issue with Undefined Lazy Imports By Refactoring Lazy Initialization Order #21642

Merged
merged 5 commits into from
Jun 7, 2021

Conversation

sebmarkbage
Copy link
Collaborator

This is an alternative to #21639 which might fix #18768. Needs unit tests.

We should be pretty careful about using try/catch. It doesn't come without caveats:

  • Browsers have been known to deopt them. This is getting less of an issue but not sure it's non-zero issue.
  • It breaks debugging using break on uncaught exceptions.
  • Even relying on it suggests an anti-pattern because you're likely executing in some outside execution context that doesn't have the right stacks or component stacks.
  • We support suspense almost everywhere during the render. This means that technically anything that catches must know whether to forward it or not.
  • It's often easiest to avoid it and instead just letting the general purpose system deal with it.

In general the lazy protocol is pretty simple. You just call the function and it either throws, suspends or gives you the value. It also works recursively. And since that is all happen directly on the same stack you have the full JS stack and component stack for how you got there. For example the ctor part can suspend or throw. You can also throw in the .then function potentially if it's a non-standard thenable which we support for the purpose of higher performance sync resolution.

To fix the outer resolution I simply moved the timing of when we set the flag. This is generally how you'd solve these kinds of bugs. Moving the timing of the mutation. So now we just keep it as uninitialized until we've called the thenable and so we know it's a thenable. So when we set the value, we know it'll suspend the next time we call it. This also lets this call throw and it'll also rethrow at the same place every time similar to how if the ctor throws. So you always get the right stack.

The other thing I changed is that when we resolve the .default property in the resolving path, we don't have the right stack so it'll be tricky to debug it. Sure, this generally happens inside React so it's not a very useful stack anyway. Therefore I instead moved this resolution to when the lazy value is read. That way it just works. It's a tiny bit slower during the reread perhaps but in general the reconciler diffs the lazy wrapper and not the resolved value anyway. It's also how this whole suspense thing is supposed to work in general. Any "transform" of a value is applied during "render" after you read it. The underlying lazy thing (that is also what React "sees" in the promise passed to React) is the module object itself. Maybe it's unfortunate that this created a memory dependency on the whole module but it's supposed to be in the module cache anyway.

Another thing I noticed is that we warn for undefined values but it's not invalid to import undefined from another module. E.g. Flight creates lazy values that return undefined all the time. It's not useful as an Element Type which is the previous only usage, but now it's allowed as a Node too where undefined is allowed.

const lazyNode = React.lazy(() => import(...));

<div>{lazyNode}</div>

I changed the early warning to check for in. We can also check for undefined if we want specifically for element types to give a better error message but that should be checked in the reconciler where we know how we'll use it.

gaearon and others added 3 commits June 7, 2021 16:18
This is really where most unwrapping happen. The resolved promise is the
module object and then we read things from it.

This way it lines up a bit closer with the Promise model too since the
promise resolving to React gets passed this same value.

If this throws, then it throws during render so it's caught properly and
you can break on it and even see it on the right stack.
@sizebot
Copy link

sizebot commented Jun 7, 2021

Comparing: 0eea577...cc42e7b

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js = 127.29 kB 127.29 kB = 40.81 kB 40.81 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js = 130.11 kB 130.11 kB = 41.75 kB 41.75 kB
facebook-www/ReactDOM-prod.classic.js = 404.72 kB 404.72 kB = 74.90 kB 74.90 kB
facebook-www/ReactDOM-prod.modern.js = 393.27 kB 393.27 kB = 73.09 kB 73.09 kB
facebook-www/ReactDOMForked-prod.classic.js = 404.72 kB 404.72 kB = 74.90 kB 74.90 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react/cjs/react-unstable-shared-subset.development.js +1.42% 68.89 kB 69.87 kB +1.41% 18.66 kB 18.92 kB
oss-stable-semver/react/cjs/react.development.js +1.31% 74.50 kB 75.47 kB +1.12% 20.02 kB 20.24 kB
oss-stable/react/cjs/react.development.js +1.31% 74.50 kB 75.47 kB +1.12% 20.02 kB 20.24 kB
oss-experimental/react/cjs/react.development.js +1.30% 74.97 kB 75.94 kB +1.23% 20.04 kB 20.29 kB
facebook-react-native/react/cjs/React-dev.js +1.12% 89.86 kB 90.86 kB +1.03% 21.57 kB 21.79 kB
oss-stable-semver/react/umd/react.development.js +1.03% 97.25 kB 98.26 kB +0.91% 25.02 kB 25.25 kB
oss-stable/react/umd/react.development.js +1.03% 97.25 kB 98.26 kB +0.91% 25.02 kB 25.25 kB
oss-experimental/react/umd/react.development.js +1.03% 97.74 kB 98.75 kB +0.85% 25.07 kB 25.28 kB
facebook-www/React-dev.modern.js +1.02% 98.42 kB 99.42 kB +0.98% 23.97 kB 24.21 kB
facebook-www/React-dev.classic.js +1.01% 99.44 kB 100.44 kB +0.98% 24.20 kB 24.44 kB
oss-experimental/react/cjs/react-unstable-shared-subset.production.min.js +0.70% 6.53 kB 6.57 kB +0.11% 2.68 kB 2.68 kB
oss-stable-semver/react/cjs/react.production.min.js +0.65% 7.04 kB 7.09 kB +0.07% 2.81 kB 2.82 kB
oss-stable/react/cjs/react.production.min.js +0.65% 7.04 kB 7.09 kB +0.07% 2.81 kB 2.82 kB
oss-experimental/react/cjs/react.production.min.js +0.61% 7.56 kB 7.61 kB +0.10% 2.94 kB 2.95 kB
oss-stable-semver/react/umd/react.profiling.min.js +0.42% 10.93 kB 10.97 kB +0.02% 4.41 kB 4.41 kB
oss-stable/react/umd/react.profiling.min.js +0.42% 10.93 kB 10.97 kB +0.02% 4.41 kB 4.41 kB
oss-stable-semver/react/umd/react.production.min.js +0.42% 10.93 kB 10.97 kB +0.02% 4.41 kB 4.41 kB
oss-stable/react/umd/react.production.min.js +0.42% 10.93 kB 10.97 kB +0.02% 4.41 kB 4.41 kB
oss-experimental/react/umd/react.profiling.min.js +0.40% 11.39 kB 11.43 kB +0.15% 4.54 kB 4.55 kB
oss-experimental/react/umd/react.production.min.js +0.40% 11.39 kB 11.43 kB +0.15% 4.54 kB 4.55 kB

Generated by 🚫 dangerJS against cc42e7b

@@ -53,29 +53,17 @@ function lazyInitializer<T>(payload: Payload<T>): T {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if it was a sync thenable though? Wouldn't you miss it due to if (payload._status === Pending) { check in onFulfill?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I fixed it here. cc42e7b

That check is really unnecessary. It's more of a safety thing in case someone has a bad thennable. We could probably remove it.

…ined

Normally we'd just check if something is undefined but in this case it's
valid to have an undefined value in the export but if you don't have a
property then you're probably importing the wrong kind of object.
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = defaultExport;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change needs to be in lockstep right? Not that it affects anything but I remember some pain rolling out a change to lazy on RN. Maybe it's not hard anymore now that we check in the bundles.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is only changing one package so there's no lockstep. This is a nice feature of the new protocol. The reconciler just calls the function and expects a value (or throw or suspense). The implementation of this is can be whatever - like Flight has a different one.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh ok this is nice.

@sebmarkbage sebmarkbage merged commit 5aa0c56 into facebook:master Jun 7, 2021
facebook-github-bot pushed a commit to facebook/react-native that referenced this pull request Jun 11, 2021
Summary:
This sync includes the following changes:
- **[c96b78e0e](facebook/react@c96b78e0e )**: Add concurrentRoot property to ReactNativeTypes ([#21648](facebook/react#21648)) //<Samuel Susla>//
- **[1a3f1afbd](facebook/react@1a3f1afbd )**: [React Native] Fabric get current event priority ([#21553](facebook/react#21553)) //<Samuel Susla>//
- **[48a11a3ef](facebook/react@48a11a3ef )**: Update next React version ([#21647](facebook/react#21647)) //<Andrew Clark>//
- **[5aa0c5671](facebook/react@5aa0c5671 )**: Fix Issue with Undefined Lazy Imports By Refactoring Lazy Initialization Order ([#21642](facebook/react#21642)) //<Sebastian Markbåge>//

Changelog:
[General][Changed] - React Native sync for revisions 0eea577...c96b78e

jest_e2e[run_all_tests]

Reviewed By: bvaughn

Differential Revision: D29029542

fbshipit-source-id: 9f2e19b4714b5697b5b846f2db8eb28c25420932
koto pushed a commit to koto/react that referenced this pull request Jun 15, 2021
…ion Order (facebook#21642)

* Add a DEV warning for common case

* Don't set Pending flag before we know it's a promise

* Move default exports extraction to render phase

This is really where most unwrapping happen. The resolved promise is the
module object and then we read things from it.

This way it lines up a bit closer with the Promise model too since the
promise resolving to React gets passed this same value.

If this throws, then it throws during render so it's caught properly and
you can break on it and even see it on the right stack.

* Check if the default is in the module object instead of if it's undefined

Normally we'd just check if something is undefined but in this case it's
valid to have an undefined value in the export but if you don't have a
property then you're probably importing the wrong kind of object.

* We need to check if it's uninitialized for sync resolution

Co-authored-by: Dan Abramov <dan.abramov@me.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Bug: React.lazy throws undefined instead of an Error object
4 participants