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 238d8bf98a)
This commit is contained in:
Kam
2026-08-20 13:33:15 +03:00
committed by leonsenft
parent 73020da890
commit ee63c0a813
4 changed files with 57 additions and 41 deletions
@@ -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<boolean>(false);
protected readonly errors = signal<DiagnosticWithLocation[]>([]);
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<boolean>(false);
protected readonly isRenamingFile = signal<boolean>(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
@@ -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<WritableSignal<unknown>>).set).toBeUndefined();
});
});
@@ -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<DiagnosticWithLocation[]>([]);
private readonly _diagnostics = signal<DiagnosticWithLocation[]>([]);
// 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);
}
}
@@ -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)) {