mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-30 11:10:04 +00:00
fix(theme): tolerate work contexts persisted without a theme (#9139)
A tag/project entity can be persisted with no `theme` at all. Validation
at hydration is non-fatal, so such a snapshot loads anyway and every
consumer then dereferences `undefined`, crashing the theme pipeline on
every launch. Reproduced end-to-end: resolveBackground() throws on
backgroundImageDark/Light and _setColorTheme() on isAutoContrast.
Two layers:
- resolveContextTheme() extracted from the currentTheme$ map, defaulting
a missing theme to WORK_CONTEXT_DEFAULT_THEME. Single choke point for
both crashing consumers. This also fixes the tag-colour branch, which
spread an undefined theme into a partial `{ primary }` object.
- an auto-fix branch backfilling a missing tag/project theme, so data
already corrupted on disk is healed rather than merely tolerated.
Unrepaired, the same corruption also dead-ended legacy migration with
"Migration failed" and no way in.
The auto-fix branch matches on path + `value === undefined` and
deliberately NOT on `error.expected`: typia reports this as the
generated name `Readonly<__type>.oNN`, whose ordinal shifts with the
type graph, so an expected-based guard would silently stop firing.
Verified: both fixes sabotage-tested (3 failures each with the fix
reverted), full suite green in both TZ variants, and confirmed firing
against real typia output and real hydration in the browser.
This commit is contained in:
parent
014b789c22
commit
d6eee18bc0
4 changed files with 264 additions and 17 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { WorkContextService } from './work-context.service';
|
||||
import { resolveContextTheme, WorkContextService } from './work-context.service';
|
||||
import { DEFAULT_TAG_COLOR, WORK_CONTEXT_DEFAULT_THEME } from './work-context.const';
|
||||
import { TaskWithSubTasks } from '../tasks/task.model';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
|
|
@ -744,3 +745,88 @@ describe('WorkContextService - isTodayListSignal reactivity', () => {
|
|||
expect(service.isTodayListSignal()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContextTheme()', () => {
|
||||
const buildCtx = (over: Record<string, unknown> = {}): WorkContext =>
|
||||
({
|
||||
id: 'TODAY',
|
||||
title: 'Today',
|
||||
type: WorkContextType.TAG,
|
||||
routerLink: 'tag/TODAY',
|
||||
taskIds: [],
|
||||
noteIds: [],
|
||||
color: null,
|
||||
theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: '#123456' },
|
||||
...over,
|
||||
}) as unknown as WorkContext;
|
||||
|
||||
describe('regression #9139: work context persisted with no theme', () => {
|
||||
// A tag/project entity stored without `theme` propagated `undefined` into
|
||||
// resolveBackground() and _setColorTheme(), crashing on every launch.
|
||||
it('returns the default theme instead of undefined', () => {
|
||||
const ctx = buildCtx();
|
||||
delete (ctx as unknown as Record<string, unknown>).theme;
|
||||
|
||||
expect(resolveContextTheme(ctx)).toEqual(WORK_CONTEXT_DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it('returns a theme the crashing consumers can dereference', () => {
|
||||
const ctx = buildCtx();
|
||||
delete (ctx as unknown as Record<string, unknown>).theme;
|
||||
|
||||
const res = resolveContextTheme(ctx);
|
||||
|
||||
// The exact fields whose unguarded deref produced the reported crashes:
|
||||
// resolveBackground() reads backgroundImage*, _setColorTheme() reads
|
||||
// isAutoContrast.
|
||||
expect(res.backgroundImageDark).toBeDefined();
|
||||
expect(res.backgroundImageLight).toBeDefined();
|
||||
expect(res.isAutoContrast).toBeDefined();
|
||||
});
|
||||
|
||||
it('yields a COMPLETE theme when the tag-color fallback applies', () => {
|
||||
const ctx = buildCtx({ color: '#abcdef' });
|
||||
delete (ctx as unknown as Record<string, unknown>).theme;
|
||||
|
||||
const res = resolveContextTheme(ctx);
|
||||
|
||||
// Spreading an undefined theme would have produced just `{ primary }`.
|
||||
expect(res.primary).toBe('#abcdef');
|
||||
expect(res.accent).toBe(WORK_CONTEXT_DEFAULT_THEME.accent);
|
||||
expect(res.huePrimary).toBe(WORK_CONTEXT_DEFAULT_THEME.huePrimary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing behaviour is preserved', () => {
|
||||
it('keeps an explicit primary override over tag.color', () => {
|
||||
const res = resolveContextTheme(
|
||||
buildCtx({
|
||||
theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: '#explicit' },
|
||||
color: '#tagcolor',
|
||||
}),
|
||||
);
|
||||
expect(res.primary).toBe('#explicit');
|
||||
});
|
||||
|
||||
it('falls back to tag.color when primary is still the auto-default', () => {
|
||||
const res = resolveContextTheme(
|
||||
buildCtx({
|
||||
theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: DEFAULT_TAG_COLOR },
|
||||
color: '#tagcolor',
|
||||
}),
|
||||
);
|
||||
expect(res.primary).toBe('#tagcolor');
|
||||
});
|
||||
|
||||
it('does not apply the tag-color fallback for projects', () => {
|
||||
const res = resolveContextTheme(
|
||||
buildCtx({
|
||||
type: WorkContextType.PROJECT,
|
||||
theme: { ...WORK_CONTEXT_DEFAULT_THEME, primary: DEFAULT_TAG_COLOR },
|
||||
color: '#tagcolor',
|
||||
}),
|
||||
);
|
||||
expect(res.primary).toBe(DEFAULT_TAG_COLOR);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
} from 'rxjs/operators';
|
||||
import { TODAY_TAG } from '../tag/tag.const';
|
||||
import { Tag } from '../tag/tag.model';
|
||||
import { DEFAULT_TAG_COLOR } from './work-context.const';
|
||||
import { DEFAULT_TAG_COLOR, WORK_CONTEXT_DEFAULT_THEME } from './work-context.const';
|
||||
import { TagService } from '../tag/tag.service';
|
||||
import { ArchiveTask, Task, TaskWithSubTasks } from '../tasks/task.model';
|
||||
import {
|
||||
|
|
@ -76,6 +76,31 @@ import { Project } from '../project/project.model';
|
|||
import { Log } from '../../core/log';
|
||||
import { LOCAL_ACTIONS } from '../../util/local-actions.token';
|
||||
|
||||
/**
|
||||
* Resolve the theme to apply for a work context.
|
||||
*
|
||||
* `WorkContextCommon.theme` is declared required, but persisted data can lack
|
||||
* it: validation at hydration is non-fatal, so a snapshot holding a theme-less
|
||||
* tag loads anyway and every consumer then dereferences `undefined` (#9139 —
|
||||
* this crashed both `resolveBackground` and `_setColorTheme` on every launch).
|
||||
* Defaulting here gives the theme pipeline a single choke point.
|
||||
*/
|
||||
export const resolveContextTheme = (awc: WorkContext): WorkContextThemeCfg => {
|
||||
const theme = awc.theme ?? WORK_CONTEXT_DEFAULT_THEME;
|
||||
// For tags: theme.primary is the explicit override. If it's still at
|
||||
// the auto-default (or unset) and tag.color is set, fall back to
|
||||
// tag.color so newly created tags drive Material theming with their
|
||||
// randomized color while still letting users override explicitly.
|
||||
if (awc.type === WorkContextType.TAG) {
|
||||
const tagColor = (awc as unknown as Tag).color;
|
||||
const primary = theme.primary;
|
||||
if (tagColor && (!primary || primary === DEFAULT_TAG_COLOR)) {
|
||||
return { ...theme, primary: tagColor };
|
||||
}
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
|
|
@ -230,20 +255,7 @@ export class WorkContextService {
|
|||
);
|
||||
|
||||
currentTheme$: Observable<WorkContextThemeCfg> = this.activeWorkContext$.pipe(
|
||||
map((awc) => {
|
||||
// For tags: theme.primary is the explicit override. If it's still at
|
||||
// the auto-default (or unset) and tag.color is set, fall back to
|
||||
// tag.color so newly created tags drive Material theming with their
|
||||
// randomized color while still letting users override explicitly.
|
||||
if (awc.type === WorkContextType.TAG) {
|
||||
const tagColor = (awc as unknown as Tag).color;
|
||||
const primary = awc.theme?.primary;
|
||||
if (tagColor && (!primary || primary === DEFAULT_TAG_COLOR)) {
|
||||
return { ...awc.theme, primary: tagColor };
|
||||
}
|
||||
}
|
||||
return awc.theme;
|
||||
}),
|
||||
map(resolveContextTheme),
|
||||
distinctUntilChanged<WorkContextThemeCfg>(isShallowEqual),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { IValidation } from 'typia';
|
|||
import { initialTaskState } from '../../features/tasks/store/task.reducer';
|
||||
import { DEFAULT_TASK } from '../../features/tasks/task.model';
|
||||
import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter';
|
||||
import { DEFAULT_TAG } from '../../features/tag/tag.const';
|
||||
import { DEFAULT_PROJECT } from '../../features/project/project.const';
|
||||
|
||||
const createTypiaError = (
|
||||
path: string,
|
||||
|
|
@ -480,4 +482,120 @@ describe('autoFixTypiaErrors', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #9139 — tag/project entities missing `theme` entirely', () => {
|
||||
// The `expected` string typia ACTUALLY emits for this error, captured by
|
||||
// running the real validator. It is a generated anonymous type name whose
|
||||
// ordinal suffix moves whenever the type graph changes — which is exactly
|
||||
// why the fix must not key on it. See the no-longer-brittle test below.
|
||||
const REAL_EXPECTED = 'Readonly<__type>.o26';
|
||||
|
||||
it('should backfill a missing tag.theme with the default tag theme', () => {
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).tag = {
|
||||
ids: ['TODAY'],
|
||||
entities: { TODAY: { id: 'TODAY', title: 'Today', taskIds: [] } },
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, undefined),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors);
|
||||
|
||||
expect((result as any).tag.entities.TODAY.theme).toEqual(DEFAULT_TAG.theme);
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
'[auto-fix-typia-errors] Applied validation auto-fix',
|
||||
undefined,
|
||||
jasmine.objectContaining({
|
||||
fix: 'work-context-theme-undefined-to-default',
|
||||
pathRoot: 'tag',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should backfill a missing project.theme with the default project theme', () => {
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).project = {
|
||||
ids: ['p1'],
|
||||
entities: { p1: { id: 'p1', title: 'Proj', taskIds: [] } },
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError('$input.project.entities.p1.theme', REAL_EXPECTED, undefined),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors);
|
||||
|
||||
expect((result as any).project.entities.p1.theme).toEqual(DEFAULT_PROJECT.theme);
|
||||
});
|
||||
|
||||
it('should still fire when typia renames the generated `expected` type', () => {
|
||||
// Guards the #9045 failure mode: a branch keyed on `error.expected`
|
||||
// would silently stop firing the moment the ordinal shifts. This fix
|
||||
// matches on path + `value === undefined` only, so a renamed type must
|
||||
// not change the outcome.
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).tag = {
|
||||
ids: ['TODAY'],
|
||||
entities: { TODAY: { id: 'TODAY', title: 'Today', taskIds: [] } },
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError(
|
||||
'$input.tag.entities.TODAY.theme',
|
||||
'Readonly<__type>.o99999',
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors);
|
||||
|
||||
expect((result as any).tag.entities.TODAY.theme).toEqual(DEFAULT_TAG.theme);
|
||||
});
|
||||
|
||||
it('should backfill a COPY, never the shared default constant', () => {
|
||||
// Aliasing DEFAULT_TAG.theme across entities would let any later
|
||||
// mutation write through into the app-wide constant.
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).tag = {
|
||||
ids: ['TODAY', 't2'],
|
||||
entities: {
|
||||
TODAY: { id: 'TODAY', title: 'Today', taskIds: [] },
|
||||
t2: { id: 't2', title: 'Other', taskIds: [] },
|
||||
},
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, undefined),
|
||||
createTypiaError('$input.tag.entities.t2.theme', REAL_EXPECTED, undefined),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors);
|
||||
|
||||
const a = (result as any).tag.entities.TODAY.theme;
|
||||
const b = (result as any).tag.entities.t2.theme;
|
||||
expect(a).not.toBe(DEFAULT_TAG.theme);
|
||||
expect(b).not.toBe(DEFAULT_TAG.theme);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(DEFAULT_TAG.theme);
|
||||
});
|
||||
|
||||
it('should NOT overwrite a theme that is present but merely invalid', () => {
|
||||
// `value === undefined` scopes this to a genuinely absent field; a
|
||||
// present-but-wrong theme is a different problem and must not be
|
||||
// silently replaced (that would discard user styling).
|
||||
const mockData = createAppDataCompleteMock();
|
||||
const existingTheme = { primary: '#user-picked' };
|
||||
(mockData as any).tag = {
|
||||
ids: ['TODAY'],
|
||||
entities: {
|
||||
TODAY: { id: 'TODAY', title: 'Today', taskIds: [], theme: existingTheme },
|
||||
},
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError('$input.tag.entities.TODAY.theme', REAL_EXPECTED, existingTheme),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors);
|
||||
|
||||
expect((result as any).tag.entities.TODAY.theme).toEqual(existingTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { AppDataComplete } from '../model/model-config';
|
|||
import { IValidation } from 'typia';
|
||||
import type { SyncLogMeta } from '@sp/sync-core';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
import { INBOX_PROJECT } from '../../features/project/project.const';
|
||||
import { DEFAULT_PROJECT, INBOX_PROJECT } from '../../features/project/project.const';
|
||||
import { DEFAULT_TAG } from '../../features/tag/tag.const';
|
||||
import { RECREATE_FALLBACK } from '../core/recreate-fallback.const';
|
||||
import { OP_LOG_SYNC_LOGGER } from '../core/sync-logger.adapter';
|
||||
import { devError } from '../../util/dev-error';
|
||||
|
|
@ -271,6 +272,36 @@ export const autoFixTypiaErrors = (
|
|||
const created = Date.now();
|
||||
setValueByPath(data, keys, created);
|
||||
logAutoFixApplied(path, keys, 'tag-created-undefined-to-now', value, created);
|
||||
} else if (
|
||||
(keys[0] === 'tag' || keys[0] === 'project') &&
|
||||
keys[1] === 'entities' &&
|
||||
keys.length === 4 &&
|
||||
keys[3] === 'theme' &&
|
||||
value === undefined
|
||||
) {
|
||||
// A tag/project entity can be persisted with no `theme` at all (#9139).
|
||||
// Left unrepaired it either dead-ends legacy migration ("Migration
|
||||
// failed") or, on the hydration paths where validation is non-fatal,
|
||||
// loads and crashes the theme pipeline on every launch.
|
||||
//
|
||||
// Deliberately NOT matched on `error.expected`: typia reports this as
|
||||
// the generated name `Readonly<__type>.oNN`, whose ordinal shifts
|
||||
// whenever the type graph changes. Keying on it would make this branch
|
||||
// silently stop firing. Path + `value === undefined` is stable.
|
||||
// Copy, never share the constant by reference: two theme-less entities
|
||||
// would otherwise alias one object, and any later mutation would write
|
||||
// straight through into DEFAULT_TAG/DEFAULT_PROJECT for the whole app.
|
||||
const theme = {
|
||||
...(keys[0] === 'tag' ? DEFAULT_TAG.theme : DEFAULT_PROJECT.theme),
|
||||
};
|
||||
setValueByPath(data, keys, theme);
|
||||
logAutoFixApplied(
|
||||
path,
|
||||
keys,
|
||||
'work-context-theme-undefined-to-default',
|
||||
value,
|
||||
theme,
|
||||
);
|
||||
} else if (
|
||||
keys[0] === 'metric' &&
|
||||
keys[1] === 'entities' &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue