From 86c46847f04b75d47ec9d7d1daf955f56c6cdb14 Mon Sep 17 00:00:00 2001 From: vitaly liber Date: Tue, 7 Jul 2026 09:10:17 +0300 Subject: [PATCH 1/2] fix(vite-plugin-ruby): reuse an already-emitted asset instead of fingerprinting it twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fingerprintRemainingAssets re-reads and re-emits any asset matched by additionalEntrypoints (e.g. app/frontend/{assets,images,...}/**/*), even if the same file was already emitted by Vite's own asset pipeline because it's also imported from JS/CSS or referenced from an HTML entrypoint. Two independent emitFile calls for byte-identical content aren't guaranteed to resolve to the same content hash — this can differ between Rollup and Rolldown (Vite v8's default bundler). When they diverge, manifest-assets.json (which wins when vite_ruby merges manifest files, since it's loaded last) ends up pointing at a hashed filename that vite-plugin-ruby computed but never actually wrote to disk, while a different file with the correct content sits under the hash from manifest.json. Rails then 404s on any vite_asset_path call for that entry. Scan the bundle for an OutputAsset whose originalFileNames (or the deprecated singular originalFileName) already contains the absolute path before emitting again, and reuse its fileName. This removes the double-emit for any asset that's both entrypoint-globbed and otherwise referenced, regardless of which bundler is in use, and also avoids writing the same file to the output directory twice. See https://github.com/ElMassimo/vite_ruby/issues/611 for the originating report and a full production repro. --- vite-plugin-ruby/src/manifest.ts | 40 ++++++++- vite-plugin-ruby/tests/manifest.spec.ts | 111 ++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 vite-plugin-ruby/tests/manifest.spec.ts diff --git a/vite-plugin-ruby/src/manifest.ts b/vite-plugin-ruby/src/manifest.ts index 0194d3f7..cfeef213 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,33 @@ 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, absoluteFilename: string): string | undefined { + const normalizedFilename = slash(absoluteFilename) + + 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 => slash(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 +57,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, 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..04c95b91 --- /dev/null +++ b/vite-plugin-ruby/tests/manifest.spec.ts @@ -0,0 +1,111 @@ +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('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' }, + }) + }) +}) From f9709e7f4bc84ed7d853bb43877ecc5bb25bfe29 Mon Sep 17 00:00:00 2001 From: vitaly liber Date: Tue, 7 Jul 2026 09:28:58 +0300 Subject: [PATCH 2/2] fix(vite-plugin-ruby): resolve originalFileNames relative to config.root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the previous commit's fix against the real production app from issue #611 (Vite 8 + a large Vue2/Rails app) by pointing its package.json at this branch. The mismatch still reproduced — the dedup lookup never matched. Traced it to a difference in what Rolldown reports for OutputAsset#originalFileNames: Rollup's types document it as absolute paths, and that holds for most assets in this build, but for the one matched by additionalEntrypoints AND imported from a Vue SFC (bluethumb-nav-logo.svg), Rolldown reported it as a single path relative to config.root ("assets/bluethumb-nav-logo.svg") instead of absolute. findExistingAsset compared that directly against the absolute filename from vite-plugin-ruby's own glob and never matched, so it fell through to emitFile and re-triggered the original bug. Resolve non-absolute originalFileNames against config.root before comparing. Added a regression test for this exact shape, and confirmed against the real app: manifest.json and manifest-assets.json now agree on a single hash across repeated clean builds, and the asset resolves (HTTP 200) when served. --- vite-plugin-ruby/src/manifest.ts | 11 ++++++++--- vite-plugin-ruby/tests/manifest.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/vite-plugin-ruby/src/manifest.ts b/vite-plugin-ruby/src/manifest.ts index cfeef213..071ae695 100644 --- a/vite-plugin-ruby/src/manifest.ts +++ b/vite-plugin-ruby/src/manifest.ts @@ -33,15 +33,20 @@ type EmittedOutputAsset = OutputAsset & { originalFileNames?: string[] } // 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, absoluteFilename: string): string | undefined { +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 => slash(fileName) === normalizedFilename)) return asset.fileName + if (originalFileNames.some(fileName => resolveOriginalFileName(fileName) === normalizedFilename)) return asset.fileName } } @@ -57,7 +62,7 @@ export function assetsManifestPlugin (): Plugin { const remainingAssets = filterEntrypointAssets(viteRubyConfig.entrypoints) for (const [filename, absoluteFilename] of remainingAssets) { - const hashedFilename = findExistingAsset(bundle, absoluteFilename) ?? await emitAsset(ctx, filename, absoluteFilename) + const hashedFilename = findExistingAsset(bundle, config.root, absoluteFilename) ?? await emitAsset(ctx, filename, absoluteFilename) manifest.set(path.relative(config.root, absoluteFilename), { file: hashedFilename, src: filename }) } } diff --git a/vite-plugin-ruby/tests/manifest.spec.ts b/vite-plugin-ruby/tests/manifest.spec.ts index 04c95b91..e82dee10 100644 --- a/vite-plugin-ruby/tests/manifest.spec.ts +++ b/vite-plugin-ruby/tests/manifest.spec.ts @@ -88,6 +88,27 @@ describe('assetsManifestPlugin', () => { }) }) + 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]])