From cb7c868acd3ab3f6862350df63b581ece973617d Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:35:05 +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 (older DSpace 7.6.1 generation, gutter var --ds-collapsed/total-sidebar-width), 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 | 7 ++-- src/app/root/root.component.scss | 28 +++++++++++++ src/app/root/root.component.ts | 41 ++++++++++++++++++-- src/themes/custom/app/root/root.component.ts | 2 - 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index d6d05868923..fef3fdb8588 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -2,10 +2,9 @@ {{ 'root.skip-to-content' | translate }} -
+
diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index 9eb198417ad..ac2f728107b 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 sidebar-width 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-collapsed-sidebar-width); + } + + &.ds-admin-sidebar-pinned { + padding-left: var(--ds-total-sidebar-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 160504f14fa..764194d5302 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,5 +1,5 @@ import { map, startWith } from 'rxjs/operators'; -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { AfterViewInit, Component, Inject, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; @@ -16,7 +16,6 @@ import { HostWindowService } from '../shared/host-window.service'; import { ThemeConfig } from '../../config/theme.config'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { environment } from '../../environments/environment'; -import { slideSidebarPadding } from '../shared/animations/slide'; import { MenuID } from '../shared/menu/menu-id.model'; import { getPageInternalServerErrorRoute } from '../app-routing-paths'; import { hasValueOperator } from '../shared/empty.util'; @@ -25,13 +24,30 @@ import { hasValueOperator } from '../shared/empty.util'; selector: 'ds-root', templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], - animations: [slideSidebarPadding], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, AfterViewInit { sidebarVisible: Observable; slideSidebarOver: Observable; collapsedSidebarWidth: Observable; totalSidebarWidth: 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 sidebar-width 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; theme: Observable = of({} as any); notificationOptions; models; @@ -74,11 +90,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 the sidebar-width custom properties (see root.component.scss), + // identically on server and browser — fixing the jump without any hardcoded width. + this.sidebarPaddingState$ = combineLatestObservable([this.sidebarVisible, 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 6b5b0c106fa..3f9aac59932 100644 --- a/src/themes/custom/app/root/root.component.ts +++ b/src/themes/custom/app/root/root.component.ts @@ -1,5 +1,4 @@ import { Component } from '@angular/core'; -import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; @Component({ @@ -8,7 +7,6 @@ import { RootComponent as BaseComponent } from '../../../../app/root/root.compon styleUrls: ['../../../../app/root/root.component.scss'], // templateUrl: './root.component.html', templateUrl: '../../../../app/root/root.component.html', - animations: [slideSidebarPadding], }) export class RootComponent extends BaseComponent {