mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix(challenge-builder): preserve defer behavior when embedding external scripts (#66093)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
@@ -264,6 +264,30 @@ async function transformScript(documentElement, { useModules }) {
|
||||
});
|
||||
}
|
||||
|
||||
const deferScript = scriptCode => {
|
||||
// Mimic the behavior of a defer script by waiting until the DOM is loaded
|
||||
// before executing the script.
|
||||
return `
|
||||
(() => {
|
||||
const run = (() => {
|
||||
if (document.readyState === "interactive") {
|
||||
${scriptCode}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('readystatechange', run, { once: true });
|
||||
})();
|
||||
`;
|
||||
};
|
||||
|
||||
export const embedScript = (script, source, contents) => {
|
||||
const code = contents ?? '';
|
||||
|
||||
script.innerHTML = script.hasAttribute('defer') ? deferScript(code) : code;
|
||||
script.removeAttribute('src');
|
||||
script.setAttribute('data-src', source);
|
||||
};
|
||||
|
||||
// This does the final transformations of the files needed to embed them into
|
||||
// HTML.
|
||||
export const embedFilesInHtml = async function (challengeFiles) {
|
||||
@@ -272,6 +296,7 @@ export const embedFilesInHtml = async function (challengeFiles) {
|
||||
|
||||
const embedStylesAndScript = contentDocument => {
|
||||
const documentElement = contentDocument.documentElement;
|
||||
|
||||
const link =
|
||||
documentElement.querySelector('link[href="styles.css"]') ??
|
||||
documentElement.querySelector('link[href="./styles.css"]');
|
||||
@@ -310,27 +335,19 @@ export const embedFilesInHtml = async function (challengeFiles) {
|
||||
link.dataset.href = 'styles.css';
|
||||
}
|
||||
if (script) {
|
||||
script.innerHTML = scriptJs?.contents;
|
||||
script.removeAttribute('src');
|
||||
script.setAttribute('data-src', 'script.js');
|
||||
embedScript(script, 'script.js', scriptJs?.contents);
|
||||
}
|
||||
if (tsScript) {
|
||||
tsScript.innerHTML = indexTs?.contents;
|
||||
tsScript.removeAttribute('src');
|
||||
tsScript.setAttribute('data-src', 'index.ts');
|
||||
embedScript(tsScript, 'index.ts', indexTs?.contents);
|
||||
}
|
||||
if (jsxScript) {
|
||||
jsxScript.innerHTML = indexJsx?.contents;
|
||||
jsxScript.removeAttribute('src');
|
||||
embedScript(jsxScript, 'index.jsx', indexJsx?.contents);
|
||||
jsxScript.removeAttribute('type');
|
||||
jsxScript.setAttribute('data-src', 'index.jsx');
|
||||
jsxScript.setAttribute('data-type', 'text/babel');
|
||||
}
|
||||
if (tsxScript) {
|
||||
tsxScript.innerHTML = indexTsx?.contents;
|
||||
tsxScript.removeAttribute('src');
|
||||
embedScript(tsxScript, 'index.tsx', indexTsx?.contents);
|
||||
tsxScript.removeAttribute('type');
|
||||
tsxScript.setAttribute('data-src', 'index.tsx');
|
||||
tsxScript.setAttribute('data-type', 'text/babel');
|
||||
}
|
||||
return documentElement.innerHTML;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { embedFilesInHtml, embedScript } from './transformers';
|
||||
|
||||
const parseHtml = html => new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
describe('embedFilesInHtml', () => {
|
||||
it('keeps deferred script.js in place', async () => {
|
||||
const result = await embedFilesInHtml([
|
||||
{
|
||||
fileKey: 'indexhtml',
|
||||
contents:
|
||||
'<!doctype html><html><head><script defer src="script.js"></script></head><body><main id="app"></main></body></html>'
|
||||
},
|
||||
{
|
||||
fileKey: 'scriptjs',
|
||||
contents: 'window.app = document.querySelector("#app");'
|
||||
}
|
||||
]);
|
||||
|
||||
const doc = parseHtml(result);
|
||||
const script = doc.querySelector('script[data-src="script.js"]');
|
||||
|
||||
expect(script).toBeTruthy();
|
||||
expect(script?.getAttribute('src')).toBeNull();
|
||||
expect(script?.textContent).toContain(
|
||||
'window.app = document.querySelector("#app");'
|
||||
);
|
||||
expect(script?.parentElement?.tagName).toBe('HEAD');
|
||||
expect(doc.body.lastElementChild?.id).toBe('app');
|
||||
});
|
||||
|
||||
it('keeps non-deferred script.js in place when embedding', async () => {
|
||||
const result = await embedFilesInHtml([
|
||||
{
|
||||
fileKey: 'indexhtml',
|
||||
contents:
|
||||
'<!doctype html><html><head><script src="script.js"></script></head><body><main id="app"></main></body></html>'
|
||||
},
|
||||
{
|
||||
fileKey: 'scriptjs',
|
||||
contents: 'window.app = document.querySelector("#app");'
|
||||
}
|
||||
]);
|
||||
|
||||
const doc = parseHtml(result);
|
||||
const script = doc.querySelector('script[data-src="script.js"]');
|
||||
|
||||
expect(script).toBeTruthy();
|
||||
expect(script?.getAttribute('src')).toBeNull();
|
||||
expect(script?.parentElement?.tagName).toBe('HEAD');
|
||||
expect(doc.body.lastElementChild?.id).toBe('app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedScript', () => {
|
||||
const rawScript = 'console.log("Hello, world!");';
|
||||
|
||||
afterEach(() => {
|
||||
delete document.__hasRun;
|
||||
document.body.querySelectorAll('script').forEach(s => s.remove());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('runs deferred scripts when the readystate becomes interactive', async () => {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('defer', true);
|
||||
embedScript(script, 'script.js', 'document.__hasRun = true;');
|
||||
|
||||
// By default, the jsdom environment is "complete", so we need to mock it to
|
||||
// test the defer behavior.
|
||||
vi.spyOn(document, 'readyState', 'get').mockReturnValueOnce('interactive');
|
||||
// We have to wait for something to happen inside the script. Since we
|
||||
// dispatch this event, that is something we can wait for.
|
||||
const scriptRan = new Promise(resolve =>
|
||||
document.addEventListener('readystatechange', resolve, {
|
||||
once: true
|
||||
})
|
||||
);
|
||||
|
||||
document.body.appendChild(script);
|
||||
document.dispatchEvent(new Event('readystatechange'));
|
||||
|
||||
await scriptRan;
|
||||
expect(document.__hasRun).toBe(true);
|
||||
});
|
||||
|
||||
it('embeds script content into a script tag', () => {
|
||||
const script = document.createElement('script');
|
||||
embedScript(script, 'script.js', rawScript);
|
||||
|
||||
expect(script.getAttribute('src')).toBeNull();
|
||||
expect(script.textContent).toEqual(rawScript);
|
||||
});
|
||||
|
||||
it('embeds defered scripts content', () => {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('defer', true);
|
||||
embedScript(script, 'script.js', rawScript);
|
||||
|
||||
expect(script.getAttribute('defer')).toBe('true');
|
||||
expect(script.getAttribute('src')).toBeNull();
|
||||
expect(script.textContent).toContain(rawScript);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user