fix(nextjs): adjust generated CSS-in-JS to match latest Next.js recommendations (#17294)

This commit is contained in:
Jack Hsu 2023-05-30 12:12:39 -04:00 committed by GitHub
parent 911f753763
commit a7c6d5aadb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 435 additions and 150 deletions

View File

@ -1,18 +1,11 @@
import { import {
cleanupProject, cleanupProject,
isNotWindows,
killPorts,
newProject, newProject,
runCLI, runCLI,
runCommandUntil,
tmpProjPath,
uniq, uniq,
updateFile, updateFile,
} from '@nx/e2e/utils'; } from '@nx/e2e/utils';
import { getData } from 'ajv/dist/compile/validate';
import { detectPackageManager } from 'nx/src/utils/package-manager';
import { checkApp } from './utils'; import { checkApp } from './utils';
import { p } from 'vitest/dist/types-b7007192';
describe('Next.js App Router', () => { describe('Next.js App Router', () => {
let proj: string; let proj: string;

View File

@ -58,6 +58,19 @@ describe('Next.js apps', () => {
checkExport: false, checkExport: false,
}); });
const scAppWithAppRouter = uniq('app');
runCLI(
`generate @nx/next:app ${scAppWithAppRouter} --no-interactive --style=styled-components --appDir=true`
);
await checkApp(scAppWithAppRouter, {
checkUnitTest: false, // No unit tests for app router
checkLint: false,
checkE2E: false,
checkExport: false,
});
const emotionApp = uniq('app'); const emotionApp = uniq('app');
runCLI( runCLI(

View File

@ -15,7 +15,122 @@ describe('app', () => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
}); });
describe('not nested', () => { it('should add a .gitkeep file to the public directory', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
expect(tree.exists('apps/my-app/public/.gitkeep')).toBe(true);
});
it('should update tags and implicit dependencies', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
tags: 'one,two',
});
const projects = Object.fromEntries(getProjects(tree));
expect(projects).toMatchObject({
'my-app': {
tags: ['one', 'two'],
},
'my-app-e2e': {
tags: [],
implicitDependencies: ['my-app'],
},
});
});
it('should extend from root tsconfig.json when no tsconfig.base.json', async () => {
tree.rename('tsconfig.base.json', 'tsconfig.json');
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
const tsConfig = readJson(tree, 'apps/my-app/tsconfig.json');
expect(tsConfig.extends).toBe('../../tsconfig.json');
});
describe('App Router', () => {
it('should generate files for app layout', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'css',
});
const tsConfig = readJson(tree, 'apps/test-app/tsconfig.json');
expect(tsConfig.include).toEqual([
'**/*.ts',
'**/*.tsx',
'**/*.js',
'**/*.jsx',
'../../apps/test-app/.next/types/**/*.ts',
'../../dist/apps/test-app/.next/types/**/*.ts',
'next-env.d.ts',
]);
expect(tree.exists('apps/test-app/pages/styles.css')).toBeFalsy();
expect(tree.exists('apps/test-app/app/global.css')).toBeTruthy();
expect(tree.exists('apps/test-app/app/page.tsx')).toBeTruthy();
expect(tree.exists('apps/test-app/app/layout.tsx')).toBeTruthy();
expect(tree.exists('apps/test-app/app/api/hello/route.ts')).toBeTruthy();
expect(tree.exists('apps/test-app/app/page.module.css')).toBeTruthy();
expect(tree.exists('apps/test-app/public/favicon.ico')).toBeTruthy();
});
it('should add layout types correctly for standalone apps', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'css',
appDir: true,
rootProject: true,
});
const tsConfig = readJson(tree, 'tsconfig.json');
expect(tsConfig.include).toEqual([
'**/*.ts',
'**/*.tsx',
'**/*.js',
'**/*.jsx',
'.next/types/**/*.ts',
'dist/test-app/.next/types/**/*.ts',
'next-env.d.ts',
]);
});
it('should generate an unstyled component page', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'none',
appDir: true,
rootProject: true,
});
const content = tree.read('app/page.tsx').toString();
expect(content).not.toContain('import styles from');
expect(content).not.toContain('const StyledPage');
expect(content).not.toContain('className={styles.page}');
});
});
describe('Pages Router', () => {
it('should generate files for pages layout', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
appDir: false,
});
expect(tree.exists('apps/my-app/tsconfig.json')).toBeTruthy();
expect(tree.exists('apps/my-app/pages/index.tsx')).toBeTruthy();
expect(tree.exists('apps/my-app/specs/index.spec.tsx')).toBeTruthy();
expect(tree.exists('apps/my-app/pages/index.module.css')).toBeTruthy();
});
it('should update configurations', async () => { it('should update configurations', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'myApp', name: 'myApp',
@ -43,70 +158,6 @@ describe('app', () => {
expect(content).not.toContain('const StyledPage'); expect(content).not.toContain('const StyledPage');
expect(content).not.toContain('className={styles.page}'); expect(content).not.toContain('className={styles.page}');
}); });
it('should update tags and implicit dependencies', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
tags: 'one,two',
});
const projects = Object.fromEntries(getProjects(tree));
expect(projects).toMatchObject({
'my-app': {
tags: ['one', 'two'],
},
'my-app-e2e': {
tags: [],
implicitDependencies: ['my-app'],
},
});
});
it('should generate files for app router layout', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
expect(tree.exists('apps/my-app/tsconfig.json')).toBeTruthy();
expect(tree.exists('apps/my-app/app/page.tsx')).toBeTruthy();
expect(tree.exists('apps/my-app/app/page.module.css')).toBeTruthy();
});
it('should generate files for pages layout', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
appDir: false,
});
expect(tree.exists('apps/my-app/tsconfig.json')).toBeTruthy();
expect(tree.exists('apps/my-app/pages/index.tsx')).toBeTruthy();
expect(tree.exists('apps/my-app/specs/index.spec.tsx')).toBeTruthy();
expect(tree.exists('apps/my-app/pages/index.module.css')).toBeTruthy();
});
it('should extend from root tsconfig.base.json', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
const tsConfig = readJson(tree, 'apps/my-app/tsconfig.json');
expect(tsConfig.extends).toBe('../../tsconfig.base.json');
});
it('should extend from root tsconfig.json when no tsconfig.base.json', async () => {
tree.rename('tsconfig.base.json', 'tsconfig.json');
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
const tsConfig = readJson(tree, 'apps/my-app/tsconfig.json');
expect(tsConfig.extends).toBe('../../tsconfig.json');
});
}); });
describe('--style scss', () => { describe('--style scss', () => {
@ -121,6 +172,28 @@ describe('app', () => {
const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8'); const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8');
expect(indexContent).toContain(`import styles from './page.module.scss'`); expect(indexContent).toContain(`import styles from './page.module.scss'`);
expect(tree.read('apps/my-app/app/layout.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import './global.css';
export const metadata = {
title: 'Welcome to my-app',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
"
`);
}); });
}); });
@ -136,6 +209,28 @@ describe('app', () => {
const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8'); const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8');
expect(indexContent).toContain(`import styles from './page.module.less'`); expect(indexContent).toContain(`import styles from './page.module.less'`);
expect(tree.read('apps/my-app/app/layout.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import './global.less';
export const metadata = {
title: 'Welcome to my-app',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
"
`);
}); });
}); });
@ -155,7 +250,7 @@ describe('app', () => {
}); });
describe('--style styled-components', () => { describe('--style styled-components', () => {
it('should generate scss styles', async () => { it('should generate styles', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'myApp', name: 'myApp',
style: 'styled-components', style: 'styled-components',
@ -169,11 +264,73 @@ describe('app', () => {
const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8'); const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8');
expect(indexContent).not.toContain(`import styles from './page.module`); expect(indexContent).not.toContain(`import styles from './page.module`);
expect(indexContent).toContain(`import styled from 'styled-components'`); expect(indexContent).toContain(`import styled from 'styled-components'`);
expect(tree.read('apps/my-app/app/layout.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import './global.css';
import { StyledComponentsRegistry } from './registry';
export const metadata = {
title: 'Welcome to demo2',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
);
}
"
`);
expect(tree.read('apps/my-app/app/registry.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
// Types are out of date, clearTag is not defined.
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/65021
(styledComponentsStyleSheet.instance as any).clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}
"
`);
}); });
}); });
describe('--style @emotion/styled', () => { describe('--style @emotion/styled', () => {
it('should generate scss styles', async () => { it('should generate styles', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'myApp', name: 'myApp',
style: '@emotion/styled', style: '@emotion/styled',
@ -187,6 +344,28 @@ describe('app', () => {
const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8'); const indexContent = tree.read('apps/my-app/app/page.tsx', 'utf-8');
expect(indexContent).not.toContain(`import styles from './page.module`); expect(indexContent).not.toContain(`import styles from './page.module`);
expect(indexContent).toContain(`import styled from '@emotion/styled'`); expect(indexContent).toContain(`import styled from '@emotion/styled'`);
expect(tree.read('apps/my-app/app/layout.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import './global.css';
export const metadata = {
title: 'Welcome to my-app',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
"
`);
}); });
it('should add jsxImportSource in tsconfig.json', async () => { it('should add jsxImportSource in tsconfig.json', async () => {
@ -220,6 +399,49 @@ describe('app', () => {
expect(indexContent).not.toContain( expect(indexContent).not.toContain(
`import styled from 'styled-components'` `import styled from 'styled-components'`
); );
expect(tree.read('apps/my-app/app/layout.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import './global.css';
import { StyledJsxRegistry } from './registry';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<StyledJsxRegistry>{children}</StyledJsxRegistry>
</body>
</html>
);
}
"
`);
expect(tree.read('apps/my-app/app/registry.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { StyleRegistry, createStyleRegistry } from 'styled-jsx';
export function StyledJsxRegistry({ children }: { children: React.ReactNode }) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [jsxStyleRegistry] = useState(() => createStyleRegistry());
useServerInsertedHTML(() => {
const styles = jsxStyleRegistry.styles();
jsxStyleRegistry.flush();
return <>{styles}</>;
});
return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>;
}
"
`);
}); });
}); });
@ -245,7 +467,7 @@ describe('app', () => {
); );
}); });
it('should set up the nrwl next build builder', async () => { it('should set up the nx next build builder', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'my-app', name: 'my-app',
style: 'css', style: 'css',
@ -260,7 +482,7 @@ describe('app', () => {
}); });
}); });
it('should set up the nrwl next server builder', async () => { it('should set up the nx next server builder', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'my-app', name: 'my-app',
style: 'css', style: 'css',
@ -283,7 +505,7 @@ describe('app', () => {
}); });
}); });
it('should set up the nrwl next export builder', async () => { it('should set up the nx next export builder', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
name: 'my-app', name: 'my-app',
style: 'css', style: 'css',
@ -448,76 +670,4 @@ describe('app', () => {
expect(tsConfigApp.exclude).not.toContain('**/*.spec.js'); expect(tsConfigApp.exclude).not.toContain('**/*.spec.js');
}); });
}); });
it('should add a .gitkeep file to the public directory', async () => {
await applicationGenerator(tree, {
name: 'myApp',
style: 'css',
});
expect(tree.exists('apps/my-app/public/.gitkeep')).toBe(true);
});
describe('--appDir', () => {
it('should generate app directory instead of pages', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'css',
appDir: true,
});
const tsConfig = readJson(tree, 'apps/test-app/tsconfig.json');
expect(tsConfig.include).toEqual([
'**/*.ts',
'**/*.tsx',
'**/*.js',
'**/*.jsx',
'../../apps/test-app/.next/types/**/*.ts',
'../../dist/apps/test-app/.next/types/**/*.ts',
'next-env.d.ts',
]);
expect(tree.exists('apps/test-app/pages/styles.css')).toBeFalsy();
expect(tree.exists('apps/test-app/app/global.css')).toBeTruthy();
expect(tree.exists('apps/test-app/app/page.tsx')).toBeTruthy();
expect(tree.exists('apps/test-app/app/layout.tsx')).toBeTruthy();
expect(tree.exists('apps/test-app/app/api/hello/route.ts')).toBeTruthy();
expect(tree.exists('apps/test-app/app/page.module.css')).toBeTruthy();
expect(tree.exists('apps/test-app/public/favicon.ico')).toBeTruthy();
});
it('should add layout types correctly for standalone apps', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'css',
appDir: true,
rootProject: true,
});
const tsConfig = readJson(tree, 'tsconfig.json');
expect(tsConfig.include).toEqual([
'**/*.ts',
'**/*.tsx',
'**/*.js',
'**/*.jsx',
'.next/types/**/*.ts',
'dist/test-app/.next/types/**/*.ts',
'next-env.d.ts',
]);
});
it('should generate an unstyled component page', async () => {
await applicationGenerator(tree, {
name: 'testApp',
style: 'none',
appDir: true,
rootProject: true,
});
const content = tree.read('app/page.tsx').toString();
expect(content).not.toContain('import styles from');
expect(content).not.toContain('const StyledPage');
expect(content).not.toContain('className={styles.page}');
});
});
}); });

View File

@ -0,0 +1,21 @@
import './global.<%= stylesExt %>';
import { StyledComponentsRegistry } from './registry';
export const metadata = {
title: 'Welcome to demo2',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
);
}

View File

@ -0,0 +1,33 @@
'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
// Types are out of date, clearTag is not defined.
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/65021
(styledComponentsStyleSheet.instance as any).clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}

View File

@ -0,0 +1,16 @@
import './global.<%= stylesExt %>';
import { StyledJsxRegistry } from './registry';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<StyledJsxRegistry>{children}</StyledJsxRegistry>
</body>
</html>
);
}

View File

@ -0,0 +1,23 @@
'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { StyleRegistry, createStyleRegistry } from 'styled-jsx';
export function StyledJsxRegistry({
children,
}: {
children: React.ReactNode;
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [jsxStyleRegistry] = useState(() => createStyleRegistry());
useServerInsertedHTML(() => {
const styles = jsxStyleRegistry.styles();
jsxStyleRegistry.flush();
return <>{styles}</>;
});
return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>;
}

View File

@ -62,6 +62,17 @@ const nextConfig = {
// See: https://github.com/gregberge/svgr // See: https://github.com/gregberge/svgr
svgr: false, svgr: false,
}, },
<% if (style === 'styled-components') { %>
compiler: {
// For other options, see https://styled-components.com/docs/tooling#babel-plugin
styledComponents: true,
},
<% } else if (style === '@emotion/styled') { %>
compiler: {
// For other options, see https://nextjs.org/docs/architecture/nextjs-compiler#emotion
emotion: true,
},
<% } %>
}; };
const plugins = [ const plugins = [

View File

@ -65,6 +65,8 @@ export function createApplicationFiles(host: Tree, options: NormalizedSchema) {
join(options.appProjectRoot, 'app'), join(options.appProjectRoot, 'app'),
templateVariables templateVariables
); );
// RSC is not possible to unit test without extra helpers for data fetching. Leaving it to the user to figure out.
host.delete( host.delete(
joinPathFragments( joinPathFragments(
options.appProjectRoot, options.appProjectRoot,
@ -72,6 +74,29 @@ export function createApplicationFiles(host: Tree, options: NormalizedSchema) {
`index.spec.${options.js ? 'jsx' : 'tsx'}` `index.spec.${options.js ? 'jsx' : 'tsx'}`
) )
); );
if (options.style === 'styled-components') {
generateFiles(
host,
join(__dirname, '../files/app-styled-components'),
join(options.appProjectRoot, 'app'),
templateVariables
);
} else if (options.style === 'styled-jsx') {
generateFiles(
host,
join(__dirname, '../files/app-styled-jsx'),
join(options.appProjectRoot, 'app'),
templateVariables
);
} else {
generateFiles(
host,
join(__dirname, '../files/app-default-layout'),
join(options.appProjectRoot, 'app'),
templateVariables
);
}
} else { } else {
generateFiles( generateFiles(
host, host,

View File

@ -4,7 +4,7 @@ import { NormalizedSchema } from './normalize-options';
export function showPossibleWarnings(tree: Tree, options: NormalizedSchema) { export function showPossibleWarnings(tree: Tree, options: NormalizedSchema) {
if (options.style === '@emotion/styled' && options.appDir) { if (options.style === '@emotion/styled' && options.appDir) {
logger.warn( logger.warn(
`Emotion may not work with the App Router. See: https://beta.nextjs.org/docs/styling/css-in-js` `Emotion may not work with the App Router. See: https://nextjs.org/docs/app/building-your-application/styling/css-in-js`
); );
} }
} }