fix(schedule): guard formatDate against unregistered locale (NG0701)

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
This commit is contained in:
Johannes Millan 2026-04-27 12:56:12 +02:00
parent 8423d51701
commit 216dde16ef
4 changed files with 170 additions and 6 deletions

View file

@ -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;

View file

@ -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<ScheduleComponent>;
let mockScheduleService: jasmine.SpyObj<ScheduleService>;
let mockLayoutService: jasmine.SpyObj<LayoutService>;
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/);
});
});
});

View file

@ -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}`;
});

View file

@ -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) ?? '';
}
};