Skip to content

Commit fdc1fe2

Browse files
GatsbyJS Botpieh
andauthored
fix(gatsby): fix some css HMR edge cases (#29839) (#29865)
* test(e2e-development-runtime): add test cases for various edge cases related to css HMR * hackity hack * Update packages/gatsby/src/utils/webpack/force-css-hmr-for-edge-cases.ts Co-authored-by: Ward Peeters <[email protected]> * add some comments with explanation Co-authored-by: Ward Peeters <[email protected]> (cherry picked from commit 52facaf) Co-authored-by: Michal Piechowiak <[email protected]>
1 parent e8a7e3b commit fdc1fe2

File tree

8 files changed

+318
-13
lines changed

8 files changed

+318
-13
lines changed

e2e-tests/development-runtime/cypress/integration/styling/plain-css.js

Lines changed: 161 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,179 @@ after(() => {
77
})
88

99
describe(`styling: plain css`, () => {
10-
beforeEach(() => {
10+
it(`initial styling is correct`, () => {
1111
cy.visit(`/styling/plain-css`).waitForRouteChange()
12-
})
1312

14-
it(`initial styling is correct`, () => {
1513
cy.getTestElement(`styled-element`).should(
1614
`have.css`,
1715
`color`,
1816
`rgb(255, 0, 0)`
1917
)
20-
})
2118

22-
it(`updates on change`, () => {
23-
cy.exec(
24-
`npm run update -- --file src/pages/styling/plain-css.css --replacements "red:blue" --exact`
19+
cy.getTestElement(`styled-element-that-is-not-styled-initially`).should(
20+
`have.css`,
21+
`color`,
22+
`rgb(0, 0, 0)`
2523
)
2624

27-
cy.waitForHmr()
28-
29-
cy.getTestElement(`styled-element`).should(
25+
cy.getTestElement(`styled-element-by-not-visited-template`).should(
3026
`have.css`,
3127
`color`,
32-
`rgb(0, 0, 255)`
28+
`rgb(255, 0, 0)`
3329
)
30+
31+
cy.getTestElement(
32+
`styled-element-that-is-not-styled-initially-by-not-visited-template`
33+
).should(`have.css`, `color`, `rgb(0, 0, 0)`)
34+
})
35+
36+
describe(`changing styles/imports imported by visited template`, () => {
37+
it(`updates on already imported css file change`, () => {
38+
// we don't want to visit page for each test - we want to visit once and then test HMR
39+
cy.window().then(win => {
40+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
41+
})
42+
43+
cy.exec(
44+
`npm run update -- --file src/pages/styling/plain-css.css --replacements "red:blue" --exact`
45+
)
46+
47+
cy.waitForHmr()
48+
49+
cy.getTestElement(`styled-element`).should(
50+
`have.css`,
51+
`color`,
52+
`rgb(0, 0, 255)`
53+
)
54+
})
55+
56+
it(`importing new css file result in styles being applied`, () => {
57+
// we don't want to visit page for each test - we want to visit once and then test HMR
58+
cy.window().then(win => {
59+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
60+
})
61+
62+
cy.exec(
63+
`npm run update -- --file src/pages/styling/plain-css.js --replacements "// UNCOMMENT-IN-TEST:/* IMPORT-TO-COMMENT-OUT-AGAIN */" --exact`
64+
)
65+
66+
cy.waitForHmr()
67+
68+
cy.getTestElement(`styled-element-that-is-not-styled-initially`).should(
69+
`have.css`,
70+
`color`,
71+
`rgb(255, 0, 0)`
72+
)
73+
})
74+
75+
it(`updating newly imported css file result in styles being applied`, () => {
76+
// we don't want to visit page for each test - we want to visit once and then test HMR
77+
cy.window().then(win => {
78+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
79+
})
80+
81+
cy.exec(
82+
`npm run update -- --file src/pages/styling/plain-css-not-imported-initially.css --replacements "red:green" --exact`
83+
)
84+
85+
cy.waitForHmr()
86+
87+
cy.getTestElement(`styled-element-that-is-not-styled-initially`).should(
88+
`have.css`,
89+
`color`,
90+
`rgb(0, 128, 0)`
91+
)
92+
})
93+
94+
it(`removing css import results in styles being removed`, () => {
95+
// we don't want to visit page for each test - we want to visit once and then test HMR
96+
cy.window().then(win => {
97+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
98+
})
99+
100+
cy.exec(
101+
`npm run update -- --file src/pages/styling/plain-css.js --replacements "/* IMPORT-TO-COMMENT-OUT-AGAIN */:// COMMENTED-AGAIN" --exact`
102+
)
103+
104+
cy.waitForHmr()
105+
106+
cy.getTestElement(`styled-element-that-is-not-styled-initially`).should(
107+
`have.css`,
108+
`color`,
109+
`rgb(0, 0, 0)`
110+
)
111+
})
112+
})
113+
114+
describe(`changing styles/imports imported by NOT visited template`, () => {
115+
it(`updates on already imported css file change by not visited template`, () => {
116+
// we don't want to visit page for each test - we want to visit once and then test HMR
117+
cy.window().then(win => {
118+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
119+
})
120+
121+
cy.exec(
122+
`npm run update -- --file src/pages/styling/not-visited-plain-css.css --replacements "red:blue" --exact`
123+
)
124+
125+
cy.waitForHmr()
126+
127+
cy.getTestElement(`styled-element-by-not-visited-template`).should(
128+
`have.css`,
129+
`color`,
130+
`rgb(0, 0, 255)`
131+
)
132+
})
133+
134+
it(`importing new css file result in styles being applied`, () => {
135+
// we don't want to visit page for each test - we want to visit once and then test HMR
136+
cy.window().then(win => {
137+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
138+
})
139+
140+
cy.exec(
141+
`npm run update -- --file src/pages/styling/not-visited-plain-css.js --replacements "// UNCOMMENT-IN-TEST:/* IMPORT-TO-COMMENT-OUT-AGAIN */" --exact`
142+
)
143+
144+
cy.waitForHmr()
145+
146+
cy.getTestElement(
147+
`styled-element-that-is-not-styled-initially-by-not-visited-template`
148+
).should(`have.css`, `color`, `rgb(255, 0, 0)`)
149+
})
150+
151+
it(`updating newly imported css file result in styles being applied`, () => {
152+
// we don't want to visit page for each test - we want to visit once and then test HMR
153+
cy.window().then(win => {
154+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
155+
})
156+
157+
cy.exec(
158+
`npm run update -- --file src/pages/styling/not-visited-plain-css-not-imported-initially.css --replacements "red:green" --exact`
159+
)
160+
161+
cy.waitForHmr()
162+
163+
cy.getTestElement(
164+
`styled-element-that-is-not-styled-initially-by-not-visited-template`
165+
).should(`have.css`, `color`, `rgb(0, 128, 0)`)
166+
})
167+
168+
it(`removing css import results in styles being removed`, () => {
169+
// we don't want to visit page for each test - we want to visit once and then test HMR
170+
cy.window().then(win => {
171+
cy.spy(win.console, `log`).as(`hmrConsoleLog`)
172+
})
173+
174+
cy.exec(
175+
`npm run update -- --file src/pages/styling/not-visited-plain-css.js --replacements "/* IMPORT-TO-COMMENT-OUT-AGAIN */:// COMMENTED-AGAIN" --exact`
176+
)
177+
178+
cy.waitForHmr()
179+
180+
cy.getTestElement(
181+
`styled-element-that-is-not-styled-initially-by-not-visited-template`
182+
).should(`have.css`, `color`, `rgb(0, 0, 0)`)
183+
})
34184
})
35185
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.not-visited-plain-css-not-imported-initially {
2+
color: red;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.not-visited-plain-css-test {
2+
color: red;
3+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as React from "react"
2+
3+
import "./not-visited-plain-css.css"
4+
// UNCOMMENT-IN-TEST import "./not-visited-plain-css-not-imported-initially.css"
5+
6+
export default function PlainCss() {
7+
return (
8+
<>
9+
<p>
10+
This content doesn't matter - we never visit this page in tests - but
11+
because we generate single global .css file, we want to test changing
12+
css files imported by this module (and also adding new css imports).css
13+
</p>
14+
<p>css imported by this template is tested in `./plain-css.js` page</p>
15+
</>
16+
)
17+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.plain-css-not-imported-initially {
2+
color: red;
3+
}
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
11
import * as React from "react"
22

33
import "./plain-css.css"
4+
// UNCOMMENT-IN-TEST import "./plain-css-not-imported-initially.css"
45

56
export default function PlainCss() {
67
return (
7-
<div data-testid="styled-element" className="plain-css-test">
8-
test
8+
<div style={{ color: `black` }}>
9+
<div data-testid="styled-element" className="plain-css-test">
10+
test
11+
</div>
12+
<div
13+
data-testid="styled-element-that-is-not-styled-initially"
14+
className="plain-css-not-imported-initially"
15+
>
16+
test
17+
</div>
18+
<div
19+
data-testid="styled-element-by-not-visited-template"
20+
className="not-visited-plain-css-test"
21+
>
22+
test
23+
</div>
24+
<div
25+
data-testid="styled-element-that-is-not-styled-initially-by-not-visited-template"
26+
className="not-visited-plain-css-not-imported-initially "
27+
>
28+
test
29+
</div>
930
</div>
1031
)
1132
}

packages/gatsby/src/utils/webpack.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createWebpackUtils } from "./webpack-utils"
1818
import { hasLocalEslint } from "./local-eslint-config-finder"
1919
import { getAbsolutePathForVirtualModule } from "./gatsby-webpack-virtual-modules"
2020
import { StaticQueryMapper } from "./webpack/static-query-mapper"
21+
import { ForceCssHMRForEdgeCases } from "./webpack/force-css-hmr-for-edge-cases"
2122
import { getBrowsersList } from "./browserslist"
2223
import { builtinModules } from "module"
2324

@@ -217,6 +218,7 @@ module.exports = async (
217218
configPlugins = configPlugins
218219
.concat([
219220
plugins.fastRefresh({ modulesThatUseGatsby }),
221+
new ForceCssHMRForEdgeCases(),
220222
plugins.hotModuleReplacement(),
221223
plugins.noEmitOnErrors(),
222224
plugins.eslintGraphqlSchemaReload(),
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Compiler, Module } from "webpack"
2+
3+
/**
4+
* This is total hack that is meant to handle:
5+
* - https://github.com/webpack-contrib/mini-css-extract-plugin/issues/706
6+
* - https://github.com/webpack-contrib/mini-css-extract-plugin/issues/708
7+
* The way it works it is looking up what HotModuleReplacementPlugin checks internally
8+
* and tricks it by checking up if any modules that uses mini-css-extract-plugin
9+
* changed or was newly added and then modifying blank.css hash.
10+
* blank.css is css module that is used by all pages and is there from the start
11+
* so changing hash of that _should_ ensure that:
12+
* - when new css is imported it will reload css
13+
* - when css imported by not loaded (by runtime) page template changes it will reload css
14+
*/
15+
export class ForceCssHMRForEdgeCases {
16+
private name: string
17+
private originalBlankCssHash: string
18+
private blankCssKey: string
19+
private hackCounter = 0
20+
private previouslySeenCss: Set<string> = new Set<string>()
21+
22+
constructor() {
23+
this.name = `ForceCssHMRForEdgeCases`
24+
}
25+
26+
apply(compiler: Compiler): void {
27+
compiler.hooks.thisCompilation.tap(this.name, compilation => {
28+
compilation.hooks.fullHash.tap(this.name, () => {
29+
const chunkGraph = compilation.chunkGraph
30+
const records = compilation.records
31+
32+
if (!records.chunkModuleHashes) {
33+
return
34+
}
35+
36+
const seenCssInThisCompilation = new Set<string>()
37+
/**
38+
* We will get list of css modules that are removed in this compilation
39+
* by starting with list of css used in last compilation and removing
40+
* all modules that are used in this one.
41+
*/
42+
const cssRemovedInThisCompilation = this.previouslySeenCss
43+
44+
let newOrUpdatedCss = false
45+
46+
for (const chunk of compilation.chunks) {
47+
const getModuleHash = (module: Module): string => {
48+
if (compilation.codeGenerationResults.has(module, chunk.runtime)) {
49+
return compilation.codeGenerationResults.getHash(
50+
module,
51+
chunk.runtime
52+
)
53+
} else {
54+
return chunkGraph.getModuleHash(module, chunk.runtime)
55+
}
56+
}
57+
58+
const modules = chunkGraph.getChunkModulesIterable(chunk)
59+
60+
if (modules !== undefined) {
61+
for (const module of modules) {
62+
const key = `${chunk.id}|${module.identifier()}`
63+
64+
if (
65+
!this.originalBlankCssHash &&
66+
module.rawRequest === `./blank.css`
67+
) {
68+
this.blankCssKey = key
69+
this.originalBlankCssHash =
70+
records.chunkModuleHashes[this.blankCssKey]
71+
}
72+
73+
const isUsingMiniCssExtract = module.loaders?.find(loader =>
74+
loader?.loader?.includes(`mini-css-extract-plugin`)
75+
)
76+
77+
if (isUsingMiniCssExtract) {
78+
seenCssInThisCompilation.add(key)
79+
cssRemovedInThisCompilation.delete(key)
80+
81+
const hash = getModuleHash(module)
82+
if (records.chunkModuleHashes[key] !== hash) {
83+
newOrUpdatedCss = true
84+
}
85+
}
86+
}
87+
}
88+
}
89+
90+
// If css file was edited or new css import was added (`newOrUpdatedCss`)
91+
// or if css import was removed (`cssRemovedInThisCompilation.size > 0`)
92+
// trick Webpack's HMR into thinking `blank.css` file changed.
93+
if (
94+
(newOrUpdatedCss || cssRemovedInThisCompilation.size > 0) &&
95+
this.originalBlankCssHash &&
96+
this.blankCssKey
97+
) {
98+
records.chunkModuleHashes[this.blankCssKey] =
99+
this.originalBlankCssHash + String(this.hackCounter++)
100+
}
101+
102+
this.previouslySeenCss = seenCssInThisCompilation
103+
})
104+
})
105+
}
106+
}

0 commit comments

Comments
 (0)