Skip to content

Commit

Permalink
Implement new forking technique for vendored packages. (#51083)
Browse files Browse the repository at this point in the history
## Vendoring

Updates all module resolvers (node, webpack, nft for entrypoints, and nft for next-server) to consider whether vendored packages are suitable for a given resolve request and resolves them in an import semantics preserving way.

### Problem

Prior to the proposed change, vendoring has been accomplished but aliasing module requests from one specifier to a different specifier. For instance if we are using the built-in react packages for a build/runtime we might replace `require('react')` with `require('next/dist/compiled/react')`.

However this aliasing introduces a subtle bug. The React package has an export map that considers the condition `react-server` and when you require/import `'react'` the conditions should be considered and the underlying implementation of react may differ from one environment to another. In particular if you are resolving with the `react-server` condition you will be resolving the `react.shared-subset.js` implementation of React. This aliasing however breaks these semantics because it turns a bare specifier resolution of `react` with path `'.'` into a resolution with bare specifier `next` with path `'/dist/compiled/react'`. Module resolvers consider export maps of the package being imported from but in the case of `next` there is no consideration for the condition `react-server` and this resolution ends up pulling in the `index.js` implementation inside the React package by doing a simple path resolution to that package folder.

To work around this bug there is a prevalence of encoding the "right" resolution into the import itself. We for instance directly alias `react` to `next/dist/compiled/react/react.shared-subset.js` in certain cases. Other times we directly specify the runtime variant for instance `react-server-dom-webpack/server.edge` rather than `react-server-dom-wegbpack/server`, bypassing the export map altogether by selecting the runtime specific variant. However some code is meant to run in more than one runtime, for instance anything that is part of the client bundle which executes on the server during SSR and in the browser. There are workaround like using `require` conditionally or `import(...)` dynamically but these all have consequences for bundling and treeshaking and they still require careful consideration of the environment you are running in and which variant needs to load.

The result is that there is a large amount of manual pinning of aliases and additional complexity in the code and an inability to trust the package to specify the right resolution potentially causing conflicts in future versions as packages are updated.

It should be noted that aliasing is not in and of itself problematic when we are trying to implement a sort of lightweight forking based on build or runtime conditions. We have good examples of this for instance with the `next/head` package which within App Router should export a noop function. The problem is when we are trying to vendor an entire package and have the package behave semantically the same as if you had installed it yourself via node_modules

### Solution

The fix is seemingly straight forward. We need to stop aliasing these module specifiers and instead customize the resolution process to resolve from a location that will contain the desired vendored packages. We can then start simplifying our imports to use top level package resources and generally and let import conditions control the process of providing the right variant in the right context.

It should be said that vendoring is conditional. Currently we only vendor react pacakges for App Router runtimes. The implementation needs to be able to conditionally determine where a package resolves based on whether we're in an App Router context vs a Page Router one.

Additionally the implementation needs to support alternate packages such as supporting the experimental channel for React when using features that require this version.

### Implementation

The first step is to put the vendored packages inside a node_modules folder. This is essential to the correct resolving of packages by most tools that implement module resolution. For packages that are meant to be vendored, meaning whole package substitution we move the from `next/(src|dist)/compiled/...` to `next/(src|dist)/vendored/node_modules`. The purpose of this move is to clarify that vendored packages operate with a different implementation. This initial PR moves the react dependencies for App Router and `client-only` and `server-only` packages into this folder. In the future we can decide which other precompiled dependencies are best implemented as vendored packages and move them over.

It should be noted that because of our use of `JestWorker` we can get warnings for duplicate package names so we modify the vendored pacakges for react adding either `-vendored` or `-experimental-vendored` depending on which release channel the package came from. While this will require us to alter the request string for a module specifier it will still be treating the react package as the bare specifier and thus use the export map as required.

#### module resolvers
The next thing we need to do is have all systems that do module resolution implement an custom module resolution step. There are five different resolvers that need to be considered

##### node runtime
Updated the require-hook to resolve from the vendored directory without rewriting the request string to alter which package is identified in the bare specifier. For react packages we only do this vendoring if the `process.env.__NEXT_PRIVATE_PREBUNDLED_REACT` envvar is set indicating the runtime is server App Router builds. If we need a single node runtime to be able to conditionally resolve to both vendored and non vendored versions we will need to combine this with aliasing and encode whether the request is for the vendored version in the request string. Our current architecture does not require this though so we will just rely on the envvar for now

##### webpack runtime
Removed all aliases configured for react packages. Rely on the node-runtime to properly alias external react dependencies. Add a resolver plugin `NextAppResolverPlugin` to preempt perform resolution from the context of the vendored directory when encountering a vendored eligible package.

##### turbopack runtime
updated the aliasing rules for react packages to resolve from the vendored directory when in an App Router context. This implementation is all essentially config b/c the capability of doing the resolve from any position (i.e. the vendored directory) already exists

##### nft entrypoints runtime
track chunks to trace for App Router separate from Pages Router. For the trace for App Router chunks use a custom resolve hook in nft which performs the resolution from the vendored directory when appropriate.

##### nft next-server runtime
The current implementation for next-server traces both node_modules and vendored version of packages so all versions are included. This is necessary because the next server can run in either context (App vs Page router) and may depend on any possible variants. We could in theory make two traces rather than a combined one but this will require additional downstream changes so for now it is the most conservative thing to do and is correct

Once we have the correct resolution semantics for all resolvers we can start to remove instances targeting our precompiled instances for instance making `import ... from "next/dist/compiled/react-server-dom-webpack/client"` and replacing with `import ... from "react-server-dom-webpack/client"`

We can also stop requiring runtime specific variants like `import ... from "react-server-dom-webpack/client.edge"` replacing it with the generic export `"react-server-dom-webpack/client"`

There are still two special case aliases related to react
1. In profiling mode (browser only) we rewrite `react-dom` to `react-dom/profiling` and `scheduler/tracing` to `scheduler/tracing-profiling`. This can be moved to using export maps and conditions once react publishses updates that implement this on the package side.
2. When resolving `react-dom` on the server we rewrite this to `react-dom/server-rendering-stub`. This is to avoid loading the entire react-dom client bundle on the server when most of it goes unused. In the next major react will update this top level export to only contain the parts that are usable in any runtime and this alias can be dropped entirely

There are two non-react packages currently be vendored that I have maintained but think we ought to discuss the validity of vendoring. The `client-only` and `server-only` packages are vendored so you can use them without having to remember to install them into your project. This is convenient but does perhaps become surprising if you don't realize what is happening. We should consider not doing this but we can make that decision in another discussion/PR.

#### Webpack Layers
One of the things our webpack config implements for App Router is layers which allow us to have separate instances of packages for the server components graph and the client (ssr) graph. The way we were managing layer selection was a but arbitrary so in addition to the other webpack changes the way you cause a file to always end up in a specific layer is to end it with `.serverlayer`, `.clientlayer` or `.sharedlayer`. These act as layer portals so something in the server layer can import `foo.clientlayer` and that module will in fact be bundled in the client layer.

#### Packaging Changes
Most package managers are fine with this resolution redirect however yarn berry (yarn 2+ with PnP) will not resolve packages that are not defined in a package.json as a dependency. This was not a problem with the prior strategy because it was never resolving these vendored packages it was always resolving the next package and then just pointing to a file within it that happened to be from react or a related package.

To get around this issue vendored packages are both committed in src and packed as a tgz file. Then in the next package.json we define these vendored packages as `optionalDependency` pointing to these tarballs. For yarn PnP these packed versions will get used and resolved rather than the locally commited src files. For other package managers the optional dependencies may or may not get installed but the resolution will still resolve to the checked in src files. This isn't a particularly satisfying implemenation and if pnpm were to be updated to have consistent behavior installing from tarballs we could actually move the vendoring entirely to dependencies and simplify our resolvers a fair bit. But this will require an upstream chagne in pnpm and would take time to propogate in the community since many use older versions

#### Upstream Changes

As part of this work I landed some other changes upstream that were necessary. One was to make our packing use `npm` to match our publishing step. This also allows us to pack `node_modules` folders which is normally not supported but is possible if you define the folder in the package.json's files property.

See: #52563

Additionally nft did not provide a way to use the internal resolver if you were going to use the resolve hook so that is now exposed

See: vercel/nft#354

#### additional PR trivia
* When we prepare to make an isolated next install for integration tests we exclude node_modules by default so we have a special case to allow `/vendored/node_modules`

* The webpack module rules were refactored to be a little easier to reason about and while they do work as is it would be better for some of them to be wrapped in a `oneOf` rule however there is a bug in our css loader implementation that causes these oneOf rules to get deleted. We should fix this up in a followup to make the rules a little more robuts.


## Edits
* I removed `.sharedlayer` since this concept is leaky (not really related to the client/server boundary split) and it is getting refactored anyway soon into a precompiled runtime.
  • Loading branch information
gnoff committed Aug 4, 2023
1 parent e4aecab commit e06880e
Show file tree
Hide file tree
Showing 243 changed files with 6,622 additions and 1,731 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Expand Up @@ -16,6 +16,7 @@ examples/with-tigris/db/models/todoItems.ts
packages/next/src/bundles/webpack/packages/*.runtime.js
packages/next/src/bundles/webpack/packages/lazy-compilation-*.js
packages/next/src/compiled/**/*
packages/next/vendored/**/*
packages/react-refresh-utils/**/*.js
packages/react-dev-overlay/lib/**
**/__tmp__/**
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -6,6 +6,7 @@ packages/next/wasm/@next

# dependencies
node_modules
!packages/next/vendored/node_modules
package-lock.json
yarn.lock
!/yarn.lock
Expand Down
1 change: 1 addition & 0 deletions .prettierignore_staged
Expand Up @@ -3,6 +3,7 @@
**/dist/**
packages/next-swc/crates/**
packages/next/src/compiled/**/*
packages/next/vendored/**/*
packages/next/bundles/webpack/packages/*.runtime.js
lerna.json
packages/next-codemod/transforms/__testfixtures__/**/*
Expand Down
5 changes: 2 additions & 3 deletions .vscode/settings.json
Expand Up @@ -23,9 +23,8 @@
"debug.javascript.unmapMissingSources": true,

"files.exclude": {
"**/node_modules": false,
"node_modules": true,
"*[!test]**/node_modules": true
"*[!test]**/node_modules": true,
"**/node_modules": false
},

// Ensure enough terminal history is preserved when running tests.
Expand Down
16 changes: 8 additions & 8 deletions package.json
Expand Up @@ -188,14 +188,14 @@
"random-seed": "0.3.0",
"react": "18.2.0",
"react-17": "npm:react@17.0.2",
"react-builtin": "npm:react@18.3.0-canary-9377e1010-20230712",
"react-builtin": "npm:react@18.3.0-canary-546fe4681-20230713",
"react-dom": "18.2.0",
"react-dom-17": "npm:react-dom@17.0.2",
"react-dom-builtin": "npm:react-dom@18.3.0-canary-9377e1010-20230712",
"react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-9377e1010-20230712",
"react-experimental-builtin": "npm:react@0.0.0-experimental-9377e1010-20230712",
"react-server-dom-webpack": "18.3.0-canary-9377e1010-20230712",
"react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-9377e1010-20230712",
"react-dom-builtin": "npm:react-dom@18.3.0-canary-546fe4681-20230713",
"react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-546fe4681-20230713",
"react-experimental-builtin": "npm:react@0.0.0-experimental-546fe4681-20230713",
"react-server-dom-webpack": "18.3.0-canary-546fe4681-20230713",
"react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-546fe4681-20230713",
"react-ssr-prepass": "1.0.8",
"react-virtualized": "9.22.3",
"relay-compiler": "13.0.2",
Expand All @@ -205,8 +205,8 @@
"resolve-from": "5.0.0",
"sass": "1.54.0",
"satori": "0.10.1",
"scheduler-builtin": "npm:scheduler@0.24.0-canary-9377e1010-20230712",
"scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-9377e1010-20230712",
"scheduler-builtin": "npm:scheduler@0.24.0-canary-546fe4681-20230713",
"scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-546fe4681-20230713",
"seedrandom": "3.0.5",
"selenium-webdriver": "4.0.0-beta.4",
"semver": "7.3.7",
Expand Down
Expand Up @@ -2,7 +2,7 @@ import ReactDOMClient from 'react-dom/client'
import React, { use } from 'react'
import type { ReactElement } from 'react'
import { version } from 'next/package.json'
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack/client'
import { createFromReadableStream } from 'react-server-dom-webpack/client'
import { callServer } from 'next/dist/client/app-call-server'
import { linkGc } from 'next/dist/client/app-link-gc'

Expand Down
@@ -1 +1 @@
declare module 'next/dist/compiled/react-server-dom-webpack/client'
declare module 'react-server-dom-webpack/client'
1 change: 0 additions & 1 deletion packages/next-swc/crates/next-core/js/types/modules.d.ts
@@ -1,4 +1,3 @@
declare module 'next/dist/client/components/static-generation-async-storage.js'
declare module 'next/dist/client/components/request-async-storage.js'
declare module 'next/dist/client/components/hooks-server-context.js'
declare module 'next/dist/compiled/react-server-dom-webpack/server.edge'
171 changes: 87 additions & 84 deletions packages/next-swc/crates/next-core/src/next_import_map.rs
Expand Up @@ -58,6 +58,8 @@ pub async fn get_next_client_import_map(
)
.await?;

let vendor_dir = get_next_package(project_path).join("vendored".into());

match ty.into_value() {
ClientContextType::Pages { pages_dir } => {
insert_alias_to_alternatives(
Expand Down Expand Up @@ -85,31 +87,35 @@ pub async fn get_next_client_import_map(
],
);
}
ClientContextType::App { app_dir } => {
import_map.insert_exact_alias(
"react",
request_to_import_mapping(app_dir, "next/dist/compiled/react"),
);
import_map.insert_wildcard_alias(
"react/",
request_to_import_mapping(app_dir, "next/dist/compiled/react/*"),
);
import_map.insert_exact_alias(
"react-dom",
request_to_import_mapping(app_dir, "next/dist/compiled/react-dom"),
);
import_map.insert_wildcard_alias(
"react-dom/",
request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/*"),
);
import_map.insert_wildcard_alias(
"react-server-dom-webpack/",
request_to_import_mapping(app_dir, "next/dist/compiled/react-server-dom-webpack/*"),
);
ClientContextType::App { app_dir: _ } => {
import_map.insert_exact_alias(
"next/dynamic",
request_to_import_mapping(project_path, "next/dist/shared/lib/app-dynamic"),
);

for (package_name, vendored_name) in [
("react", "react-vendored"),
("react-dom", "react-dom-vendored"),
(
"react-server-dom-webpack",
"react-server-dom-webpack-vendored",
),
("scheduler", "scheduler-vendored"),
("client-only", "client-only-vendored"),
("server-only", "server-only-vendored"),
] {
import_map.insert_exact_alias(
package_name,
request_to_import_mapping(vendor_dir, vendored_name),
);
import_map.insert_wildcard_alias(
format!("{}{}", package_name, "/"),
request_to_import_mapping(
vendor_dir,
format!("{}{}", vendored_name, "/*").as_str(),
),
);
}
}
ClientContextType::Fallback => {}
ClientContextType::Other => {}
Expand Down Expand Up @@ -211,7 +217,7 @@ pub async fn get_next_server_import_map(

let ty = ty.into_value();

insert_next_server_special_aliases(&mut import_map, ty, mode).await?;
insert_next_server_special_aliases(project_path, &mut import_map, ty, mode).await?;
let external = ImportMapping::External(None).cell();

match ty {
Expand Down Expand Up @@ -283,7 +289,7 @@ pub async fn get_next_edge_import_map(

let ty = ty.into_value();

insert_next_server_special_aliases(&mut import_map, ty, mode).await?;
insert_next_server_special_aliases(project_path, &mut import_map, ty, mode).await?;

match ty {
ServerContextType::Pages { .. } | ServerContextType::PagesData { .. } => {}
Expand Down Expand Up @@ -357,10 +363,12 @@ static NEXT_ALIASES: [(&str, &str); 23] = [
];

pub async fn insert_next_server_special_aliases(
project_path: Vc<FileSystemPath>,
import_map: &mut ImportMap,
ty: ServerContextType,
mode: NextMode,
) -> Result<()> {
let vendor_dir = get_next_package(project_path).join("vendored".into());
match (mode, ty) {
(_, ServerContextType::Pages { pages_dir }) => {
import_map.insert_exact_alias(
Expand Down Expand Up @@ -409,29 +417,30 @@ pub async fn insert_next_server_special_aliases(
// @opentelemetry/api
request_to_import_mapping(app_dir, "next/dist/compiled/@opentelemetry/api"),
);
import_map.insert_exact_alias(
"react",
request_to_import_mapping(app_dir, "next/dist/compiled/react"),
);
import_map.insert_wildcard_alias(
"react/",
request_to_import_mapping(app_dir, "next/dist/compiled/react/*"),
);
import_map.insert_exact_alias(
"react-dom",
request_to_import_mapping(
app_dir,
"next/dist/compiled/react-dom/server-rendering-stub.js",

for (package_name, vendored_name) in [
("react", "react-vendored"),
("react-dom", "react-dom-vendored"),
(
"react-server-dom-webpack",
"react-server-dom-webpack-vendored",
),
);
import_map.insert_wildcard_alias(
"react-dom/",
request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/*"),
);
import_map.insert_wildcard_alias(
"react-server-dom-webpack/",
request_to_import_mapping(app_dir, "next/dist/compiled/react-server-dom-webpack/*"),
);
("scheduler", "scheduler-vendored"),
("client-only", "client-only-vendored"),
("server-only", "server-only-vendored"),
] {
import_map.insert_exact_alias(
package_name,
request_to_import_mapping(vendor_dir, vendored_name),
);
import_map.insert_wildcard_alias(
format!("{}{}", package_name, "/"),
request_to_import_mapping(
vendor_dir,
format!("{}{}", vendored_name, "/*").as_str(),
),
);
}
}
// NOTE(alexkirsz) This logic maps loosely to
// `next.js/packages/next/src/build/webpack-config.ts`, where:
Expand All @@ -453,60 +462,54 @@ pub async fn insert_next_server_special_aliases(
// @opentelemetry/api
request_to_import_mapping(app_dir, "next/dist/compiled/@opentelemetry/api"),
);
import_map.insert_exact_alias(
"react",
request_to_import_mapping(app_dir, "next/dist/compiled/react/react.shared-subset"),
);
import_map.insert_exact_alias(
"react-dom",
request_to_import_mapping(
app_dir,
"next/dist/compiled/react-dom/server-rendering-stub",
),
);
for (wildcard_alias, request) in [
("react/", "next/dist/compiled/react/*"),
("react-dom/", "next/dist/compiled/react-dom/*"),

for (package_name, vendored_name) in [
("react", "react-vendored"),
("react-dom", "react-dom-vendored"),
(
"react-server-dom-webpack/",
"next/dist/compiled/react-server-dom-webpack/*",
"react-server-dom-webpack",
"react-server-dom-webpack-vendored",
),
("scheduler", "scheduler-vendored"),
("client-only", "client-only-vendored"),
("server-only", "server-only-vendored"),
] {
import_map.insert_exact_alias(
package_name,
request_to_import_mapping(vendor_dir, vendored_name),
);
import_map.insert_wildcard_alias(
wildcard_alias,
request_to_import_mapping(app_dir, request),
format!("{}{}", package_name, "/"),
request_to_import_mapping(
vendor_dir,
format!("{}{}", vendored_name, "/*").as_str(),
),
);
}
}
// ## SSR
//
// * always uses externals, to ensure we're using the same React instance as the Next.js
// runtime
// * maps react-dom -> react-dom/server-rendering-stub
// * passes through react and (react|react-dom|react-server-dom-webpack)/(.*) to
// next/dist/compiled/react and next/dist/compiled/$1/$2 resp.
(NextMode::Build | NextMode::Development, ServerContextType::AppSSR { .. }) => {
import_map.insert_exact_alias(
for package_name in [
"react",
external_request_to_import_mapping("next/dist/compiled/react"),
);
import_map.insert_exact_alias(
"react-dom",
external_request_to_import_mapping(
"next/dist/compiled/react-dom/server-rendering-stub",
),
);

for (wildcard_alias, request) in [
("react/", "next/dist/compiled/react/*"),
("react-dom/", "next/dist/compiled/react-dom/*"),
(
"react-server-dom-webpack/",
"next/dist/compiled/react-server-dom-webpack/*",
),
"react-server-dom-webpack",
"scheduler",
"client-only",
"server-only",
] {
let import_mapping = external_request_to_import_mapping(request);
import_map.insert_wildcard_alias(wildcard_alias, import_mapping);
import_map.insert_exact_alias(
package_name,
external_request_to_import_mapping(package_name),
);
import_map.insert_wildcard_alias(
format!("{}{}", package_name, "/"),
external_request_to_import_mapping(
format!("{}{}", package_name, "/*").as_str(),
),
);
}
}
(_, ServerContextType::Middleware) => {}
Expand Down
16 changes: 15 additions & 1 deletion packages/next/package.json
Expand Up @@ -10,6 +10,8 @@
"types": "index.d.ts",
"files": [
"dist",
"vendored",
"vendored/node_modules",
"app.js",
"app.d.ts",
"babel.js",
Expand Down Expand Up @@ -106,6 +108,18 @@
"optional": true
}
},
"optionalDependencies": {
"client-only-vendored": "file:vendored/node_modules/client-only-vendored",
"server-only-vendored": "file:vendored/node_modules/server-only-vendored",
"react-vendored": "file:vendored/node_modules/react-vendored",
"react-experimental-vendored": "file:vendored/node_modules/react-experimental-vendored",
"react-dom-vendored": "file:vendored/node_modules/react-dom-vendored",
"react-dom-experimental-vendored": "file:vendored/node_modules/react-dom-experimental-vendored",
"react-server-dom-webpack-vendored": "file:vendored/node_modules/react-server-dom-webpack-vendored",
"react-server-dom-webpack-experimental-vendored": "file:vendored/node_modules/react-server-dom-webpack-experimental-vendored",
"scheduler-vendored": "file:vendored/node_modules/scheduler-vendored",
"scheduler-experimental-vendored": "file:vendored/node_modules/scheduler-experimental-vendored"
},
"devDependencies": {
"@ampproject/toolbox-optimizer": "2.8.3",
"@babel/code-frame": "7.12.11",
Expand Down Expand Up @@ -183,7 +197,7 @@
"@types/webpack-sources1": "npm:@types/webpack-sources@0.1.5",
"@types/ws": "8.2.0",
"@vercel/ncc": "0.34.0",
"@vercel/nft": "0.22.6",
"@vercel/nft": "0.23.0",
"acorn": "8.5.0",
"ajv": "8.11.0",
"amphtml-validator": "1.0.35",
Expand Down

0 comments on commit e06880e

Please sign in to comment.