Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:<key>` IAppConfig sentinels in `manifest.pages[].config`
* (e.g. `@resolve:voorzieningen_register`) APP-SIDE, before the router and
Expand Down Expand Up @@ -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),
})

Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/manifest-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ async function gotoAppRoute(page: Page, route: string): Promise<void> {
// The in-app router runs in hash mode, so deep links are `#<route>`. 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
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/smoke/app-mounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
]

Expand Down
28 changes: 13 additions & 15 deletions tests/e2e/spec-coverage/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,13 @@ export async function dismissWalkthrough(page: Page): Promise<void> {

/** Deep-link to a route and wait for the Vue shell + main region to mount. */
export async function gotoAppRoute(page: Page, route: string): Promise<void> {
// The in-app router runs in hash mode, so deep links are `#<route>`. 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)
Expand All @@ -183,16 +185,12 @@ export async function gotoAppRoute(page: Page, route: string): Promise<void> {
* 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
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/spec-coverage/catalog-ratings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ async function newAnonymousContext(): Promise<APIRequestContext> {

/** Open the seeded module's detail page and wait for the reviews panel. */
async function openModuleReviews(page: Page): Promise<void> {
await page.goto(`${APP_BASE}#/modules/${moduleUuid}`, {
await page.goto(`${APP_BASE.replace(/\/$/, "")}/modules/${moduleUuid}`, {
waitUntil: 'domcontentloaded',
})
await page
Expand Down Expand Up @@ -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
Expand Down
Loading