Skip to content

Commit

Permalink
Merge pull request #69 from meza/fix/script-hydration
Browse files Browse the repository at this point in the history
fix: fix script hydration warning
  • Loading branch information
meza authored Mar 8, 2023
2 parents 6be62d4 + f7fc8f2 commit 4f5eaaa
Show file tree
Hide file tree
Showing 17 changed files with 217 additions and 79 deletions.
20 changes: 5 additions & 15 deletions src/__snapshots__/root.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
// Vitest Snapshot v1

exports[`The root module > when rendering the app > can expose the app config correctly 1`] = `
<script
dangerouslySetInnerHTML={
{
"__html": "window.appConfig = {\\"hotjarId\\":\\"a-hotjar-id\\",\\"googleAnalyticsId\\":\\"ga-id\\",\\"mixpanelToken\\":\\"a-mixpanel-token\\",\\"visitorId\\":\\"a-visitor-id\\",\\"isProduction\\":true,\\"mixpanelApi\\":\\"a-mixpanel-api\\",\\"splitToken\\":\\"a-split-token\\",\\"cookieYesToken\\":\\"a-cookieyes-token\\",\\"version\\":\\"0.0.0-dev\\",\\"sentryDsn\\":\\"a-sentry-dsn\\"}",
}
}
/>
`;

exports[`The root module > when rendering the app > renders the app 1`] = `
<DocumentFragment>
mock sentry wrapper
Expand All @@ -20,6 +10,7 @@ exports[`The root module > when rendering the app > renders the app 1`] = `
>
<head>
<script
id="app-config"
nonce="mocked-nonce"
>
window.appConfig = {"hotjarId":"a-hotjar-id","googleAnalyticsId":"ga-id","mixpanelToken":"a-mixpanel-token","visitorId":"a-visitor-id","isProduction":true,"mixpanelApi":"a-mixpanel-api","splitToken":"a-split-token","cookieYesToken":"a-cookieyes-token","version":"0.0.0-dev","sentryDsn":"a-sentry-dsn"}
Expand All @@ -32,6 +23,7 @@ exports[`The root module > when rendering the app > renders the app 1`] = `
/>
<script
async=""
id="gtm"
nonce="mocked-nonce"
src="https://www.googletagmanager.com/gtag/js?id=ga-id"
/>
Expand All @@ -40,11 +32,9 @@ exports[`The root module > when rendering the app > renders the app 1`] = `
nonce="mocked-nonce"
>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'ga-id', {
'user_id': 'a-visitor-id'
});
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'ga-id', { user_id: 'a-visitor-id' });
</script>
<script
async=""
Expand Down
6 changes: 4 additions & 2 deletions src/components/CookieYes/CookieYes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ describe('CookieYes', () => {
it('Renders with the correct values', () => {
// eslint-disable-next-line new-cap
expect(CookieYes({ isProduction: true, token: '123' })).toMatchInlineSnapshot(`
<script
<StaticContent
element="script"
id="cookieyes"
src="https://cdn-cookieyes.com/client_data/123/script.js"
type="text/javascript"
/>
`);
// eslint-disable-next-line new-cap
expect(CookieYes({ isProduction: true, token: 'abc', nonce: 'a-nonce' })).toMatchInlineSnapshot(`
<script
<StaticContent
element="script"
id="cookieyes"
nonce="a-nonce"
src="https://cdn-cookieyes.com/client_data/abc/script.js"
Expand Down
11 changes: 10 additions & 1 deletion src/components/CookieYes/CookieYes.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { StaticContent } from '~/components/StaticContent';

export const CookieYes = (props: { isProduction: boolean, token: string; nonce?: string; }) => {
if (props.isProduction) {
return <script nonce={props.nonce} id={'cookieyes'} type={'text/javascript'} src={`https://cdn-cookieyes.com/client_data/${props.token}/script.js`}/>;
return (
<StaticContent
element={'script'}
nonce={props.nonce}
id={'cookieyes'}
type={'text/javascript'}
src={`https://cdn-cookieyes.com/client_data/${props.token}/script.js`}/>
);
}
return null;
};
24 changes: 24 additions & 0 deletions src/components/ExposeAppConfig/ExposeAppConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, it } from 'vitest';
import { ExposeAppConfig } from './ExposeAppConfig';

describe('ExposeAppConfig', () => {
const appConfig: AppConfig = {
hotjarId: 'a-hotjar-id',
googleAnalyticsId: 'ga-id',
mixpanelToken: 'a-mixpanel-token',
visitorId: 'a-visitor-id',
isProduction: true,
mixpanelApi: 'a-mixpanel-api',
splitToken: 'a-split-token',
cookieYesToken: 'a-cookieyes-token',
version: '0.0.0-dev',
sentryDsn: 'a-sentry-dsn'
};

it('can expose the app config correctly', () => {
// eslint-disable-next-line new-cap
const markup = ExposeAppConfig({ appConfig: appConfig });
expect(markup).toMatchSnapshot();
});
});

13 changes: 13 additions & 0 deletions src/components/ExposeAppConfig/ExposeAppConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { StaticContent } from '~/components/StaticContent';

export const ExposeAppConfig = (props: { appConfig: AppConfig, nonce?: string }) => {
return (
<StaticContent
element={'script'}
id={'app-config'}
nonce={props.nonce}
dangerouslySetInnerHTML={{
__html: `window.appConfig = ${JSON.stringify(props.appConfig)}` //typed in the ../types/global.d.ts
}}/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Vitest Snapshot v1

exports[`ExposeAppConfig > can expose the app config correctly 1`] = `
<StaticContent
dangerouslySetInnerHTML={
{
"__html": "window.appConfig = {\\"hotjarId\\":\\"a-hotjar-id\\",\\"googleAnalyticsId\\":\\"ga-id\\",\\"mixpanelToken\\":\\"a-mixpanel-token\\",\\"visitorId\\":\\"a-visitor-id\\",\\"isProduction\\":true,\\"mixpanelApi\\":\\"a-mixpanel-api\\",\\"splitToken\\":\\"a-split-token\\",\\"cookieYesToken\\":\\"a-cookieyes-token\\",\\"version\\":\\"0.0.0-dev\\",\\"sentryDsn\\":\\"a-sentry-dsn\\"}",
}
}
element="script"
id="app-config"
/>
`;
1 change: 1 addition & 0 deletions src/components/ExposeAppConfig/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ExposeAppConfig';
30 changes: 16 additions & 14 deletions src/components/GoogleAnalytics/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,23 @@ describe('Google Analytics 4', () => {
nonce: 'a-nonce'
})).toMatchInlineSnapshot(`
<React.Fragment>
<script
<StaticContent
async={true}
element="script"
id="gtm"
nonce="a-nonce"
src="https://www.googletagmanager.com/gtag/js?id=123"
/>
<script
<StaticContent
dangerouslySetInnerHTML={
{
"__html": "window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '123', {
'user_id': 'abc'
});",
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '123', { user_id: 'abc' });",
}
}
element="script"
id="google-analytics"
nonce="a-nonce"
/>
Expand All @@ -43,22 +44,23 @@ describe('Google Analytics 4', () => {
nonce: 'a-nonce2'
})).toMatchInlineSnapshot(`
<React.Fragment>
<script
<StaticContent
async={true}
element="script"
id="gtm"
nonce="a-nonce2"
src="https://www.googletagmanager.com/gtag/js?id=triangulation"
/>
<script
<StaticContent
dangerouslySetInnerHTML={
{
"__html": "window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'triangulation', {
'user_id': 'abc123'
});",
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'triangulation', { user_id: 'abc123' });",
}
}
element="script"
id="google-analytics"
nonce="a-nonce2"
/>
Expand Down
30 changes: 16 additions & 14 deletions src/components/GoogleAnalytics/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import { StaticContent } from '~/components/StaticContent';

interface GoogleAnalyticsProps {
googleAnalyticsId: string;
visitorId: string;
nonce?: string;
}

export const GoogleAnalytics = (props: GoogleAnalyticsProps) => {
const inputProps: {nonce?: string} = {};
if (props.nonce) {
inputProps.nonce = props.nonce;
}
return (
<>
<script async nonce={props.nonce} src={`https://www.googletagmanager.com/gtag/js?id=${props.googleAnalyticsId}`}></script>
<script
{...inputProps}
<StaticContent
element={'script'}
id={'gtm'}
async
nonce={props.nonce}
src={`https://www.googletagmanager.com/gtag/js?id=${props.googleAnalyticsId}`}
/>
<StaticContent
element={'script'}
id={'google-analytics'}
nonce={props.nonce}
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${props.googleAnalyticsId}', {
'user_id': '${props.visitorId}'
});`
}}
></script>
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${props.googleAnalyticsId}', { user_id: '${props.visitorId}' });`
}}/>
</>
);
};
12 changes: 8 additions & 4 deletions src/components/Hotjar/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('Hotjar', () => {
nonce: 'a-nonce'
})).toMatchInlineSnapshot(`
<React.Fragment>
<script
<StaticContent
async={true}
dangerouslySetInnerHTML={
{
Expand All @@ -26,11 +26,13 @@ describe('Hotjar', () => {
",
}
}
element="script"
id="hotjar-init"
nonce="a-nonce"
/>
<script
<StaticContent
async={true}
element="script"
id="hotjar-script"
nonce="a-nonce"
src="https://static.hotjar.com/c/hotjar-123.js?sv=6"
Expand All @@ -45,7 +47,7 @@ describe('Hotjar', () => {
nonce: 'a-nonce2'
})).toMatchInlineSnapshot(`
<React.Fragment>
<script
<StaticContent
async={true}
dangerouslySetInnerHTML={
{
Expand All @@ -57,11 +59,13 @@ describe('Hotjar', () => {
",
}
}
element="script"
id="hotjar-init"
nonce="a-nonce2"
/>
<script
<StaticContent
async={true}
element="script"
id="hotjar-script"
nonce="a-nonce2"
src="https://static.hotjar.com/c/hotjar-324123.js?sv=6"
Expand Down
10 changes: 7 additions & 3 deletions src/components/Hotjar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { StaticContent } from '~/components/StaticContent';

interface HotjarProps {
hotjarId: string;
visitorId: string;
Expand All @@ -7,7 +9,7 @@ interface HotjarProps {
export const Hotjar = (props: HotjarProps) => {
const { hotjarId, visitorId } = props;

const inputProps: {nonce?: string} = {};
const inputProps: { nonce?: string } = {};
if (props.nonce) {
inputProps.nonce = props.nonce;
}
Expand All @@ -16,7 +18,8 @@ export const Hotjar = (props: HotjarProps) => {

return (
<>
<script
<StaticContent
element={'script'}
{...inputProps}
async
id={'hotjar-init'}
Expand All @@ -29,7 +32,8 @@ export const Hotjar = (props: HotjarProps) => {
`
}}
/>
<script
<StaticContent
element={'script'}
{...inputProps}
async
id={'hotjar-script'}
Expand Down
18 changes: 18 additions & 0 deletions src/components/StaticContent/StaticContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render } from '@testing-library/react';
import { expect, it } from 'vitest';
import { StaticContent } from './StaticContent';

describe('StaticContent', () => {
it('should wrap with div by default', () => {
// eslint-disable-next-line new-cap
const { container } = render(<StaticContent title={'some div'}>{'div content'}</StaticContent>);
expect(container).toMatchSnapshot();
});

it('should wrap with given element', () => {
// eslint-disable-next-line new-cap
const { container } = render(<StaticContent async element={'script'}>{'script content'}</StaticContent>);
expect(container).toMatchSnapshot();
});
});

44 changes: 44 additions & 0 deletions src/components/StaticContent/StaticContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createElement, useRef, useState, useEffect } from 'react';
import type { ReactNode } from 'react';

const useStaticContent = () => {
const ref = useRef<HTMLElement>(null);
const [render, setRender] = useState(typeof document === 'undefined');

useEffect(() => {
// check if the innerHTML is empty as client side navigation
// need to render the component without server-side backup
const isEmpty = ref.current?.innerHTML === '';
if (isEmpty) {
setRender(true);
}
}, []);

return [render, ref];
};

export const StaticContent = <Elem extends keyof JSX.IntrinsicElements = 'div'>(
{ children, element, ...props }: {
element?: Elem;
children?: ReactNode;
} & JSX.IntrinsicElements[Elem]
) => {
const elem = element || 'div';
const [render, ref] = useStaticContent();

// if we're in the server or a spa navigation, just render it
if (render) {
return createElement(elem, {
...props,
children: children
});
}

// avoid re-render on the client
return createElement(elem, {
...props,
ref: ref,
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: '' }
});
};
Loading

0 comments on commit 4f5eaaa

Please sign in to comment.