Angular Deep Dive — Revision Sheet¶
DI Hierarchy¶
- Injector tree mirrors the component tree. Requesting a dependency bubbles up the tree until a provider is found.
- Multiple injector trees run in parallel: environment injectors (root/module —
providedIn: 'root') and element injectors (component-level, viaproviders: [...]in@Component). - Component-level
providers= new instance per component instantiation, not shared app-wide. This is NOT the same as .NET's per-HTTP-request scope — it's per-component-subtree, per-instantiation. - Resolution order: own element injector → walk up ancestor element injectors → environment injector (root) →
NotFoundErrorunless@Optional(). - Modifiers:
@Self()(don't bubble),@SkipSelf()(skip own, start from parent — classic parent-child aggregator pattern),@Host()(stop at host boundary),@Optional()(injectnullinstead of throwing).
Providers¶
A provider = a recipe: token (the key) + instructions to produce the value.
useClass—{ provide: OrderRepository, useClass: IndexedDbOrderAdapter }— DIP in action, port + adapter.useValue—{ provide: API_BASE_URL, useValue: 'https://...' }— hand out a literal, no construction.useFactory— run a function to produce the value;depsarray lists what the factory itself needs, resolved and passed positionally.useExisting— alias one token to another's resolved value, no new instance.@Injectable({ providedIn: 'root' })is shorthand for{ provide: X, useClass: X }at root scope.provideRouter(),provideHttpClient()are functions that return arrays of providers bundled for convenience — not providers themselves.
Interface Erasure — why abstract class / InjectionToken, not interface¶
- TypeScript
interfaceis compile-time only — fully erased in compiled JS output. Nothing left at runtime to use as a DI lookup key. abstract classcompiles to a real JSclass— exists at runtime, usable as a token. Standard way to write a "port" in Angular.InjectionToken<T>— purpose-built runtime key for cases with no natural class (e.g. a plain settings shape). The<T>generic is compile-time only, for type-checking what the token resolves to.- Naming convention: PascalCase (
AppSettings) = the type/shape. SCREAMING_SNAKE_CASE (APP_SETTINGS) = the actual runtimeInjectionTokenvalue. Two separate declarations, different roles — TS allows type names and value names to coexist without collision.
useFactory Worked Example — online/offline API switch¶
{
provide: OrderApi,
useFactory: (settings, http, indexedDb) =>
settings.useLocalApi ? new LocalOrderApi(indexedDb) : new RemoteOrderApi(http),
deps: [APP_SETTINGS, HttpClient, IndexedDbService]
}
depsis the only thing that does real runtime work — each entry is a real token, resolved by the injector, passed positionally into the factory function as arguments.- Type annotations on the factory's parameters (
settings: AppSettings) are decorative only — pure dev-time type safety/autocomplete, erased at compile time, never consulted by the injector. Deleting theAppSettingsinterface entirely would not break this code at runtime. Deleting theAPP_SETTINGStoken would. - Factory runs once, result is cached for the injector's lifetime. Wrong tool if the decision needs to be re-evaluated per-call (e.g. live connectivity check) — for that, inject both adapters + a checker into a wrapper service instead, and decide per-call inside it.
Bootstrapping Flow¶
index.htmlloads,<app-root>is empty,main.js(the bundled output) loads.main.tsruns, callsbootstrapApplication(AppComponent, appConfig).appConfig'sprovidersarray builds the root environment injector.- Angular resolves
AppComponent's deps, constructs it, mounts into<app-root>. - Router (if
provideRouterconfigured) matches the URL against route config, may trigger lazy-loading (separate bundle chunk). - Route component's deps resolve (walking injector tree), constructor runs,
ngOnInitfires — this, not the constructor, is where async bootstrap work (e.g. IndexedDB read) belongs. - Change detection takes over as the ongoing loop from here.
One-liner: index.html → main.ts → bootstrapApplication → root injector from app.config.ts → AppComponent mounted → router activates matched route → dependencies resolve → lifecycle hooks fire → change detection running from here on.
Bundling — what bundlers actually do¶
- Module resolution — walk every
importfrom the entry point (main.ts), build the full dependency graph. - Tree-shaking — drop exports that are never imported anywhere (dead code elimination), including unused parts of libraries like RxJS.
- Bundling — concatenate many files into few, avoiding a waterfall of network requests.
- Minification — strip whitespace, shorten names.
- Code-splitting — lazy-loaded routes go into separate chunks, fetched only when navigated to.
-
Source maps — let devtools show original TS despite running minified JS.
-
Webpack — bundles everything upfront, even in dev mode (slower dev server startup).
- Vite / esbuild — dev mode serves source as native ES modules directly to the browser, transforming on demand; full bundle pipeline still runs for production builds. Angular 17+ uses esbuild directly via its own CLI builder (
"builder": "@angular-devkit/build-angular:application"inangular.json) — not Vite itself (Vite uses Rollup for prod builds + esbuild for dev pre-bundling; Angular's builder is a separate esbuild-based pipeline).
Project Structure Reference¶
src/app/
app.config.ts → providers (~ Program.cs builder.Services.Add...())
core/services/ → providedIn:'root' singletons
shared/ → dumb/presentational components, pipes, directives
features/<domain>/ → smart components + domain services
domain/ports/ → abstract class "interfaces"
domain/adapters/ → concrete implementations of ports
main.ts → bootstrap entry (~ Program.cs)
angular.json → build/serve/test config (~ .csproj)
package.json / package-lock.json → deps + exact pinned versions (~ .csproj PackageReference / packages.lock.json)
package-lock.jsonis committed to git;node_modules/is not (gitignored).npm ci(used in CI) requires lock file andpackage.jsonto agree exactly and refuses to silently update —npm installwill update the lock file if out of sync.angular.jsoncontrols build entry/output, style/asset bundling, and environment file replacement at build time (environment.ts↔environment.prod.ts) — same spirit asappsettings.Development.jsonvsappsettings.Production.json, but done via CLI file-swap rather than runtime config layering.
Polyfills, Zone.js, and What's Replacing It¶
- Polyfills — patch missing browser APIs so older/varied browsers behave consistently. Loaded before app code runs.
- Zone.js — monkey-patches every async browser API (
setTimeout,Promise.then,addEventListener, XHR) so Angular gets notified whenever anything async completes, anywhere. Can't tell Angular what changed — only that something did — so default change detection re-checks the entire component tree on every async event.OnPushis the escape hatch from this blanket re-check. - Signals replace this: each signal knows exactly when it changed (
.set()call is itself the notification), so Angular updates only what actually depends on that signal — no blanket re-check needed. - Zoneless Angular (
provideZonelessChangeDetection()) — becomes viable once enough of the app is signal-based that Zone.js's blanket async-sniffing is no longer needed. Direction the framework is actively moving.
Guards vs Resolvers¶
| Guard | Resolver | |
|---|---|---|
| Question | Can this navigation happen? | What data does this route need before showing? |
| Can block navigation? | Yes (false/UrlTree) |
No — navigation already proceeding |
| Runs relative to component construction | Determines if it happens | Determines when it happens (waits for data) |
CanActivate— gate on entering a specific route.CanActivateChild— same gate, applied once to cover an entire child subtree (avoids repeating the same guard on every child route).CanDeactivate— the only guard that runs on exit, not entry. Receives the actual component instance, can call methods on it (e.g.hasUnsavedChanges()) to decide whether to block leaving.CanMatch— runs during route matching itself, not after. Iffalse, the router treats the route as not a match and falls through to try another route config with the same path (e.g. feature-flagged new vs legacy flow at the same URL).CanActivatecan only block/redirect, not offer a fallback route.- Resolvers are somewhat out of favor in newer guidance — they block the whole navigation (URL doesn't change, back button behaves oddly) until data arrives; letting the component's own
ngOnInit/signals trigger the fetch with a loading state is often preferred UX.
Guard/Resolver Function Parameters — DI or Not?¶
route,state,component(inCanDeactivate) — plain function arguments, supplied directly by the router. Not DI.- Anything pulled via
inject()inside the function body — is DI, same resolution mechanism as constructor injection, different syntax because guards/resolvers are plain functions with no constructor. Works because the router runs these functions inside an injection context. - Same underlying split as
useFactory'sdepsarray + positional params — just a different syntactic channel (inject()on demand vs.depsdeclared up front).
Route vs ActivatedRoute¶
Route |
ActivatedRoute |
|
|---|---|---|
| What | Static config object | Live runtime instance for the current navigation |
| Where | Your routes.ts array |
Injected into the component |
| Real values? | No — :id is a pattern |
Yes — actual matched value, e.g. '42' |
| Lifetime | Always exists | Only while that route is active |
ActivatedRouteSnapshot(seen in guard signatures) is a frozen point-in-time copy, used in guards/resolvers because navigation hasn't committed yet — no live instance exists to inject.
Component State — the different flavors¶
- Plain instance fields — Angular has no idea they changed unless something (Zone.js) tells it to re-check.
- Signals —
signal(),computed()— the value itself announces its change; targeted re-render. @Input()— state handed down from parent. UnderOnPush, only a reference change triggers re-check — mutating a nested property in place is invisible. Newer signal-based inputs:input<T>().@Output()—EventEmitter, child notifies parent of a state change; not state itself, the mechanism for pushing it upward.- Component-scoped DI-provided services — state held in an injected service scoped to a component subtree (from DI hierarchy section); destroyed with the component (
ngOnDestroycascades). - Smart vs dumb components — smart owns state + injects services (e.g.
OrderTabComponent); dumb only receives via@Input()/emits via@Output(), owns no business state (e.g.OrderLineComponent).
Lifecycle Hooks — full order¶
On creation, once:
constructor()
→ ngOnChanges() [only if @Input()s exist; fires before ngOnInit too]
→ ngOnInit()
→ ngDoCheck()
→ ngAfterContentInit()
→ ngAfterContentChecked()
→ ngAfterViewInit()
→ ngAfterViewChecked()
On every subsequent change detection cycle:
ngOnChanges() [if inputs changed] → ngDoCheck() → ngAfterContentChecked() → ngAfterViewChecked()
On destruction, once: ngOnDestroy()
constructor()— DI resolution only. No async work, no reading@Input()(not bound yet).ngOnChanges(changes: SimpleChanges)— fires on@Input()reference change; gives old vs new value. This is the actual mechanism behind theOnPushmutation bug — mutate in place, no new reference,ngOnChangesnever fires,OnPushnever re-renders.ngOnInit()— correct place for initial data fetching. DI fully resolved, initial inputs set.ngDoCheck()— runs on every CD cycle, for any reason. Escape hatch for changes Angular can't otherwise detect (e.g. in-place mutation). Expensive — rarely used in practice.ngAfterContentInit/Checked— about projected content (<ng-content>), not regular@Input().ngAfterViewInit/Checked— about the component's own rendered template/children.@ViewChild()isundefinedbeforengAfterViewInit.ngOnDestroy()— the most important one to get right. Unsubscribe here or leak: subscription outlives the component, callback still fires, component can't be garbage collected.
Subscription Cleanup¶
- Manual: store the
Subscription, call.unsubscribe()inngOnDestroy(). takeUntilDestroyed(destroyRef?)— modern idiom. Needs an injection context to call with no argument (works implicitly in constructor / field initializers). Outside an injection context (e.g. insidengOnInit), captureDestroyRefas a field first (private destroyRef = inject(DestroyRef)), pass it explicitly.toSignal(obs$, { initialValue })— subscription lifecycle and change-detection notification both handled automatically; no manual unsubscribe, no manual field-assignment-then-hope-Zone.js-notices.- Manual
subscribe()insidengOnInitwith zero cleanup is a smell even when technically safe (e.g. an HTTP call that completes on its own) — it throws away composability and doesn't demonstrate the reactive-composition tools that exist specifically to make lifecycle automatic.
Subjects¶
- A
Subjectis simultaneously anObservable(.subscribe()-able) and anObserver(.next()/.error()/.complete()callable) — you manually push values in, whenever your own code decides to, rather than a fixed producer function baked in at creation (the plain-Observablemodel). subscribe()on a Subject does nothing but register a listener — it does not trigger anything, because there's no producer function to run..next()is what actually walks the list of registered subscribers and calls each of their handlers — this is the "active" step, inverted from plain Observable wheresubscribeitself is the trigger.- Multicast (Subject: one shared execution, every subscriber gets the same value at the same time) vs unicast (plain Observable: producer function reruns independently per subscriber).
BehaviorSubject— requires an initial value; any new subscriber immediately gets the current value. Natural fit for modeling live state (pre-Signals, this was the standard pattern; Signals are the evolution of the same idea with automatic dependency tracking).ReplaySubject(n)— no initial value required; buffers the lastnvalues, replays them to late subscribers.AsyncSubject— only emits its final value, only after.complete(). Rare.- When to use a Subject at all: when you are the producer — no existing Observable already represents the trigger (a raw DOM event you're wiring by hand, an in-app event bus, a manual refresh trigger). Don't wrap something already Observable (
valueChanges,HttpClientresponses,ActivatedRoute.paramMap,fromEvent) in a Subject — pipe directly onto the existing one.
Operator Chain Mechanics — how .next() actually propagates¶
- Calling
.next(value)on the source actively pushes the value through every operator in the pipe, in order, synchronously as part of that call (or asynchronously once an operator's own timer/internal logic decides to forward it — e.g.debounceTime). - Every operator in a
.pipe()chain is simultaneously an Observer of the operator before it and an Observable (with its own internal, hidden.next()) to the operator after it. subscribe()at the very end is just the last link — the one place a plain callback receives the final value instead of another operator.- Concrete trace for
debounceTime → filter → distinctUntilChanged → switchMap:.next()call arrives →debounceTimestarts/resets a timer, holds the value → timer fires independently → forwards tofilter→ passes/fails the predicate → if passed, forwards todistinctUntilChanged→ compares to last passed value → if different, forwards toswitchMap→ cancels any prior inner Observable, subscribes to the new one → inner Observable resolves (async) → forwards downstream → reachessubscribe().
Search-as-you-type / Race Conditions¶
firstValueFrom— converts Observable to Promise, resolves with first emission, auto-unsubscribes. Valid for genuinely one-shot, non-repeatable triggers (e.g. fetch config once at bootstrap, or inside a guard/resolver which by definition runs once per navigation). Wrong tool the moment a user can trigger the same action again before the first resolves — no cancellation, second-click race means whichever response arrives last wins regardless of which the user meant to see. Also no cleanup if the component is destroyed mid-request.switchMap— cancels the previous in-flight inner Observable the moment a new value arrives. Structurally prevents the stale-overwrite race, rather than relying on remembering to guard against it.- Full search-as-you-type pipe:
debounceTime(300) → filter(length check) → distinctUntilChanged() → switchMap(→ http call). - Reactive Forms'
valueChangesis already an Observable — no manual Subject/DOM-event-wiring needed if usingFormControl; only build a manualSubjectbridge (listening to raw(input)events) when there's noFormControlavailable. - Loading spinner pitfalls:
tap()beforeswitchMap,tap()after to reset — broken: ifswitchMapcancels the first inner Observable, its "reset loading"tapnever runs (the Observable was killed, not completed) → loading state can get stucktrue.finalize()— fixes the stuck-forever case (runs on completion, error, or unsubscription/cancellation) but can still cause a brief flicker (loading flips false from the cancelled request, then true again from the new one). Reasonable, production-acceptable trade for most cases.startWith({ loading: true, ... })inside the switchMap's inner pipe + a single combined state object (loading + result together) — the actually airtight version; avoids both the stuck state and the flicker, at the cost of more structure.- Calibration point: don't reach for
switchMap/cancellation machinery by default — it's overkill for triggers that can't realistically double-fire meaningfully (e.g. many button clicks). Reaching for the heaviest pattern regardless of fit is itself a smell; knowing when the ceremony earns its keep (real concurrency — background sync, network flapping) vs when six lines of plainsubscribe()+ signals suffices is the actual signal of depth.
Reactive Forms¶
FormControl— single input's value + state (touched/dirty/valid) +.valueChangesObservable.FormGroup— named collection of controls treated as one unit;.validis true only if every child is valid; own.valueChangesemits the whole object on any child change.FormArray— dynamic list of controls when the count isn't known upfront (e.g. order line items UI) —.push(),.removeAt(index).FormBuilder(fb.group({...})) — shorthand for constructing the sameFormGroup/FormControltree; each entry is[initialValue, syncValidators, asyncValidators].- Validators — plain functions:
(control: AbstractControl) => ValidationErrors | null.null= valid; error object (arbitrary keys) = invalid. Custom validators plug into the same array as built-ins — no special registration. - Validator factories — a function that takes config and returns the actual validator function (same shape as
useFactoryfrom DI) — needed when the validator itself needs a parameter (e.g. a disallowed-characters string). - Cross-field validation — put the validator on the
FormGroup(second argument tofb.group), not an individual control, since a single-field validator can't see sibling fields. - Async validators — return
Observable<ValidationErrors | null>; go in the third array position, not mixed with sync validators; musttake(1)or otherwise complete after one emission. SameswitchMap-cancellation reflex as search-as-you-type. - Error message display — the validator only returns a key (optionally with data attached, e.g.
{ minlength: { requiredLength, actualLength } }); the human-readable message text lives separately. - Simplest: inline
@if (control.hasError('key'))per field — repetitive at scale. - Centralized key-map pattern:
VALIDATION_MESSAGES: Record<string, (error) => string>keyed by the exact error key each validator returns — one place translating "which validator failed" into "what to show." Add a new validator later → just add one map entry, no template changes needed elsewhere. - Often wrapped as a reusable pipe (
{{ control | errorMessage }}) or a small dumb<app-field-error [control]="...">component, so no template repeats the@ifchain per field. .touched/.dirtygate when errors are shown (don't show validation errors before the user's interacted with the field);markAllAsTouched()forces all errors to show on submit-without-touching-everything.
Pipes vs Directives¶
| Pipe | Directive | |
|---|---|---|
| Syntax | {{ value \| name }} |
Attribute or structural syntax on an element |
| Touches DOM? | No — pure value transform | Yes — attribute directives modify host element; structural add/remove DOM |
| Side effects? | Should have none (pure by default) | Routine — event listeners, DOM manipulation |
- Pipes: implement
PipeTransform.transform(value, ...args). Chainable ({{ x | uppercase | slice:0:10 }}). Pure by default — only re-run if the input reference changes (same reference-equality rule asOnPush);{ pure: false }re-runs every CD cycle, rare/expensive. Built-ins:date,currency,uppercase,json,async(auto subscribe/unsubscribe in-template, same spirit astoSignal). - Directives — three kinds:
- Component — technically a directive with a template attached.
- Attribute directive — modifies the host element it's attached to, no template of its own (
ngClass,ngStyle, a custom[appHighlight]). - Structural directive — adds/removes entire DOM subtrees based on a condition. Modern
@if/@for/@switchcontrol-flow syntax replaces the older*ngIf/*ngFor(still valid, common in older code) —*ngIfdesugars to an<ng-template>only instantiated when true; this is real DOM add/remove, notdisplay:none.@for'strack(old:trackBy) tells Angular which nodes to reuse vs destroy/recreate on list changes.
JS/TS High-Yield Sweep¶
==vs===— always use===/!==;==coerces types inconsistently (0 == '0'→ true,null == undefined→ true butnull === undefined→ false).NaN === NaNisfalse— useNumber.isNaN().- Falsy values, the complete list:
false, 0, '', null, undefined, NaN. Everything else truthy — including'0'and[]and{}. var/let/const—varis function-scoped and hoisted, avoid.let/constare block-scoped.constlocks the binding/reference, not the contents —const arr = []; arr.push(1)is fine;arr = []is not.- Spread —
[...arr, x],{...obj, k: v}— expands into individual elements/properties, creates a new reference (directly relevant to theOnPushimmutable-update pattern:items.update(current => [...current, newItem])). - Rest —
function f(...args)— gathers remaining arguments/properties into one variable; opposite direction from spread. - Destructuring —
const { a, b } = obj,const [x, y] = arr, including in function parameters. - Optional chaining
?.— short-circuits toundefinedinstead of throwing if a link in the chain is null/undefined. - Nullish coalescing
??vs||—??only falls back onnull/undefined;||falls back on any falsy value including a legitimate0,'',false. Classic bug:order.total || 0incorrectly overrides a real total of0;order.total ?? 0doesn't. High-yield gotcha — have this cold. map/filter/reduce— none mutate the original array, all return something new.map— same length output, transforms each element.filter— predicate keeps/drops elements, output length 0..n.reduce(fn, initialValue)— accumulator carried through every element;fnreturns the next accumulator value; final return isreduce's result. Accumulator can be any shape (number, object, array) — e.g. building a lookup object or grouping into buckets, not just summing.- Chain pattern:
.filter(...).map(...).reduce(...)≈ LINQ's.Where().Select().Sum(). thisbinding — arrow functions capturethislexically from the enclosing scope at definition time; regularfunctionexpressions getthisdetermined by how they're called. This is why callbacks (.subscribe(),.then(), event handlers) are almost always arrow functions in Angular code — you wantthisto reliably mean the component instance.- Promise vs Observable — Promise: one eventual value, starts executing immediately on creation, no native cancellation, can't "resubscribe" and rerun. Observable: can emit multiple values over time, lazy (nothing runs until
.subscribe()), natively cancellable (unsubscribe()). This is the actual root reasonswitchMap's cancellation trick has no clean native equivalent with plainasync/await. - Generators (
function*,yield) — JS/TS parallel toIEnumerabledeferred execution; function body doesn't run until a value is pulled via.next(). Rarely asked directly but conceptually the same laziness Observables share.
Promise Syntax in Angular¶
async/awaitworks the same as any TS/JS — nothing Angular-specific about the syntax.firstValueFrom(observable$)— Observable → Promise, resolves on the first emission, auto-unsubscribes. Fine for genuinely one-shot triggers (bootstrap config, inside a guard/resolver). Wrong tool if a user can re-trigger the same action before the first resolves — no cancellation, last-response-wins race.lastValueFrom(observable$)— same bridge, but waits for the last emission before the source completes; matters if the Observable emits more than once (firstValueFromwould resolve early and miss the rest).- Native
fetch()bypassesHttpCliententirely — works, but loses Angular's interceptor pipeline (auth headers, centralized error handling). Real code sticks toHttpClient+ Observables, converting to a Promise only at the edge. - Don't mix
try/catch(Promise style) andcatchError(RxJS style) in the same function — pick one per function.
async loadOrder(id: string) {
try {
const order = await firstValueFrom(this.orderApi.getOrder(id));
this.result.set(order);
} catch {
this.error.set('Order not found');
}
}
Timer Syntax — timer / interval (RxJS, not plain JS)¶
timer(3000).subscribe(() => {}); // emits ONCE after 3s, then completes
timer(0, 5000).subscribe(() => {}); // first emission immediately, then every 5s, forever
interval(5000).subscribe(() => {}); // every 5s, starting at 5s — same as timer(5000, 5000)
- The plain
setIntervalbug it solves:setIntervaldoesn't wait for async work inside it to finish — if the callback's async call takes longer than the interval period, calls overlap and stack up (the exact race your background sync engine needs to avoid). - The fix —
interval()piped throughexhaustMap(notswitchMap): don't cancel an in-flight sync (could leave data half-synced), skip the next tick entirely if one's still running.
interval(5000).pipe(
exhaustMap(() => this.syncQueue.flush()),
takeUntilDestroyed(this.destroyRef)
).subscribe();
takeUntilDestroyed()matters more here than almost anywhere —interval()never completes on its own, so without cleanup it runs forever even after the component is gone.
RxJS Operator Recap¶
Core framing: every operator answers "when a new value arrives, what do I do with it?" Plain value out → map. Observable out (another async call) → one of the four flattening operators.
| Operator | On new value while previous in-flight | Use when |
|---|---|---|
switchMap |
Cancel previous, start new | Only latest result matters (search box) |
concatMap |
Queue — wait, then run next | Order matters (sequential uploads) |
exhaustMap |
Ignore the new value | Prevent duplicate action while one's running (payment button, sync timer tick) |
mergeMap |
Run both in parallel | Order doesn't matter, want speed (N profiles at once) |
// switchMap — search
searchControl.valueChanges.pipe(
debounceTime(300),
switchMap(val => this.http.get(`/search?q=${val}`))
).subscribe(results => this.results = results);
// concatMap — ordered uploads
fileUploads$.pipe(concatMap(file => this.uploadService.upload(file)))
.subscribe(result => console.log('uploaded:', result));
// exhaustMap — payment button / sync timer
paymentClicks$.pipe(exhaustMap(() => this.paymentService.submit()))
.subscribe(result => console.log(result));
// mergeMap — parallel, order doesn't matter
userIds$.pipe(mergeMap(id => this.http.get(`/users/${id}`)))
.subscribe(user => console.log(user));
forkJoin— not a pipe operator, a standalone combinator. Fires all given Observables simultaneously, emits once, only after all complete, as an array/tuple in the same order passed in. If any one errors or never completes,forkJoinnever emits at all.
forkJoin([
this.http.get('/user/profile'),
this.http.get('/user/settings'),
this.http.get('/user/permissions')
]).subscribe(([profile, settings, permissions]) => { ... });
package-lock.json / node_modules / npm ci¶
package.json— loose version ranges (^7.8.0).package-lock.json— exact resolved versions + integrity hashes for the whole dependency tree. Committed to git. Nearest .NET equivalent:packages.lock.json.node_modules/— actual downloaded files. Not committed (gitignored).npm install— can update the lock file if it's out of sync withpackage.json.npm ci(used in CI/CD) — requires lock file andpackage.jsonto agree exactly; deletesnode_modulesfresh; installs exactly what the lock file says, nothing more. The one to name for "how do you ensure reproducible builds."