diff --git a/packages/vite/helpers/bundle-css.spec.ts b/packages/vite/helpers/bundle-css.spec.ts new file mode 100644 index 0000000000..2e5353b2af --- /dev/null +++ b/packages/vite/helpers/bundle-css.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { BUNDLE_CSS_AST_SENTINEL, collectBundleCssAssets, inlineBundleCss } from './bundle-css.js'; + +const css = (fileName: string, source: string) => ({ type: 'asset', fileName, source }); +const chunk = (fileName: string, extra: Record = {}) => ({ type: 'chunk', fileName, code: '', imports: [], dynamicImports: [], viteMetadata: { importedCss: new Set() }, ...extra }); + +function appBundle() { + return { + 'bundle.mjs': chunk('bundle.mjs', { + isEntry: true, + code: `try{m(\`${BUNDLE_CSS_AST_SENTINEL}\`,\`ns-bundle-css\`)}catch{}`, + imports: ['vendor.mjs'], + dynamicImports: ['lazy.mjs'], + viteMetadata: { importedCss: new Set(['assets/bundle.css']) }, + }), + 'vendor.mjs': chunk('vendor.mjs', { viteMetadata: { importedCss: new Set(['assets/vendor.css']) } }), + 'lazy.mjs': chunk('lazy.mjs', { viteMetadata: { importedCss: new Set(['assets/lazy.css']) } }), + 'assets/bundle.css': css('assets/bundle.css', '.btn{color:app}'), + 'assets/lazy.css': css('assets/lazy.css', '.lazy{color:red}'), + 'assets/vendor.css': css('assets/vendor.css', '.btn{color:vendor}'), + } as Record; +} + +describe('collectBundleCssAssets', () => { + it('orders static imports before the importer and dynamic imports after', () => { + expect(collectBundleCssAssets(appBundle()).map((a) => a.fileName)).toEqual(['assets/vendor.css', 'assets/bundle.css', 'assets/lazy.css']); + }); + + it('appends CSS no chunk claims', () => { + const bundle = appBundle(); + bundle['assets/orphan.css'] = css('assets/orphan.css', ''); + expect( + collectBundleCssAssets(bundle) + .map((a) => a.fileName) + .at(-1), + ).toBe('assets/orphan.css'); + }); +}); + +describe('inlineBundleCss', () => { + it('replaces a template-literal sentinel with the AST, in import order', () => { + const bundle = appBundle(); + const result = inlineBundleCss(bundle, (cssText) => JSON.stringify(cssText)); + + expect(result).toMatchObject({ assetCount: 3, replaced: true }); + expect(bundle['bundle.mjs'].code).toBe(`try{m(${JSON.stringify('.btn{color:vendor}\n.btn{color:app}\n.lazy{color:red}\n')},\`ns-bundle-css\`)}catch{}`); + expect(Object.keys(bundle).filter((k) => k.endsWith('.css'))).toEqual([]); + }); + + it.each([`'`, `"`])('replaces a %s-quoted sentinel', (quote) => { + const bundle = appBundle(); + bundle['bundle.mjs'].code = `m(${quote}${BUNDLE_CSS_AST_SENTINEL}${quote})`; + expect(inlineBundleCss(bundle, () => '{}').replaced).toBe(true); + expect(bundle['bundle.mjs'].code).toBe('m({})'); + }); + + it('keeps the CSS assets when no chunk carries the sentinel', () => { + const bundle = appBundle(); + bundle['bundle.mjs'].code = ''; + expect(inlineBundleCss(bundle, () => '{}').replaced).toBe(false); + expect(bundle['assets/vendor.css']).toBeDefined(); + }); +}); diff --git a/packages/vite/helpers/bundle-css.ts b/packages/vite/helpers/bundle-css.ts new file mode 100644 index 0000000000..9d1baf668e --- /dev/null +++ b/packages/vite/helpers/bundle-css.ts @@ -0,0 +1,67 @@ +export const BUNDLE_CSS_AST_SENTINEL = '__NS_BUNDLE_CSS_AST__'; + +// The release minifier runs before generateBundle and may print the sentinel +// as a template literal. +const SENTINEL_RE = new RegExp(`(['"\`])${BUNDLE_CSS_AST_SENTINEL}\\1`); + +function isCssAsset(file: any): boolean { + return !!file && file.type === 'asset' && typeof file.fileName === 'string' && file.fileName.endsWith('.css'); +} + +/** + * The bundle's CSS assets in the order the modules import them: a chunk's + * static imports first, then its own CSS, then its dynamic imports. Bundle + * order would put the entry's CSS ahead of `vendor.css`, letting node_modules + * rules win equal-specificity ties against the app's overrides. + */ +export function collectBundleCssAssets(bundle: Record): any[] { + const cssByName = new Map(); + for (const file of Object.values(bundle)) { + if (isCssAsset(file)) cssByName.set(file.fileName, file); + } + const ordered = new Set(); + const visited = new Set(); + const visit = (fileName: string) => { + const chunk = bundle[fileName]; + if (!chunk || chunk.type !== 'chunk' || visited.has(fileName)) return; + visited.add(fileName); + for (const dep of chunk.imports || []) visit(dep); + for (const css of chunk.viteMetadata?.importedCss || []) { + const asset = cssByName.get(css); + if (asset) ordered.add(asset); + } + for (const dep of chunk.dynamicImports || []) visit(dep); + }; + for (const file of Object.values(bundle)) { + if (file && file.type === 'chunk' && file.isEntry) visit(file.fileName); + } + for (const asset of cssByName.values()) ordered.add(asset); + return [...ordered]; +} + +/** + * Replaces the bundle-CSS sentinel with the AST of every CSS asset and drops + * those assets, which have no other consumer on device. The assets are kept + * when no chunk carries the sentinel. + */ +export function inlineBundleCss(bundle: Record, cssToAstJson: (cssText: string) => string): { assetCount: number; bytes: number; replaced: boolean } { + const cssAssets = collectBundleCssAssets(bundle); + let cssText = ''; + for (const asset of cssAssets) { + const src = asset.source; + cssText += (typeof src === 'string' ? src : new TextDecoder().decode(src as Uint8Array)) + '\n'; + } + let replaced = false; + for (const file of Object.values(bundle)) { + if (file && file.type === 'chunk' && typeof file.code === 'string' && SENTINEL_RE.test(file.code)) { + const astJson = cssToAstJson(cssText); + file.code = file.code.replace(SENTINEL_RE, () => astJson); + replaced = true; + break; + } + } + if (replaced) { + for (const asset of cssAssets) delete bundle[asset.fileName]; + } + return { assetCount: cssAssets.length, bytes: cssText.length, replaced }; +} diff --git a/packages/vite/helpers/main-entry.ts b/packages/vite/helpers/main-entry.ts index 9ec6567d90..2c45103a6e 100644 --- a/packages/vite/helpers/main-entry.ts +++ b/packages/vite/helpers/main-entry.ts @@ -13,6 +13,7 @@ import { setAppCssState } from './app-css-state.js'; import { createAppCssRefresher } from './app-css-refresh.js'; import { rewritePlatformCssImports } from './css-platform-plugin.js'; import { buildGlobalSeedStatements, getRuntimeSeedValues } from './global-defines.js'; +import { BUNDLE_CSS_AST_SENTINEL, inlineBundleCss } from './bundle-css.js'; // Switched to runtime modules to avoid fragile string injection and enable TS checks const projectRoot = getProjectRootPath(); const appRootDir = getProjectAppPath(); @@ -63,7 +64,6 @@ const APP_CSS_RESOLVED = '\0' + APP_CSS_VIRTUAL_ID; // `nsSfcStylesPlugin` workaround — apps no longer need their own. const BUNDLE_CSS_VIRTUAL_ID = 'virtual:ns-bundle-css'; const BUNDLE_CSS_RESOLVED = '\0' + BUNDLE_CSS_VIRTUAL_ID; -const BUNDLE_CSS_AST_SENTINEL = '__NS_BUNDLE_CSS_AST__'; // Virtual module that installs the XHR polyfill from @nativescript/core/xhr. // Rolldown tree-shakes the polyfill-xhr.ts side-effect import from @nativescript/core/globals, @@ -121,31 +121,10 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' generateBundle(_outputOptions: unknown, bundle: Record) { if (opts.hmrActive) return; try { - const cssAssets = Object.values(bundle).filter((f: any) => f && f.type === 'asset' && typeof f.fileName === 'string' && f.fileName.endsWith('.css')); - let cssText = ''; - for (const a of cssAssets) { - const src = (a as any).source; - cssText += (typeof src === 'string' ? src : new TextDecoder().decode(src as Uint8Array)) + '\n'; - } // Rework AST, position-stripped (the form app.css uses; ~halves size). - const ast = parseCssToAst(cssText, { silent: true }); - const astJson = JSON.stringify(ast, (key, value) => (key === 'position' ? undefined : value)); - // Rewrite the sentinel in whichever chunk carries the applier. - const sentinelRe = new RegExp(`(['"])${BUNDLE_CSS_AST_SENTINEL}\\1`); - let replaced = false; - for (const file of Object.values(bundle)) { - if (file && (file as any).type === 'chunk' && typeof (file as any).code === 'string' && sentinelRe.test((file as any).code)) { - (file as any).code = (file as any).code.replace(sentinelRe, () => astJson); - replaced = true; - break; - } - } - // Drop the orphan asset only after its rules are applied above. - if (replaced) { - for (const a of cssAssets) delete bundle[(a as any).fileName]; - } + const result = inlineBundleCss(bundle, (cssText) => JSON.stringify(parseCssToAst(cssText, { silent: true }), (key, value) => (key === 'position' ? undefined : value))); if (opts.verbose) { - console.info(`[ns-entry] bundle CSS: ${cssAssets.length} asset(s), ${cssText.length} bytes, applied=${replaced}`); + console.info(`[ns-entry] bundle CSS: ${result.assetCount} asset(s), ${result.bytes} bytes, applied=${result.replaced}`); } } catch (e: any) { if (opts.verbose) console.warn('[ns-entry] bundle CSS injection failed:', e?.message || e);