mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
fix(migration): normalize legacy theme values under v5 marker
Two related bugs surfaced during end-to-end verification:
1. The 'isAlreadyV5' short-circuit in both the main-side and renderer-side
v4-to-v5 transforms returned the persisted object as-is when a
migration.version=5 marker was present. A previously-shipped v4 build
had written theme: 'ayu-light' (and similar) under the v5 marker;
the transform trusted the marker and passed the invalid theme through,
which then failed the renderer's zod schema on every launch and
reset user settings to defaults.
Fix: in both transforms, when isAlreadyV5 matches, normalize theme
against the v5 enum (light/dark/system) and validate the full result
with the v5 schema before returning. Out-of-range values are replaced
with the v5 default ('system').
2. The renderer's settings-store onRehydrateStorage callback called
useSettingsStore.setState() to reset the store on validation failure.
At that moment the store is still being constructed, and setState
could hit a TDZ ReferenceError (the one we already wrapped in a
try/catch in v5.0.0, which only hid the symptom).
Fix: return the normalized state object from onRehydrateStorage
instead. Zustand's persist middleware applies the returned value
*after* construction completes, so there is no TDZ.
Tests: 334 vitest + 208 jest = 542 passing.
E2E: 12/12 verify-features.mjs steps green; no console errors.
Amit Haridas
This commit is contained in:
@@ -11,7 +11,36 @@ const v4SettingsSchema = z.object({
|
|||||||
snippets: z.array(z.unknown()).default([]),
|
snippets: z.array(z.unknown()).default([]),
|
||||||
}).passthrough();
|
}).passthrough();
|
||||||
|
|
||||||
|
const v5SettingsSchema = z.object({
|
||||||
|
fontSize: z.number().default(14),
|
||||||
|
tabSize: z.number().default(4),
|
||||||
|
lineNumbers: z.boolean().default(true),
|
||||||
|
wordWrap: z.boolean().default(true),
|
||||||
|
minimap: z.boolean().default(true),
|
||||||
|
theme: z.enum(['light', 'dark', 'system']).default('system'),
|
||||||
|
accentColor: z.string().default('brand'),
|
||||||
|
fontFamily: z.string().default('system'),
|
||||||
|
pdfFormat: z.string().default('a4'),
|
||||||
|
pdfMargins: z.string().default('normal'),
|
||||||
|
pdfEmbedFonts: z.boolean().default(true),
|
||||||
|
docxTemplate: z.string().default('standard'),
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null),
|
||||||
|
replOpen: z.boolean().default(false),
|
||||||
|
breadcrumbSymbols: z.boolean().default(true),
|
||||||
|
htmlHighlightStyle: z.string().default('github'),
|
||||||
|
renderTablesAsAscii: z.boolean().default(false),
|
||||||
|
welcomeDismissed: z.boolean().default(false),
|
||||||
|
editorFontSize: z.number().default(14),
|
||||||
|
customCssPath: z.string().nullable().default(null),
|
||||||
|
userBindings: z.record(z.string(), z.string()).default({}),
|
||||||
|
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
|
||||||
|
autoCheckUpdates: z.boolean().default(true),
|
||||||
|
firstRun: z.boolean().default(true),
|
||||||
|
'migration.version': z.literal(5).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
|
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
|
||||||
|
const v5ThemeValues = ['light', 'dark', 'system'];
|
||||||
|
|
||||||
function isAlreadyV5(data) {
|
function isAlreadyV5(data) {
|
||||||
if (!data || typeof data !== 'object') return false;
|
if (!data || typeof data !== 'object') return false;
|
||||||
@@ -20,6 +49,18 @@ function isAlreadyV5(data) {
|
|||||||
return v5OnlyFields.some((f) => f in data);
|
return v5OnlyFields.some((f) => f in data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAlreadyV5(data) {
|
||||||
|
// Some earlier v5 builds wrote a legacy theme value (e.g. "ayu-light")
|
||||||
|
// under the v5 marker. Trusting the marker blindly broke the renderer's
|
||||||
|
// zod schema on every launch. Always normalize theme against the v5 enum
|
||||||
|
// before returning, so persisted files are always valid v5.
|
||||||
|
const out = { ...data };
|
||||||
|
if (typeof out.theme !== 'string' || !v5ThemeValues.includes(out.theme)) {
|
||||||
|
out.theme = 'system';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const v5SettingsShape = {
|
const v5SettingsShape = {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
tabSize: 4,
|
tabSize: 4,
|
||||||
@@ -49,10 +90,12 @@ const v5SettingsShape = {
|
|||||||
|
|
||||||
function migrateV4ToV5(v4) {
|
function migrateV4ToV5(v4) {
|
||||||
// If data already looks like v5 (has migration.version=5 or v5-only fields),
|
// If data already looks like v5 (has migration.version=5 or v5-only fields),
|
||||||
// return it as-is so the runner can mark it done without re-migrating.
|
// normalize and validate against the v5 schema. This handles the case where
|
||||||
// This handles the case where a buggy v5 run wrote v5 fields without the marker.
|
// a buggy v5 run wrote v5 fields without the marker, AND the case where a
|
||||||
|
// v5 file has a legacy theme value (e.g. "ayu-light") that would otherwise
|
||||||
|
// be rejected by the renderer.
|
||||||
if (isAlreadyV5(v4)) {
|
if (isAlreadyV5(v4)) {
|
||||||
return v4;
|
return v5SettingsSchema.parse(normalizeAlreadyV5(v4));
|
||||||
}
|
}
|
||||||
const parsed = v4SettingsSchema.parse(v4 || {});
|
const parsed = v4SettingsSchema.parse(v4 || {});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const v4SettingsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
|
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
|
||||||
|
const v5ThemeValues = ['light', 'dark', 'system'] as const;
|
||||||
|
|
||||||
function isAlreadyV5(data: unknown): boolean {
|
function isAlreadyV5(data: unknown): boolean {
|
||||||
if (!data || typeof data !== 'object') return false;
|
if (!data || typeof data !== 'object') return false;
|
||||||
@@ -18,8 +19,23 @@ function isAlreadyV5(data: unknown): boolean {
|
|||||||
return v5OnlyFields.some((f) => f in (data as Record<string, unknown>));
|
return v5OnlyFields.some((f) => f in (data as Record<string, unknown>));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAlreadyV5(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
// Some earlier v5 builds wrote a legacy theme value (e.g. "ayu-light")
|
||||||
|
// under the v5 marker. Trusting the marker blindly broke the renderer's
|
||||||
|
// zod schema on every launch. Always normalize theme against the v5 enum
|
||||||
|
// before returning, so persisted files are always valid v5.
|
||||||
|
const out = { ...data };
|
||||||
|
if (typeof out.theme !== 'string' || !v5ThemeValues.includes(out.theme as (typeof v5ThemeValues)[number])) {
|
||||||
|
out.theme = 'system';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function migrateV4ToV5(v4: unknown): z.infer<typeof settingsSchema> {
|
export function migrateV4ToV5(v4: unknown): z.infer<typeof settingsSchema> {
|
||||||
if (isAlreadyV5(v4)) return v4 as z.infer<typeof settingsSchema>;
|
if (isAlreadyV5(v4)) {
|
||||||
|
const normalized = normalizeAlreadyV5(v4 as Record<string, unknown>);
|
||||||
|
return settingsSchema.parse(normalized);
|
||||||
|
}
|
||||||
const parsed = v4SettingsSchema.parse(v4);
|
const parsed = v4SettingsSchema.parse(v4);
|
||||||
const defaults = settingsSchema.parse({});
|
const defaults = settingsSchema.parse({});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,18 +25,22 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
const { setSetting, resetToDefaults, ...rest } = state;
|
const { setSetting, resetToDefaults, ...rest } = state;
|
||||||
return rest;
|
return rest;
|
||||||
},
|
},
|
||||||
|
// onRehydrateStorage must NOT call useSettingsStore.setState() — at the
|
||||||
|
// moment the callback runs the store is still being constructed, and
|
||||||
|
// setState can hit a TDZ ReferenceError. Instead, return a normalized
|
||||||
|
// state object from this callback. Zustand's persist middleware will
|
||||||
|
// merge it into the store *after* construction completes.
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
try {
|
|
||||||
const result = settingsSchema.safeParse(state);
|
const result = settingsSchema.safeParse(state);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.warn('[settings-store] invalid persisted state, resetting to defaults', result.error);
|
console.warn(
|
||||||
useSettingsStore.setState({ ...DEFAULTS } as any);
|
'[settings-store] invalid persisted state, replacing with defaults',
|
||||||
}
|
result.error.issues.map((i) => i.path.join('.') + ': ' + i.message).join('; '),
|
||||||
} catch (err) {
|
);
|
||||||
console.warn('[settings-store] rehydration failed, resetting to defaults', err);
|
return { ...DEFAULTS } as Partial<SettingsState>;
|
||||||
useSettingsStore.setState({ ...DEFAULTS } as any);
|
|
||||||
}
|
}
|
||||||
|
return result.data as Partial<SettingsState>;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ describe('migrateV4ToV5', () => {
|
|||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy theme names like "ayu-light" to a v5 value', () => {
|
||||||
|
// A previously-shipped v4 build wrote theme: 'ayu-light' (and similar).
|
||||||
|
// When an already-v5-marker file has such a value, isAlreadyV5 used to
|
||||||
|
// short-circuit and return the file as-is — which then failed the v5
|
||||||
|
// schema and reset user settings on every launch. The transform must
|
||||||
|
// normalize unknown theme values to the schema's default.
|
||||||
|
const out = migrateV4ToV5({ 'migration.version': 5, theme: 'ayu-light' });
|
||||||
|
expect(['light', 'dark', 'system']).toContain(out.theme);
|
||||||
|
});
|
||||||
|
|
||||||
test('exposes the v4 schema for use by main', () => {
|
test('exposes the v4 schema for use by main', () => {
|
||||||
expect(v4SettingsSchema).toBeDefined();
|
expect(v4SettingsSchema).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Regression test for the v5 migration normalizer: previously, files with
|
||||||
|
// migration.version:5 but a legacy theme value (e.g. 'ayu-light') were
|
||||||
|
// short-circuited and returned as-is, breaking the renderer schema.
|
||||||
|
const { migrateV4ToV5 } = require('../../../../src/main/updater/migration-transform');
|
||||||
|
|
||||||
|
describe('migrateV4ToV5 (main mirror) — regression: legacy theme under v5 marker', () => {
|
||||||
|
test('normalizes theme: ayu-light under v5 marker to a v5 enum value', () => {
|
||||||
|
const out = migrateV4ToV5({ 'migration.version': 5, theme: 'ayu-light' });
|
||||||
|
expect(['light', 'dark', 'system']).toContain(out.theme);
|
||||||
|
});
|
||||||
|
test('preserves a valid v5 theme', () => {
|
||||||
|
const out = migrateV4ToV5({ 'migration.version': 5, theme: 'dark' });
|
||||||
|
expect(out.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
test('still maps v4 theme: auto to system', () => {
|
||||||
|
const out = migrateV4ToV5({ theme: 'auto' });
|
||||||
|
expect(out.theme).toBe('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user