diff --git a/docs/basic-features/script.md b/docs/basic-features/script.md index d46bb2fe95712dc..e673936661fe9df 100644 --- a/docs/basic-features/script.md +++ b/docs/basic-features/script.md @@ -73,13 +73,30 @@ There are three different loading strategies that can be used: #### beforeInteractive -Scripts that load with the `beforeInteractive` strategy are injected into the initial HTML from the server and run before self-bundled JavaScript is executed. This strategy should be used for any critical scripts that need to be fetched and executed before the page is interactive. +Scripts that load with the `beforeInteractive` strategy are injected into the initial HTML from the server and run before self-bundled JavaScript is executed. This strategy should be used for any critical scripts that need to be fetched and executed before any page becomes interactive. This strategy only works inside **\_document.js** and is designed to load scripts that are needed by the entire site (i.e. the script will load when any page in the application has been loaded server-side). + +The reason `beforeInteractive` was designed to work only inside `\_document.js` is to support streaming and Suspense functionality. Outside of the `_document`, it's not possible to guarantee the timing or ordering of `beforeInteractive` scripts. ```jsx - + + + ) +} ``` Examples of scripts that should be loaded as soon as possible with this strategy include: diff --git a/errors/manifest.json b/errors/manifest.json index 20be7781e407bd3..6141e5076ce840c 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -562,6 +562,10 @@ "title": "script-in-document-page", "path": "/errors/no-script-in-document-page.md" }, + { + "title": "before-interactive-script-outside-document", + "path": "/errors/no-before-interactive-script-outside-document.md" + }, { "title": "script-component-in-head-component", "path": "/errors/no-script-component-in-head-component.md" diff --git a/errors/no-before-interactive-script-outside-document.md b/errors/no-before-interactive-script-outside-document.md new file mode 100644 index 000000000000000..9ba97c59e80539f --- /dev/null +++ b/errors/no-before-interactive-script-outside-document.md @@ -0,0 +1,33 @@ +# beforeInteractive Script component outside \_document.js + +#### Why This Error Occurred + +You can't use the `next/script` component with the `beforeInteractive` strategy outside the `_document.js` page. That's because `beforeInteractive` strategy only works inside **\_document.js** and is designed to load scripts that are needed by the entire site (i.e. the script will load when any page in the application has been loaded server-side). + +#### Possible Ways to Fix It + +If you want a global script, move the script inside `_document.js` page. + +```jsx +// In _document.js +import { Html, Head, Main, NextScript } from 'next/document' +import Script from 'next/script' + +export default function Document() { + return ( + + + +
+ + + + + ) +} +``` + +- [next-script](https://nextjs.org/docs/basic-features/script#usage) diff --git a/packages/eslint-plugin-next/lib/index.js b/packages/eslint-plugin-next/lib/index.js index f6036140f0d4b9f..98554159b660198 100644 --- a/packages/eslint-plugin-next/lib/index.js +++ b/packages/eslint-plugin-next/lib/index.js @@ -13,13 +13,13 @@ module.exports = { 'link-passhref': require('./rules/link-passhref'), 'no-document-import-in-page': require('./rules/no-document-import-in-page'), 'no-head-import-in-document': require('./rules/no-head-import-in-document'), - 'no-script-in-document': require('./rules/no-script-in-document'), 'no-script-component-in-head': require('./rules/no-script-component-in-head'), 'no-server-import-in-page': require('./rules/no-server-import-in-page'), 'no-typos': require('./rules/no-typos'), 'no-duplicate-head': require('./rules/no-duplicate-head'), 'inline-script-id': require('./rules/inline-script-id'), 'next-script-for-ga': require('./rules/next-script-for-ga'), + 'no-before-interactive-script-outside-document': require('./rules/no-before-interactive-script-outside-document'), 'no-assign-module-variable': require('./rules/no-assign-module-variable'), }, configs: { @@ -40,12 +40,12 @@ module.exports = { '@next/next/next-script-for-ga': 1, '@next/next/no-document-import-in-page': 2, '@next/next/no-head-import-in-document': 2, - '@next/next/no-script-in-document': 2, '@next/next/no-script-component-in-head': 2, '@next/next/no-server-import-in-page': 2, '@next/next/no-typos': 1, '@next/next/no-duplicate-head': 2, '@next/next/inline-script-id': 2, + '@next/next/no-before-interactive-script-outside-document': 1, '@next/next/no-assign-module-variable': 2, }, }, diff --git a/packages/eslint-plugin-next/lib/rules/no-before-interactive-script-outside-document.js b/packages/eslint-plugin-next/lib/rules/no-before-interactive-script-outside-document.js new file mode 100644 index 000000000000000..0eeaf97cdd18245 --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/no-before-interactive-script-outside-document.js @@ -0,0 +1,51 @@ +const path = require('path') + +module.exports = { + meta: { + docs: { + description: + 'Disallow using next/script beforeInteractive strategy outside the next/_document component', + recommended: true, + url: 'https://nextjs.org/docs/messages/no-before-interactive-script-outside-document', + }, + }, + create: function (context) { + let scriptImportName = null + + return { + 'ImportDeclaration[source.value="next/script"] > ImportDefaultSpecifier'( + node + ) { + scriptImportName = node.local.name + }, + JSXOpeningElement(node) { + if (!scriptImportName) { + return + } + + if (node.name && node.name.name !== scriptImportName) { + return + } + + const strategy = node.attributes.find( + (child) => child.name && child.name.name === 'strategy' + ) + + if (!strategy || strategy?.value?.value !== 'beforeInteractive') { + return + } + + const document = context.getFilename().split('pages')[1] + if (document && path.parse(document).name.startsWith('_document')) { + return + } + + context.report({ + node, + message: + 'next/script beforeInteractive strategy should only be used inside next/_document. See: https://nextjs.org/docs/messages/no-before-interactive-script-outside-document', + }) + }, + } + }, +} diff --git a/packages/eslint-plugin-next/lib/rules/no-script-in-document.js b/packages/eslint-plugin-next/lib/rules/no-script-in-document.js deleted file mode 100644 index a503975d812b919..000000000000000 --- a/packages/eslint-plugin-next/lib/rules/no-script-in-document.js +++ /dev/null @@ -1,30 +0,0 @@ -const path = require('path') - -module.exports = { - meta: { - docs: { - description: 'Disallow importing next/script inside pages/_document.js', - recommended: true, - url: 'https://nextjs.org/docs/messages/no-script-in-document-page', - }, - }, - create: function (context) { - return { - ImportDeclaration(node) { - if (node.source.value !== 'next/script') { - return - } - - const document = context.getFilename().split('pages')[1] - if (!document || !path.parse(document).name.startsWith('_document')) { - return - } - - context.report({ - node, - message: `next/script should not be used in pages/_document.js. See: https://nextjs.org/docs/messages/no-script-in-document-page`, - }) - }, - } - }, -} diff --git a/packages/next/client/script.tsx b/packages/next/client/script.tsx index 14e44e836bd4bec..4ed0f6a761c1ba2 100644 --- a/packages/next/client/script.tsx +++ b/packages/next/client/script.tsx @@ -108,14 +108,14 @@ const loadScript = (props: ScriptProps): void => { document.body.appendChild(el) } -function handleClientScriptLoad(props: ScriptProps) { +export function handleClientScriptLoad(props: ScriptProps) { const { strategy = 'afterInteractive' } = props - if (strategy === 'afterInteractive') { - loadScript(props) - } else if (strategy === 'lazyOnload') { + if (strategy === 'lazyOnload') { window.addEventListener('load', () => { requestIdleCallback(() => loadScript(props)) }) + } else { + loadScript(props) } } @@ -129,8 +129,20 @@ function loadLazyScript(props: ScriptProps) { } } +function addBeforeInteractiveToCache() { + const scripts = [ + ...document.querySelectorAll('[data-nscript="beforeInteractive"]'), + ...document.querySelectorAll('[data-nscript="beforePageRender"]'), + ] + scripts.forEach((script) => { + const cacheKey = script.id || script.getAttribute('src') + LoadCache.add(cacheKey) + }) +} + export function initScriptLoader(scriptLoaderItems: ScriptProps[]) { scriptLoaderItems.forEach(handleClientScriptLoad) + addBeforeInteractiveToCache() } function Script(props: ScriptProps): JSX.Element | null { diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index afbfd890977051d..95399d2bb986c30 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -586,6 +586,7 @@ export async function renderToHTML( App.getInitialProps === (App as any).origGetInitialProps const hasPageGetInitialProps = !!(Component as any)?.getInitialProps + const hasPageScripts = (Component as any)?.unstable_scriptLoader const pageIsDynamic = isDynamicRoute(pathname) @@ -774,6 +775,14 @@ export async function renderToHTML( let head: JSX.Element[] = defaultHead(inAmpMode) + let initialScripts: any = {} + if (hasPageScripts) { + initialScripts.beforeInteractive = [] + .concat(hasPageScripts()) + .filter((script: any) => script.props.strategy === 'beforeInteractive') + .map((script: any) => script.props) + } + let scriptLoader: any = {} const nextExport = !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) @@ -823,7 +832,7 @@ export async function renderToHTML( updateScripts: (scripts) => { scriptLoader = scripts }, - scripts: {}, + scripts: initialScripts, mountedInstances: new Set(), }} > diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 5e858c1f60b3265..a2bdea45efd973f 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -16,6 +16,7 @@ import { isAssetError, markAssetError, } from '../../../client/route-loader' +import { handleClientScriptLoad } from '../../../client/script' import isError, { getProperError } from '../../../lib/is-error' import { denormalizePagePath } from '../../../server/denormalize-page-path' import { normalizeLocalePath } from '../i18n/normalize-locale-path' @@ -1275,6 +1276,15 @@ export default class Router implements BaseRouter { ) let { error, props, __N_SSG, __N_SSP } = routeInfo + const component: any = routeInfo.Component + if (component && component.unstable_scriptLoader) { + const scripts = [].concat(component.unstable_scriptLoader()) + + scripts.forEach((script: any) => { + handleClientScriptLoad(script.props) + }) + } + // handle redirect on client-transition if ((__N_SSG || __N_SSP) && props) { if (props.pageProps && props.pageProps.__N_REDIRECT) { diff --git a/test/e2e/next-script-worker-strategy/index.test.ts b/test/e2e/next-script-worker-strategy/index.test.ts index f279aab0cfc1c07..632884aae548aa5 100644 --- a/test/e2e/next-script-worker-strategy/index.test.ts +++ b/test/e2e/next-script-worker-strategy/index.test.ts @@ -12,7 +12,7 @@ describe('experimental.nextScriptWorkers: false with no Partytown dependency', ( files: { 'pages/index.js': ` import Script from 'next/script' - + export default function Page() { return ( <> @@ -68,7 +68,7 @@ describe('experimental.nextScriptWorkers: true with required Partytown dependenc files: { 'pages/index.js': ` import Script from 'next/script' - + export default function Page() { return ( <> @@ -123,7 +123,7 @@ describe('experimental.nextScriptWorkers: true with required Partytown dependenc `document.querySelectorAll('script[type="text/partytown"]').length` ) - expect(predefinedWorkerScripts).toEqual(1) + expect(predefinedWorkerScripts).toBeGreaterThan(0) await waitFor(1000) @@ -132,7 +132,7 @@ describe('experimental.nextScriptWorkers: true with required Partytown dependenc `document.querySelectorAll('script[type="text/partytown-x"]').length` ) - expect(processedWorkerScripts).toEqual(1) + expect(processedWorkerScripts).toBeGreaterThan(0) } finally { if (browser) await browser.close() } @@ -183,7 +183,7 @@ describe('experimental.nextScriptWorkers: true with config override', () => { `, 'pages/index.js': ` import Script from 'next/script' - + export default function Page() { return ( <> diff --git a/test/integration/script-loader/base/pages/_app.js b/test/integration/script-loader/base/pages/_app.js index becbc64e9beb31a..f811f147a872e11 100644 --- a/test/integration/script-loader/base/pages/_app.js +++ b/test/integration/script-loader/base/pages/_app.js @@ -15,11 +15,6 @@ function MyApp({ Component, pageProps }) { src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=documentLazyOnload" strategy="lazyOnload" /> - + + +
+ +
+ + + ) } diff --git a/test/integration/script-loader/base/pages/page1.js b/test/integration/script-loader/base/pages/page1.js index 8ef106350652e94..9ffa5f252e48ecf 100644 --- a/test/integration/script-loader/base/pages/page1.js +++ b/test/integration/script-loader/base/pages/page1.js @@ -3,14 +3,19 @@ import Script from 'next/script' const Page = () => { return (
-
page1
) } +Page.unstable_scriptLoader = () => { + return ( + + ) +} + export default Page diff --git a/test/integration/script-loader/base/pages/page6.js b/test/integration/script-loader/base/pages/page6.js new file mode 100644 index 000000000000000..9042f62c12df452 --- /dev/null +++ b/test/integration/script-loader/base/pages/page6.js @@ -0,0 +1,16 @@ +import Script from 'next/script' + +const Page = () => { + return ( +
+ +
page6
+
+ ) +} + +export default Page diff --git a/test/integration/script-loader/test/index.test.js b/test/integration/script-loader/test/index.test.js index c07e4b8a221deff..44355c135bb9373 100644 --- a/test/integration/script-loader/test/index.test.js +++ b/test/integration/script-loader/test/index.test.js @@ -127,7 +127,27 @@ describe('Next.js Script - Primary Strategies', () => { } test('scriptBeforeInteractive') - test('documentBeforeInteractive') + }) + + // Warning - Will be removed in the next major release + it('priority beforeInteractive - older version', async () => { + const html = await renderViaHTTP(appPort, '/page6') + const $ = cheerio.load(html) + + function test(id) { + const script = $(`#${id}`) + + // Renders script tag + expect(script.length).toBe(1) + expect(script.attr('data-nscript')).toBeDefined() + + // Script is inserted before NextScripts + expect( + $(`#${id} ~ script[src^="/_next/static/chunks/main"]`).length + ).toBeGreaterThan(0) + } + + test('scriptBeforePageRenderOld') }) it('priority beforeInteractive on navigate', async () => { @@ -137,7 +157,7 @@ describe('Next.js Script - Primary Strategies', () => { // beforeInteractive scripts should load once let documentBIScripts = await browser.elementsByCss( - '[src$="documentBeforeInteractive"]' + '[src$="scriptBeforeInteractive"]' ) expect(documentBIScripts.length).toBe(1) @@ -146,16 +166,11 @@ describe('Next.js Script - Primary Strategies', () => { await browser.waitForElementByCss('.container') - const script = await browser.elementById('scriptBeforeInteractive') - // Ensure beforeInteractive script isn't duplicated on navigation documentBIScripts = await browser.elementsByCss( - '[src$="documentBeforeInteractive"]' + '[src$="scriptBeforeInteractive"]' ) expect(documentBIScripts.length).toBe(1) - - // Renders script tag - expect(script).toBeDefined() } finally { if (browser) await browser.close() } diff --git a/test/unit/eslint-plugin-next/no-before-interactive-script-outside-document.ts b/test/unit/eslint-plugin-next/no-before-interactive-script-outside-document.ts new file mode 100644 index 000000000000000..bd5eb6d8184cccd --- /dev/null +++ b/test/unit/eslint-plugin-next/no-before-interactive-script-outside-document.ts @@ -0,0 +1,103 @@ +import rule from '@next/eslint-plugin-next/lib/rules/no-before-interactive-script-outside-document' +import { RuleTester } from 'eslint' +;(RuleTester as any).setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) +const ruleTester = new RuleTester() + +ruleTester.run('no-before-interactive-script-outside-document', rule, { + valid: [ + { + code: ` + import Document, { Html, Main, NextScript } from 'next/document' + import Script from 'next/script' + + class MyDocument extends Document { + render() { + return ( + + + + + +
+ + + + + ) + } + } + + export default MyDocument + `, + filename: 'pages/_document.js', + }, + { + code: ` + import Document, { Html, Main, NextScript } from 'next/document' + import ScriptComponent from 'next/script' + + class MyDocument extends Document { + render() { + return ( + + + + + +
+ + + + + ) + } + } + + export default MyDocument + `, + filename: 'pages/_document.tsx', + }, + ], + + invalid: [ + { + code: ` + import Head from "next/head"; + import Script from "next/script"; + + export default function Index() { + return ( + + ); + }`, + filename: 'pages/index.js', + errors: [ + { + message: + 'next/script beforeInteractive strategy should only be used inside next/_document. See: https://nextjs.org/docs/messages/no-before-interactive-script-outside-document', + }, + ], + }, + ], +}) diff --git a/test/unit/eslint-plugin-next/no-script-in-document.test.ts b/test/unit/eslint-plugin-next/no-script-in-document.test.ts deleted file mode 100644 index d019e6d0018c86b..000000000000000 --- a/test/unit/eslint-plugin-next/no-script-in-document.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import rule from '@next/eslint-plugin-next/lib/rules/no-script-in-document' -import { RuleTester } from 'eslint' -;(RuleTester as any).setDefaultConfig({ - parserOptions: { - ecmaVersion: 2018, - sourceType: 'module', - ecmaFeatures: { - modules: true, - jsx: true, - }, - }, -}) -const ruleTester = new RuleTester() - -ruleTester.run('no-script-import-in-document', rule, { - valid: [ - { - code: `import Document, { Html, Head, Main, NextScript } from 'next/document' - - class MyDocument extends Document { - static async getInitialProps(ctx) { - //... - } - - render() { - return ( - - - - ) - } - } - - export default MyDocument - `, - filename: 'pages/_document.js', - }, - { - code: `import Document, { Html, Head, Main, NextScript } from 'next/document' - - class MyDocument extends Document { - render() { - return ( - - - - - - ) - } - } - - export default MyDocument - `, - filename: 'pages/_document.tsx', - }, - ], - invalid: [ - { - code: ` - import Document, { Html, Main, NextScript } from 'next/document' - import Script from 'next/script' - - class MyDocument extends Document { - render() { - return ( - - - - ) - } - } - - export default MyDocument - `, - filename: 'pages/_document.js', - errors: [ - { - message: `next/script should not be used in pages/_document.js. See: https://nextjs.org/docs/messages/no-script-in-document-page`, - }, - ], - }, - { - code: ` - import Document, { Html, Main, NextScript } from 'next/document' - import NextScriptTag from 'next/script' - - class MyDocument extends Document { - render() { - return ( - - - - - -
- - - - - ) - } - } - - export default MyDocument - `, - filename: 'pages/_document.js', - errors: [ - { - message: `next/script should not be used in pages/_document.js. See: https://nextjs.org/docs/messages/no-script-in-document-page`, - }, - ], - }, - ], -})