From 7d9e74f8f0d0e0ea60573a19965eded61dc41024 Mon Sep 17 00:00:00 2001 From: Marija Najdova Date: Fri, 7 May 2021 02:37:13 +0200 Subject: [PATCH] feat(server): add extractCriticalToChunks (#2334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * add some resets * updates * fixes * cleanup * Update packages/server/types/create-instance.d.ts Co-authored-by: Mateusz Burzyński * Update packages/server/src/create-instance/extract-critical2.js Co-authored-by: Mateusz Burzyński * adress comments * tests * flow fixes * Improve auto-rehydration for global styles when using extractCritical2 * Fixed constructStyleTags to include all ids correctly * Update packages/server/types/create-instance.d.ts Co-authored-by: Mateusz Burzyński * Update packages/server/src/create-instance/construct-style-tags.js Co-authored-by: Mateusz Burzyński * fix flow issues & update tests * Update packages/react/__tests__/rehydration.js * tweak test title * renamed to extractCriticalToChunks * spacings * spacing * Fixed an issue with Global styles being reinjected when rehydrating * resolve comments * test updates * Fixed issue with global styles being sometimes owned by multiple sheets after hydration * Add tests for global rehydration * add comment about why the data-attribute is reset in * fix flow errors * Revert merging layout effects in * Update packages/server/src/create-instance/extract-critical-to-chunks.js * Create tidy-feet-buy.md * Create chilly-clocks-jam.md Co-authored-by: Mateusz Burzyński Co-authored-by: Mitchell Hamilton --- .changeset/chilly-clocks-jam.md | 7 + .changeset/tidy-feet-buy.md | 5 + docs/ssr.mdx | 42 ++ packages/react/__tests__/rehydration.js | 430 ++++++++++++++++++ packages/react/src/global.js | 15 +- .../construct-style-tags-from-chunks.js | 26 ++ .../extract-critical-to-chunks.js | 53 +++ packages/server/src/create-instance/index.js | 10 +- packages/server/src/create-instance/inline.js | 26 +- packages/server/src/create-instance/utils.js | 10 + packages/server/src/index.js | 4 +- .../extract-critical-to-chunks.test.js.snap | 119 +++++ .../test/extract-critical-to-chunks.test.js | 82 ++++ packages/server/test/util.js | 24 +- packages/server/types/create-instance.d.ts | 7 + 15 files changed, 840 insertions(+), 20 deletions(-) create mode 100644 .changeset/chilly-clocks-jam.md create mode 100644 .changeset/tidy-feet-buy.md create mode 100644 packages/server/src/create-instance/construct-style-tags-from-chunks.js create mode 100644 packages/server/src/create-instance/extract-critical-to-chunks.js create mode 100644 packages/server/src/create-instance/utils.js create mode 100644 packages/server/test/__snapshots__/extract-critical-to-chunks.test.js.snap create mode 100644 packages/server/test/extract-critical-to-chunks.test.js diff --git a/.changeset/chilly-clocks-jam.md b/.changeset/chilly-clocks-jam.md new file mode 100644 index 000000000..3fe115f3f --- /dev/null +++ b/.changeset/chilly-clocks-jam.md @@ -0,0 +1,7 @@ +--- +"@emotion/react": patch +--- + +The Global component no longer replaces style elements from server-rendering on first mount and instead reuses the server-side rendered style element + +author: @Andarist diff --git a/.changeset/tidy-feet-buy.md b/.changeset/tidy-feet-buy.md new file mode 100644 index 000000000..a03f3ea42 --- /dev/null +++ b/.changeset/tidy-feet-buy.md @@ -0,0 +1,5 @@ +--- +"@emotion/server": minor +--- + +Added `extractCriticalToChunks` that allows the Global component to remove styles on unmount unlike `extractCritical` along with `constructStyleTagsFromChunks` to render the chunks to style tags. diff --git a/docs/ssr.mdx b/docs/ssr.mdx index 6459f235f..14ce17eb1 100644 --- a/docs/ssr.mdx +++ b/docs/ssr.mdx @@ -35,6 +35,48 @@ You can also use the advanced integration, it requires more work but does not ha ### On server +#### When using `@emotion/react` + +```jsx +import { CacheProvider } from '@emotion/react' +import { renderToString } from 'react-dom/server' +import createEmotionServer from '@emotion/server/create-instance' +import createCache from '@emotion/cache' + +const key = 'custom' +const cache = createCache({ key }) +const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache) + +let element = ( + + + +) + +let { html, styles } = extractCriticalToChunks(renderToString(element)) + +res + .status(200) + .header('Content-Type', 'text/html') + .send(` + + + + + + My site + ${constructStyleTagsFromChunks({ html, styles })} + + +
${html}
+ + + +`); +``` + +#### When using `@emotion/css` + ```jsx import { CacheProvider } from '@emotion/react' import { renderToString } from 'react-dom/server' diff --git a/packages/react/__tests__/rehydration.js b/packages/react/__tests__/rehydration.js index c79ee0f30..c415100c6 100644 --- a/packages/react/__tests__/rehydration.js +++ b/packages/react/__tests__/rehydration.js @@ -11,22 +11,54 @@ afterEach(() => { jest.clearAllMocks() }) +let React let ReactDOM +let ReactDOMServer let createCache let css let jsx let CacheProvider +let Global +let createEmotionServer const resetAllModules = () => { jest.resetModules() createCache = require('@emotion/cache').default + React = require('react') ReactDOM = require('react-dom') + ReactDOMServer = require('react-dom/server') const emotionReact = require('@emotion/react') css = emotionReact.css jsx = emotionReact.jsx CacheProvider = emotionReact.CacheProvider + Global = emotionReact.Global + createEmotionServer = require('@emotion/server/create-instance').default +} + +const removeGlobalProp = prop => { + let descriptor = Object.getOwnPropertyDescriptor(global, prop) + Object.defineProperty(global, prop, { + value: undefined, + writable: true, + configurable: true + }) + // $FlowFixMe + return () => Object.defineProperty(global, prop, descriptor) +} + +const disableBrowserEnvTemporarily = (fn: () => T): T => { + let restoreDocument = removeGlobalProp('document') + let restoreWindow = removeGlobalProp('window') + let restoreHTMLElement = removeGlobalProp('HTMLElement') + try { + return fn() + } finally { + restoreDocument() + restoreWindow() + restoreHTMLElement() + } } test("cache created in render doesn't cause a hydration mismatch", () => { @@ -166,3 +198,401 @@ test('initializing another Emotion instance should not move already moved styles `) }) + +test('global styles can be removed individually after rehydrating HTML SSRed with extractCriticalToChunks', () => { + const { app, styles } = disableBrowserEnvTemporarily(() => { + resetAllModules() + + let cache = createCache({ key: 'mui' }) + let { + extractCriticalToChunks, + constructStyleTagsFromChunks + } = createEmotionServer(cache) + + const rendered = ReactDOMServer.renderToString( + + + +
+
+
+
+ ) + const extracted = extractCriticalToChunks(rendered) + return { + app: extracted.html, + styles: constructStyleTagsFromChunks(extracted) + } + }) + + safeQuerySelector('head').innerHTML = styles + safeQuerySelector('body').innerHTML = `
${app}
` + expect(safeQuerySelector('html')).toMatchInlineSnapshot(` + + + + + + + +
+
+
+
+
+ + + `) + + resetAllModules() + const cache = createCache({ key: 'mui', speedy: true }) + + ReactDOM.render( + + + +
+
+
+
, + safeQuerySelector('#root') + ) + + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + + `) + + ReactDOM.render( + + +
+
+
+
, + safeQuerySelector('#root') + ) + + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + `) +}) + +test('duplicated global styles can be removed safely after rehydrating HTML SSRed with extractCriticalToChunks', () => { + const { app, styles } = disableBrowserEnvTemporarily(() => { + resetAllModules() + + let cache = createCache({ key: 'muii' }) + let { + extractCriticalToChunks, + constructStyleTagsFromChunks + } = createEmotionServer(cache) + + const rendered = ReactDOMServer.renderToString( + + + +
+ + ) + const extracted = extractCriticalToChunks(rendered) + return { + app: extracted.html, + styles: constructStyleTagsFromChunks(extracted) + } + }) + + safeQuerySelector('head').innerHTML = styles + safeQuerySelector('body').innerHTML = `
${app}
` + expect(safeQuerySelector('html')).toMatchInlineSnapshot(` + + + + + + +
+
+
+ + + `) + + resetAllModules() + const cache = createCache({ key: 'muii', speedy: true }) + + ReactDOM.render( + + + +
+ , + safeQuerySelector('#root') + ) + + // it's expected that this contains 2 copies of the same global style + // where the second one is added during client hydration + // this makes them flushable individually + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + + `) + + ReactDOM.render( + + +
+ , + safeQuerySelector('#root') + ) + + // this should still have a global style + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + `) + + ReactDOM.render( + +
+ , + safeQuerySelector('#root') + ) + + // this should render without a crash + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + `) +}) + +test('duplicated global styles can be removed safely after rehydrating HTML SSRed with zero config approach', () => { + const { app } = disableBrowserEnvTemporarily(() => { + resetAllModules() + + let cache = createCache({ key: 'globcop' }) + + const rendered = ReactDOMServer.renderToString( + + + +
+ + ) + return { + app: rendered + } + }) + + safeQuerySelector('head').innerHTML = '' + safeQuerySelector('body').innerHTML = `
${app}
` + + expect(safeQuerySelector('html')).toMatchInlineSnapshot(` + + + +
+ + + +
+
+ + + `) + + resetAllModules() + const cache = createCache({ key: 'globcop', speedy: true }) + + ReactDOM.render( + + + +
+ , + safeQuerySelector('#root') + ) + + // it's expected that this contains 2 copies of the same global style + // as both were rendered "inline" during SSR + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + + `) + + ReactDOM.render( + + +
+ , + safeQuerySelector('#root') + ) + + // this should still have a global style + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + + `) + + ReactDOM.render( + +
+ , + safeQuerySelector('#root') + ) + + // this should render without a crash + expect(safeQuerySelector('head')).toMatchInlineSnapshot(` + + + + `) +}) diff --git a/packages/react/src/global.js b/packages/react/src/global.js index 3c76ff0e2..8d1ccce63 100644 --- a/packages/react/src/global.js +++ b/packages/react/src/global.js @@ -97,18 +97,21 @@ export let Global: React.AbstractComponent< container: cache.sheet.container, speedy: cache.sheet.isSpeedy }) + let rehydrating = false // $FlowFixMe let node: HTMLStyleElement | null = document.querySelector( `style[data-emotion="${key} ${serialized.name}"]` ) - if (cache.sheet.tags.length) { sheet.before = cache.sheet.tags[0] } if (node !== null) { + rehydrating = true + // clear the hash so this node won't be recognizable as rehydratable by other s + node.setAttribute('data-emotion', key) sheet.hydrate([node]) } - sheetRef.current = sheet + sheetRef.current = [sheet, rehydrating] return () => { sheet.flush() } @@ -118,11 +121,17 @@ export let Global: React.AbstractComponent< React.useLayoutEffect( () => { + let sheetRefCurrent = (sheetRef.current: any) + let [sheet, rehydrating] = sheetRefCurrent + if (rehydrating) { + sheetRefCurrent[1] = false + return + } if (serialized.next !== undefined) { // insert keyframes insertStyles(cache, serialized.next, true) } - let sheet: StyleSheet = ((sheetRef.current: any): StyleSheet) + if (sheet.tags.length) { // if this doesn't exist then it will be null so the style element will be appended let element = sheet.tags[sheet.tags.length - 1].nextElementSibling diff --git a/packages/server/src/create-instance/construct-style-tags-from-chunks.js b/packages/server/src/create-instance/construct-style-tags-from-chunks.js new file mode 100644 index 000000000..a9fa1b762 --- /dev/null +++ b/packages/server/src/create-instance/construct-style-tags-from-chunks.js @@ -0,0 +1,26 @@ +// @flow +import type { EmotionCache } from '@emotion/utils' +import { generateStyleTag } from './utils' + +const createConstructStyleTagsFromChunks = ( + cache: EmotionCache, + nonceString: string +) => (criticalData: { + html: string, + styles: Array<{ key: string, ids: Array, css: string }> +}) => { + let styleTagsString = '' + + criticalData.styles.forEach(item => { + styleTagsString += generateStyleTag( + item.key, + item.ids.join(' '), + item.css, + nonceString + ) + }) + + return styleTagsString +} + +export default createConstructStyleTagsFromChunks diff --git a/packages/server/src/create-instance/extract-critical-to-chunks.js b/packages/server/src/create-instance/extract-critical-to-chunks.js new file mode 100644 index 000000000..a9363659c --- /dev/null +++ b/packages/server/src/create-instance/extract-critical-to-chunks.js @@ -0,0 +1,53 @@ +// @flow +import type { EmotionCache } from '@emotion/utils' + +const createExtractCriticalToChunks = (cache: EmotionCache) => ( + html: string +) => { + // parse out ids from html + // reconstruct css/rules/cache to pass + let RGX = new RegExp(`${cache.key}-([a-zA-Z0-9-_]+)`, 'gm') + + let o = { html, styles: [] } + let match + let ids = {} + while ((match = RGX.exec(html)) !== null) { + // $FlowFixMe + if (ids[match[1]] === undefined) { + // $FlowFixMe + ids[match[1]] = true + } + } + + const regularCssIds = [] + let regularCss = '' + + Object.keys(cache.inserted).forEach(id => { + if ( + (ids[id] !== undefined || + cache.registered[`${cache.key}-${id}`] === undefined) && + cache.inserted[id] !== true + ) { + if (cache.registered[`${cache.key}-${id}`]) { + // regular css can be added in one style tag + regularCssIds.push(id) + // $FlowFixMe + regularCss += cache.inserted[id] + } else { + // each global styles require a new entry so it can be independently flushed + o.styles.push({ + key: `${cache.key}-global`, + ids: [id], + css: cache.inserted[id] + }) + } + } + }) + + // make sure that regular css is added after the global styles + o.styles.push({ key: cache.key, ids: regularCssIds, css: regularCss }) + + return o +} + +export default createExtractCriticalToChunks diff --git a/packages/server/src/create-instance/index.js b/packages/server/src/create-instance/index.js index ddd6b3421..a11b41f8c 100644 --- a/packages/server/src/create-instance/index.js +++ b/packages/server/src/create-instance/index.js @@ -1,9 +1,10 @@ // @flow import type { EmotionCache } from '@emotion/utils' import createExtractCritical from './extract-critical' +import createExtractCriticalToChunks from './extract-critical-to-chunks' import createRenderStylesToString from './inline' import createRenderStylesToStream from './stream' - +import createConstructStyleTagsFromChunks from './construct-style-tags-from-chunks' export default function(cache: EmotionCache) { if (cache.compat !== true) { // is this good? should we do this automatically? @@ -13,7 +14,12 @@ export default function(cache: EmotionCache) { const nonceString = cache.nonce !== undefined ? ` nonce="${cache.nonce}"` : '' return { extractCritical: createExtractCritical(cache), + extractCriticalToChunks: createExtractCriticalToChunks(cache), renderStylesToString: createRenderStylesToString(cache, nonceString), - renderStylesToNodeStream: createRenderStylesToStream(cache, nonceString) + renderStylesToNodeStream: createRenderStylesToStream(cache, nonceString), + constructStyleTagsFromChunks: createConstructStyleTagsFromChunks( + cache, + nonceString + ) } } diff --git a/packages/server/src/create-instance/inline.js b/packages/server/src/create-instance/inline.js index 389612d5d..8faf8de48 100644 --- a/packages/server/src/create-instance/inline.js +++ b/packages/server/src/create-instance/inline.js @@ -1,16 +1,6 @@ // @flow import type { EmotionCache } from '@emotion/utils' - -function generateStyleTag( - cssKey: string, - ids: string, - styles: string, - nonceString: string -) { - return `` -} +import { generateStyleTag } from './utils' const createRenderStylesToString = ( cache: EmotionCache, @@ -38,7 +28,12 @@ const createRenderStylesToString = ( } if (globalStyles !== '') { - result = generateStyleTag(cssKey, globalIds, globalStyles, nonceString) + result = generateStyleTag( + cssKey, + globalIds.substring(1), + globalStyles, + nonceString + ) } let ids = '' @@ -50,7 +45,12 @@ const createRenderStylesToString = ( // $FlowFixMe if (match[0] === '<') { if (ids !== '') { - result += generateStyleTag(cssKey, ids, styles, nonceString) + result += generateStyleTag( + cssKey, + ids.substring(1), + styles, + nonceString + ) ids = '' styles = '' } diff --git a/packages/server/src/create-instance/utils.js b/packages/server/src/create-instance/utils.js new file mode 100644 index 000000000..d47166af8 --- /dev/null +++ b/packages/server/src/create-instance/utils.js @@ -0,0 +1,10 @@ +// @flow + +export function generateStyleTag( + cssKey: string, + ids: string, + styles: string, + nonceString: string +) { + return `` +} diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 58c162cb3..94000e3e6 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -4,6 +4,8 @@ import { cache } from '@emotion/css' export const { extractCritical, + extractCriticalToChunks, renderStylesToString, - renderStylesToNodeStream + renderStylesToNodeStream, + constructStyleTagsFromChunks } = createEmotionServer(cache) diff --git a/packages/server/test/__snapshots__/extract-critical-to-chunks.test.js.snap b/packages/server/test/__snapshots__/extract-critical-to-chunks.test.js.snap new file mode 100644 index 000000000..e37bd760f --- /dev/null +++ b/packages/server/test/__snapshots__/extract-critical-to-chunks.test.js.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractCriticalToChunks generates correct style tags using constructStyleTagsFromChunks 1`] = ` + + + +`; + +exports[`extractCriticalToChunks generates correct style tags using constructStyleTagsFromChunks 2`] = ` + + + +`; + +exports[`extractCriticalToChunks returns static css 1`] = ` +Object { + "html":
+
, + "styles": Array [ + Object { + "css": "body { + color: white; +}", + "ids": Array [ + "l6h", + ], + "key": "css-global", + }, + Object { + "css": "html { + background: red; +}", + "ids": Array [ + "10q49a4", + ], + "key": "css-global", + }, + Object { + "css": ".css-14e1j2p-hoverStyles-Something_Main { + color: hotpink; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.css-14e1j2p-hoverStyles-Something_Main:hover { + color: white; + background-color: lightgray; + border-color: aqua; + box-shadow: -15px -15px 0 0 aqua,-30px -30px 0 0 cornflowerblue; +}", + "ids": Array [ + "14e1j2p-hoverStyles-Something_Main", + ], + "key": "css", + }, + ], +} +`; + +exports[`extractCriticalToChunks returns static css 2`] = ` +Object { + "html":
+
, + "styles": Array [ + Object { + "css": "body { + color: white; +}", + "ids": Array [ + "l6h", + ], + "key": "css-global", + }, + Object { + "css": "html { + background: red; +}", + "ids": Array [ + "10q49a4", + ], + "key": "css-global", + }, + Object { + "css": ".css-14e1j2p-hoverStyles-Something_Main { + color: hotpink; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.css-14e1j2p-hoverStyles-Something_Main:hover { + color: white; + background-color: lightgray; + border-color: aqua; + box-shadow: -15px -15px 0 0 aqua,-30px -30px 0 0 cornflowerblue; +}", + "ids": Array [ + "14e1j2p-hoverStyles-Something_Main", + ], + "key": "css", + }, + ], +} +`; diff --git a/packages/server/test/extract-critical-to-chunks.test.js b/packages/server/test/extract-critical-to-chunks.test.js new file mode 100644 index 000000000..b035592fc --- /dev/null +++ b/packages/server/test/extract-critical-to-chunks.test.js @@ -0,0 +1,82 @@ +/** + * @jest-environment node + * @flow + */ + +import React from 'react' +import { renderToString } from 'react-dom/server' +import type { Emotion } from '@emotion/css/create-instance' +import { prettifyCriticalChunks } from './util' + +let emotion = require('@emotion/css') +let reactEmotion = require('@emotion/styled') +let emotionServer = require('@emotion/server') + +export const getComponents = ( + emotion: Emotion, + { default: styled }: { default: Function } +) => { + let Provider = require('@emotion/react').CacheProvider + let Global = require('@emotion/react').Global + let { css } = emotion + + const hoverStyles = css` + color: hotpink; + &:hover { + color: white; + background-color: lightgray; + border-color: aqua; + box-shadow: -15px -15px 0 0 aqua, -30px -30px 0 0 cornflowerblue; + } + label: hoverStyles; + ` + + // this is using @emotion/styled/base + // so the call syntax has to be used + const Main = styled('main')` + ${hoverStyles}; + display: flex; + label: Something_Main; + ` + + const Page1 = () => ( + + + +
+ + ) + + const Page2 = () => ( + + +
+ + ) + return { Page1, Page2 } +} + +describe('extractCriticalToChunks', () => { + const { Page1, Page2 } = getComponents(emotion, reactEmotion) + + const page1Critical = emotionServer.extractCriticalToChunks( + renderToString() + ) + const page2Critical = emotionServer.extractCriticalToChunks( + renderToString() + ) + + test('returns static css', () => { + expect(prettifyCriticalChunks(page1Critical)).toMatchSnapshot() + expect(prettifyCriticalChunks(page2Critical)).toMatchSnapshot() + }) + + test('generates correct style tags using constructStyleTagsFromChunks', () => { + expect( + emotionServer.constructStyleTagsFromChunks(page1Critical) + ).toMatchSnapshot() + expect( + emotionServer.constructStyleTagsFromChunks(page2Critical) + ).toMatchSnapshot() + }) +}) diff --git a/packages/server/test/util.js b/packages/server/test/util.js index 2aa421605..23896d0ca 100644 --- a/packages/server/test/util.js +++ b/packages/server/test/util.js @@ -163,7 +163,29 @@ export const prettifyCritical = ({ css: string, ids: Array }) => { - return { css: prettify(css), ids, html } + return { + css: prettify(css), + ids, + html + } +} + +export const prettifyCriticalChunks = ({ + html, + styles +}: { + html: string, + styles: Array<{ key: string, css: string, ids: Array }> +}) => { + return { + // $FlowFixMe + styles: styles.map<{ key: string, css: string, ids: Array }>( + (item): { key: string, css: string, ids: Array } => { + return { css: prettify(item.css || ''), ids: item.ids, key: item.key } + } + ), + html + } } const isSSRedStyle = node => { diff --git a/packages/server/types/create-instance.d.ts b/packages/server/types/create-instance.d.ts index aeda29ce3..d711458bd 100644 --- a/packages/server/types/create-instance.d.ts +++ b/packages/server/types/create-instance.d.ts @@ -10,10 +10,17 @@ export interface EmotionCritical { css: string } +export interface EmotionCriticalToChunks { + html: string + styles: Array<{ key: string; ids: Array; css: string }> +} + export interface EmotionServer { extractCritical(html: string): EmotionCritical + extractCriticalToChunks(html: string): EmotionCriticalToChunks renderStylesToString(html: string): string renderStylesToNodeStream(): NodeJS.ReadWriteStream + constructStyleTagsFromChunks(criticalData: EmotionCriticalToChunks): string } export default function createEmotionServer(cache: EmotionCache): EmotionServer