Skip to content

Migrate to Vue 3 and Quasar 2 - #361

Open
HarmlessHarm wants to merge 27 commits into
developfrom
feature/vue3-migration
Open

HarmlessHarm wants to merge 27 commits into
developfrom
feature/vue3-migration

Conversation

@HarmlessHarm

@HarmlessHarm HarmlessHarm commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

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 from main@2.42.0, keeps a dual value/modelValue contract on 17 components, silently drops vue-croppa and vuefire, 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/app 2 (webpack 4) → @quasar/app-webpack 3.15.1 (webpack 5), quasar.conf.js → quasar.config.js.

Deliberately not @quasar/app-webpack v4: it dropped Vuex support, and its generated preFetch wiring only injects store for pinia. With 15 preFetch({ 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.js is untouched. The API layer now degrades gracefully only for a missing firebaseServiceAccountKey.json and rethrows everything else, and render.js keeps the Access-Control-Allow-Origin header the Vue 2 server set. PWA config is unchanged except ssr.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.

Package Call sites Replacement
vue-snotify 150 calls / 52 files src/plugins/snotify.js over Quasar Notify
vee-validate v3 155 slots / 43 files src/plugins/validation/ with the v3 slot contract
vue-shortkey 17 files src/directives/shortkey.js
vuefire 1.x 19 files src/plugins/vuefire.js — the firebase() option on Firebase v8

These 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, vuedraggable 2 → 4, splitpanes 2 → 3, @gtm-support/vue2-gtm → vue-gtm, numeral filter → $numeral, new Vue({}) event bus → mitt.

The rest is mechanical and file-at-a-time: 441 slot=/slot-scope, 560 Vue.set/$set, .sync → v-model:x, beforeDestroy → beforeUnmount, <q-table :data> → :rows, meta() → createMetaMixin(), ::v-deep → :deep().

Verification

Gate Result
npm run lint clean
npx quasar build succeeds, no warnings
npx quasar build -m ssr succeeds; 5 workbox "too large to precache" warnings (4 hero images + vendor chunk)
npx quasar dev -m ssr compiles clean
npm ci --dry-run lockfile valid
24 public routes, production SSR HTTP 200, zero console errors
Compendium lists + detail pages zero console errors

Two findings worth a look during review

Async components break hydration. hk-* components were registered with defineAsyncComponent, mirroring the Vue 2 () => import(...) factories. Vue 3 defers hydration of an async subtree until its chunk loads, which is after mount — so a loading flag flipped in mounted, and Quasar's isRuntimeSsrPreHydration (which changes what QImg renders), had both already moved on. The result was Hydration completed but contains mismatches. logged as a console error, in production builds as well as dev, on /demo, /encounter-builder and four /tools pages. Registering them synchronously removes the whole class of bug; it costs +15.4 KB gzipped on the initial payload and leaves vendor untouched. On Vue 2 the same divergence existed but was a dev-only warning, so it was invisible in production.

src/css/home.scss had a ::v-deep block 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.js logs process.env and VUE_APP_PATREON_CLIENT_SECRET to the console, and the module is reachable from the client bundle — so the secret ships in the browser JS. It is on develop today; fixing it properly means moving Patreon auth behind the BFF, which does not belong in a framework migration.

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).
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.
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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