fix(ssr): skip canvas/WebGL components on the server
continuous-integration/drone/push Build is passing

requestAnimationFrame/document are browser-only; Three.js trophies,
particles, cursor, and loading screen now no-op during SSR. Add a
Node rAF shim as a safety net.
This commit is contained in:
miczek committed 2026-10-15 05:32:13 +03:00
1 parent 0a30980105
commit 1583b7035f
14 files changed
+106 -8

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
import 'dotenv/config'; import 'dotenv/config';
import 'zone.js/node'; import 'zone.js/node';
// Node has no rAF; polyfill before Angular SSR bootstraps (belt + suspenders
// alongside browser-only guards in canvas components).
if (typeof globalThis.requestAnimationFrame !== 'function') {
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) =>
setTimeout(() => cb(Date.now()), 16)) as unknown as typeof requestAnimationFrame;
}
if (typeof globalThis.cancelAnimationFrame !== 'function') {
globalThis.cancelAnimationFrame = ((id: number) =>
clearTimeout(id)) as unknown as typeof cancelAnimationFrame;
}
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node'; import { CommonEngine } from '@angular/ssr/node';
import express from 'express'; import express from 'express';
+20 -8
View File
@@ -6,6 +6,7 @@ import { LoadingScreenComponent } from './shared/components/loading-screen/loadi
import { NavbarComponent } from './shared/components/navbar/navbar.component'; import { NavbarComponent } from './shared/components/navbar/navbar.component';
import { FooterComponent } from './shared/components/footer/footer.component'; import { FooterComponent } from './shared/components/footer/footer.component';
import { GoalsService } from './core/services/goals.service'; import { GoalsService } from './core/services/goals.service';
import { injectIsBrowser } from './shared/utils/is-browser';
import { filter, map, startWith } from 'rxjs/operators'; import { filter, map, startWith } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
@@ -21,17 +22,21 @@ import { toSignal } from '@angular/core/rxjs-interop';
FooterComponent, FooterComponent,
], ],
template: ` template: `
<app-loading-screen @if (isBrowser) {
[progress]="loadingProgress" <app-loading-screen
[loadingText]="loadingText" [progress]="loadingProgress"
[fadeOut]="loadingComplete" [loadingText]="loadingText"
></app-loading-screen> [fadeOut]="loadingComplete"
<app-custom-cursor></app-custom-cursor> ></app-loading-screen>
<app-custom-cursor></app-custom-cursor>
}
<div <div
class="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 relative overflow-hidden transition-opacity duration-500 flex flex-col" class="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50 to-purple-50 relative overflow-hidden transition-opacity duration-500 flex flex-col"
[class.opacity-0]="!loadingComplete()" [class.opacity-0]="isBrowser && !loadingComplete()"
> >
<app-particle-background></app-particle-background> @if (isBrowser) {
<app-particle-background></app-particle-background>
}
<div class="relative z-10 flex flex-col min-h-screen"> <div class="relative z-10 flex flex-col min-h-screen">
@if (showNavbarAndFooter()) { @if (showNavbarAndFooter()) {
<app-navbar></app-navbar> <app-navbar></app-navbar>
@@ -49,6 +54,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
title = 'goals-tracker'; title = 'goals-tracker';
readonly isBrowser = injectIsBrowser();
loadingProgress = signal(0); loadingProgress = signal(0);
loadingText = signal('Initializing...'); loadingText = signal('Initializing...');
loadingComplete = signal(false); loadingComplete = signal(false);
@@ -96,6 +102,12 @@ export class AppComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
if (!this.isBrowser) {
// SSR: skip canvas/loading chrome; render content immediately.
this.loadingComplete.set(true);
return;
}
this.loadAllData(); this.loadAllData();
// Track route changes for navigation loading // Track route changes for navigation loading
@@ -1,5 +1,6 @@
import { Component, Input, OnInit, OnDestroy } from '@angular/core'; import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
@Component({ @Component({
selector: 'app-animated-counter', selector: 'app-animated-counter',
@@ -13,6 +14,8 @@ import { CommonModule } from '@angular/common';
styles: [], styles: [],
}) })
export class AnimatedCounterComponent implements OnInit, OnDestroy { export class AnimatedCounterComponent implements OnInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@Input() value: number = 0; @Input() value: number = 0;
@Input() duration: number = 2000; @Input() duration: number = 2000;
@Input() decimals: number = 1; @Input() decimals: number = 1;
@@ -25,6 +28,10 @@ export class AnimatedCounterComponent implements OnInit, OnDestroy {
private startValue: number = 0; private startValue: number = 0;
ngOnInit(): void { ngOnInit(): void {
if (!this.isBrowser) {
this.displayValue = this.value;
return;
}
this.startValue = this.displayValue; this.startValue = this.displayValue;
this.startTime = performance.now(); this.startTime = performance.now();
this.animate(); this.animate();
@@ -7,6 +7,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
@Component({ @Component({
@@ -51,6 +52,8 @@ import * as THREE from 'three';
], ],
}) })
export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy { export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene; private scene!: THREE.Scene;
@@ -77,6 +80,9 @@ export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createScene(); this.createScene();
this.setupEventListeners(); this.setupEventListeners();
@@ -1,5 +1,6 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
interface ConfettiParticle { interface ConfettiParticle {
x: number; x: number;
@@ -28,6 +29,8 @@ interface ConfettiParticle {
], ],
}) })
export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy { export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
@@ -38,6 +41,9 @@ export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!; this.ctx = canvas.getContext('2d')!;
canvas.width = window.innerWidth; canvas.width = window.innerWidth;
@@ -6,6 +6,7 @@ import {
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
@Component({ @Component({
selector: 'app-custom-cursor', selector: 'app-custom-cursor',
@@ -123,6 +124,8 @@ import { CommonModule } from '@angular/common';
], ],
}) })
export class CustomCursorComponent implements OnInit, OnDestroy { export class CustomCursorComponent implements OnInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
x = 0; x = 0;
y = 0; y = 0;
ringX = 0; ringX = 0;
@@ -145,6 +148,9 @@ export class CustomCursorComponent implements OnInit, OnDestroy {
].join(','); ].join(',');
ngOnInit(): void { ngOnInit(): void {
if (!this.isBrowser) {
return;
}
this.animate(); this.animate();
this.setupClickableDetection(); this.setupClickableDetection();
} }
@@ -7,6 +7,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
interface Duck { interface Duck {
@@ -96,6 +97,8 @@ interface Bullet {
], ],
}) })
export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestroy { export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene; private scene!: THREE.Scene;
@@ -119,6 +122,9 @@ export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestr
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createScene(); this.createScene();
this.setupEventListeners(); this.setupEventListeners();
@@ -9,6 +9,7 @@ import {
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
@Component({ @Component({
@@ -68,6 +69,8 @@ import * as THREE from 'three';
], ],
}) })
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy { export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() progress = signal(0); @Input() progress = signal(0);
@Input() loadingText = signal('Loading...'); @Input() loadingText = signal('Loading...');
@@ -96,6 +99,9 @@ export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createParticles(); this.createParticles();
this.animate(); this.animate();
@@ -7,6 +7,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
interface Block { interface Block {
@@ -59,6 +60,8 @@ interface Block {
], ],
}) })
export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy { export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene; private scene!: THREE.Scene;
@@ -78,6 +81,9 @@ export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createScene(); this.createScene();
this.setupEventListeners(); this.setupEventListeners();
@@ -7,6 +7,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
@Component({ @Component({
@@ -31,6 +32,8 @@ import * as THREE from 'three';
], ],
}) })
export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy { export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private scene!: THREE.Scene; private scene!: THREE.Scene;
@@ -61,6 +64,9 @@ export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDes
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createParticles(); this.createParticles();
this.animate(); this.animate();
@@ -7,6 +7,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
interface Bullet { interface Bullet {
x: number; x: number;
@@ -68,6 +69,8 @@ interface Enemy {
], ],
}) })
export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy { export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
@@ -88,6 +91,9 @@ export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!; this.ctx = canvas.getContext('2d')!;
this.resizeCanvas(); this.resizeCanvas();
@@ -8,6 +8,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
@Component({ @Component({
@@ -28,6 +29,8 @@ import * as THREE from 'three';
], ],
}) })
export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy { export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() type: 'floating' | 'rings' | 'particles' = 'floating'; @Input() type: 'floating' | 'rings' | 'particles' = 'floating';
@Input() color: string = '#3b82f6'; @Input() color: string = '#3b82f6';
@@ -41,6 +44,9 @@ export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createGeometry(); this.createGeometry();
this.animate(); this.animate();
@@ -8,6 +8,7 @@ import {
AfterViewInit, AfterViewInit,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { injectIsBrowser } from '../../utils/is-browser';
import * as THREE from 'three'; import * as THREE from 'three';
@Component({ @Component({
@@ -35,6 +36,8 @@ import * as THREE from 'three';
], ],
}) })
export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy { export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly isBrowser = injectIsBrowser();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() progress: number | null = null; @Input() progress: number | null = null;
@Input() size: number = 200; @Input() size: number = 200;
@@ -50,6 +53,9 @@ export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {} ngOnInit(): void {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.initThree(); this.initThree();
this.createTrophy(); this.createTrophy();
this.animate(); this.animate();
+7
View File
@@ -0,0 +1,7 @@
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
/** True when running in a real browser (not during SSR). */
export function injectIsBrowser(): boolean {
return isPlatformBrowser(inject(PLATFORM_ID));
}