From d30167ecd5204532011c6084b95446f5bc42ba86 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:35:07 +0200 Subject: [PATCH] Backport #1333: admin-sidebar gutter via CSS var (no logged-in reload shift) Backport of dataquest-dev/dspace-angular#1333 (originally landed on customer/vsb-tuo). Problem: for an authenticated user, a hard reload shifted the whole page right by the admin-sidebar width when the SSR snapshot was removed. The .outer-wrapper left gutter was produced by the @slideSidebarPadding animation, whose width is read from a browser-only CSS-variable store; on the server that store is empty so it renders padding-left:0, then the browser resolves the real width and the page jumps. Fix: drive the gutter from a CSS class (ds-admin-sidebar-{hidden,unpinned,pinned}, set from a small sidebarPaddingState$) whose padding-left resolves from the admin-sidebar width custom properties in CSS. CSS resolves those identically on the server (the SSR snapshot) and the browser (the live app), so there is no shift and no hardcoded width. The pin/unpin slide is preserved via transition:padding-left, gated behind ds-admin-sidebar-animate (enabled only after the first browser paint) so the initial SSR->CSR gutter resolution does not animate. Translated to this branch's root.component (Angular 18 standalone generation, base + themed root components), not a cherry-pick. Refs: dataquest-dev/dspace-customers#717, dataquest-dev/dspace-angular#1333 Co-Authored-By: Claude Opus 4.8 --- src/app/root/root.component.html | 10 +++-- src/app/root/root.component.scss | 28 ++++++++++++++ src/app/root/root.component.ts | 40 ++++++++++++++++++-- src/themes/custom/app/root/root.component.ts | 2 - 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 91cb236abed..b584cd95143 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -2,10 +2,12 @@ {{ 'root.skip-to-content' | translate }} -
+
diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index 9eb198417ad..71811df3cda 100644 --- a/src/app/root/root.component.scss +++ b/src/app/root/root.component.scss @@ -14,3 +14,31 @@ top: 0; } } + +// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from +// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a +// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0 +// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the +// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the +// server (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the +// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0. +.outer-wrapper { + // padding-left:0 (no admin sidebar); explicit for self-documentation. + &.ds-admin-sidebar-hidden { + padding-left: 0; + } + + &.ds-admin-sidebar-unpinned { + padding-left: var(--ds-admin-sidebar-fixed-element-width); + } + + &.ds-admin-sidebar-pinned { + padding-left: var(--ds-admin-sidebar-total-width); + } + + // Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled) + // so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates. + &.ds-admin-sidebar-animate { + transition: padding-left 300ms ease-in-out; + } +} diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 9c33116b6c0..4797a2afbf8 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -3,6 +3,7 @@ import { NgClass, } from '@angular/common'; import { + AfterViewInit, Component, Inject, Input, @@ -38,7 +39,6 @@ import { } from '../core/services/window.service'; import { ThemedFooterComponent } from '../footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; -import { slideSidebarPadding } from '../shared/animations/slide'; import { HostWindowService } from '../shared/host-window.service'; import { LiveRegionComponent } from '../shared/live-region/live-region.component'; import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; @@ -52,7 +52,6 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne selector: 'ds-base-root', templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], - animations: [slideSidebarPadding], standalone: true, imports: [ AsyncPipe, @@ -69,12 +68,30 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne TranslateModule, ], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, AfterViewInit { theme: Observable = of({} as any); isSidebarVisible$: Observable; slideSidebarOver$: Observable; collapsedSidebarWidth$: Observable; expandedSidebarWidth$: Observable; + + /** + * The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the + * outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular + * animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it + * is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live + * app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware. + */ + sidebarPaddingState$: Observable; + + /** + * Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial + * SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS + * transition would animate that initial 0->gutter change (the overlay settle detector only watches + * DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed. + * Off on the server and on first render, so only genuine pin/unpin toggles animate. + */ + gutterTransitionEnabled = false; notificationOptions: INotificationBoardOptions; models: any; @@ -130,11 +147,28 @@ export class RootComponent implements OnInit { startWith(true), ); + // Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the + // animation needs a concrete width from the browser-only CSS-variable store, so on the server it + // rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed. + // The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically + // on server and browser — fixing the jump without any hardcoded width. + this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe( + map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'), + ); + if (this.router.url === getPageInternalServerErrorRoute()) { this.shouldShowRouteLoader = false; } } + ngAfterViewInit(): void { + // Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not + // defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { this.gutterTransitionEnabled = true; }); + } + } + skipToMainContent() { const mainContent = document.getElementById('main-content'); if (mainContent) { diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts index a200982bf0e..d7d04c22b14 100644 --- a/src/themes/custom/app/root/root.component.ts +++ b/src/themes/custom/app/root/root.component.ts @@ -11,7 +11,6 @@ import { ThemedBreadcrumbsComponent } from '../../../../app/breadcrumbs/themed-b import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component'; import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; -import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component'; import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component'; @@ -23,7 +22,6 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler styleUrls: ['../../../../app/root/root.component.scss'], // templateUrl: './root.component.html', templateUrl: '../../../../app/root/root.component.html', - animations: [slideSidebarPadding], standalone: true, imports: [ AsyncPipe,