From 216dde16efaa308ce374a2e1c8fbdc3eeef56e6b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 27 Apr 2026 12:56:12 +0200 Subject: [PATCH] fix(schedule): guard formatDate against unregistered locale (NG0701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-default locale data is registered lazily via requestIdleCallback (commit d989c064f3). If the schedule view renders before the idle callback fires — e.g. cold start with zh-cn during initial sync replay — formatDate(date, fmt, locale) throws NG0701 "Missing locale data". Wrap the four schedule formatDate call sites in a safeFormatDate helper that falls back to DEFAULT_LOCALE on throw. After the idle callback registers the user's locale, any signal change re-runs the computed and the user's locale takes effect. Refs #7383 --- .../schedule-month.component.ts | 6 +- .../schedule/schedule-locale-ng0701.spec.ts | 136 ++++++++++++++++++ .../schedule/schedule/schedule.component.ts | 8 +- src/app/util/safe-format-date.ts | 26 ++++ 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts create mode 100644 src/app/util/safe-format-date.ts diff --git a/src/app/features/schedule/schedule-month/schedule-month.component.ts b/src/app/features/schedule/schedule-month/schedule-month.component.ts index 797e126a8c..bf6fe3f437 100644 --- a/src/app/features/schedule/schedule-month/schedule-month.component.ts +++ b/src/app/features/schedule/schedule-month/schedule-month.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/core'; import { ScheduleEvent } from '../schedule.model'; import { ScheduleEventComponent } from '../schedule-event/schedule-event.component'; -import { formatDate } from '@angular/common'; +import { safeFormatDate } from 'src/app/util/safe-format-date'; import { T } from '../../../t.const'; import { ScheduleService } from '../schedule.service'; import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe'; @@ -45,7 +45,9 @@ export class ScheduleMonthComponent { const date = new Date(sundayDate); date.setDate(sundayDate.getDate() + dayIndex); // 'EEE' format gives abbreviated day name (e.g., 'Mon', 'Tue') - headers.push(formatDate(date, 'EEE', this._dateTimeFormatService.currentLocale())); + headers.push( + safeFormatDate(date, 'EEE', this._dateTimeFormatService.currentLocale()), + ); } return headers; diff --git a/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts b/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts new file mode 100644 index 0000000000..01bfb3c94e --- /dev/null +++ b/src/app/features/schedule/schedule/schedule-locale-ng0701.spec.ts @@ -0,0 +1,136 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { formatDate } from '@angular/common'; +import { signal } from '@angular/core'; +import { of } from 'rxjs'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DateAdapter } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; + +import { ScheduleComponent } from './schedule.component'; +import { TaskService } from '../../tasks/task.service'; +import { LayoutService } from '../../../core-ui/layout/layout.service'; +import { ScheduleService } from '../schedule.service'; +import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service'; +import { GlobalConfigService } from '../../config/global-config.service'; + +/** + * Regression guard for issue #7383 (NG0701 on /schedule). + * + * Root cause: src/main.ts lazily registers non-default locales via + * requestIdleCallback. If the schedule view renders before idle fires + * (e.g. during initial sync replay on a slow Electron host), Angular's + * formatDate(date, fmt, 'zh-cn') throws RuntimeError(701). + * + * Fix: schedule's formatDate calls go through safeFormatDate (try/catch + * with DEFAULT_LOCALE fallback) so a missing locale registration shows + * a default-locale string instead of crashing. + */ +describe('issue #7383 — NG0701 race on /schedule', () => { + // Angular's locale registry is module-global. We avoid registering any + // locale data here so that zh-cn is consistently absent — that absence is + // the race window we're reproducing. + + describe('via Angular formatDate (race window simulation)', () => { + it('throws NG0701 for zh-cn when locale data is not registered', () => { + expect(() => formatDate(new Date(2026, 3, 20), 'LLLL yyyy', 'zh-cn')).toThrowError( + /NG07?01|Missing locale data/i, + ); + }); + + it('does NOT throw for en-gb (Angular falls back to baked-in en data)', () => { + expect(() => formatDate(new Date(2026, 3, 20), 'LLLL yyyy', 'en-gb')).not.toThrow(); + }); + }); + + describe('via ScheduleComponent.headerTitle() (race window simulation)', () => { + let component: ScheduleComponent; + let fixture: ComponentFixture; + let mockScheduleService: jasmine.SpyObj; + let mockLayoutService: jasmine.SpyObj; + let localeSignal: ReturnType< + typeof signal<{ firstDayOfWeek: number; dateTimeLocale: string }> + >; + + beforeEach(async () => { + const mockTaskService = jasmine.createSpyObj('TaskService', ['currentTaskId']); + (mockTaskService as any).currentTaskId = signal(null); + + mockLayoutService = jasmine.createSpyObj('LayoutService', [], { + selectedTimeView: signal('month'), + }); + + mockScheduleService = jasmine.createSpyObj('ScheduleService', [ + 'getDaysToShow', + 'getMonthDaysToShow', + 'buildScheduleDays', + 'getTodayStr', + 'createScheduleDaysWithContext', + 'getDayClass', + 'hasEventsForDay', + 'getEventsForDay', + ]); + const monthDays = Array.from({ length: 35 }, (_, i) => { + const d = new Date(2026, 3, 1 + i); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }); + mockScheduleService.getDaysToShow.and.returnValue(monthDays); + mockScheduleService.getMonthDaysToShow.and.returnValue(monthDays); + mockScheduleService.buildScheduleDays.and.returnValue([]); + mockScheduleService.getTodayStr.and.returnValue('2026-04-15'); + mockScheduleService.createScheduleDaysWithContext.and.returnValue([]); + mockScheduleService.getDayClass.and.returnValue(''); + mockScheduleService.hasEventsForDay.and.returnValue(false); + mockScheduleService.getEventsForDay.and.returnValue([]); + (mockScheduleService as any).scheduleRefreshTick = signal(0); + + const mockGlobalTrackingIntervalService = jasmine.createSpyObj( + 'GlobalTrackingIntervalService', + [], + { todayDateStr$: of('2026-04-15') }, + ); + + // The reporter's environment: dateTimeLocale='zh-cn'. + localeSignal = signal({ firstDayOfWeek: 1, dateTimeLocale: 'zh-cn' }); + const mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], { + localization: localeSignal, + cfg: signal(undefined), + }); + + await TestBed.configureTestingModule({ + imports: [ScheduleComponent, TranslateModule.forRoot()], + providers: [ + provideMockStore({ initialState: {} }), + { provide: TaskService, useValue: mockTaskService }, + { provide: LayoutService, useValue: mockLayoutService }, + { provide: ScheduleService, useValue: mockScheduleService }, + { provide: MatDialog, useValue: jasmine.createSpyObj('MatDialog', ['open']) }, + { + provide: GlobalTrackingIntervalService, + useValue: mockGlobalTrackingIntervalService, + }, + { provide: GlobalConfigService, useValue: mockGlobalConfigService }, + { + provide: DateAdapter, + useValue: { getFirstDayOfWeek: () => 1, setLocale: () => {} }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ScheduleComponent); + component = fixture.componentInstance; + }); + + it('does NOT throw when zh-cn locale data is not yet registered (regression guard for #7383)', () => { + // headerTitle() in month view calls safeFormatDate(mid, 'LLLL yyyy', 'zh-cn'). + // Pre-fix this would throw NG0701; safeFormatDate falls back to the + // default locale until lazy-loaded zh-cn registration completes. + let result: string | undefined; + expect(() => { + result = component.headerTitle(); + }).not.toThrow(); + // Should produce a non-empty string (the fallback locale's rendering). + expect(result).toMatch(/\S/); + }); + }); +}); diff --git a/src/app/features/schedule/schedule/schedule.component.ts b/src/app/features/schedule/schedule/schedule.component.ts index 398d9fc23d..34065a91b2 100644 --- a/src/app/features/schedule/schedule/schedule.component.ts +++ b/src/app/features/schedule/schedule/schedule.component.ts @@ -11,7 +11,7 @@ import { import { fromEvent } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { debounceTime, map, startWith } from 'rxjs/operators'; -import { formatDate } from '@angular/common'; +import { safeFormatDate } from '../../../util/safe-format-date'; import { TaskService } from '../../tasks/task.service'; import { LayoutService } from '../../../core-ui/layout/layout.service'; import { MatIconButton } from '@angular/material/button'; @@ -152,14 +152,14 @@ export class ScheduleComponent { if (this.isMonthView()) { const mid = parseDbDateStr(days[Math.floor(days.length / 2)]); - return formatDate(mid, 'LLLL yyyy', locale) ?? ''; + return safeFormatDate(mid, 'LLLL yyyy', locale); } const start = parseDbDateStr(days[0]); const end = parseDbDateStr(days[days.length - 1]); const weekNr = getWeekNumber(start); // ISO — default firstDayOfWeek=1 - const startStr = formatDate(start, 'MMM d', locale) ?? ''; - const endStr = formatDate(end, 'MMM d', locale) ?? ''; + const startStr = safeFormatDate(start, 'MMM d', locale); + const endStr = safeFormatDate(end, 'MMM d', locale); const label = this._translate.instant(T.F.WORKLOG.CMP.WEEK_NR, { nr: weekNr }); return `${label} · ${startStr} – ${endStr}`; }); diff --git a/src/app/util/safe-format-date.ts b/src/app/util/safe-format-date.ts new file mode 100644 index 0000000000..17b72913eb --- /dev/null +++ b/src/app/util/safe-format-date.ts @@ -0,0 +1,26 @@ +import { formatDate } from '@angular/common'; +import { DEFAULT_LOCALE } from '../core/locale.constants'; + +/** + * Wrapper around Angular's `formatDate` that falls back to the default locale + * if the requested locale's data hasn't been registered yet. + * + * Why: non-default locale data is lazily registered via requestIdleCallback + * (see main.ts). If a render needs a date string with the user's configured + * locale before that callback fires, Angular throws NG0701. See issue #7383. + * + * After the idle callback completes and the user triggers any signal change + * that re-evaluates the calling computed, the formatted output will switch + * to the user's locale. + */ +export const safeFormatDate = ( + value: Date | string | number, + format: string, + locale: string, +): string => { + try { + return formatDate(value, format, locale) ?? ''; + } catch { + return formatDate(value, format, DEFAULT_LOCALE) ?? ''; + } +};