Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/vite/helpers/bundle-css.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({ type: 'chunk', fileName, code: '', imports: [], dynamicImports: [], viteMetadata: { importedCss: new Set<string>() }, ...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<string, any>;
}

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();
});
});
67 changes: 67 additions & 0 deletions packages/vite/helpers/bundle-css.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): any[] {
const cssByName = new Map<string, any>();
for (const file of Object.values(bundle)) {
if (isCssAsset(file)) cssByName.set(file.fileName, file);
}
const ordered = new Set<any>();
const visited = new Set<string>();
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<string, any>, 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 };
}
27 changes: 3 additions & 24 deletions packages/vite/helpers/main-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -121,31 +121,10 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos'
generateBundle(_outputOptions: unknown, bundle: Record<string, any>) {
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);
Expand Down
Loading