kanak09's avatar

Vite is building assets with hash in filename but hash is missing in manifest.json and js entry point

Hello CoMmunity,

[TLDR] After "npm run build", my local website searches for /build/assets/Home-CAF9ffrI.css but real file is under /build/assets/Home-CAF9ffrI-b1476d91.css. A hash is added to the file but not in the manifest.json


I am struggling so much building to production my Laravel-Vue-Inertia app. For now everything was running ultra well in dev mode using vitejs. "npm run dev" or "vite" commands work perfectly fine and all the css / js / vue components are displaying ok. Now that i am building to prod "npm run build" , the compilation is successful but the website is a blank page with 404 on all assets url.

In the browser console there is 404 for http://myvirtualhost.local/build/assets/Home-CAF9ffrI.css but the file is under http://myvirtualhost.local/build/assets/Home-CAF9ffrI-b1476d91.css A hash b1476d91 is added by vite on the compilation time i guess, but in the manifest.json I have somehting like

"css": [ "assets/Home-CAF9ffrI.css" ]

and in the built entry point js, i also have the filename without the hash.

I tried to manually update the JS entry point by updating all references to their references with hash and then the website works...

So what did i miss in the config to have such a behavior ?

Thank you very much for help

Please find below the main files : app.blade.php , vite.config.js, example of one entry point elearning.ts

------ app.blade.php -------

{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
    (function() {
        const appearance = '{{ $appearance ?? "system" }}';

        if (appearance === 'system') {
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

            if (prefersDark) {
                document.documentElement.classList.add('dark');
            }
        }
    })();

</script>

@if (!app()->isProduction())
    <title inertia>{{ config('app.name') }}</title>
@endif
<!--
   -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.png" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

@php
    if( request()->routeIs('admin.*') ){
        $entry = 'admin';
    }else if(request()->routeIs('elearning.*') ){
        $entry = 'elearning';
    } else {
        $entry = 'web';
    }
@endphp

@vite('resources/js/' . $entry . '.ts')

@inertiaHead

---------- vite.config.js -------------

import vue from "@vitejs/plugin-vue"; import laravel from "laravel-vite-plugin"; import path from "path"; import { defineConfig } from "vite"; import compression from "vite-plugin-compression"; import purge from "@erbelion/vite-plugin-laravel-purgecss"; import { visualizer } from "rollup-plugin-visualizer"; import { laravelTranslationsPlugin } from "./vite/translation"; import { wayfinder } from "@laravel/vite-plugin-wayfinder";

export default defineConfig({ server: { host: "0.0.0.0", // important for Docker port: 5173, strictPort: true, hmr: { host: "localhost", // or your local IP for dev }, }, plugins: [ purge({ paths: ["resources/{js,views}/**/*.{blade.php,svelte,vue,html,ts}"], safelist: [], }), visualizer({ open: true, // Automatically opens the visualizer in your browser }), laravel({ input: ["resources/js/web.ts", "resources/js/elearning.ts"], ssr: "resources/js/ssr.ts", refresh: true, }), vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false, }, }, }), wayfinder(), laravelTranslationsPlugin(), //typescriptTransformer(), compression({ algorithm: "gzip" }), compression({ algorithm: "brotliCompress" }), ], resolve: { alias: { "@": path.resolve(__dirname, "./resources/js"), }, }, build: { sourcemap: false, rollupOptions: { input: { web: path.resolve(__dirname, "resources/js/web.ts"), elearning: path.resolve(__dirname, "resources/js/elearning.ts"), }, output: { manualChunks(id) { // sépare toutes les dépendances node_modules dans un chunk vendor if (id.includes("node_modules")) { return "vendor"; } }, }, }, }, });

---------- baseEntrypoint.ts -------

import { createInertiaApp } from "@inertiajs/vue3"; import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers"; import { createApp, h } from "vue"; import { initializeTheme } from "./composables/useAppearance"; import { createI18n } from "vue-i18n"; import fr from "@/i18n/fr.json"; import { Tooltip } from "bootstrap";

const appName = import.meta.env.VITE_APP_NAME;

export function bootstrapLayout(options: { pagesDir: string; pageGlob: Record<string, any>; }) {

const i18n = createI18n({
    legacy: false,
    locale: "fr",
    fallbackLocale: "fr",
    messages: { fr },
});

function initializeTooltips() {
    const tooltipTriggerList = document.querySelectorAll(
        '[data-bs-toggle="tooltip"]',
    );
    tooltipTriggerList.forEach((el) => new Tooltip(el));
}

createInertiaApp({
    title: (title) => (title ? `${title} - ${appName}` : appName),
    resolve: (name) =>
        resolvePageComponent(`./pages/${name}.vue`, options.pageGlob),
    setup({ el, App, props, plugin }) {
        const app = createApp({ render: () => h(App, props) })
            .use(plugin)
            .use(i18n);

        app.mount(el);

        initializeTooltips();
        initializeTheme();
    },
    progress: {
        color: "#3b86ec",
        includeCSS: true,
        showSpinner: false,
    },
});

}

----- elearning.ts ----------

import './assets/scss/Elearning/main.scss';

import { bootstrapLayout } from "./bootstapLayout"; import { DefineComponent } from "vue";

baseEntrypoint({ pagesDir: "Elearning", pageGlob: import.meta.glob<DefineComponent>("./pages/Elearning/**/*.vue"), });

0 likes
4 replies
vincent15000's avatar

That's weird, I never had this with my applications.

Can you show the your vite.config.js file ?

Mega_Aleksandar's avatar

Hello,

This bit is the issue:

output: { manualChunks(id) { // sépare toutes les dépendances node_modules dans un chunk vendor if (id.includes("node_modules")) { return "vendor"; } }, }, }, }, });

If you are already overriding the output, you have to also be explicit about what to do with your own files.

Also, as @carlbidwell268 pointed out, remove the build directory manually before build, remove vite cache from node_modules (sometimes it can cause troubles if you saved too fast, changed too little), reinstall vite if needed to purge it, disable a plugin at a time, see which one causes the extra hash appending to the file name.

Hope it helps.

JussiMannisto's avatar

No. That just defines how Vite (Rollup) should chunk assets. It's completely normal, and you don't have to do anything different.

kanak09's avatar

Thanks all for your help.

@vincent15000 My vite file was included in my first message but the formatting is destroyed and doesnt format the whole file... Here it is again

START VITE.CONFIG.JS FILE

import vue from "@vitejs/plugin-vue"; import laravel from "laravel-vite-plugin"; import path from "path"; import { defineConfig, type UserConfig } from "vite"; import compression from "vite-plugin-compression"; import purge from "@erbelion/vite-plugin-laravel-purgecss"; import { laravelTranslationsPlugin } from "./vite/translation"; import { fixCssReferences } from "./vite/fixCSSReferences"; import { wayfinder } from "@laravel/vite-plugin-wayfinder";

const isProd = process.env.NODE_ENV === "production"; const isClever = process.env.PROD_ENV === "clevercloud";

return {
    server: {
        host: "0.0.0.0", // important for Docker
        port: 5173,
        strictPort: true,
        hmr: {
            host: "localhost", // or your local IP for dev
        },
    },
    plugins: [
        purge({
            paths: [
                "resources/{js,views}/**/*.{blade.php,svelte,vue,html,ts}",
            ],
            safelist: {
                // Preserve all scoped component styles (data-v-* attributes)
                deep: [/data-v-.*$/],
                // Add specific component classes that might be purged
                standard: [
                    "password-toggle-btn",
                    "password-input-container",
                    "password-rules",
                    "rule-indicator",
                    "bullet",
                ],
                greedy: [
                    /^password-/, // Preserve all password-related classes
                    /\[data-v-/, // Preserve all scoped styles
                ],
            },
        }),
        laravel({
            input: ["resources/js/app.ts"],
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
        wayfinder(),
        laravelTranslationsPlugin(),
        fixCssReferences(), // Fix CSS references in manifest to use hashed filenames

        // Compression gzip/brotli uniquement si pas sur Clever Cloud
        isProd &&
            !isClever &&
            compression({
                algorithm: "gzip",
                compressionOptions: { level: 9 },
                ext: ".gz",
                threshold: 10240, // Only compress files larger than 10KB
            }),
        isProd &&
            !isClever &&
            compression({
                algorithm: "brotliCompress",
                compressionOptions: { level: 11 },
                ext: ".br",
                threshold: 10240,
            }),
    ].filter(Boolean),
    resolve: {
        alias: {
            "@": path.resolve(__dirname, "./resources/js"),
        },
    },
    build: {
        sourcemap: false,
        cssCodeSplit: true,
        chunkSizeWarningLimit: 800,
        minify: "esbuild", //terser
        target: "es2020",
        rollupOptions: {
            output: {
                manualChunks(id) {
                    // sépare toutes les dépendances node_modules dans un chunk vendor
                    if (id.includes("node_modules")) {
                        return "vendor";
                    }
                },
                //chunkFileNames: "assets/[name]-[hash].js",
                /*
            assetFileNames: (assetInfo) => {
                // Use names array instead of deprecated name property
                const name = assetInfo.names?.[0] || "";

                // For CSS files, return the exact name without hash placeholders
                // The fixCssReferences plugin will handle the hash mapping in manifest
                if (name.endsWith(".css")) {
                    return `assets/${name}`;
                }

                // For other assets (fonts, images), use content hash for cache busting
                return "assets/[name]-[hash][extname]";
            },
             */
            },
        },
    },
    optimizeDeps: {
        include: ["vue", "@inertiajs/vue3", "axios", "lodash"],
    },
};

});

END VITE.CONFIG.JS FILE

@mega_aleksandar I tried to remove the output but same result and as @jussimannisto said it should not interfer as it is a complete normal configuration, best example is for the vendor files.

Anyway for all, I found a solution. For sure it's not the best and it should not be like that, but thanks to AI I wrote a vite plugin that fixes my css references in the manifest regarding what has been generated and it works like a charm ! I know it's not a normal solution don't have choice for now.

If anyone has a brillant idea i still take it. Cheers

Here is the vite plugin for asset fixes

START VITE PLUGIN // Plugin to fix CSS references in manifest and chunks // Best practice: Keep hashed CSS files on disk for cache busting, // but ensure manifest.json correctly references them import { Plugin } from "vite";

export function fixCssReferences(): Plugin { return { name: 'fix-css-references', enforce: 'post', generateBundle(options, bundle) { // Map to store CSS filename mappings (non-hashed -> hashed) const cssMap: Map<string, string> = new Map();

        // First pass: Build a map of CSS files with their hashes
        for (const fileName in bundle) {
            if (fileName.endsWith('.css') && fileName.includes('-')) {
                // Extract the base name without hash (e.g., "Home-b1476d91.css" -> "Home.css")
                const baseName = fileName.replace(/-[a-f0-9]{8}\.css$/, '.css');
                cssMap.set(baseName, fileName);
            }
        }

        // Second pass: Update CSS references in all chunks
        for (const fileName in bundle) {
            const chunk = bundle[fileName];

            if (chunk.type === 'chunk' && 'viteMetadata' in chunk) {
                const metadata = chunk.viteMetadata as any;

                // Update CSS imports in metadata (importedCss is a Set)
                if (metadata?.importedCss && metadata.importedCss instanceof Set) {
                    const updatedCss = new Set<string>();

                    for (const cssFile of metadata.importedCss) {
                        const hashedVersion = cssMap.get(cssFile);
                        updatedCss.add(hashedVersion || cssFile);
                    }

                    metadata.importedCss = updatedCss;
                }
            }
        }
    },
};

}

END VITE PLUGIN

Please or to participate in this conversation.