Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions invokeai/frontend/webv2/src/workbench/workbenchState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
35 changes: 28 additions & 7 deletions invokeai/frontend/webv2/src/workbench/workbenchState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading