fix(ssr): apply theme CSS vars during SSR and enable client hydration
CI Pipeline / backend-test (push) Successful in 9m4s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 14s
CI Pipeline / backend-test (pull_request) Successful in 9m17s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s

- ThemeService: remove isPlatformBrowser guard so CSS custom properties
  are applied during SSR/prerender — server-rendered HTML now carries the
  correct color scheme and gradients (fixes missing colors on test site).

- main.ts: add provideClientHydration() so Angular preserves the server-
  rendered DOM on the client instead of destroying and rebuilding it
  (fixes jarring full-page reload between static and dynamic versions).

- Tests: update theme.service.spec.ts to match new behaviour, expand
  coverage with gradient tests and empty-value handling (9 tests).
  Fix structured-data.component.spec.ts import paths. All 54 tests pass.
This commit is contained in:
Hermes Agent committed 2026-08-06 03:24:57 +00:00
1 parent 2d26269c1a
commit e95a158d57
10 files changed
+551 -16

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
// Load the JIT compiler BEFORE any Angular router/common imports
import '@angular/compiler';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Injector } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { EventService } from './event.service';
import { firstValueFrom } from 'rxjs';
// Mock the app-config service
vi.mock('./app-config.service', () => ({
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
}));
describe('EventService — SSR error handling', () => {
let httpMock: any;
let service: EventService;
beforeEach(() => {
httpMock = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
const injector = Injector.create({
providers: [
{ provide: HttpClient, useValue: httpMock },
EventService,
],
});
service = injector.get(EventService);
});
it('should return empty array when API fails (SSR fallback)', async () => {
const { throwError } = await import('rxjs');
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
const result = await firstValueFrom(service.getEvents());
expect(result).toEqual([]);
});
it('should return empty array when API fails with active filter', async () => {
const { throwError } = await import('rxjs');
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
const result = await firstValueFrom(service.getEvents(true));
expect(result).toEqual([]);
});
it('should call the API endpoint', async () => {
const { of } = await import('rxjs');
httpMock.get.mockReturnValue(of([{ id: 1, name: 'Test Event' }]));
await firstValueFrom(service.getEvents());
expect(httpMock.get).toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
// Load the JIT compiler BEFORE any Angular router/common imports
import '@angular/compiler';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Injector } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ShowService } from './show.service';
import { firstValueFrom } from 'rxjs';
// Mock the app-config service
vi.mock('./app-config.service', () => ({
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
}));
describe('ShowService — SSR error handling', () => {
let httpMock: any;
let service: ShowService;
beforeEach(() => {
httpMock = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
const injector = Injector.create({
providers: [
{ provide: HttpClient, useValue: httpMock },
ShowService,
],
});
service = injector.get(ShowService);
});
it('should return empty array when API fails (SSR fallback)', async () => {
const { throwError } = await import('rxjs');
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
const result = await firstValueFrom(service.getShows());
expect(result).toEqual([]);
});
it('should call the API endpoint', async () => {
const { of } = await import('rxjs');
httpMock.get.mockReturnValue(of([{ id: 1, title: 'Test Show' }]));
await firstValueFrom(service.getShows());
expect(httpMock.get).toHaveBeenCalledWith('http://localhost:8000/api/shows');
});
});
+170
View File
@@ -0,0 +1,170 @@
// Load the JIT compiler BEFORE any Angular router/common imports
import '@angular/compiler';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { Injector } from '@angular/core';
import { ThemeService } from './theme.service';
import { StationConfig } from '../interfaces/station-config';
describe('ThemeService', () => {
let service: ThemeService;
const mockConfig: StationConfig = {
id: 1,
key: 'default',
callsign: 'KMOF',
name_primary: 'KMountain',
name_secondary: 'Flower Radio',
tagline: 'Community Radio',
frequency: '91.7',
founded_year: 2020,
nonprofit_type: '501c3',
ein: '12-3456789',
logo_url: 'https://kmtnflower.org/logo.png',
hero_background_url: '',
hero_icon_url: '',
hero_divider_url: '',
address: '',
phone: '',
email: 'info@kmtnflower.org',
website: 'kmtnflower.org',
donation_url: '',
stream_url: '',
stream_metadata_url: '',
play_store_icon_url: '',
play_store_url: '',
app_store_embed_html: '',
social_facebook: '',
social_twitter: '',
social_instagram: '',
social_youtube: '',
hero_subtitle: 'Your community-powered radio station.',
about_intro: 'About KMountain Flower Radio.',
about_donation_text: '',
schedule_intro: 'Our weekly schedule.',
events_intro: 'Community events.',
color_primary: '#4a90d9',
color_primary_light: '#6ba3eb',
color_primary_dark: '#2e6faa',
color_primary_muted: '#7baee7',
color_accent: '#e85d3a',
color_accent_light: '#eb7d5e',
color_accent_dark: '#c94422',
color_accent_muted: '#e98a72',
color_background: '#faf6f0',
color_text: '#333333',
color_success: '#4caf50',
color_danger: '#f44336',
color_info: '#2196f3',
color_white: '#ffffff',
color_light: '#eeeeee',
color_medium: '#9e9e9e',
color_black: '#000000',
} as const as StationConfig;
beforeEach(() => {
const injector = Injector.create({
providers: [ThemeService],
});
service = injector.get(ThemeService);
});
afterEach(() => {
// Clear all inline CSS custom properties after each test.
const root = document.documentElement.style;
for (const { prop } of [
{ prop: '--color-primary' },
{ prop: '--color-primary-light' },
{ prop: '--color-primary-dark' },
{ prop: '--color-primary-muted' },
{ prop: '--color-accent' },
{ prop: '--color-accent-light' },
{ prop: '--color-accent-dark' },
{ prop: '--color-accent-muted' },
{ prop: '--color-cream' },
{ prop: '--color-dark' },
{ prop: '--color-success' },
{ prop: '--color-danger' },
{ prop: '--color-info' },
{ prop: '--color-white' },
{ prop: '--color-light' },
{ prop: '--color-medium' },
{ prop: '--color-black' },
{ prop: '--sky-gradient' },
{ prop: '--sunset-gradient' },
{ prop: '--mixed-gradient' },
]) {
root.removeProperty(prop);
}
});
it('should apply all CSS color variables', () => {
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
service.apply(mockConfig);
// 17 color fields + 3 gradient fields = 20 setProperty calls
expect(setPropertySpy).toHaveBeenCalledTimes(20);
});
it('should set primary color CSS variable', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
});
it('should set accent color CSS variable', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
expect(rootStyle.getPropertyValue('--color-accent')).toBe('#e85d3a');
});
it('should set background color to --color-cream', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
expect(rootStyle.getPropertyValue('--color-cream')).toBe('#faf6f0');
});
it('should derive --sky-gradient from primary colors', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
const sky = rootStyle.getPropertyValue('--sky-gradient');
expect(sky).toContain('linear-gradient(135deg');
expect(sky).toContain('#2e6faa');
expect(sky).toContain('#4a90d9');
expect(sky).toContain('#6ba3eb');
});
it('should derive --sunset-gradient from accent colors', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
const sunset = rootStyle.getPropertyValue('--sunset-gradient');
expect(sunset).toContain('linear-gradient(135deg');
expect(sunset).toContain('#c94422');
expect(sunset).toContain('#e85d3a');
expect(sunset).toContain('#eb7d5e');
});
it('should derive --mixed-gradient from primary and accent', () => {
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
const mixed = rootStyle.getPropertyValue('--mixed-gradient');
expect(mixed).toContain('linear-gradient(135deg');
expect(mixed).toContain('#2e6faa');
expect(mixed).toContain('#4a90d9');
expect(mixed).toContain('#e85d3a');
});
it('should work on both browser and server platforms (no platform check)', () => {
// ThemeService no longer guards on isPlatformBrowser — it applies CSS vars
// during SSR so the server-rendered HTML carries the correct theme.
service.apply(mockConfig);
const rootStyle = document.documentElement.style;
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
});
it('should skip fields with empty string values', () => {
const emptyConfig = { ...mockConfig, color_primary: '' } as StationConfig;
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
service.apply(emptyConfig);
expect(setPropertySpy).not.toHaveBeenCalledWith('--color-primary', '');
});
});
+6 -7
View File
@@ -1,5 +1,4 @@
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Injectable } from '@angular/core';
import { StationConfig } from '../interfaces/station-config';
@@ -33,16 +32,16 @@ function gradient(from: string, mid: string, to: string): string {
providedIn: 'root',
})
export class ThemeService {
private platformId = inject(PLATFORM_ID);
/**
* Apply color theme from StationConfig as CSS custom properties on `:root`.
* Also generates derived gradient variables.
* Skips silently on the server (SSR) — CSS vars are set in the browser after hydration.
*
* Works on both browser and server platforms. During SSR the style properties
* are written to the virtual DOM and serialised into the HTML output so the
* prerendered / server-rendered page carries the correct colour scheme —
* eliminating the "white flash" before hydration.
*/
apply(config: StationConfig): void {
if (!isPlatformBrowser(this.platformId)) return;
const root = document.documentElement.style;
for (const { field, prop } of COLOR_MAP) {