diff --git a/src/main.js b/src/main.js index 5e8a7f73..3a27436e 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' @@ -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 @@ -160,7 +188,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(routerBase()), 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