fix(client): lint conformance for the preference services

Replace optional chains on always-present globals with the repo's
typeof guards (store.ts precedent), drop the non-null assertion by
failing loud on an impossible registry miss, and fix two arrow-parens
slips; cover the no-localStorage boot path in both service suites.
This commit is contained in:
imccyu
2026-07-26 01:30:46 +08:00
parent 8cc46e6025
commit 2ee4cda066
6 changed files with 42 additions and 10 deletions

View File

@@ -87,7 +87,8 @@ export class ThemeService {
constructor(ctx: Context) {
this.ctx = ctx
this.preference = restorePreference()
this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
this.snapshot = this.buildSnapshot()
if (this.media !== undefined) {
const media = this.media
@@ -157,8 +158,9 @@ export class ThemeService {
: this.preference
// Both built-ins always exist; a registered preference id resolves or has
// been reset by its disposer, so the lookup cannot miss.
/* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */
const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]!
const active = this.themes.find(t => t.id === resolvedId)
/* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
return Object.freeze({
preference: this.preference,
active,
@@ -176,8 +178,10 @@ export class ThemeService {
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
function restorePreference(): ThemePreference {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE
try {
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
@@ -187,8 +191,9 @@ function restorePreference(): ThemePreference {
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
function persistPreference(preference: ThemePreference): void {
if (typeof localStorage === 'undefined') return
try {
globalThis.localStorage?.setItem(STORAGE_KEY, preference)
localStorage.setItem(STORAGE_KEY, preference)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.

View File

@@ -88,6 +88,18 @@ describe('ThemeService', () => {
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
})
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
vi.stubGlobal('localStorage', undefined)
try {
const { theme } = make()
expect(theme.getTheme().preference).toBe('system')
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
} finally {
vi.unstubAllGlobals()
}
})
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
type Listener = () => void
const stubMedia = (initialMatches: boolean) => {