Skip to content

Switch to vite - #19317

Open
snipe wants to merge 3 commits into
developfrom
switch-to-vite
Open

Switch to vite#19317
snipe wants to merge 3 commits into
developfrom
switch-to-vite

Conversation

@snipe

@snipe snipe commented Jul 19, 2026

Copy link
Copy Markdown
Member

Snipe-IT's frontend build has been moved from laravel-mix (webpack) to Vite. The production build ships correctly to both open-source users and hosted customers with committed public/build/ assets, and the local iteration story is npm run watch plus a browser refresh. The one thing Vite is famous for that we do NOT yet have working is npm run dev with HMR. This document explains the choices we made to get here, why the codebase constraints forced most of them, and what a follow-up refactor would look like to unlock the rest of Vite's benefits.

Why we're TRYING to migrate

  • Blocked security patches on laravel-mix's dependency tree. css-loader 5.x and less-loader 6.x had CVEs; laravel-mix 6 pinned incompatible versions of both, and there is no laravel-mix 7 to move to.
  • laravel-mix has been effectively unmaintained for years. Every dependency CVE would recur without an upstream path to fix.
  • Vite is the current Laravel standard. First-party plugin, active maintenance, cleaner config, faster builds.

The stack that makes this hard

Snipe-IT's frontend was built in the era when jQuery-plus-inline-scripts was the standard pattern. Every Blade template has fragments like:

<select class="select2"></select>
<script>$('.select2').select2({ placeholder: '{{ trans('general.pick_one') }}' });</script>

Those inline scripts run during document parse, BEFORE the deferred module bundle executes. That single fact shapes almost every compromise below.

What we shipped, and why each piece exists

1. jQuery and moment are loaded as blocking classic script tags before @vite

resources/views/layouts/default.blade.php includes:

<script src="{{ url('build/vendor/jquery.min.js') }}"></script>
<script src="{{ url('build/vendor/moment-with-locales.min.js') }}"></script>

immediately before the @vite([...]) directive. Rationale: hundreds of inline <script>$('.foo')...</script> blocks scattered through Blades expect window.jQuery to be defined the moment the parser reaches them. Module scripts have defer semantics, so any bundle-provided jQuery arrives too late. The files are static-copied to public/build/vendor/ on every build by vite-plugin-static-copy.

2. Bundle-side imports of jquery and moment route to .cjs shim modules

vite.config.js aliases both packages at tiny shim files:

// resources/assets/js/jquery-window-shim.cjs
module.exports = window.jQuery;

Rationale: without the shim, the bundle imports a SECOND copy of jQuery. Plugins imported into the bundle (select2, colorpicker, datetimepicker, jquery-ui) would register on that second copy, and inline $('.thing').plugin() calls in the Blades (which use the FIRST copy on window) would throw "plugin is not a function".

The .cjs extension is deliberate. Rolldown treats CJS modules as eagerly evaluated on first import, so module.exports = window.jQuery captures the real value at the moment the bundle initializes. An equivalent .js file with export default window.jQuery compiled into a lazily-initialized binding that stayed undefined long enough for jQuery UI's UMD wrapper to blow up during load.

3. select2's UMD installer is invoked explicitly

import select2Installer from 'select2';
if (typeof select2Installer === 'function') {
    select2Installer(window, window.jQuery);
}

Rationale: select2's UMD wrapper detects "browser with global jQuery" and self-installs. Under bundlers it instead detects CJS and exposes an installer function, expecting the consumer to call it. Vite/rolldown treat select2 as CJS, so $.fn.select2 never got attached until we called the installer manually.

4. Two bundles, not one

  • resources/assets/js/app.js and resources/assets/less/vite-main.less compile into the main bundle.
  • resources/assets/js/bootstrap-table.js and resources/assets/less/vite-bootstrap-table.less compile into a separate bundle.

Rationale: the bootstrap-table stack is ~675KB. It's only used on pages that render a bootstrap-table. Keeping it in its own bundle keeps the weight off login, dashboard, forms, and every other non-list page. This mirrors the split that existed under mix.

5. tableExport, jsPDF, DejaVu font loader, FileSaver, and xlsx are loaded as classic script tags

These packages do $jscomp.getGlobal(this) or reference bare jQuery at module top level. Under ESM strict mode, top-level this === undefined, so those calls throw. Loading them as classic script tags via resources/views/partials/bootstrap-table.blade.php gives them the ambient browser this === window they expect. They are static-copied to public/build/vendor/ on build.

6. Chart.js and select2 i18n locales are also static-copied and loaded via classic script tags

Chart.js is a 350KB UMD blob used on exactly two pages (dashboard and reports/index). It is loaded directly via <script src="build/vendor/Chart.min.js"> on those two pages, bypassing @vite.

select2 i18n has one JS file per locale. The default layout picks the current locale at render time and loads the appropriate file directly, since bundling every locale would waste bandwidth.

7. Output filenames are pinned; no content hashes

vite.config.js sets:

build: {
    rollupOptions: {
        output: {
            entryFileNames: 'assets/[name].js',
            chunkFileNames: 'assets/[name].js',
            assetFileNames: 'assets/[name].[ext]',
        },
    },
},

Rationale: we commit public/build/ to git so open-source users and our hosted customers get a working app straight from the repo without needing Node. Content-hashed filenames would churn every rebuild and create massive commit diffs plus orphaned files in git history.

Trade-off: no automatic cache-busting on user upgrades. Users may need a hard-refresh once after a version bump. Same behavior as mix's ?id=<hash> query strings if you ignore the query string entirely.

8. Sourcemaps are committed

build.sourcemap: true. This keeps the browser DevTools console clean (no 404s trying to fetch missing .map files). Precedent: mix committed all.js.map too. Cost: roughly 4MB of sourcemap files under public/build/assets/ on every build.

9. npm run watch provided; npm run dev present but unfixed

  • npm run build produces a full production build.
  • npm run watch is vite build --watch, which rebuilds on save using the same code path as npm run build. Devs iterating locally run watch in one terminal and hard-refresh the browser after each save. No dev-server timing surprises because the output is identical to production.
  • npm run dev is the true Vite dev server with HMR. It is wired up but currently throws several errors on load because dev-server module resolution doesn't match production bundling. See the next section.

Why HMR doesn't work today

The Vite dev server serves each module as its own ES module over HTTP. app.js and bootstrap-table.js are separate entries whose imports resolve in parallel with no guaranteed ordering between them. The production build hides this by concatenating everything into ordered bundles.

Specific breakage seen in npm run dev:

  • dragtable's $.widget(...) at module top level throws because $.widget comes from jquery-ui, imported in app.js, which has not necessarily finished loading when bootstrap-table.js executes.
  • Colorpicker does not register because the .cjs shim behaves differently under esbuild's dev-mode pre-bundle than under rolldown's production build.
  • select2 i18n throws e.define undefined for the same class of timing reason.
  • Importing binding name 'default' cannot be resolved by star export entries on one of the imports where esbuild-dev treats a module as re-export-only.
  • Stale public/hot file if the dev server exits ungracefully. Laravel's @vite reads this file and continues sending page requests to the dead dev server. Removing the file fixes it.

None of these are Vite bugs. They are all consequences of asking a jQuery-plus-inline-script codebase to work in an ES-module dev environment.

What's next: unlocking the rest of Vite

The root cause of every dev-mode issue is the inline-script pattern. Every Blade fragment like:

<select class="select2"></select>
<script>$('.select2').select2({ placeholder: '{{ trans('general.pick_one') }}' });</script>

introduces a timing dependency between document parse and the deferred bundle. The fix is to move initialization OUT of Blades and INTO the bundle, using data-* attributes on elements to carry per-instance configuration.

The pattern to migrate toward

Before:

<select class="select2"></select>
<script>$('.select2').select2({ placeholder: '{{ trans('general.pick_one') }}' });</script>

After:

<select data-toggle="select2" data-placeholder="{{ trans('general.pick_one') }}"></select>

In snipeit.js (or a dedicated init module):

$(document).ready(function () {
    $('[data-toggle="select2"]').each(function () {
        $(this).select2({
            placeholder: $(this).data('placeholder'),
        });
    });
});

Bootstrap-table already uses this pattern (<table data-toggle="table" data-url="...">). Bootstrap-table is the model, not the target.

What we get once the migration is far enough along

  • npm run dev works reliably. Devs get true HMR with sub-100ms feedback on JS and CSS edits.
  • The blocking <script src="build/vendor/jquery.min.js"> in the layout can be deleted.
  • The .cjs shims can be deleted.
  • The explicit select2Installer(...) call can be deleted.
  • Prod and dev behavior converge. Fewer "works locally, breaks in production" surprises.

Rough thresholds:

  • 60% to 70% of inline scripts migrated: npm run dev probably becomes usable with occasional glitches.
  • 100%: full HMR, no shims, no blocking scripts.

How to phase the migration

Do NOT tackle it as a big-bang project. Too much surface area, too much risk of regressions. Instead:

  • When you touch a Blade for any other reason, migrate its inline scripts as part of the same PR. Boy-scout rule.
  • Keep a running list of which widget types have data-toggle initializers already built out (select2, datepicker, colorpicker, etc.) so contributors can consistently reach for the pattern instead of copying inline scripts from other files.
  • For PHP-computed values that do not fit a data-* attribute (rare), use a partial that populates window.SnipeIT = { ... } once per page and read from it in the bundle.
  • Bootstrap-table is not in scope. It stays, and it stays in its own bundle.

Deployment model unchanged

  • Open-source users still git clone or download a release zip and get working assets from public/build/. They do not need Node.
  • Hosted customers still deploy directly from the same repo. Master must always have current built assets. Same responsibility model as under mix.
  • Contributors ship source-only PRs. Maintainer builds, verifies, and commits public/build/ as part of merging. No pre-commit hooks or CI automation enforce this, matching the mix-era workflow.

Files touched by the migration

Added:

  • vite.config.js
  • resources/assets/js/app.js (ESM entry replacing the top of snipeit.js)
  • resources/assets/js/bootstrap-table.js (ESM entry for the table bundle)
  • resources/assets/less/vite-main.less (CSS entry mirroring mix's .styles(...) chain)
  • resources/assets/less/vite-bootstrap-table.less (CSS entry for the table bundle)
  • resources/assets/js/jquery-window-shim.cjs
  • resources/assets/js/moment-window-shim.cjs
  • resources/views/partials/success-sound-js.blade.php (handles browser autoplay policy for the success sound)

Removed:

  • webpack.mix.js
  • public/mix-manifest.json
  • Everything under public/js/dist/, public/css/dist/, and public/css/build/

Modified:

  • package.json (Vite devDeps in, mix devDeps out; dev / watch / build / prod / production scripts)
  • .gitignore (removed /public/build so built assets are tracked; /public/hot still ignored)
  • CLAUDE.md (stack docs updated from mix to Vite)
  • Every Blade layout that referenced mix('...') now uses @vite([...])
  • app/Providers/AppServiceProvider.php (CSP nonce applied to Vite-emitted script and link tags)
  • resources/assets/js/snipeit.js (undeclared globals fixed for ESM strict mode, $.fn.datepicker reference guarded)

Test plan

  • npm ci && npm run build produces public/build/manifest.json and hashed-free asset files with no errors.
  • Login page renders and login works (basic layout).
  • Dashboard renders, Chart.js widgets appear.
  • Asset index renders a bootstrap-table with working filters, export, sort, and column reorder.
  • Asset create/edit renders datepickers, select2s (including AJAX-loaded), colorpicker, image upload.
  • Bulk checkout renders with Livewire panels and select2 AJAX.
  • Custom field create/edit renders (heavy Livewire page).
  • Reports index renders Chart.js and the date range picker.
  • User print view renders bootstrap-table with export controls.
  • Setup wizard renders (setup layout).
  • Passport OAuth authorize page renders (basic layout, no bootstrap-table).
  • After profile save with sounds and confetti enabled, both fire on the redirect page (or on the next user interaction if browser autoplay blocks the immediate play).
  • php artisan test still passes.
  • View page source on any page and confirm every <script> and <link> emitted by @vite carries a nonce attribute matching the CSP nonce pattern used by other inline scripts.

Disclaimer: I used the crap out of Claude for this. It was hard and awful.

@guardrails

guardrails Bot commented Jul 19, 2026

Copy link
Copy Markdown

⚠️ We detected 2 security issues in this pull request:

Vulnerable Libraries (2)
Severity Details
High pkg:npm/laravel-vite-plugin@3.1.3 upgrade to: > 3.1.3
High pkg:npm/vite-plugin-static-copy@4.1.1 upgrade to: > 4.1.1

More info on how to fix Vulnerable Libraries in JavaScript.


👉 Go to the dashboard for detailed results.

📥 Happy? Share your feedback with us.

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 critical · 4 medium

Alerts:
⚠ 5 issues (≤ 0 issues of at least minor severity)

Results:
5 new issues

Category Results
UnusedCode 2 medium
BestPractice 1 medium
Security 1 critical
1 medium

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@snipe

snipe commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

The Guardrails alerts here don't have an actionable fix, but also...

  • laravel-vite-plugin@3.1.3 is the latest version on npm, published 2026-07-13 (6 days ago). No > 3.1.3 version exists.
  • vite-plugin-static-copy@4.1.1 is also the latest on npm, published 2026-06-06. No > 4.1.1 version exists.
  • npm audit (which pulls from GHSA, the same database GitHub Dependabot uses) does NOT flag either package.

So Guardrails is telling us to upgrade to versions that literally do not exist yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant