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

Expose CSS Variables for SSR to avoid FOUC #160

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a450431
chore(Highlight.js): fix
nluangrath-godaddy Jul 22, 2022
f4d294a
improv(utils): add themeWithCssVariables function
nluangrath-godaddy Jul 23, 2022
75c8cdf
improv(themeToDict.js): add optional third arg for root styles
nluangrath-godaddy Jul 26, 2022
c659b2c
test(Highlight.test.js): add additional data attribute for raw style …
nluangrath-godaddy Jul 26, 2022
81b606f
fix(themeToDict.js): got messed up by prettier
nluangrath-godaddy Jul 26, 2022
fe5f390
refactor(Highlight.js): use css variables as placeholders
nluangrath-godaddy Jul 26, 2022
98b52fb
improv: add id field to PrismTheme type
nluangrath-godaddy Jul 26, 2022
aff1614
feat: add SSR / FOUC support
nluangrath-godaddy Jul 26, 2022
520a8eb
fix(generateScriptForSSR.js): cant use nullish collesing or whatever
nluangrath-godaddy Jul 27, 2022
595ab5e
chore(index.d.ts): update type declaration file
nluangrath-godaddy Aug 1, 2022
ef2f8e7
chore: make PrismTheme.id required, variable renaming
nluangrath-godaddy Aug 1, 2022
49da00a
fix(generateScriptForSSR.js): must quote strings in strinigified js
nluangrath-godaddy Aug 1, 2022
82040cc
fix(generateScriptForSSR): root isnt a global variable u dummy
nluangrath-godaddy Aug 1, 2022
30b2d1e
fix(generateScriptForSSR.js): illegal return u dummy
nluangrath-godaddy Aug 1, 2022
9d7954a
docs(README.md): add Next.js example
nluangrath-godaddy Aug 2, 2022
efc23ca
chore(README.md): use string interpolation
nluangrath-godaddy Aug 2, 2022
dcd4681
docs(README.md): clarify SSR docs
narinluangrath Aug 3, 2022
bcff206
improv(generateScriptForSSR.js): wrap script with try/catch
narinluangrath Aug 3, 2022
ad4aae6
docs(README.md): add comment about CSP
narinluangrath Aug 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,101 @@ property limits styles to highlighted languages.
When converting a Prism CSS theme it's mostly just necessary to use classes as
`types` and convert the declarations to object-style-syntax and put them on `style`.

### SSR Support / Avoiding FOUC

If your React app supports "light mode / dark mode" you need to do additional work to avoid a flash of unstyled content (FOUC).
Suppose you have the following application code:

```jsx
import { useState, useEffect } from 'react';
import Highlight from 'prism-react-renderer'
import duotoneDark from 'prism-react-renderer/themes/duotoneDark';
import duotoneLight from 'prism-react-renderer/themes/duotoneLight';

const useTheme = () => {
const [theme, _setTheme] = useState(duotoneLight);

useEffect(() => {
const colorMode = window.localStorage.getItem('color-mode');
_setTheme(colorMode === 'dark' ? duotoneDark : duotoneLight);
}, []);

const setTheme = (themeId) => {
if (themeId === duotoneLight.id) {
window.localStorage.setItem('color-mode', 'light');
_setTheme(duotoneLight);
}
if (themeId === duotoneDark.id) {
window.localStorage.setItem('color-mode', 'dark');
_setTheme(duotoneDark);
}
}

return { theme, setTheme }
}

const MyComponent = () => {
const { setTheme, theme } = useTheme();

return (
<>
<Highlight theme={theme}>
// omitted for brevity
</Highlight>
<button onClick={() => setTheme(duotoneLight.id)}>Light Mode!</button>
<button onClick={() => setTheme(duotoneDark.id)}>Dark Mode!</button>
</>
)
}
```

You should generate the following script tag and inject it into your HTML so it runs _before_ your content loads. Note: **Do NOT copy/paste the following code.** Use it as a starting point and modify it to match your application's method of persisting theme state.

```js
import Highlight, { generateScriptForSSR } from 'prism-react-renderer'
import duotoneDark from 'prism-react-renderer/themes/duotoneDark';
import duotoneLight from 'prism-react-renderer/themes/duotoneLight';

const getThemeIdFuncStr = `
() => (
window.localStorage.getItem('color-mode') === 'dark'
? '${duotoneDark.id}'
: '${duotoneLight.id}'
);
`.trim()

const codeToRunOnClient = generateScriptForSSR(
// Include whatever themes `getThemeIdFuncStr` might return
[duotoneDark, duotoneLight],
getThemeIdFuncStr
);

// Gatsby
export const onRenderBody = ({ setPreBodyComponents }) => {
setPreBodyComponents(
<script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
);
Comment on lines +465 to +467
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if we have any way around this, but if end-user is using any sort of Content Security Policy header to, say, restrict inline scripts – then this will likely fail. I don't think very many end-users would be doing that, but just wanted to point that out as a potential failure point here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yes, I totally didn't think about that. I'll add a warning about it and a way to work with CSP.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a note about CSP.

Choose a reason for hiding this comment

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

Should we sanitize codeToRunOnClient? I see that we are sometimes getting theme values from localStorage, which means the theme data isn't guaranteed to be as expected.

Forgive me if this comment is naive or intrusive. I am in the middle of security training which is why the dangers of dangerouslySetInnerHTML are at the front of my mind. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @ZimChristine, thanks for the question! I think this code is fine because I believe the worst thing that could happen is we set the wrong CSS variables, but I would love to have a second opinion!

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll chime in here. I think this is a very valid question! I think in this case, we'll be okay – since the code that we're "dangerously" setting is generated from the site developer and not from users. Generally, injection attacks occur when you fail to sanitize user-provided data. In this case, the code we're "injecting" here is provided explicitly by the site developer, and is not really dynamic – so if there were some sort of shenanigans going on, it'd be coming from the site developer themselves, and there's not much we can do about that.

Choose a reason for hiding this comment

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

Right on! Thanks for the reply. Agreed that for the current usage this dangerously set html is protected from an injection attack; glad for the dialogue. :)

};

// Next.js (pages/_document.js)
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
return (
<Html>
<Head />
<body>
<script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
<Main />
<NextScript />
</body>
</Html>
)
}
```

Note: You may need to tweak your application's [Content-Security-Policy (CSP) header](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) for inline scripts to work properly.

## FAQ

<details>
Expand Down
3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ declare module "prism-react-renderer" {
};

type PrismTheme = {
id: string;
plain: PrismThemeEntry;
styles: Array<{
types: string[];
Expand Down Expand Up @@ -173,6 +174,8 @@ declare module "prism-react-renderer" {

export const Prism: PrismLib;

export const generateScriptForSSR: (themes: PrismTheme[], getThemeIdFuncStr: string) => string;

export { Language, DefaultProps, PrismTheme };
}

Expand Down
32 changes: 25 additions & 7 deletions src/components/Highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { Component, type Node } from "react";
import normalizeTokens from "../utils/normalizeTokens";
import themeToDict, { type ThemeDict } from "../utils/themeToDict";
import themeWithCssVariables from "../utils/themeWithCssVariables";

import type {
Language,
Expand All @@ -16,6 +17,7 @@ import type {
PrismLib,
PrismTheme,
PrismToken,
StyleObj,
} from "../types";

type Props = {
Expand All @@ -30,6 +32,13 @@ class Highlight extends Component<Props, *> {
prevTheme: PrismTheme | void;
prevLanguage: Language | void;
themeDict: ThemeDict | void;
state = {
isFirstRender: true,
};

componentDidMount() {
this.setState({ isFirstRender: false });
}

getThemeDict = (props: Props): ThemeDict | void => {
if (
Expand All @@ -42,10 +51,13 @@ class Highlight extends Component<Props, *> {

this.prevTheme = props.theme;
this.prevLanguage = props.language;

const themeDict = props.theme
? themeToDict(props.theme, props.language)
: undefined;
let themeDict;
if (props.theme) {
// Replace CSS Values with CSS Variable placeholders
// This is necessary for SSR support
const { theme, variables } = themeWithCssVariables(props.theme);
themeDict = themeToDict(theme, props.language, variables);
}
return (this.themeDict = themeDict);
};

Expand Down Expand Up @@ -79,7 +91,7 @@ class Highlight extends Component<Props, *> {
return output;
};

getStyleForToken = ({ types, empty }: Token) => {
getStyleForToken = ({ types, empty }: Token): StyleObj | void => {
const typesSize = types.length;
const themeDict = this.getThemeDict(this.props);

Expand All @@ -92,7 +104,6 @@ class Highlight extends Component<Props, *> {
}

const baseStyle = empty ? { display: "inline-block" } : {};
// $FlowFixMe
const typeStyles = types.map((type) => themeDict[type]);
return Object.assign(baseStyle, ...typeStyles);
};
Expand Down Expand Up @@ -162,7 +173,14 @@ class Highlight extends Component<Props, *> {
return children({
tokens,
className: `prism-code language-${language}`,
style: themeDict !== undefined ? themeDict.root : {},
// Omit loading CSS variable declarations during the first render.
// That way, the consumer can override the CSS variable declarations
// via `generateScriptTagForSSR` for the very first render. After that
// client side CSS variables will be used.
style:
themeDict !== undefined && !this.state.isFirstRender
? themeDict.root
: {},
Comment on lines +176 to +183
Copy link
Contributor

Choose a reason for hiding this comment

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

just wondering, wouldn't this be a problem for users that are not using generateScriptForSSR yet?

It's better to have the wrong theme style than no style at all?

Copy link
Contributor Author

@narinluangrath narinluangrath Aug 11, 2022

Choose a reason for hiding this comment

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

@slorber Good questions

On line 40, we set this.state.isFirstRender to true immediately. For my initial testing, the state change has been fast enough that I never even see the unstyled content (even without generateScriptForSSR).

Let me know if you have follow up questions. This piece of code is rather awkward, but it's the best solution I could find.

getLineProps: this.getLineProps,
getTokenProps: this.getTokenProps,
});
Expand Down
84 changes: 56 additions & 28 deletions src/components/__tests__/Highlight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ const exampleCode = `
return () => <App />;
`.trim();

// The `data-style` properties are added because `react-test-renderer`
// will parse your style object into a string. However, CSS Variable
// references (e.g. color: var(--custom-color)) will be erased and
// hexadecimal colors (e.g. #000000) will be converted to rgb
// (e.g. rgb(0, 0, 0)). We don't want that.
const TestComponent = ({
className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre className={className} style={style} data-style={JSON.stringify(style)}>
{tokens.map((line, i) => {
const lineProps = getLineProps({ line, key: i });
return (
<div {...lineProps} data-style={JSON.stringify(lineProps.style)}>
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
{...tokenProps}
data-style={JSON.stringify(tokenProps.style)}
/>
);
})}
</div>
);
})}
</pre>
);

describe("<Highlight />", () => {
afterEach(cleanup);

Expand All @@ -21,15 +53,13 @@ describe("<Highlight />", () => {
const { container } = render(
<Highlight {...defaultProps} code={exampleCode} language="jsx">
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
<TestComponent
className={className}
style={style}
tokens={tokens}
getLineProps={getLineProps}
getTokenProps={getTokenProps}
/>
)}
</Highlight>
);
Expand All @@ -45,15 +75,13 @@ describe("<Highlight />", () => {
language="abcdefghijklmnop"
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
<TestComponent
className={className}
style={style}
tokens={tokens}
getLineProps={getLineProps}
getTokenProps={getTokenProps}
/>
)}
</Highlight>
);
Expand All @@ -70,20 +98,20 @@ describe("<Highlight />", () => {
language="jsx"
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={className} style={style}>
{tokens.map((line, i) => (
<div {...getLineProps({ line, key: i })}>
{line.map((token, key) => (
<span {...getTokenProps({ token, key })} />
))}
</div>
))}
</pre>
<TestComponent
className={className}
style={style}
tokens={tokens}
getLineProps={getLineProps}
getTokenProps={getTokenProps}
/>
)}
</Highlight>
);

expect(container.innerHTML.includes("style")).toBeFalsy();
expect(container.querySelector("pre[style]")).toBeFalsy(); // Root
expect(container.querySelector("div[style]")).toBeFalsy(); // Lines
expect(container.querySelector("span[style]")).toBeFalsy(); // Tokens
});
});

Expand Down
Loading