Skip to content

Commit 3e167af

Browse files
authored
Fix host mode routing without trailing slash (#4915)
1 parent d2ad4e4 commit 3e167af

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

packages/host/app/services/host-mode-service.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ function ensureSingleTitle(headHTML: string): string {
1919
? headHTML
2020
: `${DEFAULT_HEAD_HTML}\n${headHTML}`;
2121
}
22+
23+
// Normalize trailing-slash variance for routing-map matching. `/realm/`
24+
// and `/realm` are the same destination from the user's perspective,
25+
// but the injected map keys and Ember's `params.path` disagree on
26+
// the trailing slash. Stripping it on both sides makes the comparator
27+
// robust. Preserve the root `/` since stripping it would empty the path.
28+
function canonicalizeRoutingPath(path: string): string {
29+
if (path === '/') return '/';
30+
return path.replace(/\/+$/, '');
31+
}
2232
import type HostModeStateService from '@cardstack/host/services/host-mode-state-service';
2333
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
2434
import type RealmService from '@cardstack/host/services/realm';
@@ -130,10 +140,19 @@ export default class HostModeService extends Service {
130140
// `https://host/<user>/<realm>/whitepaper`); a leading slash is added if
131141
// absent so the index path is matchable as either '' or '/'. The
132142
// server prefixes each rule's `path` with the realm's mount pathname
133-
// before injecting the map, so the two sides line up as direct equality.
143+
// before injecting the map, so the two sides line up as direct equality
144+
// — except for the trailing-slash variance at the realm root. A `/`
145+
// rule's injected key is the realm's mount pathname WITH trailing
146+
// slash (e.g. `/progressive-cheetah/`), but Ember's catch-all strips
147+
// it (`params.path === 'progressive-cheetah'` for either visit form).
148+
// Canonicalize both sides by stripping trailing slashes (except the
149+
// root `/` itself) before comparing so `/realm` ↔ `/realm/` resolve.
134150
resolveRoutedPath(path: string): string | null {
135151
let normalized = path.startsWith('/') ? path : `/${path}`;
136-
let rule = this.hostRoutingMap.find((r) => r.path === normalized);
152+
let canonical = canonicalizeRoutingPath(normalized);
153+
let rule = this.hostRoutingMap.find(
154+
(r) => canonicalizeRoutingPath(r.path) === canonical,
155+
);
137156
return rule ? rule.id : null;
138157
}
139158

packages/matrix/tests/host-mode.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,4 +436,112 @@ test.describe('Host mode', () => {
436436
await page.goto(routedURL);
437437
await expect(page.locator('[data-test-white-paper]')).toBeVisible();
438438
});
439+
440+
test('routing rule for `/` resolves when realm root is visited without a trailing slash', async ({
441+
page,
442+
}) => {
443+
// The realm publishes at e.g. `https://published.localhost:4205/<user>/<realm>/`
444+
// (with trailing slash). When a visitor types the URL without the
445+
// trailing slash, the server-rendered HTML is correct (the SSR
446+
// path-in-realm computation handles the missing slash), but the
447+
// Ember SPA's catch-all `/*path` strips the trailing slash from
448+
// the URL on the client side. Without canonicalization the
449+
// injected map key `/<user>/<realm>/` for the `/` rule wouldn't
450+
// match the client's `params.path === '<user>/<realm>'`, and
451+
// hydration would replace the SSR'd card with the bare-shell
452+
// fallback. This test pins the canonicalized comparator.
453+
await login(page, username, password);
454+
await page.goto(realmURL);
455+
await page.locator('[data-test-stack-item-content]').first().waitFor();
456+
457+
await postCardSource(
458+
page,
459+
realmURL,
460+
'realm.json',
461+
JSON.stringify({
462+
data: {
463+
type: 'card',
464+
attributes: {
465+
cardInfo: { name: `Routed Realm ${randomUUID()}` },
466+
hostRoutingRules: [{ path: '/' }],
467+
},
468+
relationships: {
469+
'hostRoutingRules.0.instance': {
470+
links: { self: './white-paper' },
471+
},
472+
},
473+
meta: {
474+
adoptsFrom: {
475+
module: 'https://cardstack.com/base/realm-config',
476+
name: 'RealmConfig',
477+
},
478+
},
479+
},
480+
}),
481+
);
482+
483+
await page.evaluate(
484+
async ({ realmURL, publishedRealmURL }) => {
485+
let sessions = JSON.parse(
486+
window.localStorage.getItem('boxel-session') ?? '{}',
487+
);
488+
let token = sessions[realmURL];
489+
if (!token) {
490+
throw new Error(`No session token found for ${realmURL}`);
491+
}
492+
let response = await fetch('https://localhost:4205/_publish-realm', {
493+
method: 'POST',
494+
headers: {
495+
Accept: 'application/json',
496+
'Content-Type': 'application/json',
497+
Authorization: token,
498+
},
499+
body: JSON.stringify({
500+
sourceRealmURL: realmURL,
501+
publishedRealmURL,
502+
}),
503+
});
504+
if (!response.ok) {
505+
throw new Error(await response.text());
506+
}
507+
},
508+
{ realmURL, publishedRealmURL },
509+
);
510+
511+
await logout(page);
512+
513+
// Wait until the SSR HTML at the canonical (trailing-slash) URL
514+
// contains the routed card's marker, then navigate to the
515+
// NO-TRAILING-SLASH variant and assert the marker stays visible
516+
// through hydration. The no-slash navigation is what the
517+
// canonicalization fix targets.
518+
await waitUntil(async () => {
519+
let response = await page.request.get(publishedRealmURL, {
520+
headers: { Accept: 'text/html' },
521+
});
522+
if (!response.ok()) {
523+
return false;
524+
}
525+
let text = await response.text();
526+
return text.includes('data-test-white-paper');
527+
});
528+
529+
let noSlashURL = publishedRealmURL.replace(/\/$/, '');
530+
await page.goto(noSlashURL);
531+
// `[data-test-host-mode-card="<id>"]` is set by the host SPA's
532+
// CardRenderer — that attribute exists ONLY post-hydration (it's
533+
// not in the SSR'd isolated_html). Pinning it to the rule's target
534+
// id means:
535+
// (a) `toBeVisible` implicitly waits for hydration to finish,
536+
// so it can't pass on the brief SSR flash before the SPA
537+
// replaces it; and
538+
// (b) if the resolveRoutedPath miss makes the SPA fall back to
539+
// the realm index card, the attribute value is `…/index`
540+
// (or similar) and this assertion fails with a clear diff
541+
// instead of silently catching the SSR'd marker.
542+
let expectedRoutedCardId = `${publishedRealmURL}white-paper`;
543+
await expect(
544+
page.locator(`[data-test-host-mode-card="${expectedRoutedCardId}"]`),
545+
).toBeVisible();
546+
});
439547
});

0 commit comments

Comments
 (0)