diff --git a/packages/challenge-builder/src/transformers.js b/packages/challenge-builder/src/transformers.js index 23b3c33b383..9299600b43f 100644 --- a/packages/challenge-builder/src/transformers.js +++ b/packages/challenge-builder/src/transformers.js @@ -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; diff --git a/packages/challenge-builder/src/transformers.test.js b/packages/challenge-builder/src/transformers.test.js new file mode 100644 index 00000000000..7d5d1ad1e11 --- /dev/null +++ b/packages/challenge-builder/src/transformers.test.js @@ -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: + '
' + }, + { + 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: + '
' + }, + { + 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); + }); +});