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

[Script] Remove next/script code from main bundle unless it's used in a page #37567

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/next/build/check-next-script-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { promises } from 'fs'
import { join } from 'path'

export async function checkNextScriptImport(
pagePaths: string[],
pagesDir: string
): Promise<boolean> {
for (const page of pagePaths) {
const fileContent = await promises.readFile(join(pagesDir, page), 'utf8')
if (/import[^}]*.*('|")next\/script('|")/.test(fileContent)) {
Copy link
Contributor

@SukkaW SukkaW Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern here can not match the following cases:

import Script from 'next/dist/client/script';
const Script = require('next/script');

IMHO we could simply use fileContent.includes('next/script') || fileContent.includes('next/dist/client/script').

Or, maybe we could implement an swc AST based build analysis, see: next/build/analysis/extract-const-value, next/build/analysis/get-page-static-info and next/build/analysis/parse-module.

Copy link
Contributor

@SukkaW SukkaW Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using await inside for...of loop degrades the performance by a lot. Promise.all should be used instead:

  let isNextScriptImported = false;
  await Promise.all(pagePaths.map(
    async (page) => {
      // bail out early
      if (isNextScriptImported) return;
      const fileContent = await promises.readFile(join(pagesDir, page), 'utf8')
      if (fileContent.includes('next/script') || fileContent.includes('next/dist/client/script')) {
        isNextScriptImported = true;
      }
    }
  ))
  return isNextScriptImported;

Copy link
Collaborator Author

@housseindjirdeh housseindjirdeh Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Updated the PR to use Promise.all for now but I'm going to switch to using swc and try to use #37589 :)

return true
}
}
Copy link
Collaborator Author

@housseindjirdeh housseindjirdeh Jun 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way to detect next/script usage during build? I first tried writing a webpack plugin that checks for the script module after all modules have finished compiling, but quickly realized I can't use DefinePlugin to define env variables after bundling.

Copy link
Member

@styfle styfle Jun 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we utilize SWC? See #37589

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh this is great. I'll hold off on this PR until that gets merged and use scanNextPackageUsages instead


return false
}
8 changes: 8 additions & 0 deletions packages/next/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { getPageStaticInfo } from './analysis/get-page-static-info'
import { createEntrypoints, createPagesMapping } from './entries'
import { generateBuildId } from './generate-build-id'
import { isWriteable } from './is-writeable'
import { checkNextScriptImport } from './check-next-script-import'
import * as Log from './output/log'
import createSpinner from './spinner'
import { trace, flushAllTraces, setGlobal } from '../trace'
Expand Down Expand Up @@ -355,6 +356,12 @@ export default async function build(
await flatReaddir(join(pagesDir, '..'), middlewareDetectionRegExp)
).map((absoluteFile) => absoluteFile.replace(dir, ''))

// Check if any page is importing next/script.
// Used in webpack config to define an env variable in order to only include next/script code into main bundle if needed
const isNextScriptImported: boolean = await nextBuildSpan
.traceChild('is-next-script-imported')
.traceAsyncFn(() => checkNextScriptImport(pagePaths, pagesDir))

// needed for static exporting since we want to replace with HTML
// files

Expand Down Expand Up @@ -716,6 +723,7 @@ export default async function build(
buildId,
config,
hasReactRoot,
isNextScriptImported,
pagesDir,
reactProductionProfiling,
rewrites,
Expand Down
4 changes: 4 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export default async function getBaseWebpackConfig(
dev = false,
entrypoints,
hasReactRoot,
isNextScriptImported = false,
isDevFallback = false,
pagesDir,
reactProductionProfiling = false,
Expand All @@ -338,6 +339,7 @@ export default async function getBaseWebpackConfig(
dev?: boolean
entrypoints: webpack5.EntryObject
hasReactRoot: boolean
isNextScriptImported?: boolean
isDevFallback?: boolean
pagesDir: string
reactProductionProfiling?: boolean
Expand Down Expand Up @@ -1507,6 +1509,8 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_SCRIPT_WORKERS': JSON.stringify(
config.experimental.nextScriptWorkers && !dev
),
'process.env.__NEXT_SCRIPT_IMPORTED':
JSON.stringify(isNextScriptImported),
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
config.experimental.scrollRestoration
),
Expand Down
6 changes: 3 additions & 3 deletions packages/next/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ImageConfigContext } from '../shared/lib/image-config-context'
import { ImageConfigComplete } from '../shared/lib/image-config'
import { removeBasePath } from './remove-base-path'
import { hasBasePath } from './has-base-path'
import { initalizeScriptLoader } from './initialize-script-loader'

const ReactDOM = process.env.__NEXT_REACT_ROOT
? require('react-dom/client')
Expand Down Expand Up @@ -252,9 +253,8 @@ export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
}
}

if (initialData.scriptLoader) {
const { initScriptLoader } = require('./script')
initScriptLoader(initialData.scriptLoader)
if (initialData.scriptLoader && initialData.scriptLoader.length > 0) {
Copy link
Member

@styfle styfle Jun 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the if statement order should be inverted such that if (process.env.__NEXT_SCRIPT_IMPORTED) is checked first and then everything else is inside that.

That way dead code elimination will remove it all

initalizeScriptLoader(initialData.scriptLoader)
}

pageLoader = new PageLoader(initialData.buildId, prefix)
Expand Down
7 changes: 7 additions & 0 deletions packages/next/client/initialize-script-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { initScriptLoader } from './script'

export const initalizeScriptLoader: typeof initScriptLoader = (...args) => {
if (process.env.__NEXT_SCRIPT_IMPORTED) {
return require('./script').initScriptLoader(...args)
}
}
14 changes: 10 additions & 4 deletions packages/next/shared/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
isAssetError,
markAssetError,
} from '../../../client/route-loader'
import { handleClientScriptLoad } from '../../../client/script'
import isError, { getProperError } from '../../../lib/is-error'
import { denormalizePagePath } from '../page-path/denormalize-page-path'
import { normalizeLocalePath } from '../i18n/normalize-locale-path'
Expand Down Expand Up @@ -1255,9 +1254,16 @@ export default class Router implements BaseRouter {
if (component && component.unstable_scriptLoader) {
const scripts = [].concat(component.unstable_scriptLoader())

scripts.forEach((script: any) => {
handleClientScriptLoad(script.props)
})
if (scripts.length > 0) {
// Script code should already be present in main bundle if next/script is imported
const { handleClientScriptLoad } = await import(
/* webpackMode: "weak" */
'../../../client/script'
)
scripts.forEach((script: any) => {
handleClientScriptLoad(script.props)
})
}
}

// handle redirect on client-transition
Expand Down
3 changes: 2 additions & 1 deletion packages/next/shared/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { IncomingMessage, ServerResponse } from 'http'
import type { NextRouter } from './router/router'
import type { ParsedUrlQuery } from 'querystring'
import type { PreviewData } from 'next/types'
import type { ScriptProps } from '../../client/script'

export type NextComponentType<
C extends BaseContext = NextPageContext,
Expand Down Expand Up @@ -102,7 +103,7 @@ export type NEXT_DATA = {
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocale[]
scriptLoader?: any[]
scriptLoader?: ScriptProps[]
isPreview?: boolean
notFoundSrcPage?: string
rsc?: boolean
Expand Down