Whose bug is it anyway?
Astro 7 came out. Woo!
Wait, that means my integration is supposed to support it. Not woo!
Let’s hope it just works™.
It blew up
Yeah. Can’t say I didn’t expect it. Astro did just move from Vite 7 to Vite 8.
Our first clue is that it works in dev, but has runtime errors in a build preview:
<script> // ... const pagefind = await r( () => import("/pagefind/pagefind.js"), __VITE_PRELOAD__ // <- ReferenceError at runtime //^^^^^^^^^^^^^^^^ ); // ...</script>This is a great breadcrumb. Let’s see where it leads.
Vite
A simple search on GitHub in the Vite repo for __VITE_PRELOAD__ yields a single meaningful result. Score.
// ...export const preloadMarker = `__VITE_PRELOAD__`;// ...export function buildImportAnalysisPlugin( config: ResolvedConfig,): Plugin[] { // ... const plugin: Plugin = { name: "vite:build-import-analysis", // ... async generateBundle(opts, bundle) { // ... }, // ... }; // ... return [ plugin, perEnvironmentPlugin( "native:import-analysis-build", (environment) => { const preloadCode = getPreloadCode( environment, !!renderBuiltUrl, isRelativeBase, ); return nativeBuildImportAnalysisPlugin({ preloadCode, insertPreload: getInsertPreload(environment), optimizeModulePreloadRelativePaths: false, renderBuiltUrl: !!renderBuiltUrl, isRelativeBase, }); }, ), ];}Quite the big haul. After reading a decent amount of code, the important thing to note is that the interesting marker cleanup happens in generateBundle. That makes me assume that the marker injection happens in the returned native plugin.
Well, our problem is the cleanup. So after adding a few breakpoints in the cleanup logic and running the debugger… Nothing. The code paths I expected to run: never ran.
Then I took a closer look at the chunk the cleanup logic was trying to process:
const chunk = bundle[fileName];// undefinedDude, where’s my chunk?
Vite has its own resolved plugin ordering, and vite:build-import-analysis runs after Astro’s normal user-side plugins. generateBundle hooks themselves are sequential, so mutations made by an earlier hook are visible to later ones.
In our case, Astro builds through Vite, so Astro’s plugins occupy that user-plugin part of the pipeline.
So now we can expect the problem is Astro source code, but what do we look for? Well we already said generateBundle is sequential. It’s not crazy to think the problem happens in a generateBundle of another plugin that happens before Vite’s build import analysis. As that might give us a bit more than we bargained for, perhaps we can constrain our search a bit more.
At this point I switched to searching the installed source locally. Repository search is convenient, but when you need an exhaustive answer to “where is this mutated?”, an IDE or rg over the exact version in node_modules is a safer bet.
So I tried searching for bundle[ and glanced at each result until I saw the smoking gun:
// ... })) { internals.inlinedScripts.set(output.facadeModuleId, output.code.trim()); delete bundle[output.fileName]; } } // ...This looks extremely promising: Astro is explicitly taking a generated chunk, copying its code into the inlined-script map, and deleting it from the bundle. That matches exactly what we’re seeing in the output. But why wasn’t this an issue in Astro 6?
After the “Why?”, the “How?”
A good next step is to downgrade to Astro 6 and compare the build. Turns out Astro 6 doesn’t inline this code into HTML at all, just keeps the script in its own .js file.
| Astro | v6 | v7 |
|---|---|---|
| script inlined | no | yes |
Seeing as the code for this plugin was identical at the time, the code paths were the same. The difference must be the conditional for inlining.
This is the part where we just run the debugger or log all of the conditions on both versions.
for (const output of outputs) { if ( // ... output.dynamicImports.length === 0 && // v6 false, v7 true // ... ) { internals.inlinedScripts.set(output.facadeModuleId, output.code.trim()); delete bundle[output.fileName]; }}Looks like our chunk metadata has changed with the version upgrade. We lost an import: dynamicImports used to have our import, now it’s empty.
output.dynamicImports;// ["/pagefind/pagefind.js"]output.dynamicImports;// []That’s weird. The code very clearly contains a dynamic import.
There is one important detail about it, though: /pagefind/pagefind.js is external. Pagefind isn’t being bundled into the Astro output; that import is intentionally left for the browser to resolve at runtime.
I’d already compared this part of Vite between versions 7 and 8, and nothing there explained the metadata difference. But the big difference is their own dependency. Looks like they moved from Rollup to Rolldown.
Did Rollup and Rolldown disagree about whether external imports
belong in dynamicImports?
Digging further suggested this wasn’t just an accidental missing push into an array. Rollup and Rolldown appear to derive this metadata differently.
Rollup keeps explicit information about external imports through its module/output model. Rolldown’s dynamicImports metadata, meanwhile, appears closely tied to its internal chunk graph. Since an external import has no generated chunk on the other end of that graph, that seems to explain why it disappears here.
Solution time
Let’s summarise what we know so far, because it points directly at the possible fixes:
- Vite wraps external dynamic imports in its preload machinery even when there are no bundled dependencies to preload
- Astro inlines a script before it gets cleaned up by Vite
- Rolldown differs in metadata calculation compared to Rollup
This gives us a few possible places to fix the problem. But “best fix” depends on what we’re optimizing for.
There are really two questions now:
- What gets Astro 7 back to Astro 6 behaviour without waiting on downstream changes?
- Where would the cleanest fix live if we were designing the system from scratch?
Timing
One option is to change the timing when Astro does its inlining work:
// ...return { name: "@astro/plugin-scripts", // ... generateBundle: { order: "post", async handler(_options, bundle) { // ... logic stays the same }, }, // ...};// ...Now inlining happens after Vite cleanup, meaning the code is safe to use. That comes with some caveats:
- this works because the Vite work is a normal hook, if Vite ever changes its hook to
order: "post", the same timing issue comes back - perhaps the inlining timing was purposefully mutating the bundle in order to reduce needless processing of the bundle from Vite’s side: now we re-introduce more Vite work
- this changes previous inlining behaviour: meaning the output of Astro 6 and Astro 7 differ when it comes to inlining scripts
Rolldown
The next option could be all the way downstream: Rolldown could change its output metadata to match Rollup’s behavior and include external dynamic imports in dynamicImports.
This would mean Astro wouldn’t need to change a thing, and Astro 6 behaviour would be preserved, but it would also mean waiting for the dependency and either keeping the problem or introducing a temporary workaround.
Vite
Another downstream option, and probably the one that makes the most sense to me: Vite could change its preload logic to skip external dynamic imports. Since an external dynamic import has no generated target chunk in the bundle for Vite to traverse, there are no bundled dependencies for this mechanism to discover. The work could be skipped entirely.
Dependencies in the preload sense mean the dependencies of the dynamically imported chunk. Finding those and requesting them from the server at the same time as our dynamic import can drastically improve performance and prevent network waterfalls.
However, we still have the problem of waiting for this downstream change, and more importantly, this would change the result of inlining between Astro versions.
Direct fix
If we want to preserve Astro 6 behaviour without waiting on a downstream dependency, we need another way to detect external dynamic imports.
Scanning the source string is always an option and we can find examples of it in Vite’s own plugin source:
chunk.code.includes(preloadMarker);But that can be left as a last resort. Turns out we’re not out of metadata yet:
chunk.moduleIds;// [".../Component.client.ts", "\0vite/preload-helper.js"]My idea was to scan the moduleIds array for the preload-helper used by Vite for dynamic imports. If a chunk claims it has no dynamic imports, but it needs the preload helper, then we caught it red-handed.
And while this is way better than scanning a potentially big source code string, it’s still a solution specific to preloading. Not at all generic.
One of the Astro core maintainers had a way cooler idea:
// code was modified for article purposeschunk.moduleIds.some((id) => { const info = this.getModuleInfo(id); // `this` belongs to `generateBundle` const hasDynamicImports = (info?.dynamicallyImportedIds.length ?? 0) > 0;
return hasDynamicImports;});Our moduleIds contain the modules that contributed code to this chunk. One of those is the script we wrote in our Astro component. Instead of asking the chunk whether it has dynamic imports, we can ask each source module inside it.
My approach detected the Vite symptom. The maintainer’s detects the actual condition Astro cares about.
In closing
Not a bad way to spend 6 hours. Digging through Astro, Vite, Rollup and Rolldown was quite fun.
I’m especially happy with the final solution and maybe a little frustrated I didn’t think of it first. But moments like these are a nice reminder that there’s more ground to cover. And that’s a great thing.
From everything I’ve seen so far, it seems to me like Astro is in good hands.
PS. If you’d like to see the actual GitHub issue, you can find it here.