From 1b4df28fdd49507ca6a6c5225de36d90068bdf96 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 3 Sep 2026 18:19:16 -0400 Subject: [PATCH] fix(webv2): never hydrate the workbench without a project An editor always holds a project: `closeProject` refuses the last tab, and a session with none is the Home screen. Every load path is meant to seed a draft before hydration, but one returns the cached snapshot verbatim -- when a project the canvas gate refused cannot be retained, the primary cache is deliberately left in place and handed over as-is. That cache is projectless whenever the last tab was closed before the reload, so the store hydrates with `projects: []` and its active-project selector answers `undefined`. Nothing catches that: `activeProject` is typed `Project`, and the first consumer to read it dereferences undefined instead of finding an empty editor. That consumer is `BootWidgetHintController`, whose first property access happens to be `widgetRegions` -- so the whole editor fails to mount with "Cannot read properties of undefined (reading 'widgetRegions')" before the shell renders anything, and the only recovery is clearing localStorage. Seed the draft in `normalizeWorkbenchState` instead, the one point every hydration passes through, so no load path can produce a projectless store regardless of which one returned. The draft is built from the hydrated account, like the offline path's replacement draft: an empty cache still owns the account's preset defaults. Repair a dangling `activeProjectId` there too -- it named no project after a refusal, and `updateActiveProject` matches on it, so edits to the project the UI was showing (`projects[0]`, via the selector's fallback) silently went nowhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014xFbHnmFTLsHE9e1PAdvme --- .../src/workbench/workbenchState.test.ts | 62 +++++++++++++++++++ .../webv2/src/workbench/workbenchState.ts | 35 ++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index 967bf828f7c..435d81447de 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -471,6 +471,68 @@ describe('generation-device orchestration metadata', () => { }); }); +describe('workbench hydration invariants', () => { + it('seeds a draft when a projectless session hydrates', () => { + const initial = createInitialWorkbenchState(); + // What `persistEmptySession` caches after the last tab is closed. A load path + // that hands this over verbatim used to leave the store with no active project, + // which the first consumer to read one dereferences. + const emptySession: WorkbenchState = { ...initial, activeProjectId: '', projects: [] }; + + const hydrated = workbenchReducer(initial, { state: emptySession, type: 'hydrateWorkbench' }); + + expect(hydrated.projects).toHaveLength(1); + expect(hydrated.activeProjectId).toBe(hydrated.projects[0]?.id); + expect(getActiveProject(hydrated).widgetRegions.left.instanceIds.length).toBeGreaterThan(0); + }); + + it('builds that draft from the cached account, not the shipped defaults', () => { + const initial = createInitialWorkbenchState(); + const project = getActiveProject(initial); + // The account's saved override of the default preset is what an empty cache + // still owns; the seeded draft has to inherit it the way the offline load path's + // replacement draft does. + const customizedDefault = { + ...resolveSavedLayoutPreset(initial.account, initial.account.activeLayoutPresetId).snapshot, + widgetRegions: { + ...project.widgetRegions, + left: { ...project.widgetRegions.left, instanceIds: ['generate'] }, + }, + }; + const emptySession: WorkbenchState = { + ...initial, + account: { + ...initial.account, + layoutPresetOverrides: { [initial.account.activeLayoutPresetId]: customizedDefault }, + }, + activeProjectId: '', + projects: [], + }; + + const hydrated = workbenchReducer(initial, { state: emptySession, type: 'hydrateWorkbench' }); + + expect(getActiveProject(hydrated).widgetRegions.left.instanceIds).toEqual(['generate']); + }); + + it('repairs an active project id that names no hydrated project', () => { + const initial = createInitialWorkbenchState(); + const danglingActiveId: WorkbenchState = { ...initial, activeProjectId: 'project-that-was-refused' }; + + const hydrated = workbenchReducer(initial, { state: danglingActiveId, type: 'hydrateWorkbench' }); + + expect(hydrated.activeProjectId).toBe(hydrated.projects[0]?.id); + }); + + it('leaves a populated session alone', () => { + const initial = createInitialWorkbenchState(); + + const hydrated = workbenchReducer(initial, { state: initial, type: 'hydrateWorkbench' }); + + expect(hydrated.projects.map((project) => project.id)).toEqual(initial.projects.map((project) => project.id)); + expect(hydrated.activeProjectId).toBe(initial.activeProjectId); + }); +}); + describe('workbench widget region defaults', () => { it('starts new projects from the curated Compose widget defaults', () => { const state = createInitialWorkbenchState(); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 3ff9f36227b..d3b0a5b9ecb 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -2348,15 +2348,36 @@ export const normalizeWorkbenchAccount = (value: unknown): WorkbenchState['accou }; }; -const normalizeWorkbenchState = (state: WorkbenchState): WorkbenchState => ({ - ...state, - backendConnection: { status: 'connecting' }, +const normalizeWorkbenchState = (state: WorkbenchState): WorkbenchState => { // Built explicitly: legacy snapshots carried preferences inside the account // (they live in the settings store now) and must not resurface here. - account: normalizeWorkbenchAccount(state.account), - notifications: [], - projects: state.projects.map((project) => normalizeWorkbenchProject(project)), -}); + const account = normalizeWorkbenchAccount(state.account); + const restored = state.projects.map((project) => normalizeWorkbenchProject(project)); + // An editor always holds a project: `closeProject` refuses the last tab, and a + // session with none is the Home screen, whose cache the load paths are meant to + // replace with a fresh draft before handing the state over. One path does not -- + // when a project the canvas gate refused cannot be retained, the cached snapshot + // is returned verbatim, and that cache is projectless whenever the last tab was + // closed before the reload. Hydrating it leaves the store's active project + // undefined, and the first consumer to read it dereferences undefined rather than + // finding an empty editor: the boot widget hint, whose first access happens to be + // `widgetRegions`, before the shell renders anything. Seed the draft here, at the + // one point every load path passes through, so no snapshot can hydrate without a + // project regardless of which path produced it. + const projects = restored.length > 0 ? restored : [createDraftProject([], account)]; + const activeProjectId = projects.some((project) => project.id === state.activeProjectId) + ? state.activeProjectId + : projects[0]!.id; + + return { + ...state, + account, + activeProjectId, + backendConnection: { status: 'connecting' }, + notifications: [], + projects, + }; +}; const updateActiveLayout = ( state: WorkbenchState,