Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds web worker support to <Script /> using Partytown #34244

Merged
merged 7 commits into from Mar 11, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Expand Up @@ -25,6 +25,7 @@ packages/next-codemod/**/*.d.ts
packages/next-env/**/*.d.ts
packages/create-next-app/templates/**
test/integration/eslint/**
test/integration/script-loader/**/*
test/development/basic/legacy-decorators/**/*
test/production/emit-decorator-metadata/**/*.js
test-timings.json
Expand Down
3 changes: 3 additions & 0 deletions docs/api-reference/next/script.md
Expand Up @@ -41,6 +41,9 @@ The loading strategy of the script.
| `beforeInteractive` | Load script before the page becomes interactive |
| `afterInteractive` | Load script immediately after the page becomes interactive |
| `lazyOnload` | Load script during browser idle time |
| `worker` | Load script in a web worker |

> **Note: `worker` is an experimental strategy that can only be used when enabled in `next.config.js`. See [Off-loading Scripts To A Web Worker](/docs/basic-features/script#off-loading-scripts-to-a-web-worker-experimental).**

### onLoad

Expand Down
116 changes: 115 additions & 1 deletion docs/basic-features/script.md
Expand Up @@ -67,8 +67,9 @@ With `next/script`, you decide when to load your third-party script by using the
There are three different loading strategies that can be used:

- `beforeInteractive`: Load before the page is interactive
- `afterInteractive`: (**default**): Load immediately after the page becomes interactive
- `afterInteractive`: (**default**) Load immediately after the page becomes interactive
- `lazyOnload`: Load during idle time
- `worker`: (experimental) Load in a web worker

#### beforeInteractive

Expand Down Expand Up @@ -123,6 +124,119 @@ Examples of scripts that do not need to load immediately and can be lazy-loaded
- Chat support plugins
- Social media widgets

### Off-loading Scripts To A Web Worker (experimental)

> **Note: The `worker` strategy is not yet stable and can cause unexpected issues in your application. Use with caution.**

Scripts that use the `worker` strategy are relocated and executed in a web worker with [Partytown](https://partytown.builder.io/). This can improve the performance of your site by dedicating the main thread to the rest of your application code.

This strategy is still experimental and can only be used if the `nextScriptWorkers` flag is enabled in `next.config.js`:

```js
module.exports = {
experimental: {
nextScriptWorkers: true,
},
}
```

Then, run `next` (normally `npm run dev` or `yarn dev`) and Next.js will guide you through the installation of the required packages to finish the setup:

```bash
npm run dev

# You'll see instructions like these:
#
# Please install Partytown by running:
#
# npm install @builder.io/partytown
#
# ...
```

Once setup is complete, defining `strategy="worker` will automatically instantiate Partytown in your application and off-load the script to a web worker.

```jsx
<Script src="https://example.com/analytics.js" strategy="worker" />
```

There are a number of trade-offs that need to be considered when loading a third-party script in a web worker. Please see Partytown's [Trade-Offs](https://partytown.builder.io/trade-offs) documentation for more information.

#### Configuration

Although the `worker` strategy does not require any additional configuration to work, Partytown supports the use of a config object to modify some of its settings, including enabling `debug` mode and forwarding events and triggers.

If you would like to add additonal configuration options, you can include it within the `<Head />` component used in a [custom `_document.js`](/docs/advanced-features/custom-document.md):

```jsx
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<script
data-partytown-config
dangerouslySetInnerHTML={{
__html: `
partytown = {
lib: "/_next/static/~partytown/",
debug: true
};
`,
}}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
ijjk marked this conversation as resolved.
Show resolved Hide resolved

export default MyDocument
```

In order to modify Partytown's configuration, the following conditions must be met:

1. The `data-partytown-config` attribute must be used in order to overwrite the default configuration used by Next.js
2. Unless you decide to save Partytown's library files in a separate directory, the `lib: "/_next/static/~partytown/"` property and value must be included in the configuration object in order to let Partytown know where Next.js stores the necessary static files.

> **Note**: If you are using an [asset prefix](/docs/api-reference/next.config.js/cdn-support-with-asset-prefix.md) and would like to modify Partytown's default configuration, you must include it as part of the `lib` path:
>
> ```jsx
> import { assetPrefix } from '../next.config'
>
> class MyDocument extends Document {
> render() {
> return (
> <Html>
> <Head>
> <script
> data-partytown-config=""
> dangerouslySetInnerHTML={{
> __html: `
> partytown = {
> lib: "${assetPrefix}/_next/static/~partytown/"
> };
> `,
> }}
> />
> </Head>
> // ...
> </Html>
> )
> }
> }
>
> export default MyDocument
> ```
ijjk marked this conversation as resolved.
Show resolved Hide resolved

Take a look at Partytown's [configuration options](https://partytown.builder.io/configuration) to see the full list of other properties that can be added.

### Inline Scripts

Inline scripts, or scripts not loaded from an external file, are also supported by the Script component. They can be written by placing the JavaScript within curly braces:
Expand Down
12 changes: 12 additions & 0 deletions packages/next/build/index.ts
Expand Up @@ -30,6 +30,7 @@ import loadCustomRoutes, {
import { nonNullable } from '../lib/non-nullable'
import { recursiveDelete } from '../lib/recursive-delete'
import { verifyAndLint } from '../lib/verifyAndLint'
import { verifyPartytownSetup } from '../lib/verify-partytown-setup'
import { verifyTypeScriptSetup } from '../lib/verifyTypeScriptSetup'
import {
BUILD_ID_FILE,
Expand Down Expand Up @@ -2113,6 +2114,17 @@ export default async function build(
console.log('')
}

if (Boolean(config.experimental.nextScriptWorkers)) {
await nextBuildSpan
.traceChild('verify-partytown-setup')
.traceAsyncFn(async () => {
await verifyPartytownSetup(
dir,
join(distDir, CLIENT_STATIC_FILES_PATH)
)
})
}

await nextBuildSpan
.traceChild('telemetry-flush')
.traceAsyncFn(() => telemetry.flush())
Expand Down
5 changes: 5 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -1342,6 +1342,9 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_OPTIMIZE_CSS': JSON.stringify(
config.experimental.optimizeCss && !dev
),
'process.env.__NEXT_SCRIPT_WORKERS': JSON.stringify(
config.experimental.nextScriptWorkers && !dev
),
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
config.experimental.scrollRestoration
),
Expand Down Expand Up @@ -1550,6 +1553,7 @@ export default async function getBaseWebpackConfig(
webpack5Config.module!.parser = {
javascript: {
url: 'relative',
commonjsMagicComments: true,
},
}
webpack5Config.module!.generator = {
Expand Down Expand Up @@ -1610,6 +1614,7 @@ export default async function getBaseWebpackConfig(
reactMode: config.experimental.reactMode,
optimizeFonts: config.optimizeFonts,
optimizeCss: config.experimental.optimizeCss,
nextScriptWorkers: config.experimental.nextScriptWorkers,
scrollRestoration: config.experimental.scrollRestoration,
basePath: config.basePath,
pageEnv: config.experimental.pageEnv,
Expand Down
Expand Up @@ -192,6 +192,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) {
defaultLocale,
domainLocales: i18n?.domains,
optimizeCss: process.env.__NEXT_OPTIMIZE_CSS,
nextScriptWorkers: process.env.__NEXT_SCRIPT_WORKERS,
crossOrigin: process.env.__NEXT_CROSS_ORIGIN,
},
options
Expand Down
10 changes: 7 additions & 3 deletions packages/next/client/script.tsx
Expand Up @@ -8,7 +8,7 @@ const ScriptCache = new Map()
const LoadCache = new Set()

export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive'
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
id?: string
onLoad?: (e: any) => void
onError?: (e: any) => void
Expand Down Expand Up @@ -99,6 +99,10 @@ const loadScript = (props: ScriptProps): void => {
el.setAttribute(attr, value)
}

if (strategy === 'worker') {
el.setAttribute('type', 'text/partytown')
}

el.setAttribute('data-nscript', strategy)

document.body.appendChild(el)
Expand Down Expand Up @@ -150,9 +154,9 @@ function Script(props: ScriptProps): JSX.Element | null {
}
}, [props, strategy])

if (strategy === 'beforeInteractive') {
if (strategy === 'beforeInteractive' || strategy === 'worker') {
if (updateScripts) {
scripts.beforeInteractive = (scripts.beforeInteractive || []).concat([
scripts[strategy] = (scripts[strategy] || []).concat([
{
src,
onLoad,
Expand Down
1 change: 1 addition & 0 deletions packages/next/export/index.ts
Expand Up @@ -384,6 +384,7 @@ export default async function exportApp(
runtime: nextConfig.experimental.runtime,
crossOrigin: nextConfig.crossOrigin,
optimizeCss: nextConfig.experimental.optimizeCss,
nextScriptWorkers: nextConfig.experimental.nextScriptWorkers,
optimizeFonts: nextConfig.optimizeFonts,
reactRoot: nextConfig.experimental.reactRoot || false,
}
Expand Down
88 changes: 88 additions & 0 deletions packages/next/lib/verify-partytown-setup.ts
@@ -0,0 +1,88 @@
import { promises } from 'fs'
import chalk from 'next/dist/compiled/chalk'

import path from 'path'
import {
hasNecessaryDependencies,
NecessaryDependencies,
} from './has-necessary-dependencies'
import { isYarn } from './is-yarn'
import { fileExists } from './file-exists'
import { FatalError } from './fatal-error'
import { recursiveDelete } from './recursive-delete'
import * as Log from '../build/output/log'

async function missingDependencyError(dir: string) {
throw new FatalError(
chalk.bold.red(
"It looks like you're trying to use Partytown with next/script but do not have the required package(s) installed."
) +
'\n\n' +
chalk.bold(`Please install Partytown by running:`) +
'\n\n' +
`\t${chalk.bold.cyan(
(await isYarn(dir))
? 'yarn add @builder.io/partytown'
: 'npm install @builder.io/partytown'
)}` +
'\n\n' +
chalk.bold(
`If you are not trying to use Partytown, please disable the experimental ${chalk.cyan(
'"nextScriptWorkers"'
)} flag in next.config.js.`
) +
'\n'
)
}

async function copyPartytownStaticFiles(
deps: NecessaryDependencies,
staticDir: string
) {
const partytownLibDir = path.join(staticDir, '~partytown')
const hasPartytownLibDir = await fileExists(partytownLibDir, 'directory')

if (hasPartytownLibDir) {
await recursiveDelete(partytownLibDir)
await promises.rmdir(partytownLibDir)
}

const { copyLibFiles } = await Promise.resolve(
require(path.join(deps.resolved.get('@builder.io/partytown')!, '../utils'))
)

await copyLibFiles(partytownLibDir)
}

export async function verifyPartytownSetup(
dir: string,
targetDir: string
): Promise<void> {
try {
const partytownDeps: NecessaryDependencies = await hasNecessaryDependencies(
dir,
[{ file: '@builder.io/partytown', pkg: '@builder.io/partytown' }]
)

if (partytownDeps.missing?.length > 0) {
await missingDependencyError(dir)
} else {
try {
await copyPartytownStaticFiles(partytownDeps, targetDir)
} catch (err) {
Log.warn(
`Partytown library files could not be copied to the static directory. Please ensure that ${chalk.bold.cyan(
'@builder.io/partytown'
)} is installed as a dependency.`
)
}
}
} catch (err) {
// Don't show a stack trace when there is an error due to missing dependencies
if (err instanceof FatalError) {
console.error(err.message)
process.exit(1)
}
throw err
}
}