mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Improves development performance when we have a huge file with lots of functions (each of which gets its own copy of the sourcemap). The issue is due to `data:` sourcemaps, as foreseen in https://github.com/vercel/next.js/pull/81904. Node.js forever retains a parsed copy of a sourcemap per eval'd fake function, keyed by each function's unique `sourceURL`. There's also a bunch of CPU work that gets repeated. The fix is to switch to `file:` sourcemaps, which we can easily do in Turbopack in dev because all chunks have a `<chunk-name>.map` source map. We'll inject this behavior from `hot-reloader-turbopack.js`. # Test Plan That hyper slow example we've seen no longer takes 12s to render first route in dev: <img width="532" height="268" alt="Screenshot 2026-07-20 at 02 22 24" src="https://github.com/user-attachments/assets/31507ca8-f45c-4fd0-b895-c18f885476e4" /> Also, `vercel-docs` no longer OOMs when slowly navigating across a bunch of routes. (It still OOMs on fast bursts — to be investigated separately.) I see sourcemaps in: - Terminal - Instant Validation - Server Error - Client Error - Cache Error - Server HMR - Client HMR ---- <h1 id=wtf> Okay, but seriously, how does it work?</h1> I'm glad you asked... While working on this PR, I've learned a lot of trivia about sourcemaps and the tricks we do in React to make them work nicely across environments. I'm afraid I'll forget all of them so I wrote them down here. ## Basics A stack is made of frames. Frames can originate in different environments, and can look like `https://example.com/chunk.js` (browser) or `/Users/dan/layout.js` (CJS Node) or `file:///Users/dan/app/layout.js` (ESM Node), or something more exotic (`webpack://bundle.js` etc). You can see them by doing `new Error().stack`. However, these frames are not necessarily what the user *sees* in the stack. If the relevant script contained a reference to a sourcemap, the displayed frame is whatever the sourcemap says for that position. So this is how `https://example.com/bundle.js:1:2` can show up as `app/Button.tsx:5:10`. It means that `https://example.com/bundle.js` contains a sourcemapping URL to a sourcemap that says "this piece of code is actually from `app/Button.tsx`". ## Fake Frames Suppose we're in a browser, but the error contains some frames originally from the Server (in the RSC sense). If we just let the browser display them as is, a `/Users/dan/app/.next/server/chunk.js:1234:56` frame will show up in the browser. Which is fine but it wouldn't be clickable and obviously wouldn't display source code (because why would it). So we do a little trick. We eval a special empty function that's positioned right where the original frame was in the chunk (e.g. line 1234, char 56) by padding it with newlines and spaces. Inside that eval, we point at the *chunk's* sourcemap — the one that says "the code at 1234:56 is actually a function from `app/layout.js`". What this achieves is that in the stack you see `app/layout.js` (because the sourcemap says that's what this position is), and when you click on it, the contents of that server file is shown (because the sourcemap includes the original source text). Since our stub sits at exactly the position the real frame had in the chunk, the chunk's sourcemap gives the right answers for it. **So we've tricked the browser into displaying a frame with the server code.** (This was the feature that motivates all this.) Now, for all of this to work, we still need to give some name to the surrounding module during eval. In other words, our "fake" module is not a `file://` or `https://` or whatever. It's just a virtual eval'd thing that exists so that it can "sourcemap" down to the real server file, and we can splice those frames in the current environment's stacks. Let's give this module a URI like `about://React/Server/file:///Users/dan/app/.next/server/chunk.js?1`: - We want these stubs to be grouped separately away from people's eyes in the Source tab. They're not real code. - `Server` can be `Cache` or others instead, that's the React environment it represents - The `file://` part after that is the compiled file whose frame this stub is standing in for - The `?1` part at the end makes this stub module unique - We need that because every distinct frame (function and position) needs its own "empty file with a function" stub, and one file can appear in many frames. So we can't use the same URI or the browser will treat them as the same module. We'll call this `about://React/Server/file:///Users/dan/app/.next/server/chunk.js?1` thing a "virtualized" URL. To recap: it's the URI of a virtual eval'd module that is almost completely empty but has an empty function at just the right line, and a sourcemap that says "actually if you click on my frame, display `app/layout.js`". ## Browser Symbolication For the browser itself, the above is sufficient. The browser sees `about://React/Server/file:///Users/dan/app/.next/server/chunk.js?1` in the stack (spliced by React to mark a frame from another environment). But the user doesn't see this if they have sourcemaps on. This is because the browser loads the sourcemap that this module points to, and that sourcemap "makes it look" like the real `app/layout.js`. Illusion achieved. ## Where Does a Source Map Come From For the above to work, the virtual module generated by React needs to contain a `//# sourceMappingURL=` which points to the thing that lets it masquerade as the server module. React can't do this on its own since it shouldn't be reading files and may not have access to them anyway (at least not in the browser). So this is actually injected into React by the framework (the thing being injected is called `findSourceMapURL`). Next.js itself has multiple implementations of `findSourceMapURL`: - The version for the browser emits - `//# sourceMappingURL=<chunk-url>.map` for client sourcemaps - `//# sourceMappingURL=/__nextjs_source-map?filename=...` for server sourcemaps which loads it over HTTP - The version for Node.js emits - `//# sourceMappingURL=data:...` URI with the same sourcemap inline. React doesn't care. It just needs something to put into `//# sourceMappingURL=` of those virtual modules, and then hopefully the surrounding environment is actually able to resolve this. ## Why `data:` for Node.js? You might wonder why SSR points to sourcemaps with `data:` URIs instead of HTTP like the browser version. The answer is that we want to support debugging *Node.js process itself* with a Chrome debugger. And Node.js version of Chrome debugger does not load HTTP `sourceMappingURL`. It only supports `file:` or `data:`. Today we use `data:`. ## Terminal Symbolication What we have above is enough for browsers, where we use HTTP, and for Node.js Chrome debugger, where we currently use `data:`. They'll follow `sourceMappingURL` and grab the sourcemap when they need it. But we also show error stacks on *our own* surfaces, e.g. in terminal. So we need to replicate what the browser does. We need to resolve arbitrary frames (whether real ones like `/Users/dan/...` or `file:///Users/dan` — stuff from the current environment captured by V8 — or weird ones like `about://...`, which is stuff from an earlier environment like Server or Cache). We need to resolve them to source files like `app/layout.js` and such. For the browser (or for Node.js), this is easy because the runtime already "remembers" executing each module, and what the `sourceMappingURL` told it. However, *our own* terminal printing code is unaware of what happened inside Node.js. So we need to get creative: 1. For real Node.js frames from the current process like `/Users/dan/...` (CJS) and `file:///Users/dan` (ESM), there is no problem. If they have sourcemaps (e.g. Turbopack output), those are relative like `//# sourceMappingURL=foo.js.map`. Node.js reads and caches those when it loads each module, and `require('module').findSourceMap` gives us what it cached. 2. For virtual frames like `about://...`, we need to know what actual file it corresponds to. We could try to parse it out of the virtual frame itself, but actually we don't need to do that. **It turns out that we can simply ask the Node.js inline sourcemap cache: "give me the inline sourcemap you cached when evaluating this frame's module".** This works because Node.js already caches all sourcemaps from modules that have `data:` URIs as source map URL. So we just look into that map under the `about://...` key, and we can treat that `about://` key itself as opaque. Once we get that sourcemap, we can parse it and symbolicate the frame. ## The Problem **Okay, now we're finally getting to the point of this PR.** It turns out that it is actually expensive to use `data:` URIs for this. Worst of all, it's expensive in the happy case (when errors don't show up). Let's see why this is expensive. React's fake modules are evaluated not just for thrown errors, but for debug information (component stacks) that travels with JSX. So this has nothing to do with throwing or the cost of displaying an error. We're always materializing those frames. (Can we do that lazily? I'm not sure.) To materialize these frames, Node evaluates our fake modules — one per distinct frame that shows up in stacks. So a big file with 100 components appearing in stacks means 100 fake modules. Each of them includes the same `//# sourceMappingURL=data:...` URI carrying the entire sourcemap of the chunk that file was compiled into. So you can see why we have a problem: 1. A big original file with 100 components would produce 100 virtual modules 2. Each of the 100 virtual modules contains an identical `data:` with the sourcemap for the entire chunk 3. Node.js does not dedupe any work between `data:` URIs so they *each* get parsed 4. Worse, Node.js doesn't share memory between identical sourcemaps born from `data:` so they pile up in memory 5. Even worse, this memory is effectively never freed (the cached sourcemaps live as long as the eval'd modules, and React keeps the fake functions in a cache forever) That's why we saw 12 second slowdown on the start of one demo app with a huge number of icon sprites in one file. Basically, Node.js didn't anticipate that many different files may include an identical inline sourcemap, and so this case is very badly unoptimized. (We could plausibly send a fix for this but it would only affect those who upgrade.) So all of this isn't super great, and that's what we're trying to fix. ## The Fix: Just Use Files To reiterate, the problem we're solving is that a fake module with a huge `//# sourceMappingURL=data:...` has upfront evaluation cost, and we're potentially evaluating a lot of these modules. The obvious fix is to stop using `data:` URIs. But what other option do we have for symbolicating on the server? We can't use HTTP (like in the browser) because that would break debugging Node.js with Chrome. Our only options are `data:`... and `file:`. So let's try `file:`. But for that to work, we need the sourcemap to already exist on the disk. With webpack, we can't do this because our webpack setup doesn't write sourcemaps to disk (unless we make it, I guess). I'm not touching that in this PR. So webpack stays with suboptimal performance, and this fix doesn't improve it. But for turbopack, we can do this trivially. In dev, every first-party `chunk.js` compiled by Turbopack in the output already has a `chunk.js.map` next to it. So we just need to point our fake modules at *that* sourcemap that *already exists* on disk. Then Node.js has no per-module work to do at all — it simply ignores `file:` sourcemap URLs on eval'd modules instead of materializing them. Whoever actually needs the sourcemap later (the Chrome debugger, or our own terminal code) resolves it from disk, and all fake modules for one chunk point at the same single file. In other words, instead of this: - The version for Node.js emits `//# sourceMappingURL=data:...` URI with the same sourcemap inline. We want to do this: - The version for Node.js emits `//# sourceMappingURL=file:...` URI that points at the sourcemap on disk. **That's the `source-maps.ts` change in this PR.** We ask the bundler for a source map file first, and if the source map file exists, we return that (as a `file:` URI) instead of the `data:` URI. This fixes the memory issue and the 12 second slowdown. ## The Tricky Bits With the change above, debugging Node.js via Chrome debugger will "just work". It doesn't care whether the sourcemap is at a `data:` URI or a `file:` URI as long as the sourcemap actually exists. However, *our own* symbolication of terminal errors will break. Why is that? Recall how our terminal logic currently gets the sourcemap for every frame: >1. For real Node.js frames from the current process like `/Users/dan/...` (CJS) and `file:///Users/dan` (ESM), there is no problem. If they have sourcemaps (e.g. Turbopack output), those are relative like `//# sourceMappingURL=foo.js.map`. Node.js reads and caches those when it loads each module, and `require('module').findSourceMap` gives us what it cached. > >2. For virtual frames like `about://...`, we need to know what actual file it corresponds to. We could try to parse it out of the virtual frame itself, but actually we don't need to do that. **It turns out that we can simply ask the Node.js inline sourcemap cache: "give me the inline sourcemap you cached when evaluating this frame's module".** This works because Node.js already caches all sourcemaps from modules that have `data:` URIs as source map URL. So we just look into that map under the `about://...` key, and we can treat that `about://` key itself as opaque. The first case doesn't change — those keep working like before. But the second case is now broken. There's no entry corresponding to `about://...` URI in Node.js inline source map cache because *we did not use an inline sourcemap*. That was the whole point of switching to `file:`! It's how we get memory and CPU savings — Node.js no longer materializes them eagerly so they don't exist in any cache yet. **So we need to change the logic.** We need to treat `about://...` not as an opaque thing but as an encoded filename (it's inside — for example, `about://React/Server/file:///Users/dan/app/.next/chunk.js?1`). This lets us decode the filename out of it (strictly speaking, we get back `file:///Users/dan/app/.next/chunk.js` — React adds a `file://` prefix that decoding doesn't remove; more on that in the follow-ups). And knowing the JS file lets us get the map for it: the chunk itself was loaded by this process at some point, and Node.js read and cached the chunk's own sourcemap back then, so we look it up under the chunk's name. (If the chunk was never loaded in this process, Node.js has nothing, and we ask Turbopack for the map as a fallback.) **That's the `patch-error-inspect.ts` changes in this PR.** With this change, the terminal also gets symbolicated stacks after switching to `file:`. ## Follow-ups There's a couple of things I'm not super happy with: - It would be nice to fix this for webpack too? But maybe too risky or slow. I haven't tried. - Caching could be improved. There's no need to do duplicate work per each `?1` `?2` etc of the same file. I think there's a way to place caches so that we can still respect React's contract for "each frame may have its own map". - For some chunks with special names (e.g. `[root-of-the-server]` ones) whose sourcemaps are already loaded in Node.js memory in the same process (e.g. RSC frames transported into SSR world), we're no longer able to reuse the sourcemaps from Node.js memory, and instead have to fall back to asking Turbopack for them again. This is for two reasons: - We're no longer hitting the *inline* source map cache codepath because they're not inline. - We can't hit the Node.js *file* source map cache for them because we query them with wrong encoding: - Node.js file source map cache keys them with `pathToFileURL` encoding, like `file://...%5Broot-of-the-server%5D...` - Whereas devirtualizing `about://` React URL gives us `file://` URL with non-encoded content like `file://...[root-of-the-server]...` - The problem here is that `devirtualize(virtualize(absolutePath))` doesn't roundtrip. See https://github.com/react/react/pull/37105. - If it roundtripped, we would've gotten plain absolute `/...[root-of-the-server]...` and it would have been fine. - We can't fix this purely by tweaking the decoding on our side, because React prepends `file://` only when the filename starts with `/`, so from the decoded string we can't tell whether the original was an absolute path (CJS) that got prefixed or a real `file://` URL (ESM) that didn't. (We could try both interpretations against the cache — there are only two — but that's a follow-up.) - There's a related issue where an error travelling multiple layers (e.g. from Cache) gains `file://` on the second pass. I've had to add some defensive coding and a test for that. - This is not a huge deal because the Turbopack fallback saves us but I would've liked to fix this. I don't think they're land-blocking so I'll maybe follow up. --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Luke Sandberg <[email protected]>