diff --git a/vite-plugin-ruby/src/manifest.ts b/vite-plugin-ruby/src/manifest.ts index 0194d3f7..071ae695 100644 --- a/vite-plugin-ruby/src/manifest.ts +++ b/vite-plugin-ruby/src/manifest.ts @@ -4,9 +4,10 @@ import { createDebug } from 'obug' import type { Plugin, ResolvedConfig } from 'vite' -import type { OutputBundle, PluginContext } from 'rollup' +import type { OutputBundle, OutputAsset, PluginContext } from 'rollup' import { UnifiedConfig } from './types' import { filterEntrypointAssets } from './config' +import { slash } from './utils' const debug = createDebug('vite-plugin-ruby:assets-manifest') @@ -17,6 +18,38 @@ interface AssetsManifestChunk { type AssetsManifest = Map +// Internal: Older Rollup types don't know about the `names`/`originalFileNames` +// arrays introduced for Vite v8, so we widen the shape locally instead of +// bumping the (type-only) `rollup` dependency. +type EmittedOutputAsset = OutputAsset & { originalFileNames?: string[] } + +// Internal: Finds an asset that Vite's own build pipeline already emitted for +// this exact source file (e.g. because it's also imported from JS/CSS, or +// referenced from an HTML entrypoint), so we can reuse its hashed file name. +// +// Emitting the same file a second time isn't safe to assume idempotent: two +// independent `emitFile` calls for byte-identical content aren't guaranteed to +// resolve to the same hash (this differs between Rollup and Rolldown), and +// when they don't, only one of the two hashed files actually ends up on disk. +// Reusing the existing entry sidesteps the mismatch entirely, and also avoids +// writing the same asset to disk twice. +function findExistingAsset (bundle: OutputBundle, root: string, absoluteFilename: string): string | undefined { + const normalizedFilename = slash(absoluteFilename) + + // Rollup documents `originalFileNames` as absolute paths, but Rolldown (Vite + // v8's default bundler) can report them relative to `config.root` instead. + const resolveOriginalFileName = (fileName: string) => + slash(path.isAbsolute(fileName) ? fileName : path.resolve(root, fileName)) + + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'asset') continue + + const asset = chunk as EmittedOutputAsset + const originalFileNames = asset.originalFileNames ?? (asset.originalFileName ? [asset.originalFileName] : []) + if (originalFileNames.some(fileName => resolveOriginalFileName(fileName) === normalizedFilename)) return asset.fileName + } +} + // Internal: Writes a manifest file that allows to map an entrypoint asset file // name to the corresponding output file name. export function assetsManifestPlugin (): Plugin { @@ -29,13 +62,17 @@ export function assetsManifestPlugin (): Plugin { const remainingAssets = filterEntrypointAssets(viteRubyConfig.entrypoints) for (const [filename, absoluteFilename] of remainingAssets) { - const content = await fsp.readFile(absoluteFilename) - const ref = ctx.emitFile({ name: path.basename(filename), type: 'asset', source: content }) - const hashedFilename = ctx.getFileName(ref) + const hashedFilename = findExistingAsset(bundle, config.root, absoluteFilename) ?? await emitAsset(ctx, filename, absoluteFilename) manifest.set(path.relative(config.root, absoluteFilename), { file: hashedFilename, src: filename }) } } + async function emitAsset (ctx: PluginContext, filename: string, absoluteFilename: string) { + const content = await fsp.readFile(absoluteFilename) + const ref = ctx.emitFile({ name: path.basename(filename), type: 'asset', source: content }) + return ctx.getFileName(ref) + } + return { name: 'vite-plugin-ruby:assets-manifest', apply: 'build', diff --git a/vite-plugin-ruby/tests/manifest.spec.ts b/vite-plugin-ruby/tests/manifest.spec.ts new file mode 100644 index 00000000..e82dee10 --- /dev/null +++ b/vite-plugin-ruby/tests/manifest.spec.ts @@ -0,0 +1,132 @@ +import { resolve } from 'path' +import { describe, test, expect, vi } from 'vitest' +import type { OutputBundle } from 'rollup' +import { assetsManifestPlugin } from '@plugin/manifest' + +describe('assetsManifestPlugin', () => { + const root = resolve('example/app/frontend') + + function setup (entrypoints: [string, string][]) { + const plugin = assetsManifestPlugin() + + ;(plugin.configResolved as any)({ + root, + build: { manifest: true }, + viteRuby: { entrypoints }, + }) + + const emitFile = vi.fn(() => 'ref') + const getFileName = vi.fn(() => 'assets/re-fingerprinted-hash.svg') + const ctx: any = { emitFile, getFileName } + + return { plugin, ctx, emitFile, getFileName } + } + + function manifestAssetsSource (emitFile: ReturnType) { + const call = emitFile.mock.calls.find(([asset]) => asset.fileName === '.vite/manifest-assets.json') + return JSON.parse(call![0].source) + } + + test('reuses an asset Vite already emitted for the same source file, instead of fingerprinting it again', async () => { + const absoluteFilename = resolve(root, 'images/logo.svg') + const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]]) + + // Simulates Vite/Rolldown having already emitted this exact file elsewhere + // in the bundle (e.g. it's also imported from JS or referenced from HTML). + const bundle = { + 'assets/logo-existingHash.svg': { + type: 'asset', + fileName: 'assets/logo-existingHash.svg', + originalFileNames: [absoluteFilename], + source: '', + }, + } as unknown as OutputBundle + + await (plugin.generateBundle as any).call(ctx, {}, bundle) + + // Re-emitting the same content isn't guaranteed to resolve to the same + // hash (this can differ between Rollup and Rolldown), so the existing + // entry must be reused rather than fingerprinted a second time. + expect(getFileName).not.toHaveBeenCalled() + expect(manifestAssetsSource(emitFile)).toEqual({ + 'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' }, + }) + }) + + test('fingerprints the asset when Vite has not already emitted it elsewhere', async () => { + const absoluteFilename = resolve(root, 'images/logo.svg') + const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]]) + + const bundle = {} as OutputBundle + + await (plugin.generateBundle as any).call(ctx, {}, bundle) + + expect(getFileName).toHaveBeenCalledTimes(1) + expect(manifestAssetsSource(emitFile)).toEqual({ + 'images/logo.svg': { file: 'assets/re-fingerprinted-hash.svg', src: 'images/logo.svg' }, + }) + }) + + test('does not match an asset emitted for a different source file', async () => { + const absoluteFilename = resolve(root, 'images/logo.svg') + const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]]) + + const bundle = { + 'assets/other-hash.svg': { + type: 'asset', + fileName: 'assets/other-hash.svg', + originalFileNames: [resolve(root, 'images/other.svg')], + source: '', + }, + } as unknown as OutputBundle + + await (plugin.generateBundle as any).call(ctx, {}, bundle) + + expect(getFileName).toHaveBeenCalledTimes(1) + expect(manifestAssetsSource(emitFile)).toEqual({ + 'images/logo.svg': { file: 'assets/re-fingerprinted-hash.svg', src: 'images/logo.svg' }, + }) + }) + + test('resolves `originalFileNames` reported relative to config.root (observed with Rolldown)', async () => { + const absoluteFilename = resolve(root, 'images/logo.svg') + const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]]) + + const bundle = { + 'assets/logo-existingHash.svg': { + type: 'asset', + fileName: 'assets/logo-existingHash.svg', + originalFileNames: ['images/logo.svg'], + source: '', + }, + } as unknown as OutputBundle + + await (plugin.generateBundle as any).call(ctx, {}, bundle) + + expect(getFileName).not.toHaveBeenCalled() + expect(manifestAssetsSource(emitFile)).toEqual({ + 'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' }, + }) + }) + + test('supports the legacy singular `originalFileName` (pre-Vite-v8 shape)', async () => { + const absoluteFilename = resolve(root, 'images/logo.svg') + const { plugin, ctx, emitFile, getFileName } = setup([['images/logo.svg', absoluteFilename]]) + + const bundle = { + 'assets/logo-existingHash.svg': { + type: 'asset', + fileName: 'assets/logo-existingHash.svg', + originalFileName: absoluteFilename, + source: '', + }, + } as unknown as OutputBundle + + await (plugin.generateBundle as any).call(ctx, {}, bundle) + + expect(getFileName).not.toHaveBeenCalled() + expect(manifestAssetsSource(emitFile)).toEqual({ + 'images/logo.svg': { file: 'assets/logo-existingHash.svg', src: 'images/logo.svg' }, + }) + }) +})