fix(web): restore App Builder sessions after refresh

Mount canvas and panel subscriptions before restoring persisted sessions. Release canceled loading state and ignore stale restore responses so graph generation can resume after a page refresh.

Cover delayed conversation history, stream reconnection, and StrictMode restoration. Fixes ESQ1-283.
This commit is contained in:
twwu committed 2026-09-17 14:07:21 +08:00
1 parent a931c81cdc
commit 43f5a2fedb
4 files changed
+252 -10

No files matched your search

@@ -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) => {
<FixEntry />
<DifyBuilderPanel />
</DifyBuilderProvider>,
{ 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<SessionView>((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<typeof conversationPage>) => 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<typeof conversationPage>) => 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',
@@ -67,6 +67,13 @@ const DifyBuilderProviderContent = ({
return (
<>
<DifyBuilderCanvasLockSync />
<DifyBuilderCanvasRefreshSync
onFocusCanvas={onFocusCanvas}
onRefreshCanvas={onRefreshCanvas}
/>
{children}
{/* Mount canvas and panel subscriptions before restoring or starting a session. */}
<DifyBuilderSessionPersistence
appId={appId}
enabled={enabled}
@@ -74,13 +81,6 @@ const DifyBuilderProviderContent = ({
tenantId={tenantId}
userId={userId}
/>
<DifyBuilderCanvasLockSync />
<DifyBuilderCanvasRefreshSync
onFocusCanvas={onFocusCanvas}
onRefreshCanvas={onRefreshCanvas}
/>
{children}
{/* Subscribe the editor to draft updates before consuming the creation prompt. */}
<DifyBuilderCreationStart
appId={appId}
canEdit={canEdit}
@@ -38,10 +38,12 @@ export const DifyBuilderSessionPersistence = ({
persistedSessionIdRef.current = storedSessionId
restoringRef.current = true
let disposed = false
setActiveSessionId(storedSessionId)
void restore(storedSessionId)
.catch(() => 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(() => {
@@ -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,