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:
5
.changeset/fn-6776-board-flash.md
Normal file
5
.changeset/fn-6776-board-flash.md
Normal 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.
|
||||
@@ -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)
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1849,6 +1849,8 @@ function AppInner() {
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||
settingsLoaded={settingsLoaded}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
@@ -1887,6 +1889,8 @@ function AppInner() {
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||
settingsLoaded={settingsLoaded}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
69
packages/dashboard/app/components/Board.css
Normal file
69
packages/dashboard/app/components/Board.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||
import { Column } from "./Column";
|
||||
import "./Lane.css";
|
||||
import "./Board.css";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +16,7 @@ import { subscribeSse } from "../sse-bus";
|
||||
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -74,6 +76,10 @@ interface BoardProps {
|
||||
onOpenWorkflowEditor?: (workflowId?: string) => void;
|
||||
/** Opens the workflow editor to create a new workflow. */
|
||||
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;
|
||||
}
|
||||
|
||||
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
@@ -330,9 +350,18 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
}, []);
|
||||
|
||||
// ── 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
|
||||
// { 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 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
|
||||
// refetch below is retained as a stopgap for missed events / reconnects.
|
||||
const boardWorkflowsFetchSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||
}, [projectId, shouldHydrateBoardWorkflowsCache]);
|
||||
|
||||
useEffect(() => {
|
||||
const runFetch = () => {
|
||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||
fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflowsState({ projectId, payload });
|
||||
writeBoardWorkflowsCache(projectId, payload);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
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`
|
||||
// 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) {
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
|
||||
@@ -1139,3 +1139,56 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ".
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -241,6 +242,8 @@ interface ListViewProps {
|
||||
prAuthAvailable?: boolean;
|
||||
autoMerge?: boolean;
|
||||
onCreateWorkflow?: () => void;
|
||||
workflowColumnsEnabled?: boolean;
|
||||
settingsLoaded?: boolean;
|
||||
}
|
||||
|
||||
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
|
||||
@@ -305,6 +308,8 @@ export function ListView({
|
||||
prAuthAvailable,
|
||||
autoMerge,
|
||||
onCreateWorkflow,
|
||||
workflowColumnsEnabled,
|
||||
settingsLoaded,
|
||||
}: ListViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
@@ -313,7 +318,16 @@ export function ListView({
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = 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 viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
@@ -393,16 +407,24 @@ export function ListView({
|
||||
setSidebarWidth(readSidebarWidth(projectId));
|
||||
}, [projectId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||
}, [projectId, shouldHydrateBoardWorkflowsCache]);
|
||||
|
||||
useEffect(() => {
|
||||
const runFetch = () => {
|
||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||
fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflowsState({ projectId, payload });
|
||||
writeBoardWorkflowsCache(projectId, payload);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
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>
|
||||
);
|
||||
|
||||
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 = () => (
|
||||
<>
|
||||
<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 (
|
||||
<div className="list-view">
|
||||
{isMobile && (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
49
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal file
49
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user