Skip to content

Commit c3b9863

Browse files
committed
add injectCss option to avoid recreating components when only css changes
1 parent 7d9d71f commit c3b9863

File tree

2 files changed

+103
-10
lines changed

2 files changed

+103
-10
lines changed

lib/make-hot.js

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const defaultHotOptions = {
1717
// true, or no component will be hot reloaded (but there are a lot of edge
1818
// cases that HMR can't support correctly with accessors)
1919
acceptAccessors: true,
20+
// only inject CSS instead of recreating components when only CSS changes
21+
injectCss: true,
22+
// to mitigate FOUC between dispose (remove stylesheet) and accept
23+
cssEjectDelay: 100,
2024
}
2125

2226
const domAdapter = require.resolve('../runtime/proxy-adapter-dom.js')
@@ -50,7 +54,9 @@ const resolveImportAdapter = (
5054
const renderApplyHmr = ({
5155
id,
5256
cssId,
53-
options,
57+
nonCssHash,
58+
hotOptions, // object
59+
options, // serialized
5460
hotApi,
5561
adapterPath,
5662
importAdapterName,
@@ -78,18 +84,52 @@ export default $2
7884
compileData: ${compileData},
7985
compileOptions: ${compileOptions},
8086
cssId: ${quote(cssId)},
87+
nonCssHash: ${quote(nonCssHash)},
8188
})
8289
: $2;
90+
${
91+
// NOTE when doing CSS only voodoo, we have to inject the stylesheet as soon
92+
// as the component is loaded because Svelte normally do that when a component
93+
// is instantiated, but we might already have instances in the large when a
94+
// component is loaded with HMR
95+
hotOptions.injectCss && cssId
96+
? `
97+
if (typeof add_css !== 'undefined' && !document.getElementById(${quote(
98+
cssId
99+
)})) add_css();`
100+
: ``
101+
}
83102
`
84103

85-
const parseCssId = code => {
104+
// https://github.com/darkskyapp/string-hash/blob/master/index.js
105+
// (via https://github.com/sveltejs/svelte/blob/91d758e35b2b2154512ddd11e6b6d9d65708a99e/src/compiler/compile/utils/hash.ts#L2)
106+
const stringHashcode = str => {
107+
let hash = 5381
108+
let i = str.length
109+
while (i--) hash = ((hash << 5) - hash) ^ str.charCodeAt(i)
110+
return (hash >>> 0).toString(36)
111+
}
112+
113+
const parseCssId = (code, parseHash) => {
86114
// the regex matching is very pretty conservative 'cause I don't want to
87115
// match something else by error... I'm probably make it more lax if I have
88116
// to fix it 3 times in a single week ¯\_(ツ)_/¯
89117
let match = /^function add_css\(\) \{[\s\S]*?^}/m.exec(code)
90-
if (!match) return null
118+
if (!match) return {}
119+
const codeExceptCSS =
120+
code.slice(0, match.index) + code.slice(match.index + match[0].length)
121+
91122
match = /\bstyle\.id\s*=\s*(['"])([^'"]*)\1/.exec(match[0])
92-
return match ? match[2] : null
123+
const cssId = match ? match[2] : null
124+
125+
if (!parseHash || !cssId) return { cssId }
126+
127+
const cssHash = cssId.split('-')[1]
128+
const nonCssHash = stringHashcode(
129+
codeExceptCSS.replace(new RegExp('\\b' + cssHash + '\\b', 'g'), '')
130+
)
131+
132+
return { cssId, nonCssHash }
93133
}
94134

95135
// meta can be 'import.meta' or 'module'
@@ -139,9 +179,13 @@ const createMakeHot = (hotApi, { meta = 'import.meta', walk } = {}) => {
139179

140180
const { adapterPath, importAdapterName } = resolveImportAdapter(hotOptions)
141181

182+
const { cssId, nonCssHash } = parseCssId(compiledCode, hotOptions.injectCss)
183+
142184
const replacement = renderApplyHmr({
143185
id,
144-
cssId: parseCssId(compiledCode),
186+
cssId,
187+
nonCssHash,
188+
hotOptions,
145189
options,
146190
hotApi,
147191
adapterPath,

runtime/hot-api.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-env browser */
2+
13
import { createProxy, hasFatalError } from './proxy'
24

35
const logPrefix = '[HMR:Svelte]'
@@ -16,6 +18,30 @@ const domReload = () => {
1618
}
1719
}
1820

21+
const replaceCss = (previousId, newId) => {
22+
if (typeof document === 'undefined') return false
23+
if (!previousId) return false
24+
if (!newId) return false
25+
// svelte-xxx-style => svelte-xxx
26+
const previousClass = previousId.slice(0, -6)
27+
const newClass = newId.slice(0, -6)
28+
// eslint-disable-next-line no-undef
29+
document.querySelectorAll('.' + previousClass).forEach(el => {
30+
el.classList.remove(previousClass)
31+
el.classList.add(newClass)
32+
})
33+
return true
34+
}
35+
36+
const removeStylesheet = cssId => {
37+
if (cssId == null) return
38+
if (typeof document === 'undefined') return
39+
// eslint-disable-next-line no-undef
40+
const el = document.getElementById(cssId)
41+
if (el) el.remove()
42+
return
43+
}
44+
1945
const defaultArgs = {
2046
reload: domReload,
2147
}
@@ -33,6 +59,7 @@ function applyHmr(args) {
3359
const {
3460
id,
3561
cssId,
62+
nonCssHash,
3663
reload = domReload,
3764
// normalized hot API (must conform to rollup-plugin-hot)
3865
hot,
@@ -77,7 +104,17 @@ function applyHmr(args) {
77104
const r =
78105
existing || createProxy(ProxyAdapter, id, Component, hotOptions, canAccept)
79106

80-
r.update({ Component, hotOptions, canAccept })
107+
const cssOnly =
108+
hotOptions.injectCss &&
109+
existing &&
110+
nonCssHash &&
111+
existing.current.nonCssHash === nonCssHash
112+
113+
if (!cssOnly) {
114+
r.update({ Component })
115+
}
116+
117+
r.update({ hotOptions, canAccept, cssId, nonCssHash, cssOnly })
81118

82119
hot.dispose(data => {
83120
// handle previous fatal errors
@@ -91,16 +128,28 @@ function applyHmr(args) {
91128

92129
data.record = r
93130

94-
if (cssId != null && typeof document !== 'undefined') {
95-
// eslint-disable-next-line no-undef
96-
const el = document.getElementById(cssId)
97-
if (el) el.remove()
131+
if (r.current.cssId !== cssId) {
132+
if (hotOptions.cssEjectDelay) {
133+
setTimeout(() => removeStylesheet(cssId), hotOptions.cssEjectDelay)
134+
} else {
135+
removeStylesheet(cssId)
136+
}
98137
}
99138
})
100139

101140
if (canAccept) {
102141
hot.accept(async () => {
142+
const newCssId = r.current.cssId
143+
const cssChanged = newCssId !== cssId
144+
// ensure old style sheet has been removed by now
145+
if (cssChanged) removeStylesheet(cssId)
146+
// guard: css only change
147+
if (r.current.cssOnly && (!cssChanged || replaceCss(cssId, newCssId))) {
148+
return
149+
}
150+
103151
const success = await r.reload()
152+
104153
if (hasFatalError() || (!success && !hotOptions.optimistic)) {
105154
needsReload = true
106155
}

0 commit comments

Comments
 (0)