Compare commits

...

6 Commits

Author SHA1 Message Date
Glary-Bot
d71db8fc35 fix(subgraph): close re-entry + router-failure gaps; harden tests
Multi-agent review addressed:

Code:
- Gate routeHash watcher behind blockNavDepth so router.replace() during
  redirectToRoot() can't re-enter navigateToHash and double-fire setGraph.
- Rename blockHashUpdateDepth -> blockNavDepth (now gates both watchers).
- ensureCanvasOnRoot(): null-guard rootGraph and canvas so teardown/HMR
  paths can't throw inside a finally and swallow the original error.
- Catch router.replace rejections in redirectToRoot and updateHash; treat
  isNavigationFailure(duplicated/cancelled) as benign.
- Wrap router.push/replace in updateHash() with safeRouterCall to stop
  bare-await rejections from becoming unhandled promise rejections.
- Rename isValidSubgraphId -> isUuidShapedSubgraphId so the predicate's
  scope ('eligible for root.subgraphs.get') is on the tin.

Tests:
- Add regression test asserting the routeHash watcher does NOT re-enter
  navigateToHash when router.replace() rewrites the hash during recovery.
- Strengthen empty-hash test: transition from non-empty -> empty, assert
  no redirect AND no canvas reset.
- Strengthen stale-canvas test: assert setGraph(rootGraph) in addition to
  router.replace.
- Add 'non-UUID slug that doesn't match root' test to lock in the
  validation path (the slug-equals-root test alone passes without any
  validator).
- Replace flushHashWatcher double-calls with vi.waitFor for determinism.
- Drop unused findSubgraphPathById mock (not consulted in any nav path).

E2E:
- Wait for the store's initial hash/route sync before mutating
  location.hash to avoid racing the initialLoad branch.
- Add an explicit timeout to the URL-hash poll so a missed redirect
  fails fast on loaded CI.

Schema:
- Trim docstring to the only non-obvious bit (untrusted-input boundary).
- Dedupe canonical UUID literal in the test.
2026-05-21 13:27:08 +00:00
Glary-Bot
6dc9509892 fix(subgraph): harden recovery path - router resilience, async safety, load failures
Addresses review feedback on subgraph hash validation:

- redirectToRoot now catches router.replace rejections and always
  restores the canvas to root via a finally block, so an aborted URL
  update can't leave the user on a deleted subgraph.
- Replaces the shared blockHashUpdate boolean with a depth counter
  wrapped in withHashUpdateBlocked() so nested/overlapping async
  navigations can't release suppression early.
- Catches openWorkflow() rejections in the workflow-search branch and
  routes them through the same root recovery path.
- Mirrors the canvas.graph?.id === target.id short-circuit in the
  post-load path to avoid redundant setGraph() side effects.

Tests:
- Stale-canvas test now also asserts canvas.setGraph(root) was called.
- Adds coverage for router.replace rejection, openWorkflow rejection,
  and a real empty-hash transition (previous test was vacuous since
  beforeEach already set the hash to '').
- Replaces 'as unknown as' assignment to openWorkflows with a
  type-safe module mock of useWorkflowStore.
- beforeEach now resets app.rootGraph.id so test mutations cannot leak.

E2E: subgraphHashValidation specs now also assert window.app.canvas.graph.id
matches the root graph after redirect, not just the URL hash.
2026-05-14 14:10:21 +00:00
Glary-Bot
0932eef7d3 fix(subgraph): close stale-graph short-circuit gap
The early 'canvas.graph?.id === locatorId' return ran before resolving
whether the locator still mapped to a real graph, so a hash matching a
canvas reference to a now-deleted subgraph could no-op instead of
redirecting. Move the same-graph check after resolution: short-circuit
only when targetGraph is found (root match or live subgraph), otherwise
fall through to the workflow-search + redirect path.

Adds a regression test that asserts redirect-to-root when canvas still
holds a deleted subgraph and the hash points to it.
2026-05-13 19:54:52 +00:00
Glary-Bot
5d374fa07f fix(subgraph): only redirect when resolution fails; preserve non-UUID root ids
The previous Zod gate rejected any non-UUID hash up-front, which broke
workflows whose root graph id is a non-UUID slug (e.g. test fixtures
like 'test-missing-models-in-subgraph') by yanking the user back to a
stale root immediately after load. Restore the original
'already-on-this-graph' early return and apply the UUID schema only
when narrowing a subgraph lookup; the redirect now fires solely on
resolution failure, which still catches the FE-559 deleted/missing
subgraph case.

Adds Playwright coverage (browser_tests/tests/subgraph/subgraphHashValidation.spec.ts)
and a unit-test guard for non-UUID root ids.
2026-05-13 19:45:21 +00:00
Glary-Bot
1bb179982e test(subgraph): cover post-load fallback + clarify UUID wording
Add a test for the branch where openWorkflow() resolves but the target
subgraph is still missing from app.rootGraph.subgraphs; asserts the
warn message and the redirect to root. Also reword the schema doc:
z.string().uuid() accepts any RFC 4122 UUID, not specifically v4.
2026-05-12 05:15:14 +00:00
Glary-Bot
4c68d66d7f fix(subgraph): validate URL hash and redirect to root when subgraph missing
Browser back/forward could land on a hash referencing a deleted or
malformed subgraph id, leaving the canvas on stale state (and in some
cases triggering unrelated navigation). Validate the locator with a
shared zSubgraphId schema and redirect to the root graph whenever the
target cannot be resolved.

Fixes FE-559
2026-05-12 04:57:28 +00:00
5 changed files with 554 additions and 24 deletions

View 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)
})
}
)

View 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)
})
})
})

View 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
}

View 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()
})
})

View File

@@ -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. */