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:
@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
||||
|
||||
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;
|
||||
}
|
||||
import type { View, PopupState } from '../shared/popup-state';
|
||||
|
||||
let currentState: PopupState = {
|
||||
view: 'locked',
|
||||
|
||||
92
extension/src/shared/__tests__/state.test.ts
Normal file
92
extension/src/shared/__tests__/state.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
57
extension/src/shared/popup-state.ts
Normal file
57
extension/src/shared/popup-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user