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"