0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-06 22:10:10 -05:00

Add experimental.directRenderScript option (#10102)

* Add `experimental.directRenderScript` option

* Support inlining scripts

* Use compiler preview release

* Address feedback

* Fix

* Add enable by default note

* Fix build

* Dedupe rendered scripts

* Fix test

* Update deps

* Update .changeset/cool-jobs-fetch.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update jsdoc

* Typo

* resolve merge conflicts

* Fix examples check fail

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
Bjorn Lu 2024-03-08 21:03:02 +08:00 committed by GitHub
parent c081adf998
commit e3f02f5fb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 239 additions and 199 deletions

View file

@ -0,0 +1,25 @@
---
"astro": minor
---
Adds a new `experimental.directRenderScript` configuration option which provides a more reliable strategy to prevent scripts from being executed in pages where they are not used.
This replaces the `experimental.optimizeHoistedScript` flag introduced in v2.10.4 to prevent unused components' scripts from being included in a page unexpectedly. That experimental option no longer exists and must be removed from your configuration, whether or not you enable `directRenderScript`:
```diff
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
experimental: {
- optimizeHoistedScript: true,
+ directRenderScript: true
}
});
```
With `experimental.directRenderScript` configured, scripts are now directly rendered as declared in Astro files (including existing features like TypeScript, importing `node_modules`, and deduplicating scripts). You can also now conditionally render scripts in your Astro file.
However, this means scripts are no longer hoisted to the `<head>` and multiple scripts on a page are no longer bundled together. If you enable this option, you should check that all your `<script>` tags behave as expected.
This option will be enabled by default in Astro 5.0.

View file

@ -197,7 +197,6 @@
"@types/diff": "^5.0.8",
"@types/dlv": "^1.1.4",
"@types/dom-view-transitions": "^1.0.4",
"@types/estree": "^1.0.5",
"@types/hast": "^3.0.3",
"@types/html-escaper": "^3.0.2",
"@types/http-cache-semantics": "^4.0.4",

View file

@ -1605,25 +1605,30 @@ export interface AstroUserConfig {
experimental?: {
/**
* @docs
* @name experimental.optimizeHoistedScript
* @name experimental.directRenderScript
* @type {boolean}
* @default `false`
* @version 2.10.4
* @version 4.5.0
* @description
* Prevents unused components' scripts from being included in a page unexpectedly.
* The optimization is best-effort and may inversely miss including the used scripts. Make sure to double-check your built pages
* before publishing.
* Enable hoisted script analysis optimization by adding the experimental flag:
* Enables a more reliable strategy to prevent scripts from being executed in pages where they are not used.
*
* Scripts will directly render as declared in Astro files (including existing features like TypeScript, importing `node_modules`,
* and deduplicating scripts). You can also now conditionally render scripts in your Astro file.
* However, this means scripts are no longer hoisted to the `<head>` and multiple scripts on a page are no longer bundled together.
* If you enable this option, you should check that all your `<script>` tags behave as expected.
*
* This option will be enabled by default in Astro 5.0.
*
* ```js
* {
* experimental: {
* optimizeHoistedScript: true,
* },
* experimental: {
* directRenderScript: true,
* },
* }
* ```
*/
optimizeHoistedScript?: boolean;
directRenderScript?: boolean;
/**
* @docs
@ -2743,6 +2748,7 @@ export interface SSRResult {
scripts: Set<SSRElement>;
links: Set<SSRElement>;
componentMetadata: Map<string, SSRComponentMetadata>;
inlinedScripts: Map<string, string>;
createAstro(
Astro: AstroGlobalPartial,
props: Record<string, any>,
@ -2777,6 +2783,11 @@ export interface SSRMetadata {
* script in the page HTML before the first Solid component.
*/
rendererSpecificHydrationScripts: Set<string>;
/**
* Used by `renderScript` to track script ids that have been rendered,
* so we only render each once.
*/
renderedScripts: Set<string>;
hasDirectives: Set<string>;
hasRenderedHead: boolean;
headInTree: boolean;

View file

@ -1,7 +1,7 @@
import { extname } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Plugin, Rollup } from 'vite';
import type { AstroSettings } from '../@types/astro.js';
import type { AstroSettings, SSRElement } from '../@types/astro.js';
import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js';
import { type BuildInternals, getPageDataByViteID } from '../core/build/internal.js';
import type { AstroBuildPlugin } from '../core/build/plugin.js';
@ -70,8 +70,15 @@ export function astroContentAssetPropagationPlugin({
crawledFiles: styleCrawledFiles,
} = await getStylesForURL(pathToFileURL(basePath), devModuleLoader);
const { scripts: hoistedScripts, crawledFiles: scriptCrawledFiles } =
await getScriptsForURL(pathToFileURL(basePath), settings.config.root, devModuleLoader);
// Add hoisted script tags, skip if direct rendering with `directRenderScript`
const { scripts: hoistedScripts, crawledFiles: scriptCrawledFiles } = settings.config
.experimental.directRenderScript
? { scripts: new Set<SSRElement>(), crawledFiles: new Set<string>() }
: await getScriptsForURL(
pathToFileURL(basePath),
settings.config.root,
devModuleLoader
);
// Register files we crawled to be able to retrieve the rendered styles and scripts,
// as when they get updated, we need to re-transform ourselves.

View file

@ -15,6 +15,7 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
const assets = new Set<string>(serializedManifest.assets);
const componentMetadata = new Map(serializedManifest.componentMetadata);
const inlinedScripts = new Map(serializedManifest.inlinedScripts);
const clientDirectives = new Map(serializedManifest.clientDirectives);
return {
@ -25,6 +26,7 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
...serializedManifest,
assets,
componentMetadata,
inlinedScripts,
clientDirectives,
routes,
};

View file

@ -50,6 +50,7 @@ export type SSRManifest = {
*/
clientDirectives: Map<string, string>;
entryModules: Record<string, string>;
inlinedScripts: Map<string, string>;
assets: Set<string>;
componentMetadata: SSRResult['componentMetadata'];
pageModule?: SinglePageBuiltModule;
@ -68,10 +69,11 @@ export type SSRManifestI18n = {
export type SerializedSSRManifest = Omit<
SSRManifest,
'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'clientDirectives'
'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'inlinedScripts' | 'clientDirectives'
> & {
routes: SerializedRouteInfo[];
assets: string[];
componentMetadata: [string, SSRComponentMetadata][];
inlinedScripts: [string, string][];
clientDirectives: [string, string][];
};

View file

@ -38,6 +38,7 @@ export abstract class Pipeline {
*/
readonly adapterName = manifest.adapterName,
readonly clientDirectives = manifest.clientDirectives,
readonly inlinedScripts = manifest.inlinedScripts,
readonly compressHTML = manifest.compressHTML,
readonly i18n = manifest.i18n,
readonly middleware = manifest.middleware,

View file

@ -602,6 +602,7 @@ function createBuildManifest(
trailingSlash: settings.config.trailingSlash,
assets: new Set(),
entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
inlinedScripts: internals.inlinedScripts,
routes: [],
adapterName: '',
clientDirectives: settings.clientDirectives,

View file

@ -26,6 +26,13 @@ export interface BuildInternals {
// A mapping of hoisted script ids back to the pages which reference it
hoistedScriptIdToPagesMap: Map<string, Set<string>>;
/**
* Used by the `directRenderScript` option. If script is inlined, its id and
* inlined code is mapped here. The resolved id is an URL like "/_astro/something.js"
* but will no longer exist as the content is now inlined in this map.
*/
inlinedScripts: Map<string, string>;
// A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
// Used to render pages with the correct specifiers.
entrySpecifierToBundleMap: Map<string, string>;
@ -115,6 +122,7 @@ export function createBuildInternals(): BuildInternals {
cssModuleToChunkIdMap: new Map(),
hoistedScriptIdToHoistedMap,
hoistedScriptIdToPagesMap,
inlinedScripts: new Map(),
entrySpecifierToBundleMap: new Map<string, string>(),
pageToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(),

View file

@ -13,6 +13,7 @@ import { pluginMiddleware } from './plugin-middleware.js';
import { pluginPages } from './plugin-pages.js';
import { pluginPrerender } from './plugin-prerender.js';
import { pluginRenderers } from './plugin-renderers.js';
import { pluginScripts } from './plugin-scripts.js';
import { pluginSSR, pluginSSRSplit } from './plugin-ssr.js';
export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) {
@ -28,7 +29,11 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP
register(astroHeadBuildPlugin(internals));
register(pluginPrerender(options, internals));
register(astroConfigBuildPlugin(options, internals));
register(pluginHoistedScripts(options, internals));
if (options.settings.config.experimental.directRenderScript) {
register(pluginScripts(internals));
} else {
register(pluginHoistedScripts(options, internals));
}
register(pluginSSR(options, internals));
register(pluginSSRSplit(options, internals));
register(pluginChunks());

View file

@ -1,10 +1,9 @@
import type { ModuleInfo, PluginContext } from 'rollup';
import type { PluginContext } from 'rollup';
import type { Plugin as VitePlugin } from 'vite';
import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types.js';
import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
import type { ExportDefaultDeclaration, ExportNamedDeclaration, ImportDeclaration } from 'estree';
import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
import { prependForwardSlash } from '../../../core/path.js';
import { getTopLevelPages, moduleIsTopLevelPage, walkParentInfos } from '../graph.js';
@ -19,110 +18,6 @@ function isPropagatedAsset(id: string) {
}
}
/**
* @returns undefined if the parent does not import the child, string[] of the reexports if it does
*/
async function doesParentImportChild(
this: PluginContext,
parentInfo: ModuleInfo,
childInfo: ModuleInfo | undefined,
childExportNames: string[] | 'dynamic' | undefined
): Promise<'no' | 'dynamic' | string[]> {
if (!childInfo || !parentInfo.ast || !childExportNames) return 'no';
// If we're dynamically importing the child, return `dynamic` directly to opt-out of optimization
if (childExportNames === 'dynamic' || parentInfo.dynamicallyImportedIds?.includes(childInfo.id)) {
return 'dynamic';
}
const imports: Array<ImportDeclaration> = [];
const exports: Array<ExportNamedDeclaration | ExportDefaultDeclaration> = [];
for (const node of (parentInfo.ast as any).body) {
if (node.type === 'ImportDeclaration') {
imports.push(node);
} else if (node.type === 'ExportDefaultDeclaration' || node.type === 'ExportNamedDeclaration') {
exports.push(node);
}
}
// All local import names that could be importing the child component
const importNames: string[] = [];
// All of the aliases the child component is exported as
const exportNames: string[] = [];
// Iterate each import, find it they import the child component, if so, check if they
// import the child component name specifically. We can verify this with `childExportNames`.
for (const node of imports) {
const resolved = await this.resolve(node.source.value as string, parentInfo.id);
if (!resolved || resolved.id !== childInfo.id) continue;
for (const specifier of node.specifiers) {
// TODO: handle these?
if (specifier.type === 'ImportNamespaceSpecifier') continue;
const name =
specifier.type === 'ImportDefaultSpecifier' ? 'default' : specifier.imported.name;
// If we're importing the thing that the child exported, store the local name of what we imported
if (childExportNames.includes(name)) {
importNames.push(specifier.local.name);
}
}
}
// Iterate each export, find it they re-export the child component, and push the exported name to `exportNames`
for (const node of exports) {
if (node.type === 'ExportDefaultDeclaration') {
if (node.declaration.type === 'Identifier' && importNames.includes(node.declaration.name)) {
exportNames.push('default');
}
} else {
// Handle:
// export { Component } from './Component.astro'
// export { Component as AliasedComponent } from './Component.astro'
if (node.source) {
const resolved = await this.resolve(node.source.value as string, parentInfo.id);
if (!resolved || resolved.id !== childInfo.id) continue;
for (const specifier of node.specifiers) {
if (childExportNames.includes(specifier.local.name)) {
importNames.push(specifier.local.name);
exportNames.push(specifier.exported.name);
}
}
}
// Handle:
// export const AliasedComponent = Component
// export const AliasedComponent = Component, let foo = 'bar'
if (node.declaration) {
if (node.declaration.type !== 'VariableDeclaration') continue;
for (const declarator of node.declaration.declarations) {
if (declarator.init?.type !== 'Identifier') continue;
if (declarator.id.type !== 'Identifier') continue;
if (importNames.includes(declarator.init.name)) {
exportNames.push(declarator.id.name);
}
}
}
// Handle:
// export { Component }
// export { Component as AliasedComponent }
for (const specifier of node.specifiers) {
if (importNames.includes(specifier.local.name)) {
exportNames.push(specifier.exported.name);
}
}
}
}
if (!importNames.length) return 'no';
// If the component is imported by another component, assume it's in use
// and start tracking this new component now
if (parentInfo.id.endsWith('.astro')) {
exportNames.push('default');
} else if (parentInfo.id.endsWith('.mdx')) {
exportNames.push('Content');
}
return exportNames;
}
export function vitePluginAnalyzer(
options: StaticBuildOptions,
internals: BuildInternals
@ -150,39 +45,9 @@ export function vitePluginAnalyzer(
}
if (hoistedScripts.size) {
// These variables are only used for hoisted script analysis optimization
const depthsToChildren = new Map<number, ModuleInfo>();
const depthsToExportNames = new Map<number, string[] | 'dynamic'>();
// The component export from the original component file will always be default.
depthsToExportNames.set(0, ['default']);
for (const [parentInfo, depth] of walkParentInfos(from, this, function until(importer) {
for (const [parentInfo] of walkParentInfos(from, this, function until(importer) {
return isPropagatedAsset(importer);
})) {
// If hoisted script analysis optimization is enabled, try to analyse and bail early if possible
if (options.settings.config.experimental.optimizeHoistedScript) {
depthsToChildren.set(depth, parentInfo);
// If at any point
if (depth > 0) {
// Check if the component is actually imported:
const childInfo = depthsToChildren.get(depth - 1);
const childExportNames = depthsToExportNames.get(depth - 1);
const doesImport = await doesParentImportChild.call(
this,
parentInfo,
childInfo,
childExportNames
);
if (doesImport === 'no') {
// Break the search if the parent doesn't import the child.
continue;
}
depthsToExportNames.set(depth, doesImport);
}
}
if (isPropagatedAsset(parentInfo.id)) {
for (const [nestedParentInfo] of walkParentInfos(from, this)) {
if (moduleIsTopLevelPage(nestedParentInfo)) {
@ -263,7 +128,9 @@ export function vitePluginAnalyzer(
return {
name: '@astro/rollup-plugin-astro-analyzer',
async generateBundle() {
const hoistScanner = hoistedScriptScanner();
const hoistScanner = options.settings.config.experimental.directRenderScript
? { scan: async () => {}, finalize: () => {} }
: hoistedScriptScanner();
const ids = this.getModuleIds();
@ -317,6 +184,16 @@ export function vitePluginAnalyzer(
trackClientOnlyPageDatas(internals, newPageData, clientOnlys);
}
}
// When directly rendering scripts, we don't need to group them together when bundling,
// each script module is its own entrypoint, so we directly assign each script modules to
// `discoveredScripts` here, which will eventually be passed as inputs of the client build.
if (options.settings.config.experimental.directRenderScript && astro.scripts.length) {
for (let i = 0; i < astro.scripts.length; i++) {
const hid = `${id.replace('/@fs', '')}?astro&type=script&index=${i}&lang.ts`;
internals.discoveredScripts.add(hid);
}
}
}
// Finalize hoisting

View file

@ -270,6 +270,7 @@ function buildManifest(
renderers: [],
clientDirectives: Array.from(settings.clientDirectives),
entryModules,
inlinedScripts: Array.from(internals.inlinedScripts),
assets: staticFiles.map(prefixAssetPath),
i18n: i18nManifest,
buildFormat: settings.config.build.format,

View file

@ -0,0 +1,49 @@
import type { BuildOptions, Plugin as VitePlugin } from 'vite';
import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
import { shouldInlineAsset } from './util.js';
/**
* Used by the `experimental.directRenderScript` option to inline scripts directly into the HTML.
*/
export function vitePluginScripts(internals: BuildInternals): VitePlugin {
let assetInlineLimit: NonNullable<BuildOptions['assetsInlineLimit']>;
return {
name: '@astro/plugin-scripts',
configResolved(config) {
assetInlineLimit = config.build.assetsInlineLimit;
},
async generateBundle(_options, bundle) {
for (const [id, output] of Object.entries(bundle)) {
// Try to inline scripts that don't import anything as is within the inline limit
if (
output.type === 'chunk' &&
output.facadeModuleId &&
internals.discoveredScripts.has(output.facadeModuleId) &&
output.imports.length === 0 &&
output.dynamicImports.length === 0 &&
shouldInlineAsset(output.code, output.fileName, assetInlineLimit)
) {
internals.inlinedScripts.set(output.facadeModuleId, output.code.trim());
delete bundle[id];
}
}
},
};
}
export function pluginScripts(internals: BuildInternals): AstroBuildPlugin {
return {
targets: ['client'],
hooks: {
'build:before': () => {
return {
vitePlugin: vitePluginScripts(internals),
};
},
},
};
}

View file

@ -67,6 +67,7 @@ export async function compile({
astroConfig.devToolbar &&
astroConfig.devToolbar.enabled &&
(await preferences.get('devToolbar.enabled')),
renderScript: astroConfig.experimental.directRenderScript,
preprocessStyle: createStylePreprocessor({
filename,
viteConfig,

View file

@ -58,7 +58,7 @@ const ASTRO_CONFIG_DEFAULTS = {
legacy: {},
redirects: {},
experimental: {
optimizeHoistedScript: false,
directRenderScript: false,
contentCollectionCache: false,
contentCollectionJsonSchema: false,
clientPrerender: false,
@ -451,10 +451,10 @@ export const AstroConfigSchema = z.object({
),
experimental: z
.object({
optimizeHoistedScript: z
directRenderScript: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript),
.default(ASTRO_CONFIG_DEFAULTS.experimental.directRenderScript),
contentCollectionCache: z
.boolean()
.optional()

View file

@ -177,7 +177,8 @@ export class RenderContext {
async createResult(mod: ComponentInstance) {
const { cookies, pathname, pipeline, routeData, status } = this;
const { clientDirectives, compressHTML, manifest, renderers, resolve } = pipeline;
const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
pipeline;
const { links, scripts, styles } = await pipeline.headElements(routeData);
const componentMetadata =
(await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata;
@ -200,6 +201,7 @@ export class RenderContext {
// calling the render() function will populate the object with scripts, styles, etc.
const result: SSRResult = {
clientDirectives,
inlinedScripts,
componentMetadata,
compressHTML,
cookies,
@ -218,6 +220,7 @@ export class RenderContext {
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
hasRenderedHead: false,
renderedScripts: new Set(),
hasDirectives: new Set(),
headInTree: false,
extraHead: [],

View file

@ -13,6 +13,7 @@ export {
render,
renderComponent,
renderHead,
renderScript,
renderSlot,
renderTransition,
spreadAttributes,

View file

@ -24,6 +24,7 @@ export {
renderHead,
renderHTMLElement,
renderPage,
renderScript,
renderScriptElement,
renderSlot,
renderSlotToString,

View file

@ -2,6 +2,7 @@ export { createHeadAndContent, renderTemplate, renderToString } from './astro/in
export type { AstroComponentFactory, AstroComponentInstance } from './astro/index.js';
export { Fragment, Renderer, chunkToByteArray, chunkToString } from './common.js';
export { renderComponent, renderComponentToString } from './component.js';
export { renderScript } from './script.js';
export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js';
export type { RenderInstruction } from './instruction.js';

View file

@ -0,0 +1,19 @@
import type { SSRResult } from '../../../@types/astro.js';
import { markHTMLString } from '../escape.js';
/**
* Relies on the `renderScript: true` compiler option
* @experimental
*/
export async function renderScript(result: SSRResult, id: string) {
if (result._metadata.renderedScripts.has(id)) return;
result._metadata.renderedScripts.add(id);
const inlined = result.inlinedScripts.get(id);
if (inlined) {
return markHTMLString(`<script type="module">${inlined}</script>`);
}
const resolved = await result.resolve(id);
return markHTMLString(`<script type="module" src="${resolved}"></script>`);
}

View file

@ -60,7 +60,10 @@ export class DevPipeline extends Pipeline {
settings,
} = this;
const filePath = new URL(`./${routeData.component}`, root);
const { scripts } = await getScriptsForURL(filePath, root, loader);
// Add hoisted script tags, skip if direct rendering with `directRenderScript`
const { scripts } = settings.config.experimental.directRenderScript
? { scripts: new Set<SSRElement>() }
: await getScriptsForURL(filePath, settings.config.root, loader);
// Inject HMR scripts
if (isPage(filePath, settings) && mode === 'development') {

View file

@ -138,6 +138,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
assetsPrefix: settings.config.build.assetsPrefix,
site: settings.config.site,
componentMetadata: new Map(),
inlinedScripts: new Map(),
i18n: i18nManifest,
middleware(_, next) {
return next();

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
experimental: {
directRenderScript: true,
},
vite: {
build: {
assetsInlineLimit: 100,
},
},
});

View file

@ -0,0 +1,12 @@
<script>
console.log(`
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur posuere commodo venenatis.
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
Nam non ligula vel metus efficitur hendrerit. In hac habitasse platea dictumst. Praesent et
mauris ut mi dapibus semper. Curabitur tortor justo, efficitur sit amet pretium cursus, porta
eget odio. Cras ac venenatis dolor. Donec laoreet posuere malesuada. Curabitur nec mi tempor,
placerat leo sit amet, tincidunt est. Quisque pellentesque venenatis magna, eget tristique nibh
pulvinar in. Vestibulum vitae volutpat arcu. Aenean ut malesuada odio, sit amet pellentesque odio.
Suspendisse nunc elit, blandit nec hendrerit non, aliquet at magna. Donec id leo ut nulla sagittis sodales.
`)
</script>

View file

@ -7,3 +7,5 @@ export { A_aliased as A, C_aliased as C_aliased };
export { default as B2 } from './B.astro';
export const D_aliased = D;
export default E_aliased;
export { default as LargeScript } from './LargeScript.astro';

View file

@ -0,0 +1,7 @@
---
import { A } from '../components';
---
<A />
<A />
<A />

View file

@ -0,0 +1,5 @@
---
import { LargeScript } from '../components';
---
<LargeScript />

View file

@ -9,41 +9,16 @@ describe('Hoisted Imports', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/hoisted-imports/',
experimental: {
optimizeHoistedScript: true,
},
});
});
async function getAllScriptText(page) {
const html = await fixture.readFile(page);
const $ = cheerio.load(html);
const scriptText = [];
const importRegex = /import\s*['"]([^'"]+)['"]/g;
async function resolveImports(text) {
const matches = text.matchAll(importRegex);
for (const match of matches) {
const importPath = match[1];
const importText = await fixture.readFile('/_astro/' + importPath);
scriptText.push(await resolveImports(importText));
}
return text;
}
const scripts = $('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts.eq(i).attr('src');
let text;
if (src) {
text = await fixture.readFile(src);
} else {
text = scripts.eq(i).text();
}
scriptText.push(await resolveImports(text));
}
return scriptText.join('\n');
return $('script')
.map((_, el) => $(el).text())
.toArray()
.join('\n');
}
describe('build', () => {
@ -68,14 +43,7 @@ describe('Hoisted Imports', () => {
expectScript(scripts, 'D');
expectScript(scripts, 'E');
});
it('includes all imported scripts when dynamically imported', async () => {
const scripts = await getAllScriptText('/dynamic/index.html');
expectScript(scripts, 'A');
expectScript(scripts, 'B');
expectScript(scripts, 'C');
expectScript(scripts, 'D');
expectScript(scripts, 'E');
});
it('includes no scripts when none imported', async () => {
const scripts = await getAllScriptText('/none/index.html');
expectNotScript(scripts, 'A');
@ -84,6 +52,7 @@ describe('Hoisted Imports', () => {
expectNotScript(scripts, 'D');
expectNotScript(scripts, 'E');
});
it('includes some scripts', async () => {
const scripts = await getAllScriptText('/some/index.html');
expectScript(scripts, 'A');
@ -92,5 +61,22 @@ describe('Hoisted Imports', () => {
expectNotScript(scripts, 'D');
expectNotScript(scripts, 'E');
});
it('deduplicates already rendered scripts', async () => {
const scripts = await getAllScriptText('/dedupe/index.html');
expectScript(scripts, 'A');
const html = await fixture.readFile('/dedupe/index.html');
const $ = cheerio.load(html);
assert.equal($('script').length, 1);
});
it('inlines if script is larger than vite.assetInlineLimit: 100', async () => {
const html = await fixture.readFile('/no-inline/index.html');
const $ = cheerio.load(html);
const scripts = $('script');
assert.equal(scripts.length, 1);
assert.ok(scripts[0].attribs.src);
});
});
});

View file

@ -196,6 +196,7 @@ export function createBasicPipeline(options = {}) {
options.streaming ?? true,
options.adapterName,
options.clientDirectives ?? getDefaultClientDirectives(),
options.inlinedScripts ?? [],
options.compressHTML,
options.i18n,
options.middleware,

View file

@ -736,9 +736,6 @@ importers:
'@types/dom-view-transitions':
specifier: ^1.0.4
version: 1.0.4
'@types/estree':
specifier: ^1.0.5
version: 1.0.5
'@types/hast':
specifier: ^3.0.3
version: 3.0.3