From ee63c0a8131348dbaba399373e894fbe6a0f089b Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 20 Aug 2026 13:33:15 +0300 Subject: [PATCH] refactor(docs-infra): hold editor diagnostics in a signal `DiagnosticsState` carried a TODO to move off `BehaviorSubject` once zoneless was turned on. adev has been zoneless for a while now, with no zone.js dependency and an empty `polyfills` array, so the condition is met and the TODO can go. Both consumers now derive from the signal instead of subscribing. The Console tab badge becomes a `computed`, and so does the code editor's error list: the diagnostics are produced by a `linter()` configured with a 400ms delay rather than arriving as a stream, so the rxjs chain was only adding a further second before they were displayed. The errors box stays dismissable through a `linkedSignal`, which reverts as soon as the diagnostics change. (cherry picked from commit 238d8bf98aaeaa9ce4ceddff26eb15aab5d013b6) --- .../code-editor/code-editor.component.ts | 46 +++++++------------ .../diagnostics-state.service.spec.ts | 35 +++++++++++++- .../services/diagnostics-state.service.ts | 10 ++-- .../app/editor/embedded-editor.component.ts | 7 +-- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/adev/src/app/editor/code-editor/code-editor.component.ts b/adev/src/app/editor/code-editor/code-editor.component.ts index 41d07267232..d501317f1dc 100644 --- a/adev/src/app/editor/code-editor/code-editor.component.ts +++ b/adev/src/app/editor/code-editor/code-editor.component.ts @@ -13,9 +13,11 @@ import { ElementRef, EnvironmentInjector, afterRenderEffect, + computed, effect, inject, input, + linkedSignal, signal, untracked, viewChild, @@ -23,7 +25,7 @@ import { import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {MatTab, MatTabGroup, MatTabLabel} from '@angular/material/tabs'; import {Title} from '@angular/platform-browser'; -import {debounceTime, from, map, switchMap} from 'rxjs'; +import {from, switchMap} from 'rxjs'; import {TerminalType} from '../terminal/terminal-handler.service'; @@ -81,25 +83,22 @@ export class CodeEditor { private readonly location = inject(Location); private readonly environmentInjector = inject(EnvironmentInjector); - private readonly errors$ = this.diagnosticsState.diagnostics$.pipe( - // Display errors one second after code update - debounceTime(1000), - map((diagnosticsItem) => - diagnosticsItem - .filter((item) => item.severity === 'error') - .sort((a, b) => - a.lineNumber != b.lineNumber - ? a.lineNumber - b.lineNumber - : a.characterPosition - b.characterPosition, - ), - ), - takeUntilDestroyed(this.destroyRef), - ); - readonly TerminalType = TerminalType; - protected readonly displayErrorsBox = signal(false); - protected readonly errors = signal([]); + protected readonly errors = computed(() => + this.nodeRuntimeState.loadingStep() !== LoadingStep.READY + ? [] + : this.diagnosticsState + .diagnostics() + .filter((item) => item.severity === 'error') + .sort((a, b) => + a.lineNumber != b.lineNumber + ? a.lineNumber - b.lineNumber + : a.characterPosition - b.characterPosition, + ), + ); + + protected readonly displayErrorsBox = linkedSignal(() => this.errors().length > 0); protected readonly files = this.codeMirrorEditor.openFiles; protected readonly isCreatingFile = signal(false); protected readonly isRenamingFile = signal(false); @@ -120,7 +119,6 @@ export class CodeEditor { untracked(() => { this.codeMirrorEditor.init(parent); - this.listenToDiagnosticsChange(); this.listenToTabChange(); this.setSelectedTabOnTutorialChange(); @@ -308,16 +306,6 @@ export class CodeEditor { return true; } - private listenToDiagnosticsChange(): void { - this.errors$.subscribe((diagnostics) => { - if (this.nodeRuntimeState.loadingStep() !== LoadingStep.READY) { - return; - } - this.errors.set(diagnostics); - this.displayErrorsBox.set(diagnostics.length > 0); - }); - } - private setSelectedTabOnTutorialChange() { // Using `from` to prevent injecting the embedded tutorial manager once the // injector is destroyed (this may happen in unit tests when the test ends diff --git a/adev/src/app/editor/code-editor/services/diagnostics-state.service.spec.ts b/adev/src/app/editor/code-editor/services/diagnostics-state.service.spec.ts index 483c88cff67..09e67ec33f8 100644 --- a/adev/src/app/editor/code-editor/services/diagnostics-state.service.spec.ts +++ b/adev/src/app/editor/code-editor/services/diagnostics-state.service.spec.ts @@ -6,13 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ +import {WritableSignal} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {DiagnosticsState} from './diagnostics-state.service'; +import {DiagnosticWithLocation, DiagnosticsState} from './diagnostics-state.service'; describe('DiagnosticsState', () => { let service: DiagnosticsState; + const diagnostic = (message: string): DiagnosticWithLocation => ({ + from: 0, + to: 1, + severity: 'error', + message, + lineNumber: 1, + characterPosition: 0, + }); + beforeEach(() => { service = TestBed.inject(DiagnosticsState); }); @@ -20,4 +30,27 @@ describe('DiagnosticsState', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should start with no diagnostics', () => { + expect(service.diagnostics()).toEqual([]); + }); + + it('should expose the diagnostics it is given', () => { + const diagnostics = [diagnostic('first'), diagnostic('second')]; + + service.setDiagnostics(diagnostics); + + expect(service.diagnostics()).toEqual(diagnostics); + }); + + it('should replace the previous diagnostics', () => { + service.setDiagnostics([diagnostic('first')]); + service.setDiagnostics([]); + + expect(service.diagnostics()).toEqual([]); + }); + + it('should not expose a way to write to the diagnostics signal', () => { + expect((service.diagnostics as Partial>).set).toBeUndefined(); + }); }); diff --git a/adev/src/app/editor/code-editor/services/diagnostics-state.service.ts b/adev/src/app/editor/code-editor/services/diagnostics-state.service.ts index 93978372ba7..251eba8033a 100644 --- a/adev/src/app/editor/code-editor/services/diagnostics-state.service.ts +++ b/adev/src/app/editor/code-editor/services/diagnostics-state.service.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Service} from '@angular/core'; +import {Service, signal} from '@angular/core'; import {Diagnostic} from '@codemirror/lint'; -import {BehaviorSubject, distinctUntilChanged} from 'rxjs'; export interface DiagnosticWithLocation extends Diagnostic { lineNumber: number; @@ -17,12 +16,11 @@ export interface DiagnosticWithLocation extends Diagnostic { @Service() export class DiagnosticsState { - private readonly _diagnostics$ = new BehaviorSubject([]); + private readonly _diagnostics = signal([]); - // TODO: use signals when zoneless will be turned off - diagnostics$ = this._diagnostics$.asObservable().pipe(distinctUntilChanged()); + readonly diagnostics = this._diagnostics.asReadonly(); setDiagnostics(diagnostics: DiagnosticWithLocation[]): void { - this._diagnostics$.next(diagnostics); + this._diagnostics.set(diagnostics); } } diff --git a/adev/src/app/editor/embedded-editor.component.ts b/adev/src/app/editor/embedded-editor.component.ts index b8e3f9c55b3..230520fa12b 100644 --- a/adev/src/app/editor/embedded-editor.component.ts +++ b/adev/src/app/editor/embedded-editor.component.ts @@ -20,10 +20,8 @@ import { signal, viewChild, } from '@angular/core'; -import {toSignal} from '@angular/core/rxjs-interop'; import {IconComponent, TutorialType} from '@angular/docs'; import {MatTab, MatTabGroup, MatTabLabel} from '@angular/material/tabs'; -import {map} from 'rxjs'; import {MAX_RECOMMENDED_WEBCONTAINERS_INSTANCES} from './alert-manager.service'; @@ -93,10 +91,9 @@ export class EmbeddedEditor { !this.nodeRuntimeState.isResetting(), ); - private readonly errorsCount$ = this.diagnosticsState.diagnostics$.pipe( - map((diagnosticsItem) => diagnosticsItem.filter((item) => item.severity === 'error').length), + protected readonly errorsCount = computed( + () => this.diagnosticsState.diagnostics().filter((item) => item.severity === 'error').length, ); - protected readonly errorsCount = toSignal(this.errorsCount$, {initialValue: 0}); constructor() { if (!isPlatformBrowser(this.platformId)) {