Repository navigation
Migrate to Vue 3 and Quasar 2 - #361
Open
HarmlessHarm wants to merge 27 commits into
Open
HarmlessHarm wants to merge 27 commits into
HarmlessHarm wants to merge 27 commits into
Conversation
vuefire, vue-cookies, vuejs-logger and vue2-flip-countdown were referenced only in src/boot/plugins.js and nowhere else in the app -- no $bindAsObject, no $cookies, no logger, no countdown component. Removing them now keeps them out of the Vue 3 migration surface entirely. src/store/store.js is unreachable: nothing imports it and it imports store/modules/encounter and store/modules/content, neither of which exists. Also adds the migration spec.
- @quasar/app 2 (webpack 4) -> @quasar/app-webpack 3.15.1 (webpack 5) - vue 2.7 -> 3.5, vuex 3 -> 4, vue-router is now an explicit dep at v4 - quasar 1 -> 2, lang pack id "en-us" -> "en-US" - quasar.conf.js -> quasar.config.js wrapped in configure() - babel-eslint -> @babel/eslint-parser, eslint-plugin-vue 7 -> 9 with the vue3-essential preset plus the no-deprecated-* rules that catch Vue 2 patterns which compile on Vue 3 but do nothing at runtime - eslint-webpack-plugin 2 -> 4 (webpack 5 peer), postcss/autoprefixer pinned - jsconfig vue alias -> vue.esm-bundler.js Dependency swaps in this commit (call sites follow in later phases): vue-snotify, vue-shortkey, vue-numeral-filter and vee-validate 3 are replaced by mitt/numeral/vee-validate 4 plus small in-repo plugins; vue-croppa -> vue-advanced-cropper, vue-qr -> qrcode.vue, @egjs/vue-flicking -> @egjs/vue3-flicking, @gtm-support/vue2-gtm -> @gtm-support/vue-gtm, vuedraggable 2 -> 4, splitpanes 2 -> 3. Config notes carried over deliberately: - ssr.pwa is prod-only; GenerateSW + skipWaiting in SSR dev is an infinite reload loop. - framework.config.dark is required because index.template.html hard-codes body--dark, and without it SSR and client markup disagree. - build.env defaults every process.env key used in src/, otherwise webpack leaves a bare process.env.X in the browser bundle. Drops the stale package-lock.json.backup and the webpack-4-era http-proxy-middleware / html-minifier overrides, which conflict with the webpack 5 toolchain.
src-ssr/index.js + src-ssr/extension.js are replaced by the middleware chain Quasar 2 expects: - middlewares/compression.js gzip, production only - middlewares/api.js express.json + the /api BFF, morgan in prod - middlewares/render.js catch-all Vue render, must stay last - production-export.js app.listen for dist/ssr/index.js The BFF itself (src-ssr/api) is untouched. Static file serving and the service-worker no-cache rule are now handled by the CLI via ssr.maxAge instead of hand-rolled express.static calls, and the Access-Control-Allow-Origin header the old server set on rendered pages is preserved in render.js.
Bootstrap:
- store/index.js uses Vuex 4 createStore; router/index.js uses Vue Router 4
createRouter with a memory history on the server. The two wildcard routes
become named catch-all params, and Notify is now actually imported in the
offline guard that referenced it (it would have thrown on the offline path).
- boot files take { app } and register via app.component / app.use.
- event-bus.js is a mitt emitter -- call sites move from $on/$off/$emit to
on/off/emit in a later phase.
- App.vue: <div :is> -> <component :is>, meta() -> createMetaMixin,
Cookies.parseSSR guarded to the server, destroyed -> unmounted (with the
window listeners now actually removable), process.browser -> process.env.CLIENT.
Replacements for the Vue 2 plugins, each reproducing the API the app already
calls so the ~110 call sites stay untouched:
- src/plugins/snotify.js $snotify over Quasar Notify (52 files)
- src/plugins/validation/ ValidationProvider / ValidationObserver with the
vee-validate 3 slot contract, plus the rule
registry that boot/validation.js extends (43 files)
- src/directives/shortkey.js v-shortkey with array and named forms, one shared
keydown listener, invoking the @shortkey handler
from the vnode instead of dispatching a synthetic
DOM event so it also works on components (17 files)
Vue 3 removed the `slot=` / `slot-scope=` attributes, so 385 of them become
`<template v-slot:name>`. That conversion is eslint-plugin-vue's autofix; this
commit also runs the project's prettier config over every file it touched,
because the fixer leaves the inserted templates unindented. 37 of those files
were not prettier-clean on develop, so they carry extra formatting churn --
reviewing this commit with whitespace hidden gives a much smaller diff.
Hand-fixed on top of the autofix:
- $scopedSlots -> $slots produced `a.default || a.default` in hk-card and
hk-tip; the duplicate half is removed.
- hk-input and hk-select move to the single Vue 3 v-model contract
(modelValue + update:modelValue, with "input" still emitted for the call
sites that listen for it), drop v-on="$listeners", and set inheritAttrs:false
so forwarded listeners fire once rather than on both the wrapper and the
input. class and style are forwarded to the wrapper explicitly, which is
where Vue 2 put them.
- Their slot passthrough uses `v-bind="scope || {}"`: a named slot invoked
without props passes null, and renderSlot then throws on null.key, which
blanks the component out with no error boundary.
vue/multi-word-component-names and vue/no-reserved-component-names are turned
off: both are naming conventions rather than Vue 3 correctness, and honouring
them would rename ~150 components.
Correction to "Phase 0": vuefire is not dead code. It is never imported
outside boot/plugins.js, but 18 components and one mixin use the `firebase()`
component option it installs, and templates depend on its ".key" / ".value"
record conventions.
vuefire 1.x cannot run on Vue 3 and vuefire 3 requires the modular Firebase
SDK, which CLAUDE.md rules out. src/plugins/vuefire.js reimplements the same
option against the v8 namespaced API: array bindings track
child_added/changed/removed/moved with vuefire's ordering, object bindings
track value, and readyCallback / cancelCallback behave as before.
It binds on the client only. On the server the listeners could never resolve
before the synchronous render and would never be torn down (beforeUnmount does
not run during SSR), so the markup is the same and the server no longer leaks
a listener per rendered component.
Also converts the 33 remaining `| numeral` template filters to $numeral(),
since Vue 3 removed filters, and drops a dead `filters: { capitalize }` block
that nothing referenced.
Everything eslint-plugin-vue's vue3-essential rules flagged and could not fix itself. `npm run lint` is now clean. - <template v-for> keys move from the children onto the template (42 sites). In trackCampaign/live/Initiative.vue that also fixes the transition-group: Vue 3 keys fragment children from the fragment's own key. - v-text on Quasar components (27 sites) becomes the default slot. v-text on a component compiles to a `textContent` prop, which falls through as a plain attribute and renders nothing. - <tbody is="transition-group"> -> <transition-group tag="tbody">, and <div :is="html"> -> <component :is="html"> in character-descriptions, which compiles a template string at runtime. - <tag :is> in campaign/Players.vue becomes <component :is>. Its header was a named slot, but :is resolves to a plain div by default (cardView is false) and Vue 3 drops named slots on plain elements -- it is now a plain child, which hk-card renders in the same position. Same treatment for the header and footer blocks in the compendium view pages and LinkPatreonAccount, which were nested inside v-if templates and so could not become named slots. - Slot attributes that were already inert on Vue 2 (on a child of a plain element rather than of a component) are simply dropped: AddNpc, Entities, EditItem, DuplicateOptions. - Two q-icons targeting the same "prepend" slot are merged into one template; the autofixer had split a v-if/v-else pair across two templates, orphaning the v-else. - ContentSideRight had a v-if/v-else-if chain crossing a slot boundary, which Vue 3 does not allow; the condition is negated explicitly instead. - .native modifiers dropped (hk-popover, RunCampaign), $listeners replaced by $attrs with inheritAttrs:false (hk-pane, hk-dialog, hk-roll-action). - router-link tag="button" -> custom + navigate. - Invalid <thead><th> nesting fixed in Initiative and Keybindings; browsers re-parent those elements, which breaks SSR hydration. vue/multi-word-component-names and vue/no-reserved-component-names stay off as naming conventions; the one remaining suppression is a transition in Sidebar that never animated on Vue 2 either.
None of this is caught by a compiler or a linter -- the old names bind as plain attributes and the old handlers simply never fire, so these are the changes most likely to survive a green build and break at runtime. - @input -> @update:model-value on every Quasar form component (109 sites). On Quasar 2 a native @input on q-input still fires, but hands the handler a DOM Event instead of the value, which is the quiet version of the same bug. Our own wrapper components are converted too; they emit update:modelValue from Phase 5 onward. The one @input left is a real <textarea> in SpellCard. - :value -> :model-value on the same components (44 sites). q-linear-progress and q-circular-progress keep :value, which is their actual prop name on Quasar 2, and so do our own components that take a plain `value` prop (hk-animated-integer, hk-timer, hk-tip, hk-compendium-image, hk-markdown-editor). - <q-table :data> -> :rows (19 tables). - q-select's option slot lost itemEvents; they are part of itemProps now, so v-on="scope.itemEvents" is dropped (4 selects). :pagination.sync / :selected.sync had already become v-model:pagination / v-model:selected via the autofix in the previous commit.
24 components used the Vue 2 contract (a `value` prop plus $emit("input")).
They now take `modelValue` and emit `update:modelValue`, and declare it in
`emits` so the handler stops leaking into $attrs. Call sites using v-model
need no change; the handful passing :value explicitly are updated to
:model-value.
Deliberately not a dual contract. Keeping both `value` and `modelValue` props
would double the public API of every form component and leave Vue 2 idioms in
the codebase permanently, which is what the previous migration attempt did in
17 components.
Components whose `value` prop is a plain input rather than a model keep it:
hk-animated-integer, hk-timer, hk-tip, hk-compendium-image, hk-single-roll and
Modifier are never bound with v-model.
The temporary "input" emit that hk-input and hk-select carried through the
Quasar rename is removed now that no call site listens for it.
Vue 3's reactivity is proxy-based, so adding and removing object keys is
tracked without help: 363 Vue.set calls become plain assignments and 61
Vue.delete calls become `delete`. No store module imports Vue any more.
Two of those deletes target arrays rather than objects, where Vue.delete
spliced and a plain `delete` would leave a hole:
- general.js REMOVE_ACTION_ROLL (action_rolls is [], payload is an index)
- runEncounter.js SET_LOG "unset" (log is [], value is an index)
Both use splice, with a comment saying why. Every other target is an object
(entity saves, conditions, limited_uses, reminders, loot and the cached_*
maps are all initialised as {}).
Also drops the vee-validate 2 leftovers that have been dead since the app
moved to vee-validate 3: v-validate directives and errors.has()/errors.first()
in EditPlayer, playerRequests and Admin/Patrons/New. On Vue 3 an unresolved
directive and an undefined `errors` both log to the console on every render.
EffectsForm.vue and mixins/effects.js go with them -- nothing imports either,
and the work is preserved on origin/feature/effects.
The component-level half of the reactivity change: 171 this.$set calls become plain assignments and 74 this.$delete calls become `delete`, across 54 files plus the character and runEncounter mixins. Arrays get splice, because a plain `delete` on an array leaves a hole where Vue.delete removed the element: action/roll/companion/table-row/damage-input lists, the spell display levels, the custom rolls, the reminder variable options, and the $refs array in hk-rolls. A handful of $set/$delete calls were in templates rather than methods, where they relied on the global property; those become plain assignments and `delete` expressions. vue/no-mutating-props now sees two demo-only prop mutations in Entities and Overview that $set and $delete had hidden from it. They are pre-existing and behaviour is unchanged, so they are suppressed inline with a note rather than restructured during the migration.
- meta() -> createMetaMixin in the nine views that still had it (the five compendium detail pages, the rule page and the three tools pages). - $root.$emit / $root.$on for the "route-name" breadcrumb hand-off becomes the mitt event bus: Vue 3 instances are not event emitters. Crumble now also unsubscribes in beforeUnmount, which the $root listener never did. - EventBus.$on/$off/$emit -> on/off/emit. The two "close-popups" listeners in ActionsDropdown and SelectActor were anonymous and never removed; they are named methods now and unsubscribe in beforeUnmount, so a closed popup can no longer write to an unmounted component. - process.browser -> process.env.CLIENT (5 sites). - beforeDestroy -> beforeUnmount in mixins/debouncedSearch.js, which the .vue lint rules do not reach. EditPlayer.vue had a bare `<!-- eslint-disable -->` in its template that silenced every rule for the rest of the file and was hiding eight slot attributes, including two hk-table scoped slots. The comment is gone and the slots are converted.
- vue-croppa -> vue-advanced-cropper in hk-image-uploader. croppa also owned the file picker, which vue-advanced-cropper does not, so the component now has its own hidden file input and object-URL lifecycle. The emitted API (crop / url / cancel / clear) is unchanged, so neither call site moves. - vue-qr -> qrcode.vue in PlayerLink. qrcode.vue has no logo option; the logo is overlaid with CSS and the QR is generated at error-correction level H, which leaves room for it. - @egjs/vue-flicking -> @egjs/vue3-flicking (default export on v3). - vuedraggable 2 -> 4 in Targets and npcs/Actions. v4 renders the list itself through an #item slot, so the wrapping transition-group moves into tag + component-data. Targets binds model-value rather than list on purpose: the array must not be spliced, Sortable only moves the DOM node and @EnD applies the real reorder through initiative, exactly as before. Abilities have no id, so Actions hands vuedraggable a per-object key from a WeakMap; an index key would not survive a reorder. - splitpanes 2 -> 3 needs no template change; the component API is the same.
`quasar build` and `quasar build -m ssr` both succeed. Four things had to change to get there: - styles.scss imported ~vue-snotify/styles/material.css and carried ~180 lines of overrides for snotify's DOM. The import is gone and the overrides are replaced by the few rules that still have a target: the notification radius, keeping toasts clear of the fixed header, and the dice-roll toast, whose raw HTML now uses an .hk-roll-toast class instead of snotify's internal names. - browserslist no longer includes "maintained node versions". With a node entry in the list, webpack 5 does not treat the client bundle as a web target, drops the "browser" condition, and then cannot resolve packages whose exports map is keyed on it -- jspdf 3 fails outright. Webpack 4 did not care. - src/services/patreon.js no longer requires dotenv. That module is reachable from the browser bundle through the user store, and webpack 5 does not shim node builtins, so it pulled path/os/crypto into the client build. The env values it reads come from build.env, which is what actually supplied them. - hk-action-roll-form imported ValidationProvider from the vee-validate package rather than our own plugin, which left an unresolved import in the bundle. With that fixed nothing imports vee-validate, so it and @vee-validate/rules are dropped from package.json -- src/plugins/validation is self-contained. Also fixes a `<styles>` typo in Tools/SpellCreator.vue. The block was never a style block, so those rules have never applied; they do now, which is a visible change on that page and the intent of the original code. mini-css-extract's "Conflicting order" warnings are silenced in chainWebpack: every module it flags is a Vue scoped style, so the order cannot conflict. The only build warnings left are workbox reporting five assets (four large jpgs and the vendor chunk) as too big to precache, which is unrelated to the migration.
Every /compendium and /tools route returned a 500 until these three:
- routes.js had 37 passthrough route components written as Vue 2 render
functions: `render(c) { return c("router-view") }`. Vue 3 render functions
take no createElement argument, so `c` was undefined and the whole render
threw. They are one shared RouterPassthrough component now.
- crumble/index.vue ended up with two `methods:` blocks -- the one this
migration added for the event-bus handler silently replaced the existing one,
taking compendiumEditionCrumbs with it. Merged.
- Tools/MonsterCreator.vue likewise ended up with two `mixins:` arrays, which
dropped the dice mixin. Merged.
no-dupe-keys is now enabled so this class of failure cannot recur. It is not on
by default here because the config does not extend eslint:recommended. Turning
it on immediately found a third case that predates the migration:
trackCampaign/Meters.vue has had two `computed:` blocks on develop, so its
npcSettings and allySettings getters have never existed. Nothing referenced
them, so they are removed rather than resurrected.
Invalid table nesting is fixed in the six components that had <th> directly
under <thead> or <tr> directly under <table>. Browsers re-parent those elements,
which breaks SSR hydration.
All 25 public routes now render 200 from the SSR dev server, and it compiles
with no warnings.
`::v-deep` as a combinator is deprecated in Vue 3's SFC compiler and each occurrence emitted a build warning. The conversion drops one nesting level because `.parent :deep(.a)` compiles to the same `.parent[data-v-x] .a` that `.parent::v-deep .a` did. Two notes: - Sass cannot append a `&__suffix` inside `:deep()`, so BEM elements that were nested under a deep block get their own `:deep()` selector (TargetEntity.vue, RunCampaign.vue). - src/css/home.scss is a global stylesheet, so its `::v-deep` block was never compiled away: the selector stayed invalid and browsers dropped the rule. Removed rather than converted, so rendering is unchanged. Both builds are green and the deprecation warnings are gone.
"Hydration completed but contains mismatches." was logged as a console *error*, in production builds as well as in dev, on /demo, /encounter-builder, /privacy-policy and four of the /tools pages. Two causes: 1. hk-* components were registered with `defineAsyncComponent`, mirroring the Vue 2 `() => import(...)` factories. Vue 3 defers hydration of an async component's subtree until its chunk has loaded, which is after the app has mounted, so anything that changes on mount had already changed by the time the subtree hydrated — a component's own `loading` flag (EditEncounter) and Quasar's `isRuntimeSsrPreHydration`, which makes QImg render a different tree (the /tools pages). Registering them synchronously removes the class of bug entirely; it costs ~15 KB gzipped on the initial payload (app + chunk-common) and leaves vendor untouched. 2. Privacy.vue had a `<P>` element. Vue 2 rendered unknown capitalised tags as elements; Vue 3 tries to resolve them as components, so the client rendered nothing where the server had a paragraph. All 24 public routes plus the compendium list and detail pages now load with zero console errors against the production SSR build.
Corrects the decisions that changed while executing it (vuefire restored, vee-validate dropped, synchronous hk-* registration, the browserslist constraint) and records the verification results, the Vue 3 behaviours that caused the non-obvious bugs, and what is deliberately left unverified.
Sonar attributes these spans to this branch because the click handlers changed from `$set(...)` to plain assignment, so its keyboard-accessibility rule now fires on them as new code. They are clickable spans styled as buttons; adding `@keydown.enter` alongside the existing `@click` is the smallest fix that makes them operable from the keyboard.
…env vars
Two problems that only appear in the Docker image, because the runtime stage
installs `dist/ssr/package.json` into a fresh /app instead of reusing the
project's node_modules.
1. `@quasar/ssr-helpers` requires `source-map` without declaring it as a
dependency. Locally it resolves because webpack hoists `source-map` into the
project's node_modules; in the container nothing provides it, so
`require("@quasar/ssr-helpers/create-renderer")` throws at startup, the
server never listens and nginx returns 502. Adding it to `dependencies` puts
it in the generated `dist/ssr/package.json`. It is pinned to ^0.6.1 because
ssr-helpers uses the synchronous `new SourceMapConsumer(map)` API, which 0.7
replaced with a promise + WASM init.
2. `MONSTER_GENERATOR_API_URL` / `MONSTER_GENERATOR_API_KEY` were in the
build.env defaulting loop. They are read only from `src-ssr/`, never from
browser code, so defining them at build time inlined an empty string into the
server bundle and shadowed the values the container supplies at runtime —
which is where they come from, since they are not in the env file. Removed
from the loop, so they are runtime reads again, as on develop.
Verified against a clean /app built the way the Dockerfile builds it: without
the fix it dies on `Cannot find module 'source-map'`; with it the server boots,
serves the app, the service worker and /api.
Running an encounter rendered every entity with 0/0 hit points and AC 0, and
target drag-reorder did nothing. Both came from one error in the console:
TypeError: Cannot set properties of null (setting '__draggable_context')
vuedraggable's mounted hook walks the vnodes it rendered and stores a context
on each `vnode.el`. With `tag="transition-group"` those els are always null:
Vue's getTransitionRawChildren clones every *keyed* child
(`cloneVNode(child, { key })`), the clone is what gets mounted and receives the
el, and the original — which vuedraggable kept — never does. `item-key` means
every child is keyed, so this fails every time, not in some edge case. Older
Vue 3 releases did not clone, which is why vuedraggable 4 (last published 2021)
was written this way.
The throw propagated out of the post-flush queue, which aborted the rest of it:
Sortable was never attached to any of the three lists, and the entity rows kept
the markup from their first render, hence the zeros.
`tag` is now a plain `ul` / `div`. vuedraggable then renders the children
itself, so the vnodes it holds are the mounted ones. The cost is the enter/leave
animation those lists had (`animate__fadeInUp` / `fadeOutDown` on targets,
`fadeIn` / `fadeOut` on NPC actions); restoring it means driving Sortable
directly instead of through vuedraggable, which is worth doing separately.
Verified against the live site: entity HP, AC and the damage flow now match
(Thora 42/42 AC 16, Aboleth 135/135 AC 17; 20 damage leaves it on 115/135, with
the combat log and damage meters agreeing).
HarmlessHarm
force-pushed
the
feature/vue3-migration
branch
from
September 24, 2026 22:57
7429573 to
e8f5af7
Compare
Vue 2 accepted a bare `() => import(...)` as an async component. Vue 3 treats a bare function in `components:` or `:is` as a functional component: it calls it and renders the returned Promise as text, without any warning. Staging showed it in the campaigns sidebar (Share initiative and Subscription cards). The 17 local registrations are wrapped in `defineAsyncComponent`, which keeps the same code-splitting. Drawer.vue already awaited the module before setting it, so it now uses the loaded definition (markRaw'd, to keep it out of reactive data) instead of wrapping the loader in another function — every drawer went through that path.
The campaign overview rendered every pane empty on staging. Phase 6 rewrote hk-pane.vue and lost its <style> block, so the q-scroll-area inside each pane had no height. Putting the block back was not enough: the Share pane's "Go live" overlay and its Clear/Set action bar then covered the whole screen. hk-pane makes the scroll area `position: static`, and on develop it was still the containing block for absolute children because Quasar 1 gave `.q-scrollarea` `contain: strict`. Quasar 2 uses `contain: size`, which has no layout containment, so `contain: strict` is set here explicitly.
The template uses <Projectiles> in the projectile-assignment dialog but the component was never imported, so rolling a multi-projectile action from an entity card opened an empty dialog and warned "Failed to resolve component". Pre-existing on develop; RollActions and RollSpells import it the same way.
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Migrates the app to Vue 3 + Quasar 2. Spec:
.planning/vue3-migration.md.The aim was the smallest diff that leaves no Vue 2 in
src/— migrate now, refactor later. The previous attempt (origin/vue3-migration) was consulted file-by-file but not inherited: it branches frommain@2.42.0, keeps a dualvalue/modelValuecontract on 17 components, silently dropsvue-croppaandvuefire, swallows BFF errors, and loses the SSR CORS header. §1.2 of the spec lists all nine findings.What changed
Toolchain — Vue 2.7 → 3.5, Quasar 1 → 2.33, Vuex 3 → 4, Vue Router 3 → 4,
@quasar/app2 (webpack 4) →@quasar/app-webpack3.15.1 (webpack 5),quasar.conf.js→quasar.config.js.Deliberately not
@quasar/app-webpackv4: it dropped Vuex support, and its generatedpreFetchwiring only injectsstorefor pinia. With 15preFetch({ store })hooks and ~20 Vuex modules, v4 would drag a full Pinia migration in here. v3 is unmaintained, so v4 + Pinia is the follow-up — a store migration is much safer once the app already runs on Vue 3.SSR / BFF —
src-ssr/index.js+extension.js→middlewares/{compression,api,render}.js+production-export.js.src-ssr/api/index.jsis untouched. The API layer now degrades gracefully only for a missingfirebaseServiceAccountKey.jsonand rethrows everything else, andrender.jskeeps theAccess-Control-Allow-Originheader the Vue 2 server set. PWA config is unchanged exceptssr.pwa: ctx.prod, which avoids the dev reload loop.Shims instead of mass rewrites — four Vue 2 packages have wide call sites and a narrow API, so they were reimplemented rather than migrated at every call site. This is the single biggest lever on the diff size.
vue-snotifysrc/plugins/snotify.jsover QuasarNotifyvee-validatev3src/plugins/validation/with the v3 slot contractvue-shortkeysrc/directives/shortkey.jsvuefire1.xsrc/plugins/vuefire.js— thefirebase()option on Firebase v8These are plain Vue 3 code. No
@vue/compat, nothing importing a Vue 2 package. Firebase stays on the v8 namespaced API throughout.Library swaps —
vue-croppa→vue-advanced-cropper,vue-qr→qrcode.vue,@egjs/vue-flicking→@egjs/vue3-flicking,vuedraggable2 → 4,splitpanes2 → 3,@gtm-support/vue2-gtm→vue-gtm,numeralfilter →$numeral,new Vue({})event bus →mitt.The rest is mechanical and file-at-a-time: 441
slot=/slot-scope, 560Vue.set/$set,.sync→v-model:x,beforeDestroy→beforeUnmount,<q-table :data>→:rows,meta()→createMetaMixin(),::v-deep→:deep().Verification
npm run lintnpx quasar buildnpx quasar build -m ssrnpx quasar dev -m ssrnpm ci --dry-runTwo findings worth a look during review
Async components break hydration.
hk-*components were registered withdefineAsyncComponent, mirroring the Vue 2() => import(...)factories. Vue 3 defers hydration of an async subtree until its chunk loads, which is after mount — so aloadingflag flipped inmounted, and Quasar'sisRuntimeSsrPreHydration(which changes whatQImgrenders), had both already moved on. The result wasHydration completed but contains mismatches.logged as a console error, in production builds as well as dev, on/demo,/encounter-builderand four/toolspages. Registering them synchronously removes the whole class of bug; it costs +15.4 KB gzipped on the initial payload and leavesvendoruntouched. On Vue 2 the same divergence existed but was a dev-only warning, so it was invisible in production.src/css/home.scsshad a::v-deepblock in a global stylesheet, where nothing ever compiled it away — the selector stayed invalid and browsers dropped the rule. It is deleted rather than converted, so rendering is unchanged.Not verified
Auth-gated screens have not been browser-tested: DM screen, run encounter, character builder, track campaign, profile, admin and the user-content pages. They render server-side without errors and redirect correctly when signed out, but the interactive paths need real credentials. This is exactly where the previous attempt's only post-deploy bug hid, so it is worth a pass on staging before merge.
Pre-existing, not touched here
src/services/patreon.jslogsprocess.envandVUE_APP_PATREON_CLIENT_SECRETto the console, and the module is reachable from the client bundle — so the secret ships in the browser JS. It is ondeveloptoday; fixing it properly means moving Patreon auth behind the BFF, which does not belong in a framework migration.