Skip to content

Commit 734989d

Browse files
Timertimneutkens
authored andcommitted
[Experimental] CSS Module Support (#9686)
* 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
1 parent bc7fd2d commit 734989d

30 files changed

Lines changed: 752 additions & 23 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import loaderUtils from 'loader-utils'
2+
import path from 'path'
3+
import webpack from 'webpack'
4+
5+
export function getCssModuleLocalIdent(
6+
context: webpack.loader.LoaderContext,
7+
_: any,
8+
exportName: string,
9+
options: object
10+
) {
11+
const relativePath = path.posix.relative(
12+
context.rootContext,
13+
context.resourcePath
14+
)
15+
16+
// Generate a more meaningful name (parent folder) when the user names the
17+
// file `index.module.css`.
18+
const fileNameOrFolder =
19+
relativePath.endsWith('index.module.css') &&
20+
relativePath !== 'pages/index.module.css'
21+
? '[folder]'
22+
: '[name]'
23+
24+
// Generate a hash to make the class name unique.
25+
const hash = loaderUtils.getHashDigest(
26+
Buffer.from(`filePath:${relativePath}#className:${exportName}`),
27+
'md5',
28+
'base64',
29+
5
30+
)
31+
32+
// Have webpack interpolate the `[folder]` or `[name]` to its real value.
33+
return loaderUtils
34+
.interpolateName(
35+
context,
36+
fileNameOrFolder + '_' + exportName + '__' + hash,
37+
options
38+
)
39+
.replace(
40+
// Webpack name interpolation returns `about.module_root__2oFM9` for
41+
// `.root {}` inside a file named `about.module.css`. Let's simplify
42+
// this.
43+
/\.module_/,
44+
'_'
45+
)
46+
}

packages/next/build/webpack/config/blocks/css/index.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import curry from 'lodash.curry'
22
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
33
import path from 'path'
4-
import { Configuration } from 'webpack'
4+
import webpack, { Configuration } from 'webpack'
55
import { loader } from '../../helpers'
66
import { ConfigurationContext, ConfigurationFn, pipe } from '../../utils'
7-
import { getGlobalImportError } from './messages'
7+
import { getCssModuleLocalIdent } from './getCssModuleLocalIdent'
8+
import { getGlobalImportError, getModuleImportError } from './messages'
89
import { getPostCssPlugins } from './plugins'
9-
import webpack from 'webpack'
1010

11-
function getStyleLoader({
11+
function getClientStyleLoader({
1212
isDevelopment,
1313
}: {
1414
isDevelopment: boolean
@@ -73,14 +73,93 @@ export const css = curry(async function css(
7373

7474
const fns: ConfigurationFn[] = []
7575

76+
const postCssPlugins = await getPostCssPlugins(ctx.rootDirectory)
77+
// CSS Modules support must be enabled on the server and client so the class
78+
// names are availble for SSR or Prerendering.
79+
fns.push(
80+
loader({
81+
oneOf: [
82+
{
83+
// CSS Modules should never have side effects. This setting will
84+
// allow unused CSS to be removed from the production build.
85+
// We ensure this by disallowing `:global()` CSS at the top-level
86+
// via the `pure` mode in `css-loader`.
87+
sideEffects: false,
88+
// CSS Modules are activated via this specific extension.
89+
test: /\.module\.css$/,
90+
// CSS Modules are only supported in the user's application. We're
91+
// not yet allowing CSS imports _within_ `node_modules`.
92+
issuer: {
93+
include: [ctx.rootDirectory],
94+
exclude: /node_modules/,
95+
},
96+
97+
use: ([
98+
// Add appropriate development more or production mode style
99+
// loader
100+
ctx.isClient &&
101+
getClientStyleLoader({ isDevelopment: ctx.isDevelopment }),
102+
103+
// Resolve CSS `@import`s and `url()`s
104+
{
105+
loader: require.resolve('css-loader'),
106+
options: {
107+
importLoaders: 1,
108+
sourceMap: true,
109+
onlyLocals: ctx.isServer,
110+
modules: {
111+
// Disallow global style exports so we can code-split CSS and
112+
// not worry about loading order.
113+
mode: 'pure',
114+
// Generate a friendly production-ready name so it's
115+
// reasonably understandable. The same name is used for
116+
// development.
117+
// TODO: Consider making production reduce this to a single
118+
// character?
119+
getLocalIdent: getCssModuleLocalIdent,
120+
},
121+
},
122+
},
123+
124+
// Compile CSS
125+
{
126+
loader: require.resolve('postcss-loader'),
127+
options: {
128+
ident: 'postcss',
129+
plugins: postCssPlugins,
130+
sourceMap: true,
131+
},
132+
},
133+
] as webpack.RuleSetUseItem[]).filter(Boolean),
134+
},
135+
],
136+
})
137+
)
138+
139+
// Throw an error for CSS Modules used outside their supported scope
140+
fns.push(
141+
loader({
142+
oneOf: [
143+
{
144+
test: /\.module\.css$/,
145+
use: {
146+
loader: 'error-loader',
147+
options: {
148+
reason: getModuleImportError(),
149+
},
150+
},
151+
},
152+
],
153+
})
154+
)
155+
76156
if (ctx.isServer) {
77157
fns.push(
78158
loader({
79159
oneOf: [{ test: /\.css$/, use: require.resolve('ignore-loader') }],
80160
})
81161
)
82162
} else if (ctx.customAppFile) {
83-
const postCssPlugins = await getPostCssPlugins(ctx.rootDirectory)
84163
fns.push(
85164
loader({
86165
oneOf: [
@@ -96,7 +175,7 @@ export const css = curry(async function css(
96175
use: [
97176
// Add appropriate development more or production mode style
98177
// loader
99-
getStyleLoader({ isDevelopment: ctx.isDevelopment }),
178+
getClientStyleLoader({ isDevelopment: ctx.isDevelopment }),
100179

101180
// Resolve CSS `@import`s and `url()`s
102181
{

packages/next/build/webpack/config/blocks/css/messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ export function getGlobalImportError(file: string | null) {
99
file ? file : 'pages/_app.js'
1010
)}.\nRead more: https://err.sh/next.js/global-css`
1111
}
12+
13+
export function getModuleImportError() {
14+
// TODO: Read more link
15+
return `CSS Modules ${chalk.bold(
16+
'cannot'
17+
)} be imported from within ${chalk.bold('node_modules')}.`
18+
}

packages/next/client/page-loader.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@ function supportsPreload(el) {
1111

1212
const hasPreload = supportsPreload(document.createElement('link'))
1313

14-
function preloadScript(url) {
14+
function preloadLink(url, resourceType) {
1515
const link = document.createElement('link')
1616
link.rel = 'preload'
1717
link.crossOrigin = process.crossOrigin
1818
link.href = url
19-
link.as = 'script'
19+
link.as = resourceType
20+
document.head.appendChild(link)
21+
}
22+
23+
function loadStyle(url) {
24+
const link = document.createElement('link')
25+
link.rel = 'stylesheet'
26+
link.crossOrigin = process.crossOrigin
27+
link.href = url
2028
document.head.appendChild(link)
2129
}
2230

@@ -105,6 +113,12 @@ export default class PageLoader {
105113
) {
106114
this.loadScript(d, route, false)
107115
}
116+
if (
117+
/\.css$/.test(d) &&
118+
!document.querySelector(`link[rel=stylesheet][href^="${d}"]`)
119+
) {
120+
loadStyle(d) // FIXME: handle failure
121+
}
108122
})
109123
this.loadRoute(route)
110124
this.loadingRoutes[route] = true
@@ -228,7 +242,7 @@ export default class PageLoader {
228242
// If not fall back to loading script tags before the page is loaded
229243
// https://caniuse.com/#feat=link-rel-preload
230244
if (hasPreload) {
231-
preloadScript(url)
245+
preloadLink(url, url.match(/\.css$/) ? 'style' : 'script')
232246
return
233247
}
234248

packages/next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"conf": "5.0.0",
8888
"content-type": "1.0.4",
8989
"cookie": "0.4.0",
90-
"css-loader": "3.2.0",
90+
"css-loader": "3.3.0",
9191
"cssnano-simple": "1.0.0",
9292
"devalue": "2.0.1",
9393
"etag": "1.8.1",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { redText } from './index.module.css'
2+
3+
export default function Home() {
4+
return (
5+
<div id="verify-red" className={redText}>
6+
This text should be red.
7+
</div>
8+
)
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.redText {
2+
color: red;
3+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { redText } from './index.module.css'
2+
3+
export default function Home() {
4+
return (
5+
<div id="verify-red" className={redText}>
6+
This text should be red.
7+
</div>
8+
)
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.redText {
2+
color: red;
3+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { redText } from './index.module.css'
2+
3+
function Home() {
4+
return (
5+
<>
6+
<div id="verify-red" className={redText}>
7+
This text should be red.
8+
</div>
9+
<br />
10+
<input key={'' + Math.random()} id="text-input" type="text" />
11+
</>
12+
)
13+
}
14+
15+
export default Home

0 commit comments

Comments
 (0)