From 547f2d40894cd584df8dad6fb5a5cac6bebd10e3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 30 May 2026 21:45:20 -0400 Subject: [PATCH] refactor(ext/shared): typed StateHost + sweep as-any casts (Plan C Phase 1) Replaces the previously any-typed StateHost contract with a typed interface. Adds double-registration guard and __resetHostForTests for vitest. sendMessage wrapper is currently a pass-through; Phase 4 will fill its body with the vault_locked intercept lifted from vault.ts. Widens PopupState/View on shared/popup-state.ts to cover vault-tab-only views (history, backup, import) and vault-tab-only fields (unlocked, drawerOpen, typePanelOpen) so VaultState satisfies StateHost.getState() without a cast. The popup surface ignores the new optional fields. Drops the `any` annotations on vault.ts's registerHost callbacks now that the typed StateHost contract infers them from PopupState. Co-Authored-By: Claude Opus 4.7 --- extension/src/shared/popup-state.ts | 14 ++++++++- extension/src/shared/state.ts | 46 ++++++++++++++++++++--------- extension/src/vault/vault.ts | 4 +-- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts index eb2c728..09e94e7 100644 --- a/extension/src/shared/popup-state.ts +++ b/extension/src/shared/popup-state.ts @@ -20,7 +20,12 @@ export type View = | 'settings-vault' | 'trash' | 'devices' - | 'field-history'; + | 'field-history' + // Vault-tab-only views; popup never navigates to these. Kept in the union so + // a single typed StateHost contract serves both surfaces (popup + vault). + | 'history' + | 'backup' + | 'import'; export interface PopupState { view: View; @@ -42,4 +47,11 @@ export interface PopupState { vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; historyItemId: ItemId | null; + // Vault-tab-only fields. The popup surface leaves these at their defaults + // (unlocked=false implicit via separate lock-screen view, drawer/panel false). + // Kept on the shared shape so VaultState satisfies StateHost.getState() + // without a cast. + unlocked?: boolean; + drawerOpen?: boolean; + typePanelOpen?: boolean; } diff --git a/extension/src/shared/state.ts b/extension/src/shared/state.ts index 07f914b..30deeaf 100644 --- a/extension/src/shared/state.ts +++ b/extension/src/shared/state.ts @@ -1,15 +1,21 @@ -/// Service-locator for cross-bundle state access. -/// -/// Both popup.ts and vault.ts register themselves as the "host". -/// All popup components import from here instead of from popup.ts, -/// so the same component code works in either bundle. +// extension/src/shared/state.ts +// +// Single channel for popup and vault-tab UI to read/write app state and +// dispatch messages to the service worker. Two registered hosts (popup, +// vault tab) implement StateHost; each surface calls registerHost(this) at +// boot. +// +// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts +// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature; +// the body is a thin pass-through until Phase 4. import type { Request, Response } from './messages'; +import type { PopupState, View } from './popup-state'; export interface StateHost { - getState(): any; - setState(partial: any): void; - navigate(view: string, extras?: any): void; + getState(): PopupState; + setState(partial: Partial): void; + navigate(view: View, extras?: Partial): void; sendMessage(request: Request): Promise; escapeHtml(s: string): string; popOutToTab(): void; @@ -19,24 +25,36 @@ export interface StateHost { let host: StateHost | null = null; -export function registerHost(h: StateHost): void { host = h; } +export function registerHost(h: StateHost): void { + if (host) throw new Error('state host already registered'); + host = h; +} -export function getState(): any { +/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */ +export function __resetHostForTests(): void { + host = null; +} + +export function getState(): PopupState { if (!host) throw new Error('No state host registered'); return host.getState(); } -export function setState(partial: any): void { +export function setState(partial: Partial): void { if (!host) throw new Error('No state host registered'); host.setState(partial); } -export function navigate(view: string, extras?: any): void { +export function navigate(view: View, extras?: Partial): void { if (!host) throw new Error('No state host registered'); host.navigate(view, extras); } -export function sendMessage(request: Request): Promise { +/** + * Phase 4 will add a vault_locked intercept here. For now, this is a pure + * pass-through so the signature is stable for Phase 4 to fill. + */ +export async function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); return host.sendMessage(request); } @@ -52,7 +70,7 @@ export function popOutToTab(): void { } export function isInTab(): boolean { - if (!host) return false; + if (!host) throw new Error('No state host registered'); return host.isInTab(); } diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 9bbdc57..421c918 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -229,11 +229,11 @@ const state: VaultState = { registerHost({ getState: () => state, - setState: (partial: any) => { + setState: (partial) => { Object.assign(state, partial); renderPane(); }, - navigate: (view: string, extras?: any) => { + navigate: (view, extras) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass();