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

@@ -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;
}

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();
}

View File

@@ -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();