FN-6776: prevent workflow board legacy flash

Prevent workflow-enabled board surfaces from flashing the legacy layout during first paint.

- Hydrate Board and ListView workflow metadata from a per-project session cache before async revalidation.
- Gate legacy board/list rendering behind settings and workflow-lane metadata readiness, with neutral skeleton states for cold or empty workflow loads.
- Add regression coverage for no-legacy-flash behavior and cache validation, plus docs and changeset notes.

Files changed:
 .changeset/fn-6776-board-flash.md                  |   5 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/App.tsx                     |   4 +
 packages/dashboard/app/components/Board.css        |  69 +++++
 packages/dashboard/app/components/Board.tsx        |  53 +++-
 packages/dashboard/app/components/ListView.css     |  53 ++++
 packages/dashboard/app/components/ListView.tsx     |  52 +++-
 .../__tests__/board-no-legacy-flash.test.tsx       | 326 +++++++++++++++++++++
 .../utils/__tests__/boardWorkflowsCache.test.ts    |  71 +++++
 .../dashboard/app/utils/boardWorkflowsCache.ts     |  49 ++++
 10 files changed, 676 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6776

Fusion-Task-Lineage: caa8e53f-9b6d-49a6-94ca-b694550b43ef
This commit is contained in:
gsxdsm
2026-06-20 15:42:16 -07:00
parent 08d1f09107
commit c808177979
10 changed files with 676 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Eliminate the legacy board flash before workflow lanes load by caching per-project board workflow metadata and showing a neutral skeleton while metadata resolves.

View File

@@ -78,6 +78,7 @@ Features:
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns. - Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash.
![Board view](./screenshots/dashboard-overview.png) ![Board view](./screenshots/dashboard-overview.png)

View File

@@ -1849,6 +1849,8 @@ function AppInner() {
prAuthAvailable={prAuthAvailable} prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav} onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav} onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
settingsLoaded={settingsLoaded}
/> />
</PageErrorBoundary> </PageErrorBoundary>
); );
@@ -1887,6 +1889,8 @@ function AppInner() {
prAuthAvailable={prAuthAvailable} prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge} autoMerge={autoMerge}
onCreateWorkflow={openCreateWorkflowWithNav} onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
settingsLoaded={settingsLoaded}
/> />
</PageErrorBoundary> </PageErrorBoundary>
); );

View File

@@ -0,0 +1,69 @@
/*
FNXC:BoardWorkflows 2026-06-20-09:02:
The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load.
*/
.board.board-workflows-skeleton {
display: flex;
align-items: stretch;
gap: var(--column-gap, var(--space-md));
overflow-x: auto;
overflow-y: hidden;
padding: var(--board-padding, var(--space-md));
background: var(--bg);
}
.board-workflows-skeleton__column {
flex: 1 0 min(24rem, 85vw);
min-width: min(24rem, 85vw);
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border);
background: var(--surface);
}
.board-workflows-skeleton__header,
.board-workflows-skeleton__card {
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--border) 70%, var(--surface));
animation: board-workflows-skeleton-pulse 1.4s ease-in-out infinite;
}
.board-workflows-skeleton__header {
block-size: var(--space-lg);
inline-size: 55%;
}
.board-workflows-skeleton__card {
block-size: calc(var(--space-2xl) * 2);
inline-size: 100%;
}
.board-workflows-skeleton__card--short {
inline-size: 75%;
}
@keyframes board-workflows-skeleton-pulse {
0%, 100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (max-width: 768px) {
.board.board-workflows-skeleton {
gap: var(--space-sm);
padding: var(--space-sm);
scroll-snap-type: x proximity;
}
.board-workflows-skeleton__column {
flex-basis: 86vw;
min-width: 86vw;
scroll-snap-align: center;
}
}

View File

@@ -3,6 +3,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting"; import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column"; import { Column } from "./Column";
import "./Lane.css"; import "./Lane.css";
import "./Board.css";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,6 +16,7 @@ import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask"; import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
interface BoardProps { interface BoardProps {
tasks: Task[]; tasks: Task[];
@@ -74,6 +76,10 @@ interface BoardProps {
onOpenWorkflowEditor?: (workflowId?: string) => void; onOpenWorkflowEditor?: (workflowId?: string) => void;
/** Opens the workflow editor to create a new workflow. */ /** Opens the workflow editor to create a new workflow. */
onCreateWorkflow?: () => void; onCreateWorkflow?: () => void;
/** Already-resolved app setting for whether workflow lanes should be used. */
workflowColumnsEnabled?: boolean;
/** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */
settingsLoaded?: boolean;
} }
@@ -124,7 +130,21 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
return true; return true;
} }
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow }: BoardProps) { function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
return (
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
{[0, 1, 2].map((index) => (
<section className="board-workflows-skeleton__column card" key={index} aria-hidden="true">
<div className="board-workflows-skeleton__header" />
<div className="board-workflows-skeleton__card" />
<div className="board-workflows-skeleton__card board-workflows-skeleton__card--short" />
</section>
))}
</main>
);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded }: BoardProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [archivedCollapsed, setArchivedCollapsed] = useState(true); const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false); const archivedLoadedRef = useRef(false);
@@ -330,9 +350,18 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
}, []); }, []);
// ── U9 multi-lane board (flag-gated) ────────────────────────────────────── // ── U9 multi-lane board (flag-gated) ──────────────────────────────────────
/*
FNXC:BoardWorkflows 2026-06-20-08:58:
Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown.
*/
// Fetch board-workflows metadata. When the flag is OFF the server returns // Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below. // { flagEnabled: false } and we render the legacy single-lane board below.
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null); const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null); const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const draggingTaskIdRef = useRef<string | null>(null); const draggingTaskIdRef = useRef<string | null>(null);
@@ -344,16 +373,24 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// when a definition's lanes / column traits change. The visibility/focus // when a definition's lanes / column traits change. The visibility/focus
// refetch below is retained as a stopgap for missed events / reconnects. // refetch below is retained as a stopgap for missed events / reconnects.
const boardWorkflowsFetchSeqRef = useRef(0); const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateBoardWorkflowsCache]);
useEffect(() => { useEffect(() => {
const runFetch = () => { const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current; const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId) fetchBoardWorkflows(projectId)
.then((payload) => { .then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
}) })
.catch(() => { .catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) { if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
} }
}); });
}; };
@@ -509,6 +546,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
// messages. We do NOT eagerly call `/api/github/batch-status` on board load. // messages. We do NOT eagerly call `/api/github/batch-status` on board load.
const shouldGateLegacyBoard = boardWorkflows === null
? (workflowColumnsEnabled === true || settingsLoaded === false)
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
if (shouldGateLegacyBoard) {
return <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
}
if (workflowMode && selectedWorkflow) { if (workflowMode && selectedWorkflow) {
return ( return (
<div className="board-workflow-view"> <div className="board-workflow-view">

View File

@@ -1139,3 +1139,56 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i
min-height: 36px; min-height: 36px;
} }
} }
/*
FNXC:BoardWorkflows 2026-06-20-09:12:
ListView needs a neutral workflow-loading shell under the same flag-aware gate as Board, avoiding a first-paint fallback to legacy list columns while workflow metadata revalidates.
*/
.list-view--workflow-skeleton {
gap: var(--space-lg);
}
.list-workflow-skeleton {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
border: 1px solid var(--border);
background: var(--surface);
}
.list-workflow-skeleton__row {
block-size: var(--space-xl);
inline-size: 100%;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--border) 70%, var(--surface));
animation: list-workflow-skeleton-pulse 1.4s ease-in-out infinite;
}
.list-workflow-skeleton__row--header {
inline-size: 60%;
}
.list-workflow-skeleton__row--short {
inline-size: 78%;
}
@keyframes list-workflow-skeleton-pulse {
0%, 100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (max-width: 768px) {
.list-view--workflow-skeleton {
gap: var(--space-md);
}
.list-workflow-skeleton {
padding: var(--space-md);
}
}

View File

@@ -23,6 +23,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ".
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
const COLUMN_COLOR_MAP: Record<Column, string> = { const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)", triage: "var(--triage)",
@@ -241,6 +242,8 @@ interface ListViewProps {
prAuthAvailable?: boolean; prAuthAvailable?: boolean;
autoMerge?: boolean; autoMerge?: boolean;
onCreateWorkflow?: () => void; onCreateWorkflow?: () => void;
workflowColumnsEnabled?: boolean;
settingsLoaded?: boolean;
} }
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
@@ -305,6 +308,8 @@ export function ListView({
prAuthAvailable, prAuthAvailable,
autoMerge, autoMerge,
onCreateWorkflow, onCreateWorkflow,
workflowColumnsEnabled,
settingsLoaded,
}: ListViewProps) { }: ListViewProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const columnLabel = useColumnLabel(); const columnLabel = useColumnLabel();
@@ -313,7 +318,16 @@ export function ListView({
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null); const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null); const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null); const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null); /*
FNXC:BoardWorkflows 2026-06-20-09:07:
ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown.
*/
const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null); const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile"; const isMobile = viewportMode === "mobile";
@@ -393,16 +407,24 @@ export function ListView({
setSidebarWidth(readSidebarWidth(projectId)); setSidebarWidth(readSidebarWidth(projectId));
}, [projectId, tasks]); }, [projectId, tasks]);
useEffect(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateBoardWorkflowsCache]);
useEffect(() => { useEffect(() => {
const runFetch = () => { const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current; const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId) fetchBoardWorkflows(projectId)
.then((payload) => { .then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
}) })
.catch(() => { .catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) { if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
} }
}); });
}; };
@@ -1689,6 +1711,22 @@ export function ListView({
</div> </div>
); );
const renderListWorkflowSkeleton = (empty = false) => (
<div className="list-view list-view--workflow-skeleton" aria-busy={!empty} aria-label={empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")} data-testid={empty ? "list-workflows-empty" : "list-workflows-skeleton"}>
<div className="list-view-header">
<div>
<h2>{t("listView.title", "List View")}</h2>
<p className="list-subtitle">{empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")}</p>
</div>
</div>
<div className="list-workflow-skeleton card" aria-hidden="true">
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--header" />
<div className="list-workflow-skeleton__row" />
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--short" />
</div>
</div>
);
const renderBulkEditToolbars = () => ( const renderBulkEditToolbars = () => (
<> <>
<div className="bulk-edit-toolbar"> <div className="bulk-edit-toolbar">
@@ -1770,6 +1808,14 @@ export function ListView({
</> </>
); );
const shouldGateLegacyList = boardWorkflows === null
? (workflowColumnsEnabled === true || settingsLoaded === false)
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
if (shouldGateLegacyList) {
return renderListWorkflowSkeleton(boardWorkflows?.flagEnabled === true);
}
return ( return (
<div className="list-view"> <div className="list-view">
{isMobile && ( {isMobile && (

View File

@@ -0,0 +1,326 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { Board } from "../Board";
import { ListView } from "../ListView";
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
import type { BoardWorkflowsPayload } from "../../api";
import type { Task } from "@fusion/core";
const apiMocks = vi.hoisted(() => ({
fetchBoardWorkflows: vi.fn(),
fetchWorkflowSteps: vi.fn(),
fetchNodes: vi.fn(),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
promoteTask: vi.fn(),
fetchModels: vi.fn(),
fetchSettings: vi.fn(),
fetchGlobalSettings: vi.fn(),
api: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchBoardWorkflows: apiMocks.fetchBoardWorkflows,
fetchWorkflowSteps: apiMocks.fetchWorkflowSteps,
fetchNodes: apiMocks.fetchNodes,
fetchTaskDetail: apiMocks.fetchTaskDetail,
batchUpdateTaskModels: apiMocks.batchUpdateTaskModels,
promoteTask: apiMocks.promoteTask,
fetchModels: apiMocks.fetchModels,
fetchSettings: apiMocks.fetchSettings,
fetchGlobalSettings: apiMocks.fetchGlobalSettings,
api: apiMocks.api,
}));
vi.mock("../../hooks/useBlockerFanout", () => ({
useBlockerFanout: () => new Map(),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
vi.mock("../Column", () => ({
Column: React.memo(({ column, workflowMode }: { column: string; workflowMode?: boolean }) => (
<div className="column" data-testid={`column-${column}`} data-workflow-mode={workflowMode ? "true" : "false"} />
)),
}));
const workflowPayload: BoardWorkflowsPayload = {
flagEnabled: true,
defaultWorkflowId: "workflow-a",
workflows: [
{
id: "workflow-a",
name: "Workflow A",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
},
{
id: "workflow-b",
name: "Workflow B",
columns: [
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
},
],
taskWorkflowIds: {},
};
const emptyWorkflowPayload: BoardWorkflowsPayload = {
flagEnabled: true,
defaultWorkflowId: "workflow-a",
workflows: [],
taskWorkflowIds: {},
};
const flagOffPayload: BoardWorkflowsPayload = {
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function mockViewport(width: number) {
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn((query: string) => ({
matches: query.includes("768px") ? width <= 768 : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
const tasks: Task[] = [];
const boardProps = {
tasks,
maxConcurrent: 2,
onMoveTask: vi.fn(async () => ({} as Task)),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onNewTask: vi.fn(),
autoMerge: true,
onToggleAutoMerge: vi.fn(),
};
const listProps = {
tasks,
onMoveTask: vi.fn(async () => ({} as Task)),
onDeleteTask: vi.fn(async () => ({} as Task)),
onMergeTask: vi.fn(async () => ({} as never)),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onCreateWorkflow: vi.fn(),
};
type Surface = "Board" | "ListView";
type Breakpoint = "desktop" | "mobile";
function renderSurface(surface: Surface, projectId = "project-a") {
if (surface === "Board") {
return render(<Board {...boardProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
}
return render(<ListView {...listProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
}
function expectWorkflowLayout(surface: Surface) {
if (surface === "Board") {
expect(document.querySelector(".board-workflow-columns")).not.toBeNull();
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
expect(document.querySelectorAll('.column[data-workflow-mode="true"]').length).toBeGreaterThan(0);
return;
}
expect(screen.getByTestId("workflow-switcher")).toBeInTheDocument();
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
}
function expectLegacyLayout(surface: Surface) {
if (surface === "Board") {
expect(document.querySelector(".board-workflow-columns")).toBeNull();
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
expect(document.querySelectorAll('.column[data-workflow-mode="false"]').length).toBeGreaterThan(0);
return;
}
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("list-split-layout")).toBeInTheDocument();
}
function expectSkeleton(surface: Surface, empty = false) {
if (surface === "Board") {
expect(screen.getByTestId(empty ? "board-workflows-empty" : "board-workflows-skeleton")).toBeInTheDocument();
expect(document.querySelector(".board-workflow-columns")).toBeNull();
expect(document.querySelectorAll(".column")).toHaveLength(0);
return;
}
expect(screen.getByTestId(empty ? "list-workflows-empty" : "list-workflows-skeleton")).toBeInTheDocument();
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
}
describe("no legacy-board flash before workflow lanes load (FN-6776)", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.fetchWorkflowSteps.mockResolvedValue([]);
apiMocks.fetchNodes.mockResolvedValue([]);
apiMocks.fetchTaskDetail.mockResolvedValue(null);
apiMocks.promoteTask.mockResolvedValue({});
apiMocks.fetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
apiMocks.fetchSettings.mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} });
apiMocks.fetchGlobalSettings.mockResolvedValue({});
apiMocks.api.mockResolvedValue({ sessions: [] });
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
window.sessionStorage.clear();
vi.unstubAllGlobals();
});
it.each<Surface>(["Board", "ListView"])("%s renders legacy immediately when workflowColumns is known off", (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
if (surface === "Board") {
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
} else {
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
}
expectLegacyLayout(surface);
});
it.each<[Surface, Breakpoint]>([
["Board", "desktop"],
["Board", "mobile"],
["ListView", "desktop"],
["ListView", "mobile"],
])("%s at %s renders skeleton, not legacy, while uncached workflow payload is pending", async (surface, breakpoint) => {
mockViewport(breakpoint === "mobile" ? 390 : 1200);
const deferred = createDeferred<BoardWorkflowsPayload>();
apiMocks.fetchBoardWorkflows.mockReturnValue(deferred.promise);
renderSurface(surface);
expectSkeleton(surface);
await act(async () => {
deferred.resolve(workflowPayload);
await deferred.promise;
});
await waitFor(() => expectWorkflowLayout(surface));
});
it.each<[Surface, Breakpoint]>([
["Board", "desktop"],
["Board", "mobile"],
["ListView", "desktop"],
["ListView", "mobile"],
])("%s at %s renders cached workflow lanes on first paint", (surface, breakpoint) => {
mockViewport(breakpoint === "mobile" ? 390 : 1200);
writeBoardWorkflowsCache("project-a", workflowPayload);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
renderSurface(surface);
expectWorkflowLayout(surface);
});
it.each<Surface>(["Board", "ListView"])("%s keeps legacy hidden when the enabled payload has no workflows", async (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockResolvedValue(emptyWorkflowPayload);
renderSurface(surface);
await waitFor(() => expectSkeleton(surface, true));
expectLegacyLayoutHiddenForEmpty(surface);
});
it.each<Surface>(["Board", "ListView"])("%s renders skeleton while settings are not loaded", (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
if (surface === "Board") {
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
} else {
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
}
expectSkeleton(surface);
});
it.each<Surface>(["Board", "ListView"])("%s fetch error exits the skeleton to a terminal legacy layout", async (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockRejectedValue(new Error("network"));
renderSurface(surface);
expectSkeleton(surface);
await waitFor(() => expectLegacyLayout(surface));
});
it.each<Surface>(["Board", "ListView"])("%s does not leak cached workflow layouts across project switches", async (surface) => {
mockViewport(1024);
writeBoardWorkflowsCache("project-a", workflowPayload);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
const view = renderSurface(surface, "project-a");
expectWorkflowLayout(surface);
if (surface === "Board") {
view.rerender(<Board {...boardProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
} else {
view.rerender(<ListView {...listProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
}
expectSkeleton(surface);
});
it.each<Surface>(["Board", "ListView"])("%s ignores another project's cache when flag-off payload is cached locally", (surface) => {
mockViewport(1024);
writeBoardWorkflowsCache("project-b", workflowPayload);
writeBoardWorkflowsCache("project-a", flagOffPayload);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
renderSurface(surface, "project-a");
expectLegacyLayout(surface);
});
});
function expectLegacyLayoutHiddenForEmpty(surface: Surface) {
if (surface === "Board") {
expect(document.querySelector(".board-workflow-columns")).toBeNull();
expect(document.querySelectorAll(".column")).toHaveLength(0);
return;
}
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
}

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowsPayload } from "../../api";
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../boardWorkflowsCache";
const payload: BoardWorkflowsPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [{ id: "todo", name: "Todo", flags: {} }],
},
],
taskWorkflowIds: { "FN-1": "builtin:coding" },
};
describe("boardWorkflowsCache", () => {
afterEach(() => {
if (typeof window !== "undefined") {
window.sessionStorage.clear();
}
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("round-trips board-workflows payloads per project", () => {
writeBoardWorkflowsCache("project-a", payload);
expect(readBoardWorkflowsCache("project-a")).toEqual(payload);
});
it("keeps project cache keys isolated", () => {
writeBoardWorkflowsCache("project-a", payload);
expect(readBoardWorkflowsCache("project-b")).toBeNull();
});
it("returns null for missing, corrupt, or invalid entries", () => {
expect(readBoardWorkflowsCache("missing")).toBeNull();
window.sessionStorage.setItem("fusion:board-workflows:corrupt", "{");
expect(readBoardWorkflowsCache("corrupt")).toBeNull();
window.sessionStorage.setItem("fusion:board-workflows:invalid", JSON.stringify({ flagEnabled: true, workflows: {} }));
expect(readBoardWorkflowsCache("invalid")).toBeNull();
});
it("swallows sessionStorage write failures", () => {
vi.spyOn(window.sessionStorage.__proto__, "setItem").mockImplementation(() => {
throw new Error("quota");
});
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
});
it("swallows sessionStorage read failures", () => {
vi.spyOn(window.sessionStorage.__proto__, "getItem").mockImplementation(() => {
throw new Error("private mode");
});
expect(readBoardWorkflowsCache("project-a")).toBeNull();
});
it("returns null without window for SSR callers", () => {
vi.stubGlobal("window", undefined);
expect(readBoardWorkflowsCache("project-a")).toBeNull();
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
});
});

View File

@@ -0,0 +1,49 @@
import type { BoardWorkflowsPayload } from "../api";
const BOARD_WORKFLOWS_CACHE_PREFIX = "fusion:board-workflows:";
const DEFAULT_PROJECT_CACHE_KEY = "default";
function cacheKey(projectId?: string): string {
return `${BOARD_WORKFLOWS_CACHE_PREFIX}${projectId ?? DEFAULT_PROJECT_CACHE_KEY}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBoardWorkflowsPayload(value: unknown): value is BoardWorkflowsPayload {
if (!isRecord(value)) return false;
if (typeof value.flagEnabled !== "boolean") return false;
if (!Array.isArray(value.workflows)) return false;
if (typeof value.defaultWorkflowId !== "string") return false;
if (!isRecord(value.taskWorkflowIds)) return false;
return true;
}
/**
* FNXC:BoardWorkflows 2026-06-20-08:50:
* Cache the last successful board-workflows payload per project in sessionStorage so Board and ListView can render the correct workflow-lane layout immediately on remount and never flash the legacy single-lane board before the async revalidation finishes.
*/
export function readBoardWorkflowsCache(projectId?: string): BoardWorkflowsPayload | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(cacheKey(projectId));
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
return isBoardWorkflowsPayload(parsed) ? parsed : null;
} catch {
return null;
}
}
export function writeBoardWorkflowsCache(projectId: string | undefined, payload: BoardWorkflowsPayload): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(cacheKey(projectId), JSON.stringify(payload));
} catch {
// Private-mode/quota failures should never prevent board rendering.
}
}