diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 855e69cccd121371ffbeaa4d278c7b5f905ef445..e34c745b170fc41d43ff80333daba18b74ec941a 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -367,6 +367,9 @@ export default async function getBaseWebpackConfig( // Which makes bundles slightly smaller, but also skips parsing a module that we know will result in this alias 'next/head': 'next/dist/next-server/lib/head.js', 'next/router': 'next/dist/client/router.js', + 'next/experimental-script': config.experimental.scriptLoader + ? 'next/dist/client/experimental-script.js' + : '', 'next/config': 'next/dist/next-server/lib/runtime-config.js', 'next/dynamic': 'next/dist/next-server/lib/dynamic.js', next: NEXT_PROJECT_ROOT, diff --git a/packages/next/client/experimental-script.tsx b/packages/next/client/experimental-script.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77e914f331bc7a566d25038be89fa0bbe681a4e2 --- /dev/null +++ b/packages/next/client/experimental-script.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useContext } from 'react' +import { ScriptHTMLAttributes } from 'react' +import { HeadManagerContext } from '../next-server/lib/head-manager-context' +import { DOMAttributeNames } from './head-manager' +import requestIdleCallback from './request-idle-callback' + +const ScriptCache = new Map() +const LoadCache = new Set() + +interface Props extends ScriptHTMLAttributes { + strategy?: 'defer' | 'lazy' | 'dangerouslyBlockRendering' | 'eager' + id?: string + onLoad?: () => void + onError?: () => void + children?: React.ReactNode + preload?: boolean +} + +const loadScript = (props: Props) => { + const { + src = '', + onLoad = () => {}, + dangerouslySetInnerHTML, + children = '', + id, + onError, + } = props + + const cacheKey = id || src + if (ScriptCache.has(src)) { + if (!LoadCache.has(cacheKey)) { + LoadCache.add(cacheKey) + // Execute onLoad since the script loading has begun + ScriptCache.get(src).then(onLoad, onError) + } + return + } + + const el = document.createElement('script') + + const loadPromise = new Promise((resolve, reject) => { + el.addEventListener('load', function () { + resolve() + if (onLoad) { + onLoad.call(this) + } + }) + el.addEventListener('error', function () { + reject() + if (onError) { + onError() + } + }) + }) + + if (src) { + ScriptCache.set(src, loadPromise) + LoadCache.add(cacheKey) + } + + if (dangerouslySetInnerHTML) { + el.innerHTML = dangerouslySetInnerHTML.__html || '' + } else if (children) { + el.textContent = + typeof children === 'string' + ? children + : Array.isArray(children) + ? children.join('') + : '' + } else if (src) { + el.src = src + } + + for (const [k, value] of Object.entries(props)) { + if (value === undefined) { + continue + } + + const attr = DOMAttributeNames[k] || k.toLowerCase() + el.setAttribute(attr, value) + } + + document.body.appendChild(el) +} + +export default function Script(props: Props) { + const { + src = '', + onLoad = () => {}, + dangerouslySetInnerHTML, + children = '', + strategy = 'defer', + onError, + preload = false, + ...restProps + } = props + + // Context is available only during SSR + const { updateScripts, scripts } = useContext(HeadManagerContext) + + useEffect(() => { + if (strategy === 'defer') { + loadScript(props) + } else if (strategy === 'lazy') { + window.addEventListener('load', () => { + requestIdleCallback(() => loadScript(props)) + }) + } + }, [strategy, props]) + + if (strategy === 'dangerouslyBlockRendering') { + const syncProps: Props = { ...restProps } + + for (const [k, value] of Object.entries({ + src, + onLoad, + onError, + dangerouslySetInnerHTML, + children, + })) { + if (!value) { + continue + } + if (k === 'children') { + syncProps.dangerouslySetInnerHTML = { + __html: + typeof value === 'string' + ? value + : Array.isArray(value) + ? value.join('') + : '', + } + } else { + ;(syncProps as any)[k] = value + } + } + + return +
index
+ + ) +} + +export default Page diff --git a/test/integration/script-loader/pages/page1.js b/test/integration/script-loader/pages/page1.js new file mode 100644 index 0000000000000000000000000000000000000000..9fea586b4a736436f26a3e1b66fb9a0280347ed3 --- /dev/null +++ b/test/integration/script-loader/pages/page1.js @@ -0,0 +1,16 @@ +import Script from 'next/experimental-script' + +const Page = () => { + return ( +
+ +
page1
+
+ ) +} + +export default Page diff --git a/test/integration/script-loader/pages/page2.js b/test/integration/script-loader/pages/page2.js new file mode 100644 index 0000000000000000000000000000000000000000..cb779f0a90f3d7c567bace02e9f6ad388085cf74 --- /dev/null +++ b/test/integration/script-loader/pages/page2.js @@ -0,0 +1,16 @@ +import Script from 'next/experimental-script' + +const Page = () => { + return ( +
+ +
page2
+
+ ) +} + +export default Page diff --git a/test/integration/script-loader/pages/page3.js b/test/integration/script-loader/pages/page3.js new file mode 100644 index 0000000000000000000000000000000000000000..5ee81d1a30dba04a47881fb2e75563dcb4639226 --- /dev/null +++ b/test/integration/script-loader/pages/page3.js @@ -0,0 +1,23 @@ +import Script from 'next/experimental-script' + +const Page = () => { + return ( +
+ + +
page3
+
+ ) +} + +export default Page diff --git a/test/integration/script-loader/pages/page4.js b/test/integration/script-loader/pages/page4.js new file mode 100644 index 0000000000000000000000000000000000000000..5e24bdfb784f917e910bf5b6809fd05b314f892d --- /dev/null +++ b/test/integration/script-loader/pages/page4.js @@ -0,0 +1,38 @@ +import Script from 'next/experimental-script' + +const url = + 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js' + +const Page = () => { + return ( +
+ + + +
+
+ ) +} + +export default Page diff --git a/test/integration/script-loader/styles/styles.css b/test/integration/script-loader/styles/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..301c8e48e6e18d80f84d28454c1274bc4b1a824e --- /dev/null +++ b/test/integration/script-loader/styles/styles.css @@ -0,0 +1,6 @@ +body { + font-family: 'Arial', sans-serif; + padding: 20px 20px 60px; + max-width: 680px; + margin: 0 auto; +} diff --git a/test/integration/script-loader/test/index.test.js b/test/integration/script-loader/test/index.test.js new file mode 100644 index 0000000000000000000000000000000000000000..a2a764baad74842f4a3a47707de960478eb6ca63 --- /dev/null +++ b/test/integration/script-loader/test/index.test.js @@ -0,0 +1,149 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { + renderViaHTTP, + nextServer, + startApp, + stopApp, + nextBuild, + waitFor, +} from 'next-test-utils' +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' + +jest.setTimeout(1000 * 60 * 5) + +let appDir = join(__dirname, '..') +let server +let appPort + +describe('Script Loader', () => { + beforeAll(async () => { + await nextBuild(appDir) + const app = nextServer({ + dir: appDir, + dev: false, + quiet: true, + }) + + server = await startApp(app) + appPort = server.address().port + }) + afterAll(() => { + stopApp(server) + }) + + it('priority defer', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + await waitFor(1000) + + const script = await browser.elementById('script') + const src = await script.getAttribute('src') + const scriptPreload = await browser.elementsByCss( + `link[rel=preload][href="${src}"]` + ) + const endScripts = await browser.elementsByCss( + '#script ~ script[src^="/_next/static/"]' + ) + const endPreloads = await browser.elementsByCss( + `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` + ) + + // Renders script tag + expect(script).toBeDefined() + // Renders preload + expect(scriptPreload.length).toBeGreaterThan(0) + // Script is inserted at the end + expect(endScripts.length).toBe(0) + //Preload is defined at the end + expect(endPreloads.length).toBe(0) + } finally { + if (browser) await browser.close() + } + }) + + it('priority lazy', async () => { + let browser + try { + browser = await webdriver(appPort, '/page3') + + await browser.waitForElementByCss('#onload-div') + await waitFor(1000) + + const script = await browser.elementById('script') + const endScripts = await browser.elementsByCss( + '#script ~ script[src^="/_next/static/"]' + ) + + // Renders script tag + expect(script).toBeDefined() + // Script is inserted at the end + expect(endScripts.length).toBe(0) + } finally { + if (browser) await browser.close() + } + }) + + it('priority eager', async () => { + const html = await renderViaHTTP(appPort, '/page1') + const $ = cheerio.load(html) + + const script = $('#script') + const src = script.attr('src') + + // Renders script tag + expect(script).toBeDefined() + // Preload is inserted at the beginning + expect( + $( + `link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]` + ).length && + !$( + `link[rel=preload][href^="/_next/static/chunks/main"] ~ link[rel=preload][href="${src}"]` + ).length + ).toBeTruthy() + + // Preload is inserted after fonts and CSS + expect( + $( + `link[rel=stylesheet][href^="/_next/static/css"] ~ link[rel=preload][href="${src}"]` + ).length + ).toBeGreaterThan(0) + expect( + $( + `link[rel=stylesheet][href="https://fonts.googleapis.com/css?family=Voces"] ~ link[rel=preload][href="${src}"]` + ).length + ).toBeGreaterThan(0) + + // Script is inserted before NextScripts + expect( + $('#__NEXT_DATA__ ~ #script ~ script[src^="/_next/static/chunks/main"]') + .length + ).toBeGreaterThan(0) + }) + + it('priority dangerouslyBlockRendering', async () => { + const html = await renderViaHTTP(appPort, '/page2') + const $ = cheerio.load(html) + + // Script is inserted in place + expect($('.container #script').length).toBeGreaterThan(0) + }) + + it('onloads fire correctly', async () => { + let browser + try { + browser = await webdriver(appPort, '/page4') + await waitFor(3000) + + const text = await browser.elementById('text').text() + + expect(text).toBe('aaabbbccc') + } finally { + if (browser) await browser.close() + } + }) +})