Skip to content

Commit 191c557

Browse files
authored
feat(gatsby-script): Handle duplicate script callbacks (#35708)
* feat(gatsby-script): Handle duplicate script callback * Test coverage for dev and prod runtime
1 parent eb59c93 commit 191c557

File tree

5 files changed

+220
-8
lines changed

5 files changed

+220
-8
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
describe(`duplicate scripts`, () => {
2+
beforeEach(() => {
3+
cy.visit(`/gatsby-script-duplicate-scripts/`)
4+
})
5+
6+
it(`should execute load callbacks of duplicate scripts`, () => {
7+
cy.get(`[data-on-load-result=duplicate-1]`).should(`have.length`, 1)
8+
cy.get(`[data-on-load-result=duplicate-2]`).should(`have.length`, 1)
9+
cy.get(`[data-on-load-result=duplicate-3]`).should(`have.length`, 1)
10+
})
11+
12+
it(`should execute error callbacks of duplicate scripts`, () => {
13+
cy.get(`[data-on-error-result=duplicate-1]`).should(`have.length`, 1)
14+
cy.get(`[data-on-error-result=duplicate-2]`).should(`have.length`, 1)
15+
cy.get(`[data-on-error-result=duplicate-3]`).should(`have.length`, 1)
16+
})
17+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { useState } from "react"
2+
import { Script } from "gatsby"
3+
import { scripts } from "../../gatsby-script-scripts"
4+
import { onLoad, onError } from "../utils/gatsby-script-callbacks"
5+
6+
const DuplicateScripts = () => {
7+
const [onLoadScriptLoaded, setOnLoadScriptLoaded] = useState(false)
8+
const [onErrorScriptLoaded, setOnErrorScriptLoaded] = useState(false)
9+
10+
return (
11+
<main>
12+
<h1>Script component e2e test</h1>
13+
14+
<Script
15+
src={scripts.marked}
16+
onLoad={() => {
17+
onLoad(`duplicate-1`)
18+
}}
19+
/>
20+
<Script
21+
src={scripts.marked}
22+
onLoad={() => {
23+
onLoad(`duplicate-2`)
24+
setOnLoadScriptLoaded(true)
25+
}}
26+
/>
27+
{onLoadScriptLoaded && (
28+
<Script
29+
src={scripts.marked}
30+
onLoad={() => {
31+
onLoad(`duplicate-3`)
32+
}}
33+
/>
34+
)}
35+
36+
<Script
37+
src="/non-existent-script.js"
38+
onError={() => {
39+
onError(`duplicate-1`)
40+
}}
41+
/>
42+
<Script
43+
src="/non-existent-script.js"
44+
onError={() => {
45+
onError(`duplicate-2`)
46+
setOnErrorScriptLoaded(true)
47+
}}
48+
/>
49+
{onErrorScriptLoaded && (
50+
<Script
51+
src="/non-existent-script.js"
52+
onError={() => {
53+
onError(`duplicate-3`)
54+
}}
55+
/>
56+
)}
57+
</main>
58+
)
59+
}
60+
61+
export default DuplicateScripts
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Cypress.config(`defaultCommandTimeout`, 30000) // Since we're asserting network requests
2+
3+
describe(`duplicate scripts`, () => {
4+
beforeEach(() => {
5+
cy.visit(`/gatsby-script-duplicate-scripts/`)
6+
})
7+
8+
it(`should execute load callbacks of duplicate scripts`, () => {
9+
cy.get(`[data-on-load-result=duplicate-1]`).should(`have.length`, 1)
10+
cy.get(`[data-on-load-result=duplicate-2]`).should(`have.length`, 1)
11+
cy.get(`[data-on-load-result=duplicate-3]`).should(`have.length`, 1)
12+
})
13+
14+
it(`should execute error callbacks of duplicate scripts`, () => {
15+
cy.get(`[data-on-error-result=duplicate-1]`).should(`have.length`, 1)
16+
cy.get(`[data-on-error-result=duplicate-2]`).should(`have.length`, 1)
17+
cy.get(`[data-on-error-result=duplicate-3]`).should(`have.length`, 1)
18+
})
19+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { useState } from "react"
2+
import { Script } from "gatsby"
3+
import { scripts } from "../../gatsby-script-scripts"
4+
import { onLoad, onError } from "../utils/gatsby-script-callbacks"
5+
6+
const DuplicateScripts = () => {
7+
const [onLoadScriptLoaded, setOnLoadScriptLoaded] = useState(false)
8+
const [onErrorScriptLoaded, setOnErrorScriptLoaded] = useState(false)
9+
10+
return (
11+
<main>
12+
<h1>Script component e2e test</h1>
13+
14+
<Script
15+
src={scripts.marked}
16+
onLoad={() => {
17+
onLoad(`duplicate-1`)
18+
}}
19+
/>
20+
<Script
21+
src={scripts.marked}
22+
onLoad={() => {
23+
onLoad(`duplicate-2`)
24+
setOnLoadScriptLoaded(true)
25+
}}
26+
/>
27+
{onLoadScriptLoaded && (
28+
<Script
29+
src={scripts.marked}
30+
onLoad={() => {
31+
onLoad(`duplicate-3`)
32+
}}
33+
/>
34+
)}
35+
36+
<Script
37+
src="/non-existent-script.js"
38+
onError={() => {
39+
onError(`duplicate-1`)
40+
}}
41+
/>
42+
<Script
43+
src="/non-existent-script.js"
44+
onError={() => {
45+
onError(`duplicate-2`)
46+
setOnErrorScriptLoaded(true)
47+
}}
48+
/>
49+
{onErrorScriptLoaded && (
50+
<Script
51+
src="/non-existent-script.js"
52+
onError={() => {
53+
onError(`duplicate-3`)
54+
}}
55+
/>
56+
)}
57+
</main>
58+
)
59+
}
60+
61+
export default DuplicateScripts

packages/gatsby-script/src/gatsby-script.tsx

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,20 @@ const handledProps = new Set([
2929
`onError`,
3030
])
3131

32-
export const scriptCache = new Set()
32+
export const scriptCache: Set<string> = new Set()
33+
export const scriptCallbackCache: Map<
34+
string,
35+
{
36+
load?: {
37+
callbacks?: Array<(event: Event) => void>
38+
event?: Event | undefined
39+
}
40+
error?: {
41+
callbacks?: Array<(event: ErrorEvent) => void>
42+
event?: ErrorEvent | undefined
43+
}
44+
}
45+
> = new Map()
3346

3447
export function Script(props: ScriptProps): ReactElement | null {
3548
const {
@@ -120,7 +133,40 @@ function injectScript(props: ScriptProps): HTMLScriptElement | null {
120133
onError,
121134
} = props || {}
122135

123-
if (scriptCache.has(id || src)) {
136+
const scriptKey = id || src || (scriptCache.size + 1).toString()
137+
138+
/**
139+
* If a duplicate script is already loaded/errored, we replay load/error callbacks with the original event.
140+
* If it's not yet loaded/errored, keep track of callbacks so we can call load/error callbacks for each when the event occurs.
141+
*/
142+
const cachedCallbacks = scriptCallbackCache.get(scriptKey) || {}
143+
144+
const callbackNames = [`load`, `error`]
145+
146+
const currentCallbacks = {
147+
load: onLoad,
148+
error: onError,
149+
}
150+
151+
for (const name of callbackNames) {
152+
if (currentCallbacks?.[name]) {
153+
const { callbacks = [] } = cachedCallbacks?.[name] || {}
154+
callbacks.push(currentCallbacks?.[name])
155+
156+
if (cachedCallbacks?.[name]?.event) {
157+
currentCallbacks?.[name]?.(cachedCallbacks?.[name]?.event)
158+
} else {
159+
scriptCallbackCache.set(scriptKey, {
160+
[name]: {
161+
callbacks,
162+
},
163+
})
164+
}
165+
}
166+
}
167+
168+
// Avoid injecting duplicate scripts into the DOM
169+
if (scriptCache.has(scriptKey)) {
124170
return null
125171
}
126172

@@ -147,17 +193,25 @@ function injectScript(props: ScriptProps): HTMLScriptElement | null {
147193
script.src = src
148194
}
149195

150-
if (onLoad) {
151-
script.addEventListener(`load`, onLoad)
152-
}
196+
for (const name of callbackNames) {
197+
if (currentCallbacks?.[name]) {
198+
script.addEventListener(name, event => {
199+
const cachedCallbacks = scriptCallbackCache.get(scriptKey) || {}
153200

154-
if (onError) {
155-
script.addEventListener(`error`, onError)
201+
for (const callback of cachedCallbacks?.[name]?.callbacks || []) {
202+
callback(event)
203+
}
204+
205+
scriptCallbackCache.set(scriptKey, { [name]: { event } })
206+
})
207+
}
156208
}
157209

210+
scriptCache.add(scriptKey)
211+
158212
document.body.appendChild(script)
159213

160-
scriptCache.add(id || src)
214+
scriptCache.add(scriptKey)
161215

162216
return script
163217
}

0 commit comments

Comments
 (0)