Skip to content

Commit 089a0b5

Browse files
committed
feat: add migration from old settings to new user-preferences in LS
- Clean migrated keys from legacy storage after migration - Guard migration with `npmx-prefs-migrated` flag to run only once - Update hydration tests to use `npmx-user-preferences` storage key - Add E2E tests for legacy settings migration
1 parent d547d1b commit 089a0b5

File tree

3 files changed

+266
-12
lines changed

3 files changed

+266
-12
lines changed

app/utils/prehydrate.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,61 @@ export function initPreferencesOnPrehydrate() {
1212
// Callback is stringified by Nuxt - external variables won't be available.
1313
// All constants must be hardcoded inside the callback.
1414
onPrehydrate(() => {
15+
// See comment above for oxlint-disable reason
16+
// oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping)
17+
function getValueFromLs<T>(lsKey: string): T | undefined {
18+
try {
19+
const value = localStorage.getItem(lsKey)
20+
if (value) {
21+
const parsed = JSON.parse(value)
22+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
23+
return parsed
24+
}
25+
}
26+
} catch {
27+
return undefined
28+
}
29+
}
30+
// oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping)
31+
function migrateLegacySettings() {
32+
const migrationFlag = 'npmx-prefs-migrated'
33+
if (localStorage.getItem(migrationFlag)) return
34+
35+
const legacySettings = getValueFromLs<UserPreferences>('npmx-settings') || {}
36+
let userPreferences = getValueFromLs<UserPreferences>('npmx-user-preferences') || {}
37+
38+
const migratableKeys = [
39+
'accentColorId',
40+
'preferredBackgroundTheme',
41+
'selectedLocale',
42+
'relativeDates',
43+
] as const
44+
45+
const keysToMigrate = migratableKeys.filter(
46+
key => key in legacySettings && !(key in userPreferences),
47+
)
48+
49+
if (keysToMigrate.length > 0) {
50+
const migrated = Object.fromEntries(keysToMigrate.map(key => [key, legacySettings[key]]))
51+
userPreferences = { ...userPreferences, ...migrated }
52+
localStorage.setItem('npmx-user-preferences', JSON.stringify(userPreferences))
53+
}
54+
55+
// Clean migrated fields from legacy storage
56+
const keysToRemove = migratableKeys.filter(key => key in legacySettings)
57+
if (keysToRemove.length > 0) {
58+
const cleaned = { ...legacySettings }
59+
for (const key of keysToRemove) {
60+
delete cleaned[key]
61+
}
62+
localStorage.setItem('npmx-settings', JSON.stringify(cleaned))
63+
}
64+
65+
localStorage.setItem(migrationFlag, '1')
66+
}
67+
68+
migrateLegacySettings()
69+
1570
// Valid accent color IDs (must match --swatch-* variables defined in main.css)
1671
const accentColorIds = new Set([
1772
'sky',
@@ -27,10 +82,7 @@ export function initPreferencesOnPrehydrate() {
2782
const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt'])
2883

2984
// Read user preferences from localStorage
30-
let preferences: UserPreferences = {}
31-
try {
32-
preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
33-
} catch {}
85+
const preferences = getValueFromLs<UserPreferences>('npmx-user-preferences') || {}
3486

3587
const accentColorId = preferences.accentColorId
3688
if (accentColorId && accentColorIds.has(accentColorId)) {
@@ -64,10 +116,7 @@ export function initPreferencesOnPrehydrate() {
64116
// Set data attribute for CSS-based visibility
65117
document.documentElement.dataset.pm = pm
66118

67-
let localSettings: Partial<UserLocalSettings> = {}
68-
try {
69-
localSettings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
70-
} catch {}
119+
const localSettings = getValueFromLs<Partial<UserLocalSettings>>('npmx-settings') || {}
71120
document.documentElement.dataset.collapsed = localSettings.sidebar?.collapsed?.join(' ') ?? ''
72121
})
73122
}

test/e2e/hydration.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ test.describe('Hydration', () => {
4949
for (const page of PAGES) {
5050
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
5151
await injectLocalStorage(pw, {
52-
'npmx-settings': JSON.stringify({ accentColorId: 'violet' }),
52+
'npmx-user-preferences': JSON.stringify({ accentColorId: 'violet' }),
5353
})
5454
await goto(page, { waitUntil: 'hydration' })
5555

@@ -63,7 +63,7 @@ test.describe('Hydration', () => {
6363
for (const page of PAGES) {
6464
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
6565
await injectLocalStorage(pw, {
66-
'npmx-settings': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
66+
'npmx-user-preferences': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
6767
})
6868
await goto(page, { waitUntil: 'hydration' })
6969

@@ -91,7 +91,7 @@ test.describe('Hydration', () => {
9191
for (const page of PAGES) {
9292
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
9393
await injectLocalStorage(pw, {
94-
'npmx-settings': JSON.stringify({ selectedLocale: 'ar-EG' }),
94+
'npmx-user-preferences': JSON.stringify({ selectedLocale: 'ar-EG' }),
9595
})
9696
await goto(page, { waitUntil: 'hydration' })
9797

@@ -105,7 +105,7 @@ test.describe('Hydration', () => {
105105
for (const page of PAGES) {
106106
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
107107
await injectLocalStorage(pw, {
108-
'npmx-settings': JSON.stringify({ relativeDates: true }),
108+
'npmx-user-preferences': JSON.stringify({ relativeDates: true }),
109109
})
110110
await goto(page, { waitUntil: 'hydration' })
111111

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import type { Page } from '@playwright/test'
2+
import { expect, test } from './test-utils'
3+
4+
const LS_USER_PREFERENCES = 'npmx-user-preferences'
5+
const LS_LOCAL_SETTINGS = 'npmx-settings'
6+
const LS_MIGRATION_FLAG = 'npmx-prefs-migrated'
7+
8+
const MIGRATABLE_KEYS = [
9+
'accentColorId',
10+
'preferredBackgroundTheme',
11+
'selectedLocale',
12+
'relativeDates',
13+
] as const
14+
15+
async function injectLocalStorage(page: Page, entries: Record<string, string>) {
16+
await page.addInitScript((e: Record<string, string>) => {
17+
for (const [key, value] of Object.entries(e)) {
18+
localStorage.setItem(key, value)
19+
}
20+
}, entries)
21+
}
22+
23+
function readLs(page: Page, key: string) {
24+
return page.evaluate((k: string) => localStorage.getItem(k), key)
25+
}
26+
27+
function readLsJson(page: Page, key: string) {
28+
return page.evaluate((k: string) => {
29+
const raw = localStorage.getItem(k)
30+
return raw ? JSON.parse(raw) : null
31+
}, key)
32+
}
33+
34+
async function verifyDefaults(page: Page) {
35+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
36+
expect(prefs.accentColorId).toBeNull()
37+
expect(prefs.preferredBackgroundTheme).toBeNull()
38+
expect(prefs.selectedLocale).toBeNull()
39+
expect(prefs.relativeDates).toBe(false)
40+
}
41+
42+
async function verifyLegacyCleaned(page: Page) {
43+
const remaining = await readLsJson(page, LS_LOCAL_SETTINGS)
44+
for (const key of MIGRATABLE_KEYS) {
45+
expect(remaining).not.toHaveProperty(key)
46+
}
47+
}
48+
49+
test.describe('Legacy settings migration', () => {
50+
test('migrates all legacy keys to user preferences', async ({ page, goto }) => {
51+
const legacy = {
52+
accentColorId: 'violet',
53+
preferredBackgroundTheme: 'slate',
54+
selectedLocale: 'de',
55+
relativeDates: true,
56+
// non-migratable key should remain untouched
57+
sidebar: { collapsed: ['deps'] },
58+
}
59+
60+
await injectLocalStorage(page, {
61+
[LS_LOCAL_SETTINGS]: JSON.stringify(legacy),
62+
})
63+
64+
await goto('/', { waitUntil: 'hydration' })
65+
66+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
67+
expect(prefs).toMatchObject({
68+
accentColorId: 'violet',
69+
preferredBackgroundTheme: 'slate',
70+
selectedLocale: 'de',
71+
relativeDates: true,
72+
})
73+
74+
await verifyLegacyCleaned(page)
75+
const localSettings = await readLsJson(page, LS_LOCAL_SETTINGS)
76+
expect(localSettings).toMatchObject({
77+
sidebar: { collapsed: ['deps'] },
78+
})
79+
})
80+
81+
test('does not overwrite existing user preferences', async ({ page, goto }) => {
82+
await injectLocalStorage(page, {
83+
[LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral', relativeDates: false }),
84+
[LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }),
85+
})
86+
87+
await goto('/', { waitUntil: 'hydration' })
88+
89+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
90+
// accentColorId should remain 'violet' (not overwritten by legacy 'coral')
91+
expect(prefs.accentColorId).toBe('violet')
92+
// relativeDates was not in user prefs, so it should be migrated from legacy
93+
expect(prefs.relativeDates).toBe(false)
94+
95+
await verifyLegacyCleaned(page)
96+
})
97+
98+
test('cleans migrated keys from legacy storage', async ({ page, goto }) => {
99+
const legacy = {
100+
accentColorId: 'violet',
101+
preferredBackgroundTheme: 'slate',
102+
sidebar: { collapsed: ['deps'] },
103+
}
104+
105+
await injectLocalStorage(page, {
106+
[LS_LOCAL_SETTINGS]: JSON.stringify(legacy),
107+
})
108+
109+
await goto('/', { waitUntil: 'hydration' })
110+
111+
await verifyLegacyCleaned(page)
112+
const remaining = await readLsJson(page, LS_LOCAL_SETTINGS)
113+
expect(remaining).toHaveProperty('sidebar')
114+
})
115+
116+
test('sets migration flag after completion', async ({ page, goto }) => {
117+
await injectLocalStorage(page, {
118+
[LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }),
119+
})
120+
121+
await goto('/', { waitUntil: 'hydration' })
122+
123+
const flag = await readLs(page, LS_MIGRATION_FLAG)
124+
expect(flag).toBe('1')
125+
})
126+
127+
test('skips migration if flag is already set', async ({ page, goto }) => {
128+
await injectLocalStorage(page, {
129+
[LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral' }),
130+
[LS_MIGRATION_FLAG]: '1',
131+
})
132+
133+
await goto('/', { waitUntil: 'hydration' })
134+
135+
// Legacy accentColorId should NOT have been migrated since flag was already set
136+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
137+
expect(prefs?.accentColorId).not.toBe('coral')
138+
})
139+
140+
test('applies migrated accent color to DOM', async ({ page, goto }) => {
141+
await injectLocalStorage(page, {
142+
[LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }),
143+
})
144+
145+
await goto('/', { waitUntil: 'hydration' })
146+
147+
const accentColor = await page.evaluate(() =>
148+
document.documentElement.style.getPropertyValue('--accent-color'),
149+
)
150+
expect(accentColor).toBe('var(--swatch-violet)')
151+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
152+
expect(prefs?.accentColorId).toBe('violet')
153+
154+
await verifyLegacyCleaned(page)
155+
})
156+
157+
test('applies migrated background theme to DOM', async ({ page, goto }) => {
158+
await injectLocalStorage(page, {
159+
[LS_LOCAL_SETTINGS]: JSON.stringify({ preferredBackgroundTheme: 'slate' }),
160+
})
161+
162+
await goto('/', { waitUntil: 'hydration' })
163+
164+
const bgTheme = await page.evaluate(() => document.documentElement.dataset.bgTheme)
165+
expect(bgTheme).toBe('slate')
166+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
167+
expect(prefs?.preferredBackgroundTheme).toBe('slate')
168+
169+
await verifyLegacyCleaned(page)
170+
})
171+
172+
test('handles empty legacy storage gracefully', async ({ page, goto }) => {
173+
await injectLocalStorage(page, {
174+
[LS_LOCAL_SETTINGS]: JSON.stringify({}),
175+
})
176+
177+
await goto('/', { waitUntil: 'hydration' })
178+
179+
const flag = await readLs(page, LS_MIGRATION_FLAG)
180+
expect(flag).toBe('1')
181+
182+
await verifyDefaults(page)
183+
})
184+
185+
test('handles missing legacy storage gracefully', async ({ page, goto }) => {
186+
// No npmx-settings at all — migration should still set the flag
187+
await goto('/', { waitUntil: 'hydration' })
188+
189+
const flag = await readLs(page, LS_MIGRATION_FLAG)
190+
expect(flag).toBe('1')
191+
await verifyDefaults(page)
192+
})
193+
194+
test('handles missing legacy storage and applies current', async ({ page, goto }) => {
195+
await injectLocalStorage(page, {
196+
[LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }),
197+
})
198+
await goto('/', { waitUntil: 'hydration' })
199+
200+
const flag = await readLs(page, LS_MIGRATION_FLAG)
201+
expect(flag).toBe('1')
202+
const prefs = await readLsJson(page, LS_USER_PREFERENCES)
203+
expect(prefs?.accentColorId).toBe('violet')
204+
})
205+
})

0 commit comments

Comments
 (0)