diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 8d63394a82..149f86c236 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -25,6 +25,10 @@ vi.mock('@/hooks/use-breakpoints', () => ({ MediaType: { mobile: 'mobile', desktop: 'desktop' }, })) +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({ setAppSidebarExpand: mockSetAppSidebarExpand, diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 6dd75ac426..290b587420 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -1,6 +1,7 @@ 'use client' import type { WorkflowProps } from '@/app/components/workflow' +import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' import type { SnippetDetailPayload } from '@/models/snippet' import { useEffect, @@ -197,7 +198,7 @@ const SnippetMain = ({ nodes={nodes} edges={edges} viewport={viewport ?? graph.viewport} - hooksStore={hooksStore as any} + hooksStore={hooksStore as unknown as Partial} > { }) vi.mock('@/service/use-snippet-workflows', () => ({ - useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess), + fetchSnippetDraftWorkflow: (snippetId: string) => mockFetchSnippetDraftWorkflow(snippetId), useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess), useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess), })) +const createDraftWorkflow = (overrides: Partial = {}): SnippetWorkflow => ({ + id: 'draft-1', + graph: {}, + features: {}, + input_fields: [], + hash: 'draft-hash', + created_at: 1_712_300_000, + updated_at: 1_712_345_678, + ...overrides, +}) + describe('useSnippetInit', () => { beforeEach(() => { vi.clearAllMocks() @@ -63,10 +78,7 @@ describe('useSnippetInit', () => { error: null, isLoading: false, }) - mockUseSnippetDraftWorkflow.mockReturnValue({ - data: undefined, - isLoading: false, - }) + mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined) mockUseSnippetDefaultBlockConfigs.mockReturnValue({ data: undefined, }) @@ -75,16 +87,20 @@ describe('useSnippetInit', () => { }) }) - it('should return snippet detail query result', () => { + it('should return snippet detail query result', async () => { const { result } = renderHook(() => useSnippetInit('snippet-1')) + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1') + expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1') expect(result.current.data?.snippet.id).toBe('snippet-1') expect(result.current.data?.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 }) - expect(result.current.isLoading).toBe(false) }) - it('should use draft input_fields for snippet inputs', () => { + it('should use draft input_fields for snippet inputs', async () => { mockUseSnippetApiDetail.mockReturnValue({ data: { id: 'snippet-1', @@ -114,28 +130,23 @@ describe('useSnippetInit', () => { error: null, isLoading: false, }) - mockUseSnippetDraftWorkflow.mockReturnValue({ - data: { - id: 'draft-1', - graph: {}, - features: {}, - input_fields: [ - { - label: 'Draft field', - variable: 'draft_field', - type: 'text-input', - required: true, - }, - ], - hash: 'draft-hash', - created_at: 1_712_300_000, - updated_at: 1_712_345_678, - }, - isLoading: false, - }) + mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({ + input_fields: [ + { + label: 'Draft field', + variable: 'draft_field', + type: 'text-input', + required: true, + }, + ], + })) const { result } = renderHook(() => useSnippetInit('snippet-1')) + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + expect(result.current.data?.inputFields).toEqual([ { label: 'Draft field', @@ -146,19 +157,96 @@ describe('useSnippetInit', () => { ]) }) - it('should sync draft metadata into workflow store', () => { - mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => { - onSuccess?.({ - updated_at: 1_712_345_678, - hash: 'draft-hash', - }) - return { data: undefined, isLoading: false } + it('should sync draft metadata before returning initialized data', async () => { + mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({ + hash: 'fetched-draft-hash', + updated_at: 1_712_345_678, + graph: { + nodes: [{ id: 'node-1' }], + edges: [], + viewport: { x: 10, y: 20, zoom: 1.2 }, + }, + })) + + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) }) - renderHook(() => useSnippetInit('snippet-1')) - expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678) - expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash') + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('fetched-draft-hash') + expect(result.current.data?.graph.viewport).toEqual({ x: 10, y: 20, zoom: 1.2 }) + }) + + it('should not return stale draft data while the draft workflow request is pending', () => { + mockFetchSnippetDraftWorkflow.mockReturnValue(new Promise(() => {})) + + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBe(true) + }) + + it('should initialize with empty graph when the draft workflow does not exist', async () => { + mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined) + + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.data?.graph).toEqual({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }) + }) + + it('should ignore outdated draft workflow response when snippet changes', async () => { + let resolveFirstDraft: (workflow: SnippetWorkflow) => void = () => {} + mockFetchSnippetDraftWorkflow.mockImplementation((snippetId: string) => { + if (snippetId === 'snippet-1') { + return new Promise((resolve) => { + resolveFirstDraft = resolve + }) + } + + return Promise.resolve(createDraftWorkflow({ + id: 'draft-2', + hash: 'snippet-2-hash', + graph: { + nodes: [{ id: 'snippet-2-node' }], + edges: [], + viewport: { x: 2, y: 2, zoom: 1 }, + }, + })) + }) + + const { result, rerender } = renderHook(({ snippetId }) => useSnippetInit(snippetId), { + initialProps: { snippetId: 'snippet-1' }, + }) + + rerender({ snippetId: 'snippet-2' }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + resolveFirstDraft(createDraftWorkflow({ + hash: 'stale-snippet-1-hash', + graph: { + nodes: [{ id: 'stale-node' }], + edges: [], + viewport: { x: 1, y: 1, zoom: 1 }, + }, + })) + await Promise.resolve() + + expect(result.current.data?.graph.nodes).toEqual([{ id: 'snippet-2-node' }]) + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('snippet-2-hash') + expect(mockSetSyncWorkflowDraftHash).not.toHaveBeenCalledWith('stale-snippet-1-hash') }) it('should normalize array default block configs into workflow store state', () => { @@ -220,16 +308,4 @@ describe('useSnippetInit', () => { expect(mockSetPublishedAt).toHaveBeenCalledWith(0) }) - - it('should stay loading while draft workflow is still fetching', () => { - mockUseSnippetDraftWorkflow.mockReturnValue({ - data: undefined, - isLoading: true, - }) - - const { result } = renderHook(() => useSnippetInit('snippet-1')) - - expect(result.current.data).toBeUndefined() - expect(result.current.isLoading).toBe(true) - }) }) diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts index f49cb73e80..00ea74ec98 100644 --- a/web/app/components/snippets/hooks/use-snippet-init.ts +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -1,8 +1,13 @@ -import { useEffect, useMemo } from 'react' +import type { SnippetWorkflow } from '@/types/snippet' +import { + useEffect, + useMemo, + useState, +} from 'react' import { useWorkflowStore } from '@/app/components/workflow/store' import { + fetchSnippetDraftWorkflow, useSnippetDefaultBlockConfigs, - useSnippetDraftWorkflow, useSnippetPublishedWorkflow, } from '@/service/use-snippet-workflows' import { @@ -36,17 +41,18 @@ const isNotFoundError = (error: unknown) => { return !!error && typeof error === 'object' && 'status' in error && error.status === 404 } +type DraftWorkflowState = { + snippetId: string + data?: SnippetWorkflow + isLoaded: boolean +} + export const useSnippetInit = (snippetId: string) => { const workflowStore = useWorkflowStore() const snippetApiDetail = useSnippetApiDetail(snippetId) - const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => { - const { - setDraftUpdatedAt, - setSyncWorkflowDraftHash, - } = workflowStore.getState() - - setDraftUpdatedAt(draftWorkflow.updated_at) - setSyncWorkflowDraftHash(draftWorkflow.hash) + const [draftWorkflowState, setDraftWorkflowState] = useState({ + snippetId: '', + isLoaded: false, }) useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => { workflowStore.setState({ @@ -64,19 +70,60 @@ export const useSnippetInit = (snippetId: string) => { workflowStore.getState().setPublishedAt(publishedWorkflowQuery.data?.created_at ?? 0) }, [publishedWorkflowQuery.data?.created_at, publishedWorkflowQuery.isLoading, workflowStore]) + useEffect(() => { + let ignore = false + + if (!snippetId) + return + + fetchSnippetDraftWorkflow(snippetId) + .then((response) => { + if (ignore) + return + + if (response) { + const { + setDraftUpdatedAt, + setSyncWorkflowDraftHash, + } = workflowStore.getState() + + setDraftUpdatedAt(response.updated_at) + setSyncWorkflowDraftHash(response.hash) + } + + setDraftWorkflowState({ + snippetId, + data: response, + isLoaded: true, + }) + }) + .catch(() => { + // Keep the canvas gated on unexpected draft fetch failures. + // `fetchSnippetDraftWorkflow` resolves with undefined for 404, so this + // branch represents a real initialization failure rather than "no draft". + }) + + return () => { + ignore = true + } + }, [snippetId, workflowStore]) + + const isDraftWorkflowLoading = !!snippetId && (!draftWorkflowState.isLoaded || draftWorkflowState.snippetId !== snippetId) + const draftWorkflow = draftWorkflowState.snippetId === snippetId ? draftWorkflowState.data : undefined + const data = useMemo(() => { - if (snippetApiDetail.data && !draftWorkflowQuery.isLoading) - return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data) + if (snippetApiDetail.data && !isDraftWorkflowLoading) + return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflow) if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error)) return null return undefined - }, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, snippetApiDetail.data, snippetApiDetail.error]) + }, [draftWorkflow, isDraftWorkflowLoading, snippetApiDetail.data, snippetApiDetail.error]) return { ...snippetApiDetail, data, - isLoading: snippetApiDetail.isLoading || draftWorkflowQuery.isLoading, + isLoading: snippetApiDetail.isLoading || isDraftWorkflowLoading, } } diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx index a69dee643b..86651d0c48 100644 --- a/web/app/components/snippets/index.tsx +++ b/web/app/components/snippets/index.tsx @@ -70,7 +70,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => { const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => { return ( - + diff --git a/web/service/use-snippet-workflows.ts b/web/service/use-snippet-workflows.ts index 8130e73752..e049179ab9 100644 --- a/web/service/use-snippet-workflows.ts +++ b/web/service/use-snippet-workflows.ts @@ -7,6 +7,18 @@ const isNotFoundError = (error: unknown) => { return !!error && typeof error === 'object' && 'status' in error && error.status === 404 } +export const fetchSnippetDraftWorkflow = async (snippetId: string) => { + try { + return await get(`/snippets/${snippetId}/workflows/draft`, {}, { silent: true }) + } + catch (error) { + if (isNotFoundError(error)) + return undefined + + throw error + } +} + const invalidateSnippetWorkflowQueries = async ( queryClient: ReturnType, snippetId: string, @@ -49,17 +61,10 @@ export const useSnippetDraftWorkflow = ( return useQuery({ ...queryOptions, queryFn: async () => { - try { - const draftWorkflow = await get(`/snippets/${snippetId}/workflows/draft`, {}, { silent: true }) + const draftWorkflow = await fetchSnippetDraftWorkflow(snippetId) + if (draftWorkflow) onSuccess?.(draftWorkflow) - return draftWorkflow - } - catch (error) { - if (isNotFoundError(error)) - return undefined - - throw error - } + return draftWorkflow }, }) }