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

Throw an error when using hooks inside useMemo, or .memo's comparison function #14608

Merged
merged 16 commits into from Jan 18, 2019

Conversation

threepointone
Copy link
Contributor

This PR adds a flag to 'pause' hooks while they're running, so we can throw an error whenever a hook is called inside 2 spots - useMemo's nextCreate function, and React.memo's comparison function.
It's a bit icky, but wanted to confirm the approach with you.

  • need to add a test for verifying you can't useContext in the same scenarios
  • Need to add the same logic to the PartialRenderer

I could also rename pauseHooks to something more explicit, like pauseHooksWhileComparingMemos

@sizebot
Copy link

sizebot commented Jan 16, 2019

ReactDOM: size: 0.0%, gzip: 0.0%

Details of bundled changes.

Comparing: be457ca...3242f68

react-dom

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-dom.development.js +0.1% 0.0% 732.01 KB 732.6 KB 168.88 KB 168.96 KB UMD_DEV
react-dom.production.min.js 0.0% 0.0% 98.92 KB 98.92 KB 32.21 KB 32.21 KB UMD_PROD
react-dom.profiling.min.js 0.0% 0.0% 101.9 KB 101.9 KB 32.83 KB 32.84 KB UMD_PROFILING
react-dom.development.js +0.1% 0.0% 727.07 KB 727.66 KB 167.45 KB 167.53 KB NODE_DEV
react-dom.production.min.js 0.0% 0.0% 98.92 KB 98.92 KB 31.66 KB 31.67 KB NODE_PROD
react-dom.profiling.min.js 0.0% 0.0% 102.02 KB 102.02 KB 32.29 KB 32.29 KB NODE_PROFILING
ReactDOM-dev.js +0.1% +0.1% 749.04 KB 749.63 KB 168.59 KB 168.69 KB FB_WWW_DEV
ReactDOM-prod.js 🔺+0.1% 🔺+0.1% 310.94 KB 311.37 KB 57.42 KB 57.48 KB FB_WWW_PROD
ReactDOM-profiling.js +0.1% +0.1% 318.11 KB 318.54 KB 58.76 KB 58.81 KB FB_WWW_PROFILING
react-dom-unstable-fire.development.js +0.1% 0.0% 732.36 KB 732.95 KB 169.02 KB 169.1 KB UMD_DEV
react-dom-unstable-fire.production.min.js 0.0% 0.0% 98.93 KB 98.93 KB 32.22 KB 32.22 KB UMD_PROD
react-dom-unstable-fire.profiling.min.js 0.0% 0.0% 101.92 KB 101.92 KB 32.84 KB 32.84 KB UMD_PROFILING
react-dom-unstable-fire.development.js +0.1% 0.0% 727.41 KB 728.01 KB 167.6 KB 167.67 KB NODE_DEV
react-dom-unstable-fire.production.min.js 0.0% 0.0% 98.94 KB 98.94 KB 31.67 KB 31.67 KB NODE_PROD
react-dom-unstable-fire.profiling.min.js 0.0% 0.0% 102.04 KB 102.04 KB 32.3 KB 32.3 KB NODE_PROFILING
ReactFire-dev.js +0.1% +0.1% 748.25 KB 748.84 KB 168.54 KB 168.64 KB FB_WWW_DEV
ReactFire-prod.js 🔺+0.1% 🔺+0.1% 299.54 KB 299.97 KB 55.16 KB 55.21 KB FB_WWW_PROD
ReactFire-profiling.js +0.1% +0.1% 306.63 KB 307.06 KB 56.43 KB 56.49 KB FB_WWW_PROFILING
react-dom-test-utils.development.js 0.0% 0.0% 44.87 KB 44.87 KB 12.3 KB 12.3 KB UMD_DEV
react-dom-test-utils.production.min.js 0.0% 🔺+0.1% 9.97 KB 9.97 KB 3.71 KB 3.71 KB UMD_PROD
react-dom-unstable-native-dependencies.development.js 0.0% 0.0% 60.29 KB 60.29 KB 15.79 KB 15.79 KB NODE_DEV
react-dom-server.browser.development.js +0.3% +0.2% 124.7 KB 125.09 KB 33.28 KB 33.35 KB UMD_DEV
react-dom-server.browser.development.js +0.3% +0.2% 120.83 KB 121.22 KB 32.35 KB 32.43 KB NODE_DEV
ReactDOMServer-dev.js +0.3% +0.3% 122 KB 122.4 KB 31.99 KB 32.07 KB FB_WWW_DEV
ReactDOMServer-prod.js 🔺+1.0% 🔺+0.7% 44.73 KB 45.18 KB 10.33 KB 10.4 KB FB_WWW_PROD
react-dom-server.node.development.js +0.3% +0.2% 122.89 KB 123.28 KB 32.9 KB 32.97 KB NODE_DEV
react-dom-server.node.production.min.js 0.0% -0.0% 17.74 KB 17.74 KB 6.83 KB 6.83 KB NODE_PROD
react-dom-unstable-fizz.browser.development.js 0.0% +0.1% 3.63 KB 3.63 KB 1.44 KB 1.44 KB UMD_DEV
react-dom-unstable-fizz.node.development.js 0.0% +0.1% 3.7 KB 3.7 KB 1.42 KB 1.42 KB NODE_DEV

react-art

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-art.development.js +0.1% +0.1% 510.86 KB 511.45 KB 112.71 KB 112.79 KB UMD_DEV
react-art.production.min.js 0.0% 0.0% 90.97 KB 90.97 KB 27.96 KB 27.97 KB UMD_PROD
react-art.development.js +0.1% +0.1% 442.38 KB 442.97 KB 95.59 KB 95.67 KB NODE_DEV
ReactART-dev.js +0.1% +0.1% 450.76 KB 451.35 KB 94.62 KB 94.72 KB FB_WWW_DEV
ReactART-prod.js 🔺+0.2% 🔺+0.2% 186.84 KB 187.27 KB 32.08 KB 32.13 KB FB_WWW_PROD

react-native-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
ReactNativeRenderer-dev.js +0.1% +0.1% 575.25 KB 575.84 KB 125.15 KB 125.24 KB RN_FB_DEV
ReactNativeRenderer-prod.js 🔺+0.2% 🔺+0.1% 242.38 KB 242.81 KB 42.67 KB 42.73 KB RN_FB_PROD
ReactNativeRenderer-profiling.js +0.2% +0.1% 248.53 KB 248.96 KB 44.02 KB 44.07 KB RN_FB_PROFILING
ReactNativeRenderer-dev.js +0.1% +0.1% 575.16 KB 575.76 KB 125.11 KB 125.2 KB RN_OSS_DEV
ReactFabric-dev.js +0.1% +0.1% 566.1 KB 566.7 KB 122.86 KB 122.95 KB RN_FB_DEV
ReactFabric-prod.js 🔺+0.2% 🔺+0.1% 235.58 KB 236.01 KB 41.2 KB 41.25 KB RN_FB_PROD
ReactFabric-profiling.js +0.2% +0.1% 240.88 KB 241.31 KB 42.55 KB 42.6 KB RN_FB_PROFILING
ReactFabric-dev.js +0.1% +0.1% 566.01 KB 566.6 KB 122.82 KB 122.91 KB RN_OSS_DEV
ReactFabric-prod.js 0.0% -0.0% 220.31 KB 220.31 KB 38.03 KB 38.03 KB RN_OSS_PROD

react-test-renderer

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-test-renderer.development.js +0.1% +0.1% 455.59 KB 456.18 KB 98.36 KB 98.44 KB UMD_DEV
react-test-renderer.production.min.js 0.0% -0.0% 57.35 KB 57.35 KB 17.62 KB 17.62 KB UMD_PROD
react-test-renderer.development.js +0.1% +0.1% 450.55 KB 451.14 KB 97.13 KB 97.21 KB NODE_DEV
ReactTestRenderer-dev.js +0.1% +0.1% 459.14 KB 459.74 KB 96.51 KB 96.61 KB FB_WWW_DEV
react-test-renderer-shallow.development.js 0.0% -0.0% 38.19 KB 38.19 KB 9.87 KB 9.87 KB UMD_DEV
react-test-renderer-shallow.production.min.js 0.0% 0.0% 10.62 KB 10.62 KB 3.28 KB 3.28 KB UMD_PROD

react-reconciler

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-reconciler.development.js +0.1% +0.1% 440.21 KB 440.8 KB 94.11 KB 94.19 KB NODE_DEV
react-reconciler-persistent.development.js +0.1% +0.1% 438.59 KB 439.18 KB 93.46 KB 93.54 KB NODE_DEV

Generated by 🚫 dangerJS

@@ -342,13 +347,16 @@ function updateMemoComponent(
// Default to shallow comparison
let compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
pauseHooks(true);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the test passes without these calls. Memo doesn't call "prepare to use hooks" so we're not in an area where there's a current fiber. You should be able to just remove them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh interesting, you're right they pass. I recollect how I broke this - I had a bad test, and 'fixed' it after I had my code in place. bleurgh, thanks for the catch.

@@ -626,8 +633,9 @@ export function useMemo<T>(
return prevState[0];
}
}

pauseHooks(true)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since we don't need pauseHooks elsewhere this can become local isInUseMemo = true.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

agreed, that was my first cut as well.

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

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

Let's remove pauseHooks and only check for useMemo (the rest already seems to work on the client).

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

What about useCallback that's called during render phase? Sounds like we'd want to disable it there too?

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

Instead of adding a second check, let's just null out currentlyRenderingFiber right before the call into user code and then restore it? Then we don't pay for a second check for all useState calls etc.

@threepointone
Copy link
Contributor Author

What about useCallback that's called during render phase? Sounds like we'd want to disable it there too?

the function itself isn't called during render, no? so I assume it should throw, but I'll add a test to verify.

@threepointone
Copy link
Contributor Author

Instead of adding a second check, let's just null out currentlyRenderingFiber right before the call into user code and then restore it?

I'm happy to do this. either approach is similarly annoying tbh ha.

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

I'm happy to do this. either approach is similarly annoying tbh ha.

I don't think they're exactly equivalent. This is a very hot path. Having one variable and doing just one check per Hook seems better than doing two. With this solution, only useEffect and the like "pay" with extra assignments. But you're not penalized for having many useStates which would each has to check isInUseMemo "just in case".

The downside is that it's an extra local variable (to hold the current Fiber) and Sebastian says this can go beyond the number of registers and cause a perf cliff. I don't know about this. I'd probably go with one extra variable though for less common hooks than an extra check for every hook.


the function itself isn't called during render, no? so I assume it should throw, but I'll add a test to verify.

Ahh I see. I meant this is a valid code:

const renderRow = useCallback((item) => {
  return <Row item={item} theme={theme} />
}, [theme])

<Table renderRow={renderRow} />

// in Table
rows.map(props.renderRow)

So callback itself can execute during render phase and it could be nice if we could disallow calling Hooks in it. (They would be attached to the Table which is super confusing.) Reading context there is probably also bad because it would read Table's context rather than Row's.

But you're right we're not actually proxying the function so we can't add extra logic to it. Theoretically we could in DEV though. But we can talk about this another time.

@threepointone
Copy link
Contributor Author

I don't think they're exactly equivalent. This is a very hot path. Having one variable and doing just one check per Hook seems better than doing two. With this solution, only useEffect and the like "pay" with extra assignments. But you're not penalized for having many useStates which would each has to check isInUseMemo "just in case".

The downside is that it's an extra local variable (to hold the current Fiber) and Sebastian says this can go beyond the number of registers and cause a perf cliff. I don't know about this. I'd probably go with one extra variable though for less common hooks than an extra check for every hook.

Good points. I'll make the changes, and keep an eye on it.

@threepointone
Copy link
Contributor Author

Curious, have you ever got to a situation where comparisons are that expensive?

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

In React everything that executes every render is expensive. It's the hottest path in the application.

@gaearon
Copy link
Collaborator

gaearon commented Jan 16, 2019

(In isolation, no, but these things add up and at some point you end up with too many — not knowing which ones are safe to remove. So we try to not add them whenever possible.)

@threepointone
Copy link
Contributor Author

I made changes. I avoided the 'local' var check with yet another module-local var.

I'll add tests for the server renderer now.

@@ -229,11 +229,14 @@ export function useReducer<S, A>(
}
}

// a variable to store current component while we compare memo values
let cachedCurrentlyRenderingComponent = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this top level rather than inside useMemo?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it's one less var to be gced, reuses the location across runs. you mentioned the local var has a cost I assumed you'd want to avoid?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

maybe I should just trust javascript engines

Copy link
Collaborator

Choose a reason for hiding this comment

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

Declaring variables pointing to shared objects has no effect on GC — I assume you mean the stack? I said it's a hypothetical concern but I'm not actually sure it matters and I think local function variable is fine.

Copy link
Contributor

@aweary aweary Jan 16, 2019

Choose a reason for hiding this comment

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

I wish we had some easy way to validate these JS engine assumptions, it’d be interesting to see how often they’re true 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

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

Well, having two checks is likely going to be more expensive than having one check, no matter what you do.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah let's keep this local to useMemo.

@@ -616,11 +608,14 @@ export function useCallback<T>(
return callback;
}

// a variable to store current component while we compare memo values
let cachedCurrentlyRenderingFiber = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same q.

@@ -633,9 +628,14 @@ export function useMemo<T>(
return prevState[0];
}
}
pauseHooks(true)

// null the reference to the component
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment is unnecessary, the line below says the same thing.

packages/react-reconciler/src/ReactFiberHooks.js Outdated Show resolved Hide resolved
Copy link
Collaborator

@acdlite acdlite left a comment

Choose a reason for hiding this comment

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

Good start. Aside from the nits, there are some missing pieces (not sure if you're intending to do these in a follow-up):

  • readContext (the one on the dispatcher) is not a hook but it also needs to be prevented inside these functions
  • Forbid Hook and context access inside these additional render phase functions:
    • Reducers (which includes setState update functions)
    • The State Hook's lazy state initializer

@@ -229,11 +229,14 @@ export function useReducer<S, A>(
}
}

// a variable to store current component while we compare memo values
let cachedCurrentlyRenderingComponent = null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah let's keep this local to useMemo.

@threepointone
Copy link
Contributor Author

I've updated the PR addressing most of the comments, including adding tests for the server side stuff. one last bit is to prevent dispatcher.readContext in these scenarios, and I can't figure a clean way of doing this just yet, so I'll send it in a follow up PR.

export function useMemo<T>(
nextCreate: () => T,
inputs: Array<mixed> | void | null,
): T {
cachedCurrentlyRenderingFiber = currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
let cachedCurrentlyRenderingFiber = (currentlyRenderingFiber = resolveCurrentlyRenderingFiber());
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Maybe just "fiber" everywhere? It's local so doesn't need too much introducing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

didn't want to encourage reading from it, but I guess that's actually fine

@@ -17,6 +17,9 @@ let ReactFeatureFlags;
let ReactTestRenderer;
let ReactDOMServer;

const OutsideHookScopeError =
'Hooks can only be called inside the body of a function component';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: just copy paste it

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

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

LG. I left some nits.

@threepointone
Copy link
Contributor Author

alright, I'm happy with this, will land after ci finishes. thanks everyone!

@threepointone
Copy link
Contributor Author

@acdlite do you want to have a onceover, or should I dismiss your review? plsthx

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

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

Looks good to go. Maybe could also guard call to eagerReducer on 854. Thanks for attending to nits!

packages/react-reconciler/src/ReactFiberHooks.js Outdated Show resolved Hide resolved
@threepointone
Copy link
Contributor Author

I'll send a separate PR for guarding eagerReducer, I want to add a fresh test for that.

@threepointone
Copy link
Contributor Author

#fileatask ;(

@threepointone threepointone merged commit e1cd83e into facebook:master Jan 18, 2019
jetoneza pushed a commit to jetoneza/react that referenced this pull request Jan 23, 2019
…r .memo's comparator (facebook#14608)

* hooks inside useMemo/.memo - failing tests

* throw an error when using hooks inside useMemo

* throw when using hooks inside .memo's compare fn

* faster/better/stronger

* same logic for useReducer, tests for the server, etc

* Update ReactDOMServerIntegrationHooks-test.internal.js

ack lint

* nits

* whitespace

* whitespace

* stray semi

* Tweak comment

* stray unmatched fiber reset

* nit
n8schloss pushed a commit to n8schloss/react that referenced this pull request Jan 31, 2019
…r .memo's comparator (facebook#14608)

* hooks inside useMemo/.memo - failing tests

* throw an error when using hooks inside useMemo

* throw when using hooks inside .memo's compare fn

* faster/better/stronger

* same logic for useReducer, tests for the server, etc

* Update ReactDOMServerIntegrationHooks-test.internal.js

ack lint

* nits

* whitespace

* whitespace

* stray semi

* Tweak comment

* stray unmatched fiber reset

* nit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants