Skip to content

feat(vue3): migrate larpingapp to Vue 3 (@conduction/nextcloud-vue@2.1.0-vue3.13) - #239

Merged
rubenvdlinde merged 11 commits into
developmentfrom
chore/vue3-migration
Aug 1, 2026
Merged

feat(vue3): migrate larpingapp to Vue 3 (@conduction/nextcloud-vue@2.1.0-vue3.13)#239
rubenvdlinde merged 11 commits into
developmentfrom
chore/vue3-migration

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Migrates larpingapp from Vue 2 to Vue 3 against @conduction/nextcloud-vue@2.1.0-vue3.13 (pinned exactly — a caret floats a prerelease when the vue3 dist-tag moves).

Wave 1b of the 13-app fleet migration.

Dependencies

package from to
vue ^2.7.14 ^3.5.13
@nextcloud/vue ^8.16.0 ^9.5.0
vue-router ^3.6.5 ^4.5.0
@nextcloud/dialogs ^3.2.0 ^7.4.1
@conduction/nextcloud-vue ^1.0.0-beta.213 2.1.0-vue3.13 (exact)
@nextcloud/webpack-vue-config ^6.0.1 ^7.0.2
vue-loader ^15 ^17.4.2

Added because nc-vue declares them as peers and an isolated clone cannot resolve them from a hoisted node_modules: @vueuse/core, axe-core, dexie, dompurify, marked, @nextcloud/capabilities, gridstack (+ its stylesheet — without it every dashboard item renders 0 px wide with no error). Added because @nextcloud/webpack-vue-config@7 require()s them without declaring them: terser-webpack-plugin, path-browserify.

Dropped: vue-template-compiler, vue-apexcharts (unused; nc-vue bundles vue3-apexcharts), and the postinstall: vue-demi-switch 2.7 vue hook.

Lint

Adopts the shared conductionVue3Fixes preset from nc-vue, spread last. No local copies of vue/no-v-model-argument / vue/no-v-for-template-key — the preset disables both itself.

Verified by severity, not by counting rule names (--print-config | grep -c counts rules set to off and will happily report 21 when none are armed):

vue/no-deprecated-* ACTIVE: 21     vue/no-deprecated-* OFF: 0
vue/no-v-model-argument      => [0]
vue/no-v-for-template-key    => [0]
vue/v-on-event-hyphenation   => [2,"always",{"ignore":["update:modelValue"]}]
languageOptions.ecmaVersion  => "latest"   parserOptions.ecmaVersion => "latest"

Positive-controlled: a seeded .vue file with beforeDestroy + filters: produces both errors, and a seeded .js file with an unused var is reported — so a clean run means the files were actually opened.

src lint: 0 errors, 0 warnings (was 0 errors / 85 warnings).

The 71 @spec "Invalid JSDoc tag name" warnings needed definedTags as rule options; settings.jsdoc.definedTags is ignored once an extended preset has configured the rule.

Code

Lint does not cover bootstrap idioms, so these were found by hand:

  • new Vue({...}).$mount('#content')createApp({...}).mount('#content') (main.js, settings.js)
  • Vue.mixin / Vue.useapp.mixin / app.use; PiniaVuePlugin removed
  • Vue.observable({...})reactive({...}) (App.vue sidebar channel)
  • new VueRouter({ mode: 'hash' })createRouter({ history: createWebHashHistory() })
  • path: '*'path: '/:pathMatch(.*)*' — removed in vue-router 4; an unknown hash would have rendered the shell with an empty <main> and no error

Found by the newly-armed rules or by reading the v9 API:

  • beforeDestroybeforeUnmount in SkillTree.vue. Vue 3 never calls the old name and never warns, so this hook would have leaked two live collection subscriptions and a $watch handle for the lifetime of the page.
  • @nextcloud/vue/dist/Components/*.js@nextcloud/vue/components/* (v9 ships an exports map; the old paths throw ERR_PACKAGE_PATH_NOT_EXPORTED).
  • NcButton type="primary|secondary|tertiary"variant="…". v9 repurposed type as the native button type; the Vue-2 spelling renders <button type="secondary"> with no warning and no lint error. (NcNoteCard still uses type — left alone.)
  • NcSelect @input / @change@update:modelValue. Neither is in @nextcloud/vue-select@4's emits list, so both listeners were silently dead: picking a character in the skill tree would never have fetched the requirement report (tree stays uncoloured), and switching a type's storage to "Internal" would have left a stale register + schema attached. Verified against the compiler that v-model and an explicit @update:modelValue merge into an array with the v-model assignment first, so the handlers see the new value.
  • App.vue dual-instance hazard. useTenantContext was pulled in with require('@conduction/nextcloud-vue') inside a try/catch while the rest of the file used a static ESM import — two module instances of the library. The injection key is a module-local Symbol, and CnAppRoot provides from the ESM copy, so the CJS-side useTenantContext() would have looked up a different key, silently returned the no-op fallback, and the tenant watcher would never have fired. Now a static import; the "absence must not crash" contract is still met by the composable's own fallback.

Deleted as dead Vue-2 leftovers (unreferenced by any import, manifest or test): jest.config.js (a @vue/vue2-jest transform; jest is not a dependency and appears in no workflow), src/navigation/MainMenu.vue (superseded by the manifest-driven CnAppNav), src/views/settings/UserSettings.vue (superseded by App.vue's #user-settings slot).

webpack

  • vue-router$ absolute-file alias — mandatory, not an optimisation. @nextcloud/vue@9 hard-depends on vue-router ^5.1.0, so npm installs both vue-router@4.6.4 (root) and vue-router@5.2.0 (nested). Without the alias, main.js gets the 4.x singleton while every @nextcloud/vue component calling useRoute()/useRouter() resolves the 5.x copy — a different injection key, so those components see no router at all.
  • @nextcloud/vue and @nextcloud/dialogs aliases now point at the entry FILE, not the package directory. Both declare no main and no module, only an exports map; a directory alias bypasses that map. This produced 233 Can't resolve '@nextcloud/vue' errors on the first Vue 3 build.
  • output.publicPath: 'auto'. @nextcloud/webpack-vue-config hardcodes /apps/<app>/js/. An app installed into Nextcloud's second apps path is served from /custom_apps/, so every lazy chunk 404s, comes back as the text/html error page, is refused on MIME grounds, and kills the page with a ChunkLoadError. The entry bundle is unaffected (Nextcloud writes that tag itself), so the build reports nothing. Reproduced in a browser on the isolated instance.

No optimization.sideEffects = false workaround was present.

e2e safety

playwright.config.ts, global-setup.ts, workflows/fixtures.ts and detail-forms-admin.spec.ts each computed their own ?? 'http://localhost:8080' default. :8080 is the shared dev container — its bind mounts reach real host checkouts, and this suite's fixture WRITE paths were creating and deleting OpenRegister objects there. tests/e2e/_base-url.ts is now the single resolver (PLAYWRIGHT_BASE_URL > NEXTCLOUD_URL > NC_BASE_URL) and it throws when none is set.

Verified against a disposable instance on :8094 whose /var/www/html is a named volume (the shared container reports ~40 bind mounts).

Pre-existing issues fixed

Baseline comparison

Measured against #238 — the same restored workflow on an otherwise untouched development:

job development (#238) this PR
Vue Quality (eslint) pass pass
Vue Quality (stylelint) fail fixed
Features Check fail fixed
PHP Quality (phpcs / phpmd / phpstan / psalm / phpmetrics / lint) pass
PHPUnit ×4 (PHP 8.3/8.4 × NC stable31/32) pass
License + Security (composer, npm) pass

Known, unchanged, reported not fixed

  • Playwright has never run in CI. code-quality.yml never sets enable-playwright, so E2E Tests (Playwright) is skipping — including on the restored baseline. 17 spec files have never gated a PR. Not enabled here because the CI-side seed is unvalidated and flipping it on would make this PR red for reasons unrelated to Vue 3.
  • npm run test:l10n reports 280 missing translations — byte-identical count on development and on this branch. Not gated in CI (frontend-checks is unset).
  • 6 other fleet apps carry the same broken workflow reference: pipelinq, procest, opencatalogi, docudesk, softwarecatalog, decidesk, openbuild.
  • PR chore(deps): bump nextcloud-vue to beta.220 (OpenBuild glyph fix) #233 (nc-vue beta.220) closed as superseded — merging it would have moved the dependency backwards onto the Vue 2 line. PR chore(deps): bump the npm_and_yarn group across 2 directories with 21 updates #235 (dependabot security group, green) merged first. PR Release: merge development into beta #90 is the development → beta release PR and is untouched.

e2e: what the 58 failures actually were

The first full run against the isolated instance was 90 passed / 58 failed / 24 skipped of 172. None of it was a Vue 3 rendering problem. Root-caused rather than retried:

1. Nextcloud's own first-run wizard (the dominant cause). On a freshly provisioned instance the admin account has never dismissed it, so it opens a modal at z-index 10005 — above larpingapp's own onboarding tour (10000) and above the entire app. Measured: two live [role="dialog"] nodes, two modal masks, and document.elementFromPoint(innerWidth/2, innerHeight/2) returning the tour's <video>.

The page underneath renders perfectly, so toBeVisible() keeps passing and only click() dies — 60 s actionability timeouts that read exactly like a rendering regression. Even "Close tour" was unreachable, because the wizard covered it.

After occ app:disable firstrunwizard: one click dismissed the app tour, and the next click landed in 35 ms.

This is why the suite passed on the shared dev container and not here — the shared instance's admin dismissed that wizard months ago. Fixed in the shared spin-up-e2e-instance.sh so no other app in the fleet loses a day to it.

2. Five copies of dismissSupportDialog that could not dismiss the tour. All matched only button[aria-label="Close"]; the tour's controls are "Close tour" and "Skip". One implementation now lives in _nav.ts and the five duplicates import it.

3. Stale fixture ids. detail-forms-admin.spec.ts hardcoded register 8 / schemas 18-25 with a comment reading "8 on the dev instance". On a fresh install LarpingApp's register imports as 15 with a different schema assignment, so every seed 404'd, seedObject() swallowed it, ids became the literal string 'seed-missing', and the specs failed 60 s later as timeouts. Now resolved at runtime from the app's settings API; seedObject() reports status and body on failure.

4. Dashboard widgets rendered "Widget not available". 5 of 7. Cause: nc-vue declares sideEffects: ["**/*.css"], so webpack legally dropped registerDashboardWidgets.js's bare side-effect imports — and stat / object-table are registered only that way. The one widget that did render, chart, is registered by an inline call tree-shaking cannot reach, which is what identified the mechanism. Fixed by calling the library's registerBuiltinDashboardWidgets() at bootstrap. Verified live: the KPI tiles now read Characters 0 | Events 48 | Items 48.

Also verified live, since the playbook calls it the silent case: gridstack items measure 231 × 160 and 462 × 320 px via getBoundingClientRect(), not 0 px wide.

Known pre-existing gap, not fixed here

The player schema does not exist on a fresh install. lib/Settings/larpingapp_register.json declares ten schemas including player, but after import the register contains nine — ability, skill, larping_item, condition, effect, larping_event, setting, xpAward, character, larping_attendance — and player_register / player_schema are empty in the app config. Positive-controlled: a character-slug search finds schema 18; a player search finds nothing in any register. Note that item, event and attendance were imported prefixed (larping_*) while player was dropped, which points at slug-collision handling in the register import. Players pages therefore have no storage on any new deployment. Out of scope for a Vue 3 migration, but it should not stay unreported.

- deps: vue 3.5, @nextcloud/vue 9, vue-router 4, @nextcloud/dialogs 7,
  @conduction/nextcloud-vue pinned exactly to 2.1.0-vue3.13
- lint: adopt the shared conductionVue3Fixes preset (spread last)
- bootstrap: createApp/app.use, vue-router 4 catch-all, reactive()
- webpack: vue-router$ singleton alias, entry-FILE aliases for the
  exports-map-only @nextcloud/vue and @nextcloud/dialogs
- remove dead Vue-2 files (jest.config.js, MainMenu.vue, UserSettings.vue)
playwright.config.ts, global-setup.ts, workflows/fixtures.ts and
detail-forms-admin.spec.ts each computed their own `?? localhost:8080`
default. :8080 is the SHARED dev container, so the fixture WRITE paths
seeded registers/schemas/objects into other people's environment, and a
spec could create a fixture on one instance and open it on another.

tests/e2e/_base-url.ts is now the single resolver: PLAYWRIGHT_BASE_URL >
NEXTCLOUD_URL > NC_BASE_URL, and it THROWS when none is set.

Also fixes the remaining lint warnings (JSDoc @PARAM types/descriptions).
…ed workflow refs

webpack: @nextcloud/webpack-vue-config hardcodes output.publicPath to
/apps/<app>/js/. An app deployed into Nextcloud's second apps path is
served from /custom_apps/, so every LAZY chunk 404s, comes back as the
text/html error page, is refused on MIME grounds and kills the page with
a ChunkLoadError. The entry bundle is fine (NC writes that tag itself),
so nothing in the build reports a problem. publicPath 'auto' derives it
from the URL the entry script actually loaded from.

ci: all 8 reusable-workflow calls pointed at Conduction/.github, which
Actions cannot resolve. Every Code Quality run since 2026-06-01 produced
ZERO jobs. The shared workflows live in ConductionNL/.github.
…lint failure)

Four single-line rule blocks tripped `rule-empty-line-before`. The failure
is pre-existing and was invisible because the Code Quality workflow had
produced zero jobs since 2026-06-01.
…ure)

The shared `features-extract` job that regenerates this file is skipped on
pull_request, and on a push to development its bot commit is rejected by
branch protection (ConductionNL/.github#61), so the file has been drifting
from openspec/specs/ with nothing able to correct it. Regenerated with
ConductionNL/.github scripts/extract-features.py; `--check` is now clean.

Pre-existing: the same failure reproduces on an untouched development in
the baseline probe (#238).
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ dc0353a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-07-31 21:30 UTC

Download the full PDF report from the workflow artifacts.

detail-forms-admin.spec.ts hardcoded register 8 and schemas 18-25 with a
comment saying '8 on the dev instance'. On any other instance LarpingApp's
register imports with a different id (15 on a fresh NC 34 install, with a
different schema assignment), so every seed POST 404s, seedObject()
swallowed it, the ids became the literal 'seed-missing', and eight specs
failed 60s later as TIMEOUTS — which reads like a rendering regression.

Now resolved at runtime from /apps/larpingapp/api/settings, the same
source workflows/fixtures.ts and the SPA itself use, honouring the
per-type <type>_register over the shared one. Explicit LARPING_* env
vars still win. seedObject() now reports the status and body on failure
instead of returning null silently.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ 4521816

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-07-31 21:35 UTC

Download the full PDF report from the workflow artifacts.

nc-vue declares `sideEffects: ["**/*.css"]` (ADR-061), so webpack may
legally drop registerDashboardWidgets.js's bare side-effect imports. The
manifest's `stat` KPI tiles and `object-table` lists are registered ONLY
that way, so getWidgetTypeEntry() missed and CnDashboardPage rendered its
'Widget not available' placeholder for 5 of the 7 dashboard widgets.

The one widget that did render, `chart`, is registered by an INLINE
registerDashboardWidget() call that tree-shaking cannot reach — which is
what identified the mechanism. The library ships this explicit no-op for
exactly this purpose.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ 8a2700e

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-07-31 21:41 UTC

Download the full PDF report from the workflow artifacts.

The USE_LOCAL_LIB alias is opt-OUT and silently overrides the exactly
pinned @conduction/nextcloud-vue dependency. The shared
apps-extra/nextcloud-vue checkout sits on wip/cnindexpage-export-action
at 1.0.0-beta.184 — the Vue 2 line — so any build run from
apps-extra/larpingapp would have compiled Vue 2 library sources into
this Vue 3 app, successfully, with the first symptom appearing at
runtime as a migration bug.

Now the alias is used only when the local checkout's major matches the
pinned dependency's; a mismatch throws with the versions named.
USE_LOCAL_LIB=false still disables it outright.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ 126bcbe

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-07-31 21:43 UTC

Download the full PDF report from the workflow artifacts.

…utton

The app's first load opens a six-step onboarding tour ('Welcome to
LARPing') whose controls are labelled 'Close tour' and 'Skip'. All five
copies of dismissSupportDialog matched only button[aria-label="Close"],
so the tour stayed open across the viewport.

Measured on the isolated instance: two live [role="dialog"] nodes, two
modal masks, and elementFromPoint(innerWidth/2, innerHeight/2) returning
the tour's <video>. The page underneath renders perfectly, so
toBeVisible() keeps passing while every locator.click() hangs on
actionability until the test times out — which reads like a rendering
regression and is really an undismissed overlay.

One implementation now lives in _nav.ts and the five duplicates import
it; it closes any visible dialog by the labels actually used, repeating
until none remains (the tour and its step controls are two dialogs).
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ 519d3ea

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-08-01 06:39 UTC

Download the full PDF report from the workflow artifacts.

Every other mention of networkidle in this suite is a comment saying
never to use it (ADR-074 rule 4 — Nextcloud's notification poll keeps the
network permanently busy). One live call survived, in the gotoDetail
helper backing every character-detail spec.

The trailing .catch(() => {}) made it look safe. It is not: the call
takes no timeout, so it inherits the unbounded navigation timeout and
never settles; the TEST times out at 60s first and the catch never runs.
The failures surface as 'Test timeout of 60000ms exceeded', which reads
like a slow or broken page instead of a wait that can never succeed.

Replaced with the app-content visibility wait used everywhere else.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ f7b8f30

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-08-01 06:58 UTC

Download the full PDF report from the workflow artifacts.

Re-verified on a clean isolated NC 34 instance, as the note itself asked.

The cause is a MISSING player SCHEMA. larpingapp_register.json declares
ten schemas including 'player'; after import the register holds nine —
item/event/attendance arrive prefixed (larping_*) and player is dropped
entirely, leaving player_register and player_schema empty in app config.

The character schema REQUIRES ocName, typed format:uuid with $ref:player.
With no player schema there is no player UUID, so the OR API rejects
every character create. seedObject() swallowed it, ids became the string
'seed-missing', and every character-detail spec failed 60s later as a
timeout. Register-import side, not the test layer.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/larpingapp @ 8a339eb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 94/94
npm ✅ 537/537
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/17 statements)


Quality workflow — 2026-08-01 07:13 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 0f94870 into development Aug 1, 2026
32 checks passed
@rubenvdlinde
rubenvdlinde deleted the chore/vue3-migration branch August 1, 2026 11:48
@rubenvdlinde
rubenvdlinde restored the chore/vue3-migration branch August 8, 2026 16:21
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