Skip to content

Commit

Permalink
[Experimental] CSS Module Support (#9686)
Browse files Browse the repository at this point in the history
* CSS Module Support

* Fix Server-Side Render of CSS Modules

* Fix Jest Snapshots
jestjs/jest#8492

* Fix snapshots

* Add test for CSS module edit without remounting

* Add tests for dev and production style being applied

* Add missing TODOs

* Include/exclude should only be applied to issuer, not the CSS file itself

* Add CSS modules + node_modules tests

* Test that content is correct

* Create Multi Module Suite

* Add client-side navigation support for CSS

* Add tests for client-side nav

* Add some delays

* Try another fix

* Increase timeout to 3 minutes

* Fix test

* Give all unique directories
  • Loading branch information
Timer authored and timneutkens committed Dec 11, 2019
1 parent bc7fd2d commit 734989d
Show file tree
Hide file tree
Showing 30 changed files with 752 additions and 23 deletions.
@@ -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_/,
'_'
)
}
91 changes: 85 additions & 6 deletions 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
Expand Down Expand Up @@ -73,14 +73,93 @@ 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({
oneOf: [{ test: /\.css$/, use: require.resolve('ignore-loader') }],
})
)
} else if (ctx.customAppFile) {
const postCssPlugins = await getPostCssPlugins(ctx.rootDirectory)
fns.push(
loader({
oneOf: [
Expand All @@ -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
{
Expand Down
7 changes: 7 additions & 0 deletions packages/next/build/webpack/config/blocks/css/messages.ts
Expand Up @@ -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')}.`
}
20 changes: 17 additions & 3 deletions packages/next/client/page-loader.js
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion packages/next/package.json
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions test/integration/css/fixtures/basic-module/pages/index.js
@@ -0,0 +1,9 @@
import { redText } from './index.module.css'

export default function Home() {
return (
<div id="verify-red" className={redText}>
This text should be red.
</div>
)
}
@@ -0,0 +1,3 @@
.redText {
color: red;
}
9 changes: 9 additions & 0 deletions test/integration/css/fixtures/dev-module/pages/index.js
@@ -0,0 +1,9 @@
import { redText } from './index.module.css'

export default function Home() {
return (
<div id="verify-red" className={redText}>
This text should be red.
</div>
)
}
@@ -0,0 +1,3 @@
.redText {
color: red;
}
15 changes: 15 additions & 0 deletions test/integration/css/fixtures/hmr-module/pages/index.js
@@ -0,0 +1,15 @@
import { redText } from './index.module.css'

function Home() {
return (
<>
<div id="verify-red" className={redText}>
This text should be red.
</div>
<br />
<input key={'' + Math.random()} id="text-input" type="text" />
</>
)
}

export default Home
@@ -0,0 +1,3 @@
.redText {
color: red;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions test/integration/css/fixtures/invalid-module/pages/index.js
@@ -0,0 +1,7 @@
import * as classes from 'example'

function Home() {
return <div>This should fail at build time {JSON.stringify(classes)}.</div>
}

export default Home
20 changes: 20 additions & 0 deletions 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 (
<>
<div id="verify-blue" className={blueText}>
This text should be blue.
</div>
<br />
<Link href="/red" prefetch>
<a id="link-red">Red</a>
</Link>
<br />
<Link href="/none" prefetch={false}>
<a id="link-none">None</a>
</Link>
</>
)
}
@@ -0,0 +1,3 @@
.blueText {
color: blue;
}
19 changes: 19 additions & 0 deletions test/integration/css/fixtures/multi-module/pages/none.js
@@ -0,0 +1,19 @@
import Link from 'next/link'

export default function None() {
return (
<>
<div id="verify-black" style={{ color: 'black' }}>
This text should be black.
</div>
<br />
<Link href="/red" prefetch={false}>
<a id="link-red">Red</a>
</Link>
<br />
<Link href="/blue" prefetch={false}>
<a id="link-blue">Blue</a>
</Link>
</>
)
}
20 changes: 20 additions & 0 deletions 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 (
<>
<div id="verify-red" className={redText}>
This text should be red.
</div>
<br />
<Link href="/blue" prefetch={false}>
<a id="link-blue">Blue</a>
</Link>
<br />
<Link href="/none" prefetch={false}>
<a id="link-none">None</a>
</Link>
</>
)
}
@@ -0,0 +1,3 @@
.redText {
color: red;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 734989d

Please sign in to comment.