From 5e5631ce8b037e82cc6eb8ef578c2d2b93814524 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 23:37:52 +0200 Subject: [PATCH 1/2] feat(router): move stackiq off hash routing to clean path URLs Stackiq is one of seven fleet apps still serving its SPA behind a `#`. This is the pilot for moving all of them: the source change is a single line, and everything else here is the test surface that assumed hashes. Verified BEFORE switching, because history mode fails at the SERVER when the AppHost SPA catch-all is missing (fleet #133 is why apps fell back to hash in the first place): /apps/stackiq/organisaties, /contracten and /organisaties/abc-123 all already returned 200 with the app shell, so the catch-all is present for this app. What moved with it: tests/e2e/spec-coverage/_helpers.ts gotoAppRoute built `${APP_BASE}#${route}` tests/e2e/manifest-pages.spec.ts same URL construction tests/e2e/spec-coverage/catalog-ratings.spec.ts 2 hash deep-links tests/e2e/smoke/app-mounts.spec.ts `/stackiq/#/organisaties` -> a real sub-path The helper docblock explaining vue-router 4's hash-relative `createHref` is rewritten rather than deleted: the id-based nav selector it defends is deliberately KEPT, because identifying the nav by a stable handle instead of an href format the router owns is what makes it survive this change. Verified in the browser against the published @conduction/nextcloud-vue (USE_LOCAL_LIB=false): /apps/stackiq/ -> 200, page id Dashboard, no hash /apps/stackiq/organisaties -> 200, page id Organisaties, no hash /apps/stackiq/contracten -> 200, page id Contracten, no hash RELOAD on /organisaties -> 200, still Organisaties That reload is the point: it is the case hash mode existed to avoid, and it is served by the catch-all rather than 404ing. Zero JS errors. The Playwright smoke project passes on both routes, including the organisations sub-route now that it is a real path. eslint exits 0 and webpack compiles. --- src/main.js | 11 ++++++-- tests/e2e/manifest-pages.spec.ts | 5 ++-- tests/e2e/smoke/app-mounts.spec.ts | 2 +- tests/e2e/spec-coverage/_helpers.ts | 28 +++++++++---------- .../e2e/spec-coverage/catalog-ratings.spec.ts | 4 +-- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/main.js b/src/main.js index 5e8a7f73..74ba1c44 100644 --- a/src/main.js +++ b/src/main.js @@ -27,7 +27,7 @@ import { } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { createApp, h } from 'vue' -import { createRouter, createWebHashHistory } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import CatalogPanels from './components/CatalogPanels.vue' import customComponents from './customComponents.js' @@ -160,7 +160,14 @@ async function bootstrap() { ) const router = createRouter({ - history: createWebHashHistory(generateUrl('/apps/stackiq')), + // History mode: clean path URLs and working deep-links + // (/apps/stackiq/organisaties/{id}). This relies on the AppHost SPA + // catch-all serving the SPA index on any sub-path — verified before + // the switch: /apps/stackiq/organisaties, /contracten and + // /organisaties/abc-123 all return 200 with the app shell. Without + // that route a deep link 404s at the SERVER on reload, which is the + // reason apps fell back to hash mode (fleet #133). + history: createWebHistory(generateUrl('/apps/stackiq')), routes: routesFromManifest(resolvedManifest), }) diff --git a/tests/e2e/manifest-pages.spec.ts b/tests/e2e/manifest-pages.spec.ts index f94ab544..6ea7a166 100644 --- a/tests/e2e/manifest-pages.spec.ts +++ b/tests/e2e/manifest-pages.spec.ts @@ -67,8 +67,9 @@ async function gotoAppRoute(page: Page, route: string): Promise { // The in-app router runs in hash mode, so deep links are `#`. A bare // path form boots the SPA but leaves the hash empty, so vue-router falls back // to the default `/` (Dashboard) and the requested surface never mounts. - // Navigate via the hash; the dashboard is `#/`. - const url = route === '/' ? `${APP_BASE}#/` : `${APP_BASE}#${route}` + // History mode: a deep link is a plain path (the dashboard is `/`). + const base = APP_BASE.endsWith('/') ? APP_BASE.slice(0, -1) : APP_BASE + const url = route === '/' ? `${base}/` : `${base}${route}` // Use `domcontentloaded`, not `networkidle`: the app fires a periodic // heartbeat / keep-alive poll, so the network never goes idle and a // `networkidle` wait times out at 60s. The explicit shell/main waits below diff --git a/tests/e2e/smoke/app-mounts.spec.ts b/tests/e2e/smoke/app-mounts.spec.ts index f86e33ea..687e11b0 100644 --- a/tests/e2e/smoke/app-mounts.spec.ts +++ b/tests/e2e/smoke/app-mounts.spec.ts @@ -37,7 +37,7 @@ const ROUTES = [ { name: 'app root', path: '/index.php/apps/stackiq/' }, { name: 'organisations sub-route', - path: '/index.php/apps/stackiq/#/organisaties', + path: '/index.php/apps/stackiq/organisaties', }, ] diff --git a/tests/e2e/spec-coverage/_helpers.ts b/tests/e2e/spec-coverage/_helpers.ts index b3936c08..6487256c 100644 --- a/tests/e2e/spec-coverage/_helpers.ts +++ b/tests/e2e/spec-coverage/_helpers.ts @@ -158,11 +158,13 @@ export async function dismissWalkthrough(page: Page): Promise { /** Deep-link to a route and wait for the Vue shell + main region to mount. */ export async function gotoAppRoute(page: Page, route: string): Promise { - // The in-app router runs in hash mode, so deep links are `#`. A bare - // path form (e.g. `/apps/stackiq/settings`) boots the SPA but leaves - // the hash empty, so vue-router falls back to the default `/` (Dashboard) - // and the requested surface never mounts. Always navigate via the hash. - const url = route === '/' ? `${APP_BASE}#/` : `${APP_BASE}#${route}` + // The in-app router runs in HISTORY mode, so a deep link is a plain path. + // This works only because the AppHost SPA catch-all serves the app shell on + // any sub-path; if that route ever goes missing these navigations 404 at the + // server rather than falling back to the dashboard, which is the loud + // failure we want. + const base = APP_BASE.endsWith('/') ? APP_BASE.slice(0, -1) : APP_BASE + const url = route === '/' ? `${base}/` : `${base}${route}` await page.goto(url, { waitUntil: 'domcontentloaded' }) await page .locator(APP_SHELL) @@ -183,16 +185,12 @@ export async function gotoAppRoute(page: Page, route: string): Promise { * check below is unchanged in strength. * * ⚠️ This used to be `nav:has(a[href*="/apps/stackiq/"])`, which stopped - * matching ANYTHING under vue-router 4. In hash mode v4 emits HASH-RELATIVE - * hrefs (`#/organisaties`); vue-router 3 emitted the base too - * (`/apps/stackiq/#/organisaties`). v4's `createHref` explicitly strips - * everything before the `#`, so no configuration of `createWebHashHistory` - * restores the old shape — the change is by design, not a misconfiguration. - * - * Navigation itself is unaffected: `#/organisaties` resolves against the current - * document, the click navigates, and the target page renders. Verified in a - * browser before this selector was touched, precisely so that a stale selector - * could not be "fixed" into hiding a real routing regression. + * matching ANYTHING under vue-router 4 in HASH mode, because v4's `createHref` + * strips everything before the `#` and emits hash-relative hrefs + * (`#/organisaties`) where v3 emitted the base too. The app has since moved to + * history mode, so full-path hrefs are back — but the id-based selector below + * is kept deliberately: it identifies the element by a stable handle rather + * than by an href format the router owns, and so survives the next such change. * * `nav#app-navigation-vue` is @nextcloud/vue's own NcAppNavigation host and is * unique on the page (the other two navs are core's app-menu and user-menu), so diff --git a/tests/e2e/spec-coverage/catalog-ratings.spec.ts b/tests/e2e/spec-coverage/catalog-ratings.spec.ts index 885b092e..64d86a62 100644 --- a/tests/e2e/spec-coverage/catalog-ratings.spec.ts +++ b/tests/e2e/spec-coverage/catalog-ratings.spec.ts @@ -136,7 +136,7 @@ async function newAnonymousContext(): Promise { /** Open the seeded module's detail page and wait for the reviews panel. */ async function openModuleReviews(page: Page): Promise { - await page.goto(`${APP_BASE}#/modules/${moduleUuid}`, { + await page.goto(`${APP_BASE.replace(/\/$/, "")}/modules/${moduleUuid}`, { waitUntil: 'domcontentloaded', }) await page @@ -379,7 +379,7 @@ test('reviews: a module with no approved reviews shows the empty aggregate, not }) expect(uuid, 'isolated module fixture has no uuid').not.toBe('') - await page.goto(`${APP_BASE}#/modules/${uuid}`, { + await page.goto(`${APP_BASE.replace(/\/$/, "")}/modules/${uuid}`, { waitUntil: 'domcontentloaded', }) await page From 89aab78288f497c9c57687e4c1639603753abe37 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 07:58:26 +0200 Subject: [PATCH 2/2] fix(router): derive the router base from the served URL History mode broke deep links on the /index.php/... URL form. Nextcloud serves the same app under BOTH /apps/stackiq/... and /index.php/apps/stackiq/..., but generateUrl() returns only the form the instance is configured for. Arriving on the other form left the path outside the router base, vue-router could not resolve it, and the catch-all redirected to '/' -- the visitor landed on the Dashboard with no error and the deep link was silently swallowed. Measured before the fix: /apps/stackiq/komplianties -> Compliance /index.php/apps/stackiq/komplianties -> Dashboard <- silently wrong Hash routing never had this: the route travelled in the fragment, so the path prefix was irrelevant. This is the one real regression the switch introduced, and it would have applied to every app in the rollout. The e2e suite navigates via /index.php/..., which is exactly how it was caught -- two index specs failed while manual browsing on the pretty URL looked fine. Now both forms resolve, with the path preserved. --- src/main.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index 74ba1c44..3a27436e 100644 --- a/src/main.js +++ b/src/main.js @@ -130,6 +130,34 @@ const pageTypesProp = { ...defaultPageTypes } const customComponentsProp = { ...customComponents } const registryProp = { ...registry } +/** + * The router base for THIS page load. + * + * ⚠️ `generateUrl('/apps/stackiq')` alone is not enough. Nextcloud serves the + * same app under BOTH `/apps/stackiq/...` and `/index.php/apps/stackiq/...`, + * but `generateUrl()` returns only the form the instance is configured for. If + * a visitor arrives on the other form — a bookmark, an emailed deep link, an + * integration that hardcodes `/index.php` — the path no longer starts with the + * router base, vue-router cannot resolve it, and the catch-all redirects to + * `/`. The user lands on the Dashboard with no error: the deep link is + * silently swallowed. + * + * Hash routing never had this, because the route travelled in the fragment and + * the path prefix was irrelevant. Measured on this app before the fix: + * `/apps/stackiq/komplianties` rendered Compliance, while + * `/index.php/apps/stackiq/komplianties` rendered the Dashboard — and that is + * the form the e2e suite uses, which is how it was caught. + * + * So derive the base from the URL actually being served, falling back to + * `generateUrl()` when the app segment is absent. + * + * @return {string} The base path vue-router should strip from the URL. + */ +function routerBase() { + const match = window.location.pathname.match(/^(.*\/apps\/stackiq)(?:\/|$)/) + return match ? match[1] : generateUrl('/apps/stackiq') +} + /** * Resolve `@resolve:` IAppConfig sentinels in `manifest.pages[].config` * (e.g. `@resolve:voorzieningen_register`) APP-SIDE, before the router and @@ -167,7 +195,7 @@ async function bootstrap() { // /organisaties/abc-123 all return 200 with the app shell. Without // that route a deep link 404s at the SERVER on reload, which is the // reason apps fell back to hash mode (fleet #133). - history: createWebHistory(generateUrl('/apps/stackiq')), + history: createWebHistory(routerBase()), routes: routesFromManifest(resolvedManifest), })