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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-30 21:45:20 -04:00
parent f1621df3e2
commit 547f2d4089
3 changed files with 47 additions and 17 deletions

View File

@@ -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<PopupState>): void;
navigate(view: View, extras?: Partial<PopupState>): void;
sendMessage(request: Request): Promise<Response>;
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<PopupState>): 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<PopupState>): void {
if (!host) throw new Error('No state host registered');
host.navigate(view, extras);
}
export function sendMessage(request: Request): Promise<Response> {
/**
* 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<Response> {
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();
}