mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
6 Commits
v1.45.11
...
glary/fe-5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71db8fc35 | ||
|
|
6dc9509892 | ||
|
|
0932eef7d3 | ||
|
|
5d374fa07f | ||
|
|
1bb179982e | ||
|
|
4c68d66d7f |
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
87
browser_tests/tests/subgraph/subgraphHashValidation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function waitForStoreInitialSync(page: Page) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app?.rootGraph?.id ?? '',
|
||||
hash: window.location.hash
|
||||
}))
|
||||
return state.rootId !== '' && state.hash === `#${state.rootId}`
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectCanvasOnRootGraph(page: Page) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
rootId: expect.any(String),
|
||||
canvasGraphId: expect.stringMatching(/.+/),
|
||||
hash: expect.stringMatching(/^#.+/)
|
||||
})
|
||||
const state = await page.evaluate(() => ({
|
||||
rootId: window.app!.rootGraph.id,
|
||||
canvasGraphId: window.app!.canvas.graph?.id,
|
||||
hash: window.location.hash
|
||||
}))
|
||||
expect(state.canvasGraphId).toBe(state.rootId)
|
||||
expect(state.hash).toBe(`#${state.rootId}`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph hash validation (FE-559)',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForStoreInitialSync(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
const phantomId = '11111111-1111-4111-8111-111111111111'
|
||||
expect(phantomId).not.toBe(rootId)
|
||||
|
||||
await comfyPage.page.evaluate((hash) => {
|
||||
window.location.hash = hash
|
||||
}, `#${phantomId}`)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
|
||||
test('redirects URL and canvas to root when hash is malformed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await waitForStoreInitialSync(comfyPage.page)
|
||||
const rootId = await comfyPage.page.evaluate(
|
||||
() => window.app!.rootGraph.id
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.location.hash = '#not-a-valid-uuid'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
|
||||
timeout: 5000
|
||||
})
|
||||
.toBe(`#${rootId}`)
|
||||
await expectCanvasOnRootGraph(comfyPage.page)
|
||||
})
|
||||
}
|
||||
)
|
||||
52
src/schemas/subgraphIdSchema.test.ts
Normal file
52
src/schemas/subgraphIdSchema.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { isUuidShapedSubgraphId, zSubgraphId } from './subgraphIdSchema'
|
||||
|
||||
const CANONICAL_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('subgraphIdSchema', () => {
|
||||
describe('zSubgraphId', () => {
|
||||
it('accepts a freshly generated UUID v4', () => {
|
||||
expect(zSubgraphId.safeParse(createUuidv4()).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a canonical UUID string', () => {
|
||||
expect(zSubgraphId.safeParse(CANONICAL_UUID).success).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty string', ''],
|
||||
['arbitrary path', '/some/path'],
|
||||
['plain word', 'subgraph'],
|
||||
['hash leftover', '#abc'],
|
||||
['hex but not UUID-shaped', 'abcdef0123456789'],
|
||||
['UUID with leading hash', `#${CANONICAL_UUID}`],
|
||||
['UUID with whitespace', ` ${CANONICAL_UUID} `]
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(zSubgraphId.safeParse(value).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['number', 123],
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
['object', { id: 'abc' }]
|
||||
])('rejects non-string %s', (_label, value) => {
|
||||
expect(zSubgraphId.safeParse(value).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUuidShapedSubgraphId', () => {
|
||||
it('returns true for a valid UUID', () => {
|
||||
expect(isUuidShapedSubgraphId(createUuidv4())).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an invalid value', () => {
|
||||
expect(isUuidShapedSubgraphId('not-a-uuid')).toBe(false)
|
||||
expect(isUuidShapedSubgraphId(undefined)).toBe(false)
|
||||
expect(isUuidShapedSubgraphId(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
10
src/schemas/subgraphIdSchema.ts
Normal file
10
src/schemas/subgraphIdSchema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Hash values from the URL bar are untrusted; validate before lookup. */
|
||||
export const zSubgraphId = z.string().uuid()
|
||||
|
||||
type SubgraphId = z.infer<typeof zSubgraphId>
|
||||
|
||||
export function isUuidShapedSubgraphId(value: unknown): value is SubgraphId {
|
||||
return zSubgraphId.safeParse(value).success
|
||||
}
|
||||
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal file
307
src/stores/subgraphNavigationStore.navigateToHash.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import type * as VueRouter from 'vue-router'
|
||||
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const ids = vi.hoisted(() => ({
|
||||
root: '00000000-0000-4000-8000-000000000000',
|
||||
validSubgraph: '11111111-1111-4111-8111-111111111111',
|
||||
deletedSubgraph: '22222222-2222-4222-8222-222222222222'
|
||||
}))
|
||||
|
||||
const workflowStoreState = vi.hoisted(() => ({
|
||||
openWorkflows: [] as unknown[],
|
||||
activeSubgraph: undefined as unknown
|
||||
}))
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
push: vi.fn().mockResolvedValue(undefined),
|
||||
replace: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const routeHashRef = ref('')
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueRouter>()
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => routerMocks
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/router', () => ({
|
||||
useRouteHash: () => routeHashRef
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: null,
|
||||
graph: null,
|
||||
setGraph: vi.fn(),
|
||||
setDirty: vi.fn(),
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0],
|
||||
state: { scale: 1, offset: [0, 0] }
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = {
|
||||
id: ids.root,
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
graph: mockRoot,
|
||||
rootGraph: mockRoot,
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => app.canvas,
|
||||
currentGraph: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ fitView: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({ requestSlotLayoutSyncForAllNodes: vi.fn() })
|
||||
)
|
||||
|
||||
const workflowServiceMocks = vi.hoisted(() => ({
|
||||
openWorkflow: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => workflowServiceMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => workflowStoreState
|
||||
}))
|
||||
|
||||
function makeSubgraph(id: string): Subgraph {
|
||||
return fromPartial<Subgraph>({
|
||||
id,
|
||||
rootGraph: app.rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
async function flushHashWatcher() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
app.rootGraph.id = ids.root
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.graph = app.rootGraph
|
||||
workflowStoreState.openWorkflows = []
|
||||
workflowStoreState.activeSubgraph = undefined
|
||||
routeHashRef.value = ''
|
||||
})
|
||||
|
||||
it('navigates to a valid, existing subgraph hash', async () => {
|
||||
const subgraph = makeSubgraph(ids.validSubgraph)
|
||||
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to root when hash references a deleted subgraph', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('redirects to root when hash is malformed (not a UUID)', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = '#not-a-valid-uuid'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when hash equals a non-UUID root graph id (loaded workflow slug)', async () => {
|
||||
const slugRootId = 'test-missing-models-in-subgraph'
|
||||
app.rootGraph.id = slugRootId
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: slugRootId })
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${slugRootId}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects when hash is a non-UUID slug that does not match root', async () => {
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = '#some-other-slug'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redirect or re-set graph when hash equals current root graph', async () => {
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when transitioning to an empty hash on the root graph', async () => {
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
useSubgraphNavigationStore()
|
||||
await flushHashWatcher()
|
||||
routerMocks.replace.mockClear()
|
||||
vi.mocked(app.canvas.setGraph).mockClear()
|
||||
|
||||
routeHashRef.value = ''
|
||||
await flushHashWatcher()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects when canvas still references a deleted subgraph (stale-graph guard)', async () => {
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers canvas to root even if router.replace rejects', async () => {
|
||||
routerMocks.replace.mockRejectedValueOnce(new Error('navigation aborted'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'phantom-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('subgraph not found after workflow load')
|
||||
)
|
||||
})
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('redirects when openWorkflow rejects during recovery', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowServiceMocks.openWorkflow.mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'broken-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('workflow load failed')
|
||||
)
|
||||
})
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('routeHash watcher does not re-enter navigateToHash during recovery redirect', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Simulate the real router replace: trigger the routeHash watcher
|
||||
// exactly the way vue-router does when the URL is replaced.
|
||||
routerMocks.replace.mockImplementation((target) => {
|
||||
const hash = typeof target === 'string' ? target : ''
|
||||
routeHashRef.value = hash
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() => {
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
})
|
||||
|
||||
// navigateToHash for the deleted id ran once and produced exactly one
|
||||
// redirect. The watcher must NOT have fired again for the rewritten
|
||||
// (root) hash and produced a second redirect.
|
||||
expect(routerMocks.replace).toHaveBeenCalledTimes(1)
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,11 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import { useRouteHash } from '@vueuse/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NavigationFailureType,
|
||||
isNavigationFailure,
|
||||
useRouter
|
||||
} from 'vue-router'
|
||||
|
||||
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -10,6 +14,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { isUuidShapedSubgraphId } from '@/schemas/subgraphIdSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
@@ -200,20 +205,64 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
//Allow navigation with forward/back buttons
|
||||
let blockHashUpdate = false
|
||||
// Counter so nested/overlapping async navigations don't release
|
||||
// suppression early; gates both the canvasStore.currentGraph watcher
|
||||
// (updateHash) and the routeHash watcher to prevent re-entrant
|
||||
// navigateToHash calls during router.replace().
|
||||
let blockNavDepth = 0
|
||||
let initialLoad = true
|
||||
|
||||
async function withNavBlocked<T>(op: () => Promise<T>): Promise<T> {
|
||||
blockNavDepth++
|
||||
try {
|
||||
return await op()
|
||||
} finally {
|
||||
blockNavDepth--
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCanvasOnRoot() {
|
||||
const root = app.rootGraph
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (!root || !canvas) return
|
||||
if (canvas.graph?.id !== root.id) canvas.setGraph(root)
|
||||
}
|
||||
|
||||
async function redirectToRoot(reason: string) {
|
||||
const root = app.rootGraph
|
||||
console.warn(`[subgraphNavigation] ${reason}; redirecting to root graph`)
|
||||
try {
|
||||
await withNavBlocked(() => router.replace('#' + root.id))
|
||||
} catch (err) {
|
||||
if (
|
||||
!isNavigationFailure(err, NavigationFailureType.duplicated) &&
|
||||
!isNavigationFailure(err, NavigationFailureType.cancelled)
|
||||
) {
|
||||
console.warn(
|
||||
'[subgraphNavigation] router.replace rejected during recovery',
|
||||
err
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
ensureCanvasOnRoot()
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToHash(newHash: string) {
|
||||
const root = app.rootGraph
|
||||
const locatorId = newHash?.slice(1) || root.id
|
||||
const canvas = canvasStore.getCanvas()
|
||||
if (canvas.graph?.id === locatorId) return
|
||||
const targetGraph =
|
||||
(locatorId || root.id) !== root.id
|
||||
|
||||
const isRoot = locatorId === root.id
|
||||
const targetGraph = isRoot
|
||||
? root
|
||||
: isUuidShapedSubgraphId(locatorId)
|
||||
? root.subgraphs.get(locatorId)
|
||||
: root
|
||||
if (targetGraph) return canvas.setGraph(targetGraph)
|
||||
: undefined
|
||||
if (targetGraph) {
|
||||
if (canvas.graph?.id === targetGraph.id) return
|
||||
return canvas.setGraph(targetGraph)
|
||||
}
|
||||
|
||||
//Search all open workflows
|
||||
for (const workflow of workflowStore.openWorkflows) {
|
||||
@@ -222,29 +271,48 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
const subgraphs = activeState.definitions?.subgraphs ?? []
|
||||
for (const graph of [activeState, ...subgraphs]) {
|
||||
if (graph.id !== locatorId) continue
|
||||
//This will trigger a navigation, which can break forward history
|
||||
// This will trigger a navigation, which can break forward history.
|
||||
// After openWorkflow resolves, app.rootGraph has been swapped, so we
|
||||
// intentionally re-read app.rootGraph below instead of using the
|
||||
// `root` captured at function entry.
|
||||
try {
|
||||
blockHashUpdate = true
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
} finally {
|
||||
blockHashUpdate = false
|
||||
await withNavBlocked(() =>
|
||||
useWorkflowService().openWorkflow(workflow)
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[subgraphNavigation] openWorkflow rejected during recovery',
|
||||
err
|
||||
)
|
||||
return redirectToRoot('workflow load failed')
|
||||
}
|
||||
const targetGraph =
|
||||
const loadedGraph =
|
||||
app.rootGraph.id === locatorId
|
||||
? app.rootGraph
|
||||
: app.rootGraph.subgraphs.get(locatorId)
|
||||
if (!targetGraph) {
|
||||
console.error('subgraph poofed after load?')
|
||||
return
|
||||
if (!loadedGraph) {
|
||||
return redirectToRoot('subgraph not found after workflow load')
|
||||
}
|
||||
if (canvas.graph?.id === loadedGraph.id) return
|
||||
return canvas.setGraph(loadedGraph)
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.setGraph(targetGraph)
|
||||
await redirectToRoot(`subgraph not found: ${locatorId}`)
|
||||
}
|
||||
|
||||
async function safeRouterCall(op: () => Promise<unknown>, label: string) {
|
||||
try {
|
||||
await op()
|
||||
} catch (err) {
|
||||
if (!isNavigationFailure(err, NavigationFailureType.duplicated)) {
|
||||
console.warn(`[subgraphNavigation] ${label} rejected`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHash() {
|
||||
if (blockHashUpdate) return
|
||||
if (blockNavDepth > 0) return
|
||||
if (initialLoad) {
|
||||
initialLoad = false
|
||||
if (!routeHash.value) return
|
||||
@@ -255,16 +323,22 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
}
|
||||
|
||||
const newId = canvasStore.getCanvas().graph?.id ?? ''
|
||||
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
|
||||
if (!routeHash.value) {
|
||||
await safeRouterCall(
|
||||
() => router.replace('#' + app.rootGraph.id),
|
||||
'router.replace'
|
||||
)
|
||||
}
|
||||
const currentId = routeHash.value?.slice(1)
|
||||
if (!newId || newId === currentId) return
|
||||
|
||||
await router.push('#' + newId)
|
||||
await safeRouterCall(() => router.push('#' + newId), 'router.push')
|
||||
}
|
||||
//update navigation hash
|
||||
//NOTE: Doesn't apply on workflow load
|
||||
watch(() => canvasStore.currentGraph, updateHash)
|
||||
watch(routeHash, () => navigateToHash(String(routeHash.value)))
|
||||
watch(routeHash, () => {
|
||||
if (blockNavDepth > 0) return
|
||||
void navigateToHash(String(routeHash.value))
|
||||
})
|
||||
|
||||
/** Save the current viewport for the active graph/workflow. Called by
|
||||
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */
|
||||
|
||||
Reference in New Issue
Block a user