fix(web): snippet init

This commit is contained in:
JzoNg 2026-04-27 15:45:13 +08:00
parent 63dcb4dd6c
commit dbeaf79d77
6 changed files with 210 additions and 77 deletions

View File

@ -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,

View File

@ -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<HooksStoreShape>}
>
<SnippetChildren
snippetId={snippetId}

View File

@ -1,4 +1,8 @@
import { renderHook } from '@testing-library/react'
import type { SnippetWorkflow } from '@/types/snippet'
import {
renderHook,
waitFor,
} from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSnippetInit } from '../use-snippet-init'
@ -7,7 +11,7 @@ const mockSetPublishedAt = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockUseSnippetApiDetail = vi.fn()
const mockUseSnippetDraftWorkflow = vi.fn()
const mockFetchSnippetDraftWorkflow = vi.fn()
const mockUseSnippetDefaultBlockConfigs = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
@ -32,11 +36,22 @@ vi.mock('@/service/use-snippets', async (importOriginal) => {
})
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> = {}): 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<SnippetWorkflow>((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)
})
})

View File

@ -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<DraftWorkflowState>({
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,
}
}

View File

@ -70,7 +70,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => {
const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => {
return (
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
<WorkflowContextProvider>
<WorkflowContextProvider key={snippetId}>
<SnippetPage snippetId={snippetId} />
</WorkflowContextProvider>
</SnippetAndEvaluationPlanGuard>

View File

@ -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<SnippetWorkflow>(`/snippets/${snippetId}/workflows/draft`, {}, { silent: true })
}
catch (error) {
if (isNotFoundError(error))
return undefined
throw error
}
}
const invalidateSnippetWorkflowQueries = async (
queryClient: ReturnType<typeof useQueryClient>,
snippetId: string,
@ -49,17 +61,10 @@ export const useSnippetDraftWorkflow = (
return useQuery({
...queryOptions,
queryFn: async () => {
try {
const draftWorkflow = await get<SnippetWorkflow>(`/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
},
})
}