Compare commits

...

3 Commits

Author SHA1 Message Date
Christian Byrne
8206022982 fix(subgraph): validate URL hash and redirect to root when subgraph missing (#12169)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix FE-559: browser forward/back to a deleted subgraph used to leave the
canvas on stale state (and sometimes triggered unrelated tab navigation)
because the subgraph id in the URL hash was looked up with no validation
or fallback.

## Changes

- **What**:
- Added `src/schemas/subgraphIdSchema.ts` — `zSubgraphId =
z.string().uuid()` + `isValidSubgraphId(value)` type guard, matching how
subgraph ids are persisted in `workflowSchema.ts` and generated by
`createUuidv4()`.
- `subgraphNavigationStore.navigateToHash()` now (a) validates the hash
with `isValidSubgraphId` before any lookup, (b) redirects to the root
graph (`router.replace('#' + root.id)` + `canvas.setGraph(root)`) when
the locator is malformed, missing from `root.subgraphs`, or still
unresolved after a workflow-load attempt.
- Replaced the `console.error('subgraph poofed after load?')` dead-end
with the same redirect helper.
- Re-ordered the "already on this graph" short-circuit so a stale canvas
reference to a now-deleted subgraph doesn't suppress the redirect.

## Review Focus

- TDD: 6 new tests in `subgraphNavigationStore.navigateToHash.test.ts`
cover valid navigation, deleted-subgraph hash, malformed (non-UUID)
hash, no-op when target equals current, empty-hash root case, and
stale-canvas recovery. 15 new tests in `subgraphIdSchema.test.ts` lock
down the validator.
- `redirectToRoot()` toggles `blockHashUpdate` while calling
`router.replace`, so the new redirect doesn't re-trigger `updateHash()`
and clobber the canvas state.
- Generalized validation: the new schema lives in `src/schemas/` and can
be reused anywhere a subgraph id crosses an untrusted boundary (URL,
IPC, etc.).

## Manual Verification

Ran ComfyUI backend (`--cpu --port 8188`) + frontend dev server, then
drove Playwright through three scenarios:

| Input hash | Result | Console |
|---|---|---|
| `#11111111-2222-4333-8444-555555555555` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | `[subgraphNavigation] subgraph not
found: 11111111-…; redirecting to root graph` |
| `#not-a-valid-uuid` (malformed) | URL replaced with `#<root-id>` |
`[subgraphNavigation] invalid subgraph id in hash: not-a-valid-uuid;
redirecting to root graph` |
| `#aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee` (UUID-shaped, non-existent) |
URL replaced with `#<root-id>` | (same redirect message) |

Screenshot below shows the redirected viewport.

Fixes FE-559

## Screenshots

![ComfyUI canvas after a hash referencing a deleted subgraph was
rewritten to the root graph
hash](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fe7f8846b3efdc95461cd63995dd10808073dd86c561eff9d8816742eb892687/pr-images/1778562546959-43f5ead4-3e13-45de-a0ac-988c3424368b.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12169-fix-subgraph-validate-URL-hash-and-redirect-to-root-when-subgraph-missing-35e6d73d3650819f840af1475b9f44d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-28 00:34:42 +00:00
Dante
5f2b2f2e87 fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) (#12488)
## Summary

### before
<img width="1107" height="958" alt="before-buggy"
src="https://github.com/user-attachments/assets/1fcbd909-e008-4bd3-967f-87cdabb2baf6"
/>

### after
<img width="1107" height="958" alt="after-fixed"
src="https://github.com/user-attachments/assets/0d3c6f3f-36d6-4556-bd29-b3826ae20216"
/>


The **IC-LoRA Loader Model Only** node (`LTXICLoRALoaderModelOnly`, from
ComfyUI-LTXVideo) didn't show cloud models from `supported_models.json`,
while the native **Load LoRA** node did.

## Changes

- **What**: Add `['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']` to
`MODEL_NODE_MAPPINGS`. Whether a combo widget swaps to the cloud asset
browser is gated by `assetService.shouldUseAssetBrowser` →
`isAssetBrowserEligible`, which only returns true for node types
registered in `MODEL_NODE_MAPPINGS` (via `modelToNodeStore`). The custom
IC-LoRA loader was absent from that list, so its `lora_name` widget fell
back to the plain combo that lists only filesystem models — never the
cloud-injected ones.
- **Breaking**: none

## Review Focus

Root cause verified live on `cloud.comfy.org` (asset API enabled, custom
node installed) via CDP:
- `LoraLoaderModelOnly` (native) → registry `lora_name`, eligible `true`
→ cloud models shown
- `LTXICLoRALoaderModelOnly` (bug) → not in registry, eligible `false` →
cloud models missing
- After registering the mapping live → eligible `true`, category `loras`
→ cloud models shown

Same class of bug as FE-492 (custom loaders missing from the mapping);
long-term, auto-detecting model-folder-backed combos would remove the
need to register each custom loader by hand.

Fixes FE-838

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
64d099f6c](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26508637513)
| 🔴 Red (failure) | Proves the test catches the bug |
| [`fix:`
6b91a570d](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26509067631)
| 🟢 Green (success) | Proves the fix resolves it |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `src/stores/modelToNodeStore.test.ts`
- [ ] E2E not applicable (custom node + cloud asset API not available in
CI)
2026-05-27 22:51:26 +00:00
Dante
a931acadd3 feat(dialog): migrate Settings dialog to Reka-UI (Phase 3) (#12182)
## Summary

Phase 3 of the dialog migration. Closes the parity gaps in the Reka
renderer (maximize affordance, headless layout mode, overlay-class
plumbing), then flips `useSettingsDialog` onto the Reka path. Public API
of `useDialogService` / `dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-575](https://linear.app/comfyorg/issue/FE-575/phase-3-migrate-settings-dialog-workspace-non-workspace-designer)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, **stacked PR base**)

> **Stacked on Phase 2**: this PR targets
`jaewon/dialog-reka-migration-phase-2`. Rebase onto `main` after #12109
lands.

## Changes

### Reka primitives — parity gaps closed

| File | Change |
| --- | --- |
| `src/components/ui/dialog/dialog.variants.ts` | New `maximized`
variant. `false` keeps the centered/sized layout; `true` switches to
`inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none` for
full-screen mode |
| `src/components/ui/dialog/DialogContent.vue` | Accepts `maximized`
prop, forwards to variants |
| `src/components/ui/dialog/DialogMaximize.vue` **(new)** | Icon-only
button toggling `lucide--maximize-2` / `lucide--minimize-2`; emits
`toggle`; uses `g.maximizeDialog` / `g.restoreDialog` i18n |
| `src/stores/dialogStore.ts` | Adds `overlayClass?:
HTMLAttributes['class']` to `CustomDialogComponentProps` (Reka-only;
PrimeVue path uses `pt.mask`) |
| `src/components/dialog/GlobalDialog.vue` | (a) Forwards `overlayClass`
to `DialogOverlay`; (b) passes `:maximized` to `DialogContent`; (c)
renders `DialogMaximize` in the header when `maximizable`, wired to a
local `toggleMaximize`; (d) when `headless: true`, skips the inner
`flex-1 overflow-auto px-4 py-2` wrapper so layout dialogs control their
own chrome |

### Settings flip

| File | Change |
| --- | --- |
| `src/platform/settings/composables/useSettingsDialog.ts` | Adds
`dialogComponentProps: { renderer: 'reka', size: 'full', contentClass:
'\<...\>', overlayClass }`. `contentClass` is `w-[90vw] max-w-[960px]
sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden` —
matches the previous `BaseModalLayout size="sm"` (960px × 80vh).
`overlayClass: 'p-8'` only when `isCloud && teamWorkspacesEnabled`
(preserves the workspace breathing-room contract) |
| `src/components/dialog/GlobalDialog.vue` | Drops the now-dead
`getDialogPt` workspace special case and the orphan
`.settings-dialog-workspace` CSS. Removes unused imports (`merge`,
`computed`, `useFeatureFlags`, `isCloud`, `DialogPassThroughOptions`) |

### Tests

- `src/platform/settings/composables/useSettingsDialog.test.ts`
**(new)** — 5 tests: renderer flip + sizing, workspace `overlayClass`
toggle, panel forwarding, `showAbout()`

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `useSettingsDialog.test.ts` — 5/5
  - `dialogService.renderer.test.ts` — 5/5
  - `GlobalDialog.test.ts` — 9/9
  - All `src/components/dialog/` — 73/73
  - All `src/platform/settings/` — 75/75
  - `CustomizationDialog.test.ts` — 4/4
- [ ] CI Playwright matrix
- [ ] Manual verification on a backend

## Screenshots

End-to-end verification of the Reka flip on a local dev server:

| | |
| --- | --- |
| Settings dialog rendered via Reka (non-modal, focus stays in dialog
body) |
![Settings](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/settings-dialog-reka.png)
|
| Keybinding panel inside the Reka Settings dialog |
![Keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/keybinding-panel.png)
|
| Nested PrimeVue **Modify keybinding** dialog stacked on top —
`document.activeElement` is the `<input autofocus>`, proving the
focus-trap fix | ![Modify
keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/nested-modify-keybinding.png)
|


## Public API impact

None. `useSettingsDialog().show()` keeps the same signature. Reka
primitives gain optional `maximized` prop and `overlayClass` field —
additive, non-breaking.

## Out of scope (later phases)

- Manager dialog — Phase 4 (FE-576) — will consume the new `maximizable`
affordance
- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Review focus

1. **Sizing strategy** — `contentClass` overrides Reka's default content
sizing (matching the existing `BaseModalLayout size="sm"` of 960 ×
80vh). Worth a designer pass per FE-575's acceptance criteria.
2. **`overlayClass: 'p-8'` workspace mode** — Reka's `DialogContent` is
positioned with viewport coordinates, so overlay padding does not
constrain it the way the old PrimeVue `mask.p-8` did. Cosmetic gutter
only. If designer flags missing breathing room, follow-up by shrinking
`contentClass` in workspace mode.
3. **`headless: true` semantics for Reka** — now skips the inner padding
wrapper. Existing migrated dialogs (Phases 1–2) all set a header, so no
visible impact. The Reka-headless path is new with this PR.
4. **Maximize wiring** — `toggleMaximize` mutates
`item.dialogComponentProps.maximized` directly (Pinia deep-reactive
proxy). The store's `onMaximize` / `onUnmaximize` callbacks are still
wired for the PrimeVue path; not double-fired.

## Test plan

- [x] Unit: 102/102 across touched + adjacent areas
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Open Settings via gear icon / keyboard shortcut → renders through
Reka, search works, panel navigation works, ESC closes
- Open Settings → trigger a reset confirmation (stacked confirm) →
confirm renders above Settings, ESC closes only the confirm
- Cloud workspace mode: Settings opens with workspace panel;
`overlayClass` applied
- Cloud non-workspace mode: Settings opens without workspace panel; no
`overlayClass`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12182-feat-dialog-migrate-Settings-dialog-to-Reka-UI-Phase-3-35e6d73d36508144bb4af88f83c5ab20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-27 22:08:30 +00:00
23 changed files with 979 additions and 92 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

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 waitForRootCanvasReady(page: Page) {
await expect
.poll(async () => {
const state = await page.evaluate(() => ({
rootId: window.app?.rootGraph?.id ?? '',
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
}))
return state.rootId !== '' && state.canvasGraphId === 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 waitForRootCanvasReady(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 waitForRootCanvasReady(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

@@ -43,7 +43,6 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [

View File

@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

View File

@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
return {
detail: { originalEvent: { target } },
preventDefault: () => {
prevented = true
},
get defaultPrevented() {
return prevented
}
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
defaultPrevented: boolean
}
}
it.for([
'p-select-overlay',
'p-colorpicker-panel',
'p-popover',
'p-autocomplete-overlay',
'p-overlay-mask',
'p-dialog'
])('prevents dismiss when target is inside %s', (className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
})
it('allows dismiss when target is outside any PrimeVue overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: false }, event)
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
}
)
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')
document.body.appendChild(portal)
const event = makeEvent(portal)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
portal.remove()
})
})

View File

@@ -8,9 +8,14 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogOverlay
v-reka-z-index
:class="item.dialogComponentProps.overlayClass"
/>
<DialogContent
v-reka-z-index
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -19,34 +24,51 @@
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -55,7 +77,6 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -86,29 +107,25 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
}
</script>
@@ -163,19 +168,6 @@ function getDialogPt(item: {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -244,7 +244,7 @@
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"

View File

@@ -0,0 +1,49 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
function isInsideOverlay(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
)
}
export function onRekaPointerDownOutside(
options: { dismissableMask?: boolean },
event: OutsideEvent
) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
return
}
if (options.dismissableMask === false) {
event.preventDefault()
}
}
// Focus / interact-outside fires when focus moves to a sibling portal (a
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}
}

View File

@@ -0,0 +1,17 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
// can lose to an already-open PrimeVue dialog. Registering Reka's content
// element with the same ZIndex counter (key 'modal', base 1700) makes both
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)
}
}

View File

@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,10 +10,15 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md'
size: 'md',
maximized: false
}
})

View File

@@ -138,6 +138,8 @@
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"maximizeDialog": "Maximize dialog",
"restoreDialog": "Restore dialog",
"or": "or",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",

View File

@@ -107,6 +107,12 @@ app.directive('tooltip', Tooltip)
app
.use(router)
.use(PrimeVue, {
zIndex: {
modal: 1800,
overlay: 1800,
menu: 1800,
tooltip: 1800
},
theme: {
preset: ComfyUIPreset,
options: {

View File

@@ -236,5 +236,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['optical_flow', 'OpticalFlowLoader', 'model_name'],
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
['loras', 'WanVideoLoraSelect', 'lora']
['loras', 'WanVideoLoraSelect', 'lora'],
// ---- LTX-Video IC-LoRA (ComfyUI-LTXVideo) ----
['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']
] as const satisfies ReadonlyArray<readonly [string, string, string]>

View File

@@ -0,0 +1,98 @@
/**
* Settings dialog migration regression net: `useSettingsDialog().show()` must
* open the Reka-renderer path with sizing that matches the previous
* `BaseModalLayout size="sm"` (960px × 80vh). Catches accidental reverts of
* the Phase 3 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const teamWorkspacesFlag = vi.hoisted(() => ({ value: false }))
const isCloudRef = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog: vi.fn() })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return teamWorkspacesFlag.value
}
}
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudRef.value
}
}))
vi.mock('@/i18n', () => ({ t: (k: string) => k }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
describe('useSettingsDialog', () => {
beforeEach(() => {
showDialog.mockReset()
teamWorkspacesFlag.value = false
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})
it('show() uses non-modal Reka so nested PrimeVue dialogs keep focus and pointer events', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.modal).toBe(false)
})
it('show() omits overlayClass when not in workspace mode', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBeUndefined()
})
it("show() sets overlayClass 'p-8' when isCloud && teamWorkspacesEnabled", () => {
isCloudRef.value = true
teamWorkspacesFlag.value = true
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBe('p-8')
})
it('show(panel) forwards defaultPanel to the dialog props', () => {
useSettingsDialog().show('about')
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
it('showAbout() opens the about panel', () => {
useSettingsDialog().showAbout()
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
})

View File

@@ -1,3 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -6,15 +8,20 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType, settingId?: string) {
const isWorkspaceMode = isCloud && flags.teamWorkspacesEnabled
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
@@ -22,6 +29,18 @@ export function useSettingsDialog() {
onClose: hide,
...(panel ? { defaultPanel: panel } : {}),
...(settingId ? { scrollToSettingId: settingId } : {})
},
dialogComponentProps: {
renderer: 'reka',
// Settings hosts nested PrimeVue dialogs (Edit Keybinding, Overwrite
// confirm, etc.) that teleport to body. Reka's modal mode traps focus
// inside the Settings content and disables body pointer-events, which
// breaks those nested dialogs' autofocus and click handling. Non-modal
// keeps the visual overlay without those traps.
modal: false,
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined
}
})
}

View File

@@ -0,0 +1,56 @@
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'
const INVALID_STRING_CASES: Array<[label: string, value: string]> = [
['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} `]
]
const NON_STRING_CASES: Array<[label: string, value: unknown]> = [
['number', 123],
['undefined', undefined],
['null', null],
['object', { id: 'abc' }]
]
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.for(INVALID_STRING_CASES)('rejects %s', ([_label, value]) => {
expect(zSubgraphId.safeParse(value).success).toBe(false)
})
it.for(NON_STRING_CASES)('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

@@ -47,6 +47,11 @@ interface CustomDialogComponentProps {
* PrimeVue path — use `pt` for that renderer.
*/
contentClass?: HTMLAttributes['class']
/**
* Class applied to the Reka-UI `DialogOverlay` element. Ignored on the
* PrimeVue path — use `pt.mask` for that renderer.
*/
overlayClass?: HTMLAttributes['class']
}
export type DialogComponentProps = Record<string, unknown> &

View File

@@ -87,7 +87,8 @@ const MOCK_NODE_NAMES = [
'IPAdapterModelLoader',
'LS_LoadSegformerModel',
'LoadNLFModel',
'FlashVSRNode'
'FlashVSRNode',
'LTXICLoRALoaderModelOnly'
] as const
const mockNodeDefsByName = Object.fromEntries(
@@ -307,7 +308,22 @@ describe('useModelToNodeStore', () => {
)
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
expect(loraProviders).toHaveLength(2)
expect(loraProviders).toHaveLength(3)
expect(loraProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoader' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoaderModelOnly' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({
name: 'LTXICLoRALoaderModelOnly'
})
})
])
)
})
it('should return single provider for model type with one node', () => {
@@ -561,6 +577,18 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('LTXICLoRALoaderModelOnly')
).toBe('loras')
expect(
modelToNodeStore.getRegisteredNodeTypes()['LTXICLoRALoaderModelOnly']
).toBe('lora_name')
})
it('should return first category when node type exists in multiple categories', () => {
const modelToNodeStore = useModelToNodeStore()

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