mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
8423d51701
commit
216dde16ef
4 changed files with 170 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
});
|
||||
|
|
|
|||
26
src/app/util/safe-format-date.ts
Normal file
26
src/app/util/safe-format-date.ts
Normal 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) ?? '';
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue