Merge phase-c-1-statehost: Plan C Phase 1 (typed StateHost + __resetHostForTests)

P1.6 closed: shared/state.ts is type-checked end to end. StateHost
interface defines every field; double-registration throws; vitest gets
__resetHostForTests to break inter-test leakage. View + PopupState moved
to shared/popup-state.ts (broke the popup→shared circular-dep blocker).

PopupState widened to absorb VaultState's vault-tab-only fields (unlocked,
drawerOpen, typePanelOpen) with optional + commented justification, so the
two surfaces share one typed contract.

378/378 vitest tests pass (baseline 371 + 7 new state.test.ts cases).

Phase 5 still running in parallel. Cross-stream note from Phase 1 subagent:
settings.ts was not touched here, so Phase 5's teardownSettingsCommon
extraction rebases cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-30 21:48:07 -04:00
5 changed files with 184 additions and 39 deletions

View File

@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
// --- State --- // --- State ---
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history'; import type { View, PopupState } from '../shared/popup-state';
export interface PopupState {
view: View;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
// Captured tab snapshot taken at popup-open. Used by fill_credentials
// to guard against TOCTOU navigation — the SW re-checks this URL's
// hostname against the tab's live URL before forwarding fill_credentials
// to the content script. See router/popup-only.ts#handleFillCredentials.
capturedTabId: number | null;
capturedUrl: string;
newType: import('../shared/types').ItemType | null;
vaultSettings: import('../shared/types').VaultSettings | null;
generatorDefaults: import('../shared/types').GeneratorRequest | null;
historyItemId: import('../shared/types').ItemId | null;
}
let currentState: PopupState = { let currentState: PopupState = {
view: 'locked', view: 'locked',

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
registerHost,
__resetHostForTests,
getState,
setState,
navigate,
sendMessage,
} from '../state';
import type { StateHost } from '../state';
import type { PopupState } from '../popup-state';
function makeHost(initial?: Partial<PopupState>): StateHost {
let state: PopupState = {
view: 'list',
entries: [],
selectedId: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
error: null,
loading: false,
capturedTabId: null,
capturedUrl: '',
newType: null,
vaultSettings: null,
generatorDefaults: null,
historyItemId: null,
...initial,
};
return {
getState: () => state,
setState: (partial) => { state = { ...state, ...partial }; },
navigate: vi.fn(),
sendMessage: vi.fn().mockResolvedValue({ ok: true }),
escapeHtml: (s) => s,
popOutToTab: vi.fn(),
isInTab: () => false,
openVaultTab: vi.fn(),
};
}
describe('shared/state', () => {
beforeEach(() => {
__resetHostForTests();
});
it('register-then-getState round-trips', () => {
const host = makeHost({ view: 'detail' });
registerHost(host);
expect(getState().view).toBe('detail');
});
it('double-register throws', () => {
registerHost(makeHost());
expect(() => registerHost(makeHost())).toThrow(/already registered/);
});
it('__resetHostForTests clears the singleton', () => {
registerHost(makeHost());
__resetHostForTests();
expect(() => getState()).toThrow(/No state host/);
});
it('getState without host throws', () => {
expect(() => getState()).toThrow(/No state host/);
});
it('setState merges partial state', () => {
const host = makeHost();
registerHost(host);
setState({ loading: true });
expect(getState().loading).toBe(true);
});
it('navigate delegates to host', () => {
const host = makeHost();
registerHost(host);
navigate('settings');
expect(host.navigate).toHaveBeenCalledWith('settings', undefined);
});
it('sendMessage delegates to host', async () => {
const host = makeHost();
registerHost(host);
const resp = await sendMessage({ type: 'is_unlocked' });
expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
expect(resp).toEqual({ ok: true });
});
});

View File

@@ -0,0 +1,57 @@
// State shared between popup and vault surfaces. Kept here (not in popup/) so
// shared/state.ts can import without creating a popup→shared circular dep.
import type {
Item,
ItemId,
ItemType,
ManifestEntry,
GeneratorRequest,
VaultSettings,
} from './types';
export type View =
| 'locked'
| 'list'
| 'detail'
| 'add'
| 'edit'
| 'settings'
| 'settings-vault'
| 'trash'
| 'devices'
| '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;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
// Captured tab snapshot taken at popup-open. Used by fill_credentials
// to guard against TOCTOU navigation — the SW re-checks this URL's
// hostname against the tab's live URL before forwarding fill_credentials
// to the content script. See router/popup-only.ts#handleFillCredentials.
capturedTabId: number | null;
capturedUrl: string;
newType: ItemType | null;
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. // extension/src/shared/state.ts
/// //
/// Both popup.ts and vault.ts register themselves as the "host". // Single channel for popup and vault-tab UI to read/write app state and
/// All popup components import from here instead of from popup.ts, // dispatch messages to the service worker. Two registered hosts (popup,
/// so the same component code works in either bundle. // 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 { Request, Response } from './messages';
import type { PopupState, View } from './popup-state';
export interface StateHost { export interface StateHost {
getState(): any; getState(): PopupState;
setState(partial: any): void; setState(partial: Partial<PopupState>): void;
navigate(view: string, extras?: any): void; navigate(view: View, extras?: Partial<PopupState>): void;
sendMessage(request: Request): Promise<Response>; sendMessage(request: Request): Promise<Response>;
escapeHtml(s: string): string; escapeHtml(s: string): string;
popOutToTab(): void; popOutToTab(): void;
@@ -19,24 +25,36 @@ export interface StateHost {
let host: StateHost | null = null; 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'); if (!host) throw new Error('No state host registered');
return host.getState(); 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'); if (!host) throw new Error('No state host registered');
host.setState(partial); 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'); if (!host) throw new Error('No state host registered');
host.navigate(view, extras); 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'); if (!host) throw new Error('No state host registered');
return host.sendMessage(request); return host.sendMessage(request);
} }
@@ -52,7 +70,7 @@ export function popOutToTab(): void {
} }
export function isInTab(): boolean { export function isInTab(): boolean {
if (!host) return false; if (!host) throw new Error('No state host registered');
return host.isInTab(); return host.isInTab();
} }

View File

@@ -229,11 +229,11 @@ const state: VaultState = {
registerHost({ registerHost({
getState: () => state, getState: () => state,
setState: (partial: any) => { setState: (partial) => {
Object.assign(state, partial); Object.assign(state, partial);
renderPane(); renderPane();
}, },
navigate: (view: string, extras?: any) => { navigate: (view, extras) => {
Object.assign(state, { view, error: null, loading: false, ...extras }); Object.assign(state, { view, error: null, loading: false, ...extras });
setHash(view as VaultView); setHash(view as VaultView);
applyShellViewClass(); applyShellViewClass();