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
Original file line number Diff line number Diff line change
Expand Up @@ -65,30 +65,30 @@
<div class="footer-links">
<div class="footer-column">
<h4 i18n="@@shell.footer.col.pages.title">Pages</h4>
<a routerLink="/" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.home">Home</a>
<a routerLink="/about" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.about">About</a>
<a routerLink="/dashboard" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.dashboard">Dashboard</a>
<a routerLink="/agents" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.agents">Agents</a>
<a routerLink="/privacy" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.privacy">Privacy</a>
<a routerLink="/support" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.col.pages.support">Support</a>
<a routerLink="/" i18n="@@shell.footer.col.pages.home">Home</a>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve top scroll for same-route footer links

In this app provideRouter(...) does not set onSameUrlNavigation, so Angular uses its default same-URL behavior and ignores a routerLink click that targets the current route. After these footer links lost their click handler, clicking the footer link for the page you are already on (for example /about → footer About) no longer emits a router scroll event and leaves the user at the bottom instead of returning to the top; keep a guarded top-scroll path for same-route footer clicks or configure same-URL navigation explicitly.

Useful? React with 👍 / 👎.

<a routerLink="/about" i18n="@@shell.footer.col.pages.about">About</a>
<a routerLink="/dashboard" i18n="@@shell.footer.col.pages.dashboard">Dashboard</a>
<a routerLink="/agents" i18n="@@shell.footer.col.pages.agents">Agents</a>
<a routerLink="/privacy" i18n="@@shell.footer.col.pages.privacy">Privacy</a>
<a routerLink="/support" i18n="@@shell.footer.col.pages.support">Support</a>
</div>
<div class="footer-column">
<h4 i18n="@@shell.footer.col.project.title">Project</h4>
<a href="https://github.com/fullselfbrowsing/FSB" target="_blank" rel="noopener" i18n="@@shell.footer.project.github" [attr.translate]="'no'">GitHub</a>
<a href="https://github.com/fullselfbrowsing/FSB/issues" target="_blank" rel="noopener" i18n="@@shell.footer.project.issues" [attr.translate]="'no'">Issues</a>
<a href="https://github.com/fullselfbrowsing/FSB/releases" target="_blank" rel="noopener" i18n="@@shell.footer.project.changelog">Changelog</a>
<a href="https://github.com/fullselfbrowsing/FSB/blob/main/LICENSE" target="_blank" rel="noopener" i18n="@@shell.footer.project.license">BSL 1.1 License</a>
<a routerLink="/stats" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.project.stats">Stats</a>
<a routerLink="/stats" i18n="@@shell.footer.project.stats">Stats</a>
</div>
<div class="footer-column">
<h4 i18n="@@shell.footer.col.research.title">Research</h4>
<a routerLink="/lattice" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.research.lattice" [attr.translate]="'no'">Lattice</a>
<a routerLink="/phantom-stream" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.research.phantomStream" [attr.translate]="'no'">PhantomStream</a>
<a routerLink="/prometheus" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.research.prometheus" [attr.translate]="'no'">Prometheus</a>
<a routerLink="/lattice" i18n="@@shell.footer.research.lattice" [attr.translate]="'no'">Lattice</a>
<a routerLink="/phantom-stream" i18n="@@shell.footer.research.phantomStream" [attr.translate]="'no'">PhantomStream</a>
<a routerLink="/prometheus" i18n="@@shell.footer.research.prometheus" [attr.translate]="'no'">Prometheus</a>
</div>
<div class="footer-column">
<h4 i18n="@@shell.footer.col.community.title">Community</h4>
<a routerLink="/sitemaps" (click)="prepareRouteTopNavigation()" i18n="@@shell.footer.community.sitemaps" [attr.translate]="'no'">Sitemaps</a>
<a routerLink="/sitemaps" i18n="@@shell.footer.community.sitemaps" [attr.translate]="'no'">Sitemaps</a>
<a href="https://discord.gg/xRbj6JQpY" target="_blank" rel="noopener" i18n="@@shell.footer.community.discord" [attr.translate]="'no'">Discord</a>
<a href="https://x.com/fsb0804" target="_blank" rel="noopener" class="footer-x-link" i18n="@@shell.footer.community.x" [attr.translate]="'no'">X</a>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive, Scroll } from '@angular/router';
import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { Subscription, filter } from 'rxjs';
import { APP_VERSION } from '../../core/seo/version';
import { ThemeService } from '../../core/theme.service';
import { LanguagePickerComponent } from '../language-picker/language-picker.component';

const ROUTE_SCROLL_RESET_DELAYS_MS = [0, 50, 150, 350, 700];
const PERSISTENT_ROUTE_SCROLL_RESET_DELAYS_MS = [0, 50, 150, 350, 700, 1500, 3000];
const ROUTE_SCROLL_RESET_STORAGE_KEY = 'fsb-route-scroll-top';

@Component({
selector: 'app-showcase-shell',
standalone: true,
Expand All @@ -24,7 +20,6 @@ export class ShowcaseShellComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private routeSub?: Subscription;
private scrollResetTimers: number[] = [];

readonly appVersion = APP_VERSION;
mobileMenuOpen = false;
Expand All @@ -39,34 +34,15 @@ export class ShowcaseShellComponent implements OnInit, OnDestroy {
this.mobileMenuOpen = false;
}

prepareRouteTopNavigation(): void {
this.markRouteTopNavigation();
this.scrollToRouteTop(true);
}

ngOnInit(): void {
this.updateShellMode();
this.useManualBrowserScrollRestoration();
this.routeSub = new Subscription();
this.routeSub.add(this.router.events
this.routeSub = this.router.events
.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd))
.subscribe(() => {
this.updateShellMode();
this.scrollToRouteTopWhenNoFragment();
}));
this.routeSub.add(this.router.events
.pipe(filter((event): event is Scroll => event instanceof Scroll))
.subscribe((event) => {
if (!event.anchor) {
this.scrollToRouteTopWhenNoFragment();
}
}));
this.scrollToRouteTopWhenNoFragment();
.subscribe(() => this.updateShellMode());
}

ngOnDestroy(): void {
this.routeSub?.unsubscribe();
this.clearScrollResetTimers();
}

@HostListener('window:scroll')
Expand All @@ -84,84 +60,4 @@ export class ShowcaseShellComponent implements OnInit, OnDestroy {
this.closeMobileMenu();
}
}

private scrollToRouteTop(persistent = false): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;

this.clearScrollResetTimers();
this.resetWindowScroll();

const delays = persistent ? PERSISTENT_ROUTE_SCROLL_RESET_DELAYS_MS : ROUTE_SCROLL_RESET_DELAYS_MS;
for (const delayMs of delays) {
const timer = window.setTimeout(() => {
this.scrollResetTimers = this.scrollResetTimers.filter((id) => id !== timer);
window.requestAnimationFrame(() => this.resetWindowScroll());
}, delayMs);
this.scrollResetTimers.push(timer);
}
}

private scrollToRouteTopWhenNoFragment(): void {
const pendingRouteTopNavigation = this.consumeRouteTopNavigation();
if (typeof window !== 'undefined' && window.location.hash && !pendingRouteTopNavigation) return;
this.scrollToRouteTop(pendingRouteTopNavigation);
}

private resetWindowScroll(): void {
window.scrollTo(0, 0);
const scrollers = [
document.scrollingElement,
document.documentElement,
document.body,
].filter((scroller): scroller is Element => !!scroller);

for (const scroller of scrollers) {
if (scroller instanceof HTMLElement) {
scroller.scrollTop = 0;
scroller.scrollLeft = 0;
}
}
}

private clearScrollResetTimers(): void {
if (typeof window === 'undefined') return;
for (const timer of this.scrollResetTimers) {
window.clearTimeout(timer);
}
this.scrollResetTimers = [];
}

private useManualBrowserScrollRestoration(): void {
if (
typeof window === 'undefined' ||
!window.history ||
!('scrollRestoration' in window.history)
) {
return;
}

window.history.scrollRestoration = 'manual';
}

private markRouteTopNavigation(): void {
if (typeof window === 'undefined' || !window.sessionStorage) return;
try {
window.sessionStorage.setItem(ROUTE_SCROLL_RESET_STORAGE_KEY, '1');
} catch {
// Ignore private-mode storage failures; the immediate scroll reset still runs.
}
}

private consumeRouteTopNavigation(): boolean {
if (typeof window === 'undefined' || !window.sessionStorage) return false;
try {
const pending = window.sessionStorage.getItem(ROUTE_SCROLL_RESET_STORAGE_KEY) === '1';
if (pending) {
window.sessionStorage.removeItem(ROUTE_SCROLL_RESET_STORAGE_KEY);
}
return pending;
} catch {
return false;
}
}
}
71 changes: 0 additions & 71 deletions showcase/angular/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,78 +19,7 @@
}
})();
</script>
<script>
(function initRouteScrollRestoration() {
var storageKey = 'fsb-route-scroll-top';
if (typeof window === 'undefined') return;
if (window.history && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}

var hasPendingReset = function() {
try {
return window.sessionStorage && window.sessionStorage.getItem(storageKey) === '1';
} catch (err) {
return false;
}
};
var markPendingReset = function() {
try {
if (window.sessionStorage) window.sessionStorage.setItem(storageKey, '1');
} catch (err) {
// Ignore private-mode storage failures; the in-page reset still runs.
}
};
var clearPendingReset = function() {
try {
if (window.sessionStorage) window.sessionStorage.removeItem(storageKey);
} catch (err) {
// Ignore private-mode storage failures.
}
};

var reset = function() {
window.scrollTo(0, 0);
};
var scheduleReset = function(persistent) {
var delays = persistent ? [0, 100, 500, 1500, 3000] : [0, 100, 500];
reset();
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(reset);
}
for (var i = 0; i < delays.length; i += 1) {
window.setTimeout(reset, delays[i]);
}
};
var isInternalFooterRouteLink = function(link) {
if (!link || !link.href) return false;
var url;
try {
url = new URL(link.href, window.location.href);
} catch (err) {
return false;
}
return url.origin === window.location.origin && !url.hash;
};

document.addEventListener('click', function(event) {
var target = event.target;
var link = target && target.closest ? target.closest('footer a[href]') : null;
if (!isInternalFooterRouteLink(link)) return;
markPendingReset();
scheduleReset(true);
}, true);

var pendingReset = hasPendingReset();
if (pendingReset) clearPendingReset();
if (!window.location.hash || pendingReset) {
scheduleReset(pendingReset);
window.addEventListener('pageshow', function() { scheduleReset(pendingReset); }, { once: true });
window.addEventListener('load', function() { scheduleReset(pendingReset); }, { once: true });
}
})();
</script>

<link rel="icon" type="image/png" href="/assets/icon48.png">
<!-- Organization JSON-LD (LD-01, D-10..D-12). Inherited by every prerendered route via index.html template. Payload is hand-authored single-line strict JSON; no field contains '<' so no \u003c escape pass is needed. Pattern: PITFALLS.md P4. Threat: T-LD-01 (XSS via JSON-LD injection). -->
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","@id":"https://full-selfbrowsing.com/#org","name":"FSB","alternateName":"Full Self-Browsing","url":"https://full-selfbrowsing.com","logo":"https://full-selfbrowsing.com/assets/fsb_logo_dark.png","sameAs":["https://github.com/fullselfbrowsing/FSB","https://www.youtube.com/@parzival5707"]}</script>
Expand Down
7 changes: 6 additions & 1 deletion showcase/angular/src/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@
html {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
scroll-behavior: smooth;
}

@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}

body {
Expand Down
45 changes: 20 additions & 25 deletions tests/showcase-angular-foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,20 +165,11 @@ assert(
);

assert(
/event instanceof NavigationEnd/.test(shellComponentSource) &&
/event instanceof Scroll/.test(shellComponentSource) &&
/this\.scrollToRouteTop\(/.test(shellComponentSource) &&
/!event\.anchor/.test(shellComponentSource) &&
/window\.location\.hash/.test(shellComponentSource) &&
/window\.history\.scrollRestoration = 'manual'/.test(shellComponentSource) &&
/ROUTE_SCROLL_RESET_DELAYS_MS/.test(shellComponentSource) &&
/PERSISTENT_ROUTE_SCROLL_RESET_DELAYS_MS/.test(shellComponentSource) &&
/ROUTE_SCROLL_RESET_STORAGE_KEY/.test(shellComponentSource) &&
/window\.setTimeout/.test(shellComponentSource) &&
/window\.requestAnimationFrame/.test(shellComponentSource) &&
/window\.scrollTo\(0,\s*0\)/.test(shellComponentSource) &&
/scroller\.scrollTop = 0/.test(shellComponentSource),
'showcase shell explicitly resets window scroll after late route navigation events'
/event instanceof NavigationEnd/.test(shellComponentSource) &&
!/event instanceof Scroll/.test(shellComponentSource) &&
!/(prepareRouteTopNavigation|scrollToRouteTop|resetWindowScroll|ROUTE_SCROLL_RESET|fsb-route-scroll-top)/.test(shellComponentSource) &&
!/(window\.setTimeout|window\.requestAnimationFrame|window\.scrollTo|sessionStorage|scrollRestoration)/.test(shellComponentSource),
'showcase shell delegates route scrolling exclusively to the Angular router'
);

assert(
Expand All @@ -203,14 +194,9 @@ assert(
);

assert(
/initRouteScrollRestoration/.test(indexSource) &&
/window\.history\.scrollRestoration = 'manual'/.test(indexSource) &&
/window\.location\.hash/.test(indexSource) &&
/fsb-route-scroll-top/.test(indexSource) &&
/document\.addEventListener\('click'/.test(indexSource) &&
/target\.closest\('footer a\[href\]'\)/.test(indexSource) &&
/window\.setTimeout\(reset,\s*delays\[i\]\)/.test(indexSource),
'index pre-bootstrap script disables browser scroll restoration and resets non-hash route loads to top'
!/(initRouteScrollRestoration|fsb-route-scroll-top|scheduleReset|target\.closest\('footer a\[href\]'\))/.test(indexSource) &&
!/(window\.scrollTo|window\.setTimeout|window\.history\.scrollRestoration)/.test(indexSource),
'index bootstrap does not install a competing route-scroll owner'
);

const themeBootstrapIndex = indexSource.indexOf("window.matchMedia('(prefers-color-scheme: dark)'");
Expand Down Expand Up @@ -240,9 +226,18 @@ assert(
);

assert(
/prepareRouteTopNavigation\(\): void/.test(shellComponentSource) &&
(shellTemplateSource.match(/\(click\)="prepareRouteTopNavigation\(\)"/g) || []).length >= 11,
'footer internal router links reset scroll before route navigation starts'
(shellTemplateSource.match(/<a routerLink=/g) || []).length >= 23 &&
!/\(click\)="prepareRouteTopNavigation\(\)"/.test(shellTemplateSource),
'footer internal links remain plain router links without imperative scroll handlers'
);

const reducedMotionMediaIndex = globalStylesSource.indexOf('@media (prefers-reduced-motion: no-preference)');
const smoothScrollIndex = globalStylesSource.indexOf('scroll-behavior: smooth');
assert(
reducedMotionMediaIndex !== -1 &&
smoothScrollIndex > reducedMotionMediaIndex &&
/@media\s*\(prefers-reduced-motion:\s*no-preference\)\s*\{[\s\S]*?html\s*\{[\s\S]*?scroll-behavior:\s*smooth;[\s\S]*?\}/.test(globalStylesSource),
'smooth scrolling is enabled only when reduced motion is not requested'
);

assert(
Expand Down
Loading