Skip to content

Commit 13d73fa

Browse files
milanmajchrakclaude
andcommitted
fix(ssr-overlay): render admin-sidebar gutter via CSS var so logged-in reload doesn't shift
Follow-up to #1321 (the anti-flicker SSR overlay, merged into `customer/vsb-tuo`). ## Problem With the overlay in place, an authenticated user's hard reload still showed the whole page jump right by the admin-sidebar width when the SSR snapshot was removed. (Anonymous users were already smooth — they have no sidebar.) ## Root cause (independent of the overlay; the overlay just makes it a visible "reveal") The `.outer-wrapper` left gutter came from the `@slideSidebarPadding` Angular animation, whose width is read via `cssService.getVariable('--ds-admin-sidebar-*')`. That store is populated only in the browser (`AppComponent.storeCSSVariables` -> `getComputedStyle`); on the server it stays empty, `skipWhile(!val)` blocks forever, and the animation renders `outer-wrapper { padding-left: 0 }`. The browser then resolves the real width (e.g. 55px), so the authenticated page (and the SSR snapshot, which is just that server HTML) jumps right on reveal. ## Fix Drive the gutter from a CSS class (`ds-admin-sidebar-{hidden,unpinned,pinned}`, set from a small `sidebarPaddingState$`) whose `padding-left` is `var(--ds-admin-sidebar-fixed-element-width)` / `--ds-admin-sidebar-total-width`. CSS resolves those custom properties identically on the server (the snapshot) and the browser (the live app), so they always match — no hardcoded pixel width, fully theme-overridable and viewport/media-query aware. The `@slideSidebarPadding` animation (which can't take a `var()` value and is stripped to empty on SSR) is no longer used here; the unused import/registration is dropped from the base and custom-theme root components. Non-admins are unaffected ('hidden' -> 0). The pin/unpin slide is preserved via `transition: padding-left`, but GATED behind a `ds-admin-sidebar-animate` class enabled only after the first browser paint (`gutterTransitionEnabled`). This stops the initial SSR->CSR gutter resolution — which happens behind the overlay — from animating and leaking a 300ms slide right as the overlay is removed (the overlay's settle detector watches DOM mutations, not style changes). Addresses review feedback (3 independent SSR/DSpace reviewers). ## Verification (authenticated admin, real UA, throttled hard reload, local SSR build) - SSR HTML renders `<div class="outer-wrapper ds-admin-sidebar-unpinned">` — a class, no inline px, and no `ds-admin-sidebar-animate` (transition correctly off on the server / initial paint). - snapshot vs settled `#main-content`: desktop 55/55 -> shift 0px; mobile (375px) 55/55 -> shift 0px; the live app gains `ds-admin-sidebar-animate` only after first paint. Refs: dspace-customers#725, #1321 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 468a58a commit 13d73fa

4 files changed

Lines changed: 71 additions & 11 deletions

File tree

src/app/root/root.component.html

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
{{ 'root.skip-to-content' | translate }}
33
</button>
44

5-
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [@slideSidebarPadding]="{
6-
value: (!(isSidebarVisible$ | async) ? 'hidden' : (slideSidebarOver$ | async) ? 'unpinned' : 'pinned'),
7-
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
8-
}">
5+
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader"
6+
[class.ds-admin-sidebar-animate]="gutterTransitionEnabled"
7+
[ngClass]="'ds-admin-sidebar-' + (sidebarPaddingState$ | async)">
98
<ds-themed-admin-sidebar [expandedSidebarWidth$]="expandedSidebarWidth$" [collapsedSidebarWidth$]="collapsedSidebarWidth$"></ds-themed-admin-sidebar>
109
<div class="inner-wrapper">
1110
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>

src/app/root/root.component.scss

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,31 @@
1414
top: 0;
1515
}
1616
}
17+
18+
// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from
19+
// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a
20+
// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0
21+
// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the
22+
// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the
23+
// server (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the
24+
// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0.
25+
.outer-wrapper {
26+
// padding-left:0 (no admin sidebar); explicit for self-documentation.
27+
&.ds-admin-sidebar-hidden {
28+
padding-left: 0;
29+
}
30+
31+
&.ds-admin-sidebar-unpinned {
32+
padding-left: var(--ds-admin-sidebar-fixed-element-width);
33+
}
34+
35+
&.ds-admin-sidebar-pinned {
36+
padding-left: var(--ds-admin-sidebar-total-width);
37+
}
38+
39+
// Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled)
40+
// so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates.
41+
&.ds-admin-sidebar-animate {
42+
transition: padding-left 300ms ease-in-out;
43+
}
44+
}

src/app/root/root.component.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { first, map, skipWhile, startWith } from 'rxjs/operators';
2-
import { Component, Input, OnInit } from '@angular/core';
2+
import { AfterViewInit, Component, Input, OnInit } from '@angular/core';
33
import { Router } from '@angular/router';
44

55
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -8,7 +8,6 @@ import { MenuService } from '../shared/menu/menu.service';
88
import { HostWindowService } from '../shared/host-window.service';
99
import { ThemeConfig } from '../../config/theme.config';
1010
import { environment } from '../../environments/environment';
11-
import { slideSidebarPadding } from '../shared/animations/slide';
1211
import { MenuID } from '../shared/menu/menu-id.model';
1312
import { getPageInternalServerErrorRoute } from '../app-routing-paths';
1413
import { INotificationBoardOptions } from 'src/config/notifications-config.interfaces';
@@ -17,14 +16,31 @@ import { INotificationBoardOptions } from 'src/config/notifications-config.inter
1716
selector: 'ds-root',
1817
templateUrl: './root.component.html',
1918
styleUrls: ['./root.component.scss'],
20-
animations: [slideSidebarPadding],
2119
})
22-
export class RootComponent implements OnInit {
20+
export class RootComponent implements OnInit, AfterViewInit {
2321
theme: Observable<ThemeConfig> = of({} as any);
2422
isSidebarVisible$: Observable<boolean>;
2523
slideSidebarOver$: Observable<boolean>;
2624
collapsedSidebarWidth$: Observable<string>;
2725
expandedSidebarWidth$: Observable<string>;
26+
27+
/**
28+
* The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
29+
* outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
30+
* animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it
31+
* is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
32+
* app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
33+
*/
34+
sidebarPaddingState$: Observable<string>;
35+
36+
/**
37+
* Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
38+
* SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
39+
* transition would animate that initial 0->gutter change (the overlay settle detector only watches
40+
* DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
41+
* Off on the server and on first render, so only genuine pin/unpin toggles animate.
42+
*/
43+
gutterTransitionEnabled = false;
2844
notificationOptions: INotificationBoardOptions;
2945
models: any;
3046

@@ -42,14 +58,16 @@ export class RootComponent implements OnInit {
4258
private router: Router,
4359
private cssService: CSSVariableService,
4460
private menuService: MenuService,
45-
private windowService: HostWindowService
61+
private windowService: HostWindowService,
4662
) {
4763
this.notificationOptions = environment.notifications;
4864
}
4965

5066
ngOnInit() {
5167
this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
5268

69+
// Still provided to <ds-themed-admin-sidebar>; the sidebar element itself sizes from CSS vars, so a
70+
// null value on the server (the store is browser-only) is harmless there.
5371
this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe(
5472
skipWhile((val) => !val),
5573
first(),
@@ -66,11 +84,28 @@ export class RootComponent implements OnInit {
6684
startWith(true),
6785
);
6886

87+
// Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
88+
// animation needs a concrete width from the browser-only CSS-variable store, so on the server it
89+
// rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
90+
// The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically
91+
// on server and browser — fixing the jump without any hardcoded width.
92+
this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe(
93+
map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
94+
);
95+
6996
if (this.router.url === getPageInternalServerErrorRoute()) {
7097
this.shouldShowRouteLoader = false;
7198
}
7299
}
73100

101+
ngAfterViewInit(): void {
102+
// Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
103+
// defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled.
104+
if (typeof requestAnimationFrame === 'function') {
105+
requestAnimationFrame(() => { this.gutterTransitionEnabled = true; });
106+
}
107+
}
108+
74109
skipToMainContent() {
75110
const mainContent = document.getElementById('main-content');
76111
if (mainContent) {

src/themes/custom/app/root/root.component.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Component } from '@angular/core';
2-
import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
32
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
43

54
@Component({
@@ -8,7 +7,6 @@ import { RootComponent as BaseComponent } from '../../../../app/root/root.compon
87
styleUrls: ['../../../../app/root/root.component.scss'],
98
// templateUrl: './root.component.html',
109
templateUrl: '../../../../app/root/root.component.html',
11-
animations: [slideSidebarPadding],
1210
})
1311
export class RootComponent extends BaseComponent {
1412

0 commit comments

Comments
 (0)