From 1583b7035f8aaa7565a21e7aa8de313e97034feb Mon Sep 17 00:00:00 2001 From: miczek Date: Thu, 15 Oct 2026 05:32:13 +0300 Subject: [PATCH] fix(ssr): skip canvas/WebGL components on the server 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. --- server.ts | 12 ++++++++ src/app/app.component.ts | 28 +++++++++++++------ .../animated-counter.component.ts | 7 +++++ .../basketball-game.component.ts | 6 ++++ .../components/confetti/confetti.component.ts | 6 ++++ .../custom-cursor/custom-cursor.component.ts | 6 ++++ .../duck-shooting-game.component.ts | 6 ++++ .../loading-screen.component.ts | 6 ++++ .../magic-ball-game.component.ts | 6 ++++ .../particle-background.component.ts | 6 ++++ .../space-invaders.component.ts | 6 ++++ .../three-geometric.component.ts | 6 ++++ .../three-trophy/three-trophy.component.ts | 6 ++++ src/app/shared/utils/is-browser.ts | 7 +++++ 14 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 src/app/shared/utils/is-browser.ts diff --git a/server.ts b/server.ts index 1af5a3e..b4bb50a 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,17 @@ import 'dotenv/config'; 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 { CommonEngine } from '@angular/ssr/node'; import express from 'express'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ad89bad..7555bfb 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -6,6 +6,7 @@ import { LoadingScreenComponent } from './shared/components/loading-screen/loadi import { NavbarComponent } from './shared/components/navbar/navbar.component'; import { FooterComponent } from './shared/components/footer/footer.component'; import { GoalsService } from './core/services/goals.service'; +import { injectIsBrowser } from './shared/utils/is-browser'; import { filter, map, startWith } from 'rxjs/operators'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -21,17 +22,21 @@ import { toSignal } from '@angular/core/rxjs-interop'; FooterComponent, ], template: ` - - + @if (isBrowser) { + + + }
- + @if (isBrowser) { + + }
@if (showNavbarAndFooter()) { @@ -49,6 +54,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; }) export class AppComponent implements OnInit { title = 'goals-tracker'; + readonly isBrowser = injectIsBrowser(); loadingProgress = signal(0); loadingText = signal('Initializing...'); loadingComplete = signal(false); @@ -96,6 +102,12 @@ export class AppComponent implements OnInit { } ngOnInit(): void { + if (!this.isBrowser) { + // SSR: skip canvas/loading chrome; render content immediately. + this.loadingComplete.set(true); + return; + } + this.loadAllData(); // Track route changes for navigation loading diff --git a/src/app/shared/components/animated-counter/animated-counter.component.ts b/src/app/shared/components/animated-counter/animated-counter.component.ts index 62fcdf5..2bc899d 100644 --- a/src/app/shared/components/animated-counter/animated-counter.component.ts +++ b/src/app/shared/components/animated-counter/animated-counter.component.ts @@ -1,5 +1,6 @@ import { Component, Input, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; @Component({ selector: 'app-animated-counter', @@ -13,6 +14,8 @@ import { CommonModule } from '@angular/common'; styles: [], }) export class AnimatedCounterComponent implements OnInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @Input() value: number = 0; @Input() duration: number = 2000; @Input() decimals: number = 1; @@ -25,6 +28,10 @@ export class AnimatedCounterComponent implements OnInit, OnDestroy { private startValue: number = 0; ngOnInit(): void { + if (!this.isBrowser) { + this.displayValue = this.value; + return; + } this.startValue = this.displayValue; this.startTime = performance.now(); this.animate(); diff --git a/src/app/shared/components/basketball-game/basketball-game.component.ts b/src/app/shared/components/basketball-game/basketball-game.component.ts index 9ffd50e..98e70f4 100644 --- a/src/app/shared/components/basketball-game/basketball-game.component.ts +++ b/src/app/shared/components/basketball-game/basketball-game.component.ts @@ -7,6 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; @Component({ @@ -51,6 +52,8 @@ import * as THREE from 'three'; ], }) export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private scene!: THREE.Scene; @@ -77,6 +80,9 @@ export class BasketballGameComponent implements OnInit, AfterViewInit, OnDestroy ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createScene(); this.setupEventListeners(); diff --git a/src/app/shared/components/confetti/confetti.component.ts b/src/app/shared/components/confetti/confetti.component.ts index 4c895d2..91ec42b 100644 --- a/src/app/shared/components/confetti/confetti.component.ts +++ b/src/app/shared/components/confetti/confetti.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; interface ConfettiParticle { x: number; @@ -28,6 +29,8 @@ interface ConfettiParticle { ], }) export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private ctx!: CanvasRenderingContext2D; @@ -38,6 +41,9 @@ export class ConfettiComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } const canvas = this.canvasRef.nativeElement; this.ctx = canvas.getContext('2d')!; canvas.width = window.innerWidth; diff --git a/src/app/shared/components/custom-cursor/custom-cursor.component.ts b/src/app/shared/components/custom-cursor/custom-cursor.component.ts index 1d1a797..fbe556b 100644 --- a/src/app/shared/components/custom-cursor/custom-cursor.component.ts +++ b/src/app/shared/components/custom-cursor/custom-cursor.component.ts @@ -6,6 +6,7 @@ import { signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; @Component({ selector: 'app-custom-cursor', @@ -123,6 +124,8 @@ import { CommonModule } from '@angular/common'; ], }) export class CustomCursorComponent implements OnInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + x = 0; y = 0; ringX = 0; @@ -145,6 +148,9 @@ export class CustomCursorComponent implements OnInit, OnDestroy { ].join(','); ngOnInit(): void { + if (!this.isBrowser) { + return; + } this.animate(); this.setupClickableDetection(); } diff --git a/src/app/shared/components/duck-shooting-game/duck-shooting-game.component.ts b/src/app/shared/components/duck-shooting-game/duck-shooting-game.component.ts index efc7bb6..8948e25 100644 --- a/src/app/shared/components/duck-shooting-game/duck-shooting-game.component.ts +++ b/src/app/shared/components/duck-shooting-game/duck-shooting-game.component.ts @@ -7,6 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; interface Duck { @@ -96,6 +97,8 @@ interface Bullet { ], }) export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private scene!: THREE.Scene; @@ -119,6 +122,9 @@ export class DuckShootingGameComponent implements OnInit, AfterViewInit, OnDestr ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createScene(); this.setupEventListeners(); diff --git a/src/app/shared/components/loading-screen/loading-screen.component.ts b/src/app/shared/components/loading-screen/loading-screen.component.ts index 152e23e..0908fa0 100644 --- a/src/app/shared/components/loading-screen/loading-screen.component.ts +++ b/src/app/shared/components/loading-screen/loading-screen.component.ts @@ -9,6 +9,7 @@ import { signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; @Component({ @@ -68,6 +69,8 @@ import * as THREE from 'three'; ], }) export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; @Input() progress = signal(0); @Input() loadingText = signal('Loading...'); @@ -96,6 +99,9 @@ export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy } ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createParticles(); this.animate(); diff --git a/src/app/shared/components/magic-ball-game/magic-ball-game.component.ts b/src/app/shared/components/magic-ball-game/magic-ball-game.component.ts index 839b0fe..c6e99f5 100644 --- a/src/app/shared/components/magic-ball-game/magic-ball-game.component.ts +++ b/src/app/shared/components/magic-ball-game/magic-ball-game.component.ts @@ -7,6 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; interface Block { @@ -59,6 +60,8 @@ interface Block { ], }) export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private scene!: THREE.Scene; @@ -78,6 +81,9 @@ export class MagicBallGameComponent implements OnInit, AfterViewInit, OnDestroy ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createScene(); this.setupEventListeners(); diff --git a/src/app/shared/components/particle-background/particle-background.component.ts b/src/app/shared/components/particle-background/particle-background.component.ts index e68cc09..0e2a15d 100644 --- a/src/app/shared/components/particle-background/particle-background.component.ts +++ b/src/app/shared/components/particle-background/particle-background.component.ts @@ -7,6 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; @Component({ @@ -31,6 +32,8 @@ import * as THREE from 'three'; ], }) export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private scene!: THREE.Scene; @@ -61,6 +64,9 @@ export class ParticleBackgroundComponent implements OnInit, AfterViewInit, OnDes ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createParticles(); this.animate(); diff --git a/src/app/shared/components/space-invaders/space-invaders.component.ts b/src/app/shared/components/space-invaders/space-invaders.component.ts index ef196e5..294af18 100644 --- a/src/app/shared/components/space-invaders/space-invaders.component.ts +++ b/src/app/shared/components/space-invaders/space-invaders.component.ts @@ -7,6 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; interface Bullet { x: number; @@ -68,6 +69,8 @@ interface Enemy { ], }) export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; private ctx!: CanvasRenderingContext2D; @@ -88,6 +91,9 @@ export class SpaceInvadersComponent implements OnInit, AfterViewInit, OnDestroy ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } const canvas = this.canvasRef.nativeElement; this.ctx = canvas.getContext('2d')!; this.resizeCanvas(); diff --git a/src/app/shared/components/three-geometric/three-geometric.component.ts b/src/app/shared/components/three-geometric/three-geometric.component.ts index 5f45869..c4d10e4 100644 --- a/src/app/shared/components/three-geometric/three-geometric.component.ts +++ b/src/app/shared/components/three-geometric/three-geometric.component.ts @@ -8,6 +8,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; @Component({ @@ -28,6 +29,8 @@ import * as THREE from 'three'; ], }) export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; @Input() type: 'floating' | 'rings' | 'particles' = 'floating'; @Input() color: string = '#3b82f6'; @@ -41,6 +44,9 @@ export class ThreeGeometricComponent implements OnInit, AfterViewInit, OnDestroy ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createGeometry(); this.animate(); diff --git a/src/app/shared/components/three-trophy/three-trophy.component.ts b/src/app/shared/components/three-trophy/three-trophy.component.ts index 51d1ff3..b87ac82 100644 --- a/src/app/shared/components/three-trophy/three-trophy.component.ts +++ b/src/app/shared/components/three-trophy/three-trophy.component.ts @@ -8,6 +8,7 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { injectIsBrowser } from '../../utils/is-browser'; import * as THREE from 'three'; @Component({ @@ -35,6 +36,8 @@ import * as THREE from 'three'; ], }) export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly isBrowser = injectIsBrowser(); + @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; @Input() progress: number | null = null; @Input() size: number = 200; @@ -50,6 +53,9 @@ export class ThreeTrophyComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void {} ngAfterViewInit(): void { + if (!this.isBrowser) { + return; + } this.initThree(); this.createTrophy(); this.animate(); diff --git a/src/app/shared/utils/is-browser.ts b/src/app/shared/utils/is-browser.ts new file mode 100644 index 0000000..e8d6290 --- /dev/null +++ b/src/app/shared/utils/is-browser.ts @@ -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)); +}