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:
Sem Bauke
2026-03-13 16:24:25 +01:00
committed by GitHub
parent fbda2ee939
commit 94517213d9
2 changed files with 138 additions and 12 deletions
+29 -12
View File
@@ -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);
});
});