diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx
index 5d77818f1ba..856c13a7542 100644
--- a/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx
+++ b/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx
@@ -164,7 +164,7 @@ const FixEntry = () => {
}
const builderModel = { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} }
-const renderFlow = (edgeCount = 0) => {
+const renderFlow = (edgeCount = 0, reactStrictMode = false) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.textGeneration), {
data: {
@@ -220,7 +220,7 @@ const renderFlow = (edgeCount = 0) => {
,
- { features: { dify_builder_enabled: true }, queryClient },
+ { features: { dify_builder_enabled: true }, queryClient, reactStrictMode },
)
}
@@ -236,6 +236,237 @@ describe('Dify Builder Build, Edit, and Fix flows', () => {
window.sessionStorage.clear()
})
+ it.each([false, true])(
+ 'restores waiting Build actions after refresh with StrictMode=%s',
+ async (reactStrictMode) => {
+ const notice: ConversationItem = {
+ seq: 0,
+ at_version: 4,
+ kind: 'notice',
+ payload: { text: 'The workflow is ready on the canvas.' },
+ }
+ const waiting = createSessionView({
+ actions: [
+ { id: 'run_test', kind: 'primary', label: 'Run test' },
+ { id: 'revert', kind: 'destructive', label: 'Revert' },
+ ],
+ conversation_last_seq: 0,
+ entry_mode: 'build',
+ phase: 'modify',
+ run_status: 'waiting_confirmation',
+ state: 'build.execution',
+ version: 4,
+ })
+ mocks.get.mockImplementation(async (_input: unknown, { signal }: { signal: AbortSignal }) => {
+ await Promise.resolve()
+ signal.throwIfAborted()
+ return waiting
+ })
+ mocks.conversation.mockResolvedValue(conversationPage([notice]))
+ mocks.action.mockResolvedValue(
+ streamOf(
+ commandStartedEvent(waiting),
+ stateEvent({
+ ...waiting,
+ actions: [{ id: 'publish_workflow', kind: 'primary', label: 'Publish' }],
+ phase: 'review',
+ state: 'build.review',
+ version: 5,
+ }),
+ ),
+ )
+ window.sessionStorage.setItem(
+ 'dify-builder:v1:workspace-1:user-1:app-1:active-session-id',
+ waiting.session_id,
+ )
+ const user = userEvent.setup()
+ renderFlow(1, reactStrictMode)
+
+ expect(await screen.findByText(notice.payload.text)).toBeInTheDocument()
+ const runTest = screen.getByRole('button', { name: 'Run test' })
+ await waitFor(() => expect(runTest).toBeEnabled())
+ expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
+ expect(screen.queryByText('workflow.common.running')).not.toBeInTheDocument()
+ expect(mocks.refreshCanvas).toHaveBeenCalledOnce()
+ expect(mocks.stream).not.toHaveBeenCalled()
+
+ await user.click(runTest)
+
+ const publish = await screen.findByRole('button', { name: 'Publish' })
+ await waitFor(() => expect(publish).toBeEnabled())
+ expect(mocks.action).toHaveBeenCalledWith(
+ {
+ params: { session_id: waiting.session_id },
+ body: {
+ action_id: 'run_test',
+ base_app_revision: 'hash-1',
+ base_version: 4,
+ payload: {},
+ },
+ },
+ { signal: expect.any(AbortSignal) },
+ )
+ },
+ )
+
+ it('ignores a canceled restore response that arrives after the replacement restore', async () => {
+ const waiting = createSessionView({
+ actions: [
+ { id: 'run_test', kind: 'primary', label: 'Run test' },
+ { id: 'revert', kind: 'destructive', label: 'Revert' },
+ ],
+ entry_mode: 'build',
+ phase: 'modify',
+ run_status: 'waiting_confirmation',
+ state: 'build.execution',
+ version: 4,
+ })
+ let finishCanceledRequest!: (view: SessionView) => void
+ mocks.get
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishCanceledRequest = resolve
+ }),
+ )
+ .mockResolvedValue(waiting)
+ const storageKey = 'dify-builder:v1:workspace-1:user-1:app-1:active-session-id'
+ window.sessionStorage.setItem(storageKey, waiting.session_id)
+ renderFlow(1, true)
+
+ const runTest = await screen.findByRole('button', { name: 'Run test' })
+ await waitFor(() => expect(runTest).toBeEnabled())
+
+ // The lock can settle without changing the durable session version.
+ await act(async () => {
+ finishCanceledRequest({ ...waiting, run_status: 'processing', canvas_read_only: true })
+ })
+
+ expect(runTest).toBeEnabled()
+ expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
+ expect(screen.queryByText('workflow.common.running')).not.toBeInTheDocument()
+ expect(window.sessionStorage.getItem(storageKey)).toBe(waiting.session_id)
+ expect(mocks.stream).not.toHaveBeenCalled()
+ })
+
+ it.each([false, true])(
+ 'refreshes the canvas after restored history loads with StrictMode=%s',
+ async (reactStrictMode) => {
+ const waiting = createSessionView({
+ actions: [
+ { id: 'run_test', kind: 'primary', label: 'Run test' },
+ { id: 'revert', kind: 'destructive', label: 'Revert' },
+ ],
+ entry_mode: 'build',
+ phase: 'modify',
+ run_status: 'waiting_confirmation',
+ state: 'build.execution',
+ version: 6,
+ })
+ let finishHistory!: (page: ReturnType) => void
+ mocks.get.mockImplementation(async (_input: unknown, { signal }: { signal: AbortSignal }) => {
+ await Promise.resolve()
+ signal.throwIfAborted()
+ return waiting
+ })
+ mocks.conversation.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishHistory = resolve
+ }),
+ )
+ window.sessionStorage.setItem(
+ 'dify-builder:v1:workspace-1:user-1:app-1:active-session-id',
+ waiting.session_id,
+ )
+ renderFlow(1, reactStrictMode)
+
+ const runTest = await screen.findByRole('button', { name: 'Run test' })
+ expect(runTest).toBeDisabled()
+ expect(mocks.refreshCanvas).not.toHaveBeenCalled()
+
+ await act(async () => finishHistory(conversationPage()))
+
+ await waitFor(() => expect(runTest).toBeEnabled())
+ expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
+ expect(screen.queryByText('workflow.common.running')).not.toBeInTheDocument()
+ expect(mocks.refreshCanvas).toHaveBeenCalledOnce()
+ expect(mocks.setCanvasReadOnly).toHaveBeenLastCalledWith(false)
+ },
+ )
+
+ it.each([false, true])(
+ 'reconnects graph generation and unlocks its settled canvas with StrictMode=%s',
+ async (reactStrictMode) => {
+ const processing = createSessionView({
+ canvas_read_only: true,
+ entry_mode: 'build',
+ phase: 'plan',
+ run_status: 'processing',
+ state: 'build.plan_approval',
+ version: 5,
+ })
+ const waiting = createSessionView({
+ actions: [
+ { id: 'run_test', kind: 'primary', label: 'Run test' },
+ { id: 'revert', kind: 'destructive', label: 'Revert' },
+ ],
+ conversation_last_seq: 0,
+ entry_mode: 'build',
+ phase: 'modify',
+ run_status: 'waiting_confirmation',
+ state: 'build.execution',
+ version: 6,
+ })
+ const notice: ConversationItem = {
+ seq: 0,
+ at_version: 6,
+ kind: 'notice',
+ payload: { text: 'The generated workflow is ready.' },
+ }
+ const reconnect = createControlledEventStream()
+ mocks.get.mockImplementation(async (_input: unknown, { signal }: { signal: AbortSignal }) => {
+ await Promise.resolve()
+ signal.throwIfAborted()
+ return processing
+ })
+ mocks.stream.mockResolvedValue(reconnect.iterable)
+ window.sessionStorage.setItem(
+ 'dify-builder:v1:workspace-1:user-1:app-1:active-session-id',
+ processing.session_id,
+ )
+ renderFlow(1, reactStrictMode)
+ await waitFor(() => expect(mocks.stream).toHaveBeenCalledOnce())
+ await act(async () => reconnect.push(commandStartedEvent(processing)))
+ expect(getComposer()).toBeDisabled()
+ expect(mocks.refreshCanvas).not.toHaveBeenCalled()
+
+ let finishHistory!: (page: ReturnType) => void
+ mocks.conversation.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishHistory = resolve
+ }),
+ )
+ await act(async () => reconnect.push(stateEvent(waiting)))
+ const runTest = await screen.findByRole('button', { name: 'Run test' })
+ expect(runTest).toBeDisabled()
+ expect(mocks.refreshCanvas).not.toHaveBeenCalled()
+
+ await act(async () => finishHistory(conversationPage([notice])))
+
+ expect(await screen.findByText(notice.payload.text)).toBeInTheDocument()
+ await waitFor(() => expect(runTest).toBeEnabled())
+ expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
+ expect(getComposer()).toBeEnabled()
+ expect(screen.queryByText('workflow.common.running')).not.toBeInTheDocument()
+ expect(mocks.refreshCanvas).toHaveBeenCalledOnce()
+ expect(mocks.setCanvasReadOnly).toHaveBeenLastCalledWith(false)
+ expect(mocks.create).not.toHaveBeenCalled()
+ expect(mocks.action).not.toHaveBeenCalled()
+ },
+ )
+
it.each([
{
entryMode: 'build',
diff --git a/web/app/components/workflow-app/components/dify-builder/provider/index.tsx b/web/app/components/workflow-app/components/dify-builder/provider/index.tsx
index 47e13976d80..ac633d49a3a 100644
--- a/web/app/components/workflow-app/components/dify-builder/provider/index.tsx
+++ b/web/app/components/workflow-app/components/dify-builder/provider/index.tsx
@@ -67,6 +67,13 @@ const DifyBuilderProviderContent = ({
return (
<>
+
+
+ {children}
+ {/* Mount canvas and panel subscriptions before restoring or starting a session. */}
-
-
- {children}
- {/* Subscribe the editor to draft updates before consuming the creation prompt. */}
undefined)
.finally(() => {
+ if (disposed) return
restoringRef.current = false
const currentSessionId = jotaiStore.get(difyBuilderActiveSessionIdAtom)
try {
@@ -56,6 +58,11 @@ export const DifyBuilderSessionPersistence = ({
// Session persistence is optional and must not block Builder recovery.
}
})
+ return () => {
+ disposed = true
+ attemptedStorageKeyRef.current = null
+ restoringRef.current = false
+ }
}, [enabled, jotaiStore, restore, setActiveSessionId, storageKey])
useEffect(() => {
diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts
index 24f47166ea6..cb656c3055c 100644
--- a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts
+++ b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts
@@ -90,8 +90,11 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController
return () => {
abortRef.current?.abort()
abortRef.current = null
+ // Clearing the request ref prevents its finally block from releasing loading state.
+ setIsBusy(false)
+ setConversationLoading(false)
}
- }, [])
+ }, [setConversationLoading, setIsBusy])
const applySessionView = useCallback(
(nextView: SessionView) => {
@@ -402,6 +405,7 @@ export function useDifyBuilderSessionController(): DifyBuilderSessionController
if (controller.signal.aborted) return latestOutcome
try {
const view = await getSession(sessionId, controller.signal)
+ if (controller.signal.aborted) return latestOutcome
const stateApplied = applySessionView(view)
const historyApplied = await syncConversation(
sessionId,