Skip to content

Commit 0189686

Browse files
committed
fix(context-navigation): clear hash fragment on context changes
Hash fragments are intentionally cleared when context changes to prevent inconsistent app state. When a context change resets the app to its root view, preserving the hash would leave anchors pointing to sections that may not exist in the new context. This matches the existing behavior for sub-routes (intentionally dropped) and maintains consistency across all three adapters (path, query, custom). Adds test to verify hash is cleared on context change. Addresses PR review feedback in #4751
1 parent 236ad57 commit 0189686

7 files changed

Lines changed: 51 additions & 5 deletions

File tree

packages/dev-portal/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ The portal is composed of these internal parts:
9595

9696
- **`render`** — Entry point; creates a React root with theme, framework, and people-resolver providers.
9797
- **`configure`** — Configures all framework modules (telemetry, navigation, context navigation, bookmarks, feature flags, analytics, AG Grid, services).
98-
- **`configureDevPortalContext`**Wires the context module's path generator and path extractor to the shared context-navigation URL utilities.
98+
- **Context configuration**`enableContext` wires the context module's path generator and path extractor to the shared context-navigation URL utilities.
9999
- **`Router`** — Sets up routes with `react-router` via the navigation module; routes `/apps/:appKey/*` to the app loader.
100100
- **`AppLoader`** — Resolves, initializes, and mounts a Fusion app by key; handles loading states and errors.
101101
- **`Header`** — Top bar with the Fusion logo, context selector, bookmark toggle, and person settings.

packages/dev-portal/src/configure.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ declare global {
4242
* Modules enabled:
4343
* - **Telemetry** — portal-scoped usage analytics with version metadata.
4444
* - **App** — application manifest loading and lifecycle.
45-
* - **Context** — context routing URL hooks via {@link configureDevPortalContext}.
45+
* - **Context** — context routing URL hooks (path generator + extractor) wired to the shared context-navigation URL utilities.
4646
* - **Context Navigation plugin** — keeps the browser URL in sync with the
4747
* active context, handles app-switch carry-over, and guards against
4848
* accidental context loss. Telemetry is auto-resolved from the framework

packages/plugins/context-navigation/src/__tests__/active-app-navigation-events.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BehaviorSubject, EMPTY, Subject } from 'rxjs';
1+
import { BehaviorSubject, Subject } from 'rxjs';
22
import { describe, expect, it } from 'vitest';
33

44
import type { AppModulesInstance } from '@equinor/fusion-framework-module-app';
@@ -15,6 +15,7 @@ describe('activeAppNavigationEvents$', () => {
1515
const currentApp$ = new BehaviorSubject({
1616
appKey: 'my-app',
1717
instance$: new BehaviorSubject(appModules),
18+
manifest$: new BehaviorSubject(null),
1819
});
1920

2021
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -56,6 +57,7 @@ describe('activeAppNavigationEvents$', () => {
5657
const currentApp$ = new BehaviorSubject({
5758
appKey: 'my-app',
5859
instance$: new BehaviorSubject(null),
60+
manifest$: new BehaviorSubject(null),
5961
});
6062

6163
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -79,6 +81,7 @@ describe('activeAppNavigationEvents$', () => {
7981
const currentApp$ = new BehaviorSubject({
8082
appKey: 'my-app',
8183
instance$: new BehaviorSubject(appModules),
84+
manifest$: new BehaviorSubject(null),
8285
});
8386

8487
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -109,6 +112,7 @@ describe('activeAppNavigationEvents$', () => {
109112
const currentApp$ = new BehaviorSubject<unknown>({
110113
appKey: 'app-a',
111114
instance$: new BehaviorSubject(appModulesA),
115+
manifest$: new BehaviorSubject(null),
112116
});
113117

114118
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -122,7 +126,7 @@ describe('activeAppNavigationEvents$', () => {
122126
navigationState$.next({});
123127
expect(emissions[0]?.appKey).toBe('app-a');
124128

125-
currentApp$.next({ appKey: 'app-b', instance$: new BehaviorSubject(appModulesB) });
129+
currentApp$.next({ appKey: 'app-b', instance$: new BehaviorSubject(appModulesB), manifest$: new BehaviorSubject(null) });
126130
navigationState$.next({});
127131
expect(emissions[1]?.appKey).toBe('app-b');
128132

packages/plugins/context-navigation/src/__tests__/plugin.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BehaviorSubject, EMPTY, Subject } from 'rxjs';
2-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
33

44
import type { AppModulesInstance } from '@equinor/fusion-framework-module-app';
55
import type { ContextItem, ContextModule } from '@equinor/fusion-framework-module-context';
@@ -320,6 +320,35 @@ describe('createContextNavigationPlugin', () => {
320320
expect(targetURL.pathname).toBe('/apps/my-app/ctx-5');
321321
});
322322
});
323+
324+
it('clears hash fragment on context change to avoid inconsistent app state', async () => {
325+
const adapter = makeAdapter({
326+
encode: ({ context, currentURL }) => {
327+
if (!context) return null;
328+
const target = new URL(`/apps/my-app/${context.id}`, currentURL.origin);
329+
target.search = currentURL.search;
330+
// Hash intentionally not preserved — context change resets app to root view
331+
return target;
332+
},
333+
});
334+
harness = createHarness({ adapters: [adapter] });
335+
Object.assign(harness.navigation, {
336+
path: { pathname: '/apps/my-app', search: '', hash: '#section-2' },
337+
});
338+
const appModules = { context: { currentContext: null } } as unknown as AppModulesInstance<
339+
[ContextModule]
340+
>;
341+
342+
harness.source$.next({ appKey: 'my-app', appModules, contextState: makeContext('ctx-6') });
343+
344+
await vi.waitFor(() => {
345+
const navigateCall = vi.mocked(harness.navigation.navigate).mock.calls[0];
346+
const targetURL = navigateCall?.[0] as URL;
347+
// Hash should be empty — dropped when context changes
348+
expect(targetURL.hash).toBe('');
349+
expect(targetURL.pathname).toBe('/apps/my-app/ctx-6');
350+
});
351+
});
323352
});
324353

325354
// ─── Null Context URL ─────────────────────────────────────────────
@@ -373,6 +402,7 @@ describe('createContextNavigationPlugin', () => {
373402
const currentApp$ = new BehaviorSubject({
374403
appKey: 'my-app',
375404
instance$: new BehaviorSubject(appModules),
405+
manifest$: new BehaviorSubject(null),
376406
});
377407

378408
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -423,6 +453,7 @@ describe('createContextNavigationPlugin', () => {
423453
const currentApp$ = new BehaviorSubject({
424454
appKey: 'my-app',
425455
instance$: new BehaviorSubject(appModules),
456+
manifest$: new BehaviorSubject(null),
426457
});
427458

428459
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -480,6 +511,7 @@ describe('createContextNavigationPlugin', () => {
480511
const currentApp$ = new BehaviorSubject({
481512
appKey: 'my-app',
482513
instance$: new BehaviorSubject(appModules),
514+
manifest$: new BehaviorSubject(null),
483515
});
484516

485517
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];
@@ -530,6 +562,7 @@ describe('createContextNavigationPlugin', () => {
530562
const currentApp$ = new BehaviorSubject({
531563
appKey: 'my-app',
532564
instance$: new BehaviorSubject(appModules),
565+
manifest$: new BehaviorSubject(null),
533566
});
534567

535568
const app = { current$: currentApp$ } as unknown as ContextNavigationPluginArgs['app'];

packages/plugins/context-navigation/src/adapters/custom-adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export function createCustomAdapter(): ContextNavigationAdapterFactory {
164164
url.search = currentURL.search;
165165
// Remove query-adapter param — custom apps encode context in the path
166166
stripContextQueryParam(url);
167+
// Hash is intentionally not preserved — returning to root app view
167168
return url;
168169
}
169170

@@ -191,6 +192,7 @@ export function createCustomAdapter(): ContextNavigationAdapterFactory {
191192
url.search = currentURL.search;
192193
// Remove query-adapter param — custom apps encode context in the path
193194
stripContextQueryParam(url);
195+
// Hash is intentionally not preserved — context changes reset app to root view
194196
return url;
195197
},
196198

packages/plugins/context-navigation/src/adapters/path-adapter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function createPathAdapter(): ContextNavigationAdapter {
7878
const url = new URL(targetPath, currentURL.origin);
7979
url.search = currentURL.search;
8080
stripContextQueryParam(url);
81+
// Hash is intentionally not preserved — context changes reset app to root view
8182
return url;
8283
},
8384

packages/plugins/context-navigation/src/adapters/query-adapter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export function createQueryAdapter(paramName = CONTEXT_QUERY_PARAM_KEY): Context
5656
* After mutation, the `$` character is restored from `%24` encoding
5757
* because `URLSearchParams` percent-encodes `$` but we want it
5858
* human-readable in the address bar as a namespace marker.
59+
*
60+
* Hash fragment is intentionally cleared — context changes reset the app
61+
* to its root view, so any hash pointing to sub-route sections would be invalid.
5962
*/
6063
encode({ context, currentURL }: { context: ContextItem | null; currentURL: URL }): URL | null {
6164
const url = new URL(currentURL.href);
@@ -69,6 +72,9 @@ export function createQueryAdapter(paramName = CONTEXT_QUERY_PARAM_KEY): Context
6972
// Restore $ from %24 — $ is a reserved namespace prefix we want visible in URLs
7073
url.search = url.search.replace(/%24/gi, '$');
7174

75+
// Clear hash — context change resets app to root view
76+
url.hash = '';
77+
7278
return url;
7379
},
7480

0 commit comments

Comments
 (0)