preactjs / preact

⚛️ Fast 3kB React alternative with the same modern API. Components & Virtual DOM.
https://preactjs.com
MIT License
36.49k stars 1.94k forks source link

hydration is slower than React when there are a lot of contexts #2531

Open kenny-f opened 4 years ago

kenny-f commented 4 years ago

Issue

When using a css-in-js library like emotion of framework like theme-ui (which uses emotion) and with enough components, preact hydration takes longer than react. This increases the first input delay metric for the site when there are a lot of styled components.

My theory is due to the amount of contexts in the app as emotion wraps each styled component in a context so that it has access to the theme. (When removing the contexts hydration times are comparable)

Reproduction

https://github.com/kenny-f/preact-hydration-repro

npm run build npm run start:prod

Go to localhost:3000

To run the app in `react

Steps to reproduce

Run the performance profiler in chrome devtools (Start profiling and reload page)

Here are some results:

Preact:

preact-1

you can see from the gif below that the process is extremely deep: preact

React:

react-2

I'm not sure if this a known issue as I couldn't find any information on it. Just our own observations when profiling our app that does not have much functionality right now.

Happy to provide more information if required.

developit commented 4 years ago

hmm - something is up here. If I extract this code out of Razzle and don't use theme-ui, hydration takes 17ms. I haven't been able to track down whether this is an issue with theme-ui or razzle - my hunch is that something is pulling in react somehow, which could break hook invocation.

This could also just be a stack depth issue. Emotion's Babel plugin combined with theme-ui is honestly a massive amount of work to be doing on every single created VNode - this demo takes half a second to hydrate in both libraries, and the bulk of the time is spent serializing styles and generating style hashes.

so I don't lose it, here's the isolated hydrate call on codesandbox

kenny-f commented 4 years ago

@developit thanks for the response. Based on your reply I've done some more experiments.

First is dropping theme-ui and only use emotion directly: https://github.com/kenny-f/preact-hydration-repro/tree/emotion-only

preact:

preact-emotion-only

react:

react-emotion-only

as you can see the timings for both have dropped significantly.

The second is dropping both theme-ui and emotion and manually creating a ThemeContext and wrapping each div in a Consumer https://github.com/kenny-f/preact-hydration-repro/tree/manual-context

preact:

preact-manual

react:

react-manual

The timings drop even further here. This does seem to suggest that your theory is correct in that theme-ui and emotion maybe the culprits

developit commented 4 years ago

Thanks for the extra data. My take-away from yesterday's investigation is twofold:

jeremy-coleman commented 4 years ago

there is some trick with react scheduler to do calculateChangedBits = () => 0, which stops context propagation similar to shouldComponentUpdate(){return false}. That seems like a likely candidate for the shorter traces? https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L2800

developit commented 4 years ago

could be, yup. certainly for updates, though I would hope that during hydration there are no actual updates occurring?

jeremy-coleman commented 4 years ago

My guess is that bc preact diffs inside out its just hitting a lot of props.context on the way up so it has more to diff, regardless of updates. Just a shot in the dark. However, id say if this really matters, which im pretty sure it doesnt, just dont use a lib that goes batshit on context wrappers and instead just use css vars, ggez

annez commented 4 years ago

My guess is that bc preact diffs inside out its just hitting a lot of props.context on the way up so it has more to diff, regardless of updates. Just a shot in the dark. However, id say if this really matters, which im pretty sure it doesnt, just dont use a lib that goes batshit on context wrappers and instead just use css vars, ggez

Appreciate the insight on this and understand that deep stacks might not actually affect hydration performance. However as @developit pointed out, it's about the performance of hydrating Preact compared to React here.

@developit Can we help? (we are actively working on this as a production-live project) Would be happy to contribute back if possible.

My takeaways are:

jeremy-coleman commented 4 years ago

(Not related to this issue) @annez if you need a quick win, replace styled with a stylesheet/rule approach. This way, u will only have 1 context provider. Alternatively, which is my preferrnce, you can print the theme(s) ahead of time and use a hard coded object, so you can still use theme ui or whatever you want. You can even replace theme colors with css vars so you have a fully dynamic theme . The only downside to this approach is that its harder to let consumers of your library override stuff, but if youre making your own bespoke theme, that shouldnt be an issue for your use case

jeremy-coleman commented 4 years ago

@developit i should have thought about this sooner, but the material ui repo has a benchmark setup for ssr, which could be useful to test this. I added to their setup to test several implementations of ‘styled’ libraries, and i was supprised to see styled-jss (the standalone version using jss v9) was by far the fastest. 2x faster than emotion and 5x faster than muis version. Emotion was ~50k op/s whereas styled-jss was close to 100k. Most others, including mui, were around 20k. I think this is likely due to using theme context, whereas styled jss only optionally uses the brcast based wrapper and doesnt wrap every component

marvinhagemeister commented 4 years ago

That's probably it. For benchmarks of this order every function call is very noticeable in the final numbers. Wrapping every node with a component is expensive.