diff --git a/e2e-tests/development-runtime/.gitignore b/e2e-tests/development-runtime/.gitignore index 3cbf65216661d..b98e14052be21 100644 --- a/e2e-tests/development-runtime/.gitignore +++ b/e2e-tests/development-runtime/.gitignore @@ -76,3 +76,6 @@ cypress/videos __history__.json src/gatsby-types.d.ts +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-inline-scripts.js b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-inline-scripts.js new file mode 100644 index 0000000000000..c729ae2cead10 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-inline-scripts.js @@ -0,0 +1,195 @@ +import { inlineScript } from "../../../gatsby-script-scripts" +import { resourceRecord, markRecord } from "../../../gatsby-script-records" + +const page = { + target: `/gatsby-script-inline-scripts/`, + navigation: `/gatsby-script-navigation/`, +} + +const typesOfInlineScripts = [ + { + descriptor: `dangerouslySetInnerHTML`, + inlineScriptType: inlineScript.dangerouslySet, + }, + { + descriptor: `template literals`, + inlineScriptType: inlineScript.templateLiteral, + }, +] + +/** + * Normally we would duplicate the tests so they're flatter and easier to debug, + * but since the test count grew and the cases are exactly the same we'll iterate. + */ + +for (const { descriptor, inlineScriptType } of typesOfInlineScripts) { + describe(`inline scripts set via ${descriptor}`, () => { + describe(`using the post-hydrate strategy`, () => { + it(`should execute successfully`, () => { + cy.visit(page.target) + + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `success`, + true + ).should(`equal`, `true`) + }) + + it(`should load after the framework bundle has loaded`, () => { + cy.visit(page.target) + + // Assert framework is loaded before inline script is executed + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + markRecord.executeStart + ).then(dangerouslySetExecuteStart => { + cy.getRecord(`framework`, resourceRecord.responseEnd).should( + `be.lessThan`, + dangerouslySetExecuteStart + ) + }) + }) + }) + + describe(`using the idle strategy`, () => { + it(`should execute successfully`, () => { + cy.visit(page.target) + + cy.getRecord(`idle-${inlineScriptType}`, `success`, true).should( + `equal`, + `true` + ) + }) + + it(`should load after other strategies`, () => { + cy.visit(page.target) + + cy.getRecord(`idle-${inlineScriptType}`, markRecord.executeStart).then( + dangerouslySetExecuteStart => { + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + markRecord.executeStart + ).should(`be.lessThan`, dangerouslySetExecuteStart) + } + ) + }) + }) + + describe(`when navigation occurs`, () => { + it(`should load only once on initial page load`, () => { + cy.visit(page.target) + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + + it(`should load only once after the page is refreshed`, () => { + cy.visit(page.target).waitForRouteChange() + cy.reload().url().should(`contain`, page.target) + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + + it(`should load only once after anchor link navigation`, () => { + cy.visit(page.target) + cy.get(`a[id=anchor-link-back-to-index]`).click() + cy.url().should(`contain`, page.navigation) + cy.get(`a[href="${page.target}"][id=anchor-link]`).click() + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + + it(`should load only once if the page is revisited via browser back/forward buttons after anchor link navigation`, () => { + cy.visit(page.navigation).waitForRouteChange() + cy.get(`a[href="${page.target}"][id=anchor-link]`).click() + cy.get(`table[id=script-mark-records] tbody`) // Make sure history has time to change + cy.go(`back`) + cy.go(`forward`) + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + + it(`should load only once after Gatsby link navigation`, () => { + cy.visit(page.target) + cy.get(`a[id=gatsby-link-back-to-index]`).click() + cy.get(`a[href="${page.target}"][id=gatsby-link]`).click() + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + + it(`should load only once if the page is revisited via browser back/forward buttons after Gatsby link navigation`, () => { + cy.visit(page.navigation) + cy.get(`a[href="${page.target}"][id=gatsby-link]`).click() + cy.go(`back`) + cy.go(`forward`) + + cy.get(`table[id=script-mark-records] tbody`) + .children() + .should(`have.length`, 4) + cy.getRecord( + `post-hydrate-${inlineScriptType}`, + `strategy`, + true + ).should(`equal`, `post-hydrate`) + cy.getRecord(`idle-${inlineScriptType}`, `strategy`, true).should( + `equal`, + `idle` + ) + }) + }) + }) +} diff --git a/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-scripts-with-sources.js b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-scripts-with-sources.js new file mode 100644 index 0000000000000..b087868bd8907 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-scripts-with-sources.js @@ -0,0 +1,166 @@ +import { script } from "../../../gatsby-script-scripts" +import { resourceRecord } from "../../../gatsby-script-records" + +const page = { + target: `/gatsby-script-scripts-with-sources/`, + navigation: `/gatsby-script-navigation/`, +} + +describe(`scripts with sources`, () => { + describe(`using the post-hydrate strategy`, () => { + it(`should load successfully`, () => { + cy.visit(page.target) + cy.getRecord(script.three, `success`, true).should(`equal`, `true`) + }) + + it(`should load after the framework bundle has loaded`, () => { + cy.visit(page.target) + + // Assert framework is loaded before three starts loading + cy.getRecord(script.three, resourceRecord.fetchStart).then( + threeFetchStart => { + cy.getRecord(`framework`, resourceRecord.responseEnd).should( + `be.lessThan`, + threeFetchStart + ) + } + ) + }) + + it(`should call an on load callback once the script has loaded`, () => { + cy.visit(page.target) + cy.getRecord(script.three, resourceRecord.responseEnd).then(() => { + cy.get(`[data-on-load-result=post-hydrate]`) + }) + }) + + it(`should call an on error callback if an error occurred`, () => { + cy.visit(page.target) + cy.get(`[data-on-error-result=post-hydrate]`) + }) + }) + + describe(`using the idle strategy`, () => { + it(`should load successfully`, () => { + cy.visit(page.target) + cy.getRecord(script.marked, `success`, true).should(`equal`, `true`) + }) + + it(`should load after other strategies`, () => { + cy.visit(page.target) + + cy.getRecord(script.marked, resourceRecord.fetchStart).then( + markedFetchStart => { + cy.getRecord(script.three, resourceRecord.fetchStart).should( + `be.lessThan`, + markedFetchStart + ) + } + ) + }) + + it(`should call an on load callback once the script has loaded`, () => { + cy.visit(page.target) + cy.getRecord(script.marked, resourceRecord.responseEnd).then(() => { + cy.get(`[data-on-load-result=idle]`) + }) + }) + + it(`should call an on error callback if an error occurred`, () => { + cy.visit(page.target) + cy.get(`[data-on-error-result=idle]`) + }) + }) + + describe(`when navigation occurs`, () => { + it(`should load only once on initial page load`, () => { + cy.visit(page.target) + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + + it(`should load only once after the page is refreshed`, () => { + cy.visit(page.target) + cy.reload() + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + + it(`should load only once after anchor link navigation`, () => { + cy.visit(page.target) + cy.get(`a[id=anchor-link-back-to-index]`).click() + cy.url().should(`contain`, page.navigation) + cy.get(`a[href="${page.target}"][id=anchor-link]`).click() + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + + it(`should load only once if the page is revisited via browser back/forward buttons after anchor link navigation`, () => { + cy.visit(page.navigation) + cy.get(`a[href="${page.target}"][id=anchor-link]`).click() + cy.go(`back`) + cy.go(`forward`) + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + + it(`should load only once after Gatsby link navigation`, () => { + cy.visit(page.target) + cy.get(`a[id=gatsby-link-back-to-index]`).click() + cy.get(`a[href="${page.target}"][id=gatsby-link]`).click() + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + + it(`should load only once if the page is revisited via browser back/forward buttons after Gatsby link navigation`, () => { + cy.visit(page.navigation) + cy.get(`a[href="${page.target}"][id=gatsby-link]`).click() + cy.go(`back`) + cy.go(`forward`) + + cy.get(`table[id=script-resource-records] tbody`) + .children() + .should(`have.length`, 5) + cy.getRecord(script.three, `strategy`, true).should( + `equal`, + `post-hydrate` + ) + cy.getRecord(script.marked, `strategy`, true).should(`equal`, `idle`) + }) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-shimmed-req-idle-callback.js b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-shimmed-req-idle-callback.js new file mode 100644 index 0000000000000..bae5d0b841110 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-shimmed-req-idle-callback.js @@ -0,0 +1,52 @@ +import { script } from "../../../gatsby-script-scripts" +import { resourceRecord } from "../../../gatsby-script-records" + +// The page that we will assert against +const page = `/gatsby-script-scripts-with-sources` + +Cypress.on(`window:before:load`, win => { + cy.spy(win, `requestIdleCallback`).as(`requestIdleCallback`) + win.requestIdleCallback = undefined +}) + +/* + * Some browsers don't support the requestIdleCallback API, so we need to + * shim it. Here we test that the idle behaviour remains the same with shimmed requestIdleCallback + */ +describe(`using the idle strategy with shimmed requestIdleCallback`, () => { + it(`should load successfully`, () => { + cy.visit(page).waitForRouteChange() + cy.getRecord(script.marked, `success`, true).should(`equal`, `true`) + + cy.get(`@requestIdleCallback`).should(`not.be.called`) + }) + + it(`should load after other strategies`, () => { + cy.visit(page).waitForRouteChange() + + cy.getRecord(script.marked, resourceRecord.fetchStart).then( + markedFetchStart => { + cy.getRecord(script.three, resourceRecord.fetchStart).should( + `be.lessThan`, + markedFetchStart + ) + } + ) + cy.get(`@requestIdleCallback`).should(`not.be.called`) + }) + + it(`should call an on load callback once the script has loaded`, () => { + cy.visit(page).waitForRouteChange() + cy.getRecord(script.marked, resourceRecord.responseEnd).then(() => { + cy.get(`[data-on-load-result=idle]`) + }) + cy.get(`@requestIdleCallback`).should(`not.be.called`) + }) + + it(`should call an on error callback if an error occurred`, () => { + cy.visit(page).waitForRouteChange() + cy.get(`[data-on-error-result=idle]`) + + cy.get(`@requestIdleCallback`).should(`not.be.called`) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-ssr-browser-apis.js b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-ssr-browser-apis.js new file mode 100644 index 0000000000000..2a470190bd8f7 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/gatsby-script/gatsby-script-ssr-browser-apis.js @@ -0,0 +1,15 @@ +import { script } from "../../../gatsby-script-scripts" + +const page = `/gatsby-script-ssr-browser-apis/` + +it(`scripts load successfully when used via wrapPageElement`, () => { + cy.visit(page) + cy.getRecord(script.three, `success`, true).should(`equal`, `true`) + cy.getRecord(script.marked, `success`, true).should(`equal`, `true`) +}) + +it(`scripts load successfully when used via wrapRootElement`, () => { + cy.visit(page) + cy.getRecord(script.jQuery, `success`, true).should(`equal`, `true`) + cy.getRecord(script.popper, `success`, true).should(`equal`, `true`) +}) diff --git a/e2e-tests/development-runtime/cypress/support/commands.js b/e2e-tests/development-runtime/cypress/support/commands.js index bb6981c2e5d8d..947d235eec469 100644 --- a/e2e-tests/development-runtime/cypress/support/commands.js +++ b/e2e-tests/development-runtime/cypress/support/commands.js @@ -108,13 +108,13 @@ Cypress.Commands.add(`waitForHmr`, (message = `App is up to date`) => { cy.wait(1000) }) -Cypress.Commands.add(`getFastRefreshOverlay`, () => ( - cy.get('gatsby-fast-refresh').shadow() -)) +Cypress.Commands.add(`getFastRefreshOverlay`, () => + cy.get(`gatsby-fast-refresh`).shadow() +) -Cypress.Commands.add(`assertNoFastRefreshOverlay`, () => ( - cy.get('gatsby-fast-refresh').should('not.exist') -)) +Cypress.Commands.add(`assertNoFastRefreshOverlay`, () => + cy.get(`gatsby-fast-refresh`).should(`not.exist`) +) addMatchImageSnapshotCommand({ customDiffDir: `/__diff_output__`, @@ -124,3 +124,15 @@ addMatchImageSnapshotCommand({ failureThreshold: 0.08, failureThresholdType: `percent`, }) + +/** + * Get a record from a table cell in one of the test components. + * @example cy.getRecord(Script.dayjs, ResourceRecord.fetchStart) + * @example cy.getRecord(`${ScriptStrategy.preHydrate}-${InlineScript.dangerouslySet}`, MarkRecord.executeStart) + */ +Cypress.Commands.add(`getRecord`, (key, metric, raw = false) => { + return cy + .get(`[id=${key}] [id=${metric}]`) + .invoke(`text`) + .then(value => (raw ? value : Number(value))) +}) diff --git a/e2e-tests/development-runtime/gatsby-browser.js b/e2e-tests/development-runtime/gatsby-browser.js index d8a65b3cc6595..1e783bea454bd 100644 --- a/e2e-tests/development-runtime/gatsby-browser.js +++ b/e2e-tests/development-runtime/gatsby-browser.js @@ -1,3 +1,4 @@ +import WrapPageElement from "./src/wrap-page-element" import WrapRootElement from "./src/wrap-root-element" import * as React from "react" @@ -23,6 +24,10 @@ export const onPrefetchPathname = ({ pathname }) => { addLogEntry(`onPrefetchPathname`, pathname) } +export const wrapPageElement = ({ element, props }) => ( + +) + export const wrapRootElement = ({ element }) => ( ) diff --git a/e2e-tests/development-runtime/gatsby-config.js b/e2e-tests/development-runtime/gatsby-config.js index 9ccc5806d1583..13d43473d4f25 100644 --- a/e2e-tests/development-runtime/gatsby-config.js +++ b/e2e-tests/development-runtime/gatsby-config.js @@ -36,10 +36,7 @@ module.exports = { { resolve: `gatsby-transformer-remark`, options: { - plugins: [ - `gatsby-remark-subcache`, - `gatsby-remark-images` - ], + plugins: [`gatsby-remark-subcache`, `gatsby-remark-images`], }, }, `gatsby-plugin-sharp`, @@ -63,4 +60,5 @@ module.exports = { // To learn more, visit: https://gatsby.dev/offline // 'gatsby-plugin-offline', ], + partytownProxiedURLs: [`https://unpkg.com/three@0.139.1/build/three.js`], } diff --git a/e2e-tests/development-runtime/gatsby-script-records.js b/e2e-tests/development-runtime/gatsby-script-records.js new file mode 100644 index 0000000000000..fff69c671fbcc --- /dev/null +++ b/e2e-tests/development-runtime/gatsby-script-records.js @@ -0,0 +1,12 @@ +/** + * Naming matches `PerformanceResourceTiming` interface. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming} + */ +export const resourceRecord = { + fetchStart: `fetch-start`, + responseEnd: `response-end`, +} + +export const markRecord = { + executeStart: `execute-start`, +} diff --git a/e2e-tests/development-runtime/gatsby-script-scripts.js b/e2e-tests/development-runtime/gatsby-script-scripts.js new file mode 100644 index 0000000000000..d206c8642fb66 --- /dev/null +++ b/e2e-tests/development-runtime/gatsby-script-scripts.js @@ -0,0 +1,68 @@ +export const script = { + three: `three`, + marked: `marked`, + jQuery: `jQuery`, + popper: `popper`, +} + +export const scripts = { + [script.three]: `https://unpkg.com/three@0.139.1/build/three.js`, + [script.marked]: `https://cdn.jsdelivr.net/npm/marked/marked.min.js`, + [script.jQuery]: `https://code.jquery.com/jquery-3.4.1.min.js`, + [script.popper]: `https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js`, +} + +export const scriptStrategyIndex = { + [script.three]: `post-hydrate`, + [script.marked]: `idle`, + [script.jQuery]: `post-hydrate`, + [script.popper]: `idle`, +} + +export const scriptSuccessIndex = { + // @ts-ignore + [script.three]: () => typeof THREE === `object`, + // @ts-ignore + [script.marked]: () => typeof marked === `object`, + // @ts-ignore + [script.jQuery]: () => typeof jQuery === `function`, + // @ts-ignore + [script.popper]: () => typeof Popper === `function`, +} + +export const scriptUrlIndex = { + [scripts.three]: script.three, + [scripts.marked]: script.marked, + [scripts.jQuery]: script.jQuery, + [scripts.popper]: script.popper, +} + +export const scriptUrls = new Set(Object.values(scripts)) + +export const inlineScript = { + dangerouslySet: `dangerously-set`, + templateLiteral: `template-literal`, +} + +// Create an object literal instead of iterating so the contents are explicit +export const inlineScripts = { + "dangerously-set": { + "post-hydrate": constructInlineScript(`dangerously-set`, `post-hydrate`), + idle: constructInlineScript(`dangerously-set`, `idle`), + }, + "template-literal": { + "post-hydrate": constructInlineScript(`template-literal`, `post-hydrate`), + idle: constructInlineScript(`template-literal`, `idle`), + }, +} + +function constructInlineScript(type, strategy) { + return ` + performance.mark(\`inline-script\`, { detail: { + strategy: \`${strategy}\`, + type: \`${type}\`, + executeStart: performance.now() + }}) + window[\`${strategy}-${type}\`] = true; + ` +} diff --git a/e2e-tests/development-runtime/package.json b/e2e-tests/development-runtime/package.json index 3a917d6370960..0b9eda2fe591d 100644 --- a/e2e-tests/development-runtime/package.json +++ b/e2e-tests/development-runtime/package.json @@ -49,12 +49,19 @@ "update:webhook": "node scripts/webhook.js", "update:cms-webhook": "node scripts/cms-webhook.js", "update:preview": "curl -X POST -d \"{ \\\"fake-data-update\\\": true }\" -H \"Content-Type: application/json\" http://localhost:8000/__refresh", - "start-server-and-test": "start-server-and-test develop http://localhost:8000 cy:run", - "start-server-and-debug": "start-server-and-test develop http://localhost:8000 cy:open", + "start-server-and-test": "start-server-and-test develop http://localhost:8000 combined", + "start-server-and-test:locally": "start-server-and-test develop http://localhost:8000 cy:open", "cy:open": "cypress open", - "cy:run": "node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome" + "cy:run": "node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome", + "playwright": "playwright test --project=chromium", + "playwright:debug": "playwright test --project=chromium --debug", + "start-server-and-test:playwright": "start-server-and-test develop http://localhost:8000 playwright", + "start-server-and-test:playwright-debug": "start-server-and-test develop http://localhost:8000 playwright:debug", + "combined": "npm run playwright && npm run cy:run", + "postinstall": "playwright install chromium" }, "devDependencies": { + "@playwright/test": "^1.22.0", "@testing-library/cypress": "^7.0.0", "cross-env": "^5.2.0", "cypress": "6.1.0", diff --git a/e2e-tests/development-runtime/playwright.config.ts b/e2e-tests/development-runtime/playwright.config.ts new file mode 100644 index 0000000000000..ddaffc935a26d --- /dev/null +++ b/e2e-tests/development-runtime/playwright.config.ts @@ -0,0 +1,57 @@ +import type { PlaywrightTestConfig } from "@playwright/test" +import { devices } from "@playwright/test" + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./playwright", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:8000", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', +} + +export default config diff --git a/e2e-tests/development-runtime/playwright/gatsby-script-off-main-thread.spec.ts b/e2e-tests/development-runtime/playwright/gatsby-script-off-main-thread.spec.ts new file mode 100644 index 0000000000000..4a1cef1c08e17 --- /dev/null +++ b/e2e-tests/development-runtime/playwright/gatsby-script-off-main-thread.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test" + +const id = { + templateLiteral: `inline-script-template-literal-mutation`, + dangerouslySet: `inline-script-dangerously-set-mutation`, +} + +test.describe(`off-main-thread scripts`, () => { + test(`should load successfully`, async ({ page }) => { + await page.goto(`/gatsby-script-off-main-thread/`) + + // @ts-ignore + const scriptWithSrc = await page.evaluate(() => typeof THREE === `function`) + + const templateLiteral = await page + .locator(`[id=${id.templateLiteral}]`) + .textContent() + + const dangerouslySet = await page + .locator(`[id=${id.dangerouslySet}]`) + .textContent() + + await expect(scriptWithSrc).toBeTruthy() + await expect(templateLiteral).toEqual(`${id.templateLiteral}: success`) + await expect(dangerouslySet).toEqual(`${id.dangerouslySet}: success`) + }) +}) diff --git a/e2e-tests/development-runtime/src/components/gatsby-script-mark-records.js b/e2e-tests/development-runtime/src/components/gatsby-script-mark-records.js new file mode 100644 index 0000000000000..00d90e573eb9e --- /dev/null +++ b/e2e-tests/development-runtime/src/components/gatsby-script-mark-records.js @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from "react" +import { markRecord } from "../../gatsby-script-records" +import { trim } from "../utils/trim" + +/** + * Displays performance mark records of scripts in a table. + */ +export function ScriptMarkRecords(props) { + const { check, count } = props + + const [records, setRecords] = useState([]) + + /** + * Poll for the mark records we care about. + * We'll use this approach instead of listening for the load event to be consistent. + */ + useEffect(() => { + const interval = setInterval(() => { + const markRecords = performance.getEntriesByType(`mark`) + + const scriptRecords = markRecords.filter(check) + + if (scriptRecords.length === count || performance.now() > 10000) { + setRecords(scriptRecords) + clearInterval(interval) + } + }, 100) + }, []) + + return ( + + + + + + + + + + + {records + .sort((a, b) => a.detail.executeStart - b.detail.executeStart) + .map(record => { + const { strategy, type, executeStart } = record.detail + const key = `${strategy}-${type}` + // @ts-ignore Do not complain about key not being a number + const success = `${typeof window[key] === `boolean`}` + return ( + + + + + + + ) + })} + +
TypeStrategySuccessExecute start (ms)
{type}{strategy}{success}{trim(executeStart)}
+ ) +} diff --git a/e2e-tests/development-runtime/src/components/gatsby-script-resource-records.js b/e2e-tests/development-runtime/src/components/gatsby-script-resource-records.js new file mode 100644 index 0000000000000..313fdfbcf22d7 --- /dev/null +++ b/e2e-tests/development-runtime/src/components/gatsby-script-resource-records.js @@ -0,0 +1,79 @@ +import React, { useState, useEffect } from "react" +import { + scriptUrlIndex, + scriptStrategyIndex, + scriptSuccessIndex, +} from "../../gatsby-script-scripts" +import { resourceRecord } from "../../gatsby-script-records" +import { trim } from "../utils/trim" + +/** + * Displays performance resource records of scripts in a table. + */ +export function ScriptResourceRecords(props) { + const { check, count } = props + + const [records, setRecords] = useState([]) + + /** + * Poll for the resource records we care about. + * Use this approach since `PerformanceObserver` doesn't give us preload link records (e.g. framework) + */ + useEffect(() => { + const interval = setInterval(() => { + const resourceRecords = performance.getEntriesByType(`resource`) + + const scriptRecords = resourceRecords.filter(check) + + if (scriptRecords.length === count || performance.now() > 10000) { + setRecords(scriptRecords) + clearInterval(interval) + } + }, 100) + }, []) + + return ( + + + + + + + + + + + + {records + .sort((a, b) => a.fetchStart - b.fetchStart) + .map(record => { + const { name: url, fetchStart, responseEnd } = record || {} + + let name + let strategy + let success + + if (record.name.includes(`framework`)) { + name = `framework` + strategy = `N/A` + success = `N/A` + } else { + name = scriptUrlIndex[url] + strategy = scriptStrategyIndex[name] + success = `${scriptSuccessIndex[name]()}` + } + + return ( + + + + + + + + ) + })} + +
ScriptStrategySuccessFetch start (ms)Response end (ms)
{name}{strategy}{success}{trim(fetchStart)}{trim(responseEnd)}
+ ) +} diff --git a/e2e-tests/development-runtime/src/hooks/use-occupy-main-thread.js b/e2e-tests/development-runtime/src/hooks/use-occupy-main-thread.js new file mode 100644 index 0000000000000..f6d76654f6a87 --- /dev/null +++ b/e2e-tests/development-runtime/src/hooks/use-occupy-main-thread.js @@ -0,0 +1,16 @@ +import { useEffect } from "react" + +/** + * Attempts to occupy the main thread with work so that the idle strategy can be observed. + */ +export function useOccupyMainThread(timeout = 3000) { + useEffect(() => { + const interval = setInterval(() => { + console.log(`Occupying main thread`) + }, 0) + + setTimeout(() => { + clearInterval(interval) + }, timeout) + }, []) +} diff --git a/e2e-tests/development-runtime/src/pages/gatsby-script-inline-scripts.js b/e2e-tests/development-runtime/src/pages/gatsby-script-inline-scripts.js new file mode 100644 index 0000000000000..5c1777d866a73 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/gatsby-script-inline-scripts.js @@ -0,0 +1,71 @@ +import * as React from "react" +import { Link, Script } from "gatsby" +import { ScriptResourceRecords } from "../components/gatsby-script-resource-records" +import { ScriptMarkRecords } from "../components/gatsby-script-mark-records" +import { useOccupyMainThread } from "../hooks/use-occupy-main-thread" +import { inlineScripts, inlineScript } from "../../gatsby-script-scripts" + +function InlineScriptsPage() { + useOccupyMainThread() + + return ( +
+

Script component e2e test

+ +
+

Framework script

+ record.name.includes(`framework`)} + count={1} + /> + +
+

Inline scripts

+ record.name === `inline-script`} + count={4} + /> + +
+ + + + +
+ ) +} + +export default InlineScriptsPage diff --git a/e2e-tests/development-runtime/src/pages/gatsby-script-navigation.js b/e2e-tests/development-runtime/src/pages/gatsby-script-navigation.js new file mode 100644 index 0000000000000..3135d4e3e1be2 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/gatsby-script-navigation.js @@ -0,0 +1,47 @@ +import * as React from "react" +import { Link } from "gatsby" + +const pages = [ + { + name: `Scripts with sources`, + path: `/gatsby-script-scripts-with-sources/`, + }, + { name: `Inline scripts`, path: `/gatsby-script-inline-scripts/` }, + { + name: `Scripts from SSR and browser apis`, + path: `/gatsby-script-ssr-browser-apis/`, + }, +] + +function IndexPage() { + return ( +
+

Script component e2e test

+

Tests are on other pages, links below.

+ +

Links to pages (anchor):

+ + +

Links to pages (gatsby-link):

+
    + {pages.map(({ name, path }) => ( +
  • + + {`${name} (gatsby-link)`} + +
  • + ))} +
+
+ ) +} + +export default IndexPage diff --git a/e2e-tests/development-runtime/src/pages/gatsby-script-off-main-thread.js b/e2e-tests/development-runtime/src/pages/gatsby-script-off-main-thread.js new file mode 100644 index 0000000000000..a6387e0dd2cc0 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/gatsby-script-off-main-thread.js @@ -0,0 +1,44 @@ +import * as React from "react" +import { Script } from "gatsby" +import { scripts } from "../../gatsby-script-scripts" + +function OffMainThreadScripts() { + return ( +
+

Script component e2e test

+ +
+

Scripts with sources

+ + + + + +
+ ) +} + +export default InlineScriptsPage diff --git a/e2e-tests/production-runtime/src/pages/gatsby-script-navigation.js b/e2e-tests/production-runtime/src/pages/gatsby-script-navigation.js new file mode 100644 index 0000000000000..3135d4e3e1be2 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/gatsby-script-navigation.js @@ -0,0 +1,47 @@ +import * as React from "react" +import { Link } from "gatsby" + +const pages = [ + { + name: `Scripts with sources`, + path: `/gatsby-script-scripts-with-sources/`, + }, + { name: `Inline scripts`, path: `/gatsby-script-inline-scripts/` }, + { + name: `Scripts from SSR and browser apis`, + path: `/gatsby-script-ssr-browser-apis/`, + }, +] + +function IndexPage() { + return ( +
+

Script component e2e test

+

Tests are on other pages, links below.

+ +

Links to pages (anchor):

+ + +

Links to pages (gatsby-link):

+
    + {pages.map(({ name, path }) => ( +
  • + + {`${name} (gatsby-link)`} + +
  • + ))} +
+
+ ) +} + +export default IndexPage diff --git a/e2e-tests/production-runtime/src/pages/gatsby-script-off-main-thread.js b/e2e-tests/production-runtime/src/pages/gatsby-script-off-main-thread.js new file mode 100644 index 0000000000000..b888521385bd1 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/gatsby-script-off-main-thread.js @@ -0,0 +1,44 @@ +import * as React from "react" +import { Script } from "gatsby" +import { scripts } from "../../gatsby-script-scripts" + +function OffMainThreadScripts() { + return ( +
+

Script component e2e test

+ +
+

Scripts with sources

+ + + + + +
+ ) +} + +export default IndexPage +``` diff --git a/packages/gatsby-script/package.json b/packages/gatsby-script/package.json new file mode 100644 index 0000000000000..abbf93ceb785d --- /dev/null +++ b/packages/gatsby-script/package.json @@ -0,0 +1,56 @@ +{ + "name": "gatsby-script", + "description": "An enhanced script component for Gatsby sites with support for various loading strategies", + "version": "1.0.0-next.3", + "author": "Ty Hopp ", + "bugs": { + "url": "https://github.com/gatsbyjs/gatsby/issues" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/index.d.ts" + ] + } + }, + "files": [ + "dist/*" + ], + "scripts": { + "typegen": "rimraf \"dist/**/*.d.ts\" && tsc --emitDeclarationOnly --declaration --declarationDir dist/", + "build": "babel src --extensions \".js,.jsx,.ts,.tsx\" --out-dir dist --ignore \"**/__tests__\"", + "watch": "babel -w src --extensions \".js,.jsx,.ts,.tsx\" --out-dir dist --ignore \"**/__tests__\"", + "prepare": "cross-env NODE_ENV=production npm run build && npm run typegen" + }, + "dependencies": {}, + "devDependencies": { + "@babel/cli": "^7.15.4", + "@babel/core": "^7.15.5", + "@babel/preset-typescript": "^7.16.7", + "@testing-library/react": "^11.2.7", + "babel-preset-gatsby-package": "^2.15.0-next.0", + "cross-env": "^7.0.3", + "rimraf": "^3.0.2", + "typescript": "^4.6.3" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-script#readme", + "keywords": [ + "gatsby" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gatsbyjs/gatsby.git", + "directory": "packages/gatsby-script" + } +} diff --git a/packages/gatsby-script/src/__tests__/gatsby-script.tsx b/packages/gatsby-script/src/__tests__/gatsby-script.tsx new file mode 100644 index 0000000000000..e3100553f7884 --- /dev/null +++ b/packages/gatsby-script/src/__tests__/gatsby-script.tsx @@ -0,0 +1,131 @@ +/** + * @jest-environment jsdom + */ + +import React from "react" +import { render } from "@testing-library/react/pure" +import { Script, ScriptStrategy, scriptCache } from "../gatsby-script" + +const scripts: Record = { + react: `https://unpkg.com/react@18/umd/react.development.js`, + inline: `console.log('Hello world!')`, +} + +const strategies: Array = [ + ScriptStrategy.postHydrate, + ScriptStrategy.idle, +] + +jest.mock(`../request-idle-callback-shim`, () => { + const originalModule = jest.requireActual(`../request-idle-callback-shim`) + + return { + __esModule: true, + ...originalModule, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + requestIdleCallback: jest.fn(callback => callback()), + } +}) + +describe(`Script`, () => { + beforeAll(() => { + // @ts-ignore Mock it out for now + performance.getEntriesByName = jest.fn(() => []) + }) + + afterEach(() => { + scriptCache.delete(scripts.react) + while (document.body.hasChildNodes()) { + document.body.lastElementChild?.remove() + } + }) + + it(`should default to a post-hydrate strategy`, async () => { + const { container } = render( + ) + const script = container.parentElement.querySelector(`script`) + expect(script.textContent).toBe(scripts.inline) + } + }) + + it(`should apply normal attributes`, () => { + const lines = { + first: `It's the bear necessities`, + second: `the simple bear necessities`, + third: `forget about your worries and your strife`, + } + + for (const strategy of strategies) { + const { container } = render( + + ) + + const script = container.parentElement.querySelector(`script`) + + expect(script.dataset.first).toBe(lines.first) + expect(script.dataset.second).toBe(lines.second) + expect(script.dataset.third).toBe(lines.third) + } + }) +}) diff --git a/packages/gatsby-script/src/gatsby-script.tsx b/packages/gatsby-script/src/gatsby-script.tsx new file mode 100644 index 0000000000000..2cd7741ac2837 --- /dev/null +++ b/packages/gatsby-script/src/gatsby-script.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useContext } from "react" +import { PartytownContext } from "./partytown-context" +import type { ReactElement, ScriptHTMLAttributes } from "react" +import { requestIdleCallback } from "./request-idle-callback-shim" + +export enum ScriptStrategy { + postHydrate = `post-hydrate`, + idle = `idle`, + offMainThread = `off-main-thread`, +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ScriptProps + extends Omit, `onLoad` | `onError`> { + id?: string + strategy?: ScriptStrategy | `post-hydrate` | `idle` | `off-main-thread` + children?: string + onLoad?: (event: Event) => void + onError?: (event: ErrorEvent) => void + forward?: Array +} + +const handledProps = new Set([ + `src`, + `strategy`, + `dangerouslySetInnerHTML`, + `children`, + `onLoad`, + `onError`, +]) + +export const scriptCache = new Set() + +export function Script(props: ScriptProps): ReactElement | null { + const { + id, + src, + strategy = ScriptStrategy.postHydrate, + onLoad, + onError, + } = props || {} + const { collectScript } = useContext(PartytownContext) + + useEffect(() => { + let script: HTMLScriptElement | null + + switch (strategy) { + case ScriptStrategy.postHydrate: + script = injectScript(props) + break + case ScriptStrategy.idle: + requestIdleCallback(() => { + script = injectScript(props) + }) + break + case ScriptStrategy.offMainThread: + if (typeof window !== `undefined` && collectScript) { + const attributes = resolveAttributes(props) + collectScript(attributes) + } + break + } + + return (): void => { + if (onLoad) { + script?.removeEventListener(`load`, onLoad) + } + if (onError) { + script?.removeEventListener(`error`, onError) + } + script?.remove() + } + }, []) + + if (strategy === ScriptStrategy.offMainThread) { + const inlineScript = resolveInlineScript(props) + const attributes = resolveAttributes(props) + + if (typeof window === `undefined` && collectScript) { + const identifier = id || src || `no-id-or-src` + console.warn( + `Unable to collect off-main-thread script '${identifier}' for configuration with Partytown.\nGatsby script components must be used either as a child of your page, in wrapPageElement, or wrapRootElement.\nSee https://gatsby.dev/gatsby-script for more information.` + ) + collectScript(attributes) + } + + if (inlineScript) { + return ( +