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:
parent
c081adf998
commit
e3f02f5fb1
30 changed files with 239 additions and 199 deletions
25
.changeset/cool-jobs-fetch.md
Normal file
25
.changeset/cool-jobs-fetch.md
Normal 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.
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
* 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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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][];
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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));
|
||||
if (options.settings.config.experimental.directRenderScript) {
|
||||
register(pluginScripts(internals));
|
||||
} else {
|
||||
register(pluginHoistedScripts(options, internals));
|
||||
}
|
||||
register(pluginSSR(options, internals));
|
||||
register(pluginSSRSplit(options, internals));
|
||||
register(pluginChunks());
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
49
packages/astro/src/core/build/plugins/plugin-scripts.ts
Normal file
49
packages/astro/src/core/build/plugins/plugin-scripts.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -13,6 +13,7 @@ export {
|
|||
render,
|
||||
renderComponent,
|
||||
renderHead,
|
||||
renderScript,
|
||||
renderSlot,
|
||||
renderTransition,
|
||||
spreadAttributes,
|
||||
|
|
|
@ -24,6 +24,7 @@ export {
|
|||
renderHead,
|
||||
renderHTMLElement,
|
||||
renderPage,
|
||||
renderScript,
|
||||
renderScriptElement,
|
||||
renderSlot,
|
||||
renderSlotToString,
|
||||
|
|
|
@ -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';
|
||||
|
|
19
packages/astro/src/runtime/server/render/script.ts
Normal file
19
packages/astro/src/runtime/server/render/script.ts
Normal 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>`);
|
||||
}
|
|
@ -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') {
|
||||
|
|
|
@ -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();
|
||||
|
|
12
packages/astro/test/fixtures/hoisted-imports/astro.config.js
vendored
Normal file
12
packages/astro/test/fixtures/hoisted-imports/astro.config.js
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
experimental: {
|
||||
directRenderScript: true,
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
assetsInlineLimit: 100,
|
||||
},
|
||||
},
|
||||
});
|
12
packages/astro/test/fixtures/hoisted-imports/src/components/LargeScript.astro
vendored
Normal file
12
packages/astro/test/fixtures/hoisted-imports/src/components/LargeScript.astro
vendored
Normal 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>
|
|
@ -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';
|
||||
|
|
7
packages/astro/test/fixtures/hoisted-imports/src/pages/dedupe.astro
vendored
Normal file
7
packages/astro/test/fixtures/hoisted-imports/src/pages/dedupe.astro
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
import { A } from '../components';
|
||||
---
|
||||
|
||||
<A />
|
||||
<A />
|
||||
<A />
|
5
packages/astro/test/fixtures/hoisted-imports/src/pages/no-inline.astro
vendored
Normal file
5
packages/astro/test/fixtures/hoisted-imports/src/pages/no-inline.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import { LargeScript } from '../components';
|
||||
---
|
||||
|
||||
<LargeScript />
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -196,6 +196,7 @@ export function createBasicPipeline(options = {}) {
|
|||
options.streaming ?? true,
|
||||
options.adapterName,
|
||||
options.clientDirectives ?? getDefaultClientDirectives(),
|
||||
options.inlinedScripts ?? [],
|
||||
options.compressHTML,
|
||||
options.i18n,
|
||||
options.middleware,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue