Cu SSR sau prerender, componentele tale rulează de două ori: o dată în Node — la fiecare request sau la build — ca să producă HTML, și încă o dată în browser, la hidratare. Constructorii, field initializer-ele, ngOnInit, effect(), serviciile, interceptorii și guard-urile rulează pe ambele platforme. Doar câteva API-uri sunt rezervate browserului: afterNextRender, afterEveryRender, afterRenderEffect și, evident, evenimentele utilizatorului.
Pe server nu există window, localStorage, layout sau observer-e. Există un document — emulat de Domino — dar numai prin inject(DOCUMENT). Codul scris „pentru browser” merge perfect în ng serve fără SSR și cade abia în build-ul cu server. Sau, mai rău, nu cade, dar trimite HTML greșit: temă greșită, date lipsă, fiecare request făcut de două ori.
- Pe server nu există
window,documentsaulocalStorage. Codul care are nevoie de ele rulează înafterNextRender. - Ce ai încărcat pe server ajunge în browser prin cache-ul HTTP de transfer, așa că
HttpClientnu repetă request-ul la hidratare. - Serverul trimite pagina abia când aplicația nu mai are task-uri în așteptare (
PendingTasks). Un timer care nu se oprește ține request-ul deschis. - O pagină inexistentă trebuie să răspundă cu statusul 404, nu doar să afișeze „negăsit”.
- Variabilele la nivel de modul sunt comune tuturor request-urilor de pe server. Starea unui utilizator stă în servicii, nu acolo.
Ce nu există pe server
Serverul Angular rulează în Node, cu un DOM emulat de Domino (inclus în @angular/platform-server). Distincția importantă: globalele de browser lipsesc complet și dau ReferenceError, iar documentul primit prin DI are structură, dar nu are layout. Tabelul e verificat pe Node 22/24 și pe Domino-ul din @angular/platform-server 22.2.2.
| API | Pe server | Ce folosești în schimb |
|---|---|---|
window, document (global), location, history | nu există: ReferenceError: window is not defined | inject(DOCUMENT), Router, Location |
localStorage, sessionStorage | nu există | afterNextRender; pentru ce contează la randare, un cookie citit din REQUEST |
matchMedia, innerWidth | lipsesc și de pe document.defaultView (fereastra Domino) | CSS media queries; BreakpointObserver din CDK (pe server, query-urile nu se potrivesc); afterNextRender |
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame | nu există | @defer (on viewport); afterNextRender + DestroyRef |
getBoundingClientRect(), offsetWidth, scrollHeight | elementele Domino nu le au (TypeError sau undefined) | afterNextRender({ read }) |
document.cookie | getter-ul aruncă Error: NotYetImplemented | inject(REQUEST)?.headers.get('cookie') |
navigator | există în Node 21+, dar e al serverului (userAgent: 'Node.js/22') | header-ele din REQUEST; isPlatformBrowser |
setTimeout, setInterval, fetch | există, dar timerele nu se opresc singure, iar fetch nu acceptă URL-uri relative | afterNextRender + DestroyRef; HttpClient |
Ce rulează unde
| Cod | Server | Browser |
|---|---|---|
field initializers, constructor, ngOnInit, ngOnChanges | da | da |
effect(), computed(), template-ul, pipe-urile | da | da |
| servicii, interceptori, guards, resolvers | da — instanțe noi la fiecare request | da |
ngOnDestroy, DestroyRef.onDestroy | da — serverul distruge aplicația după randare | da |
afterNextRender, afterEveryRender, afterRenderEffect | nu (NOOP) | da |
event listeners: (click), (input)… | nu — nimeni nu dă click pe server | da |
@defer fără hydrate | doar @placeholder | trigger-ele pornesc aici |
theme = 'light';
constructor() {
this.theme = localStorage.getItem('theme') ?? 'light';
}ReferenceError: localStorage is not definedDe ce: Constructorul rulează și pe server, iar în Node nu există localStorage. Chiar dacă ar exista, serverul n-ar avea acces la storage-ul utilizatorului.
readonly theme = signal('light');
constructor() {
afterNextRender(() =>
this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// sau: cookie citit pe server din inject(REQUEST)ngOnInit sau effect(). Tot ce atinge DOM-ul, layout-ul, storage-ul sau o bibliotecă de browser merge în afterNextRender / afterRenderEffect — sau în spatele unui serviciu cu implementare separată pentru server.afterNextRender și fazele
afterNextRender și afterEveryRender (stabile din v20, când afterRender a fost redenumit afterEveryRender) înregistrează callback-uri care rulează după ce Angular a terminat de randat — doar în browser. Pe server sunt NOOP: callback-ul nici măcar nu se înregistrează. De aceea sunt locul implicit pentru orice cod care atinge DOM-ul, layout-ul sau API-urile de fereastră. Trebuie apelate într-un context de injecție (constructor, field initializer) sau cu opțiunea injector, și se curăță singure la distrugerea componentei.
Le poți da o funcție sau un obiect cu faze. Fazele rulează în ordinea earlyRead → write → mixedReadWrite → read, iar fiecare primește valoarea întoarsă de faza anterioară. Separarea există ca Angular să grupeze citirile și scrierile din toată aplicația: o citire de layout după o scriere obligă browserul să recalculeze layout-ul pe loc (layout thrashing). O funcție simplă rulează în mixedReadWrite, faza pe care documentația te roagă s-o eviți.
| Faza | Ai voie | Exemplu |
|---|---|---|
earlyRead | doar citiri, înaintea scrierilor | măsori ceva de care depinde o scriere (layout custom) |
write | doar scrieri | stiluri, inițializarea unei biblioteci |
mixedReadWrite | citiri și scrieri amestecate | doar dacă munca nu se poate împărți |
read | doar citiri, după scrieri | măsori rezultatul final și îl pui într-un semnal |
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';
/** Micșorează titlul până încape pe un rând. Pe server nu se întâmplă nimic. */
@Component({
selector: 'app-fit-title',
template: `<h2 #title class="fit">{{ text() }}</h2>`,
})
export class FitTitle {
readonly text = input.required<string>();
private readonly title = viewChild.required<ElementRef<HTMLElement>>('title');
constructor() {
// O singură dată, după prima randare, DOAR în browser (pe server e NOOP).
afterNextRender({
// 1. earlyRead: citește layout-ul înaintea oricărei scrieri
earlyRead: () => {
const el = this.title().nativeElement;
return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = încape
},
// 2. write: primește rezultatul fazei anterioare și doar scrie
write: (ratio) => {
this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
},
});
}
}| Expresie | Aici (live) | Pe server ar fi |
|---|---|---|
inject(PLATFORM_ID) | 'server' | 'server' |
isPlatformBrowser(id) | false | false |
isPlatformServer(id) | true | true |
inject(REQUEST) | null | Request (url, headers, cookie) |
inject(RESPONSE_INIT) | null | { status, headers } |
- +0.1 msconstructorisConnected=false, lățime=0px, copii=0, binding=null
- +0.2 msngOnInitisConnected=true, lățime=0px, copii=0, binding=""
Pe server ar rula doar rândurile constructor și ngOnInit (fără layout: lățimile n-ar exista); afterNextRender și afterEveryRender sunt ignorate.
afterEveryRender sau afterRenderEffect
afterEveryRender rulează după fiecare randare a aplicației, inclusiv când s-a schimbat ceva într-o cu totul altă componentă. Dacă munca ta depinde de niște semnale, afterRenderEffect e varianta mai ieftină: un effect care rulează după randare doar când dependențele lui s-au schimbat (și tot doar în browser). Diferența față de effect(): effect rulează și pe server și nu are faze de citire/scriere.
import { Component, ElementRef, afterRenderEffect, input, viewChild } from '@angular/core';
@Component({
selector: 'app-chat-log',
template: `
<div #scroller class="messages">
@for (m of messages(); track m.id) { <p>{{ m.text }}</p> }
</div>
`,
})
export class ChatLog {
readonly messages = input.required<readonly { id: number; text: string }[]>();
private readonly scroller = viewChild.required<ElementRef<HTMLElement>>('scroller');
constructor() {
// ✗ ar rula după FIECARE randare a aplicației, chiar dacă lista nu s-a schimbat:
// afterEveryRender({ write: () => this.scrollToBottom() });
// ✓ rulează după randare doar când semnalele citite s-au schimbat (tot doar în browser)
afterRenderEffect({
write: () => {
this.messages(); // dependență
this.scrollToBottom();
},
});
}
private scrollToBottom(): void {
const el = this.scroller().nativeElement;
el.scrollTop = el.scrollHeight;
}
}afterNextRender. Citește și scrie DOM-ul cu grijă și nu muta noduri pe care le randează Angular — altfel apar erorile de nepotrivire descrise în Hidratare.Biblioteci care ating window
Multe biblioteci (hărți, grafice, editoare rich-text) accesează window chiar la import. Serverul cade când încarcă modulul, înainte să ruleze vreo componentă, și nicio verificare din constructor nu te mai salvează. Soluția e import() dinamic în afterNextRender; bonus, biblioteca ajunge într-un chunk separat. Pentru componente întregi, @defer face același lucru declarativ.
import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet'; — modulul atinge window încă de la import:
// serverul cade când încarcă fișierul, înainte să ruleze vreo componentă.
@Component({
selector: 'app-store-map',
template: `<div #host class="map" aria-label="Harta magazinelor"></div>`,
// înălțimea fixă rezervă spațiul și în HTML-ul de pe server → fără layout shift
styles: `.map { height: 320px; }`,
})
export class StoreMap {
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private readonly destroyRef = inject(DestroyRef); // inject() doar sincron, nu după await
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // chunk separat, descărcat doar în browser
if (this.destroyRef.destroyed) return; // utilizatorul a plecat între timp
const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
this.destroyRef.onDestroy(() => map.remove());
});
}
}Dacă biblioteca rescrie DOM-ul pe care l-a randat Angular, hidratarea se plânge de nepotriviri. ngSkipHydration pe componenta-gazdă oprește hidratarea doar pentru ea — e un plasture, nu o soluție.
DOCUMENT, PLATFORM_ID și provideri pe platformă
inject(DOCUMENT) — din @angular/core începând cu v20; @angular/common îl reexportă — îți dă documentul curent pe orice platformă. Pe server e documentul Domino al request-ului: poți citi și scrie structura (title, <meta>, <link rel="canonical">, atribute pe <html>), iar modificările ajung în HTML-ul trimis. Nu ai layout, cookie-uri sau matchMedia.
import { DOCUMENT, Service, inject } from '@angular/core';
@Service()
export class CanonicalLink {
private readonly document = inject(DOCUMENT); // pe server: documentul Domino al request-ului
/** Rulează și pe server: tag-ul ajunge în HTML-ul văzut de crawlere. */
set(url: string): void {
let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.rel = 'canonical';
this.document.head.appendChild(link);
}
link.href = url;
}
}
// Pentru <title> și <meta> există deja servicii SSR-safe în @angular/platform-browser:
// inject(Title).setTitle('Telefon X — Magazin');
// inject(Meta).updateTag({ name: 'description', content: '…' });inject(PLATFORM_ID) întoarce 'browser' sau 'server' și îl testezi cu isPlatformBrowser / isPlatformServer din @angular/common. Merge, dar ghidul oficial recomandă providerii pe platformă în locul verificărilor la runtime, din două motive: verificările se înmulțesc prin tot codul, iar folosite într-un @if din template produc conținut diferit pe server și în browser, adică layout shift și erori de hidratare. Ține-l pentru efecte secundare care nu schimbă ce se randează.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Service, inject } from '@angular/core';
@Service()
export class Analytics {
// PLATFORM_ID e 'browser' sau 'server'; nu-l compara manual, folosește helper-ele din @angular/common
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
track(event: string): void {
if (!this.isBrowser) return; // ok: un efect secundar care nu schimbă ce se randează
navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
}
}
// ✗ NU așa — server și browser randează conținut diferit → layout shift și erori de hidratare:
// @if (isBrowser) { <app-recommendations /> } @else { <p>Se încarcă…</p> }
// ✓ folosește @defer (on viewport) sau afterNextRender pentru ce e doar de browser.Când platformele au nevoie de comportament diferit, varianta curată e un contract cu două implementări: componentele injectează clasa abstractă, app.config.ts dă implementarea de browser, iar app.config.server.ts o suprascrie. Niciun if în codul de business — același principiu ca în Providers și Injection tokens.
import { Injectable } from '@angular/core';
// Contractul: componentele depind doar de clasa abstractă.
export abstract class AnalyticsService {
abstract trackEvent(name: string): void;
}
@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
trackEvent(name: string): void {
navigator.sendBeacon('/collect', name); // API de browser — aici e voie
}
}
@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
trackEvent(_name: string): void {
// pe server: nimic (sau un log), fără API-uri de browser
}
}TransferState
Fără nimic special, o pagină SSR își încarcă datele de două ori: o dată pe server, ca să randeze HTML-ul, și încă o dată în browser, pentru că la hidratare componentele pornesc de la zero, cu starea goală. Rezultatul: un request în plus și conținutul de pe server înlocuit pentru o clipă de un skeleton.
TransferState e un dicționar cheie → valoare care călătorește în HTML. Pe server scrii în el; înainte de serializare Angular apelează toJson() și pune rezultatul într-un <script id="ng-state" type="application/json"> la finalul lui body — doar dacă nu e gol. În browser, la prima injectare, TransferState citește scriptul înapoi. Cheile se creează cu makeStateKey<T>('…'), care leagă și tipul valorii. API-ul complet: get(key, default), set, remove, hasKey, isEmpty, onSerialize, toJson.
import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, Service, TransferState, inject, makeStateKey } from '@angular/core';
export interface Article { slug: string; title: string; body: string }
// Cheie tipizată: TransferState știe că sub ea stă un Article
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);
@Service()
export class ArticleStore {
private readonly cms = inject(CmsClient); // SDK cu fetch propriu → NU trece prin HttpClient
private readonly state = inject(TransferState);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
async load(slug: string): Promise<Article> {
const key = articleKey(slug);
const cached = this.state.get(key, null);
if (cached) {
this.state.remove(key); // folosită o singură dată: navigările ulterioare cer date proaspete
return cached;
}
const article = await this.cms.getArticle(slug);
if (this.isServer) this.state.set(key, article); // ajunge în <script id="ng-state">
return article;
}
}HTML trimis de server
Nimic încă. Pornește randarea pe server.
Ce vede utilizatorul
/api/products/3Valorile trec prin JSON.stringify / JSON.parse: un Date ajunge string, Map și Set devin {}, instanțele de clasă își pierd metodele. Transferă DTO-uri simple (vezi Interfețe & modele). Tot ce pui acolo e vizibil în sursa paginii și îngreunează HTML-ul: demo-ul îți arată câte caractere adaugă.
HttpClient sunt transferate automat (secțiunea următoare). TransferState manual e pentru date care nu trec prin HttpClient: SDK-uri (Firebase, un CMS), fetch direct, calcule scumpe făcute o dată pe server. Pentru resurse, din v22 există și resource({ id }).Cache-ul HTTP de transfer
provideClientHydration() pornește implicit un interceptor care aplică exact pattern-ul de mai sus pentru HttpClient. Pe server salvează răspunsurile în TransferState, sub o cheie calculată ca hash SHA-256 din metodă, tipul răspunsului, URL, body și parametri. În browser le servește din cache — dar doar până când aplicația devine stabilă prima dată; după aceea totul merge la rețea, ca de obicei. httpResource trece prin HttpClient, deci beneficiază automat, la fel și interceptorii tăi (vezi HTTP).
În 22.2.2 intră implicit în cache doar request-urile GET și HEAD care:
- nu au header-e
Authorization,Proxy-AuthorizationsauCookie; - nu sunt trimise cu
withCredentialssau cucredentials: 'include'/'same-origin'; - nu au
Cache-Controlcuno-store,no-cachesauprivate(nici request-ul, nici răspunsul) și nu folosesccache: 'no-store'/'no-cache'; - al căror răspuns nu conține
Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// Cache-ul de transfer e PORNIT implicit de provideClientHydration();
// withHttpTransferCacheOptions doar îl reglează.
provideClientHydration(
withHttpTransferCacheOptions({
// ce header-e ale răspunsului se salvează (implicit: niciunul)
includeHeaders: ['ETag'],
// false → requestul nu intră în cache (date per utilizator, date care se schimbă des)
filter: (req) => !req.url.includes('/api/cart'),
// POST-uri de citire (ex. GraphQL) — doar dacă sunt idempotente
includePostRequests: true,
// implicit false: request-urile cu Authorization / Proxy-Authorization / Cookie sunt sărite
includeRequestsWithAuthHeaders: false,
}),
),
],
};| Opțiune | Implicit | Când o schimbi |
|---|---|---|
filter(req) | toate cererile eligibile | scoți endpoint-uri per utilizator sau foarte volatile (false = nu se cache-uiește) |
includeHeaders | niciun header | browserul are nevoie de ETag, Link… Niciodată header-e sensibile |
includePostRequests | false | POST folosit pentru citiri (GraphQL), doar dacă e idempotent |
includeRequestsWithAuthHeaders | false | header-ul de auth nu schimbă răspunsul (o cheie publică) |
includeRequestsWithCredentials | false | răspunsurile cu credentials sunt sigur publice |
includeNonCacheableRequests | false | ignori conștient Cache-Control / Set-Cookie (rar) |
Resurse care nu folosesc HttpClient v22
Din v22, resource() și rxResource() acceptă id. Valoarea rezolvată pe server e scrisă în TransferState sub acel id, iar în browser resursa pornește direct în starea resolved, fără să mai apeleze loader-ul — tot doar cât e activ cache-ul de hidratare, adică până la prima stabilitate, și doar cu provideClientHydration() în configurație.
import { Component, inject, resource } from '@angular/core';
@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
private readonly sdk = inject(WeatherSdk); // nu e HttpClient → cache-ul HTTP nu-l vede
// v22: id → valoarea rezolvată pe server e pusă în TransferState și refolosită la hidratare.
// id-ul trebuie să fie unic pe pagină și identic pe server și în browser.
protected readonly forecast = resource({
id: 'weather-bucharest',
loader: () => this.sdk.forecast('Bucharest'),
});
}Origini diferite pe server și în browser
Dacă serverul vorbește cu API-ul pe o adresă internă, iar browserul pe cea publică, URL-urile diferă și cheile din cache nu se mai potrivesc. HTTP_TRANSFER_CACHE_ORIGIN_MAP (din @angular/common/http) îi spune serverului să calculeze cheia ca și cum ar fi folosit originea publică. Se declară doar în configurația de server: dacă îl găsește în browser, Angular aruncă eroarea NG02803.
// api-base-url.ts — în browser: originea publică
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
factory: () => 'https://api.magazin.ro',
});
// app.config.server.ts (fragment) — pe server: rețeaua internă, fără drum prin load balancer
providers: [
provideServerRendering(withRoutes(serverRoutes)),
{ provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
// aceleași căi pe ambele origini → cheile din cache-ul de transfer se potrivesc
// (doar în configurația de server; în browser Angular aruncă eroare dacă îl găsește)
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
},
];Încă un detaliu din v22, când HttpClient folosește implicit backend-ul fetch: pe server, fiecare body de răspuns e limitat la 1 MB (altfel eroarea NG02825). Limita se schimbă cu provideServerRendering({ maxResponseBodySize }, withRoutes(…)), dar ghidul recomandă s-o ții cât mai mică: descărcările mari nu au ce căuta în randarea pe server.
Stabilitate și PendingTasks
Serverul nu știe când e „gata” pagina ta. Regula lui e simplă: după bootstrap așteaptă ApplicationRef.whenStable() și abia apoi serializează DOM-ul și TransferState. Aplicația e stabilă când nu mai e niciun task în PendingTasks. Angular adaugă singur task-uri pentru request-urile HttpClient în curs, pentru navigarea router-ului și pentru resursele care încarcă (resource, httpResource). Cu Zone.js, orice timer sau microtask din zona Angular ține și el aplicația instabilă.
Aplicațiile zoneless — implicite din v21 — nu mai văd timerele și promisiunile tale. Dacă încarci date cu un SDK propriu, cu fetch sau după un setTimeout, serverul serializează înainte să vină datele: HTML-ul pleacă gol, iar browserul le cere din nou. Le faci vizibile cu PendingTasks (stabil din v20; metoda run și operatorul pendingUntilEvent sunt încă developer preview):
import { Component, PendingTasks, inject, signal } from '@angular/core';
import { pendingUntilEvent, toSignal } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-price', template: `{{ price() }} · {{ stock() }}` })
export class Price {
private readonly tasks = inject(PendingTasks);
private readonly sdk = inject(PricingSdk); // promisiuni proprii, nu HttpClient
readonly price = signal<string | null>(null);
// 1. run(): stabilitatea așteaptă până se termină funcția async (developer preview)
constructor() {
this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
}
// 2. add(): control manual — NU uita cleanup-ul, altfel serverul nu mai răspunde
async refresh(): Promise<void> {
const done = this.tasks.add();
try {
this.price.set(await this.sdk.priceOf(3));
} finally {
done();
}
}
// 3. pendingUntilEvent(): instabil până la prima emisie / complete / eroare (developer preview)
readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}whenStable()): <p class="price"></p>
PendingTasks nu e eliminat niciodată (un add() fără finally, un Observable care nu emite cu pendingUntilEvent), răspunsul atârnă până îl taie proxy-ul. Același lucru face, cu Zone.js, un setInterval pornit la inițializare. Polling-ul, websocket-urile și timerele recurente nu au ce căuta pe server: pornește-le în afterNextRender.import { provideStabilityDebugging } from '@angular/core';
// Activ implicit în dev mode când folosești provideClientHydration().
// Manual: pentru SSR fără hidratare sau într-un build de producție, temporar.
bootstrapApplication(App, {
providers: [provideStabilityDebugging()],
});
// Dacă aplicația nu devine stabilă în ~9 s, afișează în consolă task-urile din PendingTasks
// care o țin pe loc. Cu Zone.js, importă și 'zone.js/plugins/task-tracking' pentru stack-uri.Request, response și status 404
@angular/core exportă trei tokeni pentru request-ul curent. @angular/ssr îi completează doar la randarea la cerere (RenderMode.Server); în browser, la prerender, la build și la extragerea rutelor în dev sunt null — deci întotdeauna ?..
| Token | Conține | Folosit pentru |
|---|---|---|
REQUEST | Request din Web API: url, headers | cookie-uri, Accept-Language, URL absolut pentru canonical / Open Graph |
RESPONSE_INIT | ResponseInit mutabil: status, statusText, headers | 404 / 410 dinamic, header-e calculate la randare |
REQUEST_CONTEXT | unknown: al doilea argument din handle(request, context) | tenant, request id, utilizatorul autentificat deja de un middleware |
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-product-missing',
template: `<h1>Produsul nu mai există</h1><a href="/produse">Vezi catalogul</a>`,
})
export class ProductMissing {
constructor() {
// null în browser, la prerender (SSG) și la build — de aceea ?.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // altfel: „soft 404” indexat ca pagină reală
}
}De ce contează statusul: o pagină „Produsul nu există” servită cu 200 e un soft 404, pe care motoarele de căutare o pot indexa ca pe o pagină reală. status din ServerRoute acoperă cazurile statice (ruta **); pentru „id-ul nu există în baza de date” ai nevoie de RESPONSE_INIT. Merge pentru că @angular/ssr construiește răspunsul abia după whenStable(), deci până atunci poți modifica statusul.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';
@Service()
export class RequestInfo {
private readonly request = inject(REQUEST); // Request din Web API; null în browser / SSG
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
/** Limba preferată: header-ul pe server, navigator în browser. */
preferredLanguage(): string {
if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'ro';
// fără request: browser SAU prerender. NU testa „typeof navigator” — Node 21+ îl are.
return this.isBrowser ? navigator.language : 'ro';
}
cookie(name: string): string | null {
const raw = this.request?.headers.get('cookie') ?? '';
const match = raw.split('; ').find((c) => c.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
}
}URL-uri relative și absolute
Pe server nu există o „pagină curentă” față de care să se rezolve un URL relativ. HttpClient rezolvă singur problema: @angular/platform-server adaugă un interceptor care transformă /api/… în URL absolut, pe baza URL-ului request-ului și a lui <base href>. fetch() direct și SDK-urile care îl folosesc nu beneficiază de asta.
// ✓ HttpClient pe server: URL-ul relativ e rezolvat față de URL-ul request-ului curent
this.http.get('/api/products'); // → https://magazin.ro/api/products
// ✗ fetch() direct (sau un SDK care îl folosește) — Node nu are „pagină curentă”:
await fetch('/api/products'); // TypeError: Failed to parse URL from /api/products
// ✓ dacă trebuie fetch(): construiește URL-ul absolut din request
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));Tocmai pentru că URL-urile se construiesc din host-ul request-ului, AngularNodeAppEngine verifică hostname-ul față de allowedHosts (în angular.json, la build.options.security.allowedHosts, sau prin variabila NG_ALLOWED_HOSTS) și răspunde 400 pentru un host necunoscut. E protecția împotriva SSRF: configureaz-o cu domeniile tale.
Stare scursă între request-uri
În browser, un modul JavaScript e evaluat o dată per tab, adică per utilizator. Pe server, același modul e evaluat o dată per proces și servește mii de request-uri. Orice variabilă la nivel de modul devine stare comună tuturor utilizatorilor. Un provider cu useValue are aceeași problemă: obiectul e creat la încărcarea modulului și trăiește până la restartul serverului — ghidul SSR o spune explicit și recomandă useFactory.
Serviciile sunt sigure: la fiecare request, @angular/ssr creează o platformă și o aplicație noi, deci un injector root nou. Fiecare utilizator primește instanțe proprii, distruse după răspuns.
// ✗ Starea la nivel de modul: fișierul e evaluat O SINGURĂ DATĂ per proces Node,
// deci variabilele sunt comune TUTUROR request-urilor (și tuturor utilizatorilor).
let currentUser: User | null = null; // utilizatorul B îl vede pe A
const cart = new Map<number, number>(); // coșul crește cu fiecare request
// ✗ La fel cu useValue: obiectul e creat o dată, la încărcarea modulului
{ provide: CART_STATE, useValue: { items: [] } }
// ✓ Un serviciu: pe server fiecare request bootstrap-ează o aplicație nouă,
// deci primește o instanță nouă (și e distrusă după răspuns).
@Service()
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
}
// ✓ Sau useFactory: rulează pentru fiecare aplicație creată
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }const cache = new Map() la nivel de modul pare o optimizare. Pe server e o scurgere de memorie (crește la nesfârșit) și, dacă cheia nu include utilizatorul, o scurgere de date între conturi. Dacă vrei cache între request-uri, fă-l explicit — în server.ts sau la CDN — cu chei, TTL și limită de mărime.Studiu de caz: tema
Tema e exemplul clasic: preferința stă în localStorage, iar CSS-ul depinde de un atribut pe <html>. Acest site e SPA, fără SSR, deci ThemeStore-ul lui e corect aici. Dar e un exercițiu bun: dacă am porni SSR mâine, ce s-ar strica?
// ThemeStore-ul acestui site (fragment). Site-ul e SPA, deci e corect AICI.
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
// ✗ pe server: defaultView e fereastra Domino, care NU are matchMedia → TypeError
private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
// readStoredMode() citește localStorage într-un try/catch: pe server nu cade,
// dar întoarce mereu 'system' — serverul nu poate ști ce a ales utilizatorul.
readonly mode = signal<ThemeMode>(readStoredMode());
constructor() {
this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
effect(() => {
// ✓ DOM prin DOCUMENT: merge și pe server
// localStorage în try/catch: pe server e ignorat în tăcere
});
}
}Două probleme. Prima: linia cu matchMedia aruncă la crearea serviciului, iar ?. nu ajută, fiindcă pe server defaultView există (fragmentul defaultView?.matchMedia(…) din primul demo reproduce exact asta). A doua: serverul randează mereu tema system, pentru că nu vede localStorage. Varianta SSR-safe mută preferința într-un cookie, pe care serverul îl citește din REQUEST, iar effect()-ul scrie atributul și pe server:
import { isPlatformBrowser } from '@angular/common';
import {
DOCUMENT, DestroyRef, PLATFORM_ID, REQUEST, Service,
afterNextRender, computed, effect, inject, signal,
} from '@angular/core';
export type ThemeMode = 'system' | 'light' | 'dark';
const COOKIE = 'atlas-theme';
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
// Serverul nu știe dacă sistemul e dark: pentru 'system' decide CSS-ul
// (prefers-color-scheme), iar semnalul se corectează în browser.
private readonly systemDark = signal(false);
readonly mode = signal<ThemeMode>(this.readCookie());
readonly isDark = computed(
() => this.mode() === 'dark' || (this.mode() === 'system' && this.systemDark()),
);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const media = matchMedia('(prefers-color-scheme: dark)'); // doar în browser
const onChange = (e: MediaQueryListEvent) => this.systemDark.set(e.matches);
this.systemDark.set(media.matches);
media.addEventListener('change', onChange);
destroyRef.onDestroy(() => media.removeEventListener('change', onChange));
});
effect(() => {
const mode = this.mode();
// rulează și pe server → <html data-theme="dark"> pleacă deja în HTML, fără flash
const root = this.document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
// cookie (nu localStorage): îl primește serverul la următorul request
if (this.isBrowser) {
this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
}
});
}
private readCookie(): ThemeMode {
const raw = this.request?.headers.get('cookie') // server: header-ul request-ului
?? (this.isBrowser ? this.document.cookie : ''); // browser (pe server, cookie aruncă)
const value = raw
.split('; ')
.find((c) => c.startsWith(`${COOKIE}=`))
?.slice(COOKIE.length + 1);
return value === 'light' || value === 'dark' ? value : 'system';
}
}Alternativa fără cookie: păstrezi localStorage și un script inline în index.html, care setează data-theme înainte de prima afișare — exact ce face acest site. Merge și cu SSR (serverul trimite HTML fără atribut, scriptul îl pune în browser înaintea aplicației), dar serverul tot nu știe tema, deci nu poate alege, de exemplu, imaginile sau theme-color potrivite.
<!-- Alternativa fără cookie (ce face acest site): un script inline în <head>,
care rulează înainte de prima afișare, în orice mod de randare (CSR, SSR, prerender). -->
<script>
try {
var t = localStorage.getItem('atlas-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>Când DA, când NU
Cod SSR-safe nu înseamnă if (isBrowser) peste tot, ci fiecare lucru pus în locul potrivit.
afterNextRender/afterRenderEffectpentru tot ce atinge DOM-ul, layout-ul, storage-ul sau o bibliotecă de browser.inject(DOCUMENT),Title,Metapentru tot ce trebuie să fie în HTML-ul de pe server (SEO, share).- Provideri pe platformă în
app.config.server.tscând serverul și browserul fac lucruri diferite. - Cache-ul HTTP implicit pentru GET-uri publice;
TransferStatemanual sauresource({ id })pentru date care nu trec prinHttpClient. PendingTaskssauresource()pentru orice async al cărui rezultat trebuie să fie în HTML.RESPONSE_INITpentru status-uri dinamice,REQUESTpentru cookie-uri și limbă.
isPlatformBrowserîntr-un@ifdin template →@defersauafterNextRender, cu același HTML pe ambele platforme.typeof windowpresărat prin componente → un serviciu cu implementare separată pentru server.TransferStatepentru liste mari sau date per utilizator pe pagini cache-uite la CDN → încarcă-le în browser.PendingTaskspentru polling, websocket, analytics → pornește-le înafterNextRender; serverul nu trebuie să le aștepte. Un exemplu complet e pe pagina WebSockets.- Cache HTTP pentru răspunsuri sensibile →
filtersautransferCache: false. ngSkipHydrationca soluție permanentă → repară manipularea directă a DOM-ului.
Capcane frecvente
effect care scrie în localStorage, apelează matchMedia sau pornește un observer cade la randare. Ce ține de browser merge în afterRenderEffect sau sub afterNextRender.document.defaultView?.matchMedia(…) și typeof navigator !== 'undefined' par sigure, dar pe server defaultView există (fereastra Domino, fără matchMedia), iar Node 21+ are navigator. Primul aruncă TypeError, al doilea dă răspunsul greșit. Folosește afterNextRender sau isPlatformBrowser.- Măsurători în constructor sau
ngOnInit: pe server cad (nu există layout). În browser, constructorul măsoară 0, iar înngOnInitbinding-urile și blocurile@if/@forale componentei nu sunt încă randate. Fix:afterNextRender({ read }). - Timere și subscripții pornite în constructor: cu Zone.js blochează serializarea, zoneless rămân pornite. Fix: pornește-le în
afterNextRenderși oprește-le cuDestroyRef. pendingTasks.add()fărăfinally: o excepție și request-ul nu se mai termină. Fix:try/finallysaurun().- Async neurmărit în zoneless:
fetch, SDK-uri,setTimeout→ HTML gol + încă un request în browser. Fix:resource()sauPendingTasks. - Chei
TransferStategenerice ('data') sau unresource({ id })static într-o componentă folosită de două ori pe pagină: valorile se suprascriu. Fix: chei care includ parametrii (article:${slug}), id-uri unice pe pagină. - Cheia nu e ștearsă după citire: o navigare ulterioară pe aceeași pagină primește datele vechi din HTML. Fix:
state.remove(key)după prima folosire. HTTP_TRANSFER_CACHE_ORIGIN_MAPînapp.config.ts: eroare NG02803 în browser. Fix: doar înapp.config.server.ts.fetch('/api/…')relativ pe server:TypeError: Failed to parse URL. Fix:HttpClientsau URL absolut construit dinREQUEST.- Pagina de „nu există” cu status 200: soft 404 în Google. Fix:
RESPONSE_INITsaustatus: 404pe ruta**. - Valori care diferă între server și browser:
Date.now(),Math.random(), date formatate în fusul orar al serverului (adesea UTC). Textul se schimbă la hidratare. Fix: generează-le o dată pe server și transferă-le, sau afișează-le abia în browser. - Stare la nivel de modul și
useValuecu obiecte: partajate între utilizatori. Fix: servicii sauuseFactory.
Fișă rapidă
| Vrei să… | Folosește |
|---|---|
| atingi DOM-ul / layout-ul o dată, după randare | afterNextRender({ earlyRead, write, read }) |
| sincronizezi DOM-ul cu niște semnale | afterRenderEffect(() => …) |
| măsori după fiecare randare | afterEveryRender({ read }) |
încarci o bibliotecă care atinge window | await import('lib') în afterNextRender, sau @defer |
citești / scrii title, meta, canonical | inject(DOCUMENT), Title, Meta |
| altă implementare pe server | clasă abstractă + provider în app.config.server.ts (mergeApplicationConfig) |
| un efect secundar doar în browser | isPlatformBrowser(inject(PLATFORM_ID)) |
| să nu repeți GET-urile în browser | provideClientHydration() (implicit), withHttpTransferCacheOptions(…) |
| scoți un request din cache | filter sau { transferCache: false } |
transferi date care nu vin prin HttpClient | makeStateKey<T>() + TransferState, sau resource({ id }) (v22) |
| origini API diferite pe server și în browser | HTTP_TRANSFER_CACHE_ORIGIN_MAP, doar pe server |
| ca serverul să aștepte un async | resource(), PendingTasks.run() / add(), pendingUntilEvent() |
| afli de ce aplicația nu devine stabilă | provideStabilityDebugging() |
| citești cookie-uri / header-e | inject(REQUEST)?.headers.get('cookie') |
| răspunzi cu 404 | inject(RESPONSE_INIT) → status = 404, sau status: 404 pe ruta ** |
primești context de la server.ts | handle(req, ctx) → inject(REQUEST_CONTEXT) |
| stare per utilizator pe server | servicii sau useFactory — niciodată variabile de modul |