Skip to content

Commit 9451721

Browse files
SembaukeojeytonwilliamsShaunSHamilton
authored
fix(challenge-builder): preserve defer behavior when embedding external scripts (freeCodeCamp#66093)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
1 parent fbda2ee commit 9451721

2 files changed

Lines changed: 138 additions & 12 deletions

File tree

packages/challenge-builder/src/transformers.js

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,30 @@ async function transformScript(documentElement, { useModules }) {
264264
});
265265
}
266266

267+
const deferScript = scriptCode => {
268+
// Mimic the behavior of a defer script by waiting until the DOM is loaded
269+
// before executing the script.
270+
return `
271+
(() => {
272+
const run = (() => {
273+
if (document.readyState === "interactive") {
274+
${scriptCode}
275+
}
276+
});
277+
278+
document.addEventListener('readystatechange', run, { once: true });
279+
})();
280+
`;
281+
};
282+
283+
export const embedScript = (script, source, contents) => {
284+
const code = contents ?? '';
285+
286+
script.innerHTML = script.hasAttribute('defer') ? deferScript(code) : code;
287+
script.removeAttribute('src');
288+
script.setAttribute('data-src', source);
289+
};
290+
267291
// This does the final transformations of the files needed to embed them into
268292
// HTML.
269293
export const embedFilesInHtml = async function (challengeFiles) {
@@ -272,6 +296,7 @@ export const embedFilesInHtml = async function (challengeFiles) {
272296

273297
const embedStylesAndScript = contentDocument => {
274298
const documentElement = contentDocument.documentElement;
299+
275300
const link =
276301
documentElement.querySelector('link[href="styles.css"]') ??
277302
documentElement.querySelector('link[href="./styles.css"]');
@@ -310,27 +335,19 @@ export const embedFilesInHtml = async function (challengeFiles) {
310335
link.dataset.href = 'styles.css';
311336
}
312337
if (script) {
313-
script.innerHTML = scriptJs?.contents;
314-
script.removeAttribute('src');
315-
script.setAttribute('data-src', 'script.js');
338+
embedScript(script, 'script.js', scriptJs?.contents);
316339
}
317340
if (tsScript) {
318-
tsScript.innerHTML = indexTs?.contents;
319-
tsScript.removeAttribute('src');
320-
tsScript.setAttribute('data-src', 'index.ts');
341+
embedScript(tsScript, 'index.ts', indexTs?.contents);
321342
}
322343
if (jsxScript) {
323-
jsxScript.innerHTML = indexJsx?.contents;
324-
jsxScript.removeAttribute('src');
344+
embedScript(jsxScript, 'index.jsx', indexJsx?.contents);
325345
jsxScript.removeAttribute('type');
326-
jsxScript.setAttribute('data-src', 'index.jsx');
327346
jsxScript.setAttribute('data-type', 'text/babel');
328347
}
329348
if (tsxScript) {
330-
tsxScript.innerHTML = indexTsx?.contents;
331-
tsxScript.removeAttribute('src');
349+
embedScript(tsxScript, 'index.tsx', indexTsx?.contents);
332350
tsxScript.removeAttribute('type');
333-
tsxScript.setAttribute('data-src', 'index.tsx');
334351
tsxScript.setAttribute('data-type', 'text/babel');
335352
}
336353
return documentElement.innerHTML;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { embedFilesInHtml, embedScript } from './transformers';
8+
9+
const parseHtml = html => new DOMParser().parseFromString(html, 'text/html');
10+
11+
describe('embedFilesInHtml', () => {
12+
it('keeps deferred script.js in place', async () => {
13+
const result = await embedFilesInHtml([
14+
{
15+
fileKey: 'indexhtml',
16+
contents:
17+
'<!doctype html><html><head><script defer src="script.js"></script></head><body><main id="app"></main></body></html>'
18+
},
19+
{
20+
fileKey: 'scriptjs',
21+
contents: 'window.app = document.querySelector("#app");'
22+
}
23+
]);
24+
25+
const doc = parseHtml(result);
26+
const script = doc.querySelector('script[data-src="script.js"]');
27+
28+
expect(script).toBeTruthy();
29+
expect(script?.getAttribute('src')).toBeNull();
30+
expect(script?.textContent).toContain(
31+
'window.app = document.querySelector("#app");'
32+
);
33+
expect(script?.parentElement?.tagName).toBe('HEAD');
34+
expect(doc.body.lastElementChild?.id).toBe('app');
35+
});
36+
37+
it('keeps non-deferred script.js in place when embedding', async () => {
38+
const result = await embedFilesInHtml([
39+
{
40+
fileKey: 'indexhtml',
41+
contents:
42+
'<!doctype html><html><head><script src="script.js"></script></head><body><main id="app"></main></body></html>'
43+
},
44+
{
45+
fileKey: 'scriptjs',
46+
contents: 'window.app = document.querySelector("#app");'
47+
}
48+
]);
49+
50+
const doc = parseHtml(result);
51+
const script = doc.querySelector('script[data-src="script.js"]');
52+
53+
expect(script).toBeTruthy();
54+
expect(script?.getAttribute('src')).toBeNull();
55+
expect(script?.parentElement?.tagName).toBe('HEAD');
56+
expect(doc.body.lastElementChild?.id).toBe('app');
57+
});
58+
});
59+
60+
describe('embedScript', () => {
61+
const rawScript = 'console.log("Hello, world!");';
62+
63+
afterEach(() => {
64+
delete document.__hasRun;
65+
document.body.querySelectorAll('script').forEach(s => s.remove());
66+
vi.restoreAllMocks();
67+
});
68+
69+
it('runs deferred scripts when the readystate becomes interactive', async () => {
70+
const script = document.createElement('script');
71+
script.setAttribute('defer', true);
72+
embedScript(script, 'script.js', 'document.__hasRun = true;');
73+
74+
// By default, the jsdom environment is "complete", so we need to mock it to
75+
// test the defer behavior.
76+
vi.spyOn(document, 'readyState', 'get').mockReturnValueOnce('interactive');
77+
// We have to wait for something to happen inside the script. Since we
78+
// dispatch this event, that is something we can wait for.
79+
const scriptRan = new Promise(resolve =>
80+
document.addEventListener('readystatechange', resolve, {
81+
once: true
82+
})
83+
);
84+
85+
document.body.appendChild(script);
86+
document.dispatchEvent(new Event('readystatechange'));
87+
88+
await scriptRan;
89+
expect(document.__hasRun).toBe(true);
90+
});
91+
92+
it('embeds script content into a script tag', () => {
93+
const script = document.createElement('script');
94+
embedScript(script, 'script.js', rawScript);
95+
96+
expect(script.getAttribute('src')).toBeNull();
97+
expect(script.textContent).toEqual(rawScript);
98+
});
99+
100+
it('embeds defered scripts content', () => {
101+
const script = document.createElement('script');
102+
script.setAttribute('defer', true);
103+
embedScript(script, 'script.js', rawScript);
104+
105+
expect(script.getAttribute('defer')).toBe('true');
106+
expect(script.getAttribute('src')).toBeNull();
107+
expect(script.textContent).toContain(rawScript);
108+
});
109+
});

0 commit comments

Comments
 (0)