diff --git a/packages/next/build/webpack/config/blocks/css/getCssModuleLocalIdent.ts b/packages/next/build/webpack/config/blocks/css/getCssModuleLocalIdent.ts
new file mode 100644
index 0000000000000..5e6d67ad22e0d
--- /dev/null
+++ b/packages/next/build/webpack/config/blocks/css/getCssModuleLocalIdent.ts
@@ -0,0 +1,46 @@
+import loaderUtils from 'loader-utils'
+import path from 'path'
+import webpack from 'webpack'
+
+export function getCssModuleLocalIdent(
+ context: webpack.loader.LoaderContext,
+ _: any,
+ exportName: string,
+ options: object
+) {
+ const relativePath = path.posix.relative(
+ context.rootContext,
+ context.resourcePath
+ )
+
+ // Generate a more meaningful name (parent folder) when the user names the
+ // file `index.module.css`.
+ const fileNameOrFolder =
+ relativePath.endsWith('index.module.css') &&
+ relativePath !== 'pages/index.module.css'
+ ? '[folder]'
+ : '[name]'
+
+ // Generate a hash to make the class name unique.
+ const hash = loaderUtils.getHashDigest(
+ Buffer.from(`filePath:${relativePath}#className:${exportName}`),
+ 'md5',
+ 'base64',
+ 5
+ )
+
+ // Have webpack interpolate the `[folder]` or `[name]` to its real value.
+ return loaderUtils
+ .interpolateName(
+ context,
+ fileNameOrFolder + '_' + exportName + '__' + hash,
+ options
+ )
+ .replace(
+ // Webpack name interpolation returns `about.module_root__2oFM9` for
+ // `.root {}` inside a file named `about.module.css`. Let's simplify
+ // this.
+ /\.module_/,
+ '_'
+ )
+}
diff --git a/packages/next/build/webpack/config/blocks/css/index.ts b/packages/next/build/webpack/config/blocks/css/index.ts
index 304450eab91c3..8ce0f6b5139a2 100644
--- a/packages/next/build/webpack/config/blocks/css/index.ts
+++ b/packages/next/build/webpack/config/blocks/css/index.ts
@@ -1,14 +1,14 @@
import curry from 'lodash.curry'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import path from 'path'
-import { Configuration } from 'webpack'
+import webpack, { Configuration } from 'webpack'
import { loader } from '../../helpers'
import { ConfigurationContext, ConfigurationFn, pipe } from '../../utils'
-import { getGlobalImportError } from './messages'
+import { getCssModuleLocalIdent } from './getCssModuleLocalIdent'
+import { getGlobalImportError, getModuleImportError } from './messages'
import { getPostCssPlugins } from './plugins'
-import webpack from 'webpack'
-function getStyleLoader({
+function getClientStyleLoader({
isDevelopment,
}: {
isDevelopment: boolean
@@ -73,6 +73,86 @@ export const css = curry(async function css(
const fns: ConfigurationFn[] = []
+ const postCssPlugins = await getPostCssPlugins(ctx.rootDirectory)
+ // CSS Modules support must be enabled on the server and client so the class
+ // names are availble for SSR or Prerendering.
+ fns.push(
+ loader({
+ oneOf: [
+ {
+ // CSS Modules should never have side effects. This setting will
+ // allow unused CSS to be removed from the production build.
+ // We ensure this by disallowing `:global()` CSS at the top-level
+ // via the `pure` mode in `css-loader`.
+ sideEffects: false,
+ // CSS Modules are activated via this specific extension.
+ test: /\.module\.css$/,
+ // CSS Modules are only supported in the user's application. We're
+ // not yet allowing CSS imports _within_ `node_modules`.
+ issuer: {
+ include: [ctx.rootDirectory],
+ exclude: /node_modules/,
+ },
+
+ use: ([
+ // Add appropriate development more or production mode style
+ // loader
+ ctx.isClient &&
+ getClientStyleLoader({ isDevelopment: ctx.isDevelopment }),
+
+ // Resolve CSS `@import`s and `url()`s
+ {
+ loader: require.resolve('css-loader'),
+ options: {
+ importLoaders: 1,
+ sourceMap: true,
+ onlyLocals: ctx.isServer,
+ modules: {
+ // Disallow global style exports so we can code-split CSS and
+ // not worry about loading order.
+ mode: 'pure',
+ // Generate a friendly production-ready name so it's
+ // reasonably understandable. The same name is used for
+ // development.
+ // TODO: Consider making production reduce this to a single
+ // character?
+ getLocalIdent: getCssModuleLocalIdent,
+ },
+ },
+ },
+
+ // Compile CSS
+ {
+ loader: require.resolve('postcss-loader'),
+ options: {
+ ident: 'postcss',
+ plugins: postCssPlugins,
+ sourceMap: true,
+ },
+ },
+ ] as webpack.RuleSetUseItem[]).filter(Boolean),
+ },
+ ],
+ })
+ )
+
+ // Throw an error for CSS Modules used outside their supported scope
+ fns.push(
+ loader({
+ oneOf: [
+ {
+ test: /\.module\.css$/,
+ use: {
+ loader: 'error-loader',
+ options: {
+ reason: getModuleImportError(),
+ },
+ },
+ },
+ ],
+ })
+ )
+
if (ctx.isServer) {
fns.push(
loader({
@@ -80,7 +160,6 @@ export const css = curry(async function css(
})
)
} else if (ctx.customAppFile) {
- const postCssPlugins = await getPostCssPlugins(ctx.rootDirectory)
fns.push(
loader({
oneOf: [
@@ -96,7 +175,7 @@ export const css = curry(async function css(
use: [
// Add appropriate development more or production mode style
// loader
- getStyleLoader({ isDevelopment: ctx.isDevelopment }),
+ getClientStyleLoader({ isDevelopment: ctx.isDevelopment }),
// Resolve CSS `@import`s and `url()`s
{
diff --git a/packages/next/build/webpack/config/blocks/css/messages.ts b/packages/next/build/webpack/config/blocks/css/messages.ts
index 461a37e0c0dee..ea18cab4cfb99 100644
--- a/packages/next/build/webpack/config/blocks/css/messages.ts
+++ b/packages/next/build/webpack/config/blocks/css/messages.ts
@@ -9,3 +9,10 @@ export function getGlobalImportError(file: string | null) {
file ? file : 'pages/_app.js'
)}.\nRead more: https://err.sh/next.js/global-css`
}
+
+export function getModuleImportError() {
+ // TODO: Read more link
+ return `CSS Modules ${chalk.bold(
+ 'cannot'
+ )} be imported from within ${chalk.bold('node_modules')}.`
+}
diff --git a/packages/next/client/page-loader.js b/packages/next/client/page-loader.js
index e02829bb4c7d3..de21e09997918 100644
--- a/packages/next/client/page-loader.js
+++ b/packages/next/client/page-loader.js
@@ -11,12 +11,20 @@ function supportsPreload(el) {
const hasPreload = supportsPreload(document.createElement('link'))
-function preloadScript(url) {
+function preloadLink(url, resourceType) {
const link = document.createElement('link')
link.rel = 'preload'
link.crossOrigin = process.crossOrigin
link.href = url
- link.as = 'script'
+ link.as = resourceType
+ document.head.appendChild(link)
+}
+
+function loadStyle(url) {
+ const link = document.createElement('link')
+ link.rel = 'stylesheet'
+ link.crossOrigin = process.crossOrigin
+ link.href = url
document.head.appendChild(link)
}
@@ -105,6 +113,12 @@ export default class PageLoader {
) {
this.loadScript(d, route, false)
}
+ if (
+ /\.css$/.test(d) &&
+ !document.querySelector(`link[rel=stylesheet][href^="${d}"]`)
+ ) {
+ loadStyle(d) // FIXME: handle failure
+ }
})
this.loadRoute(route)
this.loadingRoutes[route] = true
@@ -228,7 +242,7 @@ export default class PageLoader {
// If not fall back to loading script tags before the page is loaded
// https://caniuse.com/#feat=link-rel-preload
if (hasPreload) {
- preloadScript(url)
+ preloadLink(url, url.match(/\.css$/) ? 'style' : 'script')
return
}
diff --git a/packages/next/package.json b/packages/next/package.json
index ac877115f42e8..a87019c11879c 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -87,7 +87,7 @@
"conf": "5.0.0",
"content-type": "1.0.4",
"cookie": "0.4.0",
- "css-loader": "3.2.0",
+ "css-loader": "3.3.0",
"cssnano-simple": "1.0.0",
"devalue": "2.0.1",
"etag": "1.8.1",
diff --git a/test/integration/css/fixtures/basic-module/pages/index.js b/test/integration/css/fixtures/basic-module/pages/index.js
new file mode 100644
index 0000000000000..6065020a22b2f
--- /dev/null
+++ b/test/integration/css/fixtures/basic-module/pages/index.js
@@ -0,0 +1,9 @@
+import { redText } from './index.module.css'
+
+export default function Home() {
+ return (
+
+ This text should be red.
+
+ )
+}
diff --git a/test/integration/css/fixtures/basic-module/pages/index.module.css b/test/integration/css/fixtures/basic-module/pages/index.module.css
new file mode 100644
index 0000000000000..08a38e09ef8ea
--- /dev/null
+++ b/test/integration/css/fixtures/basic-module/pages/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: red;
+}
diff --git a/test/integration/css/fixtures/dev-module/pages/index.js b/test/integration/css/fixtures/dev-module/pages/index.js
new file mode 100644
index 0000000000000..6065020a22b2f
--- /dev/null
+++ b/test/integration/css/fixtures/dev-module/pages/index.js
@@ -0,0 +1,9 @@
+import { redText } from './index.module.css'
+
+export default function Home() {
+ return (
+
+ This text should be red.
+
+ )
+}
diff --git a/test/integration/css/fixtures/dev-module/pages/index.module.css b/test/integration/css/fixtures/dev-module/pages/index.module.css
new file mode 100644
index 0000000000000..08a38e09ef8ea
--- /dev/null
+++ b/test/integration/css/fixtures/dev-module/pages/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: red;
+}
diff --git a/test/integration/css/fixtures/hmr-module/pages/index.js b/test/integration/css/fixtures/hmr-module/pages/index.js
new file mode 100644
index 0000000000000..4acb2fdfcccab
--- /dev/null
+++ b/test/integration/css/fixtures/hmr-module/pages/index.js
@@ -0,0 +1,15 @@
+import { redText } from './index.module.css'
+
+function Home() {
+ return (
+ <>
+
+ This text should be red.
+
+
+
+ >
+ )
+}
+
+export default Home
diff --git a/test/integration/css/fixtures/hmr-module/pages/index.module.css b/test/integration/css/fixtures/hmr-module/pages/index.module.css
new file mode 100644
index 0000000000000..08a38e09ef8ea
--- /dev/null
+++ b/test/integration/css/fixtures/hmr-module/pages/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: red;
+}
diff --git a/test/integration/css/fixtures/invalid-module/node_modules/example/index.js b/test/integration/css/fixtures/invalid-module/node_modules/example/index.js
new file mode 100644
index 0000000000000..bd7647eec353d
--- /dev/null
+++ b/test/integration/css/fixtures/invalid-module/node_modules/example/index.js
@@ -0,0 +1 @@
+module.exports = require('./index.module.css')
diff --git a/test/integration/css/fixtures/invalid-module/node_modules/example/index.mjs b/test/integration/css/fixtures/invalid-module/node_modules/example/index.mjs
new file mode 100644
index 0000000000000..b817132edeb65
--- /dev/null
+++ b/test/integration/css/fixtures/invalid-module/node_modules/example/index.mjs
@@ -0,0 +1 @@
+export * from './index.module.css'
diff --git a/test/integration/css/fixtures/invalid-module/node_modules/example/index.module.css b/test/integration/css/fixtures/invalid-module/node_modules/example/index.module.css
new file mode 100644
index 0000000000000..f77fe0ef0bdbe
--- /dev/null
+++ b/test/integration/css/fixtures/invalid-module/node_modules/example/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: 'red';
+}
diff --git a/test/integration/css/fixtures/invalid-module/node_modules/example/package.json b/test/integration/css/fixtures/invalid-module/node_modules/example/package.json
new file mode 100644
index 0000000000000..ab5f2cd4a1cf7
--- /dev/null
+++ b/test/integration/css/fixtures/invalid-module/node_modules/example/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "example",
+ "main": "index"
+}
diff --git a/test/integration/css/fixtures/invalid-module/pages/index.js b/test/integration/css/fixtures/invalid-module/pages/index.js
new file mode 100644
index 0000000000000..83fa4e1381b64
--- /dev/null
+++ b/test/integration/css/fixtures/invalid-module/pages/index.js
@@ -0,0 +1,7 @@
+import * as classes from 'example'
+
+function Home() {
+ return This should fail at build time {JSON.stringify(classes)}.
+}
+
+export default Home
diff --git a/test/integration/css/fixtures/multi-module/pages/blue.js b/test/integration/css/fixtures/multi-module/pages/blue.js
new file mode 100644
index 0000000000000..49dff85067fa3
--- /dev/null
+++ b/test/integration/css/fixtures/multi-module/pages/blue.js
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+import { blueText } from './blue.module.css'
+
+export default function Blue() {
+ return (
+ <>
+
+ This text should be blue.
+
+
+
+ Red
+
+
+
+ None
+
+ >
+ )
+}
diff --git a/test/integration/css/fixtures/multi-module/pages/blue.module.css b/test/integration/css/fixtures/multi-module/pages/blue.module.css
new file mode 100644
index 0000000000000..4c5ac28ce4ef8
--- /dev/null
+++ b/test/integration/css/fixtures/multi-module/pages/blue.module.css
@@ -0,0 +1,3 @@
+.blueText {
+ color: blue;
+}
diff --git a/test/integration/css/fixtures/multi-module/pages/none.js b/test/integration/css/fixtures/multi-module/pages/none.js
new file mode 100644
index 0000000000000..f26c2d80b3ae0
--- /dev/null
+++ b/test/integration/css/fixtures/multi-module/pages/none.js
@@ -0,0 +1,19 @@
+import Link from 'next/link'
+
+export default function None() {
+ return (
+ <>
+
+ This text should be black.
+
+
+
+ Red
+
+
+
+ Blue
+
+ >
+ )
+}
diff --git a/test/integration/css/fixtures/multi-module/pages/red.js b/test/integration/css/fixtures/multi-module/pages/red.js
new file mode 100644
index 0000000000000..d67276dda2d76
--- /dev/null
+++ b/test/integration/css/fixtures/multi-module/pages/red.js
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+import { redText } from './red.module.css'
+
+export default function Red() {
+ return (
+ <>
+
+ This text should be red.
+
+
+
+ Blue
+
+
+
+ None
+
+ >
+ )
+}
diff --git a/test/integration/css/fixtures/multi-module/pages/red.module.css b/test/integration/css/fixtures/multi-module/pages/red.module.css
new file mode 100644
index 0000000000000..08a38e09ef8ea
--- /dev/null
+++ b/test/integration/css/fixtures/multi-module/pages/red.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: red;
+}
diff --git a/test/integration/css/fixtures/nm-module/node_modules/example/index.js b/test/integration/css/fixtures/nm-module/node_modules/example/index.js
new file mode 100644
index 0000000000000..7d1fdcb8879d9
--- /dev/null
+++ b/test/integration/css/fixtures/nm-module/node_modules/example/index.js
@@ -0,0 +1,3 @@
+const message = 'Why hello there'
+
+module.exports = { message }
diff --git a/test/integration/css/fixtures/nm-module/node_modules/example/index.mjs b/test/integration/css/fixtures/nm-module/node_modules/example/index.mjs
new file mode 100644
index 0000000000000..a81498ed3ad53
--- /dev/null
+++ b/test/integration/css/fixtures/nm-module/node_modules/example/index.mjs
@@ -0,0 +1 @@
+export const message = 'Why hello there'
diff --git a/test/integration/css/fixtures/nm-module/node_modules/example/index.module.css b/test/integration/css/fixtures/nm-module/node_modules/example/index.module.css
new file mode 100644
index 0000000000000..f77fe0ef0bdbe
--- /dev/null
+++ b/test/integration/css/fixtures/nm-module/node_modules/example/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: 'red';
+}
diff --git a/test/integration/css/fixtures/nm-module/node_modules/example/package.json b/test/integration/css/fixtures/nm-module/node_modules/example/package.json
new file mode 100644
index 0000000000000..ab5f2cd4a1cf7
--- /dev/null
+++ b/test/integration/css/fixtures/nm-module/node_modules/example/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "example",
+ "main": "index"
+}
diff --git a/test/integration/css/fixtures/nm-module/pages/index.js b/test/integration/css/fixtures/nm-module/pages/index.js
new file mode 100644
index 0000000000000..00b262300efc5
--- /dev/null
+++ b/test/integration/css/fixtures/nm-module/pages/index.js
@@ -0,0 +1,12 @@
+import * as data from 'example'
+import * as classes from 'example/index.module.css'
+
+function Home() {
+ return (
+
+ {JSON.stringify(data)} {JSON.stringify(classes)}
+
+ )
+}
+
+export default Home
diff --git a/test/integration/css/fixtures/prod-module/pages/index.js b/test/integration/css/fixtures/prod-module/pages/index.js
new file mode 100644
index 0000000000000..6065020a22b2f
--- /dev/null
+++ b/test/integration/css/fixtures/prod-module/pages/index.js
@@ -0,0 +1,9 @@
+import { redText } from './index.module.css'
+
+export default function Home() {
+ return (
+
+ This text should be red.
+
+ )
+}
diff --git a/test/integration/css/fixtures/prod-module/pages/index.module.css b/test/integration/css/fixtures/prod-module/pages/index.module.css
new file mode 100644
index 0000000000000..08a38e09ef8ea
--- /dev/null
+++ b/test/integration/css/fixtures/prod-module/pages/index.module.css
@@ -0,0 +1,3 @@
+.redText {
+ color: red;
+}
diff --git a/test/integration/css/test/index.test.js b/test/integration/css/test/index.test.js
index fe94594d59275..7526cd7e450af 100644
--- a/test/integration/css/test/index.test.js
+++ b/test/integration/css/test/index.test.js
@@ -18,7 +18,7 @@ import webdriver from 'next-webdriver'
import escapeStringRegexp from 'escape-string-regexp'
import cheerio from 'cheerio'
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 3
const fixturesDir = join(__dirname, '..', 'fixtures')
@@ -800,4 +800,421 @@ describe('CSS Support', () => {
expect(cssMapFiles.length).toBe(1)
})
})
+
+ describe('Basic CSS Module Support', () => {
+ const appDir = join(fixturesDir, 'basic-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ await nextBuild(appDir)
+ const server = nextServer({
+ dir: appDir,
+ dev: false,
+ quiet: true,
+ })
+
+ app = await startApp(server)
+ appPort = app.address().port
+ })
+ afterAll(async () => {
+ await stopApp(app)
+ })
+
+ it(`should've emitted a single CSS file`, async () => {
+ const cssFolder = join(appDir, '.next/static/css')
+
+ const files = await readdir(cssFolder)
+ const cssFiles = files.filter(f => /\.css$/.test(f))
+
+ expect(cssFiles.length).toBe(1)
+ const cssContent = await readFile(join(cssFolder, cssFiles[0]), 'utf8')
+
+ expect(
+ cssContent.replace(/\/\*.*?\*\//g, '').trim()
+ ).toMatchInlineSnapshot(`".index_redText__3CwEB{color:red}"`)
+ })
+
+ it(`should've injected the CSS on server render`, async () => {
+ const content = await renderViaHTTP(appPort, '/')
+ const $ = cheerio.load(content)
+
+ const cssPreload = $('link[rel="preload"][as="style"]')
+ expect(cssPreload.length).toBe(1)
+ expect(cssPreload.attr('href')).toMatch(/^\/_next\/static\/css\/.*\.css$/)
+
+ const cssSheet = $('link[rel="stylesheet"]')
+ expect(cssSheet.length).toBe(1)
+ expect(cssSheet.attr('href')).toMatch(/^\/_next\/static\/css\/.*\.css$/)
+
+ expect($('#verify-red').attr('class')).toMatchInlineSnapshot(
+ `"index_redText__3CwEB"`
+ )
+ })
+ })
+
+ describe('Has CSS Module in computed styles in Development', () => {
+ const appDir = join(fixturesDir, 'dev-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ appPort = await findPort()
+ app = await launchApp(appDir, appPort)
+ })
+ afterAll(async () => {
+ await killApp(app)
+ })
+
+ it('should have CSS for page', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/')
+
+ const currentColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
+
+ describe('Has CSS Module in computed styles in Production', () => {
+ const appDir = join(fixturesDir, 'prod-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ await nextBuild(appDir)
+ const server = nextServer({
+ dir: appDir,
+ dev: false,
+ quiet: true,
+ })
+
+ app = await startApp(server)
+ appPort = app.address().port
+ })
+ afterAll(async () => {
+ await stopApp(app)
+ })
+
+ it('should have CSS for page', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/')
+
+ const currentColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
+
+ describe('Can hot reload CSS Module without losing state', () => {
+ const appDir = join(fixturesDir, 'hmr-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ appPort = await findPort()
+ app = await launchApp(appDir, appPort)
+ })
+ afterAll(async () => {
+ await killApp(app)
+ })
+
+ // FIXME: this is broken
+ it.skip('should update CSS color without remounting ', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/')
+ await waitFor(2000) // ensure application hydrates
+
+ const desiredText = 'hello world'
+ await browser.elementById('text-input').type(desiredText)
+ expect(await browser.elementById('text-input').getValue()).toBe(
+ desiredText
+ )
+
+ const currentColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(currentColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+
+ const cssFile = new File(join(appDir, 'pages/index.module.css'))
+ try {
+ cssFile.replace('color: red', 'color: purple')
+ await waitFor(2000) // wait for HMR
+
+ const refreshedColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(refreshedColor).toMatchInlineSnapshot(`"rgb(128, 0, 128)"`)
+
+ // ensure text remained
+ expect(await browser.elementById('text-input').getValue()).toBe(
+ desiredText
+ )
+ } finally {
+ cssFile.restore()
+ }
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
+
+ describe('Invalid CSS Module Usage in node_modules', () => {
+ const appDir = join(fixturesDir, 'invalid-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ it('should fail to build', async () => {
+ const { stderr } = await nextBuild(appDir, [], {
+ stderr: true,
+ })
+ expect(stderr).toContain('Failed to compile')
+ expect(stderr).toContain('node_modules/example/index.module.css')
+ expect(stderr).toMatch(
+ /CSS Modules.*cannot.*be imported from within.*node_modules/
+ )
+ })
+ })
+
+ describe('Valid CSS Module Usage from within node_modules', () => {
+ const appDir = join(fixturesDir, 'nm-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ await nextBuild(appDir)
+ const server = nextServer({
+ dir: appDir,
+ dev: false,
+ quiet: true,
+ })
+
+ app = await startApp(server)
+ appPort = app.address().port
+ })
+ afterAll(async () => {
+ await stopApp(app)
+ })
+
+ it(`should've prerendered with relevant data`, async () => {
+ const content = await renderViaHTTP(appPort, '/')
+ const $ = cheerio.load(content)
+
+ const cssPreload = $('#nm-div')
+ expect(cssPreload.text()).toMatchInlineSnapshot(
+ `"{\\"message\\":\\"Why hello there\\"} {\\"redText\\":\\"example_redText__1rb5g\\"}"`
+ )
+ })
+
+ it(`should've emitted a single CSS file`, async () => {
+ const cssFolder = join(appDir, '.next/static/css')
+
+ const files = await readdir(cssFolder)
+ const cssFiles = files.filter(f => /\.css$/.test(f))
+
+ expect(cssFiles.length).toBe(1)
+ const cssContent = await readFile(join(cssFolder, cssFiles[0]), 'utf8')
+
+ expect(
+ cssContent.replace(/\/\*.*?\*\//g, '').trim()
+ ).toMatchInlineSnapshot(`".example_redText__1rb5g{color:\\"red\\"}"`)
+ })
+ })
+
+ describe('CSS Module client-side navigation in Production', () => {
+ const appDir = join(fixturesDir, 'multi-module')
+
+ beforeAll(async () => {
+ await remove(join(appDir, '.next'))
+ })
+
+ let appPort
+ let app
+ beforeAll(async () => {
+ await nextBuild(appDir)
+ const server = nextServer({
+ dir: appDir,
+ dev: false,
+ quiet: true,
+ })
+
+ app = await startApp(server)
+ appPort = app.address().port
+ })
+ afterAll(async () => {
+ await stopApp(app)
+ })
+
+ it('should be able to client-side navigate from red to blue', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/red')
+
+ await browser.eval(`window.__did_not_ssr = 'make sure this is set'`)
+
+ const redColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(redColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+
+ await browser.elementByCss('#link-blue').click()
+
+ await browser.waitForElementByCss('#verify-blue')
+
+ const blueColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-blue')).color`
+ )
+ expect(blueColor).toMatchInlineSnapshot(`"rgb(0, 0, 255)"`)
+
+ expect(
+ await browser.eval(`window.__did_not_ssr`)
+ ).toMatchInlineSnapshot(`"make sure this is set"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+
+ it('should be able to client-side navigate from blue to red', async () => {
+ const content = await renderViaHTTP(appPort, '/blue')
+ const $ = cheerio.load(content)
+
+ // Ensure only `/blue` page's CSS is preloaded
+ const serverCssPreloads = $('link[rel="preload"][as="style"]')
+ expect(serverCssPreloads.length).toBe(1)
+
+ let browser
+ try {
+ browser = await webdriver(appPort, '/blue')
+
+ await waitFor(2000) // Ensure hydration
+
+ await browser.eval(`window.__did_not_ssr = 'make sure this is set'`)
+
+ const redColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-blue')).color`
+ )
+ expect(redColor).toMatchInlineSnapshot(`"rgb(0, 0, 255)"`)
+
+ // Check that Red was preloaded
+ const result = await browser.eval(
+ `[].slice.call(document.querySelectorAll('link[rel="preload"][as="style"]')).map(e=>({href:e.href})).sort()`
+ )
+ expect(result.length).toBe(2)
+
+ // Check that CSS was not loaded as script
+ const cssPreloads = await browser.eval(
+ `[].slice.call(document.querySelectorAll('link[rel=preload][href*=".css"]')).map(e=>e.as)`
+ )
+ expect(cssPreloads.every(e => e === 'style')).toBe(true)
+
+ await browser.elementByCss('#link-red').click()
+
+ await browser.waitForElementByCss('#verify-red')
+
+ const blueColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(blueColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+
+ expect(
+ await browser.eval(`window.__did_not_ssr`)
+ ).toMatchInlineSnapshot(`"make sure this is set"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+
+ it('should be able to client-side navigate from none to red', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/none')
+
+ await browser.eval(`window.__did_not_ssr = 'make sure this is set'`)
+
+ await browser.elementByCss('#link-red').click()
+ await browser.waitForElementByCss('#verify-red')
+
+ const blueColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-red')).color`
+ )
+ expect(blueColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
+
+ expect(
+ await browser.eval(`window.__did_not_ssr`)
+ ).toMatchInlineSnapshot(`"make sure this is set"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+
+ it('should be able to client-side navigate from none to blue', async () => {
+ let browser
+ try {
+ browser = await webdriver(appPort, '/none')
+
+ await browser.eval(`window.__did_not_ssr = 'make sure this is set'`)
+
+ await browser.elementByCss('#link-blue').click()
+ await browser.waitForElementByCss('#verify-blue')
+
+ const blueColor = await browser.eval(
+ `window.getComputedStyle(document.querySelector('#verify-blue')).color`
+ )
+ expect(blueColor).toMatchInlineSnapshot(`"rgb(0, 0, 255)"`)
+
+ expect(
+ await browser.eval(`window.__did_not_ssr`)
+ ).toMatchInlineSnapshot(`"make sure this is set"`)
+ } finally {
+ if (browser) {
+ await browser.close()
+ }
+ }
+ })
+ })
})
diff --git a/yarn.lock b/yarn.lock
index 6402ec2fcbe1e..a2a9103bba870 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5420,23 +5420,23 @@ css-loader@1.0.0:
postcss-value-parser "^3.3.0"
source-list-map "^2.0.0"
-css-loader@3.2.0:
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.2.0.tgz#bb570d89c194f763627fcf1f80059c6832d009b2"
- integrity sha512-QTF3Ud5H7DaZotgdcJjGMvyDj5F3Pn1j/sC6VBEOVp94cbwqyIBdcs/quzj4MC1BKQSrTpQznegH/5giYbhnCQ==
+css-loader@3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.3.0.tgz#65f889807baec3197313965d6cda9899f936734d"
+ integrity sha512-x9Y1vvHe5RR+4tzwFdWExPueK00uqFTCw7mZy+9aE/X1SKWOArm5luaOrtJ4d05IpOwJ6S86b/tVcIdhw1Bu4A==
dependencies:
camelcase "^5.3.1"
cssesc "^3.0.0"
icss-utils "^4.1.1"
loader-utils "^1.2.3"
normalize-path "^3.0.0"
- postcss "^7.0.17"
+ postcss "^7.0.23"
postcss-modules-extract-imports "^2.0.0"
postcss-modules-local-by-default "^3.0.2"
- postcss-modules-scope "^2.1.0"
+ postcss-modules-scope "^2.1.1"
postcss-modules-values "^3.0.0"
- postcss-value-parser "^4.0.0"
- schema-utils "^2.0.0"
+ postcss-value-parser "^4.0.2"
+ schema-utils "^2.6.0"
css-prefers-color-scheme@^3.1.1:
version "3.1.1"
@@ -12213,10 +12213,10 @@ postcss-modules-scope@^1.1.0:
css-selector-tokenizer "^0.7.0"
postcss "^6.0.1"
-postcss-modules-scope@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.0.tgz#ad3f5bf7856114f6fcab901b0502e2a2bc39d4eb"
- integrity sha512-91Rjps0JnmtUB0cujlc8KIKCsJXWjzuxGeT/+Q2i2HXKZ7nBUeF9YQTZZTNvHVoNYj1AthsjnGLtqDUE0Op79A==
+postcss-modules-scope@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.1.tgz#33d4fc946602eb5e9355c4165d68a10727689dba"
+ integrity sha512-OXRUPecnHCg8b9xWvldG/jUpRIGPNRka0r4D4j0ESUU2/5IOnpsjfPPmDprM3Ih8CgZ8FXjWqaniK5v4rWt3oQ==
dependencies:
postcss "^7.0.6"
postcss-selector-parser "^6.0.0"
@@ -13759,6 +13759,14 @@ schema-utils@^2.0.0, schema-utils@^2.0.1:
ajv "^6.10.2"
ajv-keywords "^3.4.1"
+schema-utils@^2.6.0:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.1.tgz#eb78f0b945c7bcfa2082b3565e8db3548011dc4f"
+ integrity sha512-0WXHDs1VDJyo+Zqs9TKLKyD/h7yDpHUhEFsM2CzkICFdoX1av+GBq/J2xRTFfsQO5kBfhZzANf2VcIm84jqDbg==
+ dependencies:
+ ajv "^6.10.2"
+ ajv-keywords "^3.4.1"
+
scss-tokenizer@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"